initial commit

This commit is contained in:
hex
2025-05-19 20:13:39 -07:00
commit 11f14f5048
12 changed files with 1408 additions and 0 deletions

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Environment variables
.env
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Virtual environment
venv/
env/
ENV/
# Flask instance folder
instance/
# Database files
*.sqlite
*.sqlite3
*.db
# IDE files
.idea/
.vscode/
*.swp
*.swo
# Logs
logs/
*.log
# OS specific files
.DS_Store
Thumbs.db

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# Game Twacker App ʕ•ᴥ•ʔ
A web appwication fow twacking videogames you'we cuwwently pwaying or compweting! This app uses the IGDB (Intewnet Game Database) API to seawch fow games and save them to youw pewsonal twacking wist~
## Featuwes ✨
- Seawch fow games thwough the IGDB API
- Twack up to 5 games you'we cuwwently pwaying
- Update game pwogwess (0-100%)
- Set game status (Pwaying, Compweted, On Howd)
- Add pewsonal notes fow each game
- Beautifuw dawk mode UI with puwple accent
## Technologies Used 🛠️
- Python 3.12
- Flask web fwamework
- SQLite database
- IGDB API
- HTML, CSS, and JavaScript
- Fwask-SQLAwchemy ORM
- Responsibe design
## Setup Instructions 🚀
### 1. Cweate an IGDB API Account
You need to get API cwedentwials from IGDB:
1. Go to [Twitch Developer Portal](https://dev.twitch.tv/console/apps/create)
2. Wegistew a new appwication
3. Get youw Cwient ID and Cwient Secwet
4. Add them to the `.env` fiwe:
```
IGDB_CLIENT_ID=your_client_id_here
IGDB_CLIENT_SECRET=your_client_secret_here
```
### 2. Instaww Dependencwies
```bash
# Activate the viwtuaw enviwonment
source venv/bin/activate
# Instaww all wequired packages
pip install -r requirements.txt
```
### 3. Wun the Appwication
```bash
# Make suwe you'we in the pwoject woot with viwtuaw enviwonment activated
python run.py
```
The app wiww be avaiwable at `http://127.0.0.1:5000/`
## Scweenshots 📷
(Add scweenshots when avaiwable~)
## Futuwe Enhancements 🌟
- User authentication
- Supppowt fow twacking mowe than 5 games
- Game compwetion statistics
- Integwation with othew gaming pwatforms
- Achievements fow compweting games
- Timew/twacking fow time spent pwaying
- Expowt/impowt game wists
---
Made with wove and Fwask! (。・ω・。)

39
app/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 %}

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
Flask==2.3.3
Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.1
requests==2.31.0
python-dotenv==1.0.0

6
run.py Normal file
View File

@@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run(debug=True)