Add Idle Fantasy save viewer with local Flask dashboard and SQLite history tracking.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
fantasyidler_save.json
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Idle Fantasy Save Viewer
|
||||||
|
|
||||||
|
Lokaler Web-Viewer für Backups des Android-Spiels **Idle Fantasy**. Parst `fantasyidler_save.json`, zeigt Skills, Inventar, Quests und Kampfstatistiken in einem dunklen Dashboard – inklusive Filter, Item-Gruppierung und Verlaufsvergleich über SQLite.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Dashboard** mit Charakter, Coins, Skills, Inventar, Ausrüstung, Quests und Kampf
|
||||||
|
- **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`)
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- Ein Idle-Fantasy-Backup (`fantasyidler_save.json` vom Spielexport)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
### Server starten und Backup importieren
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python app.py fantasyidler_save.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Browser öffnet sich automatisch unter `http://127.0.0.1:5000`.
|
||||||
|
|
||||||
|
### Weitere Optionen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Nur importieren, kein Server
|
||||||
|
python app.py --import backup2.json
|
||||||
|
|
||||||
|
# Anderen Port, Browser nicht öffnen
|
||||||
|
python app.py fantasyidler_save.json --port 8080 --no-browser
|
||||||
|
|
||||||
|
# Eigene SQLite-Datenbank
|
||||||
|
python app.py --db data\meine_history.db fantasyidler_save.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backups im Browser importieren
|
||||||
|
|
||||||
|
Im Tab **Inventar** (Sidebar unten): **Backup importieren** – wählt eine `.json`-Datei. Duplikate (gleicher Datei-Hash) werden übersprungen.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
idle-fantasy-viewer/
|
||||||
|
├── app.py # Flask-Server und CLI
|
||||||
|
├── parser.py # Save parsen und normalisieren
|
||||||
|
├── categories.py # Item-Kategorien (Heuristiken)
|
||||||
|
├── db.py # SQLite Snapshots, Diff, Timeline
|
||||||
|
├── requirements.txt
|
||||||
|
├── static/ # CSS und JavaScript
|
||||||
|
├── templates/ # HTML
|
||||||
|
└── data/ # history.db (wird angelegt, gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API (lokal)
|
||||||
|
|
||||||
|
| 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": "..."}` |
|
||||||
|
|
||||||
|
## Save-Format
|
||||||
|
|
||||||
|
Die Backup-Datei enthält u. a. doppelt JSON-kodierte Felder (`skillLevels`, `inventory`, `flags`, …). Der Parser löst diese automatisch auf.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- `data/history.db` speichert importierte Snapshots lokal; nicht mit ins Repo committen (steht in `.gitignore`).
|
||||||
|
- Der Viewer ist ein inoffizielles Hilfstool, nicht mit dem Spiel verbunden.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Privates Projekt – Nutzung auf eigene Verantwortung.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Idle Fantasy Save Viewer – local Flask server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, render_template, request
|
||||||
|
|
||||||
|
from db import (
|
||||||
|
DEFAULT_DB,
|
||||||
|
diff_snapshots,
|
||||||
|
get_latest_snapshot,
|
||||||
|
get_snapshot,
|
||||||
|
import_save,
|
||||||
|
init_db,
|
||||||
|
list_snapshots,
|
||||||
|
get_connection,
|
||||||
|
timeline,
|
||||||
|
)
|
||||||
|
app = Flask(__name__)
|
||||||
|
DB_PATH = DEFAULT_DB
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/snapshot/latest")
|
||||||
|
def api_latest():
|
||||||
|
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)
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/snapshots/<int:older_id>/diff/<int:newer_id>")
|
||||||
|
def api_diff(older_id: int, newer_id: int):
|
||||||
|
try:
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/import", methods=["POST"])
|
||||||
|
def api_import():
|
||||||
|
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}"
|
||||||
|
f.save(tmp)
|
||||||
|
try:
|
||||||
|
result = import_save(tmp, db_path=DB_PATH)
|
||||||
|
finally:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
path = body.get("path")
|
||||||
|
if not path:
|
||||||
|
return jsonify({"error": "Provide file upload or JSON body with path"}), 400
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
return jsonify({"error": f"File not found: {path}"}), 404
|
||||||
|
return jsonify(import_save(path, db_path=DB_PATH))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Idle Fantasy Save Viewer")
|
||||||
|
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("--no-browser", action="store_true")
|
||||||
|
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="SQLite database path")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
global DB_PATH
|
||||||
|
DB_PATH = args.db
|
||||||
|
|
||||||
|
conn = get_connection(DB_PATH)
|
||||||
|
init_db(conn)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
import_path = args.import_file or args.save_file
|
||||||
|
if import_path:
|
||||||
|
path = Path(import_path)
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Error: file not found: {path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
result = import_save(path, db_path=DB_PATH)
|
||||||
|
if result.get("imported"):
|
||||||
|
print(f"Imported snapshot #{result['snapshot_id']} from {path.name}")
|
||||||
|
else:
|
||||||
|
print(f"Skipped duplicate: {path.name} (snapshot #{result['snapshot_id']})")
|
||||||
|
|
||||||
|
if args.import_file and not args.save_file:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
url = f"http://127.0.0.1:{args.port}"
|
||||||
|
print(f"Starting server at {url}")
|
||||||
|
if not args.no_browser:
|
||||||
|
webbrowser.open(url)
|
||||||
|
app.run(host="127.0.0.1", port=args.port, debug=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
"""Item category heuristics for Idle Fantasy save inventory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
CATEGORY_ORDER = [
|
||||||
|
"Currency",
|
||||||
|
"Ores & Mining",
|
||||||
|
"Bars & Smithing",
|
||||||
|
"Wood & Planks",
|
||||||
|
"Runes",
|
||||||
|
"Raw Food",
|
||||||
|
"Cooked Food",
|
||||||
|
"Seeds & Farming",
|
||||||
|
"Melee Weapons",
|
||||||
|
"Ranged",
|
||||||
|
"Magic",
|
||||||
|
"Armor",
|
||||||
|
"Bones & Hides",
|
||||||
|
"Gems & Jewelry",
|
||||||
|
"Potions & Brews",
|
||||||
|
"Misc",
|
||||||
|
]
|
||||||
|
|
||||||
|
COOKED_FOOD = frozenset({
|
||||||
|
"lobster", "shark", "salmon", "tuna", "swordfish", "monkfish", "herring",
|
||||||
|
"mackerel", "shrimp", "trout", "sardine", "cooked_rat_meat", "cooked_mutton",
|
||||||
|
"cooked_beef", "cooked_chicken", "corn", "cabbage", "onion", "carrot",
|
||||||
|
"pumpkin", "watermelon", "strawberry", "tomato", "potato",
|
||||||
|
})
|
||||||
|
|
||||||
|
GEMS = frozenset({
|
||||||
|
"emerald", "sapphire", "ruby", "diamond", "black_pearl",
|
||||||
|
})
|
||||||
|
|
||||||
|
MELEE_SUFFIXES = (
|
||||||
|
"_sword", "_axe", "_dagger", "_scimitar", "_longsword", "_battleaxe",
|
||||||
|
"_warhammer", "_mace", "_spear", "_halberd", "_claws",
|
||||||
|
)
|
||||||
|
|
||||||
|
ARMOR_PARTS = (
|
||||||
|
"_helmet", "_platebody", "_platelegs", "_plateskirt", "_boots",
|
||||||
|
"_shield", "_kiteshield", "_gloves", "_cape", "_body", "_legs",
|
||||||
|
"_mail", "_hood", "_hat", "_robe", "_amulet",
|
||||||
|
)
|
||||||
|
|
||||||
|
RANGED_PARTS = ("bow", "shortbow", "longbow", "crossbow", "arrow", "bolt")
|
||||||
|
|
||||||
|
|
||||||
|
def categorize_item(key: str) -> str:
|
||||||
|
k = key.lower()
|
||||||
|
|
||||||
|
if k == "coins" or k == "carnival_ticket":
|
||||||
|
return "Currency"
|
||||||
|
|
||||||
|
if k.endswith("_ore") or k in ("coal", "rune_essence", "stone", "carved_stone", "tin_ore"):
|
||||||
|
return "Ores & Mining"
|
||||||
|
|
||||||
|
if k.endswith("_bar") or k.endswith("_nail"):
|
||||||
|
return "Bars & Smithing"
|
||||||
|
|
||||||
|
if k.endswith("_log") or k.endswith("_plank") or k.endswith("_ashes"):
|
||||||
|
return "Wood & Planks"
|
||||||
|
|
||||||
|
if k.endswith("_rune"):
|
||||||
|
return "Runes"
|
||||||
|
|
||||||
|
if k.startswith("raw_"):
|
||||||
|
return "Raw Food"
|
||||||
|
|
||||||
|
if k.startswith("cooked_") or k in COOKED_FOOD:
|
||||||
|
return "Cooked Food"
|
||||||
|
|
||||||
|
if k.endswith("_seed") or k == "magic_bean":
|
||||||
|
return "Seeds & Farming"
|
||||||
|
|
||||||
|
if any(k.endswith(s) for s in MELEE_SUFFIXES):
|
||||||
|
return "Melee Weapons"
|
||||||
|
|
||||||
|
if any(p in k for p in RANGED_PARTS):
|
||||||
|
return "Ranged"
|
||||||
|
|
||||||
|
if k.endswith("_staff") or k.endswith("_wand") or k.endswith("_tome"):
|
||||||
|
return "Magic"
|
||||||
|
|
||||||
|
if any(p in k for p in ARMOR_PARTS) or k in ("goblin_mail",):
|
||||||
|
return "Armor"
|
||||||
|
|
||||||
|
if "bone" in k or k.endswith("_hide") or k.endswith("_silk") or k.endswith("_fang") or k.endswith("_horn"):
|
||||||
|
return "Bones & Hides"
|
||||||
|
|
||||||
|
if k in GEMS or k.endswith("_necklace") or k.startswith("ring_"):
|
||||||
|
return "Gems & Jewelry"
|
||||||
|
|
||||||
|
if k.endswith("_potion") or k.endswith("_brew"):
|
||||||
|
return "Potions & Brews"
|
||||||
|
|
||||||
|
if k.endswith("_pickaxe") or k.endswith("_axe") and not k.endswith("_battleaxe"):
|
||||||
|
# pickaxe/hoe/fishing_rod handled as tools -> Misc unless caught above
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "Misc"
|
||||||
|
|
||||||
|
|
||||||
|
def category_sort_key(category: str) -> int:
|
||||||
|
try:
|
||||||
|
return CATEGORY_ORDER.index(category)
|
||||||
|
except ValueError:
|
||||||
|
return len(CATEGORY_ORDER)
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""SQLite persistence for save snapshots and history analysis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from parser import normalize_save, load_save
|
||||||
|
|
||||||
|
DEFAULT_DB = Path(__file__).parent / "data" / "history.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection(db_path: Path | str = DEFAULT_DB) -> sqlite3.Connection:
|
||||||
|
db_path = Path(db_path)
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
imported_at TEXT NOT NULL,
|
||||||
|
source_file TEXT NOT NULL,
|
||||||
|
file_hash TEXT NOT NULL UNIQUE,
|
||||||
|
exported_at INTEGER,
|
||||||
|
character_name TEXT,
|
||||||
|
coins INTEGER,
|
||||||
|
total_level INTEGER,
|
||||||
|
raw_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory_snapshots (
|
||||||
|
snapshot_id INTEGER NOT NULL,
|
||||||
|
item_key TEXT NOT NULL,
|
||||||
|
qty INTEGER NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (snapshot_id, item_key),
|
||||||
|
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS skill_snapshots (
|
||||||
|
snapshot_id INTEGER NOT NULL,
|
||||||
|
skill_key TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL,
|
||||||
|
xp INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (snapshot_id, skill_key),
|
||||||
|
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snapshots_exported ON snapshots(exported_at);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def file_hash(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def import_save(
|
||||||
|
path: str | Path,
|
||||||
|
conn: sqlite3.Connection | None = None,
|
||||||
|
db_path: Path | str = DEFAULT_DB,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(path)
|
||||||
|
|
||||||
|
digest = file_hash(path)
|
||||||
|
own_conn = conn is None
|
||||||
|
if own_conn:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM snapshots WHERE file_hash = ?", (digest,)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
result = {"imported": False, "snapshot_id": existing["id"], "reason": "duplicate"}
|
||||||
|
if own_conn:
|
||||||
|
conn.close()
|
||||||
|
return result
|
||||||
|
|
||||||
|
raw = load_save(path)
|
||||||
|
normalized = normalize_save(raw, source_file=path.name)
|
||||||
|
meta = normalized["meta"]
|
||||||
|
character = normalized["character"]
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO snapshots
|
||||||
|
(imported_at, source_file, file_hash, exported_at, character_name, coins, total_level, raw_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
_utc_now_iso(),
|
||||||
|
path.name,
|
||||||
|
digest,
|
||||||
|
meta.get("exported_at"),
|
||||||
|
character.get("name"),
|
||||||
|
meta.get("coins"),
|
||||||
|
meta.get("total_level"),
|
||||||
|
json.dumps(normalized),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
snapshot_id = cur.lastrowid
|
||||||
|
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO inventory_snapshots (snapshot_id, item_key, qty, category) VALUES (?, ?, ?, ?)",
|
||||||
|
[(snapshot_id, i["key"], i["qty"], i["category"]) for i in normalized["inventory"]],
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO skill_snapshots (snapshot_id, skill_key, level, xp) VALUES (?, ?, ?, ?)",
|
||||||
|
[(snapshot_id, s["key"], s["level"], s["xp"]) for s in normalized["skills"]],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if own_conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {"imported": True, "snapshot_id": snapshot_id}
|
||||||
|
|
||||||
|
|
||||||
|
def list_snapshots(conn: sqlite3.Connection | None = None, db_path: Path | str = DEFAULT_DB) -> list[dict]:
|
||||||
|
own_conn = conn is None
|
||||||
|
if own_conn:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, imported_at, source_file, exported_at, character_name, coins, total_level
|
||||||
|
FROM snapshots ORDER BY exported_at DESC, id DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if own_conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_snapshot(snapshot_id: int, conn: sqlite3.Connection | None = None, db_path: Path | str = DEFAULT_DB) -> dict | None:
|
||||||
|
own_conn = conn is None
|
||||||
|
if own_conn:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
|
||||||
|
row = conn.execute("SELECT raw_json FROM snapshots WHERE id = ?", (snapshot_id,)).fetchone()
|
||||||
|
if own_conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return json.loads(row["raw_json"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_snapshot(conn: sqlite3.Connection | None = None, db_path: Path | str = DEFAULT_DB) -> dict | None:
|
||||||
|
own_conn = conn is None
|
||||||
|
if own_conn:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT raw_json FROM snapshots ORDER BY exported_at DESC, id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if own_conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return json.loads(row["raw_json"])
|
||||||
|
|
||||||
|
|
||||||
|
def diff_snapshots(
|
||||||
|
older_id: int,
|
||||||
|
newer_id: int,
|
||||||
|
conn: sqlite3.Connection | None = None,
|
||||||
|
db_path: Path | str = DEFAULT_DB,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
own_conn = conn is None
|
||||||
|
if own_conn:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
|
||||||
|
meta_rows = conn.execute(
|
||||||
|
"SELECT id, character_name, coins, total_level, exported_at, source_file FROM snapshots WHERE id IN (?, ?)",
|
||||||
|
(older_id, newer_id),
|
||||||
|
).fetchall()
|
||||||
|
meta_by_id = {r["id"]: dict(r) for r in meta_rows}
|
||||||
|
|
||||||
|
if older_id not in meta_by_id or newer_id not in meta_by_id:
|
||||||
|
if own_conn:
|
||||||
|
conn.close()
|
||||||
|
raise ValueError("Snapshot not found")
|
||||||
|
|
||||||
|
older_meta = meta_by_id[older_id]
|
||||||
|
newer_meta = meta_by_id[newer_id]
|
||||||
|
|
||||||
|
def _inventory(sid: int) -> dict[str, dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT item_key, qty, category FROM inventory_snapshots WHERE snapshot_id = ?",
|
||||||
|
(sid,),
|
||||||
|
).fetchall()
|
||||||
|
return {r["item_key"]: {"qty": r["qty"], "category": r["category"]} for r in rows}
|
||||||
|
|
||||||
|
def _skills(sid: int) -> dict[str, dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT skill_key, level, xp FROM skill_snapshots WHERE snapshot_id = ?",
|
||||||
|
(sid,),
|
||||||
|
).fetchall()
|
||||||
|
return {r["skill_key"]: {"level": r["level"], "xp": r["xp"]} for r in rows}
|
||||||
|
|
||||||
|
old_inv, new_inv = _inventory(older_id), _inventory(newer_id)
|
||||||
|
old_sk, new_sk = _skills(older_id), _skills(newer_id)
|
||||||
|
|
||||||
|
all_items = set(old_inv) | set(new_inv)
|
||||||
|
inventory_changes = []
|
||||||
|
for key in sorted(all_items):
|
||||||
|
old_qty = old_inv.get(key, {}).get("qty", 0)
|
||||||
|
new_qty = new_inv.get(key, {}).get("qty", 0)
|
||||||
|
delta = new_qty - old_qty
|
||||||
|
if delta != 0:
|
||||||
|
cat = new_inv.get(key, old_inv.get(key, {})).get("category", "Misc")
|
||||||
|
inventory_changes.append({
|
||||||
|
"key": key,
|
||||||
|
"name": key.replace("_", " ").title(),
|
||||||
|
"category": cat,
|
||||||
|
"old_qty": old_qty,
|
||||||
|
"new_qty": new_qty,
|
||||||
|
"delta": delta,
|
||||||
|
})
|
||||||
|
|
||||||
|
all_skills = set(old_sk) | set(new_sk)
|
||||||
|
skill_changes = []
|
||||||
|
for key in sorted(all_skills):
|
||||||
|
old_s = old_sk.get(key, {"level": 0, "xp": 0})
|
||||||
|
new_s = new_sk.get(key, {"level": 0, "xp": 0})
|
||||||
|
if old_s["level"] != new_s["level"] or old_s["xp"] != new_s["xp"]:
|
||||||
|
skill_changes.append({
|
||||||
|
"key": key,
|
||||||
|
"name": key.replace("_", " ").title(),
|
||||||
|
"old_level": old_s["level"],
|
||||||
|
"new_level": new_s["level"],
|
||||||
|
"level_delta": new_s["level"] - old_s["level"],
|
||||||
|
"old_xp": old_s["xp"],
|
||||||
|
"new_xp": new_s["xp"],
|
||||||
|
"xp_delta": new_s["xp"] - old_s["xp"],
|
||||||
|
})
|
||||||
|
|
||||||
|
if own_conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"older": older_meta,
|
||||||
|
"newer": newer_meta,
|
||||||
|
"summary": {
|
||||||
|
"coins_delta": newer_meta["coins"] - older_meta["coins"],
|
||||||
|
"total_level_delta": newer_meta["total_level"] - older_meta["total_level"],
|
||||||
|
},
|
||||||
|
"inventory_changes": inventory_changes,
|
||||||
|
"skill_changes": skill_changes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def timeline(db_path: Path | str = DEFAULT_DB) -> list[dict]:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT s.id, s.exported_at, s.coins, s.total_level, s.character_name, s.source_file
|
||||||
|
FROM snapshots s ORDER BY s.exported_at ASC, s.id ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Parse and normalize Idle Fantasy Android save files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from categories import categorize_item
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_parse_json(value: Any) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
stripped = value.strip()
|
||||||
|
if stripped.startswith(("{", "[")):
|
||||||
|
try:
|
||||||
|
return json.loads(stripped)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_parse(obj: Any) -> Any:
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _deep_parse(_maybe_parse_json(v)) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_deep_parse(_maybe_parse_json(v)) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def xp_for_level(level: int) -> int:
|
||||||
|
"""Approximate cumulative XP threshold (OSRS-style curve)."""
|
||||||
|
if level <= 1:
|
||||||
|
return 0
|
||||||
|
total = 0
|
||||||
|
for lv in range(1, level):
|
||||||
|
total += int(lv + 300 * (2 ** (lv / 7.0)))
|
||||||
|
return total // 4
|
||||||
|
|
||||||
|
|
||||||
|
def xp_to_next_level(level: int, xp: int) -> dict[str, int]:
|
||||||
|
current_threshold = xp_for_level(level)
|
||||||
|
next_threshold = xp_for_level(level + 1)
|
||||||
|
span = max(next_threshold - current_threshold, 1)
|
||||||
|
progress = max(0, min(xp - current_threshold, span))
|
||||||
|
return {
|
||||||
|
"xp_in_level": progress,
|
||||||
|
"xp_needed": span,
|
||||||
|
"progress_pct": round(100 * progress / span, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_item_name(key: str) -> str:
|
||||||
|
return key.replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def format_key(key: str) -> str:
|
||||||
|
return key.replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def load_save(path: str | Path) -> dict[str, Any]:
|
||||||
|
path = Path(path)
|
||||||
|
with path.open(encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
return _deep_parse(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_save(raw: dict[str, Any], source_file: str = "") -> dict[str, Any]:
|
||||||
|
flags = raw.get("flags") or {}
|
||||||
|
skill_levels = raw.get("skillLevels") or {}
|
||||||
|
skill_xp = raw.get("skillXp") or {}
|
||||||
|
inventory = raw.get("inventory") or {}
|
||||||
|
equipped = raw.get("equipped") or {}
|
||||||
|
equipped_values = {v for v in equipped.values() if v}
|
||||||
|
|
||||||
|
inventory_items = []
|
||||||
|
for key, qty in sorted(inventory.items()):
|
||||||
|
if qty <= 0:
|
||||||
|
continue
|
||||||
|
inventory_items.append({
|
||||||
|
"key": key,
|
||||||
|
"name": format_item_name(key),
|
||||||
|
"qty": qty,
|
||||||
|
"category": categorize_item(key),
|
||||||
|
"equipped": key in equipped_values,
|
||||||
|
})
|
||||||
|
|
||||||
|
skills = []
|
||||||
|
total_level = 0
|
||||||
|
for key in sorted(skill_levels.keys()):
|
||||||
|
level = int(skill_levels[key])
|
||||||
|
xp = int(skill_xp.get(key, 0))
|
||||||
|
total_level += level
|
||||||
|
prog = xp_to_next_level(level, xp)
|
||||||
|
skills.append({
|
||||||
|
"key": key,
|
||||||
|
"name": format_key(key),
|
||||||
|
"level": level,
|
||||||
|
"xp": xp,
|
||||||
|
**prog,
|
||||||
|
})
|
||||||
|
|
||||||
|
equipment = []
|
||||||
|
for slot, item_key in equipped.items():
|
||||||
|
equipment.append({
|
||||||
|
"slot": slot,
|
||||||
|
"slot_name": format_key(slot),
|
||||||
|
"key": item_key,
|
||||||
|
"name": format_item_name(item_key) if item_key else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
story_quests = []
|
||||||
|
for q in raw.get("questProgress") or []:
|
||||||
|
story_quests.append({
|
||||||
|
"id": q.get("questId"),
|
||||||
|
"name": format_key(q.get("questId", "")),
|
||||||
|
"progress": q.get("progress", 0),
|
||||||
|
"completed": bool(q.get("completed")),
|
||||||
|
"completed_at": q.get("completedAt"),
|
||||||
|
})
|
||||||
|
|
||||||
|
daily_quests = _build_flag_quests(
|
||||||
|
flags.get("daily_quest_ids") or [],
|
||||||
|
flags.get("daily_quest_progress") or {},
|
||||||
|
flags.get("daily_quest_claimed") or [],
|
||||||
|
)
|
||||||
|
weekly_quests = _build_flag_quests(
|
||||||
|
flags.get("weekly_quest_ids") or [],
|
||||||
|
flags.get("weekly_quest_progress") or {},
|
||||||
|
flags.get("weekly_quest_claimed") or [],
|
||||||
|
)
|
||||||
|
guild_quests = _build_flag_quests(
|
||||||
|
flags.get("guild_daily_ids") or [],
|
||||||
|
flags.get("guild_daily_progress") or {},
|
||||||
|
flags.get("guild_daily_claimed") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
sessions = []
|
||||||
|
for s in raw.get("sessions") or []:
|
||||||
|
frames = s.get("frames") or []
|
||||||
|
total_xp = sum(f.get("xp_gain", 0) for f in frames) if isinstance(frames, list) else 0
|
||||||
|
total_kills = sum(f.get("kills", 0) for f in frames) if isinstance(frames, list) else 0
|
||||||
|
sessions.append({
|
||||||
|
"id": s.get("session_id"),
|
||||||
|
"skill": s.get("skill_name"),
|
||||||
|
"activity": s.get("activity_key"),
|
||||||
|
"started_at": s.get("started_at"),
|
||||||
|
"ends_at": s.get("ends_at"),
|
||||||
|
"completed": s.get("completed"),
|
||||||
|
"total_xp": total_xp,
|
||||||
|
"total_kills": total_kills,
|
||||||
|
"frame_count": len(frames) if isinstance(frames, list) else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"character": {
|
||||||
|
"name": flags.get("character_name"),
|
||||||
|
"gender": flags.get("character_gender"),
|
||||||
|
"race": flags.get("character_race"),
|
||||||
|
"hp": flags.get("current_hp"),
|
||||||
|
"active_potion": flags.get("active_potion_key"),
|
||||||
|
"active_spell": flags.get("active_spell"),
|
||||||
|
"active_weapon_slot": flags.get("active_weapon_slot"),
|
||||||
|
"active_blessing": flags.get("active_blessing_key"),
|
||||||
|
"blessing_expires_at": flags.get("active_blessing_expires_at"),
|
||||||
|
"theme": flags.get("theme_preference"),
|
||||||
|
},
|
||||||
|
"skills": skills,
|
||||||
|
"inventory": inventory_items,
|
||||||
|
"equipment": equipment,
|
||||||
|
"quests": {
|
||||||
|
"story": story_quests,
|
||||||
|
"daily": daily_quests,
|
||||||
|
"weekly": weekly_quests,
|
||||||
|
"guild": guild_quests,
|
||||||
|
},
|
||||||
|
"combat": {
|
||||||
|
"enemy_kills": flags.get("enemy_kills") or {},
|
||||||
|
"dungeon_runs": flags.get("dungeon_runs") or {},
|
||||||
|
"slayer_task": flags.get("active_slayer_task"),
|
||||||
|
"slayer_points": flags.get("slayer_points", 0),
|
||||||
|
},
|
||||||
|
"guild_reputation": flags.get("guild_reputation") or {},
|
||||||
|
"pets": raw.get("pets") or [],
|
||||||
|
"farming": raw.get("farmingPatches") or [],
|
||||||
|
"farming_fertilizer": flags.get("farming_fertilizer") or {},
|
||||||
|
"session_queue": flags.get("session_queue") or [],
|
||||||
|
"recent_sessions": flags.get("recent_sessions") or [],
|
||||||
|
"sessions": sessions,
|
||||||
|
"town_buildings": flags.get("town_building_tiers") or {},
|
||||||
|
"meta": {
|
||||||
|
"source_file": source_file,
|
||||||
|
"exported_at": raw.get("exported_at"),
|
||||||
|
"coins": raw.get("coins", 0),
|
||||||
|
"inventory_coins": inventory.get("coins", 0),
|
||||||
|
"total_level": total_level,
|
||||||
|
"item_count": len(inventory_items),
|
||||||
|
"total_items": sum(i["qty"] for i in inventory_items),
|
||||||
|
"version_code": flags.get("last_seen_version_code"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_flag_quests(
|
||||||
|
ids: list[str],
|
||||||
|
progress: dict[str, int],
|
||||||
|
claimed: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
claimed_set = set(claimed or [])
|
||||||
|
result = []
|
||||||
|
for qid in ids:
|
||||||
|
result.append({
|
||||||
|
"id": qid,
|
||||||
|
"name": format_key(qid),
|
||||||
|
"progress": progress.get(qid, 0),
|
||||||
|
"claimed": qid in claimed_set,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_save_file(path: str | Path) -> dict[str, Any]:
|
||||||
|
path = Path(path)
|
||||||
|
raw = load_save(path)
|
||||||
|
return normalize_save(raw, source_file=path.name)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
flask>=3.0
|
||||||
+626
@@ -0,0 +1,626 @@
|
|||||||
|
/* Idle Fantasy Save Viewer – client UI */
|
||||||
|
|
||||||
|
let state = {
|
||||||
|
data: null,
|
||||||
|
snapshots: [],
|
||||||
|
timeline: [],
|
||||||
|
inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false },
|
||||||
|
skills: { search: "", sort: "level", sortAsc: false },
|
||||||
|
quests: { tab: "story", filter: "all" },
|
||||||
|
history: { olderId: null, newerId: null, diff: null },
|
||||||
|
charts: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = [
|
||||||
|
"Currency", "Ores & Mining", "Bars & Smithing", "Wood & Planks", "Runes",
|
||||||
|
"Raw Food", "Cooked Food", "Seeds & Farming", "Melee Weapons", "Ranged",
|
||||||
|
"Magic", "Armor", "Bones & Hides", "Gems & Jewelry", "Potions & Brews", "Misc",
|
||||||
|
];
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
setupNav();
|
||||||
|
setupUpload();
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupNav() {
|
||||||
|
document.querySelectorAll(".nav-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll(".nav-btn").forEach((b) => b.classList.remove("active"));
|
||||||
|
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
|
||||||
|
btn.classList.add("active");
|
||||||
|
document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active");
|
||||||
|
if (btn.dataset.tab === "history") loadHistoryTab();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupUpload() {
|
||||||
|
document.getElementById("file-upload").addEventListener("change", async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/import", { method: "POST", body: fd });
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.imported || result.snapshot_id) {
|
||||||
|
await loadData();
|
||||||
|
alert(result.imported ? `Importiert: Snapshot #${result.snapshot_id}` : "Backup bereits vorhanden (Duplikat).");
|
||||||
|
} else {
|
||||||
|
alert(result.error || "Import fehlgeschlagen");
|
||||||
|
}
|
||||||
|
e.target.value = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/snapshot/latest");
|
||||||
|
if (!res.ok) {
|
||||||
|
showEmpty("Kein Save importiert. Starte mit: python app.py fantasyidler_save.json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.data = await res.json();
|
||||||
|
renderAll();
|
||||||
|
} catch (err) {
|
||||||
|
showEmpty(`Fehler beim Laden: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmpty(msg) {
|
||||||
|
document.getElementById("character-header").innerHTML = `<span class="loading">${esc(msg)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
const d = state.data;
|
||||||
|
if (!d) return;
|
||||||
|
|
||||||
|
renderHeader(d);
|
||||||
|
renderOverview(d);
|
||||||
|
renderSkills(d);
|
||||||
|
renderInventory(d);
|
||||||
|
renderEquipment(d);
|
||||||
|
renderQuests(d);
|
||||||
|
renderCombat(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeader(d) {
|
||||||
|
const c = d.character;
|
||||||
|
const m = d.meta;
|
||||||
|
document.getElementById("character-header").innerHTML = `
|
||||||
|
<h2>${esc(c.name || "Unbekannt")}</h2>
|
||||||
|
<div class="character-meta">
|
||||||
|
${esc(c.race || "")} · ${esc(c.gender || "")} · Export: ${formatTs(m.exported_at)}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.getElementById("kpi-row").innerHTML = `
|
||||||
|
<div class="kpi"><div class="kpi-label">Coins</div><div class="kpi-value">${fmt(m.coins)}</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-label">Gesamt-Level</div><div class="kpi-value">${m.total_level}</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-label">Items</div><div class="kpi-value">${m.item_count}</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-label">Stückzahl</div><div class="kpi-value">${fmt(m.total_items)}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverview(d) {
|
||||||
|
const c = d.character;
|
||||||
|
const queue = (d.session_queue || []).map((q) =>
|
||||||
|
`<li><span>${esc(q.skill_display_name || q.skill_name)}</span><span>${esc(q.activity_key || "—")} · ${q.qty || 0}</span></li>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
const slayer = d.combat.slayer_task;
|
||||||
|
const slayerHtml = slayer
|
||||||
|
? `<p><strong>${esc(slayer.display_name)}</strong>: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} Punkte)</p>`
|
||||||
|
: "<p class='empty-state'>Kein Slayer-Task aktiv</p>";
|
||||||
|
|
||||||
|
const pets = (d.pets || []).map((p) =>
|
||||||
|
`<li><span>${esc(p.id.replace(/_/g, " "))}</span><span>+${p.boost_percent}%</span></li>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
const farming = (d.farming || []).map((p) =>
|
||||||
|
`<li><span>Feld ${p.patchNumber}</span><span>${esc(p.cropType || "—")}</span></li>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
document.getElementById("tab-overview").innerHTML = `
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Charakter</h3>
|
||||||
|
<ul class="list-compact">
|
||||||
|
<li><span>HP</span><span>${c.hp ?? "—"}</span></li>
|
||||||
|
<li><span>Aktiver Trank</span><span>${esc(c.active_potion || "—")}</span></li>
|
||||||
|
<li><span>Aktiver Zauber</span><span>${esc(c.active_spell || "—")}</span></li>
|
||||||
|
<li><span>Waffen-Slot</span><span>${esc(c.active_weapon_slot || "—")}</span></li>
|
||||||
|
<li><span>Segen</span><span>${esc(c.active_blessing || "—")}</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Session-Queue</h3>
|
||||||
|
<ul class="list-compact">${queue || "<li><span>Leer</span></li>"}</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Slayer</h3>
|
||||||
|
${slayerHtml}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Pets</h3>
|
||||||
|
<ul class="list-compact">${pets || "<li><span>Keine</span></li>"}</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Farming</h3>
|
||||||
|
<ul class="list-compact">${farming || "<li><span>Keine Felder</span></li>"}</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Gilden-Ruf</h3>
|
||||||
|
<ul class="list-compact">${Object.entries(d.guild_reputation || {}).map(([k, v]) =>
|
||||||
|
`<li><span>${esc(k)}</span><span>${fmt(v)}</span></li>`).join("")}</ul>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSkills(d) {
|
||||||
|
const panel = document.getElementById("tab-skills");
|
||||||
|
const s = state.skills;
|
||||||
|
let items = [...d.skills];
|
||||||
|
if (s.search) {
|
||||||
|
const q = s.search.toLowerCase();
|
||||||
|
items = items.filter((sk) => sk.name.toLowerCase().includes(q) || sk.key.includes(q));
|
||||||
|
}
|
||||||
|
items.sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
if (s.sort === "name") cmp = a.name.localeCompare(b.name);
|
||||||
|
else if (s.sort === "level") cmp = a.level - b.level;
|
||||||
|
else cmp = a.xp - b.xp;
|
||||||
|
return s.sortAsc ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="toolbar">
|
||||||
|
<input class="search-input" id="skill-search" placeholder="Skill suchen…" value="${esc(s.search)}">
|
||||||
|
<select class="select-input" id="skill-sort">
|
||||||
|
<option value="level" ${s.sort === "level" ? "selected" : ""}>Nach Level</option>
|
||||||
|
<option value="xp" ${s.sort === "xp" ? "selected" : ""}>Nach XP</option>
|
||||||
|
<option value="name" ${s.sort === "name" ? "selected" : ""}>Nach Name</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort="name">Skill</th>
|
||||||
|
<th data-sort="level">Level</th>
|
||||||
|
<th data-sort="xp">XP</th>
|
||||||
|
<th>Fortschritt</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${items.map((sk) => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(sk.name)}</td>
|
||||||
|
<td>${sk.level}</td>
|
||||||
|
<td>${fmt(sk.xp)}</td>
|
||||||
|
<td style="min-width:140px">
|
||||||
|
${sk.progress_pct}%
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:${sk.progress_pct}%"></div></div>
|
||||||
|
</td>
|
||||||
|
</tr>`).join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.getElementById("skill-search").addEventListener("input", (e) => {
|
||||||
|
state.skills.search = e.target.value;
|
||||||
|
renderSkills(state.data);
|
||||||
|
});
|
||||||
|
document.getElementById("skill-sort").addEventListener("change", (e) => {
|
||||||
|
state.skills.sort = e.target.value;
|
||||||
|
renderSkills(state.data);
|
||||||
|
});
|
||||||
|
panel.querySelectorAll("th[data-sort]").forEach((th) => {
|
||||||
|
th.addEventListener("click", () => {
|
||||||
|
const key = th.dataset.sort;
|
||||||
|
if (state.skills.sort === key) state.skills.sortAsc = !state.skills.sortAsc;
|
||||||
|
else { state.skills.sort = key; state.skills.sortAsc = false; }
|
||||||
|
renderSkills(state.data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInventory(d) {
|
||||||
|
const panel = document.getElementById("tab-inventory");
|
||||||
|
const inv = state.inventory;
|
||||||
|
const categories = [...new Set(d.inventory.map((i) => i.category))].sort(
|
||||||
|
(a, b) => CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b)
|
||||||
|
);
|
||||||
|
|
||||||
|
let items = [...d.inventory];
|
||||||
|
if (inv.search) {
|
||||||
|
const q = inv.search.toLowerCase();
|
||||||
|
items = items.filter((i) => i.name.toLowerCase().includes(q) || i.key.includes(q));
|
||||||
|
}
|
||||||
|
if (inv.categories.size > 0) {
|
||||||
|
items = items.filter((i) => inv.categories.has(i.category));
|
||||||
|
}
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (inv.sort === "qty") return b.qty - a.qty;
|
||||||
|
if (inv.sort === "name") return a.name.localeCompare(b.name);
|
||||||
|
const ca = CATEGORY_ORDER.indexOf(a.category);
|
||||||
|
const cb = CATEGORY_ORDER.indexOf(b.category);
|
||||||
|
return ca - cb || a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = {};
|
||||||
|
for (const item of items) {
|
||||||
|
if (!grouped[item.category]) grouped[item.category] = [];
|
||||||
|
grouped[item.category].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupRows = Object.entries(grouped).map(([cat, catItems]) => {
|
||||||
|
const totalQty = catItems.reduce((s, i) => s + i.qty, 0);
|
||||||
|
const header = `
|
||||||
|
<tr class="inv-group-row" data-group="${esc(cat)}">
|
||||||
|
<td colspan="3">
|
||||||
|
<button type="button" class="inv-group-toggle" data-group="${esc(cat)}" aria-expanded="true">
|
||||||
|
<span class="inv-group-title">${esc(cat)}</span>
|
||||||
|
<span class="inv-group-meta">${catItems.length} Items · ${fmt(totalQty)} Stück</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
const rows = catItems.map((i) => `
|
||||||
|
<tr class="inv-item-row ${i.equipped && inv.highlightEquipped ? "item-equipped" : ""}" data-group="${esc(cat)}">
|
||||||
|
<td class="col-name">${esc(i.name)}${i.equipped ? '<span class="equipped-mark" title="Ausgerüstet">⚡</span>' : ""}</td>
|
||||||
|
<td class="col-qty">${fmt(i.qty)}</td>
|
||||||
|
<td class="col-key"><code>${esc(i.key)}</code></td>
|
||||||
|
</tr>`).join("");
|
||||||
|
return header + rows;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const tableHtml = groupRows ? `
|
||||||
|
<div class="inv-table-wrap">
|
||||||
|
<table class="inv-table">
|
||||||
|
<colgroup>
|
||||||
|
<col class="col-name">
|
||||||
|
<col class="col-qty">
|
||||||
|
<col class="col-key">
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-name">Item</th>
|
||||||
|
<th class="col-qty">Menge</th>
|
||||||
|
<th class="col-key">ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${groupRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>` : "<p class='empty-state'>Keine Items gefunden</p>";
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="toolbar">
|
||||||
|
<input class="search-input" id="inv-search" placeholder="Item suchen…" value="${esc(inv.search)}">
|
||||||
|
<select class="select-input" id="inv-sort">
|
||||||
|
<option value="category" ${inv.sort === "category" ? "selected" : ""}>Nach Kategorie</option>
|
||||||
|
<option value="name" ${inv.sort === "name" ? "selected" : ""}>Nach Name</option>
|
||||||
|
<option value="qty" ${inv.sort === "qty" ? "selected" : ""}>Nach Menge</option>
|
||||||
|
</select>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" id="inv-equipped" ${inv.highlightEquipped ? "checked" : ""}>
|
||||||
|
Ausgerüstet hervorheben
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="chip-row" id="inv-chips">
|
||||||
|
${categories.map((c) => `
|
||||||
|
<span class="chip ${inv.categories.has(c) ? "active" : ""}" data-cat="${esc(c)}">${esc(c)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="card inv-card">
|
||||||
|
${tableHtml}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.getElementById("inv-search").addEventListener("input", (e) => {
|
||||||
|
state.inventory.search = e.target.value;
|
||||||
|
renderInventory(state.data);
|
||||||
|
});
|
||||||
|
document.getElementById("inv-sort").addEventListener("change", (e) => {
|
||||||
|
state.inventory.sort = e.target.value;
|
||||||
|
renderInventory(state.data);
|
||||||
|
});
|
||||||
|
document.getElementById("inv-equipped").addEventListener("change", (e) => {
|
||||||
|
state.inventory.highlightEquipped = e.target.checked;
|
||||||
|
renderInventory(state.data);
|
||||||
|
});
|
||||||
|
panel.querySelectorAll(".chip").forEach((chip) => {
|
||||||
|
chip.addEventListener("click", () => {
|
||||||
|
const cat = chip.dataset.cat;
|
||||||
|
if (state.inventory.categories.has(cat)) state.inventory.categories.delete(cat);
|
||||||
|
else state.inventory.categories.add(cat);
|
||||||
|
renderInventory(state.data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
panel.querySelectorAll(".inv-group-toggle").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const group = btn.dataset.group;
|
||||||
|
const expanded = btn.getAttribute("aria-expanded") === "true";
|
||||||
|
btn.setAttribute("aria-expanded", expanded ? "false" : "true");
|
||||||
|
panel.querySelectorAll(`.inv-item-row[data-group="${group}"]`).forEach((row) => {
|
||||||
|
row.classList.toggle("collapsed", expanded);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEquipment(d) {
|
||||||
|
document.getElementById("tab-equipment").innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h3>Ausrüstung</h3>
|
||||||
|
<div class="equip-grid">
|
||||||
|
${d.equipment.map((eq) => `
|
||||||
|
<div class="equip-slot ${eq.key ? "" : "empty"}">
|
||||||
|
<div class="slot-name">${esc(eq.slot_name)}</div>
|
||||||
|
<div class="item-name">${eq.name ? esc(eq.name) : "—"}</div>
|
||||||
|
</div>`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuests(d) {
|
||||||
|
const panel = document.getElementById("tab-quests");
|
||||||
|
const q = state.quests;
|
||||||
|
const tabs = [
|
||||||
|
{ key: "story", label: "Story" },
|
||||||
|
{ key: "daily", label: "Daily" },
|
||||||
|
{ key: "weekly", label: "Weekly" },
|
||||||
|
{ key: "guild", label: "Gilde" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let items = d.quests[q.tab] || [];
|
||||||
|
if (q.tab === "story") {
|
||||||
|
if (q.filter === "open") items = items.filter((x) => !x.completed);
|
||||||
|
if (q.filter === "done") items = items.filter((x) => x.completed);
|
||||||
|
} else {
|
||||||
|
if (q.filter === "open") items = items.filter((x) => !x.claimed);
|
||||||
|
if (q.filter === "done") items = items.filter((x) => x.claimed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStory = q.tab === "story";
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="quest-tabs">
|
||||||
|
${tabs.map((t) => `<button class="quest-tab ${q.tab === t.key ? "active" : ""}" data-tab="${t.key}">${t.label}</button>`).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<select class="select-input" id="quest-filter">
|
||||||
|
<option value="all" ${q.filter === "all" ? "selected" : ""}>Alle</option>
|
||||||
|
<option value="open" ${q.filter === "open" ? "selected" : ""}>Offen</option>
|
||||||
|
<option value="done" ${q.filter === "done" ? "selected" : ""}>Abgeschlossen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>Quest</th>
|
||||||
|
<th>Fortschritt</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${items.map((quest) => {
|
||||||
|
const done = isStory ? quest.completed : quest.claimed;
|
||||||
|
return `<tr>
|
||||||
|
<td>${esc(quest.name)}</td>
|
||||||
|
<td>${fmt(quest.progress)}</td>
|
||||||
|
<td><span class="badge ${done ? "badge-success" : "badge-warning"}">${done ? "Erledigt" : "Offen"}</span></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join("")}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
panel.querySelectorAll(".quest-tab").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
state.quests.tab = btn.dataset.tab;
|
||||||
|
renderQuests(state.data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById("quest-filter").addEventListener("change", (e) => {
|
||||||
|
state.quests.filter = e.target.value;
|
||||||
|
renderQuests(state.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCombat(d) {
|
||||||
|
const kills = Object.entries(d.combat.enemy_kills || {})
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k, v]) => `<li><span>${esc(k.replace(/_/g, " "))}</span><span>${fmt(v)}</span></li>`).join("");
|
||||||
|
|
||||||
|
const dungeons = Object.entries(d.combat.dungeon_runs || {})
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k, v]) => `<li><span>${esc(k.replace(/_/g, " "))}</span><span>${fmt(v)} Runs</span></li>`).join("");
|
||||||
|
|
||||||
|
const recent = (d.recent_sessions || [])
|
||||||
|
.map((s) => `<li><span>${esc(s.activity_display_name || s.activity_key)}</span><span>${esc(s.skill_name)}</span></li>`).join("");
|
||||||
|
|
||||||
|
const active = (d.sessions || [])
|
||||||
|
.map((s) => `<li><span>${esc(s.activity)}</span><span>${esc(s.skill)} · ${s.completed ? "fertig" : "läuft"}</span></li>`).join("");
|
||||||
|
|
||||||
|
document.getElementById("tab-combat").innerHTML = `
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card"><h3>Feind-Kills</h3><ul class="list-compact">${kills || "<li>Keine</li>"}</ul></div>
|
||||||
|
<div class="card"><h3>Dungeon-Runs</h3><ul class="list-compact">${dungeons || "<li>Keine</li>"}</ul></div>
|
||||||
|
<div class="card"><h3>Letzte Aktivitäten</h3><ul class="list-compact">${recent || "<li>Keine</li>"}</ul></div>
|
||||||
|
<div class="card"><h3>Aktive Sessions</h3><ul class="list-compact">${active || "<li>Keine</li>"}</ul></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistoryTab() {
|
||||||
|
const panel = document.getElementById("tab-history");
|
||||||
|
panel.innerHTML = "<p class='loading'>Lade Verlauf…</p>";
|
||||||
|
|
||||||
|
const [snapRes, tlRes] = await Promise.all([
|
||||||
|
fetch("/api/snapshots"),
|
||||||
|
fetch("/api/timeline"),
|
||||||
|
]);
|
||||||
|
state.snapshots = await snapRes.json();
|
||||||
|
state.timeline = await tlRes.json();
|
||||||
|
|
||||||
|
if (state.snapshots.length === 0) {
|
||||||
|
panel.innerHTML = "<p class='empty-state'>Noch keine Snapshots. Importiere ein Backup.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = state.history;
|
||||||
|
if (!h.newerId) h.newerId = state.snapshots[0].id;
|
||||||
|
if (!h.olderId && state.snapshots.length > 1) h.olderId = state.snapshots[1].id;
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Coins-Verlauf</h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-coins"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Gesamt-Level-Verlauf</h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-level"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Snapshot-Vergleich</h3>
|
||||||
|
<div class="toolbar">
|
||||||
|
<select class="select-input" id="diff-older">
|
||||||
|
${state.snapshots.map((s) => option(s, h.olderId)).join("")}
|
||||||
|
</select>
|
||||||
|
<span>→</span>
|
||||||
|
<select class="select-input" id="diff-newer">
|
||||||
|
${state.snapshots.map((s) => option(s, h.newerId)).join("")}
|
||||||
|
</select>
|
||||||
|
<button class="select-input" id="diff-run" style="background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600">Vergleichen</button>
|
||||||
|
</div>
|
||||||
|
<div id="diff-result"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Alle Snapshots</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ID</th><th>Charakter</th><th>Coins</th><th>Level</th><th>Export</th><th>Datei</th></tr></thead>
|
||||||
|
<tbody>${state.snapshots.map((s) => `
|
||||||
|
<tr>
|
||||||
|
<td>${s.id}</td>
|
||||||
|
<td>${esc(s.character_name || "—")}</td>
|
||||||
|
<td>${fmt(s.coins)}</td>
|
||||||
|
<td>${s.total_level}</td>
|
||||||
|
<td>${formatTs(s.exported_at)}</td>
|
||||||
|
<td>${esc(s.source_file)}</td>
|
||||||
|
</tr>`).join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
renderTimelineCharts();
|
||||||
|
document.getElementById("diff-older").addEventListener("change", (e) => { h.olderId = +e.target.value; });
|
||||||
|
document.getElementById("diff-newer").addEventListener("change", (e) => { h.newerId = +e.target.value; });
|
||||||
|
document.getElementById("diff-run").addEventListener("click", runDiff);
|
||||||
|
if (h.olderId && h.newerId && h.olderId !== h.newerId) runDiff();
|
||||||
|
}
|
||||||
|
|
||||||
|
function option(s, selected) {
|
||||||
|
const label = `#${s.id} · ${s.character_name || "?"} · ${formatTs(s.exported_at)}`;
|
||||||
|
return `<option value="${s.id}" ${s.id === selected ? "selected" : ""}>${esc(label)}</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimelineCharts() {
|
||||||
|
const tl = state.timeline;
|
||||||
|
if (!tl.length) return;
|
||||||
|
|
||||||
|
const labels = tl.map((s) => formatTs(s.exported_at));
|
||||||
|
const coins = tl.map((s) => s.coins);
|
||||||
|
const levels = tl.map((s) => s.total_level);
|
||||||
|
|
||||||
|
destroyChart("coins");
|
||||||
|
destroyChart("level");
|
||||||
|
|
||||||
|
state.charts.coins = new Chart(document.getElementById("chart-coins"), {
|
||||||
|
type: "line",
|
||||||
|
data: { labels, datasets: [{ label: "Coins", data: coins, borderColor: "#6c8cff", tension: 0.3, fill: false }] },
|
||||||
|
options: chartOpts(),
|
||||||
|
});
|
||||||
|
state.charts.level = new Chart(document.getElementById("chart-level"), {
|
||||||
|
type: "line",
|
||||||
|
data: { labels, datasets: [{ label: "Gesamt-Level", data: levels, borderColor: "#4ade80", tension: 0.3, fill: false }] },
|
||||||
|
options: chartOpts(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartOpts() {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { labels: { color: "#8b92a8" } } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: "#8b92a8" }, grid: { color: "#2d3348" } },
|
||||||
|
y: { ticks: { color: "#8b92a8" }, grid: { color: "#2d3348" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyChart(key) {
|
||||||
|
if (state.charts[key]) {
|
||||||
|
state.charts[key].destroy();
|
||||||
|
delete state.charts[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDiff() {
|
||||||
|
const h = state.history;
|
||||||
|
const el = document.getElementById("diff-result");
|
||||||
|
if (!h.olderId || !h.newerId || h.olderId === h.newerId) {
|
||||||
|
el.innerHTML = "<p class='empty-state'>Wähle zwei verschiedene Snapshots.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 diff = await res.json();
|
||||||
|
if (diff.error) {
|
||||||
|
el.innerHTML = `<p class='empty-state'>${esc(diff.error)}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coinDelta = diff.summary.coins_delta;
|
||||||
|
const coinCls = coinDelta >= 0 ? "delta-pos" : "delta-neg";
|
||||||
|
|
||||||
|
const invRows = diff.inventory_changes.slice(0, 50).map((i) => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(i.name)}</td>
|
||||||
|
<td>${fmt(i.old_qty)} → ${fmt(i.new_qty)}</td>
|
||||||
|
<td class="${i.delta >= 0 ? "delta-pos" : "delta-neg"}">${i.delta >= 0 ? "+" : ""}${fmt(i.delta)}</td>
|
||||||
|
</tr>`).join("");
|
||||||
|
|
||||||
|
const skRows = diff.skill_changes
|
||||||
|
.sort((a, b) => b.xp_delta - a.xp_delta)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((s) => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(s.name)}</td>
|
||||||
|
<td>${s.old_level} → ${s.new_level}</td>
|
||||||
|
<td class="${s.xp_delta >= 0 ? "delta-pos" : "delta-neg"}">${s.xp_delta >= 0 ? "+" : ""}${fmt(s.xp_delta)} XP</td>
|
||||||
|
</tr>`).join("");
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<p>Coins: <span class="${coinCls}">${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}</span>
|
||||||
|
· Gesamt-Level: ${diff.summary.total_level_delta >= 0 ? "+" : ""}${diff.summary.total_level_delta}</p>
|
||||||
|
<h4 style="margin-top:16px">Inventar-Änderungen (${diff.inventory_changes.length})</h4>
|
||||||
|
<table><thead><tr><th>Item</th><th>Menge</th><th>Delta</th></tr></thead>
|
||||||
|
<tbody>${invRows || "<tr><td colspan='3'>Keine Änderungen</td></tr>"}</tbody></table>
|
||||||
|
<h4 style="margin-top:16px">Skill-Änderungen (${diff.skill_changes.length})</h4>
|
||||||
|
<table><thead><tr><th>Skill</th><th>Level</th><th>XP-Delta</th></tr></thead>
|
||||||
|
<tbody>${skRows || "<tr><td colspan='3'>Keine Änderungen</td></tr>"}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
if (n == null) return "—";
|
||||||
|
return Number(n).toLocaleString("de-DE");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTs(ts) {
|
||||||
|
if (!ts) return "—";
|
||||||
|
const d = new Date(Number(ts));
|
||||||
|
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--bg-card: #1a1d27;
|
||||||
|
--bg-hover: #242836;
|
||||||
|
--border: #2d3348;
|
||||||
|
--text: #e8eaf0;
|
||||||
|
--text-muted: #8b92a8;
|
||||||
|
--accent: #6c8cff;
|
||||||
|
--accent-dim: #4a62b3;
|
||||||
|
--success: #4ade80;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--danger: #f87171;
|
||||||
|
--radius: 10px;
|
||||||
|
--sidebar-w: 220px;
|
||||||
|
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px 8px;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.nav-btn.active { background: var(--accent-dim); color: #fff; font-weight: 600; }
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn:hover { opacity: 0.9; }
|
||||||
|
|
||||||
|
.main {
|
||||||
|
margin-left: var(--sidebar-w);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 24px 32px 32px;
|
||||||
|
max-width: 1320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.character-header h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input, .chip {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-block;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:hover { color: var(--text); }
|
||||||
|
tr:hover td { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inventory table */
|
||||||
|
.inv-card {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-table col.col-name { width: 46%; }
|
||||||
|
.inv-table col.col-qty { width: 6.25rem; }
|
||||||
|
.inv-table col.col-key { width: 34%; }
|
||||||
|
|
||||||
|
.inv-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: 11px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-table th.col-name,
|
||||||
|
.inv-table td.col-name {
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 16px;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-table th.col-qty,
|
||||||
|
.inv-table td.col-qty {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-table th.col-key,
|
||||||
|
.inv-table td.col-key {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 24px;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-table td {
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-table td.col-key code {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-table .inv-item-row:hover td {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-table .inv-item-row.item-equipped .col-name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-group-row td {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-group-row:not(:first-child) td {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-group-toggle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-group-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-group-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-group-meta {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-item-row.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipped-mark {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equip-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equip-slot {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equip-slot .slot-name {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equip-slot .item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equip-slot.empty .item-name { color: var(--text-muted); font-weight: 400; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success { background: #14532d; color: var(--success); }
|
||||||
|
.badge-warning { background: #422006; color: var(--warning); }
|
||||||
|
.badge-muted { background: var(--bg-hover); color: var(--text-muted); }
|
||||||
|
|
||||||
|
.quest-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-tab {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quest-tab.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-pos { color: var(--success); }
|
||||||
|
.delta-neg { color: var(--danger); }
|
||||||
|
|
||||||
|
.chart-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 220px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-equipped { color: var(--accent); }
|
||||||
|
|
||||||
|
.loading, .empty-state {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compact {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compact li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-compact li:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
.layout { flex-direction: column; }
|
||||||
|
.main { margin-left: 0; padding: 16px; }
|
||||||
|
.nav { flex-direction: row; flex-wrap: wrap; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<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="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script>
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-icon">⚔</span>
|
||||||
|
<div>
|
||||||
|
<h1>Idle Fantasy</h1>
|
||||||
|
<p class="subtitle">Save Viewer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="nav" id="nav">
|
||||||
|
<button class="nav-btn active" data-tab="overview">Übersicht</button>
|
||||||
|
<button class="nav-btn" data-tab="skills">Skills</button>
|
||||||
|
<button class="nav-btn" data-tab="inventory">Inventar</button>
|
||||||
|
<button class="nav-btn" data-tab="equipment">Ausrüstung</button>
|
||||||
|
<button class="nav-btn" data-tab="quests">Quests</button>
|
||||||
|
<button class="nav-btn" data-tab="combat">Kampf</button>
|
||||||
|
<button class="nav-btn" data-tab="history">Verlauf</button>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<label class="upload-btn">
|
||||||
|
Backup importieren
|
||||||
|
<input type="file" id="file-upload" accept=".json" hidden>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div id="character-header" class="character-header">
|
||||||
|
<span class="loading">Lade Save…</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-row" id="kpi-row"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="tab-panel active" id="tab-overview"></section>
|
||||||
|
<section class="tab-panel" id="tab-skills"></section>
|
||||||
|
<section class="tab-panel" id="tab-inventory"></section>
|
||||||
|
<section class="tab-panel" id="tab-equipment"></section>
|
||||||
|
<section class="tab-panel" id="tab-quests"></section>
|
||||||
|
<section class="tab-panel" id="tab-combat"></section>
|
||||||
|
<section class="tab-panel" id="tab-history"></section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user