Compare commits

..

No commits in common. "f5b184fe54b8553d5e4f8c177a5a914d80ff7e8a" and "192d86dbd51e2aa6ce57b47cb1cf3a21fda2b597" have entirely different histories.

37 changed files with 716 additions and 2026 deletions

34
.env Normal file
View file

@ -0,0 +1,34 @@
# Flask-Configuration - Key are generated through setup.sh
SECRET_KEY=""
REDEEM_SECRET=""
WTF_CSRF_SECRET_KEY=""
# locales
BABEL_DEFAULT_LOCALE="en"
BABEL_SUPPORTED_LOCALES="de,en"
BABEL_TRANSLATION_DIRECTORIES="translations"
# Timezone
TZ=Europe/Berlin
# Security
SESSION_COOKIE_SECURE="False"
CSRF_ENABLED="True"
# Account registration
REGISTRATION_ENABLED="True"
# checking interval if keys have to be redeemed before a specific date
CHECK_EXPIRING_KEYS_INTERVAL_HOURS=6
# Pushover
PUSHOVER_APP_TOKEN=""
PUSHOVER_USER_KEY=""
# Gotify
GOTIFY_URL=""
GOTIFY_TOKEN=""
# Matrix
MATRIX_HOMESERVER=""
MATRIX_ACCESS_TOKEN=""
MATRIX_ROOM_ID=""

183
README.md
View file

@ -5,7 +5,9 @@
This project helps you keep track of your collected game keys. This project helps you keep track of your collected game keys.
No more confusion about whether a key is redeemed, gifted, or still unused now you have everything in one place, with search, status, and even automatic Steam cover images! No more confusion about whether a key is redeemed, gifted, or still unused now you have everything in one place, with search, status, and even automatic Steam cover images!
You can even gift your keys via a unique 24-hour website link just mark a game as "Gifted" and copy the link from your overview. (HTTPS recommended) It's even possible to gift your keys via a unique website. Just edit the game to "Gifted" and you'll get a option to copy the on your overview page. (maybe HTTPS only)
(the link will also remain in the edit area)
![Screenshot](GameManager.png) ![Screenshot](GameManager.png)
@ -14,72 +16,70 @@ You can even gift your keys via a unique 24-hour website link just mark a ga
## ✨ Features ✨ ## ✨ Features ✨
- **Key Management:** - **Key Management:**
Enter your game keys, platform, source, and more. Enter your game keys, the corresponding game, platform, and maybe where you got the key.
- **Status Tracking:** - **Status Tracking:**
Mark keys as "Redeemed", "Gifted", or "Available". Mark keys as "Redeemed", "Gifted" or "Available" always know your status.
- **Steam Cover & Shop Info:** - **Shop URL & Steam Cover:**
Provide the Steam AppID and get the official game cover. Add shop URLs too. Save the shop URL and (optionally) the Steam AppID. The app will automatically show the official Steam cover image if available.
- **Game Descriptions & Prices:** - **Gift your Games:**
Automatically fetch game descriptions, current best prices, and historical lows from [IsThereAnyDeal](https://isthereanydeal.com/) (API key required). You can create a unique redeem/gift website, which will expire after 24h.
- **Gifting:** - **Multi-user:**
Create a one-time gift link for each game that expires after 24 hours.
- **Search Functionality:**
Quickly find games with an integrated search bar.
- **Multi-user Support:**
Each user manages their own keys. Each user manages their own keys.
- **User Roles:** - **Enable/Disable Registrations:**
The first registered user becomes an admin automatically. Perfect if you want to run the Server just on your own (via .env file)
- **Admin Area:** - **Search:**
Admins can reset passwords, delete users, and view audit logs. Find games quickly with the search function.
- **Audit Logs:**
Track user logins, password resets, and deleted accounts.
- **Registration Toggle:**
Enable or disable user registration via the `.env` file.
- **Responsive UI:** - **Responsive UI:**
Fully functional on desktop and mobile with Dark Mode support. Works on desktop and mobile, with Dark Mode toggle.
- **Multi-language:** - **Multi-language:**
Switch between English and German on the fly. Switch between English and German instantly*.
- **Import/Export (CSV, PDF export):** - **Import/Export (CSV / PDF -only export-):**
Import/export your game keys easily. Easy export and import of your keys. (e.g. in case you have to start over)
- **Password Management:** - **Change Password:**
Users can change their passwords directly. Change your Password on the fly.
- **Website Security:**
You can turn on/off CSRF and Secure Cookie via .env file.
- **Notifications:** - **Notifications:**
Get alerts for expiring keys via Gotify, Matrix, or Pushover. If you have key that have to be redeemed before a specific date. You can set up sending messages via, Pushover, Matrix and Gotify
- **Security Settings:** - **No key data leaves your server!**
Toggle CSRF protection and secure cookies in `.env`. - **(Planned):**
- **Self-hosted:** - ~~Import/Export (CSV)~~
No data leaves your server. - ~~Redeem site with unique sharing link~~
--- ---
## 🚀 Get Started ## 🚀 Get Started! 🚀
### 1. Clone the Repository ## 1. **Clone the Repository (Option 1 or Option 2)**
### Option 1: Clone the main repository
```bash ```bash
git clone https://codeberg.org/nocci/GameKeyManager git clone https://codeberg.org/nocci/GameKeyManager
cd steam-gift-manager cd steam-gift-manager
``` ```
Alternative: ### Option 2: Clone from alternative repository (if option 1 fails)
```bash ```bash
git clone https://git.nocci.it/nocci/GameKeyManager git clone https://git.nocci.it/nocci/GameKeyManager
``` ```
### 2. Setup Docker ### 2. **Setup Docker**
Make sure Docker and docker-compose are installed. Make sure you have [Docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/) installed.
If not, the setup script can guide you (Arch-based distros may vary). If not, the script will ask you what to do and can install Docker and docker-compose for you. (maybe not if you are running Arch)
### 3. Initial Setup ### 3. **Initial Setup**
```bash ```bash
chmod +x setup.sh chmod +x setup.sh
./setup.sh ./setup.sh
``` ```
### 4. Build and Start the App This script prepares all directories, configuration, and translation files.
### 4. **Build and Start the App**
```bash ```bash
cd steam-gift-manager/ cd steam-gift-manager/
@ -87,81 +87,102 @@ docker-compose build --no-cache
docker-compose up -d docker-compose up -d
``` ```
### 5. Configure `.env` File ### 5. **Edit your .env file to your liking**
Adjust your settings: It's in your root folder of the installation!
```env ```xml
SESSION_COOKIE_SECURE="True" # Only works with HTTPS # Security
SESSION_COOKIE_SECURE="True" (only works if you run this app via HTTPS)
CSRF_ENABLED="True" CSRF_ENABLED="True"
ITAD_API_KEY="your_api_key" # Optional, for price data
``` ```
Apply changes after editing: **Important after any(!) change of the .env file!**
```bash ```bash
cd steam-gift-manager/
docker-compose down && docker-compose up -d --build docker-compose down && docker-compose up -d --build
``` ```
### 6. Translate (optional) ### 6. **Initialize and Edit Translations (Optional)**
```bash ```bash
./translate.sh ./translate.sh
``` ```
Edit the `.json` files in `translations/`, then restart: Edit the .po files in translations/de_DE/LC_MESSAGES/messages.po and en_US/LC_MESSAGES/messages.po
```bash ```bash
./translate.sh
cd steam-gift-manager/
docker-compose down && docker-compose up -d --build docker-compose down && docker-compose up -d --build
``` ```
### 7. Access the App ### 7. **Open the App**
Visit [http://localhost:5000](http://localhost:5000) Go to [http://localhost:5000](http://localhost:5000) in your browser.
Register the first user this account becomes the admin!
- Register your first user.
- Add your keys, shop URLs etc.
- Enjoy search, status, and automatic Steam cover images!
--- ---
## 🔔 Notifications (optinal) ## 🛠️ Technology Stack 🛠️
- Reminders for expiring keys (48h notice) - **Frontend:** Bootstrap 5, Jinja2 Templates ...
- Pushover, Matrix, Gotify and more are supported through AppRise - **Backend:** Python 3, Flask, Flask-Babel, Flask-Login, Flask-SQLAlchemy, Apprise ...
- Configurable via `.env` - **Database:** SQLite (persisted in `data/`)
- **Containerization:** Docker, docker-compose
- **Translations:** Flask-Babel, editable `.po` files in `translations/`
## 🌍 Multi-language
- Switch between English and German using the dropdown in the navigation bar.
- All game and menu texts can be translated or individualized.
## 🔔 Notifications
- Send notifications if a game has to be redeemed by a specific date
- Switched from generic notifications to [Apprise](https://github.com/caronc/apprise) - have a look into the .env file
- 48 hours before you are running out of time the app will send you a notice to your services
--- ---
## 🛠️ Tech Stack ## 🪙 Do you this project? 🪙
- **Frontend:** Bootstrap 5, Jinja2, ... If youd like to support itme, you can make a donation here:
- **Backend:** Python 3, Flask, Flask-SQLAlchemy, ...
- **Database:** SQLite
- **Container:** Docker, docker-compose
---
## 💬 Contribute
Contributions are welcome:
- Report bugs
- Suggest features
- Submit Pull Requests
---
## 🪙 Support
Like the project? You can support me:
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/nocci) [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/nocci)
[![Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/nocci/donate) [![Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/nocci/donate)
--- Thank you!
## 📜 License
Licensed under [Apache License 2.0](LICENSE).
--- ---
**Enjoy managing your game collection!** ## 🙌 Contribute! 🙌
This project is open source!
- **Bug Reports:** Please report bugs as Issues.
- **Feature Requests:** Suggest new features!
- **Pull Requests:** Submit your code changes!
// **only possible after Forgejo opens for federation** \\\
---
## 📜 License 📜
This project is licensed under the [Apache License 2.0](LICENSE).
---
## 💖 Acknowledgements 💖
A big thank you to everyone who supports and contributes to this project!
---
**Enjoy your organized Game key collection!** 🚀

0
app.py Normal file
View file

305
setup.sh
View file

@ -79,28 +79,13 @@ PROJECT_DIR="steam-gift-manager"
TRANSLATIONS_DIR="$PWD/$PROJECT_DIR/translations" TRANSLATIONS_DIR="$PWD/$PROJECT_DIR/translations"
DATA_DIR="$PWD/data" DATA_DIR="$PWD/data"
# Create folders # 1. Create folders
mkdir -p "$PROJECT_DIR" mkdir -p "$PROJECT_DIR"
mkdir -p "$PROJECT_DIR"/{templates,static,translations} mkdir -p "$PROJECT_DIR"/{templates,static,translations}
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
chmod -R a+rwX "$TRANSLATIONS_DIR" "$DATA_DIR" chmod -R a+rwX "$TRANSLATIONS_DIR" "$DATA_DIR"
echo -e "\n\033[1;32m✅ Downloading assets - Please wait!\033[0m"
# Download Pictures from my server
cd "$PROJECT_DIR/static"
wget -O logo.webp "https://drop.nocadmin.net/logo.webp" > /dev/null 2>&1
wget -O logo_small.webp "https://drop.nocadmin.net/logo_small.webp" > /dev/null 2>&1
wget -O forgejo.webp "https://drop.nocadmin.net/forgejo.webp" > /dev/null 2>&1
wget -O gog_logo.webp "https://drop.nocadmin.net/gog_logo.webp" > /dev/null 2>&1
wget -O logo_small_maskable.webp "https://drop.nocadmin.net/logo_small_maskable.webp" > /dev/null 2>&1
wget -O favicon.ico "https://drop.nocadmin.net/favicon.ico" > /dev/null 2>&1
wget -O apple-touch-icon.png "https://drop.nocadmin.net/apple-touch-icon.png" > /dev/null 2>&1
wget -O web-app-manifest-192x192.png "https://drop.nocadmin.net/web-app-manifest-192x192.png" > /dev/null 2>&1
wget -O web-app-manifest-512x512.png "https://drop.nocadmin.net/web-app-manifest-512x512.png" > /dev/null 2>&1
cd ../..
cd $PROJECT_DIR cd $PROJECT_DIR
# requirements.txt # requirements.txt
@ -150,7 +135,6 @@ TZ=Europe/Berlin
FORCE_HTTPS=False FORCE_HTTPS=False
SESSION_COOKIE_SECURE=auto SESSION_COOKIE_SECURE=auto
CSRF_ENABLED="True" CSRF_ENABLED="True"
# Account registration # Account registration
REGISTRATION_ENABLED="True" REGISTRATION_ENABLED="True"
@ -174,7 +158,7 @@ REDIS_URL=redis://redis:6379/0
# Enable Debug (e.g. for VS Code) # Enable Debug (e.g. for VS Code)
FLASK_DEBUG=1 FLASK_DEBUG=1
DEBUGPY=0 DEBUGPY=1
EOL EOL
# app.py (the main app) # app.py (the main app)
@ -183,19 +167,19 @@ cat <<'PYTHON_END' > app.py
import atexit import atexit
import csv import csv
import io import io
import locale import locale # Note: locale was in your imports but not standard for typical web apps unless specific use.
import logging import logging
import os import os
import random import random
import re import re
import secrets import secrets
import sqlite3 import sqlite3 # Note: direct sqlite3 import is unusual if you're using SQLAlchemy for all DB ops.
import time import time
import traceback import traceback
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import wraps from functools import wraps
from io import BytesIO from io import BytesIO # Note: io.BytesIO is good, no need for direct BytesIO import if io is already imported.
from time import sleep from time import sleep # Note: time.sleep is fine, no need for direct 'sleep' import if 'time' is imported.
from urllib.parse import urlparse from urllib.parse import urlparse
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import warnings import warnings
@ -275,12 +259,6 @@ ITAD_API_KEY_PLACEHOLDER = "your_api_key_here"
TZ = os.getenv('TZ', 'UTC') TZ = os.getenv('TZ', 'UTC')
os.environ['TZ'] = TZ os.environ['TZ'] = TZ
app = Flask(__name__) app = Flask(__name__)
app.jinja_env.globals['getattr'] = getattr
@app.errorhandler(404)
def not_found_error(error):
return render_template('404.html'), 404
# UNIX-Systems (Linux, Docker) # UNIX-Systems (Linux, Docker)
try: try:
@ -500,9 +478,8 @@ class Game(db.Model):
historical_low = db.Column(db.Float) historical_low = db.Column(db.Float)
release_date = db.Column(db.DateTime) release_date = db.Column(db.DateTime)
release_date = db.Column(db.DateTime) release_date = db.Column(db.DateTime)
steam_description = db.Column(db.Text)
itad_slug = db.Column(db.String(200)) itad_slug = db.Column(db.String(200))
steam_description_en = db.Column(db.Text)
steam_description_de = db.Column(db.Text)
# with users.id # with users.id
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
@ -557,13 +534,11 @@ class GameForm(FlaskForm):
steam_appid = StringField('Steam App ID') steam_appid = StringField('Steam App ID')
PLATFORM_CHOICES = [ PLATFORM_CHOICES = [
('steam', 'Steam'), ('pc', 'PC'),
('gog', 'GOG'),
('xbox', 'XBox'), ('xbox', 'XBox'),
('playstation', 'PlayStation'), ('playstation', 'PlayStation'),
('switch', 'Nintendo Switch'), ('switch', 'Nintendo Switch'),
('other', 'Other'), ('other', 'Andere')
('pc', 'PC')
] ]
STATUS_CHOICES = [ STATUS_CHOICES = [
@ -604,28 +579,26 @@ def log_activity(user_id, action, details=None):
db.session.commit() db.session.commit()
# Game Infos Helper # Game Infos Helper
def fetch_steam_data(appid, lang='en'): def fetch_steam_data(appid):
lang_map = {
'en': 'english',
'de': 'german'
}
steam_lang = lang_map.get(lang, 'english')
try: try:
response = requests.get( response = requests.get(
"https://store.steampowered.com/api/appdetails", "https://store.steampowered.com/api/appdetails",
params={"appids": appid, "l": steam_lang}, params={"appids": appid, "l": "german"},
timeout=15 timeout=15
) )
if response.status_code != 200:
app.logger.error(f"Steam API Error: Status {response.status_code}")
return None
data = response.json().get(str(appid), {}) data = response.json().get(str(appid), {})
if data.get("success"): if not data.get("success"):
return { app.logger.error(f"Steam API Error: {data.get('error', 'Unknown error')}")
"name": data["data"].get("name"), return None
"detailed_description": data["data"].get("detailed_description"),
"release_date": data["data"].get("release_date", {}).get("date"), return data.get("data", {})
}
except Exception as e: except Exception as e:
app.logger.error(f"Steam API error: {str(e)}") app.logger.error(f"Steam API Exception: {str(e)}")
return None return None
def parse_steam_release_date(date_str): def parse_steam_release_date(date_str):
"""Parsing Steam-Release-Date (the german us thingy, you know)""" """Parsing Steam-Release-Date (the german us thingy, you know)"""
@ -899,12 +872,12 @@ def edit_game(game_id):
if request.method == 'POST': if request.method == 'POST':
try: try:
# Validation # Validierung
if not request.form.get('name') or not request.form.get('steam_key'): if not request.form.get('name') or not request.form.get('steam_key'):
flash(translate('Name and Steam Key are required'), 'error') flash(translate('Name and Steam Key are required'), 'error')
return redirect(url_for('edit_game', game_id=game_id)) return redirect(url_for('edit_game', game_id=game_id))
# Duplicate check # Dublettenprüfung
existing = Game.query.filter( existing = Game.query.filter(
Game.steam_key == request.form['steam_key'], Game.steam_key == request.form['steam_key'],
Game.id != game.id, Game.id != game.id,
@ -914,7 +887,7 @@ def edit_game(game_id):
flash(translate('Steam Key already exists'), 'error') flash(translate('Steam Key already exists'), 'error')
return redirect(url_for('edit_game', game_id=game_id)) return redirect(url_for('edit_game', game_id=game_id))
# Update fields # Felder aktualisieren
game.name = request.form['name'] game.name = request.form['name']
game.steam_key = request.form['steam_key'] game.steam_key = request.form['steam_key']
game.status = request.form['status'] game.status = request.form['status']
@ -925,12 +898,12 @@ def edit_game(game_id):
game.steam_appid = request.form.get('steam_appid', '') game.steam_appid = request.form.get('steam_appid', '')
game.redeem_date = safe_parse_date(request.form.get('redeem_date', '')) game.redeem_date = safe_parse_date(request.form.get('redeem_date', ''))
# Token-Logic # Token-Logik
if game.status == 'geschenkt': if game.status == 'geschenkt':
# Vorhandene Tokens löschen # Vorhandene Tokens löschen
RedeemToken.query.filter_by(game_id=game.id).delete() RedeemToken.query.filter_by(game_id=game.id).delete()
# Generate new Token # Neuen Token generieren
token = secrets.token_urlsafe(12)[:17] token = secrets.token_urlsafe(12)[:17]
expires = datetime.now(local_tz) + timedelta(hours=24) expires = datetime.now(local_tz) + timedelta(hours=24)
new_token = RedeemToken( new_token = RedeemToken(
@ -948,11 +921,11 @@ def edit_game(game_id):
except IntegrityError as e: except IntegrityError as e:
db.session.rollback() db.session.rollback()
app.logger.error(f"IntegrityError: {traceback.format_exc()}") app.logger.error(f"IntegrityError: {traceback.format_exc()}")
flash(translate('Database error: {error}', error=str(e.orig)), 'error') flash(translate('Database error: {error}', error=str(e.orig)), 'error') # Platzhalter korrigiert
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
app.logger.error(f"Unexpected error: {traceback.format_exc()}") app.logger.error(f"Unexpected error: {traceback.format_exc()}")
flash(translate('Unexpected error: {error}', error=str(e)), 'error') flash(translate('Unexpected error: {error}', error=str(e)), 'error') # Platzhalter korrigiert
return render_template( return render_template(
'edit_game.html', 'edit_game.html',
@ -1027,14 +1000,14 @@ def export_pdf():
elements = [] elements = []
img_height = 2*cm img_height = 2*cm
# Title # Titel
elements.append(Paragraph( elements.append(Paragraph(
translate("Game List (without Keys)", lang=session.get('lang', 'en')), translate("Game List (without Keys)", lang=session.get('lang', 'en')),
styles['Title'] styles['Title']
)) ))
elements.append(Spacer(1, 12)) elements.append(Spacer(1, 12))
# Table header # Tabellenkopf
col_widths = [ col_widths = [
5*cm, 10*cm, 6*cm, 3*cm 5*cm, 10*cm, 6*cm, 3*cm
] ]
@ -1068,7 +1041,7 @@ def export_pdf():
game.redeem_date.strftime('%d.%m.%y') if game.redeem_date else '' game.redeem_date.strftime('%d.%m.%y') if game.redeem_date else ''
]) ])
# Table format # Table format (korrekte Einrückung)
table = Table(data, colWidths=col_widths, repeatRows=1) table = Table(data, colWidths=col_widths, repeatRows=1)
table.setStyle(TableStyle([ table.setStyle(TableStyle([
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
@ -1143,6 +1116,7 @@ def import_games():
return render_template('import.html') return render_template('import.html')
@app.route('/generate_redeem/<int:game_id>', methods=['POST']) @app.route('/generate_redeem/<int:game_id>', methods=['POST'])
@login_required @login_required
def generate_redeem(game_id): def generate_redeem(game_id):
@ -1172,45 +1146,31 @@ def generate_redeem(game_id):
db.session.rollback() db.session.rollback()
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@app.route('/redeem/<token>', endpoint='redeem') @app.route('/redeem/<token>', endpoint='redeem')
def redeem_page(token): def redeem_page(token):
redeem_token = RedeemToken.query.filter_by(token=token).first() redeem_token = RedeemToken.query.filter_by(token=token).first()
if not redeem_token: if not redeem_token:
abort(404) abort(404)
# Zeit in UTC umwandeln
expires_utc = redeem_token.expires.astimezone(pytz.UTC) expires_utc = redeem_token.expires.astimezone(pytz.UTC)
if datetime.now(pytz.UTC) > expires_utc: if datetime.now(pytz.UTC) > expires_utc:
db.session.delete(redeem_token) db.session.delete(redeem_token)
db.session.commit() db.session.commit()
abort(404) abort(404)
game = Game.query.get(redeem_token.game_id) game = Game.query.get(redeem_token.game_id)
redeem_token.used = True redeem_token.used = True
db.session.commit() db.session.commit()
# which Plattform return render_template('redeem.html',
if game.platform == "steam" or game.steam_appid: game=game,
platform_link = 'https://store.steampowered.com/account/registerkey?key=' redeem_token=redeem_token,
platform_label = "Steam" expires_timestamp=int(expires_utc.timestamp() * 1000), # Millisekunden
elif game.platform == "gog": platform_link='https://store.steampowered.com/account/registerkey?key=' if game.steam_appid else 'https://www.gog.com/redeem')
platform_link = 'https://www.gog.com/redeem/'
platform_label = "GOG"
elif game.platform == "xbox":
platform_link = 'https://redeem.microsoft.com/'
platform_label = "XBOX"
elif game.platform == "playstation":
platform_link = 'https://store.playstation.com/redeem'
platform_label = "PlayStation"
else:
platform_link = '#'
platform_label = game.platform.capitalize() if game.platform else "Unknown"
return render_template(
'redeem.html',
game=game,
redeem_token=redeem_token,
expires_timestamp=int(expires_utc.timestamp() * 1000),
platform_link=platform_link,
platform_label=platform_label
)
@app.route('/admin/users') @app.route('/admin/users')
@login_required @login_required
@ -1281,34 +1241,37 @@ def update_game_data(game_id):
steam_appid = request.form.get('steam_appid', '').strip() steam_appid = request.form.get('steam_appid', '').strip()
app.logger.info(f"🚀 Update gestartet für Game {game_id} mit AppID: {steam_appid}") app.logger.info(f"🚀 Update gestartet für Game {game_id} mit AppID: {steam_appid}")
# 2. Steam-Data (Multilingual) # 2. Steam-Data
steam_data = None
if steam_appid: if steam_appid:
try: try:
app.logger.debug(f"🔍 Fetching Steam data for AppID: {steam_appid}") app.logger.debug(f"🔍 Steam-API-Aufruf für AppID: {steam_appid}")
for lang in ['en', 'de']: steam_data = fetch_steam_data(steam_appid)
steam_data = fetch_steam_data(steam_appid, lang=lang)
if steam_data: if steam_data:
if lang == 'en' and steam_data.get("name"): # 3. Daten in Datenbank schreiben
game.name = steam_data.get("name", game.name) game.name = steam_data.get("name", game.name)
setattr(game, f'steam_description_{lang}', steam_data.get("detailed_description") or "No Infos available") game.steam_description = steam_data.get("detailed_description") or "No Infos available"
if lang == 'en':
date_str = steam_data.get("release_date", {}) # Release-Datum mit Zeitzone
if date_str: date_str = steam_data.get("release_date", {}).get("date")
parsed_date = parse_steam_release_date(date_str) if date_str:
if parsed_date: parsed_date = parse_steam_release_date(date_str)
game.release_date = local_tz.localize(parsed_date) if parsed_date:
else: game.release_date = local_tz.localize(parsed_date)
app.logger.warning(f"Could not parse Steam release date: {date_str}") else:
app.logger.info("✅ Steam data successfully updated") app.logger.warning(f"Could not parse Steam release date: {date_str}")
app.logger.info("✅ Steam-Daten erfolgreich aktualisiert")
else:
app.logger.warning("⚠️ Keine Steam-Daten empfangen")
flash(translate('Steam-API lieferte keine Daten'), 'warning')
except Exception as e: except Exception as e:
app.logger.error(f"💥 Kritischer Steam-Fehler: {str(e)}", exc_info=True) app.logger.error(f"💥 Kritischer Steam-Fehler: {str(e)}", exc_info=True)
flash(translate('Error during Steam query'), 'danger') flash(translate('Fehler bei Steam-Abfrage'), 'danger')
else:
app.logger.warning("⚠️ Keine Steam-AppID vorhanden, Steam-Daten werden nicht aktualisiert")
flash(translate('Steam-AppID missing, no Steam Data transferred'), 'warning')
# ITAD-Slug donings and such
# ITAD-Slug doings and such
itad_slug = fetch_itad_slug(steam_appid) itad_slug = fetch_itad_slug(steam_appid)
if itad_slug: if itad_slug:
game.itad_slug = itad_slug game.itad_slug = itad_slug
@ -1443,7 +1406,7 @@ def cleanup_expired_tokens_job():
with app.app_context(): with app.app_context():
cleanup_expired_tokens() cleanup_expired_tokens()
# Add Jobs # Jobs hinzufügen
scheduler.add_job( scheduler.add_job(
check_expiring_keys_job, check_expiring_keys_job,
'interval', 'interval',
@ -1461,7 +1424,7 @@ def update_prices_job():
with app.app_context(): with app.app_context():
games = Game.query.filter(Game.steam_appid.isnot(None)).all() games = Game.query.filter(Game.steam_appid.isnot(None)).all()
for game in games: for game in games:
# just update prices # Nur Preise aktualisieren
itad_data = fetch_itad_data(f"app/{game.steam_appid}") itad_data = fetch_itad_data(f"app/{game.steam_appid}")
if itad_data: if itad_data:
game.current_price = itad_data.get('price_new') game.current_price = itad_data.get('price_new')
@ -1476,27 +1439,6 @@ scheduler.add_job(
) )
def update_missing_steam_descriptions_job():
with app.app_context():
games = Game.query.filter(
(Game.steam_description_en == None) | (Game.steam_description_en == '') |
(Game.steam_description_de == None) | (Game.steam_description_de == '')
).all()
for game in games:
for lang in ['en', 'de']:
if not getattr(game, f'steam_description_{lang}', None):
steam_data = fetch_steam_data(game.steam_appid, lang=lang)
if steam_data:
setattr(game, f'steam_description_{lang}', steam_data.get('detailed_description'))
db.session.commit()
scheduler.add_job(
update_missing_steam_descriptions_job,
'interval',
hours=24,
id='update_missing_steam_descriptions'
)
# start Scheduler # start Scheduler
scheduler.start() scheduler.start()
atexit.register(lambda: scheduler.shutdown(wait=False)) atexit.register(lambda: scheduler.shutdown(wait=False))
@ -1509,7 +1451,7 @@ if __name__ == '__main__':
PYTHON_END PYTHON_END
# Templates - this was the "fun" part # 9. Templates
mkdir -p templates static mkdir -p templates static
# Base Template # Base Template
@ -2058,16 +2000,16 @@ cat <<HTML_END > templates/edit_game.html
<textarea id="game_notes" name="notes" class="form-control" rows="3">{{ game.notes }}</textarea> <textarea id="game_notes" name="notes" class="form-control" rows="3">{{ game.notes }}</textarea>
</div> </div>
<!-- Show External Data --> <!-- Externe Daten Anzeige -->
<div class="col-12"> <div class="col-12">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<span>🔄 {{ _('External Data') }}</span> <span>🔄 {{ _('Externe Daten') }}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if game.release_date %} {% if game.release_date %}
<div class="mb-2"> <div class="mb-2">
<strong>{{ _('Release Date:') }}</strong> <strong>{{ _('Veröffentlichung:') }}</strong>
{{ game.release_date|strftime('%d.%m.%Y') }} {{ game.release_date|strftime('%d.%m.%Y') }}
</div> </div>
{% endif %} {% endif %}
@ -2100,7 +2042,7 @@ cat <<HTML_END > templates/edit_game.html
{% if game.status == 'geschenkt' %} {% if game.status == 'geschenkt' %}
<div class="col-12"> <div class="col-12">
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header">{{ _('Redeem-Link') }}</div> <div class="card-header">{{ _('Einlöse-Links') }}</div>
<div class="card-body"> <div class="card-body">
{% for token in game.redeem_tokens if not token.is_expired() %} {% for token in game.redeem_tokens if not token.is_expired() %}
<div class="input-group mb-3"> <div class="input-group mb-3">
@ -2288,9 +2230,9 @@ cat <<'HTML_END' > templates/redeem.html
<code class="fs-3">{{ game.steam_key }}</code> <code class="fs-3">{{ game.steam_key }}</code>
</div> </div>
<a href="{{ platform_link }}{{ game.steam_key }}" <a href="{{ platform_link }}{{ game.steam_key }}"
class="btn btn-primary btn-lg mb-3" class="btn btn-primary btn-lg mb-3"
target="_blank"> target="_blank">
{{ _('Redeem now on') }} {{ platform_label }} {{ _('Redeem now on') }} {% if game.steam_appid %}Steam{% else %}GOG{% endif %}
</a> </a>
<div class="mt-4 text-muted"> <div class="mt-4 text-muted">
<small> <small>
@ -2409,15 +2351,13 @@ cat <<HTML_END > templates/game_details.html
</a> </a>
</div> </div>
</div> </div>
{% set lang = session.get('lang', 'en') %} {% if game.steam_description %}
{% set desc = getattr(game, 'steam_description_' + lang) %}
{% if desc %}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header">{{ _('Game Description') }}</div> <div class="card-header">{{ _('Game Description') }}</div>
<div class="card-body"> <div class="card-body">
{{ desc|safe }} {{ game.steam_description|safe }}
</div> </div>
</div> </div>
</div> </div>
@ -2553,7 +2493,7 @@ cat <<HTML_END > templates/admin_audit_logs.html
HTML_END HTML_END
# Error Sites # Error Site
cat <<HTML_END > templates/403.html cat <<HTML_END > templates/403.html
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
@ -2582,34 +2522,6 @@ cat <<HTML_END > templates/403.html
{% endblock %} {% endblock %}
HTML_END HTML_END
cat <<HTML_END > templates/404.html
{% extends "base.html" %}
{% block content %}
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height:60vh;">
<div class="text-center">
<img src="{{ url_for('static', filename='logo.webp') }}"
alt="Forbidden"
class="img-fluid rounded shadow mb-4"
style="max-width: 160px;">
<h1 class="display-3 fw-bold text-danger mb-3">404</h1>
<h2 class="mb-4">{{ _('Access Forbidden') }}</h2>
<p class="lead mb-4">
<span class="d-block mb-2">{{ _('Sorry, you are not allowed to access this page.') }}</span>
<span class="text-muted">({{ _('Registration is currently disabled.') }})</span>
</p>
<a href="{{ url_for('index') }}" class="btn btn-lg btn-primary shadow">
🏠 {{ _('Back to Home') }}
</a>
<div class="mt-4 text-muted">
<small>
<span>Sorry, you haven't unlocked this area yet. Grind some more XP or check your DLC entitlements.<br>Maybe try again after the next patch?</span>
</small>
</div>
</div>
</div>
{% endblock %}
HTML_END
# CSS # CSS
cat <<CSS_END > static/style.css cat <<CSS_END > static/style.css
:root { :root {
@ -2823,7 +2735,7 @@ APP_DIR="steam-gift-manager"
TRANSLATION_DIR="$APP_DIR/translations" TRANSLATION_DIR="$APP_DIR/translations"
LANGS=("de" "en") LANGS=("de" "en")
# check jq # Prüfe jq
if ! command -v jq &>/dev/null; then if ! command -v jq &>/dev/null; then
echo "❌ jq is required. Install with: sudo apt-get install jq" echo "❌ jq is required. Install with: sudo apt-get install jq"
exit 1 exit 1
@ -2859,12 +2771,12 @@ SCRIPT_END
chmod +x ../translate.sh chmod +x ../translate.sh
# EXECUTE IMMEDIATELY to create basic JSONs # SOFORT AUSFÜHREN, um Basis-JSONs zu erstellen
cd .. cd ..
./translate.sh ./translate.sh
cd $PROJECT_DIR cd $PROJECT_DIR
# Dockerfile # 5. Dockerfile
cat <<'DOCKER_END' > Dockerfile cat <<'DOCKER_END' > Dockerfile
FROM python:3.10-slim FROM python:3.10-slim
@ -2873,6 +2785,12 @@ SHELL ["/bin/bash", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
wget \ wget \
&& mkdir -p /app/static \
&& wget -O /app/static/logo.webp "https://drop.nocadmin.net/logo.webp" \
&& wget -O /app/static/logo_small.webp "https://drop.nocadmin.net/logo_small.webp" \
&& wget -O /app/static/forgejo.webp "https://drop.nocadmin.net/forgejo.webp" \
&& wget -O /app/static/gog_logo.webp "https://drop.nocadmin.net/gog_logo.webp" \
&& wget -O /app/static/logo_small_maskable.webp "https://drop.nocadmin.net/logo_small_maskable.webp" \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y locales && \ RUN apt-get update && apt-get install -y locales && \
@ -2939,7 +2857,6 @@ services:
volumes: volumes:
- ../data:/app/data - ../data:/app/data
- ./translations:/app/translations:rw - ./translations:/app/translations:rw
- ./static:/app/static:rw
user: "${UID:-1000}:${GID:-1000}" user: "${UID:-1000}:${GID:-1000}"
restart: unless-stopped restart: unless-stopped
command: ["/app/entrypoint.sh"] command: ["/app/entrypoint.sh"]
@ -2968,7 +2885,7 @@ set -e
# Set the working directory to the project directory # Set the working directory to the project directory
cd "$(dirname "$0")/steam-gift-manager" cd "$(dirname "$0")/steam-gift-manager"
# set FLASK_APP, if needed # Setze FLASK_APP, falls nötig
export FLASK_APP=app.py export FLASK_APP=app.py
# Initialize migrations, if not yet available # Initialize migrations, if not yet available
@ -3004,30 +2921,28 @@ cat <<MANIFEST_END > static/manifest.json
}, },
"icons": [ "icons": [
{ {
"src": "/static/web-app-manifest-192x192.png", "src": "/static/logo_small.webp",
"sizes": "192x192", "sizes": "192x192",
"type": "image/webp", "type": "image/webp",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/static/web-app-manifest-192x192.png", "src": "/static/logo_small_maskable.webp",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/webp",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "/static/web-app-manifest-512x512.png", "src": "/static/logo.webp",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/webp",
"purpose": "maskable" "purpose": "any maskable"
} }
], ]
"theme_color": "#ffffff",
"background_color": "#3f3a3a",
"display": "standalone"
} }
MANIFEST_END MANIFEST_END
# Service Worker # Service Worker
cat <<SW_END > static/serviceworker.js cat <<SW_END > static/serviceworker.js
const CACHE_NAME = 'game-key-manager-v2'; const CACHE_NAME = 'game-key-manager-v2';
@ -3035,8 +2950,6 @@ const ASSETS = [
'/', '/',
'/static/style.css', '/static/style.css',
'/static/logo.webp', '/static/logo.webp',
'/static/web-app-manifest-512x512.png',
'/static/web-app-manifest-192x192.png',
'/static/logo_small.webp', '/static/logo_small.webp',
'/static/gog_logo.webp', '/static/gog_logo.webp',
'/static/forgejo.webp' '/static/forgejo.webp'
@ -3066,22 +2979,6 @@ self.addEventListener('activate', (event) => {
}); });
SW_END SW_END
# Download German Translation from my server
cd "$TRANSLATIONS_DIR"
read -p "Do you want to download German translations from the dev-server (could cause trouble, but normally it is safe)? [y/N]: " download_de
if [[ "$download_de" =~ ^[YyJj]$ ]]; then
wget -O de.json "https://drop.nocadmin.net/de.json" > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "✅ German translations downloaded successfully."
else
echo "❌ Download failed. Keeping existing de.json."
fi
else
echo "⏩ Skipped downloading German translations."
fi
echo -e "\n\033[1;32m✅ Setup done! Seems to be okay!\033[0m" echo -e "\n\033[1;32m✅ Setup done! Seems to be okay!\033[0m"
echo -e "Have a look in your .env" echo -e "Have a look in your .env"
echo -e "nano .env" echo -e "nano .env"

View file

@ -1,41 +0,0 @@
# Flask-Configuration
SECRET_KEY="ef16ed848264df8391a7f9769f0ac13d5d4c1a711793bb6b"
REDEEM_SECRET="02c6a04e663604a837141d79e75b0a93"
WTF_CSRF_SECRET_KEY="5c6c0f730c337afb804ba21c6e1b3f93"
# Language Settings
DEFAULT_LANGUAGE="en"
SUPPORTED_LANGUAGES="de,en"
# Timezone
TZ=Europe/Berlin
# Security
FORCE_HTTPS=False
SESSION_COOKIE_SECURE=auto
CSRF_ENABLED="True"
# Account registration
REGISTRATION_ENABLED="True"
# checking interval if keys have to be redeemed before a specific date
CHECK_EXPIRING_KEYS_INTERVAL_HOURS=6
# Want to check prices? Here you are!
ITAD_API_KEY="your-secret-key-here"
ITAD_COUNTRY="DE"
# Apprise URLs (separate several with a comma or space)
APPRISE_URLS=""
### example for multiple notifications
#APPRISE_URLS="pover://USER_KEY@APP_TOKEN
#gotify://gotify.example.com/TOKEN
#matrixs://TOKEN@matrix.org/!ROOM_ID"
# Redis URL
REDIS_URL=redis://redis:6379/0
# Enable Debug (e.g. for VS Code)
FLASK_DEBUG=1
DEBUGPY=0

View file

@ -2,25 +2,13 @@ FROM python:3.10-slim
SHELL ["/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends wget && mkdir -p /app/static && wget -O /app/static/logo.webp "https://drop.nocadmin.net/logo.webp" && wget -O /app/static/logo_small.webp "https://drop.nocadmin.net/logo_small.webp" && wget -O /app/static/forgejo.webp "https://drop.nocadmin.net/forgejo.webp" && wget -O /app/static/gog_logo.webp "https://drop.nocadmin.net/gog_logo.webp" && wget -O /app/static/logo_small_maskable.webp "https://drop.nocadmin.net/logo_small_maskable.webp" && rm -rf /var/lib/apt/lists/*
curl \
wget \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y locales && \ RUN mkdir -p /app/data && chown -R 1000:1000 /app/data
sed -i '/de_DE.UTF-8/s/^# //' /etc/locale.gen && \
locale-gen
ENV LC_ALL=de_DE.UTF-8 LANG=de_DE.UTF-8
ENV TZ=
RUN ln -snf /usr/share/zoneinfo/ /etc/localtime && echo > /etc/timezone
RUN mkdir -p /app/data && \
chown -R 1000:1000 /app/data
ENV TZ=${TZ}
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
@ -30,14 +18,10 @@ COPY . .
ARG UID=1000 ARG UID=1000
ARG GID=1000 ARG GID=1000
RUN groupadd -g $GID appuser && useradd -u $UID -g $GID -m appuser && chown -R appuser:appuser /app
RUN groupadd -g ${GID} appuser && \
useradd -l -o -u ${UID} -g appuser -m appuser && \
mkdir -p /app && \
chown -R appuser:appuser /app
USER appuser USER appuser
EXPOSE 5000 5678 EXPOSE 5000
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"]

File diff suppressed because it is too large Load diff

View file

@ -1,44 +1,15 @@
services: services:
redis:
image: redis:alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- app-network
steam-manager: steam-manager:
build: build: .
context: .
args:
- UID=0
- GID=1000
ports: ports:
- "5000:5000" - "5000:5000"
- "5678:5678"
env_file:
- .env
environment: environment:
- REDIS_URL=redis://redis:6379/0 - REGISTRATION_ENABLED=True
- TZ=
volumes: volumes:
- ../data:/app/data - ../data:/app/data
- ./translations:/app/translations:rw - ./translations:/app/translations:rw
- ./static:/app/static:rw - ../.env:/app/.env
user: "0:1000" user: "0:"
restart: unless-stopped restart: unless-stopped
command: ["/app/entrypoint.sh"]
networks:
- app-network
depends_on:
- redis
volumes:
redis_data:
networks:
app-network:
driver: bridge

View file

@ -1,16 +0,0 @@
#!/bin/bash
# Debug-Output
echo "🔄 DEBUGPY-Value: ''"
echo "🔄 FLASK_DEBUG-Value: ''"
# Debug-Modus activate if .env told you so
if [[ "" == "1" || "" == "1" ]]; then
echo "🔄 Starting in DEBUG mode (Port 5678)..."
exec python -m debugpy --listen 0.0.0.0:5678 -m flask run --host=0.0.0.0 --port=5000
else
echo "🚀 Starting in PRODUCTION mode..."
exec gunicorn -b 0.0.0.0:5000 app:app
fi

View file

@ -14,7 +14,3 @@ requests
pillow pillow
gunicorn gunicorn
apprise apprise
debugpy
pytz
Flask-Session
redis

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -13,25 +13,22 @@
}, },
"icons": [ "icons": [
{ {
"src": "/static/web-app-manifest-192x192.png", "src": "/static/logo_small.webp",
"sizes": "192x192", "sizes": "192x192",
"type": "image/webp", "type": "image/webp",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/static/web-app-manifest-192x192.png", "src": "/static/logo_small_maskable.webp",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/webp",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "/static/web-app-manifest-512x512.png", "src": "/static/logo.webp",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/webp",
"purpose": "maskable" "purpose": "any maskable"
} }
], ]
"theme_color": "#ffffff",
"background_color": "#3f3a3a",
"display": "standalone"
} }

View file

@ -3,8 +3,6 @@ const ASSETS = [
'/', '/',
'/static/style.css', '/static/style.css',
'/static/logo.webp', '/static/logo.webp',
'/static/web-app-manifest-512x512.png',
'/static/web-app-manifest-192x192.png',
'/static/logo_small.webp', '/static/logo_small.webp',
'/static/gog_logo.webp', '/static/gog_logo.webp',
'/static/forgejo.webp' '/static/forgejo.webp'

View file

@ -133,38 +133,3 @@ body {
border: 0; border: 0;
} }
.price-value {
font-size: 1.2em;
font-weight: 400;
margin-top: 2px;
}
.navbar-nav .nav-link {
white-space: nowrap;
}
@media (max-width: 991.98px) {
.navbar-nav {
flex-direction: column !important;
align-items: flex-start !important;
}
}
.card-body img,
.steam-description img {
max-width: 100%;
height: auto;
display: block;
margin: 8px auto;
}
td.font-monospace {
word-break: break-all;
/* or */
overflow-wrap: break-word;
}
.alert-error { background-color: #f8d7da; border-color: #f5c6cb; color: #721c24; }
.alert-success { background-color: #d4edda; border-color: #c3e6cb; color: #155724; }
.alert-info { background: #d9edf7; color: #31708f; }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

View file

@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height:60vh;">
<div class="text-center">
<img src="{{ url_for('static', filename='logo.webp') }}"
alt="Forbidden"
class="img-fluid rounded shadow mb-4"
style="max-width: 160px;">
<h1 class="display-3 fw-bold text-danger mb-3">403</h1>
<h2 class="mb-4">{{ _('Access Forbidden') }}</h2>
<p class="lead mb-4">
<span class="d-block mb-2">{{ _('Sorry, you are not allowed to access this page.') }}</span>
<span class="text-muted">({{ _('Registration is currently disabled.') }})</span>
</p>
<a href="{{ url_for('index') }}" class="btn btn-lg btn-primary shadow">
🏠 {{ _('Back to Home') }}
</a>
<div class="mt-4 text-muted">
<small>
<span>Sorry, you haven't unlocked this area yet. Grind some more XP or check your DLC entitlements.<br>Maybe try again after the next patch?</span>
</small>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height:60vh;">
<div class="text-center">
<img src="{{ url_for('static', filename='logo.webp') }}"
alt="Forbidden"
class="img-fluid rounded shadow mb-4"
style="max-width: 160px;">
<h1 class="display-3 fw-bold text-danger mb-3">404</h1>
<h2 class="mb-4">{{ _('Access Forbidden') }}</h2>
<p class="lead mb-4">
<span class="d-block mb-2">{{ _('Sorry, you are not allowed to access this page.') }}</span>
<span class="text-muted">({{ _('Registration is currently disabled.') }})</span>
</p>
<a href="{{ url_for('index') }}" class="btn btn-lg btn-primary shadow">
🏠 {{ _('Back to Home') }}
</a>
<div class="mt-4 text-muted">
<small>
<span>Sorry, you haven't unlocked this area yet. Grind some more XP or check your DLC entitlements.<br>Maybe try again after the next patch?</span>
</small>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,94 +1,51 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="card p-4 shadow-sm"> <div class="card p-4 shadow-sm">
<h2 class="mb-4">{{ _('Add Game') }}</h2> <h2 class="mb-4">{{ _('Add New Game') }}</h2>
{% with messages = get_flashed_messages(with_categories=true) %} <form method="POST" aria-label="{{ _('Add New Game') }}">
{% if messages %} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="row g-3">
{% for category, message in messages %} <div class="col-md-6">
<div class="alert alert-{{ 'danger' if category == 'error' else category }}"> <label for="game_name" class="form-label">{{ _('Name') }} <span aria-hidden="true" class="text-danger">*</span></label>
{{ message|safe }} <input type="text" id="game_name" name="name" class="form-control" required aria-required="true">
</div> </div>
{% endfor %} <div class="col-md-6">
</div> <label for="game_key" class="form-label">{{ _('Game Key') }} <span aria-hidden="true" class="text-danger">*</span></label>
{% endif %} <input type="text" id="game_key" name="steam_key" class="form-control" required aria-required="true">
{% endwith %} </div>
<form method="POST" aria-label="{{ _('Add Game') }}"> <div class="col-md-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <label for="game_status" class="form-label">{{ _('Status') }} <span aria-hidden="true" class="text-danger">*</span></label>
<div class="row g-3"> <select id="game_status" name="status" class="form-select" required aria-required="true">
<!-- Name --> <option value="nicht eingelöst">{{ _('Not redeemed') }}</option>
<div class="col-md-6"> <option value="verschenkt">{{ _('Gifted') }}</option>
<label for="game_name" class="form-label">{{ _('Name') }} <span class="text-danger">*</span></label> <option value="eingelöst">{{ _('Redeemed') }}</option>
<input type="text" id="game_name" name="name" class="form-control" value="{{ request.form.name or '' }}" required> </select>
</div> </div>
<div class="col-md-4">
<!-- Steam Key --> <label for="game_redeem_date" class="form-label">{{ _('Redeem by') }}</label>
<div class="col-md-6"> <input type="date" id="game_redeem_date" name="redeem_date" class="form-control">
<label for="game_key" class="form-label">{{ _('Game Key') }} <span class="text-danger">*</span></label> </div>
<input type="text" id="game_key" name="steam_key" class="form-control" value="{{ request.form.steam_key or '' }}" required> <div class="col-md-4">
</div> <label for="game_recipient" class="form-label">{{ _('Recipient') }}</label>
<input type="text" id="game_recipient" name="recipient" class="form-control">
<!-- Platform Dropdown --> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="game_platform" class="form-label">{{ _('Platform') }} <span class="text-danger">*</span></label> <label for="game_appid" class="form-label">{{ _('Steam AppID (optional)') }}</label>
<select id="game_platform" name="platform" class="form-select" required> <input type="text" id="game_appid" name="steam_appid" class="form-control">
{% for value, label in platforms %} </div>
<option value="{{ value }}" {% if request.form.platform == value %}selected{% endif %}> <div class="col-md-6">
{{ _(label) }} <label for="game_url" class="form-label">{{ _('Shop URL') }}</label>
</option> <input type="url" id="game_url" name="url" class="form-control">
{% endfor %} </div>
</select> <div class="col-12">
</div> <label for="game_notes" class="form-label">{{ _('Notes') }}</label>
<textarea id="game_notes" name="notes" class="form-control" rows="3"></textarea>
<!-- Status Dropdown --> </div>
<div class="col-md-6"> <div class="col-12">
<label for="game_status" class="form-label">{{ _('Status') }} <span class="text-danger">*</span></label> <button type="submit" class="btn btn-success">{{ _('Save') }}</button>
<select id="game_status" name="status" class="form-select" required> <a href="{{ url_for('index') }}" class="btn btn-outline-secondary">{{ _('Cancel') }}</a>
{% for value, label in statuses %} </div>
<option value="{{ value }}" {% if request.form.status == value %}selected{% endif %}> </div>
{{ _(label) }} </form>
</option>
{% endfor %}
</select>
</div>
<!-- Steam AppID -->
<div class="col-md-6">
<label for="game_appid" class="form-label">{{ _('Steam AppID (optional)') }}</label>
<input type="text" id="game_appid" name="steam_appid" class="form-control" value="{{ request.form.steam_appid or '' }}">
</div>
<!-- Redeem Date -->
<div class="col-md-6">
<label for="game_redeem_date" class="form-label">{{ _('Redeem by') }}</label>
<input type="date" id="game_redeem_date" name="redeem_date" class="form-control" value="{{ request.form.redeem_date or '' }}">
</div>
<!-- Recipient -->
<div class="col-12">
<label for="game_recipient" class="form-label">{{ _('Recipient') }}</label>
<input type="text" id="game_recipient" name="recipient" class="form-control" value="{{ request.form.recipient or '' }}">
</div>
<!-- Shop URL -->
<div class="col-12">
<label for="game_url" class="form-label">{{ _('Shop URL') }}</label>
<input type="url" id="game_url" name="url" class="form-control" value="{{ request.form.url or '' }}">
</div>
<!-- Notes -->
<div class="col-12">
<label for="game_notes" class="form-label">{{ _('Notes') }}</label>
<textarea id="game_notes" name="notes" class="form-control" rows="3">{{ request.form.notes or '' }}</textarea>
</div>
<!-- Buttons -->
<div class="col-12">
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary ms-2">{{ _('Cancel') }}</a>
</div>
</div>
</form>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,55 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>{{ _('Audit Logs') }}</h2>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{{ _('Timestamp') }}</th>
<th>{{ _('User') }}</th>
<th>{{ _('Action') }}</th>
<th>{{ _('Details') }}</th>
</tr>
</thead>
<tbody>
{% for log in logs.items %}
<tr>
<td>{{ log.timestamp|strftime('%d.%m.%Y %H:%M') }}</td>
<td>{{ log.user.username if log.user else 'System' }}</td>
<td>{{ log.action }}</td>
<td>{{ log.details|default('', true) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if logs.pages > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination">
{% if logs.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin_audit_logs', page=logs.prev_num) }}">{{ _('Previous') }}</a>
</li>
{% endif %}
{% for page_num in logs.iter_pages() %}
<li class="page-item {% if page_num == logs.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin_audit_logs', page=page_num) }}">{{ page_num }}</a>
</li>
{% endfor %}
{% if logs.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin_audit_logs', page=logs.next_num) }}">{{ _('Next') }}</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% endblock %}

View file

@ -1,39 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>{{ _('User Management') }}</h2>
<table class="table">
<thead>
<tr>
<th>{{ _('Username') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
{{ user.username }}
{% if user.is_admin %}<span class="badge bg-primary">Admin</span>{% endif %}
</td>
<td>
{% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('admin_delete_user', user_id=user.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger btn-sm">{{ _('Delete') }}</button>
</form>
<form method="POST" action="{{ url_for('admin_reset_password', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-warning">{{ _('Reset Password') }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -11,8 +11,9 @@
<!-- Preload Bootstrap CSS for better LCP --> <!-- Preload Bootstrap CSS for better LCP -->
<link rel="preload" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'"> <link rel="preload" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"></noscript> <noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"></noscript>
<!-- My Styles --> <!-- Eigene Styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{# LCP-Optimierung: Preload für das erste Cover-Bild, falls vorhanden #}
{% if games and games[0].steam_appid %} {% if games and games[0].steam_appid %}
<link rel="preload" <link rel="preload"
as="image" as="image"
@ -35,53 +36,59 @@
})(); })();
</script> </script>
<body> <body>
<nav class="navbar navbar-expand-lg bg-body-tertiary"> <nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container"> <div class="container">
<a class="navbar-brand d-flex align-items-center gap-2" href="/"> <a class="navbar-brand d-flex align-items-center gap-2" href="/">
<img src="{{ url_for('static', filename='logo_small.webp') }}" alt="Logo" width="36" height="28" style="object-fit:contain; border-radius:8px;"> <img src="{{ url_for('static', filename='logo_small.webp') }}" alt="Logo" width="150" height="116" style="object-fit:contain; border-radius:8px;">
<span>Game Key Manager</span> <span>Game Key Manager</span>
</a> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavbar" aria-controls="mainNavbar" aria-expanded="false" aria-label="Toggle navigation"> <div class="d-flex align-items-center gap-3">
<span class="navbar-toggler-icon"></span> <form class="d-flex" action="{{ url_for('index') }}" method="GET" role="search" aria-label="{{ _('Search games') }}">
</button> <label for="searchInput" class="visually-hidden">{{ _('Search') }}</label>
<div class="collapse navbar-collapse flex-grow-1" id="mainNavbar"> <input class="form-control me-2"
<form class="d-flex ms-auto my-2 my-lg-0" action="{{ url_for('index') }}" method="GET" role="search" aria-label="{{ _('Search games') }}"> type="search"
<input class="form-control me-2" type="search" name="q" id="searchInput" placeholder="{{ _('Search') }}" value="{{ search_query }}"> name="q"
<button class="btn btn-outline-success" type="submit" aria-label="{{ _('Search') }}">🔍</button> id="searchInput"
</form> placeholder="{{ _('Search') }}"
<ul class="navbar-nav ms-lg-3 mb-2 mb-lg-0"> value="{{ search_query }}">
<li class="nav-item dropdown"> <button class="btn btn-outline-success" type="submit" aria-label="{{ _('Search') }}">🔍</button>
<a class="nav-link dropdown-toggle" href="#" id="langDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false"> </form>
{% if session.get('lang', 'en') == 'de' %} Deutsch {% elif session.get('lang', 'en') == 'en' %} English {% else %} Sprache {% endif %} <div class="form-check form-switch">
</a> <input class="form-check-input"
<ul class="dropdown-menu" aria-labelledby="langDropdown"> type="checkbox"
<li><a class="dropdown-item {% if session.get('lang', 'en') == 'de' %}active{% endif %}" href="{{ url_for('set_lang', lang='de') }}">Deutsch</a></li> id="darkModeSwitch" {% if theme == 'dark' %}checked{% endif %}>
<li><a class="dropdown-item {% if session.get('lang', 'en') == 'en' %}active{% endif %}" href="{{ url_for('set_lang', lang='en') }}">English</a></li> <label class="form-check-label" for="darkModeSwitch">{{ _('Dark Mode') }}</label>
</ul> </div>
</li> <div class="dropdown ms-3">
{% if current_user.is_authenticated %} <div hidden id="locale-debug" data-locale="{{ session.get('lang', 'en') }}"></div>
{% if current_user.is_admin %} <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin_users') }}">⚙️ {{ _('Admin') }}</a></li> {% if session.get('lang', 'en') == 'de' %} Deutsch {% elif session.get('lang', 'en') == 'en' %} English {% else %} Sprache {% endif %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin_audit_logs') }}">📜 {{ _('Audit Logs') }}</a></li> </button>
{% endif %} <ul class="dropdown-menu">
<li class="nav-item"><a class="nav-link" href="{{ url_for('change_password') }}">🔒 {{ _('Password') }}</a></li> <li><a class="dropdown-item {% if session.get('lang', 'en') == 'de' %}active{% endif %}" href="{{ url_for('set_lang', lang='de') }}">Deutsch</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('logout') }}">🚪 {{ _('Logout') }}</a></li> <li><a class="dropdown-item {% if session.get('lang', 'en') == 'en' %}active{% endif %}" href="{{ url_for('set_lang', lang='en') }}">English</a></li>
{% endif %} </ul>
</ul> </div>
</div> {% if current_user.is_authenticated %}
</div> <li class="nav-item">
</nav> <a class="nav-link" href="{{ url_for('change_password') }}">🔒 {{ _('Password') }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('logout') }}">🚪 {{ _('Logout') }}</a>
</li>
{% endif %}
</div>
</div>
</nav>
<div class="container mt-4"> <div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<div class="flash-container"> {% for category, message in messages %}
{% for category, message in messages %} <div class="alert alert-{{ category }} alert-dismissible fade show">
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert"> {{ message }}
{{ message|safe }} <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div> </div>
{% endfor %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}

View file

@ -1,85 +1,35 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="card p-4 shadow-sm"> <div class="card p-4 shadow-sm">
<h2 class="mb-4">{{ _('Spiel bearbeiten') }}</h2> <h2 class="mb-4">{{ _('Edit Game') }}</h2>
<form method="POST" aria-label="{{ _('Edit Game') }}">
<!-- Flash-Nachrichten -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages mb-4">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Update Data Form (separate, outside main form, uses POST) -->
<div class="mb-3 text-end">
<form method="POST" action="{{ url_for('update_game_data', game_id=game.id) }}" id="updateDataForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Ändere die ID für Eindeutigkeit -->
<input type="hidden" name="steam_appid" id="itad_steam_appid" value="{{ game.steam_appid }}">
<button type="submit" class="btn btn-secondary">
🔄 {{ _('Update Data') }}
</button>
</form>
<script>
document.getElementById('updateDataForm').addEventListener('submit', function(e) {
e.preventDefault();
const currentAppId = document.getElementById('game_appid').value;
document.getElementById('itad_steam_appid').value = currentAppId;
this.submit();
});
</script>
</div>
<form method="POST" aria-label="{{ _('Spiel bearbeiten') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row g-3"> <div class="row g-3">
<!-- Formularfelder -->
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">{{ _('Name') }} <span class="text-danger">*</span></label> <label for="game_name" class="form-label">{{ _('Name') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="text" name="name" class="form-control" value="{{ game.name }}" required> <input type="text" id="game_name" name="name" class="form-control" value="{{ game.name }}" required aria-required="true">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="game_platform" class="form-label">{{ _('Platform') }} <span class="text-danger">*</span></label> <label for="game_key" class="form-label">{{ _('Game Key') }} <span aria-hidden="true" class="text-danger">*</span></label>
<select id="game_platform" name="platform" class="form-select" required> <input type="text" id="game_key" name="steam_key" class="form-control" value="{{ game.steam_key }}" required aria-required="true">
{% for value, label in platforms %}
<option value="{{ value }}" {% if game.platform == value %}selected{% endif %}>{{ _(label) }}</option>
{% endfor %}
</select>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="game_status" class="form-label">{{ _('Status') }} <span class="text-danger">*</span></label> <label for="game_appid" class="form-label">{{ _('Steam AppID (optional)') }}</label>
<select id="game_status" name="status" class="form-select" required>
{% for value, label in statuses %}
<option value="{{ value }}" {% if game.status == value %}selected{% endif %}>{{ _(label) }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">{{ _('Steam Key') }} <span class="text-danger">*</span></label>
<input type="text" name="steam_key" class="form-control" value="{{ game.steam_key }}" required>
</div>
<div class="col-md-6">
<label for="game_appid" class="form-label">{{ _('Steam AppID') }}</label>
<input type="text" id="game_appid" name="steam_appid" class="form-control" value="{{ game.steam_appid or '' }}"> <input type="text" id="game_appid" name="steam_appid" class="form-control" value="{{ game.steam_appid or '' }}">
<small class="text-muted">
{{ _('For GOG games: Enter the Steam AppID here to enable price tracking.') }}
</small>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<label for="game_status" class="form-label">{{ _('Status') }} <span aria-hidden="true" class="text-danger">*</span></label>
<select id="game_status" name="status" class="form-select" required aria-required="true">
<option value="nicht eingelöst" {% if game.status == 'nicht eingelöst' %}selected{% endif %}>{{ _('Not redeemed') }}</option>
<option value="verschenkt" {% if game.status == 'verschenkt' %}selected{% endif %}>{{ _('Gifted') }}</option>
<option value="eingelöst" {% if game.status == 'eingelöst' %}selected{% endif %}>{{ _('Redeemed') }}</option>
</select>
</div>
<div class="col-md-4">
<label for="game_redeem_date" class="form-label">{{ _('Redeem by') }}</label> <label for="game_redeem_date" class="form-label">{{ _('Redeem by') }}</label>
<input type="date" id="game_redeem_date" name="redeem_date" class="form-control" value="{{ game.redeem_date.strftime('%Y-%m-%d') if game.redeem_date else '' }}"> <input type="date" id="game_redeem_date" name="redeem_date" class="form-control" value="{{ redeem_date }}">
</div> </div>
<div class="col-12"> <div class="col-md-4">
<label for="game_recipient" class="form-label">{{ _('Recipient') }}</label> <label for="game_recipient" class="form-label">{{ _('Recipient') }}</label>
<input type="text" id="game_recipient" name="recipient" class="form-control" value="{{ game.recipient }}"> <input type="text" id="game_recipient" name="recipient" class="form-control" value="{{ game.recipient }}">
</div> </div>
@ -91,95 +41,27 @@
<label for="game_notes" class="form-label">{{ _('Notes') }}</label> <label for="game_notes" class="form-label">{{ _('Notes') }}</label>
<textarea id="game_notes" name="notes" class="form-control" rows="3">{{ game.notes }}</textarea> <textarea id="game_notes" name="notes" class="form-control" rows="3">{{ game.notes }}</textarea>
</div> </div>
<!-- Show External Data -->
<div class="col-12"> <div class="col-12">
<div class="card mb-4"> {% if redeem_url and active_redeem %}
<div class="card-header"> <div class="mb-3">
<span>🔄 {{ _('External Data') }}</span> <label for="active_redeem_link" class="form-label">{{ _('Active Redeem Link') }}</label>
</div> <input type="text"
<div class="card-body"> id="active_redeem_link"
{% if game.release_date %} class="form-control"
<div class="mb-2"> value="{{ redeem_url }}"
<strong>{{ _('Release Date:') }}</strong> readonly
{{ game.release_date|strftime('%d.%m.%Y') }} onclick="this.select()">
</div> <small class="text-muted">
{% endif %} {{ _('Expires at') }}: {{ active_redeem.expires.strftime('%d.%m.%Y %H:%M') }}
{% if game.current_price %} </small>
<div class="text-center mb-2">
<span class="badge bg-primary d-block">{{ _('Now') }}</span>
<div class="fw-bold" style="font-size:1.1em;">
{{ "%.2f"|format(game.current_price) }} €
</div>
</div>
{% endif %}
{% if game.historical_low %}
<div class="text-center">
<span class="badge bg-secondary d-block">{{ _('Hist. Low') }}</span>
<div class="fw-bold" style="font-size:1.1em;">
{{ "%.2f"|format(game.historical_low) }} €
</div>
</div>
{% endif %}
{% if game.itad_slug %}
<a href="https://isthereanydeal.com/game/{{ game.itad_slug }}/info/" target="_blank" rel="noopener" class="btn btn-outline-info mt-2">
🔗 {{ _('View on IsThereAnyDeal') }}
</a>
{% endif %}
</div>
</div> </div>
{% endif %}
</div> </div>
<!-- Einlöse-Links -->
{% if game.status == 'geschenkt' %}
<div class="col-12">
<div class="card mb-3">
<div class="card-header">{{ _('Redeem-Link') }}</div>
<div class="card-body">
{% for token in game.redeem_tokens if not token.is_expired() %}
<div class="input-group mb-3">
<input type="text" class="form-control" value="{{ url_for('redeem', token=token.token, _external=True) }}" readonly id="redeem-link-{{ loop.index }}">
<button type="button" class="btn btn-outline-secondary copy-btn" data-clipboard-target="#redeem-link-{{ loop.index }}">
{{ _('Copy') }}
</button>
</div>
<small class="text-muted">
{{ _('Expires at') }}: {{ token.expires.astimezone(local_tz).strftime('%d.%m.%Y %H:%M') }}
</small>
{% else %}
<p class="text-muted mb-0">{{ _('No active redeem links') }}</p>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Buttons -->
<div class="col-12"> <div class="col-12">
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button> <button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary ms-2">{{ _('Cancel') }}</a> <a href="{{ url_for('index') }}" class="btn btn-outline-secondary ms-2">{{ _('Cancel') }}</a>
<a href="{{ url_for('game_details', game_id=game.id) }}" class="btn btn-info ms-2">🔍 {{ _('View Details') }}</a>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<!-- Copy-JavaScript -->
<script>
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const input = document.querySelector(this.dataset.clipboardTarget);
try {
await navigator.clipboard.writeText(input.value);
this.innerHTML = '✅ {{ _("Copied!") }}';
setTimeout(() => this.innerHTML = '{{ _("Copy") }}', 2000);
} catch (err) {
this.innerHTML = '❌ {{ _("Error") }}';
setTimeout(() => this.innerHTML = '{{ _("Copy") }}', 2000);
}
});
});
</script>
{% endblock %} {% endblock %}

View file

@ -1,62 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="card shadow-sm">
<div class="card-body">
<h1>{{ game.name }}</h1>
<div class="row">
<!-- Bild und Basis-Infos -->
<div class="col-md-4">
{% if game.steam_appid %}
<img src="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ game.steam_appid }}/header.jpg"
class="img-fluid rounded mb-3"
alt="{{ game.name }} Cover"
loading="lazy">
{% endif %}
</div>
<!-- Details -->
<div class="col-md-8">
<dl class="row">
<dt class="col-sm-3">{{ _('Status') }}</dt>
<dd class="col-sm-9">
{% if game.status == 'nicht eingelöst' %}
<span class="badge bg-warning text-dark">{{ _('Not redeemed') }}</span>
{% elif game.status == 'geschenkt' %}
<span class="badge bg-success">{{ _('Gifted') }}</span>
{% elif game.status == 'eingelöst' %}
<span class="badge bg-secondary">{{ _('Redeemed') }}</span>
{% endif %}
</dd>
<dt class="col-sm-3">{{ _('Release Date') }}</dt>
<dd class="col-sm-9">{{ game.release_date|strftime('%d.%m.%Y') if game.release_date else 'N/A' }}</dd>
<dt class="col-sm-3">{{ _('Current Price') }}</dt>
<dd class="col-sm-9">{{ "%.2f €"|format(game.current_price) if game.current_price else 'N/A' }}</dd>
</dl>
<a href="{{ url_for('edit_game', game_id=game.id) }}" class="btn btn-primary">
{{ _('Edit') }}
</a>
</div>
</div>
{% set lang = session.get('lang', 'en') %}
{% set desc = getattr(game, 'steam_description_' + lang) %}
{% if desc %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">{{ _('Game Description') }}</div>
<div class="card-body">
{{ desc|safe }}
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -22,7 +22,6 @@
<th>{{ _('Created') }}</th> <th>{{ _('Created') }}</th>
<th>{{ _('Redeem by') }}</th> <th>{{ _('Redeem by') }}</th>
<th>{{ _('Shop') }}</th> <th>{{ _('Shop') }}</th>
<th>{{ _('Price') }}</th>
<th>{{ _('Actions') }}</th> <th>{{ _('Actions') }}</th>
</tr> </tr>
</thead> </thead>
@ -30,40 +29,38 @@
{% for game in games %} {% for game in games %}
<tr> <tr>
<td> <td>
<a href="{{ url_for('game_details', game_id=game.id) }}" title="{{ _('Details') }}"> {% if game.steam_appid %}
{% if game.steam_appid %} <img src="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ game.steam_appid }}/header.jpg"
<img src="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ game.steam_appid }}/header.jpg" alt="Steam Header"
alt="Steam Header" class="game-cover"
class="game-cover" {% if loop.first %}fetchpriority="high"{% endif %}
{% if loop.first %}fetchpriority="high"{% endif %} width="368"
width="368" height="172"
height="172" loading="lazy">
loading="lazy"> {% elif game.url and 'gog.com' in game.url %}
{% elif game.url and 'gog.com' in game.url %} <img src="{{ url_for('static', filename='gog_logo.webp') }}"
<img src="{{ url_for('static', filename='gog_logo.webp') }}" alt="GOG Logo"
alt="GOG Logo" class="game-cover"
class="game-cover" width="368"
width="368" height="172"
height="172" loading="lazy">
loading="lazy"> {% endif %}
{% endif %}
</a>
</td> </td>
<td>{{ game.name }}</td> <td>{{ game.name }}</td>
<td class="font-monospace">{{ game.steam_key }}</td> <td class="font-monospace">{{ game.steam_key }}</td>
<td> <td>
{% if game.status == 'nicht eingelöst' %} {% if game.status == 'nicht eingelöst' %}
<span class="badge bg-warning text-dark">{{ _('Not redeemed') }}</span> <span class="badge bg-warning text-dark">{{ _('Not redeemed') }}</span>
{% elif game.status == 'geschenkt' %} {% elif game.status == 'verschenkt' %}
<span class="badge bg-success">{{ _('Gifted') }}</span> <span class="badge bg-success">{{ _('Gifted') }}</span>
{% elif game.status == 'eingelöst' %} {% elif game.status == 'eingelöst' %}
<span class="badge bg-secondary">{{ _('Redeemed') }}</span> <span class="badge bg-secondary">{{ _('Redeemed') }}</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ game.created_at|strftime('%d.%m.%Y') }}</td> <td>{{ format_date(game.created_at) }}</td>
<td> <td>
{% if game.redeem_date %} {% if game.redeem_date %}
<span class="badge bg-danger">{{ game.redeem_date|strftime('%d.%m.%Y') }}</span> <span class="badge bg-danger">{{ format_date(game.redeem_date) }}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -71,37 +68,9 @@
<a href="{{ game.url }}" target="_blank" class="btn btn-sm btn-outline-info">🔗 {{ _('Shop') }}</a> <a href="{{ game.url }}" target="_blank" class="btn btn-sm btn-outline-info">🔗 {{ _('Shop') }}</a>
{% endif %} {% endif %}
</td> </td>
<td>
{% if game.current_price is not none %}
<div {% if game.historical_low is not none %}class="mb-2"{% endif %}>
<div class="text-body-secondary" style="font-size: 0.85em; line-height: 1.2;">
{{ _('Current Deal') }}
</div>
<div style="font-size: 1.05em; line-height: 1.2;">
{{ "%.2f"|format(game.current_price) }} €
{% if game.current_price_shop %}
<span class="d-block text-body-secondary" style="font-size: 0.75em; line-height: 1.1;">({{ game.current_price_shop }})</span>
{% endif %}
</div>
</div>
{% endif %}
{# Historical Low #}
{% if game.historical_low is not none %}
<div>
<div class="text-body-secondary" style="font-size: 0.85em; line-height: 1.2;">
{{ _('Hist. Low') }}
</div>
<div style="font-size: 1.05em; line-height: 1.2;">
{{ "%.2f"|format(game.historical_low) }} €
</div>
</div>
{% endif %}
</td>
<td class="text-nowrap"> <td class="text-nowrap">
{% if game.status == 'geschenkt' %} {% if game.status == 'verschenkt' %}
<button type="button" <button class="btn btn-sm btn-success generate-redeem"
class="btn btn-sm btn-success generate-redeem"
data-game-id="{{ game.id }}" data-game-id="{{ game.id }}"
title="{{ _('Generate redeem link') }}"> title="{{ _('Generate redeem link') }}">
🔗 🔗
@ -118,46 +87,29 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<script> <script>
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
document.querySelectorAll('.generate-redeem').forEach(btn => { document.querySelectorAll('.generate-redeem').forEach(btn => {
btn.addEventListener('click', async function() { btn.addEventListener('click', async function() {
const gameId = this.dataset.gameId; const gameId = this.dataset.gameId;
const flashContainer = document.querySelector('.flash-container');
try { try {
const response = await fetch(`/generate_redeem/${gameId}`, { const response = await fetch('/generate_redeem/' + gameId, {
method: 'POST', method: 'POST',
headers: { headers: {
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content, 'X-CSRFToken': csrfToken
'Accept': 'application/json'
} }
}); });
if (!response.ok) throw new Error('Network error');
const data = await response.json(); const data = await response.json();
if(data.url) {
if (!response.ok) {
throw new Error(data.error || '{{ _("Unknown error") }}');
}
if (data.url) {
await navigator.clipboard.writeText(data.url); await navigator.clipboard.writeText(data.url);
alert('{{ _("Redeem link copied to clipboard!") }}');
// Erfolgsmeldung mit übersetztem Text
flashContainer.innerHTML = `
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ _("Link copied") }}: <a href="${data.url}" target="_blank">${data.url}</a>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
} }
} catch (error) { } catch (error) {
// Fehlermeldung mit übersetztem Text console.error('Error:', error);
flashContainer.innerHTML = ` alert('{{ _("Error generating link") }}');
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ _("Error") }}: ${error.message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
} }
}); });
}); });

View file

@ -2,7 +2,7 @@
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<h1 class="mb-4 text-center">{{ _('Login') }}</h1> <h1 class="mb-4">{{ _('Login') }}</h1>
<form method="POST" aria-label="{{ _('Login form') }}" autocomplete="on"> <form method="POST" aria-label="{{ _('Login form') }}" autocomplete="on">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="mb-3">
@ -26,26 +26,18 @@
autocomplete="current-password" autocomplete="current-password"
aria-required="true"> aria-required="true">
</div> </div>
<div class="mb-3 form-check"> {% if error %}
<input type="checkbox" class="form-check-input" id="remember_me" name="remember_me" value="true">
<label class="form-check-label" for="remember_me">{{ _('Remember me') }}</label>
</div>
{# Flash messages are handled in base.html, so the specific error block here can be removed #}
{# {% if error %}
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
{{ error }} {{ error }}
</div> </div>
{% endif %} #} {% endif %}
<button type="submit" class="btn btn-primary w-100">{{ _('Login') }}</button>
<button type="submit" class="btn btn-primary w-100 mb-3">{{ _('Login') }}</button>
</form> </form>
{% if config.REGISTRATION_ENABLED %}
<div class="mt-3 text-center"> <div class="mt-3 text-center">
<a href="{{ url_for('register') }}">{{ _('No account? Register here!') }}</a> <a href="{{ url_for('register') }}">{{ _('No account? Register here!') }}</a>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -17,9 +17,9 @@
<code class="fs-3">{{ game.steam_key }}</code> <code class="fs-3">{{ game.steam_key }}</code>
</div> </div>
<a href="{{ platform_link }}{{ game.steam_key }}" <a href="{{ platform_link }}{{ game.steam_key }}"
class="btn btn-primary btn-lg mb-3" class="btn btn-primary btn-lg mb-3"
target="_blank"> target="_blank">
{{ _('Redeem now on') }} {{ platform_label }} {{ _('Redeem now on') }} {% if game.steam_appid %}Steam{% else %}GOG{% endif %}
</a> </a>
<div class="mt-4 text-muted"> <div class="mt-4 text-muted">
<small> <small>
@ -41,10 +41,9 @@
</div> </div>
<script> <script>
const totalDuration = {{ redeem_token.total_hours * 3600 * 1000 }}; // Gesamtdauer in Millisekunden const totalDuration = {{ redeem_token.total_hours * 3600 * 1000 }}; // Gesamtdauer in Millisekunden
const expires = {{ expires_timestamp }}; const expires = {{ (redeem_token.expires.timestamp() * 1000) | int }};
const countdownEl = document.getElementById('expiry-countdown'); const countdownEl = document.getElementById('expiry-countdown');
const progressBar = document.getElementById('expiry-bar'); const progressBar = document.getElementById('expiry-bar');
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
function formatTime(unit) { function formatTime(unit) {
return unit < 10 ? `0${unit}` : unit; return unit < 10 ? `0${unit}` : unit;

View file

@ -1,94 +1,71 @@
{ {
"Access Forbidden": "Zugriff verweigert", "": "",
"Action": "Aktion",
"Actions": "Aktionen", "Actions": "Aktionen",
"Add Game": "Spiel hinzufügen", "Active Redeem Link": "Aktiver Einlöse-Link",
"Add New Game": "Neues Spiel hinzufügen", "Add New Game": "Neues Spiel hinzufügen",
"Admin": "Admin", "Already have an account? Login!": "",
"Already have an account? Login!": "Du hast schon ein Konto? Jetzt anmelden!",
"Audit Logs": "Prüfprotokolle",
"Back to Home": "Zurück zur Startseite",
"Cancel": "Abbrechen", "Cancel": "Abbrechen",
"Change Password": "Passwort ändern", "Change Password": "Passwort ändern",
"Change password form": "Passwort ändern Formular", "Change password form": "",
"Changes saved!": "Änderungen gespeichert!",
"Confirm New Password": "Neues Passwort bestätigen", "Confirm New Password": "Neues Passwort bestätigen",
"Confirm Password": "Passwort bestätigen", "Confirm Password": "",
"Copied!": "Kopiert!",
"Copy": "Kopieren",
"Cover": "Cover", "Cover": "Cover",
"Created": "Erstellt", "Created": "Erstellt",
"Current Deal": "Aktuelles Angebot",
"Current Password": "Aktuelles Passwort", "Current Password": "Aktuelles Passwort",
"Current Price": "Aktueller Preis", "Current passwort is wrong": "Aktuelles Passwort ist falsch",
"Delete": "Löschen", "Dark Mode": "Dunkler Modus",
"Details": "Details", "Edit Game": "Spiel bearbeiten",
"Edit": "Bearbeiten", "Error generating link": "Fehler beim Generieren des Links",
"Redeem-Link": "Einlöse-Link", "Error: ": "Fehler: ",
"Error": "Fehler", "Expires at": "Ablaufdatum",
"Expires at": "Läuft ab am",
"Export CSV": "CSV exportieren", "Export CSV": "CSV exportieren",
"Externe Daten": "Externe Daten", "Game Key": "Spiele-Key",
"For GOG games: Enter the Steam AppID here to enable price tracking.": "Für GOG-Spiele: Gib hier die Steam AppID ein, um die Preisüberwachung zu aktivieren.", "Game Key Manager": "Game-Key-Verwaltung",
"Game Description": "Spielbeschreibung", "Game List (without Keys)": "Spieleliste (ohne Keys)",
"Game Key": "Spielschlüssel", "Game added successfully!": "Spiel erfolgreich hinzugefügt!",
"Game Key Manager": "Game Key Manager",
"Generate redeem link": "Einlöse-Link generieren", "Generate redeem link": "Einlöse-Link generieren",
"Gifted": "Verschenkt", "Gifted": "Verschenkt",
"Hist. Low": "Historischer Tiefstpreis",
"Import": "Importieren", "Import": "Importieren",
"Import CSV": "CSV importieren", "Import CSV": "CSV importieren",
"Import Games": "Spiele importieren", "Import Games": "Spiele importieren",
"Key": "Schlüssel", "Import error: %(error)s', error=str(e)), 'danger": "",
"Link copied": "Link kopiert", "Invalid credentials": "Ungültige Anmeldedaten",
"Key": "Key",
"Login": "Anmelden", "Login": "Anmelden",
"Login form": "Anmeldeformular", "Login form": "",
"Logout": "Abmelden", "Logout": "Abmelden",
"My Games": "Meine Spiele", "My Games": "Meine Spiele",
"Name": "Name", "Name": "Name",
"New Password": "Neues Passwort", "New Password": "Neues Passwort",
"Next": "Weiter", "New Passwords are not matching": "Neue Passwörter stimmen nicht überein",
"No account? Register here!": "Noch kein Konto? Hier registrieren!", "No account? Register here!": "",
"No active redeem links": "Keine aktiven Einlöse-Links",
"No games yet": "Der Kornspeicher ist leer, Sire!", "No games yet": "Der Kornspeicher ist leer, Sire!",
"Notes": "Notizen", "No new registrations. They are deactivated!": "Keine neuen Registrierungen. Sie sind deaktiviert!",
"Not redeemed": "Nicht eingelöst", "Not redeemed": "Nicht eingelöst",
"Now": "Jetzt", "Notes": "Notizen",
"Password": "Passwort", "Password": "Passwort",
"Platform": "Plattform", "Password changed successfully": "Passwort erfolgreich geändert",
"Previous": "Zurück", "Please upload a valid CSV file.": "Bitte eine gültige CSV-Datei hochladen.",
"Price": "Preis",
"Really delete?": "Wirklich löschen?", "Really delete?": "Wirklich löschen?",
"Recipient": "Empfänger", "Recipient": "Empfänger",
"Redeem by": "Einlösen bis", "Redeem by": "Einzulösen vor",
"Redeemed": "Eingelöst", "Redeem link copied to clipboard!": "Einlöse-Link in die Zwischenablage kopiert!",
"Redeem now on": "Jetzt einlösen bei", "Redeem now on": "Jetzt einlösen bei",
"Redeemed": "Eingelöst",
"Register": "Registrieren", "Register": "Registrieren",
"Registration form": "Registrierungsformular", "Registration form": "",
"Registration is currently disabled.": "Registrierung ist derzeit deaktiviert.",
"Release Date": "Veröffentlichungsdatum",
"Remember me": "Angemeldet bleiben",
"Reset Password": "Passwort zurücksetzen",
"Save": "Speichern", "Save": "Speichern",
"Search": "Suchen", "Search": "Suche",
"Search games": "Spiele suchen", "Search games": "",
"Select CSV file": "CSV-Datei auswählen", "Select CSV file": "CSV-Datei auswählen",
"Shop": "Shop", "Shop": "Shop",
"Shop URL": "Shop-URL", "Shop URL": "Shop-URL",
"Sorry, you are not allowed to access this page.": "Du bist nicht berechtigt, diese Seite zu betreten.",
"Spiel bearbeiten": "Spiel bearbeiten",
"Status": "Status", "Status": "Status",
"Steam AppID": "Steam AppID", "Steam AppID (optional)": "Steam-AppID (optional)",
"Steam AppID (optional)": "Steam AppID (optional)", "Steam Key already exists!": "Steam-Key existiert bereits!",
"Steam Key": "Steam-Schlüssel",
"This page will expire in": "Diese Seite läuft ab in", "This page will expire in": "Diese Seite läuft ab in",
"Timestamp": "Zeitstempel",
"Unknown error": "Unbekannter Fehler",
"Update Data": "Daten aktualisieren",
"User": "Benutzer",
"User Management": "Benutzerverwaltung",
"Username": "Benutzername", "Username": "Benutzername",
"Release Date:": "Veröffentlichung:", "Username already exists": "Benutzername existiert bereits",
"View Details": "Details anzeigen", "Your Key:": "Dein Key:"
"View on IsThereAnyDeal": "Auf IsThereAnyDeal ansehen",
"Your Key:": "Dein Schlüssel:"
} }

View file

@ -1,94 +1,72 @@
{ {
"Access Forbidden": "", "": "",
"Action": "",
"Actions": "", "Actions": "",
"Add Game": "", "Active Redeem Link": "",
"Add New Game": "", "Add New Game": "",
"Admin": "",
"Already have an account? Login!": "", "Already have an account? Login!": "",
"Audit Logs": "",
"Back to Home": "",
"Cancel": "", "Cancel": "",
"Change Password": "", "Change Password": "",
"Change password form": "", "Change password form": "",
"Changes saved!": "",
"Confirm New Password": "", "Confirm New Password": "",
"Confirm Password": "", "Confirm Password": "",
"Copied!": "",
"Copy": "",
"Cover": "", "Cover": "",
"Created": "", "Created": "",
"Current Deal": "",
"Current Password": "", "Current Password": "",
"Current Price": "", "Current passwort is wrong": "",
"Delete": "", "Dark Mode": "",
"Details": "", "Edit Game": "",
"Edit": "", "Error: ": "",
"Error": "", "Error generating link": "",
"Expires at": "", "Expires at": "",
"Export CSV": "", "Export CSV": "",
"External Data": "", "Game added successfully!": "",
"For GOG games: Enter the Steam AppID here to enable price tracking.": "",
"Game Description": "",
"Game Key": "", "Game Key": "",
"Game Key Manager": "", "Game Key Manager": "",
"Game List (without Keys)": "",
"Generate redeem link": "", "Generate redeem link": "",
"Gifted": "", "Gifted": "",
"Hist. Low": "",
"Import": "", "Import": "",
"Import CSV": "", "Import CSV": "",
"Import error: %(error)s', error=str(e)), 'danger": "",
"Import Games": "", "Import Games": "",
"Invalid credentials": "",
"Key": "", "Key": "",
"Link copied": "",
"Login": "", "Login": "",
"Login form": "", "Login form": "",
"Logout": "", "Logout": "",
"My Games": "", "My Games": "",
"Name": "", "Name": "",
"%(new)d new games imported, %(dup)d skipped duplicates', new=new_games, dup=duplicates), 'success": "",
"New Password": "", "New Password": "",
"Next": "", "New Passwords are not matching": "",
"No account? Register here!": "", "No account? Register here!": "",
"No active redeem links": "",
"No games yet": "", "No games yet": "",
"No new registrations. They are deactivated!": "",
"Notes": "", "Notes": "",
"Not redeemed": "", "Not redeemed": "",
"Now": "",
"Password": "", "Password": "",
"Platform": "", "Password changed successfully": "",
"Previous": "", "Please upload a valid CSV file.": "",
"Price": "",
"Really delete?": "", "Really delete?": "",
"Recipient": "", "Recipient": "",
"Redeem by": "", "Redeem by": "",
"Redeemed": "", "Redeemed": "",
"Redeem-Link": "", "Redeem link copied to clipboard!": "",
"Redeem now on": "", "Redeem now on": "",
"Register": "", "Register": "",
"Registration form": "", "Registration form": "",
"Registration is currently disabled.": "",
"Release Date": "",
"Release Date:": "",
"Remember me": "",
"Reset Password": "",
"Save": "", "Save": "",
"Search": "", "Search": "",
"Search games": "", "Search games": "",
"Select CSV file": "", "Select CSV file": "",
"Shop": "", "Shop": "",
"Shop URL": "", "Shop URL": "",
"Sorry, you are not allowed to access this page.": "",
"Spiel bearbeiten": "",
"Status": "", "Status": "",
"Steam AppID": "",
"Steam AppID (optional)": "", "Steam AppID (optional)": "",
"Steam Key": "", "Steam Key already exists!": "",
"This page will expire in": "", "This page will expire in": "",
"Timestamp": "",
"Unknown error": "",
"Update Data": "",
"User": "",
"User Management": "",
"Username": "", "Username": "",
"View Details": "", "Username already exists": "",
"View on IsThereAnyDeal": "",
"Your Key:": "" "Your Key:": ""
} }

View file

@ -5,34 +5,37 @@ APP_DIR="steam-gift-manager"
TRANSLATION_DIR="$APP_DIR/translations" TRANSLATION_DIR="$APP_DIR/translations"
LANGS=("de" "en") LANGS=("de" "en")
# check jq # Prüfe jq
if ! command -v jq &>/dev/null; then if ! command -v jq &>/dev/null; then
echo "❌ jq is required. Install with: sudo apt-get install jq" echo "❌ jq is required. Install with: sudo apt-get install jq"
exit 1 exit 1
fi fi
echo -e "\n\033[1;32m✅ Extracting translations...\033[0m" # 1. Lege JSON-Dateien an, falls sie fehlen
# 1. create json files
mkdir -p "$TRANSLATION_DIR"
for lang in "${LANGS[@]}"; do for lang in "${LANGS[@]}"; do
file="$TRANSLATION_DIR/$lang.json" file="$TRANSLATION_DIR/$lang.json"
[ -f "$file" ] || echo "{}" > "$file" if [ ! -f "$file" ]; then
echo "{}" > "$file"
echo "Created $file"
fi
done done
# 2. extract all strings # 2. Extrahiere alle zu übersetzenden Strings
STRINGS=$(grep -rhoP "_\(\s*['\"]((?:[^']|'[^'])*?)['\"]\s*[,)]" \ STRINGS=$(grep -rhoP "_\(\s*['\"](.+?)['\"]\s*\)" \
"$APP_DIR/templates" "$APP_DIR/app.py" | \ "$APP_DIR/templates" "$APP_DIR/app.py" | \
sed -E "s/_\(\s*['\"](.+?)['\"]\s*[,)]/\1/" | sort | uniq) sed -E "s/_\(\s*['\"](.+?)['\"]\s*\)/\1/" | sort | uniq)
# 3. put da keys in da json # 3. Ergänze neue Keys in die JSON-Dateien
for lang in "${LANGS[@]}"; do for lang in "${LANGS[@]}"; do
file="$TRANSLATION_DIR/$lang.json" file="$TRANSLATION_DIR/$lang.json"
tmp="$file.tmp" tmp="$file.tmp"
jq --argjson keys "$(echo "$STRINGS" | jq -R . | jq -s .)" \ cp "$file" "$tmp"
'reduce $keys[] as $k (.; .[$k] = (.[$k] // ""))' "$file" > "$tmp" while IFS= read -r key; do
if ! jq -e --arg k "$key" 'has($k)' "$tmp" >/dev/null; then
jq --arg k "$key" '. + {($k): ""}' "$tmp" > "$tmp.new" && mv "$tmp.new" "$tmp"
fi
done <<< "$STRINGS"
mv "$tmp" "$file" mv "$tmp" "$file"
echo "Updated $file"
done done
echo "✅ JSON translation files updated. Please enter your translations!"
echo -e "\n\033[1;32m✅ Done! Translation keys added.\033[0m"

View file

@ -4,7 +4,7 @@ set -e
# Set the working directory to the project directory # Set the working directory to the project directory
cd "$(dirname "$0")/steam-gift-manager" cd "$(dirname "$0")/steam-gift-manager"
# set FLASK_APP, if needed # Setze FLASK_APP, falls nötig
export FLASK_APP=app.py export FLASK_APP=app.py
# Initialize migrations, if not yet available # Initialize migrations, if not yet available