From 4b8b921e02ee773efc39acf9914df0f792165ad1 Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 19 Jun 2026 15:41:58 +0200 Subject: [PATCH] Add Idle Fantasy save viewer with local Flask dashboard and SQLite history tracking. Co-authored-by: Cursor --- .gitignore | 6 + README.md | 89 ++++++ app.py | 131 +++++++++ categories.py | 108 ++++++++ db.py | 289 ++++++++++++++++++++ parser.py | 224 ++++++++++++++++ requirements.txt | 1 + static/app.js | 626 +++++++++++++++++++++++++++++++++++++++++++ static/style.css | 527 ++++++++++++++++++++++++++++++++++++ templates/index.html | 56 ++++ 10 files changed, 2057 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 categories.py create mode 100644 db.py create mode 100644 parser.py create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/style.css create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab0592d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +data/ +*.db +fantasyidler_save.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce4181b --- /dev/null +++ b/README.md @@ -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/` | 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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..2910b67 --- /dev/null +++ b/app.py @@ -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/") +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//diff/") +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()) diff --git a/categories.py b/categories.py new file mode 100644 index 0000000..591be89 --- /dev/null +++ b/categories.py @@ -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) diff --git a/db.py b/db.py new file mode 100644 index 0000000..c285529 --- /dev/null +++ b/db.py @@ -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] diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..20fc77b --- /dev/null +++ b/parser.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..001e7c4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask>=3.0 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..955387d --- /dev/null +++ b/static/app.js @@ -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 = `${esc(msg)}`; +} + +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 = ` +

${esc(c.name || "Unbekannt")}

+
+ ${esc(c.race || "")} · ${esc(c.gender || "")} · Export: ${formatTs(m.exported_at)} +
`; + + document.getElementById("kpi-row").innerHTML = ` +
Coins
${fmt(m.coins)}
+
Gesamt-Level
${m.total_level}
+
Items
${m.item_count}
+
Stückzahl
${fmt(m.total_items)}
`; +} + +function renderOverview(d) { + const c = d.character; + const queue = (d.session_queue || []).map((q) => + `
  • ${esc(q.skill_display_name || q.skill_name)}${esc(q.activity_key || "—")} · ${q.qty || 0}
  • ` + ).join(""); + + const slayer = d.combat.slayer_task; + const slayerHtml = slayer + ? `

    ${esc(slayer.display_name)}: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} Punkte)

    ` + : "

    Kein Slayer-Task aktiv

    "; + + const pets = (d.pets || []).map((p) => + `
  • ${esc(p.id.replace(/_/g, " "))}+${p.boost_percent}%
  • ` + ).join(""); + + const farming = (d.farming || []).map((p) => + `
  • Feld ${p.patchNumber}${esc(p.cropType || "—")}
  • ` + ).join(""); + + document.getElementById("tab-overview").innerHTML = ` +
    +
    +

    Charakter

    +
      +
    • HP${c.hp ?? "—"}
    • +
    • Aktiver Trank${esc(c.active_potion || "—")}
    • +
    • Aktiver Zauber${esc(c.active_spell || "—")}
    • +
    • Waffen-Slot${esc(c.active_weapon_slot || "—")}
    • +
    • Segen${esc(c.active_blessing || "—")}
    • +
    +
    +
    +

    Session-Queue

    +
      ${queue || "
    • Leer
    • "}
    +
    +
    +

    Slayer

    + ${slayerHtml} +
    +
    +

    Pets

    +
      ${pets || "
    • Keine
    • "}
    +
    +
    +

    Farming

    +
      ${farming || "
    • Keine Felder
    • "}
    +
    +
    +

    Gilden-Ruf

    +
      ${Object.entries(d.guild_reputation || {}).map(([k, v]) => + `
    • ${esc(k)}${fmt(v)}
    • `).join("")}
    +
    +
    `; +} + +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 = ` +
    + + +
    +
    + + + + + + + + ${items.map((sk) => ` + + + + + + `).join("")} + +
    SkillLevelXPFortschritt
    ${esc(sk.name)}${sk.level}${fmt(sk.xp)} + ${sk.progress_pct}% +
    +
    +
    `; + + 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 = ` + + + + + `; + const rows = catItems.map((i) => ` + + ${esc(i.name)}${i.equipped ? '' : ""} + ${fmt(i.qty)} + ${esc(i.key)} + `).join(""); + return header + rows; + }).join(""); + + const tableHtml = groupRows ? ` +
    + + + + + + + + + + + + + + ${groupRows} +
    ItemMengeID
    +
    ` : "

    Keine Items gefunden

    "; + + panel.innerHTML = ` +
    + + + +
    +
    + ${categories.map((c) => ` + ${esc(c)}`).join("")} +
    +
    + ${tableHtml} +
    `; + + 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 = ` +
    +

    Ausrüstung

    +
    + ${d.equipment.map((eq) => ` +
    +
    ${esc(eq.slot_name)}
    +
    ${eq.name ? esc(eq.name) : "—"}
    +
    `).join("")} +
    +
    `; +} + +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 = ` +
    + ${tabs.map((t) => ``).join("")} +
    +
    + +
    +
    + + + + + + + ${items.map((quest) => { + const done = isStory ? quest.completed : quest.claimed; + return ` + + + + `; + }).join("")} +
    QuestFortschrittStatus
    ${esc(quest.name)}${fmt(quest.progress)}${done ? "Erledigt" : "Offen"}
    +
    `; + + 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]) => `
  • ${esc(k.replace(/_/g, " "))}${fmt(v)}
  • `).join(""); + + const dungeons = Object.entries(d.combat.dungeon_runs || {}) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `
  • ${esc(k.replace(/_/g, " "))}${fmt(v)} Runs
  • `).join(""); + + const recent = (d.recent_sessions || []) + .map((s) => `
  • ${esc(s.activity_display_name || s.activity_key)}${esc(s.skill_name)}
  • `).join(""); + + const active = (d.sessions || []) + .map((s) => `
  • ${esc(s.activity)}${esc(s.skill)} · ${s.completed ? "fertig" : "läuft"}
  • `).join(""); + + document.getElementById("tab-combat").innerHTML = ` +
    +

    Feind-Kills

      ${kills || "
    • Keine
    • "}
    +

    Dungeon-Runs

      ${dungeons || "
    • Keine
    • "}
    +

    Letzte Aktivitäten

      ${recent || "
    • Keine
    • "}
    +

    Aktive Sessions

      ${active || "
    • Keine
    • "}
    +
    `; +} + +async function loadHistoryTab() { + const panel = document.getElementById("tab-history"); + panel.innerHTML = "

    Lade Verlauf…

    "; + + 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 = "

    Noch keine Snapshots. Importiere ein Backup.

    "; + 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 = ` +
    +
    +

    Coins-Verlauf

    +
    +
    +
    +

    Gesamt-Level-Verlauf

    +
    +
    +
    +
    +

    Snapshot-Vergleich

    +
    + + + + +
    +
    +
    +
    +

    Alle Snapshots

    + + + ${state.snapshots.map((s) => ` + + + + + + + + `).join("")} + +
    IDCharakterCoinsLevelExportDatei
    ${s.id}${esc(s.character_name || "—")}${fmt(s.coins)}${s.total_level}${formatTs(s.exported_at)}${esc(s.source_file)}
    +
    `; + + 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 ``; +} + +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 = "

    Wähle zwei verschiedene Snapshots.

    "; + 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 = `

    ${esc(diff.error)}

    `; + 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) => ` + + ${esc(i.name)} + ${fmt(i.old_qty)} → ${fmt(i.new_qty)} + ${i.delta >= 0 ? "+" : ""}${fmt(i.delta)} + `).join(""); + + const skRows = diff.skill_changes + .sort((a, b) => b.xp_delta - a.xp_delta) + .slice(0, 20) + .map((s) => ` + + ${esc(s.name)} + ${s.old_level} → ${s.new_level} + ${s.xp_delta >= 0 ? "+" : ""}${fmt(s.xp_delta)} XP + `).join(""); + + el.innerHTML = ` +

    Coins: ${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)} + · Gesamt-Level: ${diff.summary.total_level_delta >= 0 ? "+" : ""}${diff.summary.total_level_delta}

    +

    Inventar-Änderungen (${diff.inventory_changes.length})

    + + ${invRows || ""}
    ItemMengeDelta
    Keine Änderungen
    +

    Skill-Änderungen (${diff.skill_changes.length})

    + + ${skRows || ""}
    SkillLevelXP-Delta
    Keine Änderungen
    `; +} + +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, """); +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..9a40036 --- /dev/null +++ b/static/style.css @@ -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; } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7212d09 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,56 @@ + + + + + + Idle Fantasy Viewer + + + + + +
    + + +
    +
    +
    + Lade Save… +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +