initial commit
This commit is contained in:
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal 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
75
README.md
Normal 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
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 %}
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user