diff --git a/app/__init__.py b/app/__init__.py index 8cd6c09..f95c67b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,5 @@ import os +import secrets from flask import Flask from flask_sqlalchemy import SQLAlchemy from dotenv import load_dotenv @@ -14,7 +15,7 @@ def create_app(): # Configure the app app.config.from_mapping( - SECRET_KEY=os.environ.get('SECRET_KEY', 'dev'), + SECRET_KEY=os.environ.get('SECRET_KEY', secrets.token_hex(16)), SQLALCHEMY_DATABASE_URI=f"sqlite:///{os.path.join(app.instance_path, 'game_tracker.sqlite')}", SQLALCHEMY_TRACK_MODIFICATIONS=False, ) diff --git a/app/igdb_helpers.py b/app/igdb_helpers.py index 627b8a5..5fe96f9 100644 --- a/app/igdb_helpers.py +++ b/app/igdb_helpers.py @@ -1,6 +1,7 @@ import os import requests import time +import datetime class IGDBHelper: def __init__(self): @@ -135,9 +136,9 @@ class IGDBHelper: # Format release date release_date = None if 'first_release_date' in game: - release_date = game['first_release_date'] - # Convert Unix timestamp to readable format - release_date = time.strftime('%Y-%m-%d', time.gmtime(release_date)) + timestamp = game['first_release_date'] + # Convert Unix timestamp to datetime object + release_date = datetime.datetime.fromtimestamp(timestamp) processed_game = { 'id': game['id'], diff --git a/app/models.py b/app/models.py index c09dcaa..10fc080 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,43 @@ from . import db from datetime import datetime +import hashlib + +class AdminConfig(db.Model): + id = db.Column(db.Integer, primary_key=True) + password_hash = db.Column(db.String(64), nullable=False) + last_updated = db.Column(db.DateTime, default=datetime.utcnow) + + @classmethod + def check_password(cls, password): + """Check if the provided password matches the stored hash""" + admin = cls.query.first() + if not admin: + return False + + # Create hash from input password + hash_input = hashlib.sha256(password.encode()).hexdigest() + + # Compare with stored hash + return hash_input == admin.password_hash + + @classmethod + def set_password(cls, password): + """Set a new admin password""" + admin = cls.query.first() + + # Create hash from the password + password_hash = hashlib.sha256(password.encode()).hexdigest() + + if admin: + admin.password_hash = password_hash + admin.last_updated = datetime.utcnow() + else: + admin = cls(password_hash=password_hash) + db.session.add(admin) + + db.session.commit() + return True + class Game(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/app/routes.py b/app/routes.py index e475cb1..1316520 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,12 +1,23 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app -from .models import Game +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app, session +from .models import Game, AdminConfig from . import db from .igdb_helpers import IGDBHelper import os +import functools main_bp = Blueprint('main', __name__) igdb = IGDBHelper() +# Admin authentication decorator +def admin_required(view): + @functools.wraps(view) + def wrapped_view(**kwargs): + if not session.get('admin_authenticated'): + flash('Please log in to access the admin area', 'error') + return redirect(url_for('main.admin_login')) + return view(**kwargs) + return wrapped_view + @main_bp.route('/') def index(): """Home page showing currently tracked games""" @@ -110,5 +121,145 @@ def remove_game(game_id): db.session.delete(game) db.session.commit() - flash(f"Removed {name} from youw twacking wist! OwO", 'success') + flash(f"Removed {name} from tracking list!", 'success') + return redirect(url_for('main.index')) + +# Admin Routes +@main_bp.route('/admin/login', methods=['GET', 'POST']) +def admin_login(): + """Admin login page""" + if request.method == 'POST': + password = request.form.get('password') + + # Check if this is initial setup + admin_exists = AdminConfig.query.first() + + if not admin_exists: + # Initial setup - set password + if password and len(password) >= 6: + AdminConfig.set_password(password) + session['admin_authenticated'] = True + flash('Admin account created successfully!', 'success') + return redirect(url_for('main.admin_dashboard')) + else: + flash('Password must be at least 6 characters long', 'error') + else: + # Regular login + if AdminConfig.check_password(password): + session['admin_authenticated'] = True + flash('Logged in successfully!', 'success') + return redirect(url_for('main.admin_dashboard')) + else: + flash('Invalid password', 'error') + + # Check if admin exists to show proper message + admin_exists = AdminConfig.query.first() + return render_template('admin/login.html', admin_exists=admin_exists) + +@main_bp.route('/admin/dashboard') +@admin_required +def admin_dashboard(): + """Admin dashboard""" + games = Game.query.all() + return render_template('admin/dashboard.html', games=games) + +@main_bp.route('/admin/search', methods=['GET', 'POST']) +@admin_required +def admin_search(): + """Admin game search""" + results = [] + query = '' + + if request.method == 'POST': + query = request.form.get('query', '') + + if query: + try: + results = igdb.search_games(query) + except Exception as e: + flash(f"Couldn't search IGDB: {str(e)}", 'error') + + return render_template('admin/search.html', results=results, query=query) + +@main_bp.route('/admin/add_game/', methods=['GET', 'POST']) +@admin_required +def admin_add_game(igdb_id): + """Admin add game""" + existing_game = Game.query.filter_by(igdb_id=igdb_id).first() + + if existing_game: + flash(f"Game {existing_game.name} is already being tracked", 'info') + return redirect(url_for('main.admin_dashboard')) + + try: + game_data = igdb.get_game_by_id(igdb_id) + + new_game = Game( + igdb_id=game_data['id'], + name=game_data['name'], + cover_url=game_data['cover_url'], + description=game_data['description'], + release_date=game_data['release_date'], + platform=', '.join(game_data['platforms']) if game_data['platforms'] else None, + developer=game_data['developer'], + publisher=game_data['publisher'], + status="Playing", + progress=0 + ) + + db.session.add(new_game) + db.session.commit() + + flash(f"Added {new_game.name} to tracking list", 'success') + return redirect(url_for('main.admin_dashboard')) + + except Exception as e: + flash(f"Failed to add game: {str(e)}", 'error') + return redirect(url_for('main.admin_search')) + +@main_bp.route('/admin/update_game/', methods=['POST']) +@admin_required +def admin_update_game(game_id): + """Admin update game""" + game = Game.query.get_or_404(game_id) + + if request.method == 'POST': + if 'status' in request.form: + game.status = request.form['status'] + + if 'progress' in request.form: + try: + progress = int(request.form['progress']) + if 0 <= progress <= 100: + game.progress = progress + except ValueError: + flash("Progress must be a number between 0 and 100", 'error') + + if 'notes' in request.form: + game.notes = request.form['notes'] + + db.session.commit() + flash(f"Updated {game.name}", 'success') + + return redirect(url_for('main.admin_dashboard')) + +@main_bp.route('/admin/remove_game/', methods=['POST']) +@admin_required +def admin_remove_game(game_id): + """Admin remove game""" + game = Game.query.get_or_404(game_id) + name = game.name + + db.session.delete(game) + db.session.commit() + + flash(f"Removed {name} from tracking list", 'success') + return redirect(url_for('main.admin_dashboard')) + +@main_bp.route('/admin/logout') +@admin_required +def admin_logout(): + """Admin logout""" + session.pop('admin_authenticated', None) + flash('Logged out successfully', 'success') return redirect(url_for('main.index')) diff --git a/app/static/css/styles.css b/app/static/css/styles.css index 33ad7a6..275602d 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -349,7 +349,7 @@ footer .heart { color: white; } -.status-on.hold { +.status-on-hold { background-color: var(--warning-color); color: var(--text-dark); } @@ -626,6 +626,191 @@ footer .heart { } } +/* Admin Styles */ +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; +} + +.admin-title h1 { + margin-bottom: 0.2rem; +} + +.admin-actions { + display: flex; + gap: 0.5rem; +} + +.admin-games h2 { + margin-bottom: 1rem; + color: var(--accent-color); +} + +.admin-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 2rem; + background-color: var(--bg-card); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); +} + +.admin-table th, +.admin-table td { + padding: 1rem; + text-align: left; + vertical-align: middle; + border-bottom: 1px solid var(--border-color); +} + +.admin-table th { + background-color: var(--primary-dark); + font-weight: 600; + color: var(--text-light); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.admin-table tbody tr:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.admin-table tbody tr:last-child td { + border-bottom: none; +} + +.game-cover-cell { + width: 80px; +} + +.game-cover-cell img { + width: 60px; + height: 80px; + object-fit: cover; + border-radius: 4px; +} + +.no-cover-small { + width: 60px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + background: linear-gradient(45deg, var(--primary-dark), var(--primary-light)); + color: rgba(255, 255, 255, 0.5); + border-radius: 4px; +} + +.game-details-row { + display: flex; + gap: 1rem; + margin-top: 0.3rem; + font-size: 0.8rem; + opacity: 0.7; +} + +.progress-bar-small { + height: 20px; + background-color: var(--border-color); + border-radius: 10px; + overflow: hidden; + position: relative; + width: 100%; + max-width: 120px; +} + +.progress-text { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + color: white; + text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); +} + +.action-buttons { + display: flex; + gap: 0.5rem; +} + +.btn-small { + padding: 0.3rem 0.6rem; + font-size: 0.8rem; +} + +.edit-row { + background-color: rgba(0, 0, 0, 0.2); +} + +.admin-edit-form { + padding: 1rem; +} + +.form-group-row { + display: flex; + gap: 1rem; + margin-bottom: 1rem; +} + +.progress-input { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.btn-wide { + width: 100%; + margin-top: 1rem; +} + +/* Login styles */ +.login-container { + max-width: 400px; + margin: 0 auto; + background-color: var(--bg-card); + border-radius: 8px; + padding: 2rem; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); +} + +.login-form { + margin-bottom: 1.5rem; +} + +.login-footer { + text-align: center; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.back-link { + color: var(--text-light); + text-decoration: none; + opacity: 0.7; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.back-link:hover { + opacity: 1; + color: var(--accent-color); +} + /* Responsive Adjustments */ @media (max-width: 768px) { header .container { @@ -646,4 +831,18 @@ footer .heart { flex-direction: column; gap: 0.5rem; } + + .admin-header { + flex-direction: column; + align-items: flex-start; + } + + .form-group-row { + flex-direction: column; + } + + .admin-table { + display: block; + overflow-x: auto; + } } diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..a8e177f --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,224 @@ +{% extends 'base.html' %} + +{% block title %}Admin Dashboard - Game Tracker{% endblock %} + +{% block content %} +
+
+

Admin Dashboard

+

Manage tracked games

+
+ +
+ +{% if games %} +
+

Currently Tracked Games

+ + + + + + + + + + + + {% for game in games %} + + + + + + + + + + + + {% endfor %} + +
CoverGameStatusProgressActions
+ {% if game.cover_url %} + {{ game.name }} cover + {% else %} +
+ +
+ {% endif %} +
+ {{ game.name }} +
+ {% if game.developer %} + {{ game.developer }} + {% endif %} + {% if game.platform %} + {{ game.platform }} + {% endif %} +
+
+ {{ game.status }} + +
+
+ {{ game.progress }}% +
+
+
+ + +
+
+
+
+
+ + +
+ +
+ +
+ + {{ game.progress }}% +
+
+
+ +
+ + +
+ +
+ + +
+
+
+
+ +{% else %} +
+
+ +
+

No games being tracked yet

+

Use the button below to search and add games to track!

+ + Add Your First Game + +
+{% endif %} + + + + + +{% endblock %} diff --git a/app/templates/admin/login.html b/app/templates/admin/login.html new file mode 100644 index 0000000..3189a45 --- /dev/null +++ b/app/templates/admin/login.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} + +{% block title %}Admin Login - Game Tracker{% endblock %} + +{% block content %} + + + +{% endblock %} diff --git a/app/templates/admin/search.html b/app/templates/admin/search.html new file mode 100644 index 0000000..36fa9d6 --- /dev/null +++ b/app/templates/admin/search.html @@ -0,0 +1,99 @@ +{% extends 'base.html' %} + +{% block title %}Admin Search - Game Tracker{% endblock %} + +{% block content %} +
+
+

Admin Game Search

+

Find games to add to the tracking list

+
+ +
+ +
+
+
+ + +
+
+
+ +{% if results %} +
+

Search Results

+

Found {{ results|length }} games matching "{{ query }}"

+
+ +
+ {% for game in results %} +
+
+ {% if game.cover_url %} + {{ game.name }} cover + {% else %} +
+ +
+ {% endif %} +
+
+

{{ game.name }}

+ +
+ {% if game.release_date %} + {{ game.release_date }} + {% endif %} + + {% if game.platforms %} + + {% if game.platforms|length > 2 %} + {{ game.platforms[0] }} + {{ game.platforms|length - 1 }} more + {% else %} + {{ game.platforms|join(', ') }} + {% endif %} + + {% endif %} + + {% if game.developer %} + {{ game.developer }} + {% endif %} +
+ + {% if game.description %} +
+

{{ game.description|truncate(150) }}

+
+ {% endif %} + +
+
+ +
+
+
+
+ {% endfor %} +
+ +{% elif query %} + +{% endif %} + +{% endblock %}