Add Docker deployment and per-player secret-link viewers.

Each player gets an isolated SQLite viewer via a unique URL without login, with landing page warnings to save the link and compose-based hosting for sharing with others.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-19 16:06:13 +02:00
parent fbc2deec45
commit f51f166fa1
14 changed files with 589 additions and 53 deletions
+12
View File
@@ -0,0 +1,12 @@
.venv/
__pycache__/
*.pyc
.git/
.gitignore
data/
*.db
fantasyidler_save.json
*.md
.dockerignore
Dockerfile
docker-compose.yml
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATA_DIR=/data
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py db.py parser.py categories.py validation.py viewers.py ./
COPY templates/ templates/
COPY static/ static/
RUN mkdir -p /data/viewers /data/uploads
VOLUME ["/data"]
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "app:app"]
+61 -13
View File
@@ -8,7 +8,8 @@ Lokaler Web-Viewer für Backups des Android-Spiels **Idle Fantasy**. Parst `fant
- **Inventar** mit Textsuche, Kategorie-Filtern, Sortierung und gruppierten Tabellen - **Inventar** mit Textsuche, Kategorie-Filtern, Sortierung und gruppierten Tabellen
- **SQLite-Verlauf** mehrere Backups importieren, Snapshots vergleichen, Coins-/Level-Charts - **SQLite-Verlauf** mehrere Backups importieren, Snapshots vergleichen, Coins-/Level-Charts
- **Import** per CLI oder Upload im Browser - **Import** per CLI oder Upload im Browser
- Läuft nur lokal (`127.0.0.1`) - **Multi-User** ohne Login jeder Spieler erhält einen eigenen Viewer über einen geheimen Link
- **Docker** für Betrieb auf einem Server
- **i18n** Englisch als Standard/Fallback, Deutsch optional; automatische Browser-Sprache oder manuelle Auswahl in der Sidebar - **i18n** Englisch als Standard/Fallback, Deutsch optional; automatische Browser-Sprache oder manuelle Auswahl in der Sidebar
## Voraussetzungen ## Voraussetzungen
@@ -32,7 +33,31 @@ pip install -r requirements.txt
python app.py fantasyidler_save.json python app.py fantasyidler_save.json
``` ```
Der Browser öffnet sich automatisch unter `http://127.0.0.1:5000`. Der Browser öffnet sich automatisch unter `http://127.0.0.1:5000/v/local/`.
### Docker (für andere Spieler hosten)
```powershell
docker compose up -d --build
```
Der Viewer ist dann unter `http://localhost:5000` erreichbar:
1. Startseite → **Meinen Viewer erstellen**
2. Persönlichen Link speichern (Bookmark) **ohne Link sind die Daten nicht wiederherstellbar** (kein Login)
3. Backups im Browser importieren
Daten liegen im Docker-Volume `viewer-data` (`/data/viewers/<id>.db`).
```powershell
# Logs
docker compose logs -f
# Stoppen
docker compose down
```
Umgebungsvariable `DATA_DIR` (Standard in Docker: `/data`) legt den Speicherort fest.
### Weitere Optionen ### Weitere Optionen
@@ -43,13 +68,31 @@ python app.py --import backup2.json
# Anderen Port, Browser nicht öffnen # Anderen Port, Browser nicht öffnen
python app.py fantasyidler_save.json --port 8080 --no-browser python app.py fantasyidler_save.json --port 8080 --no-browser
# Eigene SQLite-Datenbank # Eigene SQLite-Datenbank (Legacy, ein Datei-Modus)
python app.py --db data\meine_history.db fantasyidler_save.json python app.py --db data\meine_history.db fantasyidler_save.json
# Server für Netzwerk/Docker binden
python app.py --host 0.0.0.0 --no-browser
``` ```
### Backups im Browser importieren ### Backups im Browser importieren
Im Tab **Inventar** (Sidebar unten): **Backup importieren** wählt eine `.json`-Datei. Duplikate (gleicher Datei-Hash) werden übersprungen. Sidebar unten: **Backup importieren** wählt eine `.json`-Datei. Duplikate (gleicher Datei-Hash) werden übersprungen.
## Multi-User (ohne Login)
Jeder Viewer hat eine eigene SQLite-Datenbank unter `data/viewers/<viewer_id>.db`.
| Route | Beschreibung |
|-------|--------------|
| `GET /` | Startseite neuen Viewer anlegen |
| `POST /api/viewers` | Erstellt Viewer, liefert `{ viewer_id, url }` |
| `GET /v/<viewer_id>/` | Persönliches Dashboard |
| `GET /v/<viewer_id>/api/...` | API für diesen Viewer |
Die `viewer_id` ist ein zufälliges Token (URL-safe). Wer den Link kennt, hat Zugriff es gibt kein Passwort und keine Wiederherstellung bei verlorenem Link.
Lokale CLI-Nutzung nutzt standardmäßig den Viewer `local` (`/v/local/`).
## Sprache / i18n ## Sprache / i18n
@@ -64,28 +107,33 @@ Im Tab **Inventar** (Sidebar unten): **Backup importieren** wählt eine `.js
``` ```
idle-fantasy-viewer/ idle-fantasy-viewer/
├── app.py # Flask-Server und CLI ├── app.py # Flask-Server und CLI
├── viewers.py # Viewer-IDs und Isolation
├── parser.py # Save parsen und normalisieren ├── parser.py # Save parsen und normalisieren
├── categories.py # Item-Kategorien (Heuristiken) ├── categories.py # Item-Kategorien (Heuristiken)
├── db.py # SQLite Snapshots, Diff, Timeline ├── db.py # SQLite Snapshots, Diff, Timeline
├── Dockerfile
├── docker-compose.yml
├── requirements.txt ├── requirements.txt
├── static/ ├── static/
│ ├── i18n.js # Locale-Laden, t(), Fallback en │ ├── i18n.js # Locale-Laden, t(), Fallback en
│ ├── locales/ # en.json, de.json │ ├── locales/ # en.json, de.json
│ ├── landing.js # Startseite
│ └── app.js # Dashboard-UI │ └── app.js # Dashboard-UI
├── templates/ # HTML ├── templates/ # HTML
└── data/ # history.db (wird angelegt, gitignored) └── data/ # viewers/*.db (gitignored)
``` ```
## API (lokal) ## API
| Endpunkt | Beschreibung | | Endpunkt | Beschreibung |
|----------|--------------| |----------|--------------|
| `GET /` | Dashboard | | `GET /` | Startseite |
| `GET /api/snapshot/latest` | Neuester normalisierter Save | | `POST /api/viewers` | Neuen Viewer erstellen |
| `GET /api/snapshots` | Alle Snapshots | | `GET /v/<id>/api/snapshot/latest` | Neuester Save des Viewers |
| `GET /api/snapshots/<älter>/diff/<neuer>` | Vergleich zweier Backups | | `GET /v/<id>/api/snapshots` | Alle Snapshots |
| `GET /api/timeline` | Zeitreihe für Charts | | `GET /v/<id>/api/snapshots/<älter>/diff/<neuer>` | Vergleich |
| `POST /api/import` | JSON-Upload oder `{"path": "..."}` | | `GET /v/<id>/api/timeline` | Zeitreihe für Charts |
| `POST /v/<id>/api/import` | JSON-Upload |
## Save-Format ## Save-Format
@@ -93,7 +141,7 @@ Die Backup-Datei enthält u. a. doppelt JSON-kodierte Felder (`skillLevels`, `in
## Hinweise ## Hinweise
- `data/history.db` speichert importierte Snapshots lokal; nicht mit ins Repo committen (steht in `.gitignore`). - `data/viewers/` speichert pro Spieler eine SQLite-Datei; nicht mit ins Repo committen (steht in `.gitignore`).
- Der Viewer ist ein inoffizielles Hilfstool, nicht mit dem Spiel verbunden. - Der Viewer ist ein inoffizielles Hilfstool, nicht mit dem Spiel verbunden.
## Robustheit bei Spiel-Updates ## Robustheit bei Spiel-Updates
+115 -34
View File
@@ -1,15 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Idle Fantasy Save Viewer local Flask server.""" """Idle Fantasy Save Viewer Flask server with per-viewer secret URLs."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import os
import sys import sys
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
from flask import Flask, jsonify, render_template, request from flask import Blueprint, Flask, abort, jsonify, render_template, request
from werkzeug.utils import secure_filename
from db import ( from db import (
DEFAULT_DB, DEFAULT_DB,
@@ -22,59 +23,101 @@ from db import (
get_connection, get_connection,
timeline, timeline,
) )
from viewers import (
LOCAL_VIEWER_ID,
create_viewer,
ensure_local_viewer,
is_valid_viewer_id,
viewer_db_path,
)
app = Flask(__name__) app = Flask(__name__)
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
DB_PATH = DEFAULT_DB DB_PATH = DEFAULT_DB
@app.route("/") def get_data_dir() -> Path:
def index(): return DATA_DIR
return render_template("index.html")
@app.route("/api/snapshot/latest") def _viewer_url(viewer_id: str) -> str:
def api_latest(): base = request.host_url.rstrip("/")
data = get_latest_snapshot(db_path=DB_PATH) return f"{base}/v/{viewer_id}/"
def _resolve_viewer_db(viewer_id: str) -> Path:
if not is_valid_viewer_id(viewer_id):
abort(404)
db_path = viewer_db_path(viewer_id, get_data_dir())
if not db_path.exists():
abort(404)
return db_path
viewer_bp = Blueprint("viewer", __name__, url_prefix="/v/<viewer_id>")
@viewer_bp.route("/")
def viewer_index(viewer_id: str):
_resolve_viewer_db(viewer_id)
return render_template("index.html", viewer_id=viewer_id)
@viewer_bp.route("/api/snapshot/latest")
def api_latest(viewer_id: str):
db_path = _resolve_viewer_db(viewer_id)
data = get_latest_snapshot(db_path=db_path)
if not data: if not data:
return jsonify({"error": "No snapshots imported yet"}), 404 return jsonify({"error": "No snapshots imported yet"}), 404
return jsonify(data) return jsonify(data)
@app.route("/api/snapshot/<int:snapshot_id>") @viewer_bp.route("/api/snapshot/<int:snapshot_id>")
def api_snapshot(snapshot_id: int): def api_snapshot(viewer_id: str, snapshot_id: int):
data = get_snapshot(snapshot_id, db_path=DB_PATH) db_path = _resolve_viewer_db(viewer_id)
data = get_snapshot(snapshot_id, db_path=db_path)
if not data: if not data:
return jsonify({"error": "Snapshot not found"}), 404 return jsonify({"error": "Snapshot not found"}), 404
return jsonify(data) return jsonify(data)
@app.route("/api/snapshots") @viewer_bp.route("/api/snapshots")
def api_snapshots(): def api_snapshots(viewer_id: str):
return jsonify(list_snapshots(db_path=DB_PATH)) db_path = _resolve_viewer_db(viewer_id)
return jsonify(list_snapshots(db_path=db_path))
@app.route("/api/snapshots/<int:older_id>/diff/<int:newer_id>") @viewer_bp.route("/api/snapshots/<int:older_id>/diff/<int:newer_id>")
def api_diff(older_id: int, newer_id: int): def api_diff(viewer_id: str, older_id: int, newer_id: int):
db_path = _resolve_viewer_db(viewer_id)
try: try:
return jsonify(diff_snapshots(older_id, newer_id, db_path=DB_PATH)) return jsonify(diff_snapshots(older_id, newer_id, db_path=db_path))
except ValueError as e: except ValueError as e:
return jsonify({"error": str(e)}), 404 return jsonify({"error": str(e)}), 404
@app.route("/api/timeline") @viewer_bp.route("/api/timeline")
def api_timeline(): def api_timeline(viewer_id: str):
return jsonify(timeline(db_path=DB_PATH)) db_path = _resolve_viewer_db(viewer_id)
return jsonify(timeline(db_path=db_path))
@app.route("/api/import", methods=["POST"]) @viewer_bp.route("/api/import", methods=["POST"])
def api_import(): def api_import(viewer_id: str):
db_path = _resolve_viewer_db(viewer_id)
if "file" in request.files: if "file" in request.files:
f = request.files["file"] f = request.files["file"]
if not f.filename: if not f.filename:
return jsonify({"error": "No file selected"}), 400 return jsonify({"error": "No file selected"}), 400
tmp = Path(DB_PATH.parent) / f"_upload_{f.filename}" upload_dir = get_data_dir() / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
safe_name = secure_filename(f.filename) or "upload.json"
tmp = upload_dir / f"_upload_{viewer_id}_{safe_name}"
f.save(tmp) f.save(tmp)
try: try:
result = import_save(tmp, db_path=DB_PATH) result = import_save(tmp, db_path=db_path)
finally: finally:
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
if result.get("error"): if result.get("error"):
@@ -88,12 +131,29 @@ def api_import():
path = Path(path) path = Path(path)
if not path.exists(): if not path.exists():
return jsonify({"error": f"File not found: {path}"}), 404 return jsonify({"error": f"File not found: {path}"}), 404
result = import_save(path, db_path=DB_PATH) result = import_save(path, db_path=db_path)
if result.get("error"): if result.get("error"):
return jsonify(result), 422 return jsonify(result), 422
return jsonify(result) return jsonify(result)
app.register_blueprint(viewer_bp)
@app.route("/")
def landing():
return render_template("landing.html")
@app.post("/api/viewers")
def api_create_viewer():
viewer_id = create_viewer(get_data_dir())
return jsonify({
"viewer_id": viewer_id,
"url": _viewer_url(viewer_id),
}), 201
def _print_import_report(result: dict) -> None: def _print_import_report(result: dict) -> None:
report = result.get("import_report") or [] report = result.get("import_report") or []
if not report: if not report:
@@ -108,14 +168,31 @@ def main() -> int:
parser.add_argument("save_file", nargs="?", help="Save JSON to import on start") parser.add_argument("save_file", nargs="?", help="Save JSON to import on start")
parser.add_argument("--import", dest="import_file", metavar="FILE", help="Import save without starting server") parser.add_argument("--import", dest="import_file", metavar="FILE", help="Import save without starting server")
parser.add_argument("--port", type=int, default=5000) parser.add_argument("--port", type=int, default=5000)
parser.add_argument("--host", default="127.0.0.1", help="Bind host (use 0.0.0.0 in Docker)")
parser.add_argument("--no-browser", action="store_true") parser.add_argument("--no-browser", action="store_true")
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="SQLite database path") parser.add_argument("--db", type=Path, help="SQLite path (legacy single-file mode)")
parser.add_argument("--viewer", default=LOCAL_VIEWER_ID, help="Viewer id for CLI (default: local)")
args = parser.parse_args() args = parser.parse_args()
global DB_PATH global DATA_DIR, DB_PATH
DB_PATH = args.db DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
conn = get_connection(DB_PATH) if args.db:
DB_PATH = args.db
db_path = DB_PATH
viewer_id = None
else:
viewer_id = ensure_local_viewer(DATA_DIR) if args.viewer == LOCAL_VIEWER_ID else args.viewer
if not is_valid_viewer_id(viewer_id):
print(f"Error: invalid viewer id: {viewer_id}", file=sys.stderr)
return 1
db_path = viewer_db_path(viewer_id, DATA_DIR)
if not db_path.exists():
conn = get_connection(db_path)
init_db(conn)
conn.close()
conn = get_connection(db_path)
init_db(conn) init_db(conn)
conn.close() conn.close()
@@ -125,7 +202,7 @@ def main() -> int:
if not path.exists(): if not path.exists():
print(f"Error: file not found: {path}", file=sys.stderr) print(f"Error: file not found: {path}", file=sys.stderr)
return 1 return 1
result = import_save(path, db_path=DB_PATH) result = import_save(path, db_path=db_path)
if result.get("error"): if result.get("error"):
print(f"Import failed: {result['error']}", file=sys.stderr) print(f"Import failed: {result['error']}", file=sys.stderr)
_print_import_report(result) _print_import_report(result)
@@ -146,11 +223,15 @@ def main() -> int:
if args.import_file and not args.save_file: if args.import_file and not args.save_file:
return 0 return 0
url = f"http://127.0.0.1:{args.port}" if viewer_id:
url = f"http://{args.host}:{args.port}/v/{viewer_id}/"
else:
url = f"http://{args.host}:{args.port}/"
print(f"Starting server at {url}") print(f"Starting server at {url}")
if not args.no_browser: if not args.no_browser and args.host in ("127.0.0.1", "localhost"):
webbrowser.open(url) webbrowser.open(url)
app.run(host="127.0.0.1", port=args.port, debug=False) app.run(host=args.host, port=args.port, debug=False)
return 0
if __name__ == "__main__": if __name__ == "__main__":
+13
View File
@@ -0,0 +1,13 @@
services:
viewer:
build: .
ports:
- "5000:5000"
environment:
DATA_DIR: /data
volumes:
- viewer-data:/data
restart: unless-stopped
volumes:
viewer-data:
+1
View File
@@ -1 +1,2 @@
flask>=3.0 flask>=3.0
gunicorn>=22.0
+42 -6
View File
@@ -38,15 +38,51 @@ const CATEGORY_I18N_KEYS = {
document.addEventListener("DOMContentLoaded", init); document.addEventListener("DOMContentLoaded", init);
function apiBase() {
const vid = window.VIEWER_ID;
return vid ? `/v/${vid}/api` : "/api";
}
function viewerPageUrl() {
const vid = window.VIEWER_ID;
if (!vid) return window.location.href;
return `${window.location.origin}/v/${vid}/`;
}
async function init() { async function init() {
await I18n.init(); await I18n.init();
applyStaticI18n(); applyStaticI18n();
setupLanguage(); setupLanguage();
setupViewerBanner();
setupNav(); setupNav();
setupUpload(); setupUpload();
await loadData(); await loadData();
} }
function setupViewerBanner() {
const vid = window.VIEWER_ID;
if (!vid || vid === "local") return;
const banner = document.getElementById("viewer-link-banner");
const urlEl = document.getElementById("viewer-link-url");
const copyBtn = document.getElementById("viewer-copy-link");
const url = viewerPageUrl();
banner.hidden = false;
urlEl.textContent = url;
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(url);
const prev = copyBtn.textContent;
copyBtn.textContent = t("viewer.copied");
setTimeout(() => { copyBtn.textContent = prev; }, 2000);
} catch {
window.prompt(t("viewer.copyPrompt"), url);
}
});
}
function categoryLabel(cat) { function categoryLabel(cat) {
const key = CATEGORY_I18N_KEYS[cat]; const key = CATEGORY_I18N_KEYS[cat];
return key ? t(key) : cat; return key ? t(key) : cat;
@@ -96,7 +132,7 @@ function setupUpload() {
if (!file) return; if (!file) return;
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
const res = await fetch("/api/import", { method: "POST", body: fd }); const res = await fetch(`${apiBase()}/import`, { method: "POST", body: fd });
const result = await res.json(); const result = await res.json();
if (!res.ok || result.error) { if (!res.ok || result.error) {
showImportFailure(result); showImportFailure(result);
@@ -185,9 +221,9 @@ function renderImportReport(meta) {
async function loadData() { async function loadData() {
try { try {
const res = await fetch("/api/snapshot/latest"); const res = await fetch(`${apiBase()}/snapshot/latest`);
if (!res.ok) { if (!res.ok) {
showEmpty(t("empty.noSave")); showEmpty(window.VIEWER_ID ? t("empty.noSaveWeb") : t("empty.noSave"));
return; return;
} }
state.data = await res.json(); state.data = await res.json();
@@ -629,8 +665,8 @@ async function loadHistoryTab() {
panel.innerHTML = `<p class='loading'>${esc(t("history.loading"))}</p>`; panel.innerHTML = `<p class='loading'>${esc(t("history.loading"))}</p>`;
const [snapRes, tlRes] = await Promise.all([ const [snapRes, tlRes] = await Promise.all([
fetch("/api/snapshots"), fetch(`${apiBase()}/snapshots`),
fetch("/api/timeline"), fetch(`${apiBase()}/timeline`),
]); ]);
state.snapshots = await snapRes.json(); state.snapshots = await snapRes.json();
state.timeline = await tlRes.json(); state.timeline = await tlRes.json();
@@ -756,7 +792,7 @@ async function runDiff() {
} }
const older = Math.min(h.olderId, h.newerId); const older = Math.min(h.olderId, h.newerId);
const newer = Math.max(h.olderId, h.newerId); const newer = Math.max(h.olderId, h.newerId);
const res = await fetch(`/api/snapshots/${older}/diff/${newer}`); const res = await fetch(`${apiBase()}/snapshots/${older}/diff/${newer}`);
const diff = await res.json(); const diff = await res.json();
if (diff.error) { if (diff.error) {
el.innerHTML = `<p class='empty-state'>${esc(diff.error)}</p>`; el.innerHTML = `<p class='empty-state'>${esc(diff.error)}</p>`;
+42
View File
@@ -0,0 +1,42 @@
document.addEventListener("DOMContentLoaded", async () => {
await I18n.init();
applyStaticI18n();
setupLanguage();
setupCreate();
});
function applyStaticI18n() {
document.querySelectorAll("[data-i18n]").forEach((el) => {
el.textContent = t(el.dataset.i18n);
});
}
function setupLanguage() {
const sel = document.getElementById("locale-select");
sel.value = I18n.getPreference();
sel.addEventListener("change", async (e) => {
await I18n.setPreference(e.target.value);
applyStaticI18n();
});
}
function setupCreate() {
const btn = document.getElementById("create-viewer");
const status = document.getElementById("create-status");
btn.addEventListener("click", async () => {
btn.disabled = true;
status.hidden = false;
status.textContent = t("viewer.creating");
status.className = "landing-hint";
try {
const res = await fetch("/api/viewers", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || t("viewer.createFailed"));
window.location.href = data.url;
} catch (err) {
status.textContent = err.message;
status.className = "landing-hint landing-hint-error";
btn.disabled = false;
}
});
}
+17
View File
@@ -26,6 +26,7 @@
}, },
"empty": { "empty": {
"noSave": "Kein Save importiert. Starte mit: python app.py fantasyidler_save.json", "noSave": "Kein Save importiert. Starte mit: python app.py fantasyidler_save.json",
"noSaveWeb": "Noch kein Save importiert. Importiere ein Backup über den Button in der Sidebar.",
"loadError": "Fehler beim Laden: {message}", "loadError": "Fehler beim Laden: {message}",
"unknown": "Unbekannt", "unknown": "Unbekannt",
"none": "Keine", "none": "Keine",
@@ -174,5 +175,21 @@
"gems_jewelry": "Edelsteine & Schmuck", "gems_jewelry": "Edelsteine & Schmuck",
"potions_brews": "Tränke & Brauerei", "potions_brews": "Tränke & Brauerei",
"misc": "Sonstiges" "misc": "Sonstiges"
},
"viewer": {
"landingLead": "Erstelle deinen persönlichen Save-Viewer. Kein Konto nur ein privater Link zu deinen Daten.",
"featureDashboard": "Skills, Inventar, Quests und Verlauf",
"featureUpload": "Backups im Browser importieren",
"featurePrivate": "Deine Daten bleiben nur in deinem Viewer",
"create": "Meinen Viewer erstellen",
"creating": "Viewer wird erstellt…",
"createFailed": "Viewer konnte nicht erstellt werden",
"warningTitle": "Wichtig",
"warningBody": "Es gibt keinen Login. Dein Viewer ist nur über seinen einzigartigen Link erreichbar. Link speichern oder bookmarken ohne ihn sind deine Daten nicht wiederherstellbar.",
"linkTitle": "Dein persönlicher Link",
"linkWarning": "Link speichern es gibt keinen Login. Ohne Link sind deine Daten weg.",
"copyLink": "Link kopieren",
"copied": "Kopiert!",
"copyPrompt": "Viewer-Link kopieren:"
} }
} }
+17
View File
@@ -26,6 +26,7 @@
}, },
"empty": { "empty": {
"noSave": "No save imported. Start with: python app.py fantasyidler_save.json", "noSave": "No save imported. Start with: python app.py fantasyidler_save.json",
"noSaveWeb": "No save imported yet. Import a backup using the sidebar button.",
"loadError": "Failed to load: {message}", "loadError": "Failed to load: {message}",
"unknown": "Unknown", "unknown": "Unknown",
"none": "None", "none": "None",
@@ -174,5 +175,21 @@
"gems_jewelry": "Gems & Jewelry", "gems_jewelry": "Gems & Jewelry",
"potions_brews": "Potions & Brews", "potions_brews": "Potions & Brews",
"misc": "Misc" "misc": "Misc"
},
"viewer": {
"landingLead": "Create your personal save viewer. No account just a private link to your data.",
"featureDashboard": "Skills, inventory, quests and history",
"featureUpload": "Import backups in the browser",
"featurePrivate": "Your data stays in your viewer only",
"create": "Create my viewer",
"creating": "Creating viewer…",
"createFailed": "Could not create viewer",
"warningTitle": "Important",
"warningBody": "There is no login. Your viewer is only accessible via its unique link. Bookmark or save the link without it, your data cannot be recovered.",
"linkTitle": "Your personal link",
"linkWarning": "Save this link there is no login. Without it, your data is lost.",
"copyLink": "Copy link",
"copied": "Copied!",
"copyPrompt": "Copy your viewer link:"
} }
} }
+118
View File
@@ -597,6 +597,124 @@ tr:hover td { background: var(--bg-hover); }
.list-compact li:last-child { border-bottom: none; } .list-compact li:last-child { border-bottom: none; }
/* Landing page */
.landing-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--bg);
}
.landing-card {
width: 100%;
max-width: 520px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
}
.landing-brand { margin-bottom: 20px; }
.landing-lead {
color: var(--text-muted);
line-height: 1.5;
margin: 0 0 16px;
}
.landing-features {
margin: 0 0 24px;
padding-left: 1.2rem;
color: var(--text);
line-height: 1.6;
}
.landing-actions { margin-bottom: 20px; }
.landing-create {
width: 100%;
border: none;
cursor: pointer;
}
.landing-hint {
margin: 10px 0 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.landing-hint-error { color: #f87171; }
.landing-warning {
background: rgba(251, 191, 36, 0.08);
border: 1px solid rgba(251, 191, 36, 0.35);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 20px;
font-size: 0.88rem;
line-height: 1.5;
}
.landing-warning strong { color: #fbbf24; }
.landing-lang { margin-top: 8px; }
/* Viewer link banner */
.viewer-banner {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
padding: 12px 16px;
border-radius: var(--radius);
border: 1px solid rgba(251, 191, 36, 0.35);
background: rgba(251, 191, 36, 0.08);
}
.viewer-banner-text {
flex: 1;
min-width: 0;
}
.viewer-banner-text strong {
display: block;
color: #fbbf24;
margin-bottom: 4px;
}
.viewer-banner-warning {
margin: 0 0 8px;
font-size: 0.85rem;
color: var(--text-muted);
}
.viewer-link-url {
display: block;
word-break: break-all;
font-size: 0.8rem;
color: var(--text);
background: var(--bg);
padding: 6px 8px;
border-radius: 6px;
}
.viewer-copy-btn {
flex-shrink: 0;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-hover);
color: var(--text);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
.viewer-copy-btn:hover { background: var(--accent-dim); color: #fff; }
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {
position: relative; position: relative;
+11
View File
@@ -7,6 +7,7 @@
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script>
<script src="/static/i18n.js" defer></script> <script src="/static/i18n.js" defer></script>
<script>window.VIEWER_ID = {{ viewer_id|tojson }};</script>
<script src="/static/app.js" defer></script> <script src="/static/app.js" defer></script>
</head> </head>
<body> <body>
@@ -46,6 +47,16 @@
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
<div id="viewer-link-banner" class="viewer-banner" hidden>
<div class="viewer-banner-text">
<strong data-i18n="viewer.linkTitle">Your personal link</strong>
<p class="viewer-banner-warning" data-i18n="viewer.linkWarning">
Save this link there is no login. Without it, your data is lost.
</p>
<code id="viewer-link-url" class="viewer-link-url"></code>
</div>
<button type="button" class="viewer-copy-btn" id="viewer-copy-link" data-i18n="viewer.copyLink">Copy link</button>
</div>
<div id="import-report" class="import-report" hidden></div> <div id="import-report" class="import-report" hidden></div>
<div id="character-header" class="character-header"> <div id="character-header" class="character-header">
<span class="loading" data-i18n="app.loading">Loading save…</span> <span class="loading" data-i18n="app.loading">Loading save…</span>
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Idle Fantasy Viewer</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/i18n.js" defer></script>
<script src="/static/landing.js" defer></script>
</head>
<body class="landing-page">
<main class="landing-card">
<div class="brand landing-brand">
<span class="brand-icon"></span>
<div>
<h1 data-i18n="app.title">Idle Fantasy</h1>
<p class="subtitle" data-i18n="app.subtitle">Save Viewer</p>
</div>
</div>
<p class="landing-lead" data-i18n="viewer.landingLead">
Create your personal save viewer. No account just a private link to your data.
</p>
<ul class="landing-features">
<li data-i18n="viewer.featureDashboard">Skills, inventory, quests and history</li>
<li data-i18n="viewer.featureUpload">Import backups in the browser</li>
<li data-i18n="viewer.featurePrivate">Your data stays in your viewer only</li>
</ul>
<div class="landing-actions">
<button type="button" class="upload-btn landing-create" id="create-viewer" data-i18n="viewer.create">
Create my viewer
</button>
<p class="landing-hint" id="create-status" hidden></p>
</div>
<div class="landing-warning">
<strong data-i18n="viewer.warningTitle">Important</strong>
<p data-i18n="viewer.warningBody">
There is no login. Your viewer is only accessible via its unique link.
Bookmark or save the link without it, your data cannot be recovered.
</p>
</div>
<label class="lang-label landing-lang" for="locale-select">
<span data-i18n="settings.language">Language</span>
<select id="locale-select" class="select-input lang-select">
<option value="auto" data-i18n="settings.langAuto">Auto (browser)</option>
<option value="en" data-i18n="settings.langEn">English</option>
<option value="de" data-i18n="settings.langDe">Deutsch</option>
</select>
</label>
</main>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
"""Per-viewer isolation via secret URL tokens (no login)."""
from __future__ import annotations
import re
import secrets
from pathlib import Path
from db import get_connection, init_db
VIEWER_ID_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")
LOCAL_VIEWER_ID = "local"
def generate_viewer_id() -> str:
return secrets.token_urlsafe(16)
def is_valid_viewer_id(viewer_id: str) -> bool:
if viewer_id == LOCAL_VIEWER_ID:
return True
return bool(viewer_id and VIEWER_ID_RE.match(viewer_id))
def viewers_dir(data_dir: Path) -> Path:
return data_dir / "viewers"
def viewer_db_path(viewer_id: str, data_dir: Path) -> Path:
return viewers_dir(data_dir) / f"{viewer_id}.db"
def viewer_exists(viewer_id: str, data_dir: Path) -> bool:
return viewer_db_path(viewer_id, data_dir).exists()
def create_viewer(data_dir: Path) -> str:
"""Create a new viewer with an empty SQLite database."""
data_dir.mkdir(parents=True, exist_ok=True)
viewers_dir(data_dir).mkdir(parents=True, exist_ok=True)
for _ in range(10):
viewer_id = generate_viewer_id()
db_path = viewer_db_path(viewer_id, data_dir)
if db_path.exists():
continue
conn = get_connection(db_path)
init_db(conn)
conn.close()
return viewer_id
raise RuntimeError("Could not allocate a unique viewer id")
def ensure_local_viewer(data_dir: Path) -> str:
"""CLI default viewer not secret, for local single-user use."""
db_path = viewer_db_path(LOCAL_VIEWER_ID, data_dir)
if db_path.exists():
return LOCAL_VIEWER_ID
conn = get_connection(db_path)
init_db(conn)
conn.close()
return LOCAL_VIEWER_ID