commit 4b8b921e02ee773efc39acf9914df0f792165ad1 Author: elpatron Date: Fri Jun 19 15:41:58 2026 +0200 Add Idle Fantasy save viewer with local Flask dashboard and SQLite history tracking. Co-authored-by: Cursor 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… +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +