Compare commits

..

No commits in common. "main" and "0.1.9" have entirely different histories.
main ... 0.1.9

48 changed files with 1307 additions and 5869 deletions

21
.vscode/launch.json vendored
View file

@ -1,21 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Remote Attach",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "192.168.10.31",
"port": 5678
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "/app"
}
],
"justMyCode": true
}
]
}

BIN
GameManager.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

191
README.md
View file

@ -1,184 +1,121 @@
# 🔑 Game Key Manager 🔑 # 🗝️ Steam Key Management System 🔑
## 👋 Welcome! 👋 ![Screenshot](GameManager.png)
## Welcome! 👋
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)
![Screenshot](GameManager1_1.png)
--- ---
## ✨ Features ✨ ## ✨ Features ✨
- **Key Management:** - **Key Management:**
Enter your game keys, platform, source, and more. Enter your game keys, the corresponding game, platform, and 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:** - **Multi-user:**
Automatically fetch game descriptions, current best prices, and historical lows from [IsThereAnyDeal](https://isthereanydeal.com/) (API key required).
- **Gifting:**
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:** - **Search & Filter:**
The first registered user becomes an admin automatically. Find games quickly with the search function.
- **Admin Area:**
Admins can reset passwords, delete users, and view audit logs.
- **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):** - **No key data leaves your server!**
Import/export your game keys easily. - **(Planned):**
- **Password Management:** - Import/Export (CSV, JSON)
Users can change their passwords directly. - Redeem site with unique sharing link
- **Notifications:**
Get alerts for expiring keys via Gotify, Matrix, or Pushover.
- **Security Settings:**
Toggle CSRF protection and secure cookies in `.env`.
- **Self-hosted:**
No data leaves your server.
---
## 📱 Installable PWA
Game Key Manager now includes full Progressive Web App (PWA) support!
- Install the app on your desktop or mobile device with one click.
- Enjoy a native-app-like experience with offline access to previously loaded content.
- Add it to your home screen or applications for quicker access.
No setup required — just open the site in a modern browser (like Chrome, Edge, Firefox or Safari) and look for the install prompt or browser menu option to "Install App".
--- ---
## 🚀 Get Started ## 🚀 Get Started! 🚀
### 1. Clone the Repository ### 1. **Clone the Repository**
```bash
git clone https://codeberg.org/nocci/GameKeyManager
``` ```
git clone https://git.nocci.it/nocci/GiftGamesDB
Alternative: cd steam-gift-manager
```bash
git clone https://dev.skynet.li/nocci/GameKeyManager
``` ```
### 2. **Setup Docker**
### 2. Setup Docker Make sure you have [Docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/) installed.
Make sure Docker and docker-compose are installed. ### 3. **Initial Setup**
If not, the setup script can guide you (Arch-based distros may vary). ```
### 3. Initial Setup
```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.
```bash ### 4. **Build and Start the App**
```
cd steam-gift-manager/ cd steam-gift-manager/
docker-compose build --no-cache docker-compose build --no-cache
docker-compose up -d docker-compose up -d
``` ```
### 5. Configure `.env` File ### 5. **Initialize and Edit Translations (Optional)**
Adjust your settings:
```env
SESSION_COOKIE_SECURE="True" # Only works with HTTPS
CSRF_ENABLED="True"
ITAD_API_KEY="your_api_key" # Optional, for price data
``` ```
Apply changes after editing:
```bash
docker-compose down && docker-compose up -d --build
```
### 6. Translate (optional)
```bash
./translate.sh ./translate.sh
``` ```
Edit the .po files in steam-translations/de/LC_MESSAGES/messages.po and en/LC_MESSAGES/messages.po
Edit the `.json` files in `translations/`, then restart: ```
./translate.sh
```bash cd steam-gift-manager/
docker-compose down && docker-compose up -d --build docker-compose restart steam-manager
``` ```
### 7. Access the App ### 6. **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, and (optionally) Steam AppIDs.
- Enjoy search, status, and automatic Steam cover images!
--- ---
## 🔔 Notifications (optional) ## 🛠️ 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
- Configurable via `.env` - **Database:** SQLite (persisted in `data/`)
- **Containerization:** Docker, docker-compose
- **Translations:** Flask-Babel, editable `.po` files in `steam-translations/`
## 🌍 Multi-language
- Switch between English and German using the dropdown in the navigation bar.
- All game and menu texts are translated.
- You can add more languages by editing the `.po` files and running `./translate.sh`.
--- ---
## 🛠️ Tech Stack ## 🙌 Contribute! 🙌
- **Frontend:** Bootstrap 5, Jinja2, ... This project is open source and thrives on your help!
- **Backend:** Python 3, Flask, Flask-SQLAlchemy, ...
- **Database:** SQLite - **Bug Reports:** Please report bugs as Issues.
- **Container:** Docker, docker-compose - **Feature Requests:** Suggest new features!
- **Pull Requests:** Submit your code changes!
--- ---
## 💬 Contribute ## 📜 License 📜
Contributions are welcome: This project is licensed under the [Apache License 2.0](LICENSE).
- Report bugs
- Suggest features
- Submit Pull Requests
--- ---
## 💬 Our Base ## 💖 Acknowledgements 💖
You can find us here: [https://skynet.li](https://skynet.li) A big thank you to everyone who supports and contributes to this project!
--- ---
## 🪙 Support **Enjoy your organized Steam key collection!** 🚀
Like the project? You can support me:
[![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)
---
## 📜 License
Licensed under [Apache License 2.0](LICENSE).
---
**Enjoy managing your game collection!**

3275
setup.sh

File diff suppressed because it is too large Load diff

View file

@ -1,41 +0,0 @@
# Flask-Configuration
SECRET_KEY="1dc3d95006f7466670ac2d705ce43dc4a5ad8e2189dbe539"
REDEEM_SECRET="a50a961667ded234b1e59532ab7e27e1"
WTF_CSRF_SECRET_KEY="845ae46bd1bea30311e98df232d78b4e"
# 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

@ -1,26 +1,10 @@
FROM python:3.10-slim FROM python:3.10-slim
# Shell explizit setzen
SHELL ["/bin/bash", "-c"] SHELL ["/bin/bash", "-c"]
RUN apt-get update && apt-get install -y --no-install-recommends \ # Datenbankordner erstellen und Berechtigungen setzen
curl \ RUN mkdir -p /app/data && chmod -R a+rwX /app/data
wget \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y locales && \
sed -i '/de_DE.UTF-8/s/^# //' /etc/locale.gen && \
locale-gen
ENV LC_ALL=de_DE.UTF-8 LANG=de_DE.UTF-8
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 .
@ -31,13 +15,9 @@ COPY . .
ARG UID=1000 ARG UID=1000
ARG GID=1000 ARG GID=1000
RUN groupadd -g ${GID} appuser && \ RUN groupadd -g $GID appuser && useradd -u $UID -g $GID -m appuser && chown -R appuser:appuser /app
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 ["python", "app.py"]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
[python: **.py]
[jinja2: templates/**.html]

View file

@ -1,44 +1,13 @@
services: version: '3.8'
redis:
image: redis:alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- app-network
services:
steam-manager: steam-manager:
build: build: .
context: .
args:
- UID=0
- GID=1000
ports: ports:
- "5000:5000" - "5000:5000"
- "5678:5678"
env_file:
- .env
environment:
- REDIS_URL=redis://redis:6379/0
volumes: volumes:
- ../data:/app/data - /root/test/data:/app/data
- ./translations:/app/translations:rw - /root/test/steam-translations:/app/translations
- ./static:/app/static:rw environment:
user: "0:1000" - FLASK_DEBUG=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

@ -1,20 +1,7 @@
flask flask
flask-login flask-login
flask-wtf
flask-migrate
werkzeug werkzeug
python-dotenv python-dotenv
flask-sqlalchemy flask-sqlalchemy
flask-babel
jinja2<3.1.0 jinja2<3.1.0
itsdangerous
sqlalchemy
apscheduler
reportlab
requests
pillow
gunicorn
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

@ -1,37 +0,0 @@
{
"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/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/webp",
"purpose": "any"
},
{
"src": "/static/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#3f3a3a",
"display": "standalone"
}

View file

@ -1,34 +0,0 @@
const CACHE_NAME = 'game-key-manager-v2';
const ASSETS = [
'/',
'/static/style.css',
'/static/logo.webp',
'/static/web-app-manifest-512x512.png',
'/static/web-app-manifest-192x192.png',
'/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

@ -31,162 +31,3 @@ body {
font-size: 0.9em; font-size: 0.9em;
font-weight: 500; font-weight: 500;
} }
#expiry-countdown {
font-weight: 600;
letter-spacing: 0.05em;
color: #dc3545;
transition: color 0.3s ease;
}
[data-bs-theme="dark"] #expiry-countdown {
color: #ff6b6b;
}
/* Progressbar-Animations */
#expiry-bar {
transition: width 1s linear, background-color 0.5s ease;
}
.bg-success { background-color: #198754 !important; }
.bg-warning { background-color: #ffc107 !important; }
.bg-danger { background-color: #dc3545 !important; }
.progress-bar {
transition: width 1s linear, background-color 0.3s ease;
}
.table-pdf {
font-size: 0.8em;
}
.table-pdf td, .table-pdf th {
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;
}
.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;
}
.key-col.hidden {
display: none !important;
}
@media (max-width: 768px) {
.key-col {
display: none;
}
}
.navbar .btn,
.navbar .dropdown-toggle,
.navbar .nav-link {
min-height: 40px;
line-height: 1.5 !important;
padding-top: 6px;
padding-bottom: 6px;
display: flex;
align-items: center;
font-size: 0.95em;
}
.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,46 @@
{% 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">
{% if messages %} <div class="row g-3">
<div class="mb-3"> <div class="col-md-6">
{% for category, message in messages %} <label class="form-label">{{ _('Name') }} *</label>
<div class="alert alert-{{ 'danger' if category == 'error' else category }}"> <input type="text" name="name" class="form-control" required>
{{ message|safe }} </div>
</div> <div class="col-md-6">
{% endfor %} <label class="form-label">{{ _('Steam Key') }} *</label>
</div> <input type="text" name="steam_key" class="form-control" required>
{% endif %} </div>
{% endwith %} <div class="col-md-4">
<form method="POST" aria-label="{{ _('Add Game') }}"> <label class="form-label">{{ _('Status') }} *</label>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <select name="status" class="form-select" required>
<div class="row g-3"> <option value="nicht eingelöst">{{ _('Not redeemed') }}</option>
<!-- Name --> <option value="verschenkt">{{ _('Gifted') }}</option>
<div class="col-md-6"> <option value="eingelöst">{{ _('Redeemed') }}</option>
<label for="game_name" class="form-label">{{ _('Name') }} <span class="text-danger">*</span></label> </select>
<input type="text" id="game_name" name="name" class="form-control" value="{{ request.form.name or '' }}" required> </div>
</div> <div class="col-md-4">
<label class="form-label">{{ _('Redeem by') }}</label>
<!-- Steam Key --> <input type="date" name="redeem_date" class="form-control">
<div class="col-md-6"> </div>
<label for="game_key" class="form-label">{{ _('Game Key') }} <span class="text-danger">*</span></label> <div class="col-md-4">
<input type="text" id="game_key" name="steam_key" class="form-control" value="{{ request.form.steam_key or '' }}" required> <label class="form-label">{{ _('Recipient') }}</label>
</div> <input type="text" name="recipient" class="form-control">
</div>
<!-- Platform Dropdown --> <div class="col-12">
<div class="col-md-6"> <label class="form-label">{{ _('Shop URL') }}</label>
<label for="game_platform" class="form-label">{{ _('Platform') }} <span class="text-danger">*</span></label> <input type="url" name="url" class="form-control">
<select id="game_platform" name="platform" class="form-select" required> </div>
{% for value, label in platforms %} <div class="col-12">
<option value="{{ value }}" {% if request.form.platform == value %}selected{% endif %}> <label class="form-label">{{ _('Notes') }}</label>
{{ _(label) }} <textarea name="notes" class="form-control" rows="3"></textarea>
</option> </div>
{% endfor %} <div class="col-12">
</select> <button type="submit" class="btn btn-success">{{ _('Save') }}</button>
</div> <a href="{{ url_for('index') }}" class="btn btn-outline-secondary">{{ _('Cancel') }}</a>
</div>
<!-- Status Dropdown --> </div>
<div class="col-md-6"> </form>
<label for="game_status" class="form-label">{{ _('Status') }} <span class="text-danger">*</span></label>
<select id="game_status" name="status" class="form-select" required>
{% for value, label in statuses %}
<option value="{{ value }}" {% if request.form.status == value %}selected{% endif %}>
{{ _(label) }}
</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

@ -1,141 +1,81 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ session.get('lang', 'en') }}" data-bs-theme="{{ theme }}"> <html lang="{{ get_locale() }}" 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() }}"> <title>{{ _('Steam Manager') }}</title>
<meta name="description" content="Manage your Steam and GOG keys efficiently. Track redemption dates, share games, and export lists."> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<meta name="theme-color" content="#212529">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<title>{{ _('Game Key Manager') }}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- 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>
<!-- My Styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% 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" href="/">{{ _('Steam Manager') }}</a>
<img src="{{ url_for('static', filename='logo_small.webp') }}" alt="Logo" width="36" height="28" style="object-fit:contain; border-radius:8px;"> <div class="d-flex align-items-center gap-3">
<span>Game Key Manager</span> <form class="d-flex" action="{{ url_for('index') }}" method="GET">
</a> <input class="form-control me-2"
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavbar" aria-controls="mainNavbar" aria-expanded="false" aria-label="Toggle navigation"> type="search"
<span class="navbar-toggler-icon"></span> name="q"
</button> placeholder="{{ _('Search') }}"
<div class="collapse navbar-collapse flex-grow-1" id="mainNavbar"> value="{{ search_query }}">
<form class="d-flex ms-auto my-2 my-lg-0" action="{{ url_for('index') }}" method="GET" role="search" aria-label="{{ _('Search games') }}"> <button class="btn btn-outline-success" type="submit">🔍</button>
<input class="form-control me-2" type="search" name="q" id="searchInput" placeholder="{{ _('Search') }}" value="{{ search_query }}"> </form>
<button class="btn btn-outline-success" type="submit" aria-label="{{ _('Search') }}">🔍</button> <div class="form-check form-switch">
</form> <input class="form-check-input"
<div class="dropdown ms-3"> type="checkbox"
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> id="darkModeSwitch" {% if theme == 'dark' %}checked{% endif %}>
{% if session.get('lang', 'en') == 'de' %} Deutsch {% elif session.get('lang', 'en') == 'en' %} English {% else %} Sprache {% endif %} <label class="form-check-label" for="darkModeSwitch">{{ _('Dark Mode') }}</label>
</button> </div>
<ul class="dropdown-menu"> <!-- Sprachumschalter -->
<li><a class="dropdown-item {% if session.get('lang', 'en') == 'de' %}active{% endif %}" href="{{ url_for('set_lang', lang='de') }}">Deutsch</a></li> <div class="dropdown ms-3">
<li><a class="dropdown-item {% if session.get('lang', 'en') == 'en' %}active{% endif %}" href="{{ url_for('set_lang', lang='en') }}">English</a></li> <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
</ul> {% if get_locale() == 'de' %} Deutsch {% elif get_locale() == 'en' %} English {% else %} Sprache {% endif %}
</div> </button>
{% if current_user.is_authenticated %} <ul class="dropdown-menu">
<div class="dropdown ms-3"> <li>
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="dropdown-item {% if get_locale() == 'de' %}active{% endif %}" href="{{ url_for('set_lang', lang='de') }}">
{{ _('Import/Export') }} Deutsch
</button> </a>
<ul class="dropdown-menu"> </li>
<li><a class="dropdown-item" href="{{ url_for('export_games') }}">⬇️ {{ _('Export CSV') }}</a></li> <li>
<li><a class="dropdown-item" href="{{ url_for('export_pdf') }}">⬇️ Export PDF (for sharing)</a></li> <a class="dropdown-item {% if get_locale() == 'en' %}active{% endif %}" href="{{ url_for('set_lang', lang='en') }}">
<li><a class="dropdown-item" href="{{ url_for('import_games') }}">⬆️ {{ _('Import CSV') }}</a></li> English
</ul> </a>
</li>
</ul>
</div>
{% if current_user.is_authenticated %}
<a href="{{ url_for('logout') }}" class="btn btn-danger ms-3">{{ _('Logout') }}</a>
{% endif %}
</div>
</div> </div>
{% if current_user.is_admin %} </nav>
<a class="btn btn-outline-secondary ms-3" href="{{ url_for('admin_users') }}">⚙️ {{ _('Admin') }}</a>
<a class="btn btn-outline-secondary ms-1" href="{{ url_for('admin_audit_logs') }}">📜 {{ _('Audit Logs') }}</a>
{% endif %}
<a class="btn btn-outline-secondary ms-3" href="{{ url_for('change_password') }}">🔒 {{ _('Password') }}</a>
<a class="btn btn-outline-danger ms-1" href="{{ url_for('logout') }}">🚪 {{ _('Logout') }}</a>
{% 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 %}
</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(() => {
document.cookie = "theme=" + theme + ";path=/;max-age=31536000"; html.setAttribute('data-bs-theme', theme);
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" %}
</body> </body>
</html> </html>

View file

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

View file

@ -1,190 +1,50 @@
{% 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">
<!-- Flash-Messages --> <div class="row g-3">
{% with messages = get_flashed_messages(with_categories=true) %} <div class="col-md-6">
{% if messages %} <label class="form-label">{{ _('Name') }} *</label>
<div class="flash-messages mb-4"> <input type="text" name="name" class="form-control" value="{{ game.name }}" required>
{% for category, message in messages %} </div>
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show"> <div class="col-md-6">
{{ message|safe }} <label class="form-label">{{ _('Steam Key') }} *</label>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <input type="text" name="steam_key" class="form-control" value="{{ game.steam_key }}" required>
</div> </div>
{% endfor %} <div class="col-md-6">
</div> <label class="form-label">{{ _('Steam AppID (optional)') }}</label>
{% endif %} <input type="text" name="steam_appid" class="form-control" value="{{ game.steam_appid or '' }}">
{% endwith %} </div>
<div class="col-md-4">
<!-- Update Data Form (separate, outside main form, uses POST) --> <label class="form-label">{{ _('Status') }} *</label>
<div class="mb-3 text-end"> <select name="status" class="form-select" required>
<form method="POST" action="{{ url_for('update_game_data', game_id=game.id) }}" id="updateDataForm"> <option value="nicht eingelöst" {% if game.status == 'nicht eingelöst' %}selected{% endif %}>{{ _('Not redeemed') }}</option>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <option value="verschenkt" {% if game.status == 'verschenkt' %}selected{% endif %}>{{ _('Gifted') }}</option>
<!-- Ändere die ID für Eindeutigkeit --> <option value="eingelöst" {% if game.status == 'eingelöst' %}selected{% endif %}>{{ _('Redeemed') }}</option>
<input type="hidden" name="steam_appid" id="itad_steam_appid" value="{{ game.steam_appid }}"> </select>
<button type="submit" class="btn btn-secondary"> </div>
🔄 {{ _('Update Data') }} <div class="col-md-4">
</button> <label class="form-label">{{ _('Redeem by') }}</label>
</form> <input type="date" name="redeem_date" class="form-control" value="{{ redeem_date }}">
<script> </div>
document.getElementById('updateDataForm').addEventListener('submit', function(e) { <div class="col-md-4">
e.preventDefault(); <label class="form-label">{{ _('Recipient') }}</label>
<input type="text" name="recipient" class="form-control" value="{{ game.recipient }}">
const currentAppId = document.getElementById('game_appid').value; </div>
<div class="col-12">
document.getElementById('itad_steam_appid').value = currentAppId; <label class="form-label">{{ _('Shop URL') }}</label>
<input type="url" name="url" class="form-control" value="{{ game.url }}">
this.submit(); </div>
}); <div class="col-12">
</script> <label class="form-label">{{ _('Notes') }}</label>
</div> <textarea name="notes" class="form-control" rows="3">{{ game.notes }}</textarea>
</div>
<form method="POST" aria-label="{{ _('Spiel bearbeiten') }}"> <div class="col-12">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
<div class="row g-3"> <a href="{{ url_for('index') }}" class="btn btn-outline-secondary">{{ _('Cancel') }}</a>
<!-- Formularfelder -->
<div class="col-md-6">
<label class="form-label">{{ _('Name') }} <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control" value="{{ game.name }}" required>
</div>
<div class="col-md-6">
<label for="game_platform" class="form-label">{{ _('Platform') }} <span class="text-danger">*</span></label>
<select id="game_platform" name="platform" class="form-select" required>
{% for value, label in platforms %}
<option value="{{ value }}" {% if game.platform == value %}selected{% endif %}>{{ _(label) }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="game_status" class="form-label">{{ _('Status') }} <span class="text-danger">*</span></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>
<div class="input-group">
<input type="text" name="steam_key" class="form-control" value="{{ game.steam_key }}" id="steam-key-input" required>
<button type="button" class="btn btn-outline-secondary copy-btn" data-clipboard-target="#steam-key-input">
{{ _('Copy') }}
</button>
</div>
</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 '' }}">
<small class="text-muted">
{{ _('For GOG games: Enter the Steam AppID here to enable price tracking.') }}
</small>
</div>
<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="{{ game.redeem_date.strftime('%Y-%m-%d') if game.redeem_date else '' }}">
</div>
<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="{{ game.recipient }}">
</div>
<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="{{ game.url }}">
</div>
<div class="col-12">
<label for="game_notes" class="form-label">{{ _('Notes') }}</label>
<textarea id="game_notes" name="notes" class="form-control" rows="3">{{ game.notes }}</textarea>
</div>
<!-- Show External Data -->
<div class="col-12">
<div class="card mb-4">
<div class="card-header">
<span>🔄 {{ _('External Data') }}</span>
</div>
<div class="card-body">
{% if game.release_date %}
<div class="mb-2">
<strong>{{ _('Release Date:') }}</strong>
{{ game.release_date|strftime('%d.%m.%Y') }}
</div>
{% endif %}
{% if game.current_price %}
<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>
<!-- Redeem-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>
</div> </div>
{% endif %} </form>
<!-- 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>
<a href="{{ url_for('game_details', game_id=game.id) }}" class="btn btn-info ms-2">🔍 {{ _('View Details') }}</a>
</div>
</div>
</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,26 +0,0 @@
<footer class="mt-5 py-4 bg-body-tertiary border-top">
<div class="container text-center small text-muted">
<div class="mb-2">
<strong>Game Key Manager</strong> &mdash; is done by nocci
</div>
<div class="mb-2">
<a href="https://dev.skynet.li/nocci/GameKeyManager" target="_blank" rel="noopener">
<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
</a>
&middot;
<a href="https://skynet.li" target="_blank" rel="noopener">
skynet.li
</a>
&middot;
<a href="https://codeberg.org/nocci" target="_blank" rel="noopener">
Codeberg
</a>
</div>
<div>
<span>feel free to donate - if you can affort it:</span>
<a href="https://ko-fi.com/nocci" target="_blank" rel="noopener">Ko-fi</a> &middot;
<a href="https://liberapay.com/nocci" target="_blank" rel="noopener">Liberapay</a>
</div>
</div>
</footer>

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

@ -1,15 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="card p-4 shadow-sm">
<h2 class="mb-4">{{ _('Import Games') }}</h2>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">{{ _('Select CSV file') }}</label>
<input type="file" name="file" class="form-control" accept=".csv" required>
</div>
<button type="submit" class="btn btn-success">{{ _('Import') }}</button>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">{{ _('Cancel') }}</a>
</form>
</div>
{% endblock %}

View file

@ -1,11 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<button id="toggle-keys" class="btn btn-sm btn-outline-secondary mb-2">{{ _('Show/Hide Keys') }}</button> <div class="d-flex justify-content-between align-items-center mb-4">
<div class="mb-2 d-flex justify-content-end"> <h1>{{ _('My Games') }}</h1>
<a href="{{ url_for('add_game') }}" class="btn btn-sm btn-warning"> <a href="{{ url_for('add_game') }}" class="btn btn-primary">
{{ _('Add New Game') }} + {{ _('Add New Game') }}
</a> </a>
</div> </div>
{% if games %} {% if games %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
@ -13,12 +14,11 @@
<tr> <tr>
<th>{{ _('Cover') }}</th> <th>{{ _('Cover') }}</th>
<th>{{ _('Name') }}</th> <th>{{ _('Name') }}</th>
<th class="key-col">{{ _('Key') }}</th> <th>{{ _('Key') }}</th>
<th>{{ _('Status') }}</th> <th>{{ _('Status') }}</th>
<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>
@ -26,40 +26,26 @@
{% 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" style="height:64px;max-width:120px;object-fit:cover;">
alt="Steam Header" {% endif %}
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 %}
</a>
</td> </td>
<td>{{ game.name }}</td> <td>{{ game.name }}</td>
<td class="font-monospace key-col">{{ 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>
@ -67,45 +53,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' %}
<button type="button"
class="btn btn-sm btn-success generate-redeem"
data-game-id="{{ game.id }}"
title="{{ _('Generate redeem link') }}">
🔗
</button>
{% endif %}
<a href="{{ url_for('edit_game', game_id=game.id) }}" class="btn btn-sm btn-warning">✏️</a> <a href="{{ url_for('edit_game', game_id=game.id) }}" class="btn btn-sm btn-warning">✏️</a>
<form method="POST" action="{{ url_for('delete_game', game_id=game.id) }}" class="d-inline"> <form method="POST" action="{{ url_for('delete_game', game_id=game.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('{{ _('Really delete?') }}')">🗑️</button> <button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('{{ _('Really delete?') }}')">🗑️</button>
</form> </form>
</td> </td>
@ -114,93 +64,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<script>
document.querySelectorAll('.generate-redeem').forEach(btn => {
btn.addEventListener('click', async function() {
const gameId = this.dataset.gameId;
const flashContainer = document.querySelector('.flash-container')
try {
const response = await fetch(`/generate_redeem/${gameId}`, {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
}
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '{{ _("Unknown error") }}');
}
if (data.url) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(data.url)
.then(() => {
// Succcess ?? maybe
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(err => {
flashContainer.innerHTML = `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ _("Clipboard error") }}: ${err.message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
});
} else {
alert("Clipboard API is not supported in your browser or context.");
}
}
} catch (error) {
// Fehlermeldung mit übersetztem Text
flashContainer.innerHTML = `
<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>
`;
}
});
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const KEY_STORAGE = 'showKeys';
const toggleBtn = document.getElementById('toggle-keys');
function toggleKeys(visible) {
document.querySelectorAll('.key-col').forEach(el => {
visible ? el.classList.remove('d-none') : el.classList.add('d-none');
});
}
const savedState = localStorage.getItem(KEY_STORAGE);
const initialVisibility = savedState ? JSON.parse(savedState) : true;
toggleKeys(initialVisibility);
if (toggleBtn) {
let isVisible = initialVisibility;
toggleBtn.addEventListener('click', () => {
isVisible = !isVisible;
toggleKeys(isVisible);
localStorage.setItem(KEY_STORAGE, JSON.stringify(isVisible));
console.log(`Keys sind jetzt: ${isVisible ? 'sichtbar' : 'versteckt'}`);
console.log(`LocalStorage-Wert: ${localStorage.getItem(KEY_STORAGE)}`);
});
}
});
</script>
{% else %} {% else %}
<div class="alert alert-info">{{ _('No games yet') }}</div> <div class="alert alert-info">{{ _('No games yet') }}</div>
{% endif %} {% endif %}

View file

@ -1,51 +1,26 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center mt-5">
<div class="col-md-6 col-lg-4"> <div class="col-md-6">
<h1 class="mb-4 text-center">{{ _('Login') }}</h1> <div class="card shadow-sm">
<form method="POST" aria-label="{{ _('Login form') }}" autocomplete="on"> <div class="card-body">
<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> <div class="mb-3">
<input type="text" <label class="form-label">{{ _('Username') }}</label>
id="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>
autofocus> <button type="submit" class="btn btn-primary w-100">{{ _('Login') }}</button>
</div> </form>
<div class="mb-3"> <div class="mt-3 text-center">
<label for="password" class="form-label">{{ _('Password') }} <span aria-hidden="true" class="text-danger">*</span></label> <a href="{{ url_for('register') }}">{{ _('No account yet? Register') }}</a>
<input type="password" </div>
id="password" </div>
name="password" </div>
class="form-control"
required
autocomplete="current-password"
aria-required="true">
</div>
<div class="mb-3 form-check">
<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">
{{ error }}
</div>
{% endif %} #}
<button type="submit" class="btn btn-primary w-100 mb-3">{{ _('Login') }}</button>
</form>
{% if config.REGISTRATION_ENABLED %}
<div class="mt-3 text-center">
<a href="{{ url_for('register') }}">{{ _('No account? Register here!') }}</a>
</div> </div>
{% endif %}
</div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,98 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-5">
<div class="card shadow-lg">
<div class="row g-0">
{% if game.steam_appid %}
<div class="col-md-4">
<img src="https://cdn.cloudflare.steamstatic.com/steam/apps/{{ game.steam_appid }}/header.jpg"
class="img-fluid rounded-start" alt="Game Cover">
</div>
{% endif %}
<div class="col-md-8">
<div class="card-body">
<h1 class="card-title mb-4">{{ game.name }}</h1>
<div class="alert alert-success">
<h4>{{ _('Your Key:') }}</h4>
<code class="fs-3">{{ game.steam_key }}</code>
</div>
{% if platform_link %}
<a href="{{ platform_link }}{{ game.steam_key }}"
class="btn btn-primary btn-lg mb-3"
target="_blank">
{{ _('Redeem now on') }} {{ platform_name }}
</a>
{% else %}
<div class="alert alert-info">
{{ _('Your key:') }} <code class="fs-3">{{ game.steam_key }}</code>
</div>
{% endif %}
<div class="mt-4 text-muted">
<small>
{{ _('This page will expire in') }}
<span id="expiry-countdown" class="fw-bold"></span>
</small>
<div class="progress mt-2" style="height: 8px;">
<div id="expiry-bar"
class="progress-bar bg-danger"
role="progressbar"
style="width: 100%">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const totalDuration = {{ redeem_token.total_hours * 3600 * 1000 }}; // Gesamtdauer in Millisekunden
const expires = {{ expires_timestamp }};
const countdownEl = document.getElementById('expiry-countdown');
const progressBar = document.getElementById('expiry-bar');
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
function formatTime(unit) {
return unit < 10 ? `0${unit}` : unit;
}
function updateProgressBar(percentage) {
// Alle Farbklassen entfernen
progressBar.classList.remove('bg-success', 'bg-warning', 'bg-danger');
if (percentage > 75) {
progressBar.classList.add('bg-success');
} else if (percentage > 25) {
progressBar.classList.add('bg-warning');
} else {
progressBar.classList.add('bg-danger');
}
}
function updateCountdown() {
const now = Date.now();
const remaining = expires - now;
const percent = (remaining / totalDuration) * 100;
if (remaining < 0) {
countdownEl.innerHTML = "EXPIRED";
progressBar.style.width = "0%";
clearInterval(timer);
setTimeout(() => window.location.reload(), 5000);
return;
}
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((remaining % (1000 * 60)) / 1000);
countdownEl.innerHTML = `${formatTime(hours)}h ${formatTime(minutes)}m ${formatTime(seconds)}s`;
progressBar.style.width = `${percent}%`;
updateProgressBar(percent);
}
// run countdown
updateCountdown();
const timer = setInterval(updateCountdown, 1000);
</script>
{% endblock %}

View file

@ -1,51 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center mt-5">
<div class="col-md-6 col-lg-4"> <div class="col-md-6">
<h1 class="mb-4">{{ _('Register') }}</h1> <div class="card shadow-sm">
<form method="POST" aria-label="{{ _('Registration form') }}" autocomplete="on"> <div class="card-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <h2 class="card-title mb-4">{{ _('Register') }}</h2>
<div class="mb-3"> <form method="POST">
<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

@ -1,99 +0,0 @@
{
"Access Forbidden": "Zugriff verweigert",
"Action": "Aktion",
"Actions": "Aktionen",
"Add Game": "Spiel hinzufügen",
"Add New Game": "Neues Spiel hinzufügen",
"Admin": "Admin",
"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",
"Change Password": "Passwort ändern",
"Change password form": "Passwort ändern Formular",
"Clipboard error": "Ablagefehler",
"Confirm New Password": "Neues Passwort bestätigen",
"Confirm Password": "Passwort bestätigen",
"Copied!": "Kopiert!",
"Copy": "Kopieren",
"Cover": "Cover",
"Created": "Erstellt",
"Current Deal": "Aktuelles Angebot",
"Current Password": "Aktuelles Passwort",
"Current Price": "Aktueller Preis",
"Delete": "Löschen",
"Details": "Details",
"Edit": "Bearbeiten",
"Redeem-Link": "Einlöse-Link",
"Error": "Fehler",
"Expires at": "Läuft ab am",
"Export CSV": "CSV exportieren",
"Externe Daten": "Externe Daten",
"External Data": "Externe Daten",
"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 Description": "Spielbeschreibung",
"Game Key": "Spielschlüssel",
"Game Key Manager": "Game Key Manager",
"Generate redeem link": "Einlöse-Link generieren",
"Gifted": "Verschenkt",
"Hist. Low": "Historischer Tiefstpreis",
"Import": "Importieren",
"Import/Export": "Import/Export",
"Import CSV": "CSV importieren",
"Import Games": "Spiele importieren",
"Key": "Schlüssel",
"Link copied": "Link kopiert",
"Login": "Anmelden",
"Login form": "Anmeldeformular",
"Logout": "Abmelden",
"My Games": "Meine Spiele",
"Name": "Name",
"New Password": "Neues Passwort",
"Next": "Weiter",
"No account? Register here!": "Noch kein Konto? Hier registrieren!",
"No active redeem links": "Keine aktiven Einlöse-Links",
"No games yet": "Der Kornspeicher ist leer, Sire!",
"Notes": "Notizen",
"Not redeemed": "Nicht eingelöst",
"Now": "Jetzt",
"Password": "Passwort",
"Platform": "Plattform",
"Previous": "Zurück",
"Price": "Preis",
"Really delete?": "Wirklich löschen?",
"Recipient": "Empfänger",
"Redeem by": "Einlösen bis",
"Redeemed": "Eingelöst",
"Redeem now on": "Jetzt einlösen bei",
"Register": "Registrieren",
"Registration form": "Registrierungsformular",
"Registration is currently disabled.": "Registrierung ist derzeit deaktiviert.",
"Release Date": "Veröffentlichungsdatum",
"Remember me": "Angemeldet bleiben",
"Reset Password": "Passwort zurücksetzen",
"Save": "Speichern",
"Search": "Suchen",
"Search games": "Spiele suchen",
"Select CSV file": "CSV-Datei auswählen",
"Shop": "Shop",
"Shop URL": "Shop-URL",
"Show/Hide Keys": "Zeige/Verstecke Keys",
"Sorry, you are not allowed to access this page.": "Du bist nicht berechtigt, diese Seite zu betreten.",
"Spiel bearbeiten": "Spiel bearbeiten",
"Status": "Status",
"Steam AppID": "Steam AppID",
"Steam AppID (optional)": "Steam AppID (optional)",
"Steam Key": "Steam-Schlüssel",
"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",
"Release Date:": "Veröffentlichung:",
"View Details": "Details anzeigen",
"View on IsThereAnyDeal": "Auf IsThereAnyDeal ansehen",
"Your Key:": "Dein Key:",
"Your key:": "Dein Key"
}

View file

@ -1,96 +0,0 @@
{
"Access Forbidden": "",
"Action": "",
"Actions": "",
"Add Game": "",
"Admin": "",
"Already have an account? Login!": "",
"Audit Logs": "",
"Back to Home": "",
"Cancel": "",
"Change Password": "",
"Change password form": "",
"Clipboard error": "",
"Confirm New Password": "",
"Confirm Password": "",
"Copied!": "",
"Copy": "",
"Cover": "",
"Created": "",
"Current Deal": "",
"Current Password": "",
"Current Price": "",
"Delete": "",
"Details": "",
"Edit": "",
"Error": "",
"Expires at": "",
"Export CSV": "",
"External Data": "",
"For GOG games: Enter the Steam AppID here to enable price tracking.": "",
"Game Description": "",
"Game Key": "",
"Game Key Manager": "",
"Generate redeem link": "",
"Gifted": "",
"Hist. Low": "",
"Import": "",
"Import CSV": "",
"Import/Export": "",
"Import Games": "",
"Key": "",
"Link copied": "",
"Login": "",
"Login form": "",
"Logout": "",
"Name": "",
"New Password": "",
"Next": "",
"No account? Register here!": "",
"No active redeem links": "",
"No games yet": "",
"Notes": "",
"Not redeemed": "",
"Now": "",
"Password": "",
"Platform": "",
"Previous": "",
"Price": "",
"Really delete?": "",
"Recipient": "",
"Redeem by": "",
"Redeemed": "",
"Redeem-Link": "",
"Redeem now on": "",
"Register": "",
"Registration form": "",
"Registration is currently disabled.": "",
"Release Date": "",
"Release Date:": "",
"Remember me": "",
"Reset Password": "",
"Save": "",
"Search": "",
"Search games": "",
"Select CSV file": "",
"Shop": "",
"Shop URL": "",
"Show/Hide Keys": "",
"Sorry, you are not allowed to access this page.": "",
"Spiel bearbeiten": "",
"Status": "",
"Steam AppID": "",
"Steam AppID (optional)": "",
"Steam Key": "",
"This page will expire in": "",
"Timestamp": "",
"Unknown error": "",
"Update Data": "",
"User": "",
"User Management": "",
"Username": "",
"View Details": "",
"View on IsThereAnyDeal": "",
"Your key:": "",
"Your Key:": ""
}

Binary file not shown.

View file

@ -0,0 +1,185 @@
# 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-21 11:24+0000\n"
"PO-Revision-Date: 2025-04-21 11:24+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:93
msgid "Invalid credentials"
msgstr "Ooops. Falsche Benutzerdaten!"
#: app.py:102
msgid "Username already exists"
msgstr "Benutzer existiert bereits"
#: app.py:147
msgid "Game added successfully!"
msgstr "Spiel erfolgreich hinzugefügt!"
#: app.py:151 app.py:186
msgid "Error: "
msgstr "Ui. Ein Fehler: "
#: app.py:160 app.py:198
msgid "Not allowed!"
msgstr "Das ist nicht erlaubt!"
#: app.py:181
msgid "Changes saved!"
msgstr "Änderungen gespeichert!"
#: app.py:202
msgid "Game deleted!"
msgstr "Spiel gelöscht!"
#: app.py:205
msgid "Error deleting: "
msgstr "Fehler beim Löschen: "
#: templates/add_game.html:4 templates/index.html:6
msgid "Add New Game"
msgstr "Spiel hinzufügen"
#: templates/add_game.html:8 templates/edit_game.html:8 templates/index.html:16
msgid "Name"
msgstr "Name"
#: templates/add_game.html:12 templates/edit_game.html:12
msgid "Steam Key"
msgstr "Steam Schlüssel"
#: templates/add_game.html:16 templates/edit_game.html:20
#: templates/index.html:18
msgid "Status"
msgstr "Status"
#: templates/add_game.html:18 templates/edit_game.html:22
#: templates/index.html:38
msgid "Not redeemed"
msgstr "Nicht eingelöst"
#: templates/add_game.html:19 templates/edit_game.html:23
#: templates/index.html:40
msgid "Gifted"
msgstr "Verschenkt"
#: templates/add_game.html:20 templates/edit_game.html:24
#: templates/index.html:42
msgid "Redeemed"
msgstr "Eingelöst"
#: templates/add_game.html:24 templates/edit_game.html:28
#: templates/index.html:20
msgid "Redeem by"
msgstr "Einzulösen vor"
#: templates/add_game.html:28 templates/edit_game.html:32
msgid "Recipient"
msgstr "Empfänger*in"
#: templates/add_game.html:32 templates/edit_game.html:36
msgid "Shop URL"
msgstr "Shop URL"
#: templates/add_game.html:36 templates/edit_game.html:40
msgid "Notes"
msgstr "Notizen"
#: templates/add_game.html:40 templates/edit_game.html:44
msgid "Save"
msgstr "Gespeichert"
#: templates/add_game.html:41 templates/edit_game.html:45
msgid "Cancel"
msgstr "Abbrechen"
#: templates/base.html:6 templates/base.html:13
msgid "Steam Manager"
msgstr "Steam Spiele Manager"
#: templates/base.html:19
msgid "Search"
msgstr "Suche"
#: templates/base.html:27
msgid "Dark Mode"
msgstr "Dark Mode"
#: templates/base.html:48
msgid "Logout"
msgstr "Abmelden"
#: templates/edit_game.html:4
msgid "Edit Game"
msgstr "Spiel editieren"
#: templates/edit_game.html:16
msgid "Steam AppID (optional)"
msgstr ""
#: templates/index.html:4
msgid "My Games"
msgstr "Meine Spiele"
#: templates/index.html:15
msgid "Cover"
msgstr "Cover"
#: templates/index.html:17
msgid "Key"
msgstr "Schlüssel"
#: templates/index.html:19
msgid "Created"
msgstr "Erstellt"
#: templates/index.html:21 templates/index.html:53
msgid "Shop"
msgstr "Shop"
#: templates/index.html:22
msgid "Actions"
msgstr "Aktionen"
#: templates/index.html:59
msgid "Really delete?"
msgstr "Wirklich löschen?"
#: templates/index.html:68
msgid "No games yet"
msgstr "Noch keine Spiele vorhanden"
#: templates/login.html:7 templates/login.html:17
msgid "Login"
msgstr "Anmelden"
#: templates/login.html:10 templates/register.html:10
msgid "Username"
msgstr "Benutzername"
#: templates/login.html:14 templates/register.html:14
msgid "Password"
msgstr "Passwort"
#: templates/login.html:20
msgid "No account yet? Register"
msgstr "Noch kein Account? Hier Registrieren!"
#: templates/register.html:7 templates/register.html:17
msgid "Register"
msgstr "Registrierung"

Binary file not shown.

View file

@ -0,0 +1,185 @@
# 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-21 11:24+0000\n"
"PO-Revision-Date: 2025-04-21 11:24+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:93
msgid "Invalid credentials"
msgstr ""
#: app.py:102
msgid "Username already exists"
msgstr ""
#: app.py:147
msgid "Game added successfully!"
msgstr ""
#: app.py:151 app.py:186
msgid "Error: "
msgstr ""
#: app.py:160 app.py:198
msgid "Not allowed!"
msgstr ""
#: app.py:181
msgid "Changes saved!"
msgstr ""
#: app.py:202
msgid "Game deleted!"
msgstr ""
#: app.py:205
msgid "Error deleting: "
msgstr ""
#: templates/add_game.html:4 templates/index.html:6
msgid "Add New Game"
msgstr ""
#: templates/add_game.html:8 templates/edit_game.html:8 templates/index.html:16
msgid "Name"
msgstr ""
#: templates/add_game.html:12 templates/edit_game.html:12
msgid "Steam Key"
msgstr ""
#: templates/add_game.html:16 templates/edit_game.html:20
#: templates/index.html:18
msgid "Status"
msgstr ""
#: templates/add_game.html:18 templates/edit_game.html:22
#: templates/index.html:38
msgid "Not redeemed"
msgstr ""
#: templates/add_game.html:19 templates/edit_game.html:23
#: templates/index.html:40
msgid "Gifted"
msgstr ""
#: templates/add_game.html:20 templates/edit_game.html:24
#: templates/index.html:42
msgid "Redeemed"
msgstr ""
#: templates/add_game.html:24 templates/edit_game.html:28
#: templates/index.html:20
msgid "Redeem by"
msgstr ""
#: templates/add_game.html:28 templates/edit_game.html:32
msgid "Recipient"
msgstr ""
#: templates/add_game.html:32 templates/edit_game.html:36
msgid "Shop URL"
msgstr ""
#: templates/add_game.html:36 templates/edit_game.html:40
msgid "Notes"
msgstr ""
#: templates/add_game.html:40 templates/edit_game.html:44
msgid "Save"
msgstr ""
#: templates/add_game.html:41 templates/edit_game.html:45
msgid "Cancel"
msgstr ""
#: templates/base.html:6 templates/base.html:13
msgid "Steam Manager"
msgstr ""
#: templates/base.html:19
msgid "Search"
msgstr ""
#: templates/base.html:27
msgid "Dark Mode"
msgstr ""
#: templates/base.html:48
msgid "Logout"
msgstr ""
#: templates/edit_game.html:4
msgid "Edit Game"
msgstr ""
#: templates/edit_game.html:16
msgid "Steam AppID (optional)"
msgstr ""
#: templates/index.html:4
msgid "My Games"
msgstr ""
#: templates/index.html:15
msgid "Cover"
msgstr ""
#: templates/index.html:17
msgid "Key"
msgstr ""
#: templates/index.html:19
msgid "Created"
msgstr ""
#: templates/index.html:21 templates/index.html:53
msgid "Shop"
msgstr ""
#: templates/index.html:22
msgid "Actions"
msgstr ""
#: templates/index.html:59
msgid "Really delete?"
msgstr ""
#: templates/index.html:68
msgid "No games yet"
msgstr ""
#: templates/login.html:7 templates/login.html:17
msgid "Login"
msgstr ""
#: templates/login.html:10 templates/register.html:10
msgid "Username"
msgstr ""
#: templates/login.html:14 templates/register.html:14
msgid "Password"
msgstr ""
#: templates/login.html:20
msgid "No account yet? Register"
msgstr ""
#: templates/register.html:7 templates/register.html:17
msgid "Register"
msgstr ""

View file

@ -0,0 +1,184 @@
# 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-21 11:24+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:93
msgid "Invalid credentials"
msgstr ""
#: app.py:102
msgid "Username already exists"
msgstr ""
#: app.py:147
msgid "Game added successfully!"
msgstr ""
#: app.py:151 app.py:186
msgid "Error: "
msgstr ""
#: app.py:160 app.py:198
msgid "Not allowed!"
msgstr ""
#: app.py:181
msgid "Changes saved!"
msgstr ""
#: app.py:202
msgid "Game deleted!"
msgstr ""
#: app.py:205
msgid "Error deleting: "
msgstr ""
#: templates/add_game.html:4 templates/index.html:6
msgid "Add New Game"
msgstr ""
#: templates/add_game.html:8 templates/edit_game.html:8 templates/index.html:16
msgid "Name"
msgstr ""
#: templates/add_game.html:12 templates/edit_game.html:12
msgid "Steam Key"
msgstr ""
#: templates/add_game.html:16 templates/edit_game.html:20
#: templates/index.html:18
msgid "Status"
msgstr ""
#: templates/add_game.html:18 templates/edit_game.html:22
#: templates/index.html:38
msgid "Not redeemed"
msgstr ""
#: templates/add_game.html:19 templates/edit_game.html:23
#: templates/index.html:40
msgid "Gifted"
msgstr ""
#: templates/add_game.html:20 templates/edit_game.html:24
#: templates/index.html:42
msgid "Redeemed"
msgstr ""
#: templates/add_game.html:24 templates/edit_game.html:28
#: templates/index.html:20
msgid "Redeem by"
msgstr ""
#: templates/add_game.html:28 templates/edit_game.html:32
msgid "Recipient"
msgstr ""
#: templates/add_game.html:32 templates/edit_game.html:36
msgid "Shop URL"
msgstr ""
#: templates/add_game.html:36 templates/edit_game.html:40
msgid "Notes"
msgstr ""
#: templates/add_game.html:40 templates/edit_game.html:44
msgid "Save"
msgstr ""
#: templates/add_game.html:41 templates/edit_game.html:45
msgid "Cancel"
msgstr ""
#: templates/base.html:6 templates/base.html:13
msgid "Steam Manager"
msgstr ""
#: templates/base.html:19
msgid "Search"
msgstr ""
#: templates/base.html:27
msgid "Dark Mode"
msgstr ""
#: templates/base.html:48
msgid "Logout"
msgstr ""
#: templates/edit_game.html:4
msgid "Edit Game"
msgstr ""
#: templates/edit_game.html:16
msgid "Steam AppID (optional)"
msgstr ""
#: templates/index.html:4
msgid "My Games"
msgstr ""
#: templates/index.html:15
msgid "Cover"
msgstr ""
#: templates/index.html:17
msgid "Key"
msgstr ""
#: templates/index.html:19
msgid "Created"
msgstr ""
#: templates/index.html:21 templates/index.html:53
msgid "Shop"
msgstr ""
#: templates/index.html:22
msgid "Actions"
msgstr ""
#: templates/index.html:59
msgid "Really delete?"
msgstr ""
#: templates/index.html:68
msgid "No games yet"
msgstr ""
#: templates/login.html:7 templates/login.html:17
msgid "Login"
msgstr ""
#: templates/login.html:10 templates/register.html:10
msgid "Username"
msgstr ""
#: templates/login.html:14 templates/register.html:14
msgid "Password"
msgstr ""
#: templates/login.html:20
msgid "No account yet? Register"
msgstr ""
#: templates/register.html:7 templates/register.html:17
msgid "Register"
msgstr ""

View file

@ -1,38 +1,22 @@
#!/bin/bash #!/bin/bash
set -e set -e
APP_DIR="steam-gift-manager" cd "$(dirname "$0")/steam-gift-manager"
TRANSLATION_DIR="$APP_DIR/translations"
LANGS=("de" "en")
# check jq # 1. Extrahiere alle Texte
if ! command -v jq &>/dev/null; then docker-compose exec steam-manager pybabel extract -F babel.cfg -o translations/messages.pot .
echo "❌ jq is required. Install with: sudo apt-get install jq"
exit 1
fi
echo -e "\n\033[1;32m✅ Extracting translations...\033[0m" # 2. Initialisiere Sprachen (nur einmal nötig, danach auskommentieren)
for lang in de en; do
# 1. create json files if [ ! -f "../steam-translations/$lang/LC_MESSAGES/messages.po" ]; then
mkdir -p "$TRANSLATION_DIR" docker-compose exec steam-manager pybabel init -i translations/messages.pot -d translations -l $lang
for lang in "${LANGS[@]}"; do fi
file="$TRANSLATION_DIR/$lang.json"
[ -f "$file" ] || echo "{}" > "$file"
done done
# 2. extract all strings # 3. Aktualisiere Übersetzungen
STRINGS=$(grep -rhoP "_\(\s*['\"]((?:[^']|'[^'])*?)['\"]\s*[,)]" \ docker-compose exec steam-manager pybabel update -i translations/messages.pot -d translations
"$APP_DIR/templates" "$APP_DIR/app.py" | \
sed -E "s/_\(\s*['\"](.+?)['\"]\s*[,)]/\1/" | sort | uniq)
# 3. put da keys in da json # 4. Kompiliere Übersetzungen
for lang in "${LANGS[@]}"; do docker-compose exec steam-manager pybabel compile -d translations
file="$TRANSLATION_DIR/$lang.json"
tmp="$file.tmp"
jq --argjson keys "$(echo "$STRINGS" | jq -R . | jq -s .)" \
'reduce $keys[] as $k (.; .[$k] = (.[$k] // ""))' "$file" > "$tmp"
mv "$tmp" "$file"
done
echo -e "\n\033[1;32m✅ Done! Translation keys added.\033[0m"
echo "✅ Übersetzungen extrahiert, aktualisiert und kompiliert!"

View file

@ -1,22 +0,0 @@
#!/bin/bash
set -e
# Set the working directory to the project directory
cd "$(dirname "$0")/steam-gift-manager"
# set FLASK_APP, if needed
export FLASK_APP=app.py
# Initialize migrations, if not yet available
if [ ! -d migrations ]; then
echo "Starting Flask-Migrate..."
docker-compose exec steam-manager flask db init
fi
# Create migration (only if models have changed)
docker-compose exec steam-manager flask db migrate -m "Automatic Migration"
# Apply migration
docker-compose exec steam-manager flask db upgrade
echo "✅ Database migration completed!"