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:
@@ -0,0 +1,12 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.gitignore
|
||||
data/
|
||||
*.db
|
||||
fantasyidler_save.json
|
||||
*.md
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
+21
@@ -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"]
|
||||
@@ -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
|
||||
- **SQLite-Verlauf** – mehrere Backups importieren, Snapshots vergleichen, Coins-/Level-Charts
|
||||
- **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
|
||||
|
||||
## Voraussetzungen
|
||||
@@ -32,7 +33,31 @@ pip install -r requirements.txt
|
||||
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
|
||||
|
||||
@@ -43,13 +68,31 @@ python app.py --import backup2.json
|
||||
# Anderen Port, Browser nicht öffnen
|
||||
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
|
||||
|
||||
# Server für Netzwerk/Docker binden
|
||||
python app.py --host 0.0.0.0 --no-browser
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -64,28 +107,33 @@ Im Tab **Inventar** (Sidebar unten): **Backup importieren** – wählt eine `.js
|
||||
```
|
||||
idle-fantasy-viewer/
|
||||
├── app.py # Flask-Server und CLI
|
||||
├── viewers.py # Viewer-IDs und Isolation
|
||||
├── parser.py # Save parsen und normalisieren
|
||||
├── categories.py # Item-Kategorien (Heuristiken)
|
||||
├── db.py # SQLite Snapshots, Diff, Timeline
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── requirements.txt
|
||||
├── static/
|
||||
│ ├── i18n.js # Locale-Laden, t(), Fallback en
|
||||
│ ├── locales/ # en.json, de.json
|
||||
│ ├── landing.js # Startseite
|
||||
│ └── app.js # Dashboard-UI
|
||||
├── templates/ # HTML
|
||||
└── data/ # history.db (wird angelegt, gitignored)
|
||||
└── data/ # viewers/*.db (gitignored)
|
||||
```
|
||||
|
||||
## API (lokal)
|
||||
## API
|
||||
|
||||
| Endpunkt | Beschreibung |
|
||||
|----------|--------------|
|
||||
| `GET /` | Dashboard |
|
||||
| `GET /api/snapshot/latest` | Neuester normalisierter Save |
|
||||
| `GET /api/snapshots` | Alle Snapshots |
|
||||
| `GET /api/snapshots/<älter>/diff/<neuer>` | Vergleich zweier Backups |
|
||||
| `GET /api/timeline` | Zeitreihe für Charts |
|
||||
| `POST /api/import` | JSON-Upload oder `{"path": "..."}` |
|
||||
| `GET /` | Startseite |
|
||||
| `POST /api/viewers` | Neuen Viewer erstellen |
|
||||
| `GET /v/<id>/api/snapshot/latest` | Neuester Save des Viewers |
|
||||
| `GET /v/<id>/api/snapshots` | Alle Snapshots |
|
||||
| `GET /v/<id>/api/snapshots/<älter>/diff/<neuer>` | Vergleich |
|
||||
| `GET /v/<id>/api/timeline` | Zeitreihe für Charts |
|
||||
| `POST /v/<id>/api/import` | JSON-Upload |
|
||||
|
||||
## Save-Format
|
||||
|
||||
@@ -93,7 +141,7 @@ Die Backup-Datei enthält u. a. doppelt JSON-kodierte Felder (`skillLevels`, `in
|
||||
|
||||
## 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.
|
||||
|
||||
## Robustheit bei Spiel-Updates
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
#!/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
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import webbrowser
|
||||
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 (
|
||||
DEFAULT_DB,
|
||||
@@ -22,59 +23,101 @@ from db import (
|
||||
get_connection,
|
||||
timeline,
|
||||
)
|
||||
from viewers import (
|
||||
LOCAL_VIEWER_ID,
|
||||
create_viewer,
|
||||
ensure_local_viewer,
|
||||
is_valid_viewer_id,
|
||||
viewer_db_path,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
|
||||
DB_PATH = DEFAULT_DB
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
def get_data_dir() -> Path:
|
||||
return DATA_DIR
|
||||
|
||||
|
||||
@app.route("/api/snapshot/latest")
|
||||
def api_latest():
|
||||
data = get_latest_snapshot(db_path=DB_PATH)
|
||||
def _viewer_url(viewer_id: str) -> str:
|
||||
base = request.host_url.rstrip("/")
|
||||
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:
|
||||
return jsonify({"error": "No snapshots imported yet"}), 404
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/snapshot/<int:snapshot_id>")
|
||||
def api_snapshot(snapshot_id: int):
|
||||
data = get_snapshot(snapshot_id, db_path=DB_PATH)
|
||||
@viewer_bp.route("/api/snapshot/<int:snapshot_id>")
|
||||
def api_snapshot(viewer_id: str, snapshot_id: int):
|
||||
db_path = _resolve_viewer_db(viewer_id)
|
||||
data = get_snapshot(snapshot_id, db_path=db_path)
|
||||
if not data:
|
||||
return jsonify({"error": "Snapshot not found"}), 404
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/snapshots")
|
||||
def api_snapshots():
|
||||
return jsonify(list_snapshots(db_path=DB_PATH))
|
||||
@viewer_bp.route("/api/snapshots")
|
||||
def api_snapshots(viewer_id: str):
|
||||
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>")
|
||||
def api_diff(older_id: int, newer_id: int):
|
||||
@viewer_bp.route("/api/snapshots/<int:older_id>/diff/<int:newer_id>")
|
||||
def api_diff(viewer_id: str, older_id: int, newer_id: int):
|
||||
db_path = _resolve_viewer_db(viewer_id)
|
||||
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:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
|
||||
|
||||
@app.route("/api/timeline")
|
||||
def api_timeline():
|
||||
return jsonify(timeline(db_path=DB_PATH))
|
||||
@viewer_bp.route("/api/timeline")
|
||||
def api_timeline(viewer_id: str):
|
||||
db_path = _resolve_viewer_db(viewer_id)
|
||||
return jsonify(timeline(db_path=db_path))
|
||||
|
||||
|
||||
@app.route("/api/import", methods=["POST"])
|
||||
def api_import():
|
||||
@viewer_bp.route("/api/import", methods=["POST"])
|
||||
def api_import(viewer_id: str):
|
||||
db_path = _resolve_viewer_db(viewer_id)
|
||||
|
||||
if "file" in request.files:
|
||||
f = request.files["file"]
|
||||
if not f.filename:
|
||||
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)
|
||||
try:
|
||||
result = import_save(tmp, db_path=DB_PATH)
|
||||
result = import_save(tmp, db_path=db_path)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.get("error"):
|
||||
@@ -88,12 +131,29 @@ def api_import():
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
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"):
|
||||
return jsonify(result), 422
|
||||
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:
|
||||
report = result.get("import_report") or []
|
||||
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("--import", dest="import_file", metavar="FILE", help="Import save without starting server")
|
||||
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("--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()
|
||||
|
||||
global DB_PATH
|
||||
DB_PATH = args.db
|
||||
global DATA_DIR, DB_PATH
|
||||
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)
|
||||
conn.close()
|
||||
|
||||
@@ -125,7 +202,7 @@ def main() -> int:
|
||||
if not path.exists():
|
||||
print(f"Error: file not found: {path}", file=sys.stderr)
|
||||
return 1
|
||||
result = import_save(path, db_path=DB_PATH)
|
||||
result = import_save(path, db_path=db_path)
|
||||
if result.get("error"):
|
||||
print(f"Import failed: {result['error']}", file=sys.stderr)
|
||||
_print_import_report(result)
|
||||
@@ -146,11 +223,15 @@ def main() -> int:
|
||||
if args.import_file and not args.save_file:
|
||||
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}")
|
||||
if not args.no_browser:
|
||||
if not args.no_browser and args.host in ("127.0.0.1", "localhost"):
|
||||
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__":
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
flask>=3.0
|
||||
gunicorn>=22.0
|
||||
|
||||
+42
-6
@@ -38,15 +38,51 @@ const CATEGORY_I18N_KEYS = {
|
||||
|
||||
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() {
|
||||
await I18n.init();
|
||||
applyStaticI18n();
|
||||
setupLanguage();
|
||||
setupViewerBanner();
|
||||
setupNav();
|
||||
setupUpload();
|
||||
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) {
|
||||
const key = CATEGORY_I18N_KEYS[cat];
|
||||
return key ? t(key) : cat;
|
||||
@@ -96,7 +132,7 @@ function setupUpload() {
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
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();
|
||||
if (!res.ok || result.error) {
|
||||
showImportFailure(result);
|
||||
@@ -185,9 +221,9 @@ function renderImportReport(meta) {
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const res = await fetch("/api/snapshot/latest");
|
||||
const res = await fetch(`${apiBase()}/snapshot/latest`);
|
||||
if (!res.ok) {
|
||||
showEmpty(t("empty.noSave"));
|
||||
showEmpty(window.VIEWER_ID ? t("empty.noSaveWeb") : t("empty.noSave"));
|
||||
return;
|
||||
}
|
||||
state.data = await res.json();
|
||||
@@ -629,8 +665,8 @@ async function loadHistoryTab() {
|
||||
panel.innerHTML = `<p class='loading'>${esc(t("history.loading"))}</p>`;
|
||||
|
||||
const [snapRes, tlRes] = await Promise.all([
|
||||
fetch("/api/snapshots"),
|
||||
fetch("/api/timeline"),
|
||||
fetch(`${apiBase()}/snapshots`),
|
||||
fetch(`${apiBase()}/timeline`),
|
||||
]);
|
||||
state.snapshots = await snapRes.json();
|
||||
state.timeline = await tlRes.json();
|
||||
@@ -756,7 +792,7 @@ async function runDiff() {
|
||||
}
|
||||
const older = Math.min(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();
|
||||
if (diff.error) {
|
||||
el.innerHTML = `<p class='empty-state'>${esc(diff.error)}</p>`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"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}",
|
||||
"unknown": "Unbekannt",
|
||||
"none": "Keine",
|
||||
@@ -174,5 +175,21 @@
|
||||
"gems_jewelry": "Edelsteine & Schmuck",
|
||||
"potions_brews": "Tränke & Brauerei",
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
},
|
||||
"empty": {
|
||||
"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}",
|
||||
"unknown": "Unknown",
|
||||
"none": "None",
|
||||
@@ -174,5 +175,21 @@
|
||||
"gems_jewelry": "Gems & Jewelry",
|
||||
"potions_brews": "Potions & Brews",
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,6 +597,124 @@ tr:hover td { background: var(--bg-hover); }
|
||||
|
||||
.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) {
|
||||
.sidebar {
|
||||
position: relative;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<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="/static/i18n.js" defer></script>
|
||||
<script>window.VIEWER_ID = {{ viewer_id|tojson }};</script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -46,6 +47,16 @@
|
||||
|
||||
<main class="main">
|
||||
<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="character-header" class="character-header">
|
||||
<span class="loading" data-i18n="app.loading">Loading save…</span>
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user