initial commit
This commit is contained in:
39
app/__init__.py
Normal file
39
app/__init__.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Initialize SQLAlchemy
|
||||
db = SQLAlchemy()
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
# Configure the app
|
||||
app.config.from_mapping(
|
||||
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev'),
|
||||
SQLALCHEMY_DATABASE_URI=f"sqlite:///{os.path.join(app.instance_path, 'game_tracker.sqlite')}",
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||
)
|
||||
|
||||
# Ensure the instance folder exists
|
||||
try:
|
||||
os.makedirs(app.instance_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Initialize database with app
|
||||
db.init_app(app)
|
||||
|
||||
# Import and register blueprints
|
||||
from .routes import main_bp
|
||||
app.register_blueprint(main_bp)
|
||||
|
||||
# Create database tables
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
||||
155
app/igdb_helpers.py
Normal file
155
app/igdb_helpers.py
Normal file
@@ -0,0 +1,155 @@
|
||||
import os
|
||||
import requests
|
||||
import time
|
||||
|
||||
class IGDBHelper:
|
||||
def __init__(self):
|
||||
self.client_id = os.environ.get('IGDB_CLIENT_ID')
|
||||
self.client_secret = os.environ.get('IGDB_CLIENT_SECRET')
|
||||
self.access_token = None
|
||||
self.token_expires = 0
|
||||
self.base_url = "https://api.igdb.com/v4"
|
||||
|
||||
def get_access_token(self):
|
||||
"""Get a new access token from Twitch API for IGDB"""
|
||||
if not self.client_id or not self.client_secret:
|
||||
raise ValueError("IGDB API credentials not found in environment variables")
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# If token exists and not expired, return it
|
||||
if self.access_token and current_time < self.token_expires:
|
||||
return self.access_token
|
||||
|
||||
# Otherwise get a new token
|
||||
auth_url = "https://id.twitch.tv/oauth2/token"
|
||||
auth_params = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"grant_type": "client_credentials"
|
||||
}
|
||||
|
||||
response = requests.post(auth_url, params=auth_params)
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200 and "access_token" in data:
|
||||
self.access_token = data["access_token"]
|
||||
# Set expiry time (subtract 60 seconds to be safe)
|
||||
self.token_expires = current_time + data["expires_in"] - 60
|
||||
return self.access_token
|
||||
else:
|
||||
raise Exception(f"Failed to get access token: {data}")
|
||||
|
||||
def search_games(self, query, limit=10):
|
||||
"""Search for games by name"""
|
||||
token = self.get_access_token()
|
||||
|
||||
headers = {
|
||||
'Client-ID': self.client_id,
|
||||
'Authorization': f'Bearer {token}'
|
||||
}
|
||||
|
||||
# IGDB API endpoint for games
|
||||
endpoint = f"{self.base_url}/games"
|
||||
|
||||
# Search for games with the given query, include relevant fields
|
||||
body = f"""
|
||||
search "{query}";
|
||||
fields name, cover.url, summary, first_release_date, platforms.name, involved_companies.company.name, involved_companies.developer, involved_companies.publisher;
|
||||
limit {limit};
|
||||
"""
|
||||
|
||||
response = requests.post(endpoint, headers=headers, data=body)
|
||||
|
||||
if response.status_code == 200:
|
||||
games = response.json()
|
||||
return self._process_game_results(games)
|
||||
else:
|
||||
raise Exception(f"IGDB API error: {response.status_code} - {response.text}")
|
||||
|
||||
def get_game_by_id(self, game_id):
|
||||
"""Get a game by its IGDB ID"""
|
||||
token = self.get_access_token()
|
||||
|
||||
headers = {
|
||||
'Client-ID': self.client_id,
|
||||
'Authorization': f'Bearer {token}'
|
||||
}
|
||||
|
||||
# IGDB API endpoint for games
|
||||
endpoint = f"{self.base_url}/games"
|
||||
|
||||
# Get game with the given ID, include relevant fields
|
||||
body = f"""
|
||||
where id = {game_id};
|
||||
fields name, cover.url, summary, first_release_date, platforms.name, involved_companies.company.name, involved_companies.developer, involved_companies.publisher;
|
||||
limit 1;
|
||||
"""
|
||||
|
||||
response = requests.post(endpoint, headers=headers, data=body)
|
||||
|
||||
if response.status_code == 200 and response.json():
|
||||
game = response.json()[0]
|
||||
return self._process_game_results([game])[0]
|
||||
else:
|
||||
raise Exception(f"IGDB API error: {response.status_code} - {response.text}")
|
||||
|
||||
def _process_game_results(self, games):
|
||||
"""Process the raw API results into a more usable format"""
|
||||
processed_games = []
|
||||
|
||||
for game in games:
|
||||
# Extract developer and publisher
|
||||
developer = None
|
||||
publisher = None
|
||||
|
||||
if 'involved_companies' in game:
|
||||
for company in game['involved_companies']:
|
||||
if company.get('developer', False):
|
||||
try:
|
||||
developer = company['company']['name']
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
if company.get('publisher', False):
|
||||
try:
|
||||
publisher = company['company']['name']
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
# Format cover URL
|
||||
cover_url = None
|
||||
if 'cover' in game and 'url' in game['cover']:
|
||||
# IGDB returns image URLs without https:, and in thumbnail size
|
||||
# Replace to get larger images: t_thumb -> t_cover_big
|
||||
cover_url = game['cover']['url']
|
||||
if cover_url.startswith('//'):
|
||||
cover_url = f"https:{cover_url}"
|
||||
cover_url = cover_url.replace('t_thumb', 't_cover_big')
|
||||
|
||||
# Format platforms
|
||||
platforms = []
|
||||
if 'platforms' in game:
|
||||
platforms = [platform['name'] for platform in game['platforms']]
|
||||
|
||||
# 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))
|
||||
|
||||
processed_game = {
|
||||
'id': game['id'],
|
||||
'name': game['name'],
|
||||
'cover_url': cover_url,
|
||||
'description': game.get('summary'),
|
||||
'release_date': release_date,
|
||||
'platforms': platforms,
|
||||
'developer': developer,
|
||||
'publisher': publisher
|
||||
}
|
||||
|
||||
processed_games.append(processed_game)
|
||||
|
||||
return processed_games
|
||||
21
app/models.py
Normal file
21
app/models.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from . import db
|
||||
from datetime import datetime
|
||||
|
||||
class Game(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
igdb_id = db.Column(db.Integer, unique=True, nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
cover_url = db.Column(db.String(255))
|
||||
description = db.Column(db.Text)
|
||||
release_date = db.Column(db.DateTime)
|
||||
platform = db.Column(db.String(100))
|
||||
developer = db.Column(db.String(100))
|
||||
publisher = db.Column(db.String(100))
|
||||
status = db.Column(db.String(20), default="Playing") # Playing, Completed, On Hold
|
||||
progress = db.Column(db.Integer, default=0) # 0-100%
|
||||
notes = db.Column(db.Text)
|
||||
date_added = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_updated = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Game {self.name}>'
|
||||
114
app/routes.py
Normal file
114
app/routes.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
|
||||
from .models import Game
|
||||
from . import db
|
||||
from .igdb_helpers import IGDBHelper
|
||||
import os
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
igdb = IGDBHelper()
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
"""Home page showing currently tracked games"""
|
||||
games = Game.query.all()
|
||||
return render_template('index.html', games=games)
|
||||
|
||||
@main_bp.route('/search', methods=['GET', 'POST'])
|
||||
def search_games():
|
||||
"""Search for games using IGDB API"""
|
||||
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"Oopsie! Couldn't search IGDB: {str(e)}", 'error')
|
||||
|
||||
return render_template('search.html', results=results, query=query)
|
||||
|
||||
@main_bp.route('/add_game/<int:igdb_id>', methods=['GET', 'POST'])
|
||||
def add_game(igdb_id):
|
||||
"""Add a game to tracking list"""
|
||||
# Check if we already have this game
|
||||
existing_game = Game.query.filter_by(igdb_id=igdb_id).first()
|
||||
|
||||
if existing_game:
|
||||
flash(f"You'we alweady twacking {existing_game.name}!", 'info')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
# Check if we're at the 5 game limit
|
||||
if Game.query.count() >= 5:
|
||||
flash("You can onwy twack up to 5 games at once! Pwease wemove a game fiwst~", 'error')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
# Get game details from IGDB
|
||||
try:
|
||||
game_data = igdb.get_game_by_id(igdb_id)
|
||||
|
||||
# Create new Game object
|
||||
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 youw twacking wist! ^w^", 'success')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Faiwed to add game: {str(e)}", 'error')
|
||||
return redirect(url_for('main.search_games'))
|
||||
|
||||
@main_bp.route('/update_game/<int:game_id>', methods=['POST'])
|
||||
def update_game(game_id):
|
||||
"""Update game progress and status"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
# Update status if provided
|
||||
if 'status' in request.form:
|
||||
game.status = request.form['status']
|
||||
|
||||
# Update progress if provided
|
||||
if 'progress' in request.form:
|
||||
try:
|
||||
progress = int(request.form['progress'])
|
||||
if 0 <= progress <= 100:
|
||||
game.progress = progress
|
||||
except ValueError:
|
||||
flash("Pwogwess must be a numbew between 0 and 100!", 'error')
|
||||
|
||||
# Update notes if provided
|
||||
if 'notes' in request.form:
|
||||
game.notes = request.form['notes']
|
||||
|
||||
db.session.commit()
|
||||
flash(f"Updated {game.name}! (。・ω・。)", 'success')
|
||||
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
@main_bp.route('/remove_game/<int:game_id>', methods=['POST'])
|
||||
def remove_game(game_id):
|
||||
"""Remove a game from tracking list"""
|
||||
game = Game.query.get_or_404(game_id)
|
||||
name = game.name
|
||||
|
||||
db.session.delete(game)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Removed {name} from youw twacking wist! OwO", 'success')
|
||||
return redirect(url_for('main.index'))
|
||||
649
app/static/css/styles.css
Normal file
649
app/static/css/styles.css
Normal file
@@ -0,0 +1,649 @@
|
||||
:root {
|
||||
--primary-color: #3a0570;
|
||||
--primary-light: #6a35a0;
|
||||
--primary-dark: #260050;
|
||||
--accent-color: #9c27b0;
|
||||
--text-light: #f0f0f0;
|
||||
--text-dark: #121212;
|
||||
--bg-dark: #121212;
|
||||
--bg-card: #1e1e1e;
|
||||
--bg-card-hover: #2a2a2a;
|
||||
--border-color: #333333;
|
||||
--danger-color: #f44336;
|
||||
--success-color: #4caf50;
|
||||
--info-color: #2196f3;
|
||||
--warning-color: #ff9800;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-light);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: var(--primary-dark);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
header .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
header i {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: var(--text-light);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: var(--primary-dark);
|
||||
padding: 1.5rem 0;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
color: var(--text-light);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer .heart {
|
||||
color: var(--danger-color);
|
||||
display: inline-block;
|
||||
animation: heartbeat 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Flashes */
|
||||
.flashes {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
max-width: 300px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
animation: flash-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.flash-success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.flash-info {
|
||||
background-color: var(--info-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.flash-warning {
|
||||
background-color: var(--warning-color);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: var(--accent-color);
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-light);
|
||||
opacity: 0.7;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--border-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
/* Game cards */
|
||||
.game-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.3);
|
||||
background-color: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.game-cover {
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.game-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.game-card:hover .game-cover img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.no-cover {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
background: linear-gradient(45deg, var(--primary-dark), var(--primary-light));
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.game-info {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.game-info h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.game-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.game-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.game-meta i {
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, var(--primary-color), var(--accent-color));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-playing {
|
||||
background-color: var(--info-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-on.hold {
|
||||
background-color: var(--warning-color);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.game-details {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.game-details summary {
|
||||
cursor: pointer;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.game-details summary:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.update-form {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Range slider styling */
|
||||
.progress-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.progress-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.range-value {
|
||||
margin-left: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.add-more {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* Search page styles */
|
||||
.search-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-input input {
|
||||
flex: 1;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 25px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-light);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.search-input input:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.results-info {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.search-result-card {
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.search-result-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.3);
|
||||
background-color: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.search-result-card .game-cover {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.search-result-card .game-info {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-result-card h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.game-description {
|
||||
margin: 0.8rem 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.empty-search {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.empty-search .empty-icon {
|
||||
color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--bg-card);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
color: var(--danger-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes heartbeat {
|
||||
0% { transform: scale(1); }
|
||||
10% { transform: scale(1.2); }
|
||||
20% { transform: scale(1); }
|
||||
30% { transform: scale(1.2); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes flash-in {
|
||||
from {
|
||||
transform: translateX(50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
header .container {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-result-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-result-card .game-cover {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
65
app/templates/base.html
Normal file
65
app/templates/base.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Game Twacker{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://kit.fontawesome.com/a076d05399.js" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1><i class="fas fa-gamepad"></i> Game Twacker</h1>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('main.index') }}">My Games</a></li>
|
||||
<li><a href="{{ url_for('main.search_games') }}">Add New Game</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flashes">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>Game data provided by <a href="https://www.igdb.com/" target="_blank">IGDB</a></p>
|
||||
<p>Made with <span class="heart">♥</span> UwU</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Close flash messages
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const flashes = document.querySelectorAll('.flash');
|
||||
flashes.forEach(flash => {
|
||||
flash.addEventListener('click', function() {
|
||||
this.style.display = 'none';
|
||||
});
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
flash.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
flash.style.display = 'none';
|
||||
}, 500);
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
153
app/templates/index.html
Normal file
153
app/templates/index.html
Normal file
@@ -0,0 +1,153 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}My Games ~ Game Twacker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>My Pwaying Games</h1>
|
||||
<p class="subtitle">Twacking {{ games|length }}/5 games</p>
|
||||
</div>
|
||||
|
||||
{% if games %}
|
||||
<div class="game-cards">
|
||||
{% for game in games %}
|
||||
<div class="game-card">
|
||||
<div class="game-cover">
|
||||
{% if game.cover_url %}
|
||||
<img src="{{ game.cover_url }}" alt="{{ game.name }} cover">
|
||||
{% else %}
|
||||
<div class="no-cover">
|
||||
<i class="fas fa-gamepad"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="game-info">
|
||||
<h2>{{ game.name }}</h2>
|
||||
<div class="game-meta">
|
||||
{% if game.developer %}
|
||||
<span><i class="fas fa-code"></i> {{ game.developer }}</span>
|
||||
{% endif %}
|
||||
{% if game.platform %}
|
||||
<span><i class="fas fa-desktop"></i> {{ game.platform }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-label">
|
||||
<span class="status-badge status-{{ game.status|lower }}">{{ game.status }}</span>
|
||||
<span class="progress-value">{{ game.progress }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {{ game.progress }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="game-details">
|
||||
<summary>Update Pwogwess</summary>
|
||||
<form action="{{ url_for('main.update_game', game_id=game.id) }}" method="POST" class="update-form">
|
||||
<div class="form-group">
|
||||
<label for="status-{{ game.id }}">Status:</label>
|
||||
<select name="status" id="status-{{ game.id }}">
|
||||
<option value="Playing" {% if game.status == 'Playing' %}selected{% endif %}>Playing</option>
|
||||
<option value="Completed" {% if game.status == 'Completed' %}selected{% endif %}>Completed</option>
|
||||
<option value="On Hold" {% if game.status == 'On Hold' %}selected{% endif %}>On Hold</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="progress-{{ game.id }}">Pwogwess:</label>
|
||||
<input type="range" name="progress" id="progress-{{ game.id }}" min="0" max="100" value="{{ game.progress }}" class="progress-slider">
|
||||
<span class="range-value">{{ game.progress }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes-{{ game.id }}">Notes:</label>
|
||||
<textarea name="notes" id="notes-{{ game.id }}" rows="2">{{ game.notes or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
<button type="button" class="btn btn-danger remove-btn" data-toggle="modal" data-target="#remove-modal-{{ game.id }}">
|
||||
Wemove Game
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Remove confirmation modal -->
|
||||
<div class="modal" id="remove-modal-{{ game.id }}">
|
||||
<div class="modal-content">
|
||||
<h3>Wemove Game</h3>
|
||||
<p>Awe you suwe you want to wemove <strong>{{ game.name }}</strong> from youw twacking wist?</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary close-modal">Cancew</button>
|
||||
<form action="{{ url_for('main.remove_game', game_id=game.id) }}" method="POST">
|
||||
<button type="submit" class="btn btn-danger">Wemove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fas fa-gamepad"></i>
|
||||
</div>
|
||||
<h2>No games being twacked yet~ >w<</h2>
|
||||
<p>Click the button bewow to seawch and add games to twack!</p>
|
||||
<a href="{{ url_for('main.search_games') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Add Youw Fiwst Game
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if games|length < 5 %}
|
||||
<div class="add-more">
|
||||
<a href="{{ url_for('main.search_games') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Add Anothew Game
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// Handle range sliders
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const sliders = document.querySelectorAll('.progress-slider');
|
||||
sliders.forEach(slider => {
|
||||
const valueDisplay = slider.nextElementSibling;
|
||||
|
||||
slider.addEventListener('input', function() {
|
||||
valueDisplay.textContent = this.value + '%';
|
||||
});
|
||||
});
|
||||
|
||||
// Modal handling
|
||||
const modals = document.querySelectorAll('.modal');
|
||||
const removeBtns = document.querySelectorAll('.remove-btn');
|
||||
const closeBtns = document.querySelectorAll('.close-modal');
|
||||
|
||||
removeBtns.forEach((btn, index) => {
|
||||
btn.addEventListener('click', function() {
|
||||
modals[index].classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
closeBtns.forEach((btn, index) => {
|
||||
btn.addEventListener('click', function() {
|
||||
modals[index].classList.remove('active');
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('click', function(e) {
|
||||
modals.forEach(modal => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
92
app/templates/search.html
Normal file
92
app/templates/search.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Add New Game ~ Game Twacker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Seawch fow Games</h1>
|
||||
<p class="subtitle">Find and add games to youw twacking wist</p>
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<form action="{{ url_for('main.search_games') }}" method="POST" class="search-form">
|
||||
<div class="search-input">
|
||||
<input type="text" name="query" placeholder="Entew game name..." value="{{ query }}" required>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search"></i> Seawch
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if results %}
|
||||
<div class="results-info">
|
||||
<h2>Seawch Wesuwts</h2>
|
||||
<p>Found {{ results|length }} games matching "{{ query }}"</p>
|
||||
</div>
|
||||
|
||||
<div class="search-results">
|
||||
{% for game in results %}
|
||||
<div class="search-result-card">
|
||||
<div class="game-cover">
|
||||
{% if game.cover_url %}
|
||||
<img src="{{ game.cover_url }}" alt="{{ game.name }} cover">
|
||||
{% else %}
|
||||
<div class="no-cover">
|
||||
<i class="fas fa-gamepad"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="game-info">
|
||||
<h3>{{ game.name }}</h3>
|
||||
|
||||
<div class="game-meta">
|
||||
{% if game.release_date %}
|
||||
<span><i class="fas fa-calendar-alt"></i> {{ game.release_date }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if game.platforms %}
|
||||
<span><i class="fas fa-desktop"></i>
|
||||
{% if game.platforms|length > 2 %}
|
||||
{{ game.platforms[0] }} + {{ game.platforms|length - 1 }} more
|
||||
{% else %}
|
||||
{{ game.platforms|join(', ') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if game.developer %}
|
||||
<span><i class="fas fa-code"></i> {{ game.developer }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if game.description %}
|
||||
<div class="game-description">
|
||||
<p>{{ game.description|truncate(150) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="search-actions">
|
||||
<form action="{{ url_for('main.add_game', igdb_id=game.id) }}" method="POST">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Add to My Games
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% elif query %}
|
||||
<div class="empty-search">
|
||||
<div class="empty-icon">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<h2>No games found~ (◕︵◕)</h2>
|
||||
<p>We couldn't find any games matching "{{ query }}"</p>
|
||||
<p>Twy a diffewent seawch tewm!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user