Compare commits

...

54 Commits
1.0 ... main

Author SHA1 Message Date
nocci 106c96ba47 release 1.1 2025-05-11 14:57:33 +02:00
nocci 4d37ba56dc footer fix 2025-05-11 14:53:13 +02:00
nocci 7a221acb8e Ready For 1.1 2025-05-10 16:14:22 +02:00
nocci c23f2f88e8 swap assets server 2025-05-09 17:06:26 +02:00
nocci cb53530c88 forgot to add the PWA option 2025-05-09 16:01:52 +02:00
nocci 198023b62d fix typo 2025-05-09 14:42:35 +02:00
nocci ab06550152 README fix 2025-05-09 14:39:47 +02:00
nocci adc7b15f7d upps... no need for .env keys / setup will do that 2025-05-09 14:37:05 +02:00
nocci f5b184fe54 seems to be the last RC - this app is done - so am I 2025-05-09 14:34:33 +02:00
nocci 1506201913 double redeem block deleted 2025-05-09 11:31:50 +02:00
nocci fa8d4c4b1d dual language game descriptions stored in DB 2025-05-09 10:53:09 +02:00
nocci 192d86dbd5 well... now we have a better ux in edit_game.html - :D 2025-05-08 16:54:24 +02:00
nocci 08ef5323a6 better ux in edit_game.html 2025-05-08 16:20:49 +02:00
nocci 3b3e43d9af edit_game.html fixed 2025-05-08 16:05:13 +02:00
nocci 171719a85f better handling of ITAD API and such 2025-05-08 15:31:53 +02:00
nocci 49fdd243d0 remove metacritic because i cannot get it done (no real API) - sorry 2025-05-08 12:47:24 +02:00
nocci 4522d51f47 isthereanydeal API implemented & Metascore (Metacritic) ... and more! 2025-05-07 17:52:39 +02:00
nocci e8ea813896 minor changes due problems deleting users and generating new passwords 2025-05-04 17:30:25 +02:00
nocci 602ddc7143 now we have a admin panel for the first user 2025-05-04 15:41:22 +02:00
nocci 19da1cf430 better user handling 2025-05-04 15:03:02 +02:00
nocci 4b14d042d7 add possibility to set debug-mode in .env 2025-05-04 14:50:55 +02:00
nocci 585fffd7ac disabled flask debug in docker-compose.yml 2025-05-04 14:27:56 +02:00
nocci 30c06da254 bigger fixes - switched translations, build while setup runs etc., ui more responsible 2025-05-04 14:26:04 +02:00
nocci 2966c33ea2 small fixes 2025-05-03 12:49:47 +02:00
nocci e2c218102e better handling of translations 2025-05-03 12:26:54 +02:00
nocci 4d83464963 small fix in edit route 2025-05-03 11:52:38 +02:00
nocci 4a0a5bac3f new folder structure / add de translation 2025-05-03 11:37:42 +02:00
nocci 8aba6f5129 had to change folder structure but translations are ok now 2025-05-03 11:31:56 +02:00
nocci 8ac8e839c7 next step away from babel 2025-05-02 17:25:12 +02:00
nocci 545020609a update routes etc. 2025-05-02 16:13:34 +02:00
nocci a02bed57ff remove babel stuff 2025-05-02 16:02:41 +02:00
nocci 9fde7cd75e prepare switching from babel to .json translations 2025-05-02 15:55:38 +02:00
nocci 4f23096002 https switch in .env 2025-05-02 14:52:57 +02:00
nocci 3313fb0a89 more accessibility 2025-05-02 14:49:43 +02:00
nocci 6649cd6e23 PWA ready 2025-05-02 14:07:56 +02:00
nocci 70e7afcf39 start building PWA 2025-05-02 12:53:27 +02:00
nocci 42aaa21703 css optimation 2025-05-02 12:43:28 +02:00
nocci f260da3594 make translate.sh more robust 2025-05-02 12:07:27 +02:00
nocci 3b93b14e29 update translate.sh 2025-05-02 11:15:31 +02:00
nocci 144e43d164 fixed gog pic in route 2025-05-02 11:11:09 +02:00
nocci cef1f900e6 update readme 2025-05-01 17:22:47 +02:00
nocci 8f1e388e2f switch from generic notifications to apprise 2025-05-01 17:19:58 +02:00
nocci a284ae3963 generic gog picture if gog game 2025-05-01 13:23:46 +02:00
nocci b1aee9e5b8 Urgent Fix for Translations 2025-04-29 18:02:22 +02:00
nocci fb6a2799ce Merge branch 'dev' 2025-04-29 15:21:10 +02:00
nocci d3eb37ebff more translations fixed 2025-04-29 15:19:59 +02:00
nocci c886d5f28e translations of setup.sh 2025-04-29 14:46:55 +02:00
nocci 2ea3a8059c typo in Dockerfile 2025-04-29 14:32:45 +02:00
nocci 277bd5a283 switch webserver to gunicorn 2025-04-29 14:27:55 +02:00
nocci 653d04e76a docker compose missing in setup.sh 2025-04-29 14:13:27 +02:00
nocci 894eef8657 Merge branch 'main' of https://git.nocci.it/nocci/GiftGamesDB 2025-04-29 14:00:43 +02:00
nocci 335dbdbf63 new readme bc of codeberg 2025-04-29 14:00:40 +02:00
nocci 96abe3c2b0 Merge remote-tracking branch 'origin/main' into dev 2025-04-29 13:58:27 +02:00
nocci d4e85ea44f update readme due release on codeberg 2025-04-29 12:51:23 +02:00
51 changed files with 4754 additions and 2241 deletions

21
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,21 @@
{
"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
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

BIN
GameManager1_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

207
README.md
View File

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

2766
setup.sh

File diff suppressed because it is too large Load Diff

41
steam-gift-manager/.env Normal file
View File

@ -0,0 +1,41 @@
# 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

@ -2,13 +2,25 @@ 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 \
curl \
wget \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /app/data && chown -R 1000:1000 /app/data 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
ENV TZ=
RUN ln -snf /usr/share/zoneinfo/ /etc/localtime && echo > /etc/timezone
RUN mkdir -p /app/data && \
chown -R 1000:1000 /app/data
ENV TZ=${TZ}
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
@ -18,9 +30,14 @@ COPY . .
ARG UID=1000 ARG UID=1000
ARG GID=1000 ARG GID=1000
RUN groupadd -g $GID appuser && useradd -u $UID -g $GID -m appuser && chown -R appuser:appuser /app
RUN groupadd -g ${GID} appuser && \
useradd -l -o -u ${UID} -g appuser -m appuser && \
mkdir -p /app && \
chown -R appuser:appuser /app
USER appuser USER appuser
EXPOSE 5000 EXPOSE 5000 5678
CMD ["python", "app.py"] ENTRYPOINT ["/app/entrypoint.sh"]

File diff suppressed because it is too large Load Diff

View File

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

View File

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

@ -0,0 +1,16 @@
#!/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

@ -5,12 +5,16 @@ 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
apprise
debugpy
pytz
Flask-Session
redis

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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.

After

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,37 @@
{
"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

@ -0,0 +1,34 @@
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

@ -42,7 +42,7 @@ body {
color: #ff6b6b; color: #ff6b6b;
} }
/* Progressbar-Animationen */ /* Progressbar-Animations */
#expiry-bar { #expiry-bar {
transition: width 1s linear, background-color 0.5s ease; transition: width 1s linear, background-color 0.5s ease;
} }
@ -60,3 +60,133 @@ 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;
}
.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.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View File

@ -0,0 +1,25 @@
{% 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

@ -0,0 +1,25 @@
{% 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,47 +1,94 @@
{% 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 New Game') }}</h2> <h2 class="mb-4">{{ _('Add Game') }}</h2>
<form method="POST"> {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-3">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }}">
{{ message|safe }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST" aria-label="{{ _('Add 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">
<!-- Name -->
<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 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" value="{{ request.form.name or '' }}" required>
</div> </div>
<!-- Steam Key -->
<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 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" value="{{ request.form.steam_key or '' }}" required>
</div> </div>
<div class="col-md-4">
<label class="form-label">{{ _('Status') }} *</label> <!-- Platform Dropdown -->
<select name="status" class="form-select" required> <div class="col-md-6">
<option value="nicht eingelöst">{{ _('Not redeemed') }}</option> <label for="game_platform" class="form-label">{{ _('Platform') }} <span class="text-danger">*</span></label>
<option value="verschenkt">{{ _('Gifted') }}</option> <select id="game_platform" name="platform" class="form-select" required>
<option value="eingelöst">{{ _('Redeemed') }}</option> {% for value, label in platforms %}
<option value="{{ value }}" {% if request.form.platform == value %}selected{% endif %}>
{{ _(label) }}
</option>
{% endfor %}
</select> </select>
</div> </div>
<div class="col-md-4">
<label class="form-label">{{ _('Redeem by') }}</label> <!-- Status Dropdown -->
<input type="date" name="redeem_date" class="form-control"> <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 request.form.status == value %}selected{% endif %}>
{{ _(label) }}
</option>
{% endfor %}
</select>
</div> </div>
<div class="col-md-4">
<label class="form-label">{{ _('Recipient') }}</label> <!-- Steam AppID -->
<input type="text" name="recipient" class="form-control"> <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> </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"> <div class="col-12">
<label class="form-label">{{ _('Shop URL') }}</label> <label for="game_recipient" class="form-label">{{ _('Recipient') }}</label>
<input type="url" name="url" class="form-control"> <input type="text" id="game_recipient" name="recipient" class="form-control" value="{{ request.form.recipient or '' }}">
</div> </div>
<!-- Shop URL -->
<div class="col-12"> <div class="col-12">
<label class="form-label">{{ _('Notes') }}</label> <label for="game_url" class="form-label">{{ _('Shop URL') }}</label>
<textarea name="notes" class="form-control" rows="3"></textarea> <input type="url" id="game_url" name="url" class="form-control" value="{{ request.form.url or '' }}">
</div> </div>
<!-- Notes -->
<div class="col-12"> <div class="col-12">
<button type="submit" class="btn btn-success">{{ _('Save') }}</button> <label for="game_notes" class="form-label">{{ _('Notes') }}</label>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">{{ _('Cancel') }}</a> <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>
</div> </div>
</form> </form>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,55 @@
{% 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

@ -0,0 +1,39 @@
{% 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,80 +1,140 @@
<!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"> <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 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="36" height="28" 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"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavbar" aria-controls="mainNavbar" aria-expanded="false" aria-label="Toggle navigation">
<form class="d-flex" action="{{ url_for('index') }}" method="GET"> <span class="navbar-toggler-icon"></span>
<input class="form-control me-2" </button>
type="search" <div class="collapse navbar-collapse flex-grow-1" id="mainNavbar">
name="q" <form class="d-flex ms-auto my-2 my-lg-0" action="{{ url_for('index') }}" method="GET" role="search" aria-label="{{ _('Search games') }}">
placeholder="{{ _('Search') }}" <input class="form-control me-2" type="search" name="q" id="searchInput" placeholder="{{ _('Search') }}" value="{{ search_query }}">
value="{{ search_query }}"> <button class="btn btn-outline-success" type="submit" aria-label="{{ _('Search') }}">🔍</button>
<button class="btn btn-outline-success" type="submit">🔍</button>
</form> </form>
<div class="form-check form-switch">
<input class="form-check-input"
type="checkbox"
id="darkModeSwitch" {% if theme == 'dark' %}checked{% endif %}>
<label class="form-check-label" for="darkModeSwitch">{{ _('Dark Mode') }}</label>
</div>
<div class="dropdown ms-3"> <div class="dropdown ms-3">
<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"> <div class="dropdown ms-3">
<a class="nav-link" href="{{ url_for('change_password') }}">🔒 {{ _('Passwort') }}</a> <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
</li> {{ _('Import/Export') }}
<li class="nav-item"> </button>
<a class="nav-link" href="{{ url_for('logout') }}">🚪 {{ _('Logout') }}</a> <ul class="dropdown-menu">
</li> <li><a class="dropdown-item" href="{{ url_for('export_games') }}">⬇️ {{ _('Export CSV') }}</a></li>
<li><a class="dropdown-item" href="{{ url_for('export_pdf') }}">⬇️ Export PDF (for sharing)</a></li>
<li><a class="dropdown-item" href="{{ url_for('import_games') }}">⬆️ {{ _('Import CSV') }}</a></li>
</ul>
</div>
{% if current_user.is_admin %}
<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 %} {% 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> </div>
</div> </nav>
</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> </div>
{% endfor %} {% endfor %}
</div>
{% 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.checked = (html.getAttribute('data-bs-theme') === 'dark')
toggle.addEventListener('change', function() { toggle.addEventListener('change', function() {
const theme = this.checked ? 'dark' : 'light' const theme = this.checked ? 'dark' : 'light';
fetch('/set-theme/' + theme) document.cookie = "theme=" + theme + ";path=/;max-age=31536000";
.then(() => 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" %} {% 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">
<div class="col-md-6 col-lg-5">
<div class="card p-4 shadow-sm">
<h2 class="mb-4">{{ _('Change Password') }}</h2> <h2 class="mb-4">{{ _('Change Password') }}</h2>
<form method="POST"> <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>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary ms-2">{{ _('Cancel') }}</a>
</form> </form>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,66 +1,185 @@
{% 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">{{ _('Spiel bearbeiten') }}</h2>
<form method="POST">
<!-- Flash-Nachrichten -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages mb-4">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show">
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Update Data Form (separate, outside main form, uses POST) -->
<div class="mb-3 text-end">
<form method="POST" action="{{ url_for('update_game_data', game_id=game.id) }}" id="updateDataForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Ändere die ID für Eindeutigkeit -->
<input type="hidden" name="steam_appid" id="itad_steam_appid" value="{{ game.steam_appid }}">
<button type="submit" class="btn btn-secondary">
🔄 {{ _('Update Data') }}
</button>
</form>
<script>
document.getElementById('updateDataForm').addEventListener('submit', function(e) {
e.preventDefault();
const currentAppId = document.getElementById('game_appid').value;
document.getElementById('itad_steam_appid').value = currentAppId;
this.submit();
});
</script>
</div>
<form method="POST" aria-label="{{ _('Spiel bearbeiten') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row g-3"> <div class="row g-3">
<!-- Formularfelder -->
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">{{ _('Name') }} *</label> <label class="form-label">{{ _('Name') }} <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control" value="{{ game.name }}" required> <input type="text" name="name" class="form-control" value="{{ game.name }}" required>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">{{ _('Game Key') }} *</label> <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>
<input type="text" name="steam_key" class="form-control" value="{{ game.steam_key }}" required> <input type="text" name="steam_key" class="form-control" value="{{ game.steam_key }}" required>
</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') }}</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 class="col-md-4">
<label class="form-label">{{ _('Status') }} *</label>
<select name="status" class="form-select" required>
<option value="nicht eingelöst" {% if game.status == 'nicht eingelöst' %}selected{% endif %}>{{ _('Not redeemed') }}</option>
<option value="verschenkt" {% if game.status == 'verschenkt' %}selected{% endif %}>{{ _('Gifted') }}</option>
<option value="eingelöst" {% if game.status == 'eingelöst' %}selected{% endif %}>{{ _('Redeemed') }}</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">{{ _('Redeem by') }}</label>
<input type="date" name="redeem_date" class="form-control" value="{{ redeem_date }}">
</div>
<div class="col-md-4">
<label class="form-label">{{ _('Recipient') }}</label>
<input type="text" name="recipient" class="form-control" value="{{ game.recipient }}">
</div>
<div class="col-12">
<label class="form-label">{{ _('Shop URL') }}</label>
<input type="url" name="url" class="form-control" value="{{ game.url }}">
</div>
<div class="col-12">
<label class="form-label">{{ _('Notes') }}</label>
<textarea name="notes" class="form-control" rows="3">{{ game.notes }}</textarea>
</div>
<div class="col-12">
{% if redeem_url and active_redeem %}
<div class="mb-3">
<label class="form-label">{{ _('Active Redeem Link') }}</label>
<input type="text"
class="form-control"
value="{{ redeem_url }}"
readonly
onclick="this.select()">
<small class="text-muted"> <small class="text-muted">
{{ _('Expires at') }}: {{ active_redeem.expires.strftime('%d.%m.%Y %H:%M') }} {{ _('For GOG games: Enter the Steam AppID here to enable price tracking.') }}
</small> </small>
</div> </div>
{% endif %} <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>
<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>
<!-- Einlöse-Links -->
{% if game.status == 'geschenkt' %}
<div class="col-12">
<div class="card mb-3">
<div class="card-header">{{ _('Redeem-Link') }}</div>
<div class="card-body">
{% for token in game.redeem_tokens if not token.is_expired() %}
<div class="input-group mb-3">
<input type="text" class="form-control" value="{{ url_for('redeem', token=token.token, _external=True) }}" readonly id="redeem-link-{{ loop.index }}">
<button type="button" class="btn btn-outline-secondary copy-btn" data-clipboard-target="#redeem-link-{{ loop.index }}">
{{ _('Copy') }}
</button>
</div>
<small class="text-muted">
{{ _('Expires at') }}: {{ token.expires.astimezone(local_tz).strftime('%d.%m.%Y %H:%M') }}
</small>
{% else %}
<p class="text-muted mb-0">{{ _('No active redeem links') }}</p>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Buttons -->
<div class="col-12"> <div class="col-12">
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button> <button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">{{ _('Cancel') }}</a> <a href="{{ url_for('index') }}" class="btn btn-outline-secondary ms-2">{{ _('Cancel') }}</a>
<a href="{{ url_for('game_details', game_id=game.id) }}" class="btn btn-info ms-2">🔍 {{ _('View Details') }}</a>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
<!-- Copy-JavaScript -->
<script>
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const input = document.querySelector(this.dataset.clipboardTarget);
try {
await navigator.clipboard.writeText(input.value);
this.innerHTML = '✅ {{ _("Copied!") }}';
setTimeout(() => this.innerHTML = '{{ _("Copy") }}', 2000);
} catch (err) {
this.innerHTML = '❌ {{ _("Error") }}';
setTimeout(() => this.innerHTML = '{{ _("Copy") }}', 2000);
}
});
});
</script>
{% endblock %} {% endblock %}

View File

@ -4,10 +4,18 @@
<strong>Game Key Manager</strong> &mdash; is done by nocci <strong>Game Key Manager</strong> &mdash; is done by nocci
</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://dev.skynet.li/nocci/GameKeyManager" 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>
&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>
<div> <div>
<span>feel free to donate - if you can affort it:</span> <span>feel free to donate - if you can affort it:</span>

View File

@ -0,0 +1,62 @@
{% 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

@ -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

@ -1,15 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <button id="toggle-keys" class="btn btn-sm btn-outline-secondary mb-2">{{ _('Show/Hide Keys') }}</button>
<h1>{{ _('My Games') }}</h1>
<div>
<a href="{{ url_for('export_games') }}" class="btn btn-outline-secondary">⬇️ {{ _('Export CSV') }}</a>
<a href="{{ url_for('export_pdf') }}" class="btn btn-outline-secondary">⬇️ Export PDF (for sharing)</a>
<a href="{{ url_for('import_games') }}" class="btn btn-outline-secondary">⬆️ {{ _('Import CSV') }}</a>
<a href="{{ url_for('add_game') }}" class="btn btn-primary">+ {{ _('Add New Game') }}</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">
@ -17,11 +8,12 @@
<tr> <tr>
<th>{{ _('Cover') }}</th> <th>{{ _('Cover') }}</th>
<th>{{ _('Name') }}</th> <th>{{ _('Name') }}</th>
<th>{{ _('Key') }}</th> <th class="key-col d-md-table-cell">{{ _('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>
@ -29,26 +21,40 @@
{% 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"
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 %}
</a>
</td> </td>
<td>{{ game.name }}</td> <td>{{ game.name }}</td>
<td class="font-monospace">{{ game.steam_key }}</td> <td class="font-monospace key-col d-none d-md-table-cell">{{ 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 == 'verschenkt' %} {% elif game.status == 'geschenkt' %}
<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>{{ format_date(game.created_at) }}</td> <td>{{ game.created_at|strftime('%d.%m.%Y') }}</td>
<td> <td>
{% if game.redeem_date %} {% if game.redeem_date %}
<span class="badge bg-danger">{{ format_date(game.redeem_date) }}</span> <span class="badge bg-danger">{{ game.redeem_date|strftime('%d.%m.%Y') }}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -56,9 +62,37 @@
<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 == 'verschenkt' %} {% if game.status == 'geschenkt' %}
<button class="btn btn-sm btn-success generate-redeem" <button type="button"
class="btn btn-sm btn-success generate-redeem"
data-game-id="{{ game.id }}" data-game-id="{{ game.id }}"
title="{{ _('Generate redeem link') }}"> title="{{ _('Generate redeem link') }}">
🔗 🔗
@ -75,34 +109,84 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<script> <script>
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
document.querySelectorAll('.generate-redeem').forEach(btn => { document.querySelectorAll('.generate-redeem').forEach(btn => {
btn.addEventListener('click', async function() { btn.addEventListener('click', async function() {
const gameId = this.dataset.gameId; const gameId = this.dataset.gameId;
const flashContainer = document.querySelector('.flash-container')
try { try {
const response = await fetch('/generate_redeem/' + gameId, { const response = await fetch(`/generate_redeem/${gameId}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'X-CSRFToken': csrfToken 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
} }
}); });
if (!response.ok) throw new Error('Network error');
const data = await response.json(); const data = await response.json();
if(data.url) {
await navigator.clipboard.writeText(data.url); if (!response.ok) {
alert('{{ _("Redeem link copied to clipboard!") }}'); 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) { } catch (error) {
console.error('Error:', error); // Fehlermeldung mit übersetztem Text
alert('{{ _("Error generating link") }}'); 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>
<script>
document.addEventListener('DOMContentLoaded', function() {
console.log("DOM ist geladen!"); // Überprüfe, ob DOMContentLoaded überhaupt ausgeführt wird
const toggleKeysButton = document.getElementById('toggle-keys');
if (toggleKeysButton) {
console.log("Button with ID 'toggle-keys' found!");
toggleKeysButton.addEventListener('click', function() {
console.log("Button clicked!");
const keyCols = document.querySelectorAll('.key-col');
keyCols.forEach(function(el) {
el.classList.toggle('hidden');
});
});
} else {
console.log("Button with ID 'toggle-keys' not found!");
}
});
</script>
{% else %} {% else %}
<div class="alert alert-info">{{ _('No games yet') }}</div> <div class="alert alert-info">{{ _('No games yet') }}</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -1,28 +1,50 @@
{% 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 text-center">{{ _('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="311" height="240" class="mb-4" style="object-fit:contain;">
<h2 class="card-title mb-4">{{ _('Login') }}</h2>
<form method="POST">
<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">{{ _('Username') }}</label> <label for="username" class="form-label">{{ _('Username') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="text" name="username" class="form-control" required> <input type="text"
id="username"
name="username"
class="form-control"
required
autocomplete="username"
aria-required="true"
autofocus>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">{{ _('Password') }}</label> <label for="password" class="form-label">{{ _('Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password" name="password" class="form-control" required> <input type="password"
id="password"
name="password"
class="form-control"
required
autocomplete="current-password"
aria-required="true">
</div> </div>
<button type="submit" class="btn btn-primary w-100">{{ _('Login') }}</button> <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> </form>
{% if config.REGISTRATION_ENABLED %}
<div class="mt-3 text-center"> <div class="mt-3 text-center">
<a href="{{ url_for('register') }}">{{ _('No account yet? Register') }}</a> <a href="{{ url_for('register') }}">{{ _('No account? Register here!') }}</a>
</div>
</div>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -16,11 +16,17 @@
<h4>{{ _('Your Key:') }}</h4> <h4>{{ _('Your Key:') }}</h4>
<code class="fs-3">{{ game.steam_key }}</code> <code class="fs-3">{{ game.steam_key }}</code>
</div> </div>
{% if platform_link %}
<a href="{{ platform_link }}{{ game.steam_key }}" <a href="{{ platform_link }}{{ game.steam_key }}"
class="btn btn-primary btn-lg mb-3" class="btn btn-primary btn-lg mb-3"
target="_blank"> target="_blank">
{{ _('Redeem now on') }} {% if game.steam_appid %}Steam{% else %}GOG{% endif %} {{ _('Redeem now on') }} {{ platform_name }}
</a> </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"> <div class="mt-4 text-muted">
<small> <small>
{{ _('This page will expire in') }} {{ _('This page will expire in') }}
@ -41,9 +47,10 @@
</div> </div>
<script> <script>
const totalDuration = {{ redeem_token.total_hours * 3600 * 1000 }}; // Gesamtdauer in Millisekunden const totalDuration = {{ redeem_token.total_hours * 3600 * 1000 }}; // Gesamtdauer in Millisekunden
const expires = {{ (redeem_token.expires.timestamp() * 1000) | int }}; const expires = {{ expires_timestamp }};
const countdownEl = document.getElementById('expiry-countdown'); const countdownEl = document.getElementById('expiry-countdown');
const progressBar = document.getElementById('expiry-bar'); const progressBar = document.getElementById('expiry-bar');
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
function formatTime(unit) { function formatTime(unit) {
return unit < 10 ? `0${unit}` : unit; return unit < 10 ? `0${unit}` : unit;
@ -84,7 +91,7 @@ function updateCountdown() {
updateProgressBar(percent); updateProgressBar(percent);
} }
// Initialisierung // run countdown
updateCountdown(); updateCountdown();
const timer = setInterval(updateCountdown, 1000); const timer = setInterval(updateCountdown, 1000);
</script> </script>

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>
<form method="POST">
<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">{{ _('Username') }}</label> <label for="reg-username" class="form-label">{{ _('Username') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="text" name="username" class="form-control" required> <input type="text"
id="reg-username"
name="username"
class="form-control"
required
autocomplete="username"
aria-required="true">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">{{ _('Password') }}</label> <label for="reg-password" class="form-label">{{ _('Password') }} <span aria-hidden="true" class="text-danger">*</span></label>
<input type="password" name="password" class="form-control" required> <input type="password"
id="reg-password"
name="password"
class="form-control"
required
autocomplete="new-password"
aria-required="true">
</div> </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> <button type="submit" class="btn btn-primary w-100">{{ _('Register') }}</button>
</form> </form>
</div> <div class="mt-3 text-center">
<a href="{{ url_for('login') }}">{{ _('Already have an account? Login!') }}</a>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,99 @@
{
"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

@ -0,0 +1,96 @@
{
"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:": ""
}

View File

@ -1,28 +1,38 @@
#!/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=( # check 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
# POT-Datei erstellen echo -e "\n\033[1;32m✅ Extracting translations...\033[0m"
docker-compose exec steam-manager pybabel extract -F babel.cfg -o translations/messages.pot .
# Für jede Sprache prüfen und ggf. initialisieren # 1. create json files
for lang in "${!locales[@]}"; do mkdir -p "$TRANSLATION_DIR"
if [ ! -f "translations/${locales[$lang]}/LC_MESSAGES/messages.po" ]; then for lang in "${LANGS[@]}"; do
docker-compose exec steam-manager pybabel init \ file="$TRANSLATION_DIR/$lang.json"
-i translations/messages.pot \ [ -f "$file" ] || echo "{}" > "$file"
-d translations \
-l "${locales[$lang]}"
fi
done done
# Übersetzungen aktualisieren und kompilieren # 2. extract all 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)
# 3. put da keys in da json
for lang in "${LANGS[@]}"; do
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 aktualisiert!"

View File

@ -1,287 +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-26 11:13+0000\n"
"PO-Revision-Date: 2025-04-26 11:13+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:187
msgid "Invalid credentials"
msgstr ""
#: app.py:193
msgid "Registrierungen sind deaktiviert"
msgstr ""
#: app.py:201
msgid "Username already exists"
msgstr ""
#: app.py:227
msgid "Aktuelles Passwort ist falsch"
msgstr ""
#: app.py:231
msgid "Neue Passwörter stimmen nicht überein"
msgstr ""
#: app.py:236
msgid "Passwort erfolgreich geändert"
msgstr ""
#: app.py:266
msgid "Game added successfully!"
msgstr ""
#: app.py:271
msgid "Steam Key already exists!"
msgstr ""
#: app.py:274 app.py:318
msgid "Error: "
msgstr ""
#: app.py:313
msgid "Changes saved!"
msgstr ""
#: app.py:401
msgid "Game List (without Keys)"
msgstr ""
#: app.py:494
#, python-format
msgid "%(new)d neue Spiele importiert, %(dup)d Duplikate übersprungen"
msgstr ""
#: app.py:498
#, python-format
msgid "Importfehler: %(error)s"
msgstr ""
#: app.py:502
msgid "Bitte eine gültige CSV-Datei hochladen."
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
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:44
msgid "Passwort"
msgstr ""
#: templates/base.html:47
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 "CSV-Datei auswählen"
msgstr ""
#: templates/import.html:11
msgid "Importieren"
msgstr ""
#: templates/import.html:12
msgid "Abbrechen"
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:16 templates/register.html:15
msgid "Password"
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,287 +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-26 11:13+0000\n"
"PO-Revision-Date: 2025-04-26 11:13+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:187
msgid "Invalid credentials"
msgstr ""
#: app.py:193
msgid "Registrierungen sind deaktiviert"
msgstr ""
#: app.py:201
msgid "Username already exists"
msgstr ""
#: app.py:227
msgid "Aktuelles Passwort ist falsch"
msgstr ""
#: app.py:231
msgid "Neue Passwörter stimmen nicht überein"
msgstr ""
#: app.py:236
msgid "Passwort erfolgreich geändert"
msgstr ""
#: app.py:266
msgid "Game added successfully!"
msgstr ""
#: app.py:271
msgid "Steam Key already exists!"
msgstr ""
#: app.py:274 app.py:318
msgid "Error: "
msgstr ""
#: app.py:313
msgid "Changes saved!"
msgstr ""
#: app.py:401
msgid "Game List (without Keys)"
msgstr ""
#: app.py:494
#, python-format
msgid "%(new)d neue Spiele importiert, %(dup)d Duplikate übersprungen"
msgstr ""
#: app.py:498
#, python-format
msgid "Importfehler: %(error)s"
msgstr ""
#: app.py:502
msgid "Bitte eine gültige CSV-Datei hochladen."
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
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:44
msgid "Passwort"
msgstr ""
#: templates/base.html:47
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 "CSV-Datei auswählen"
msgstr ""
#: templates/import.html:11
msgid "Importieren"
msgstr ""
#: templates/import.html:12
msgid "Abbrechen"
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:16 templates/register.html:15
msgid "Password"
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,286 +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-26 11:13+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:187
msgid "Invalid credentials"
msgstr ""
#: app.py:193
msgid "Registrierungen sind deaktiviert"
msgstr ""
#: app.py:201
msgid "Username already exists"
msgstr ""
#: app.py:227
msgid "Aktuelles Passwort ist falsch"
msgstr ""
#: app.py:231
msgid "Neue Passwörter stimmen nicht überein"
msgstr ""
#: app.py:236
msgid "Passwort erfolgreich geändert"
msgstr ""
#: app.py:266
msgid "Game added successfully!"
msgstr ""
#: app.py:271
msgid "Steam Key already exists!"
msgstr ""
#: app.py:274 app.py:318
msgid "Error: "
msgstr ""
#: app.py:313
msgid "Changes saved!"
msgstr ""
#: app.py:401
msgid "Game List (without Keys)"
msgstr ""
#: app.py:494
#, python-format
msgid "%(new)d neue Spiele importiert, %(dup)d Duplikate übersprungen"
msgstr ""
#: app.py:498
#, python-format
msgid "Importfehler: %(error)s"
msgstr ""
#: app.py:502
msgid "Bitte eine gültige CSV-Datei hochladen."
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
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:44
msgid "Passwort"
msgstr ""
#: templates/base.html:47
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 "CSV-Datei auswählen"
msgstr ""
#: templates/import.html:11
msgid "Importieren"
msgstr ""
#: templates/import.html:12
msgid "Abbrechen"
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:16 templates/register.html:15
msgid "Password"
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,22 +1,22 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Setze das Arbeitsverzeichnis auf das Projektverzeichnis # Set the working directory to the project directory
cd "$(dirname "$0")/steam-gift-manager" cd "$(dirname "$0")/steam-gift-manager"
# Setze FLASK_APP, falls nötig # set FLASK_APP, if needed
export FLASK_APP=app.py export FLASK_APP=app.py
# Initialisiere migrations, falls noch nicht vorhanden # Initialize migrations, if not yet available
if [ ! -d migrations ]; then if [ ! -d migrations ]; then
echo "Starting Flask-Migrate..." echo "Starting Flask-Migrate..."
docker-compose exec steam-manager flask db init docker-compose exec steam-manager flask db init
fi fi
# Erzeuge Migration (nur wenn sich Modelle geändert haben) # Create migration (only if models have changed)
docker-compose exec steam-manager flask db migrate -m "Automatic Migration" docker-compose exec steam-manager flask db migrate -m "Automatic Migration"
# Wende Migration an # Apply migration
docker-compose exec steam-manager flask db upgrade docker-compose exec steam-manager flask db upgrade
echo "✅ Database-Migration abgeschlossen!" echo "✅ Database migration completed!"