update readme due release on codeberg #2

Open
nocci wants to merge 26 commits from dev into main
35 changed files with 696 additions and 1124 deletions
Showing only changes of commit 4a0a5bac3f - Show all commits

View File

@ -2,7 +2,7 @@ FROM python:3.10-slim
SHELL ["/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends wget && mkdir -p /app/static && wget -O /app/static/logo.png "https://git.nocci.it/nocci/GiftGamesDB/raw/branch/main/steam-gift-manager/static/logo.png" && wget -O /app/static/logo_small.png "https://git.nocci.it/nocci/GiftGamesDB/raw/branch/main/steam-gift-manager/static/logo_small.png" && wget -O /app/static/forgejo.svg "https://git.nocci.it/nocci/GiftGamesDB/raw/branch/main/steam-gift-manager/static/forgejo.svg" && rm -rf /var/lib/apt/lists/* 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/*
RUN mkdir -p /app/data && chown -R 1000:1000 /app/data RUN mkdir -p /app/data && chown -R 1000:1000 /app/data

View File

@ -1,16 +1,15 @@
import os import os
import logging
import warnings import warnings
from sqlalchemy.exc import LegacyAPIWarning from sqlalchemy.exc import LegacyAPIWarning
warnings.simplefilter("ignore", category=LegacyAPIWarning) warnings.simplefilter("ignore", category=LegacyAPIWarning)
from flask import Flask, render_template, request, redirect, url_for, flash, make_response, session, abort, send_file, jsonify from flask import Flask, render_template, request, redirect, url_for, flash, make_response, session, abort, send_file, jsonify
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from flask_babel import Babel, _
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
from flask import abort from flask import abort
from flask import request, redirect
import io import io
import warnings import warnings
import re import re
@ -41,8 +40,39 @@ from reportlab.lib.utils import ImageReader
from reportlab.lib.units import cm, inch, mm from reportlab.lib.units import cm, inch, mm
from io import BytesIO from io import BytesIO
import reportlab.lib import reportlab.lib
import logging
logging.basicConfig()
app = Flask(__name__) app = Flask(__name__)
# Load Languages
import os
import json
TRANSLATION_DIR = os.path.join(os.path.dirname(__file__), 'translations')
SUPPORTED_LANGUAGES = ['de', 'en']
TRANSLATIONS = {}
for lang in SUPPORTED_LANGUAGES:
try:
with open(os.path.join(TRANSLATION_DIR, f'{lang}.json'), encoding='utf-8') as f:
TRANSLATIONS[lang] = json.load(f)
except Exception:
TRANSLATIONS[lang] = {}
def translate(key, lang=None, **kwargs):
if not lang:
lang = session.get('lang', 'en')
value = TRANSLATIONS.get(lang, {}).get(key)
if value is None and lang != 'en':
value = TRANSLATIONS.get('en', {}).get(key, key)
else:
value = value or key
return value.format(**kwargs) if kwargs and isinstance(value, str) else value
## DEBUG Translations
if app.debug:
print(f"Loaded translations for 'de': {TRANSLATIONS.get('de', {})}")
csrf = CSRFProtect(app) csrf = CSRFProtect(app)
convention = { convention = {
@ -62,14 +92,16 @@ load_dotenv(override=True)
# App-Configuration # App-Configuration
app.config.update( app.config.update(
SECRET_KEY=os.getenv('SECRET_KEY'), SECRET_KEY=os.getenv('SECRET_KEY'),
SQLALCHEMY_DATABASE_URI=('sqlite:////app/data/games.db'), SQLALCHEMY_DATABASE_URI='sqlite:////app/data/games.db',
SQLALCHEMY_TRACK_MODIFICATIONS=False, SQLALCHEMY_TRACK_MODIFICATIONS=False,
BABEL_DEFAULT_LOCALE=os.getenv('BABEL_DEFAULT_LOCALE'), SESSION_COOKIE_SECURE=os.getenv('SESSION_COOKIE_SECURE', 'False') == 'True',
BABEL_SUPPORTED_LOCALES=os.getenv('BABEL_SUPPORTED_LOCALES').split(','), SESSION_COOKIE_SAMESITE='Lax',
BABEL_TRANSLATION_DIRECTORIES=os.getenv('BABEL_TRANSLATION_DIRECTORIES'), PERMANENT_SESSION_LIFETIME=timedelta(days=30),
SESSION_COOKIE_SECURE=os.getenv('SESSION_COOKIE_SECURE') == 'True', SESSION_REFRESH_EACH_REQUEST=False,
WTF_CSRF_ENABLED=os.getenv('CSRF_ENABLED') == 'True', WTF_CSRF_ENABLED=os.getenv('CSRF_ENABLED', 'True') == 'True',
REGISTRATION_ENABLED=os.getenv('REGISTRATION_ENABLED', 'True').lower() == 'true' REGISTRATION_ENABLED=os.getenv('REGISTRATION_ENABLED', 'True').lower() == 'true',
SEND_FILE_MAX_AGE_DEFAULT=int(os.getenv('SEND_FILE_MAX_AGE_DEFAULT', 0)),
TEMPLATES_AUTO_RELOAD=os.getenv('TEMPLATES_AUTO_RELOAD', 'True') == 'True'
) )
interval_hours = int(os.getenv('CHECK_EXPIRING_KEYS_INTERVAL_HOURS', 12)) interval_hours = int(os.getenv('CHECK_EXPIRING_KEYS_INTERVAL_HOURS', 12))
@ -79,24 +111,28 @@ db = SQLAlchemy(app, metadata=metadata)
migrate = Migrate(app, db) migrate = Migrate(app, db)
login_manager = LoginManager(app) login_manager = LoginManager(app)
login_manager.login_view = 'login' login_manager.login_view = 'login'
babel = Babel(app)
# Logging # Logging
app.logger.addHandler(logging.StreamHandler()) app.logger.addHandler(logging.StreamHandler())
app.logger.setLevel(logging.INFO) app.logger.setLevel(logging.INFO)
@babel.localeselector
def get_locale(): @app.before_request
if 'lang' in session and session['lang'] in app.config['BABEL_SUPPORTED_LOCALES']: def enforce_https():
return session['lang'] if os.getenv('FORCE_HTTPS', 'False').lower() == 'true':
return request.accept_languages.best_match(app.config['BABEL_SUPPORTED_LOCALES']) if request.headers.get('X-Forwarded-Proto', 'http') != 'https' and not request.is_secure:
url = request.url.replace('http://', 'https://', 1)
app.logger.info(f"Redirecting to HTTPS: {url}")
return redirect(url, code=301)
@app.context_processor @app.context_processor
def inject_template_vars(): def inject_template_vars():
return dict( def _(key, **kwargs):
get_locale=get_locale, lang = session.get('lang', 'en')
theme='dark' if request.cookies.get('dark_mode') == 'true' else 'light' return translate(key, lang, **kwargs)
) theme = request.cookies.get('theme', 'light')
return dict(_=_, theme=theme)
# DB Models # DB Models
class User(db.Model, UserMixin): class User(db.Model, UserMixin):
@ -163,14 +199,15 @@ def index():
@app.route('/set-lang/<lang>') @app.route('/set-lang/<lang>')
def set_lang(lang): def set_lang(lang):
if lang in app.config['BABEL_SUPPORTED_LOCALES']: if lang in SUPPORTED_LANGUAGES:
session['lang'] = lang session['lang'] = lang
return redirect(request.referrer or url_for('index')) return redirect(request.referrer or url_for('index'))
@app.route('/set-theme/<theme>') @app.route('/set-theme/<theme>')
def set_theme(theme): def set_theme(theme):
resp = make_response('', 204) resp = make_response('', 204)
resp.set_cookie('dark_mode', 'true' if theme == 'dark' else 'false', max_age=60*60*24*365) # Von 'dark_mode' zu 'theme' ändern
resp.set_cookie('theme', theme, max_age=60*60*24*365)
return resp return resp
@app.route('/login', methods=['GET', 'POST']) @app.route('/login', methods=['GET', 'POST'])
@ -190,7 +227,7 @@ def login():
@app.route('/register', methods=['GET', 'POST']) @app.route('/register', methods=['GET', 'POST'])
def register(): def register():
if not app.config['REGISTRATION_ENABLED']: if not app.config['REGISTRATION_ENABLED']:
flash(_('Registrierungen sind deaktiviert'), 'danger') flash(_('No new registrations. They are deactivated!'), 'danger')
return redirect(url_for('login')) return redirect(url_for('login'))
if request.method == 'POST': if request.method == 'POST':
@ -224,16 +261,16 @@ def change_password():
confirm_password = request.form['confirm_password'] confirm_password = request.form['confirm_password']
if not check_password_hash(current_user.password, current_password): if not check_password_hash(current_user.password, current_password):
flash(_('Aktuelles Passwort ist falsch'), 'danger') flash(_('Current passwort is wrong'), 'danger')
return redirect(url_for('change_password')) return redirect(url_for('change_password'))
if new_password != confirm_password: if new_password != confirm_password:
flash(_('Neue Passwörter stimmen nicht überein'), 'danger') flash(_('New Passwords are not matching'), 'danger')
return redirect(url_for('change_password')) return redirect(url_for('change_password'))
current_user.password = generate_password_hash(new_password) current_user.password = generate_password_hash(new_password)
db.session.commit() db.session.commit()
flash(_('Passwort erfolgreich geändert'), 'success') flash(_('Password changed successfully'), 'success')
return redirect(url_for('index')) return redirect(url_for('index'))
return render_template('change_password.html') return render_template('change_password.html')
@ -421,6 +458,12 @@ def export_pdf():
img = Image(img_data, width=3*cm, height=img_height) img = Image(img_data, width=3*cm, height=img_height)
except Exception: except Exception:
img = Paragraph('', styles['Normal']) img = Paragraph('', styles['Normal'])
elif game.url and 'gog.com' in game.url:
try:
img_path = os.path.join(app.root_path, 'static', 'gog_logo.webp')
img = Image(img_path, width=3*cm, height=img_height)
except Exception:
img = Paragraph('', styles['Normal'])
data.append([ data.append([
img or '', img or '',
@ -429,7 +472,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'),
@ -445,13 +488,14 @@ def export_pdf():
doc.build(elements) doc.build(elements)
buffer.seek(0) buffer.seek(0)
return send_file( return send_file(
buffer, buffer,
mimetype='application/pdf', mimetype='application/pdf',
as_attachment=True, as_attachment=True,
download_name=f'game_export_{datetime.now().strftime("%Y%m%d")}.pdf' download_name=f'game_export_{datetime.now().strftime("%Y%m%d")}.pdf'
) )
@app.route('/import', methods=['GET', 'POST']) @app.route('/import', methods=['GET', 'POST'])
@login_required @login_required
def import_games(): def import_games():
@ -491,15 +535,15 @@ def import_games():
db.session.commit() db.session.commit()
flash(_('%(new)d neue Spiele importiert, %(dup)d Duplikate übersprungen', new=new_games, dup=duplicates), 'success') flash(_('%(new)d new games imported, %(dup)d skipped duplicates', new=new_games, dup=duplicates), 'success')
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
flash(_('Importfehler: %(error)s', error=str(e)), 'danger') flash(_('Import error: %(error)s', error=str(e)), 'danger')
return redirect(url_for('index')) return redirect(url_for('index'))
flash(_('Bitte eine gültige CSV-Datei hochladen.'), 'danger') flash(_('Please upload a valid CSV file.'), 'danger')
return render_template('import.html') return render_template('import.html')
@ -557,81 +601,29 @@ def redeem_page(token):
redeem_token=redeem_token, redeem_token=redeem_token,
platform_link='https://store.steampowered.com/account/registerkey?key=' if game.steam_appid else 'https://www.gog.com/redeem') platform_link='https://store.steampowered.com/account/registerkey?key=' if game.steam_appid else 'https://www.gog.com/redeem')
# Benachrichtigungsfunktionen # Apprise Notifications
def send_pushover_notification(user, game): import apprise
"""Sendet Pushover-Benachrichtigung für ablaufenden Key"""
if not app.config['PUSHOVER_APP_TOKEN'] or not app.config['PUSHOVER_USER_KEY']: def send_apprise_notification(user, game):
return False apprise_urls = os.getenv('APPRISE_URLS', '').strip()
if not apprise_urls:
payload = { app.logger.error("No APPRISE_URLS configured")
"token": os.getenv('PUSHOVER_APP_TOKEN'),
"user": os.getenv('PUSHOVER_USER_KEY'),
"title": "Steam-Key läuft ab!",
"message": f"Dein Key für '{game.name}' läuft in weniger als 48 Stunden ab!",
"url": url_for('edit_game', game_id=game.id, _external=True),
"url_title": "Zum Spiel",
"priority": 1
}
try:
response = requests.post(
'https://api.pushover.net/1/messages.json',
data=payload
)
return response.status_code == 200
except Exception as e:
app.logger.error(f"Pushover error: {str(e)}")
return False return False
def send_gotify_notification(user, game): apobj = apprise.Apprise()
"""Sendet Gotify-Benachrichtigung für ablaufenden Key""" for url in apprise_urls.replace(',', '\n').splitlines():
if not GOTIFY_URL or not GOTIFY_TOKEN: if url.strip():
return False apobj.add(url.strip())
payload = {
"title": "Steam-Key läuft ab!",
"message": f"Dein Key für '{game.name}' läuft in weniger als 48 Stunden ab!",
"priority": 5
}
try:
response = requests.post(
f"{GOTIFY_URL}/message?token={GOTIFY_TOKEN}",
json=payload
)
return response.status_code == 200
except Exception as e:
app.logger.error(f"Gotify error: {str(e)}")
return False
def send_matrix_notification(user, game): edit_url = url_for('edit_game', game_id=game.id, _external=True)
"""Sendet Matrix-Benachrichtigung für ablaufenden Key""" result = apobj.notify(
if not MATRIX_HOMESERVER or not MATRIX_ACCESS_TOKEN or not MATRIX_ROOM_ID: title="Steam-Key läuft ab!",
return False body=f"Dein Key für '{game.name}' läuft in weniger als 48 Stunden ab!\n\nLink: {edit_url}",
)
try: return result
from matrix_client.client import MatrixClient
client = MatrixClient(MATRIX_HOMESERVER, token=MATRIX_ACCESS_TOKEN)
room = client.join_room(MATRIX_ROOM_ID)
message = f"🎮 Dein Key für '{game.name}' läuft in weniger als 48 Stunden ab!"
room.send_text(message)
return True
except Exception as e:
app.logger.error(f"Matrix error: {str(e)}")
return False
def send_notification(user, game): def send_notification(user, game):
"""Sendet Benachrichtigung über den bevorzugten Dienst des Benutzers""" return send_apprise_notification(user, game)
if user.notification_service == 'pushover':
return send_pushover_notification(user, game)
elif user.notification_service == 'gotify':
return send_gotify_notification(user, game)
elif user.notification_service == 'matrix':
return send_matrix_notification(user, game)
return False
def check_expiring_keys(): def check_expiring_keys():
with app.app_context(): with app.app_context():

View File

@ -1,3 +0,0 @@
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

View File

@ -8,7 +8,8 @@ services:
- TZ= - TZ=
volumes: volumes:
- ../data:/app/data - ../data:/app/data
- ../translations:/app/translations - ./translations:/app/translations:rw
- ../.env:/app/.env - ../.env:/app/.env
user: "1000:1000" user: "0:"
restart: unless-stopped restart: unless-stopped

View File

@ -5,13 +5,12 @@ flask-migrate
werkzeug werkzeug
python-dotenv python-dotenv
flask-sqlalchemy flask-sqlalchemy
flask-babel
jinja2<3.1.0 jinja2<3.1.0
itsdangerous itsdangerous
sqlalchemy sqlalchemy
apscheduler apscheduler
matrix-client
reportlab reportlab
requests requests
pillow pillow
gunicorn gunicorn
apprise

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 212 212" width="32" height="32"><style>circle,path{fill:none;stroke:#000;stroke-width:15}path{stroke-width:25}.orange{stroke:#f60}.red{stroke:#d40000}</style><g transform="translate(6 6)"><path d="M58 168V70a50 50 0 0 1 50-50h20" class="orange"/><path d="M58 168v-30a50 50 0 0 1 50-50h20" class="red"/><circle cx="142" cy="20" r="18" class="orange"/><circle cx="142" cy="88" r="18" class="red"/><circle cx="58" cy="180" r="18" class="red"/></g></svg>

Before

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@ -0,0 +1,34 @@
{
"id": "/",
"name": "Game Key Manager",
"short_name": "GameKeys",
"start_url": "/",
"display": "standalone",
"background_color": "#212529",
"theme_color": "#212529",
"description": "Manage Steam/GOG keys easily!",
"orientation": "any",
"launch_handler": {
"client_mode": "navigate-existing"
},
"icons": [
{
"src": "/static/logo_small.webp",
"sizes": "192x192",
"type": "image/webp",
"purpose": "any"
},
{
"src": "/static/logo_small_maskable.webp",
"sizes": "192x192",
"type": "image/webp",
"purpose": "maskable"
},
{
"src": "/static/logo.webp",
"sizes": "512x512",
"type": "image/webp",
"purpose": "any maskable"
}
]
}

View File

@ -0,0 +1,32 @@
const CACHE_NAME = 'game-key-manager-v2';
const ASSETS = [
'/',
'/static/style.css',
'/static/logo.webp',
'/static/logo_small.webp',
'/static/gog_logo.webp',
'/static/forgejo.webp'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => cachedResponse || fetch(event.request))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
))
);
});

View File

@ -60,3 +60,76 @@ body {
.table-pdf td, .table-pdf th { .table-pdf td, .table-pdf th {
padding: 4px 8px; padding: 4px 8px;
} }
.badge.bg-warning {
background-color: #ffcc00 !important;
color: #222 !important;
}
.badge.bg-success {
background-color: #198754 !important;
color: #fff !important;
}
.game-cover {
width: 368px;
height: 172px;
max-width: 100%;
max-height: 35vw;
object-fit: contain;
background: #222;
border-radius: 8px;
display: block;
margin: 0 auto;
transition: width 0.2s, height 0.2s;
}
/* Responsive Cover Images */
.game-cover {
width: 368px;
height: 172px;
object-fit: contain;
background: #222;
border-radius: 6px;
}
@media (max-width: 1200px) {
.game-cover {
width: 260px;
height: 122px;
}
}
@media (max-width: 992px) {
.game-cover {
width: 180px;
height: 84px;
}
}
@media (max-width: 768px) {
.game-cover {
width: 120px;
height: 56px;
}
}
@media (max-width: 576px) {
.game-cover {
width: 90px;
height: 42px;
}
}
/* Accessibility Improvements */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}

View File

@ -2,40 +2,44 @@
{% block content %} {% block content %}
<div class="card p-4 shadow-sm"> <div class="card p-4 shadow-sm">
<h2 class="mb-4">{{ _('Add New Game') }}</h2> <h2 class="mb-4">{{ _('Add New Game') }}</h2>
<form method="POST"> <form method="POST" aria-label="{{ _('Add New Game') }}">
<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">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">{{ _('Name') }} *</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" required> <input type="text" id="game_name" name="name" class="form-control" required aria-required="true">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">{{ _('Game Key') }} *</label> <label for="game_key" class="form-label">{{ _('Game Key') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="text" name="steam_key" class="form-control" required> <input type="text" id="game_key" name="steam_key" class="form-control" required aria-required="true">
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">{{ _('Status') }} *</label> <label for="game_status" class="form-label">{{ _('Status') }} <span aria-hidden="true" class="text-danger">*</span></label>
<select name="status" class="form-select" required> <select id="game_status" name="status" class="form-select" required aria-required="true">
<option value="nicht eingelöst">{{ _('Not redeemed') }}</option> <option value="nicht eingelöst">{{ _('Not redeemed') }}</option>
<option value="verschenkt">{{ _('Gifted') }}</option> <option value="verschenkt">{{ _('Gifted') }}</option>
<option value="eingelöst">{{ _('Redeemed') }}</option> <option value="eingelöst">{{ _('Redeemed') }}</option>
</select> </select>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">{{ _('Redeem by') }}</label> <label for="game_redeem_date" class="form-label">{{ _('Redeem by') }}</label>
<input type="date" name="redeem_date" class="form-control"> <input type="date" id="game_redeem_date" name="redeem_date" class="form-control">
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">{{ _('Recipient') }}</label> <label for="game_recipient" class="form-label">{{ _('Recipient') }}</label>
<input type="text" name="recipient" class="form-control"> <input type="text" id="game_recipient" name="recipient" class="form-control">
</div>
<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">
</div>
<div class="col-md-6">
<label for="game_url" class="form-label">{{ _('Shop URL') }}</label>
<input type="url" id="game_url" name="url" class="form-control">
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">{{ _('Shop URL') }}</label> <label for="game_notes" class="form-label">{{ _('Notes') }}</label>
<input type="url" name="url" class="form-control"> <textarea id="game_notes" name="notes" class="form-control" rows="3"></textarea>
</div>
<div class="col-12">
<label class="form-label">{{ _('Notes') }}</label>
<textarea name="notes" class="form-control" rows="3"></textarea>
</div> </div>
<div class="col-12"> <div class="col-12">
<button type="submit" class="btn btn-success">{{ _('Save') }}</button> <button type="submit" class="btn btn-success">{{ _('Save') }}</button>

View File

@ -1,51 +1,81 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ get_locale() }}" data-bs-theme="{{ theme }}"> <html lang="{{ session.get('lang', 'en') }}" data-bs-theme="{{ theme }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="description" content="Manage your Steam and GOG keys efficiently. Track redemption dates, share games, and export lists.">
<meta name="theme-color" content="#212529">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<title>{{ _('Game Key Manager') }}</title> <title>{{ _('Game Key Manager') }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <!-- 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'">
<noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"></noscript>
<!-- 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 %}
<link rel="preload"
as="image"
href="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ games[0].steam_appid }}/header.jpg"
imagesrcset="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ games[0].steam_appid }}/header.jpg 368w"
fetchpriority="high"
type="image/jpeg">
{% endif %}
</head> </head>
<script>
(function() {
try {
var theme = localStorage.getItem('theme');
if (!theme) {
// Systempräferenz als Fallback
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', theme);
} catch(e) {}
})();
</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.png') }}" alt="Logo" width="150" height="116" 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>
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<form class="d-flex" action="{{ url_for('index') }}" method="GET"> <form class="d-flex" action="{{ url_for('index') }}" method="GET" role="search" aria-label="{{ _('Search games') }}">
<label for="searchInput" class="visually-hidden">{{ _('Search') }}</label>
<input class="form-control me-2" <input class="form-control me-2"
type="search" type="search"
name="q" name="q"
id="searchInput"
placeholder="{{ _('Search') }}" placeholder="{{ _('Search') }}"
value="{{ search_query }}"> value="{{ search_query }}">
<button class="btn btn-outline-success" type="submit">🔍</button> <button class="btn btn-outline-success" type="submit" aria-label="{{ _('Search') }}">🔍</button>
</form> </form>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input class="form-check-input" <input class="form-check-input"
type="checkbox" type="checkbox"
id="darkModeSwitch" {% if theme == 'dark' %}checked{% endif %}> id="darkModeSwitch" {% if theme == 'dark' %}checked{% endif %}>
<label class="form-check-label" for="darkModeSwitch">{{ _('Dark Mode') }}</label> <label class="form-check-label" for="darkModeSwitch">{{ _('Dark Mode') }}</label>
</div> </div>
<div class="dropdown ms-3"> <div class="dropdown ms-3">
<div hidden id="locale-debug" data-locale="{{ session.get('lang', 'en') }}"></div>
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{% if get_locale() == 'de' %} Deutsch {% elif get_locale() == 'en' %} English {% else %} Sprache {% endif %} {% if session.get('lang', 'en') == 'de' %} Deutsch {% elif session.get('lang', 'en') == 'en' %} English {% else %} Sprache {% endif %}
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item {% if get_locale() == 'de' %}active{% endif %}" href="{{ url_for('set_lang', lang='de') }}">Deutsch</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><a class="dropdown-item {% if get_locale() == 'en' %}active{% endif %}" href="{{ url_for('set_lang', lang='en') }}">English</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>
</ul> </ul>
</div> </div>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('change_password') }}">🔒 {{ _('Passwort') }}</a> <a class="nav-link" href="{{ url_for('change_password') }}">🔒 {{ _('Password') }}</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('logout') }}">🚪 {{ _('Logout') }}</a> <a class="nav-link" href="{{ url_for('logout') }}">🚪 {{ _('Logout') }}</a>
</li> </li>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -65,16 +95,44 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
// Service Worker Registration for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('{{ url_for("static", filename="serviceworker.js") }}', {scope: '/'})
.then(registration => {
console.log('ServiceWorker registered:', registration.scope);
})
.catch(error => {
console.log('ServiceWorker registration failed:', error);
});
});
}
// Dark Mode Switch
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const toggle = document.getElementById('darkModeSwitch') const toggle = document.getElementById('darkModeSwitch');
const html = document.documentElement const html = document.documentElement;
if (toggle) {
toggle.addEventListener('change', function() { toggle.checked = (html.getAttribute('data-bs-theme') === 'dark')
const theme = this.checked ? 'dark' : 'light' toggle.addEventListener('change', function() {
fetch('/set-theme/' + theme) const theme = this.checked ? 'dark' : 'light';
.then(() => html.setAttribute('data-bs-theme', theme)) document.cookie = "theme=" + theme + ";path=/;max-age=31536000";
}) html.setAttribute('data-bs-theme', theme);
}) fetch('/set-theme/' + theme);
});
}
// Set theme on page load
function getThemeCookie() {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'theme') return value;
}
return null;
}
const savedTheme = getThemeCookie() || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-bs-theme', savedTheme);
});
</script> </script>
{% include "footer.html" %} {% include "footer.html" %}
</body> </body>

View File

@ -1,22 +1,28 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="card p-4 shadow-sm"> <div class="row justify-content-center">
<h2 class="mb-4">{{ _('Change Password') }}</h2> <div class="col-md-6 col-lg-5">
<form method="POST"> <div class="card p-4 shadow-sm">
<h2 class="mb-4">{{ _('Change Password') }}</h2>
<form method="POST" aria-label="{{ _('Change password form') }}">
<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">
<label class="form-label">{{ _('Current Password') }}</label> <label for="current_password" class="form-label">{{ _('Current Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password" name="current_password" class="form-control" required> <input type="password" id="current_password" name="current_password" class="form-control" required autocomplete="current-password" aria-required="true">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">{{ _('New Password') }}</label> <label for="new_password" class="form-label">{{ _('New Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password" name="new_password" class="form-control" required> <input type="password" id="new_password" name="new_password" class="form-control" required autocomplete="new-password" aria-required="true">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">{{ _('Confirm New Password') }}</label> <label for="confirm_password" class="form-label">{{ _('Confirm New Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password" name="confirm_password" class="form-control" required> <input type="password" id="confirm_password" name="confirm_password" class="form-control" required autocomplete="new-password" aria-required="true">
</div> </div>
<button type="submit" class="btn btn-primary">{{ _('Change Password') }}</button> <button type="submit" class="btn btn-primary">{{ _('Change Password') }}</button>
</form> <a href="{{ url_for('index') }}" class="btn btn-outline-secondary ms-2">{{ _('Cancel') }}</a>
</form>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,66 +1,67 @@
{% 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">{{ _('Edit Game') }}</h2> <h2 class="mb-4">{{ _('Edit Game') }}</h2>
<form method="POST"> <form method="POST" aria-label="{{ _('Edit Game') }}">
<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">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">{{ _('Name') }} *</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 class="form-label">{{ _('Game Key') }} *</label> <label for="game_key" class="form-label">{{ _('Game Key') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="text" name="steam_key" class="form-control" value="{{ game.steam_key }}" required> <input type="text" id="game_key" name="steam_key" class="form-control" value="{{ game.steam_key }}" required aria-required="true">
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">{{ _('Steam AppID (optional)') }}</label> <label for="game_appid" class="form-label">{{ _('Steam AppID (optional)') }}</label>
<input type="text" 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 '' }}">
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">{{ _('Status') }} *</label> <label for="game_status" class="form-label">{{ _('Status') }} <span aria-hidden="true" class="text-danger">*</span></label>
<select name="status" class="form-select" required> <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="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="verschenkt" {% if game.status == 'verschenkt' %}selected{% endif %}>{{ _('Gifted') }}</option>
<option value="eingelöst" {% if game.status == 'eingelöst' %}selected{% endif %}>{{ _('Redeemed') }}</option> <option value="eingelöst" {% if game.status == 'eingelöst' %}selected{% endif %}>{{ _('Redeemed') }}</option>
</select> </select>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">{{ _('Redeem by') }}</label> <label for="game_redeem_date" class="form-label">{{ _('Redeem by') }}</label>
<input type="date" name="redeem_date" class="form-control" value="{{ redeem_date }}"> <input type="date" id="game_redeem_date" name="redeem_date" class="form-control" value="{{ redeem_date }}">
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">{{ _('Recipient') }}</label> <label for="game_recipient" class="form-label">{{ _('Recipient') }}</label>
<input type="text" name="recipient" class="form-control" value="{{ game.recipient }}"> <input type="text" id="game_recipient" name="recipient" class="form-control" value="{{ game.recipient }}">
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">{{ _('Shop URL') }}</label> <label for="game_url" class="form-label">{{ _('Shop URL') }}</label>
<input type="url" name="url" class="form-control" value="{{ game.url }}"> <input type="url" id="game_url" name="url" class="form-control" value="{{ game.url }}">
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">{{ _('Notes') }}</label> <label for="game_notes" class="form-label">{{ _('Notes') }}</label>
<textarea 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>
<div class="col-12"> <div class="col-12">
{% if redeem_url and active_redeem %} {% if redeem_url and active_redeem %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label">{{ _('Active Redeem Link') }}</label> <label for="active_redeem_link" class="form-label">{{ _('Active Redeem Link') }}</label>
<input type="text" <input type="text"
class="form-control" id="active_redeem_link"
value="{{ redeem_url }}" class="form-control"
readonly value="{{ redeem_url }}"
onclick="this.select()"> readonly
<small class="text-muted"> onclick="this.select()">
{{ _('Expires at') }}: {{ active_redeem.expires.strftime('%d.%m.%Y %H:%M') }} <small class="text-muted">
</small> {{ _('Expires at') }}: {{ active_redeem.expires.strftime('%d.%m.%Y %H:%M') }}
</div> </small>
{% endif %}
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">{{ _('Cancel') }}</a>
</div>
</div> </div>
</form> {% endif %}
</div>
<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

@ -5,7 +5,7 @@
</div> </div>
<div class="mb-2"> <div class="mb-2">
<a href="https://git.nocci.it/nocci/GiftGamesDB" target="_blank" rel="noopener"> <a href="https://git.nocci.it/nocci/GiftGamesDB" target="_blank" rel="noopener">
<img src="{{ url_for('static', filename='forgejo.svg') }}" alt="forgejo" width="20" style="vertical-align:middle;margin-right:4px;"> <img src="{{ url_for('static', filename='forgejo.webp') }}" alt="forgejo" width="20" style="vertical-align:middle;margin-right:4px;">
find the source code on my Forgejo find the source code on my Forgejo
</a> </a>
</div> </div>

View File

@ -5,11 +5,11 @@
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
<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">
<label class="form-label">{{ _('CSV-Datei auswählen') }}</label> <label class="form-label">{{ _('Select CSV file') }}</label>
<input type="file" name="file" class="form-control" accept=".csv" required> <input type="file" name="file" class="form-control" accept=".csv" required>
</div> </div>
<button type="submit" class="btn btn-success">{{ _('Importieren') }}</button> <button type="submit" class="btn btn-success">{{ _('Import') }}</button>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">{{ _('Abbrechen') }}</a> <a href="{{ url_for('index') }}" class="btn btn-outline-secondary">{{ _('Cancel') }}</a>
</form> </form>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -31,7 +31,19 @@
<td> <td>
{% 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" style="height:64px;max-width:120px;object-fit:cover;"> alt="Steam Header"
class="game-cover"
{% if loop.first %}fetchpriority="high"{% endif %}
width="368"
height="172"
loading="lazy">
{% elif game.url and 'gog.com' in game.url %}
<img src="{{ url_for('static', filename='gog_logo.webp') }}"
alt="GOG Logo"
class="game-cover"
width="368"
height="172"
loading="lazy">
{% endif %} {% endif %}
</td> </td>
<td>{{ game.name }}</td> <td>{{ game.name }}</td>

View File

@ -1,29 +1,43 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="row justify-content-center mt-5"> <div class="row justify-content-center">
<div class="col-md-6"> <div class="col-md-6 col-lg-4">
<div class="card shadow-sm"> <h1 class="mb-4">{{ _('Login') }}</h1>
<div class="card-body text-center"> <form method="POST" aria-label="{{ _('Login form') }}" autocomplete="on">
<img src="{{ url_for('static', filename='logo.png') }}" alt="Logo" width="266" height="206" class="mb-4" style="object-fit:contain;"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h2 class="card-title mb-4">{{ _('Login') }}</h2> <div class="mb-3">
<form method="POST"> <label for="username" class="form-label">{{ _('Username') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="text"
<div class="mb-3"> id="username"
<label class="form-label">{{ _('Username') }}</label> name="username"
<input type="text" name="username" class="form-control" required> class="form-control"
</div> required
<div class="mb-3"> autocomplete="username"
<label class="form-label">{{ _('Password') }}</label> aria-required="true"
<input type="password" name="password" class="form-control" required> autofocus>
</div> </div>
<button type="submit" class="btn btn-primary w-100">{{ _('Login') }}</button> <div class="mb-3">
</form> <label for="password" class="form-label">{{ _('Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<div class="mt-3 text-center"> <input type="password"
<a href="{{ url_for('register') }}">{{ _('No account yet? Register') }}</a> id="password"
</div> name="password"
</div> class="form-control"
</div> required
autocomplete="current-password"
aria-required="true">
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
<button type="submit" class="btn btn-primary w-100">{{ _('Login') }}</button>
</form>
<div class="mt-3 text-center">
<a href="{{ url_for('register') }}">{{ _('No account? Register here!') }}</a>
</div> </div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,24 +1,51 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="row justify-content-center mt-5"> <div class="row justify-content-center">
<div class="col-md-6"> <div class="col-md-6 col-lg-4">
<div class="card shadow-sm"> <h1 class="mb-4">{{ _('Register') }}</h1>
<div class="card-body"> <form method="POST" aria-label="{{ _('Registration form') }}" autocomplete="on">
<h2 class="card-title mb-4">{{ _('Register') }}</h2> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<form method="POST"> <div class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <label for="reg-username" class="form-label">{{ _('Username') }} <span aria-hidden="true" class="text-danger">*</span></label>
<div class="mb-3"> <input type="text"
<label class="form-label">{{ _('Username') }}</label> id="reg-username"
<input type="text" name="username" class="form-control" required> name="username"
</div> class="form-control"
<div class="mb-3"> required
<label class="form-label">{{ _('Password') }}</label> autocomplete="username"
<input type="password" name="password" class="form-control" required> aria-required="true">
</div> </div>
<button type="submit" class="btn btn-primary w-100">{{ _('Register') }}</button> <div class="mb-3">
</form> <label for="reg-password" class="form-label">{{ _('Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
</div> <input type="password"
</div> id="reg-password"
name="password"
class="form-control"
required
autocomplete="new-password"
aria-required="true">
</div>
<div class="mb-3">
<label for="reg-password2" class="form-label">{{ _('Confirm Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password"
id="reg-password2"
name="password2"
class="form-control"
required
autocomplete="new-password"
aria-required="true">
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
{{ error }}
</div>
{% endif %}
<button type="submit" class="btn btn-primary w-100">{{ _('Register') }}</button>
</form>
<div class="mt-3 text-center">
<a href="{{ url_for('login') }}">{{ _('Already have an account? Login!') }}</a>
</div> </div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,71 @@
{
"": "",
"Actions": "Aktionen",
"Active Redeem Link": "Aktiver Einlöse-Link",
"Add New Game": "Neues Spiel hinzufügen",
"Already have an account? Login!": "",
"Cancel": "Abbrechen",
"Change Password": "Passwort ändern",
"Change password form": "",
"Changes saved!": "Änderungen gespeichert!",
"Confirm New Password": "Neues Passwort bestätigen",
"Confirm Password": "",
"Cover": "Cover",
"Created": "Erstellt",
"Current Password": "Aktuelles Passwort",
"Current passwort is wrong": "Aktuelles Passwort ist falsch",
"Dark Mode": "Dunkler Modus",
"Edit Game": "Spiel bearbeiten",
"Error generating link": "Fehler beim Generieren des Links",
"Error: ": "Fehler: ",
"Expires at": "Ablaufdatum",
"Export CSV": "CSV exportieren",
"Game Key": "Spiele-Key",
"Game Key Manager": "Game-Key-Verwaltung",
"Game List (without Keys)": "Spieleliste (ohne Keys)",
"Game added successfully!": "Spiel erfolgreich hinzugefügt!",
"Generate redeem link": "Einlöse-Link generieren",
"Gifted": "Verschenkt",
"Import": "Importieren",
"Import CSV": "CSV importieren",
"Import Games": "Spiele importieren",
"Import error: %(error)s', error=str(e)), 'danger": "",
"Invalid credentials": "Ungültige Anmeldedaten",
"Key": "Key",
"Login": "Anmelden",
"Login form": "",
"Logout": "Abmelden",
"My Games": "Meine Spiele",
"Name": "Name",
"New Password": "Neues Passwort",
"New Passwords are not matching": "Neue Passwörter stimmen nicht überein",
"No account? Register here!": "",
"No games yet": "Der Kornspeicher ist leer, Sire!",
"No new registrations. They are deactivated!": "Keine neuen Registrierungen. Sie sind deaktiviert!",
"Not redeemed": "Nicht eingelöst",
"Notes": "Notizen",
"Password": "Passwort",
"Password changed successfully": "Passwort erfolgreich geändert",
"Please upload a valid CSV file.": "Bitte eine gültige CSV-Datei hochladen.",
"Really delete?": "Wirklich löschen?",
"Recipient": "Empfänger",
"Redeem by": "Einzulösen vor",
"Redeem link copied to clipboard!": "Einlöse-Link in die Zwischenablage kopiert!",
"Redeem now on": "Jetzt einlösen bei",
"Redeemed": "Eingelöst",
"Register": "Registrieren",
"Registration form": "",
"Save": "Speichern",
"Search": "Suche",
"Search games": "",
"Select CSV file": "CSV-Datei auswählen",
"Shop": "Shop",
"Shop URL": "Shop-URL",
"Status": "Status",
"Steam AppID (optional)": "Steam-AppID (optional)",
"Steam Key already exists!": "Steam-Key existiert bereits!",
"This page will expire in": "Diese Seite läuft ab in",
"Username": "Benutzername",
"Username already exists": "Benutzername existiert bereits",
"Your Key:": "Dein Key:"
}

View File

@ -0,0 +1,72 @@
{
"": "",
"Actions": "",
"Active Redeem Link": "",
"Add New Game": "",
"Already have an account? Login!": "",
"Cancel": "",
"Change Password": "",
"Change password form": "",
"Changes saved!": "",
"Confirm New Password": "",
"Confirm Password": "",
"Cover": "",
"Created": "",
"Current Password": "",
"Current passwort is wrong": "",
"Dark Mode": "",
"Edit Game": "",
"Error: ": "",
"Error generating link": "",
"Expires at": "",
"Export CSV": "",
"Game added successfully!": "",
"Game Key": "",
"Game Key Manager": "",
"Game List (without Keys)": "",
"Generate redeem link": "",
"Gifted": "",
"Import": "",
"Import CSV": "",
"Import error: %(error)s', error=str(e)), 'danger": "",
"Import Games": "",
"Invalid credentials": "",
"Key": "",
"Login": "",
"Login form": "",
"Logout": "",
"My Games": "",
"Name": "",
"%(new)d new games imported, %(dup)d skipped duplicates', new=new_games, dup=duplicates), 'success": "",
"New Password": "",
"New Passwords are not matching": "",
"No account? Register here!": "",
"No games yet": "",
"No new registrations. They are deactivated!": "",
"Notes": "",
"Not redeemed": "",
"Password": "",
"Password changed successfully": "",
"Please upload a valid CSV file.": "",
"Really delete?": "",
"Recipient": "",
"Redeem by": "",
"Redeemed": "",
"Redeem link copied to clipboard!": "",
"Redeem now on": "",
"Register": "",
"Registration form": "",
"Save": "",
"Search": "",
"Search games": "",
"Select CSV file": "",
"Shop": "",
"Shop URL": "",
"Status": "",
"Steam AppID (optional)": "",
"Steam Key already exists!": "",
"This page will expire in": "",
"Username": "",
"Username already exists": "",
"Your Key:": ""
}

View File

@ -1,28 +1,41 @@
#!/bin/bash #!/bin/bash
set -e set -e
cd "$(dirname "$0")/steam-gift-manager" APP_DIR="steam-gift-manager"
TRANSLATION_DIR="$APP_DIR/translations"
LANGS=("de" "en")
declare -A locales=( # Prüfe jq
["de"]="de" if ! command -v jq &>/dev/null; then
["en"]="en" echo "❌ jq is required. Install with: sudo apt-get install jq"
) exit 1
fi
# create POT-file # 1. Lege JSON-Dateien an, falls sie fehlen
docker-compose exec steam-manager pybabel extract -F babel.cfg -o translations/messages.pot . for lang in "${LANGS[@]}"; do
file="$TRANSLATION_DIR/$lang.json"
# Check for each language and initialize if necessary if [ ! -f "$file" ]; then
for lang in "${!locales[@]}"; do echo "{}" > "$file"
if [ ! -f "translations/${locales[$lang]}/LC_MESSAGES/messages.po" ]; then echo "Created $file"
docker-compose exec steam-manager pybabel init \
-i translations/messages.pot \
-d translations \
-l "${locales[$lang]}"
fi fi
done done
# Update and compile translations # 2. Extrahiere alle zu übersetzenden Strings
docker-compose exec steam-manager pybabel update -i translations/messages.pot -d translations STRINGS=$(grep -rhoP "_\(\s*['\"](.+?)['\"]\s*\)" \
docker-compose exec steam-manager pybabel compile -d translations "$APP_DIR/templates" "$APP_DIR/app.py" | \
sed -E "s/_\(\s*['\"](.+?)['\"]\s*\)/\1/" | sort | uniq)
echo "✅ Translations updated!" # 3. Ergänze neue Keys in die JSON-Dateien
for lang in "${LANGS[@]}"; do
file="$TRANSLATION_DIR/$lang.json"
tmp="$file.tmp"
cp "$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"
echo "Updated $file"
done
echo "✅ JSON translation files updated. Please enter your translations!"

View File

@ -1,274 +0,0 @@
# German translations for PROJECT.
# Copyright (C) 2025 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-04-29 15:53+0000\n"
"PO-Revision-Date: 2025-04-29 15:42+0000\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
"Language-Team: de <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: app.py:194
msgid "Invalid credentials"
msgstr "Ungültige Anmeldedaten"
#: app.py:200
msgid "No new registrations. They are deactivated!"
msgstr "Keine neuen Registrierungen. Sie sind deaktiviert!"
#: app.py:208
msgid "Username already exists"
msgstr "Benutzername existiert bereits"
#: app.py:234
msgid "Current passwort is wrong"
msgstr "Aktuelles Passwort ist falsch"
#: app.py:238
msgid "New Passwords are not matching"
msgstr "Neue Passwörter stimmen nicht überein"
#: app.py:243
msgid "Password changed successfully"
msgstr "Passwort erfolgreich geändert"
#: app.py:273
msgid "Game added successfully!"
msgstr "Spiel erfolgreich hinzugefügt!"
#: app.py:278
msgid "Steam Key already exists!"
msgstr "Steam-Key existiert bereits!"
#: app.py:281 app.py:325
msgid "Error: "
msgstr "Fehler: "
#: app.py:320
msgid "Changes saved!"
msgstr "Änderungen gespeichert!"
#: app.py:408
msgid "Game List (without Keys)"
msgstr "Spieleliste (ohne Keys)"
#: app.py:501
#, python-format
msgid "%(new)d new games imported, %(dup)d skipped duplicates"
msgstr "%(new)d neue Spiele importiert, %(dup)d Duplikate übersprungen"
#: app.py:505
#, python-format
msgid "Import error: %(error)s"
msgstr "Importfehler: %(error)s"
#: app.py:509
msgid "Please upload a valid CSV file."
msgstr "Bitte eine gültige CSV-Datei hochladen."
#: templates/add_game.html:4 templates/index.html:9
msgid "Add New Game"
msgstr "Neues Spiel hinzufügen"
#: templates/add_game.html:9 templates/edit_game.html:9 templates/index.html:19
msgid "Name"
msgstr "Name"
#: templates/add_game.html:13 templates/edit_game.html:13
msgid "Game Key"
msgstr "Spiele-Key"
#: templates/add_game.html:17 templates/edit_game.html:21 templates/index.html:21
msgid "Status"
msgstr "Status"
#: templates/add_game.html:19 templates/edit_game.html:23 templates/index.html:41
msgid "Not redeemed"
msgstr "Nicht eingelöst"
#: templates/add_game.html:20 templates/edit_game.html:24 templates/index.html:43
msgid "Gifted"
msgstr "Verschenkt"
#: templates/add_game.html:21 templates/edit_game.html:25 templates/index.html:45
msgid "Redeemed"
msgstr "Eingelöst"
#: templates/add_game.html:25 templates/edit_game.html:29 templates/index.html:23
msgid "Redeem by"
msgstr "Einzulösen bis"
#: templates/add_game.html:29 templates/edit_game.html:33
msgid "Recipient"
msgstr "Empfänger"
#: templates/add_game.html:33 templates/edit_game.html:37
msgid "Shop URL"
msgstr "Shop-URL"
#: templates/add_game.html:37 templates/edit_game.html:41
msgid "Notes"
msgstr "Notizen"
#: templates/add_game.html:41 templates/edit_game.html:60
msgid "Save"
msgstr "Speichern"
#: templates/add_game.html:42 templates/edit_game.html:61 templates/import.html:12
msgid "Cancel"
msgstr "Abbrechen"
#: templates/base.html:7
msgid "Game Key Manager"
msgstr "Game-Key-Verwaltung"
#: templates/base.html:23
msgid "Search"
msgstr "Suche"
#: templates/base.html:31
msgid "Dark Mode"
msgstr "Dunkler Modus"
#: templates/base.html:46 templates/login.html:16 templates/register.html:15
msgid "Password"
msgstr "Passwort"
#: templates/base.html:49
msgid "Logout"
msgstr "Abmelden"
#: templates/change_password.html:4 templates/change_password.html:19
msgid "Change Password"
msgstr "Passwort ändern"
#: templates/change_password.html:8
msgid "Current Password"
msgstr "Aktuelles Passwort"
#: templates/change_password.html:12
msgid "New Password"
msgstr "Neues Passwort"
#: templates/change_password.html:16
msgid "Confirm New Password"
msgstr "Neues Passwort bestätigen"
#: templates/edit_game.html:4
msgid "Edit Game"
msgstr "Spiel bearbeiten"
#: templates/edit_game.html:17
msgid "Steam AppID (optional)"
msgstr "Steam-AppID (optional)"
#: templates/edit_game.html:47
msgid "Active Redeem Link"
msgstr "Aktiver Einlöse-Link"
#: templates/edit_game.html:54
msgid "Expires at"
msgstr "Ablaufdatum"
#: templates/import.html:4
msgid "Import Games"
msgstr "Spiele importieren"
#: templates/import.html:8
msgid "Select CSV file"
msgstr "CSV-Datei auswählen"
#: templates/import.html:11
msgid "Import"
msgstr "Importieren"
#: templates/index.html:4
msgid "My Games"
msgstr "Meine Spiele"
#: templates/index.html:6
msgid "Export CSV"
msgstr "CSV exportieren"
#: templates/index.html:8
msgid "Import CSV"
msgstr "CSV importieren"
#: templates/index.html:18
msgid "Cover"
msgstr "Cover"
#: templates/index.html:20
msgid "Key"
msgstr "Key"
#: templates/index.html:22
msgid "Created"
msgstr "Erstellt"
#: templates/index.html:24 templates/index.html:56
msgid "Shop"
msgstr "Shop"
#: templates/index.html:25
msgid "Actions"
msgstr "Aktionen"
#: templates/index.html:63
msgid "Generate redeem link"
msgstr "Einlöse-Link generieren"
#: templates/index.html:70
msgid "Really delete?"
msgstr "Wirklich löschen?"
#: templates/index.html:96
msgid "Redeem link copied to clipboard!"
msgstr "Einlöse-Link in die Zwischenablage kopiert!"
#: templates/index.html:100
msgid "Error generating link"
msgstr "Fehler beim Generieren des Links"
#: templates/index.html:106
msgid "No games yet"
msgstr "Der Kornspeicher ist leer, Sire!"
#: templates/login.html:8 templates/login.html:19
msgid "Login"
msgstr "Anmelden"
#: templates/login.html:12 templates/register.html:11
msgid "Username"
msgstr "Benutzername"
#: templates/login.html:22
msgid "No account yet? Register"
msgstr "Noch kein Konto? Jetzt registrieren"
#: templates/redeem.html:16
msgid "Your Key:"
msgstr "Dein Key:"
#: templates/redeem.html:22
msgid "Redeem now on"
msgstr "Jetzt einlösen bei"
#: templates/redeem.html:26
msgid "This page will expire in"
msgstr "Diese Seite läuft ab in"
#: templates/register.html:7 templates/register.html:18
msgid "Register"
msgstr "Registrieren"

View File

@ -1,280 +0,0 @@
# English translations for PROJECT.
# Copyright (C) 2025 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-04-29 15:53+0000\n"
"PO-Revision-Date: 2025-04-29 15:42+0000\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n"
"Language-Team: en <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: app.py:194
msgid "Invalid credentials"
msgstr ""
#: app.py:200
msgid "No new registrations. They are deactivated!"
msgstr ""
#: app.py:208
msgid "Username already exists"
msgstr ""
#: app.py:234
msgid "Current passwort is wrong"
msgstr ""
#: app.py:238
msgid "New Passwords are not matching"
msgstr ""
#: app.py:243
msgid "Password changed successfully"
msgstr ""
#: app.py:273
msgid "Game added successfully!"
msgstr ""
#: app.py:278
msgid "Steam Key already exists!"
msgstr ""
#: app.py:281 app.py:325
msgid "Error: "
msgstr ""
#: app.py:320
msgid "Changes saved!"
msgstr ""
#: app.py:408
msgid "Game List (without Keys)"
msgstr ""
#: app.py:501
#, python-format
msgid "%(new)d new games imported, %(dup)d skipped duplicates"
msgstr ""
#: app.py:505
#, python-format
msgid "Import error: %(error)s"
msgstr ""
#: app.py:509
msgid "Please upload a valid CSV file."
msgstr ""
#: templates/add_game.html:4 templates/index.html:9
msgid "Add New Game"
msgstr ""
#: templates/add_game.html:9 templates/edit_game.html:9 templates/index.html:19
msgid "Name"
msgstr ""
#: templates/add_game.html:13 templates/edit_game.html:13
msgid "Game Key"
msgstr ""
#: templates/add_game.html:17 templates/edit_game.html:21
#: templates/index.html:21
msgid "Status"
msgstr ""
#: templates/add_game.html:19 templates/edit_game.html:23
#: templates/index.html:41
msgid "Not redeemed"
msgstr ""
#: templates/add_game.html:20 templates/edit_game.html:24
#: templates/index.html:43
msgid "Gifted"
msgstr ""
#: templates/add_game.html:21 templates/edit_game.html:25
#: templates/index.html:45
msgid "Redeemed"
msgstr ""
#: templates/add_game.html:25 templates/edit_game.html:29
#: templates/index.html:23
msgid "Redeem by"
msgstr ""
#: templates/add_game.html:29 templates/edit_game.html:33
msgid "Recipient"
msgstr ""
#: templates/add_game.html:33 templates/edit_game.html:37
msgid "Shop URL"
msgstr ""
#: templates/add_game.html:37 templates/edit_game.html:41
msgid "Notes"
msgstr ""
#: templates/add_game.html:41 templates/edit_game.html:60
msgid "Save"
msgstr ""
#: templates/add_game.html:42 templates/edit_game.html:61
#: templates/import.html:12
msgid "Cancel"
msgstr ""
#: templates/base.html:7
msgid "Game Key Manager"
msgstr ""
#: templates/base.html:23
msgid "Search"
msgstr ""
#: templates/base.html:31
msgid "Dark Mode"
msgstr ""
#: templates/base.html:46 templates/login.html:16 templates/register.html:15
msgid "Password"
msgstr ""
#: templates/base.html:49
msgid "Logout"
msgstr ""
#: templates/change_password.html:4 templates/change_password.html:19
msgid "Change Password"
msgstr ""
#: templates/change_password.html:8
msgid "Current Password"
msgstr ""
#: templates/change_password.html:12
msgid "New Password"
msgstr ""
#: templates/change_password.html:16
msgid "Confirm New Password"
msgstr ""
#: templates/edit_game.html:4
msgid "Edit Game"
msgstr ""
#: templates/edit_game.html:17
msgid "Steam AppID (optional)"
msgstr ""
#: templates/edit_game.html:47
msgid "Active Redeem Link"
msgstr ""
#: templates/edit_game.html:54
msgid "Expires at"
msgstr ""
#: templates/import.html:4
msgid "Import Games"
msgstr ""
#: templates/import.html:8
msgid "Select CSV file"
msgstr ""
#: templates/import.html:11
msgid "Import"
msgstr ""
#: templates/index.html:4
msgid "My Games"
msgstr ""
#: templates/index.html:6
msgid "Export CSV"
msgstr ""
#: templates/index.html:8
msgid "Import CSV"
msgstr ""
#: templates/index.html:18
msgid "Cover"
msgstr ""
#: templates/index.html:20
msgid "Key"
msgstr ""
#: templates/index.html:22
msgid "Created"
msgstr ""
#: templates/index.html:24 templates/index.html:56
msgid "Shop"
msgstr ""
#: templates/index.html:25
msgid "Actions"
msgstr ""
#: templates/index.html:63
msgid "Generate redeem link"
msgstr ""
#: templates/index.html:70
msgid "Really delete?"
msgstr ""
#: templates/index.html:96
msgid "Redeem link copied to clipboard!"
msgstr ""
#: templates/index.html:100
msgid "Error generating link"
msgstr ""
#: templates/index.html:106
msgid "No games yet"
msgstr ""
#: templates/login.html:8 templates/login.html:19
msgid "Login"
msgstr ""
#: templates/login.html:12 templates/register.html:11
msgid "Username"
msgstr ""
#: templates/login.html:22
msgid "No account yet? Register"
msgstr ""
#: templates/redeem.html:16
msgid "Your Key:"
msgstr ""
#: templates/redeem.html:22
msgid "Redeem now on"
msgstr ""
#: templates/redeem.html:26
msgid "This page will expire in"
msgstr ""
#: templates/register.html:7 templates/register.html:18
msgid "Register"
msgstr ""

View File

@ -1,279 +0,0 @@
# Translations template for PROJECT.
# Copyright (C) 2025 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-04-29 15:53+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: app.py:194
msgid "Invalid credentials"
msgstr ""
#: app.py:200
msgid "No new registrations. They are deactivated!"
msgstr ""
#: app.py:208
msgid "Username already exists"
msgstr ""
#: app.py:234
msgid "Current passwort is wrong"
msgstr ""
#: app.py:238
msgid "New Passwords are not matching"
msgstr ""
#: app.py:243
msgid "Password changed successfully"
msgstr ""
#: app.py:273
msgid "Game added successfully!"
msgstr ""
#: app.py:278
msgid "Steam Key already exists!"
msgstr ""
#: app.py:281 app.py:325
msgid "Error: "
msgstr ""
#: app.py:320
msgid "Changes saved!"
msgstr ""
#: app.py:408
msgid "Game List (without Keys)"
msgstr ""
#: app.py:501
#, python-format
msgid "%(new)d new games imported, %(dup)d skipped duplicates"
msgstr ""
#: app.py:505
#, python-format
msgid "Import error: %(error)s"
msgstr ""
#: app.py:509
msgid "Please upload a valid CSV file."
msgstr ""
#: templates/add_game.html:4 templates/index.html:9
msgid "Add New Game"
msgstr ""
#: templates/add_game.html:9 templates/edit_game.html:9 templates/index.html:19
msgid "Name"
msgstr ""
#: templates/add_game.html:13 templates/edit_game.html:13
msgid "Game Key"
msgstr ""
#: templates/add_game.html:17 templates/edit_game.html:21
#: templates/index.html:21
msgid "Status"
msgstr ""
#: templates/add_game.html:19 templates/edit_game.html:23
#: templates/index.html:41
msgid "Not redeemed"
msgstr ""
#: templates/add_game.html:20 templates/edit_game.html:24
#: templates/index.html:43
msgid "Gifted"
msgstr ""
#: templates/add_game.html:21 templates/edit_game.html:25
#: templates/index.html:45
msgid "Redeemed"
msgstr ""
#: templates/add_game.html:25 templates/edit_game.html:29
#: templates/index.html:23
msgid "Redeem by"
msgstr ""
#: templates/add_game.html:29 templates/edit_game.html:33
msgid "Recipient"
msgstr ""
#: templates/add_game.html:33 templates/edit_game.html:37
msgid "Shop URL"
msgstr ""
#: templates/add_game.html:37 templates/edit_game.html:41
msgid "Notes"
msgstr ""
#: templates/add_game.html:41 templates/edit_game.html:60
msgid "Save"
msgstr ""
#: templates/add_game.html:42 templates/edit_game.html:61
#: templates/import.html:12
msgid "Cancel"
msgstr ""
#: templates/base.html:7
msgid "Game Key Manager"
msgstr ""
#: templates/base.html:23
msgid "Search"
msgstr ""
#: templates/base.html:31
msgid "Dark Mode"
msgstr ""
#: templates/base.html:46 templates/login.html:16 templates/register.html:15
msgid "Password"
msgstr ""
#: templates/base.html:49
msgid "Logout"
msgstr ""
#: templates/change_password.html:4 templates/change_password.html:19
msgid "Change Password"
msgstr ""
#: templates/change_password.html:8
msgid "Current Password"
msgstr ""
#: templates/change_password.html:12
msgid "New Password"
msgstr ""
#: templates/change_password.html:16
msgid "Confirm New Password"
msgstr ""
#: templates/edit_game.html:4
msgid "Edit Game"
msgstr ""
#: templates/edit_game.html:17
msgid "Steam AppID (optional)"
msgstr ""
#: templates/edit_game.html:47
msgid "Active Redeem Link"
msgstr ""
#: templates/edit_game.html:54
msgid "Expires at"
msgstr ""
#: templates/import.html:4
msgid "Import Games"
msgstr ""
#: templates/import.html:8
msgid "Select CSV file"
msgstr ""
#: templates/import.html:11
msgid "Import"
msgstr ""
#: templates/index.html:4
msgid "My Games"
msgstr ""
#: templates/index.html:6
msgid "Export CSV"
msgstr ""
#: templates/index.html:8
msgid "Import CSV"
msgstr ""
#: templates/index.html:18
msgid "Cover"
msgstr ""
#: templates/index.html:20
msgid "Key"
msgstr ""
#: templates/index.html:22
msgid "Created"
msgstr ""
#: templates/index.html:24 templates/index.html:56
msgid "Shop"
msgstr ""
#: templates/index.html:25
msgid "Actions"
msgstr ""
#: templates/index.html:63
msgid "Generate redeem link"
msgstr ""
#: templates/index.html:70
msgid "Really delete?"
msgstr ""
#: templates/index.html:96
msgid "Redeem link copied to clipboard!"
msgstr ""
#: templates/index.html:100
msgid "Error generating link"
msgstr ""
#: templates/index.html:106
msgid "No games yet"
msgstr ""
#: templates/login.html:8 templates/login.html:19
msgid "Login"
msgstr ""
#: templates/login.html:12 templates/register.html:11
msgid "Username"
msgstr ""
#: templates/login.html:22
msgid "No account yet? Register"
msgstr ""
#: templates/redeem.html:16
msgid "Your Key:"
msgstr ""
#: templates/redeem.html:22
msgid "Redeem now on"
msgstr ""
#: templates/redeem.html:26
msgid "This page will expire in"
msgstr ""
#: templates/register.html:7 templates/register.html:18
msgid "Register"
msgstr ""