{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-20T17:20:02.206Z","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2","content":"Implementiere Idee #7: Papierkorb-Seite (Trash UI) für PrintVault.\\n\\nBACKEND (main.py unter /media/HDD_1TB/PrintVault/main.py):\\n- TRASH_DIR existiert bereits, Soft-Delete verschiebt .stl/.png nach /trash mit Timestamp\\n- Füge 3 neue API-Endpoints hinzu: GET /api/trash, POST /api/trash/{filename}/restore, DELETE /api/trash/{filename}\\n- GET listet Dateien mit Original-ID (aus Dateiname extrahiert), Name aus DB, Löschdatum\\n- POST restore verschiebt zurück nach STL_DIR/THUMB_DIR und erstellt DB-Eintrag neu\\n- DELETE löscht Datei endgültig von Platte\\n\\nFRONTEND (index.html unter /media/HDD_1TB/PrintVault/frontend/index.html):\\n- Trash-Icon-Button im Header neben Upload mit Badge (Anzahl)\\n- Trash-Ansicht mit Karten: Name, Löschdatum, Wiederherstellen, Endgültig-Löschen\\n- Bestätigungsdialog beim endgültigen Löschen\\n- Leerer-Zustand Meldung\\n- Zurück-zur-Bibliothek Button\\n- Toast-Benachrichtigungen\\n- Styling passend zum bestehenden Design (CSS-Variablen, Cards im Trash mit --red Akzent)\\n\\nÄndere die existierenden Dateien direkt. Keine neuen Dateien."}
{"type":"queue-operation","operation":"dequeue","timestamp":"2026-05-20T17:20:02.206Z","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2"}
{"parentUuid":null,"isSidechain":false,"promptId":"998409ec-ab4b-4227-8986-411c0f339ff3","type":"user","message":{"role":"user","content":"Implementiere Idee #7: Papierkorb-Seite (Trash UI) für PrintVault.\\n\\nBACKEND (main.py unter /media/HDD_1TB/PrintVault/main.py):\\n- TRASH_DIR existiert bereits, Soft-Delete verschiebt .stl/.png nach /trash mit Timestamp\\n- Füge 3 neue API-Endpoints hinzu: GET /api/trash, POST /api/trash/{filename}/restore, DELETE /api/trash/{filename}\\n- GET listet Dateien mit Original-ID (aus Dateiname extrahiert), Name aus DB, Löschdatum\\n- POST restore verschiebt zurück nach STL_DIR/THUMB_DIR und erstellt DB-Eintrag neu\\n- DELETE löscht Datei endgültig von Platte\\n\\nFRONTEND (index.html unter /media/HDD_1TB/PrintVault/frontend/index.html):\\n- Trash-Icon-Button im Header neben Upload mit Badge (Anzahl)\\n- Trash-Ansicht mit Karten: Name, Löschdatum, Wiederherstellen, Endgültig-Löschen\\n- Bestätigungsdialog beim endgültigen Löschen\\n- Leerer-Zustand Meldung\\n- Zurück-zur-Bibliothek Button\\n- Toast-Benachrichtigungen\\n- Styling passend zum bestehenden Design (CSS-Variablen, Cards im Trash mit --red Akzent)\\n\\nÄndere die existierenden Dateien direkt. Keine neuen Dateien."},"uuid":"bcb78c00-0ebb-4bf4-bb94-eea58b9f3c59","timestamp":"2026-05-20T17:20:02.251Z","permissionMode":"bypassPermissions","userType":"external","entrypoint":"sdk-cli","cwd":"/DATA","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2","version":"2.1.145","gitBranch":"HEAD"}
{"parentUuid":"bcb78c00-0ebb-4bf4-bb94-eea58b9f3c59","isSidechain":false,"attachment":{"type":"skill_listing","content":"- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, suggest the /config command.\n- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: \"rebind ctrl+s\", \"add a chord shortcut\", \"change the submit key\", \"customize keybindings\".\n- verify: Verify that a code change actually does what it's supposed to by running the app and observing behavior. Use when asked to verify a PR, confirm a fix works, test a change manually, check that a feature works, or validate local changes before pushing.\n- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.\n- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.\n- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m) - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. \"check the deploy every 5 minutes\", \"keep running /babysit-prs\"). Do NOT invoke for one-off tasks.\n- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).\nTRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.\nSKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.\n- run: Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).\n- init: Initialize a new CLAUDE.md file with codebase documentation\n- review: Review a pull request\n- security-review: Complete a security review of the pending changes on the current branch","skillCount":11,"isInitial":true},"type":"attachment","uuid":"55c0a280-cdad-432e-8b13-9bc62219a096","timestamp":"2026-05-20T17:20:02.250Z","userType":"external","entrypoint":"sdk-cli","cwd":"/DATA","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2","version":"2.1.145","gitBranch":"HEAD"}
{"type":"ai-title","aiTitle":"Add trash page and restore functionality","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2"}
{"parentUuid":"55c0a280-cdad-432e-8b13-9bc62219a096","isSidechain":false,"message":{"id":"msg_1779297603574","type":"message","role":"assistant","content":[{"type":"tool_use","id":"functions.Read:0","name":"Read","input":{"file_path":"/media/HDD_1TB/PrintVault/main.py"}}],"model":"kimi-k2.6","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"stop_details":null},"type":"assistant","uuid":"27681d28-e7cb-46b6-ab9f-9d6b68e40846","timestamp":"2026-05-20T17:20:05.699Z","userType":"external","entrypoint":"sdk-cli","cwd":"/DATA","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2","version":"2.1.145","gitBranch":"HEAD"}
{"parentUuid":"27681d28-e7cb-46b6-ab9f-9d6b68e40846","isSidechain":false,"message":{"id":"msg_1779297603574","type":"message","role":"assistant","content":[{"type":"tool_use","id":"functions.Read:1","name":"Read","input":{"file_path":"/media/HDD_1TB/PrintVault/frontend/index.html"}}],"model":"kimi-k2.6","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"stop_details":null},"type":"assistant","uuid":"e76fa159-98fb-485b-91ae-42a3c34a8fbc","timestamp":"2026-05-20T17:20:05.700Z","userType":"external","entrypoint":"sdk-cli","cwd":"/DATA","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2","version":"2.1.145","gitBranch":"HEAD"}
{"parentUuid":"27681d28-e7cb-46b6-ab9f-9d6b68e40846","isSidechain":false,"promptId":"998409ec-ab4b-4227-8986-411c0f339ff3","type":"user","message":{"role":"user","content":[{"tool_use_id":"functions.Read:0","type":"tool_result","content":"1\t\"\"\"\n2\tPrintVault — STL-Bibliothek für ZimaOS\n3\tFastAPI Backend + Static Frontend\n4\t\"\"\"\n5\t\n6\timport json\n7\timport os\n8\timport io\n9\timport uuid\n10\timport shutil\n11\timport sqlite3\n12\timport zipfile\n13\timport tempfile\n14\timport time\n15\timport asyncio\n16\tfrom pathlib import Path\n17\tfrom datetime import datetime\n18\tfrom typing import Optional, List\n19\t\n20\tfrom fastapi import FastAPI, File, UploadFile, HTTPException, Query, Request, BackgroundTasks\n21\tfrom fastapi.responses import FileResponse, JSONResponse, HTMLResponse, StreamingResponse\n22\tfrom fastapi.staticfiles import StaticFiles\n23\tfrom fastapi.middleware.cors import CORSMiddleware\n24\timport trimesh\n25\timport numpy as np\n26\t\n27\t# ── Konfiguration ──────────────────────────────────────────\n28\t\n29\tBASE_DIR = Path(os.environ.get(\"DATA_DIR\", \"/data\"))\n30\tSTL_DIR = BASE_DIR / \"stls\"\n31\tTHUMB_DIR = BASE_DIR / \"thumbs\"\n32\tTRASH_DIR = BASE_DIR / \"trash\"\n33\tBACKUP_DIR = BASE_DIR / \"backups\"\n34\tMETA_FILE = BASE_DIR / \"metadata.json\"       # Legacy\n35\tDB_FILE = BASE_DIR / \"printvault.db\"\n36\t\n37\tfor d in (BASE_DIR, STL_DIR, THUMB_DIR, TRASH_DIR, BACKUP_DIR):\n38\t    d.mkdir(parents=True, exist_ok=True)\n39\t\n40\tMAX_BACKUPS = 10\n41\t\n42\tapp = FastAPI(title=\"PrintVault\", version=\"2.0.0\")\n43\t\n44\tapp.add_middleware(\n45\t    CORSMiddleware,\n46\t    allow_origins=[\"*\"],\n47\t    allow_methods=[\"*\"],\n48\t    allow_headers=[\"*\"],\n49\t)\n50\t\n51\t\n52\t@app.middleware(\"http\")\n53\tasync def add_frame_options(request: Request, call_next):\n54\t    response = await call_next(request)\n55\t    response.headers[\"X-Frame-Options\"] = \"ALLOWALL\"\n56\t    response.headers[\"Content-Security-Policy\"] = \"frame-ancestors 'self' http://*:* https://*:*;\"\n57\t    return response\n58\t\n59\t\n60\t# ── Atomic helpers ─────────────────────────────────────────\n61\t\n62\tdef atomic_write_bytes(path: Path, data: bytes):\n63\t    tmp = path.with_suffix(path.suffix + \".tmp\")\n64\t    tmp.write_bytes(data)\n65\t    os.replace(tmp, path)\n66\t\n67\t\n68\tdef atomic_write_text(path: Path, text: str):\n69\t    atomic_write_bytes(path, text.encode(\"utf-8\"))\n70\t\n71\t\n72\t# ── Database ───────────────────────────────────────────────\n73\t\n74\tdef db_connect() -> sqlite3.Connection:\n75\t    conn = sqlite3.connect(DB_FILE, timeout=15.0)\n76\t    conn.row_factory = sqlite3.Row\n77\t    conn.execute(\"PRAGMA foreign_keys = ON\")\n78\t    conn.execute(\"PRAGMA journal_mode = WAL\")\n79\t    conn.execute(\"PRAGMA synchronous = NORMAL\")\n80\t    return conn\n81\t\n82\t\n83\tdef init_db():\n84\t    with db_connect() as conn:\n85\t        conn.execute(\"\"\"\n86\t            CREATE TABLE IF NOT EXISTS files (\n87\t                id              TEXT PRIMARY KEY,\n88\t                name            TEXT NOT NULL,\n89\t                note            TEXT NOT NULL DEFAULT '',\n90\t                tags_json       TEXT NOT NULL DEFAULT '[]',\n91\t                analysis_json   TEXT NOT NULL DEFAULT '{}',\n92\t                analysis_status TEXT NOT NULL DEFAULT 'done',\n93\t                uploaded_at     REAL NOT NULL\n94\t            )\n95\t        \"\"\")\n96\t        conn.execute(\"CREATE INDEX IF NOT EXISTS idx_files_uploaded_at ON files(uploaded_at DESC)\")\n97\t\n98\t\n99\tdef row_to_dict(row: sqlite3.Row, include_path_info: bool = True) -> dict:\n100\t    d = {\n101\t        \"id\": row[\"id\"],\n102\t        \"name\": row[\"name\"],\n103\t        \"note\": row[\"note\"],\n104\t        \"tags\": json.loads(row[\"tags_json\"] or \"[]\"),\n105\t        \"analysis\": json.loads(row[\"analysis_json\"] or \"{}\"),\n106\t        \"analysis_status\": row[\"analysis_status\"],\n107\t        \"uploaded_at\": datetime.fromtimestamp(row[\"uploaded_at\"]).isoformat(),\n108\t    }\n109\t    if include_path_info:\n110\t        stl_path = STL_DIR / f\"{row['id']}.stl\"\n111\t        d[\"size_kb\"] = round(stl_path.stat().st_size / 1024, 1) if stl_path.exists() else 0\n112\t        d[\"has_thumb\"] = (THUMB_DIR / f\"{row['id']}.png\").exists()\n113\t        d[\"original_name\"] = stl_path.name if stl_path.exists() else \"\"\n114\t    return d\n115\t\n116\t\n117\t# ── Migration: metadata.json → SQLite ─────────────────────\n118\t\n119\tdef migrate_legacy_json():\n120\t    if not META_FILE.exists():\n121\t        return\n122\t    try:\n123\t        meta = json.loads(META_FILE.read_text() or \"{}\")\n124\t    except Exception:\n125\t        return\n126\t    if not meta:\n127\t        return\n128\t    imported = 0\n129\t    with db_connect() as conn:\n130\t        existing = {r[\"id\"] for r in conn.execute(\"SELECT id FROM files\")}\n131\t        for fid, entry in meta.items():\n132\t            if fid in existing:\n133\t                continue\n134\t            stl_path = STL_DIR / f\"{fid}.stl\"\n135\t            uploaded_at = stl_path.stat().st_mtime if stl_path.exists() else time.time()\n136\t            conn.execute(\n137\t                \"INSERT INTO files (id,name,note,tags_json,analysis_json,analysis_status,uploaded_at) \"\n138\t                \"VALUES (?,?,?,?,?,?,?)\",\n139\t                (\n140\t                    fid,\n141\t                    entry.get(\"name\", fid),\n142\t                    entry.get(\"note\", \"\"),\n143\t                    json.dumps(entry.get(\"tags\", [])),\n144\t                    json.dumps(entry.get(\"analysis\", {})),\n145\t                    \"done\" if entry.get(\"analysis\") else \"pending\",\n146\t                    uploaded_at,\n147\t                ),\n148\t            )\n149\t            imported += 1\n150\t        conn.commit()\n151\t    archive = META_FILE.with_name(\"metadata.migrated.json\")\n152\t    META_FILE.rename(archive)\n153\t    print(f\"[migrate] {imported} Einträge aus metadata.json nach SQLite importiert. Original → {archive.name}\")\n154\t\n155\t\n156\tdef import_orphan_stls():\n157\t    \"\"\"STLs auf der Platte ohne DB-Eintrag automatisch erfassen.\"\"\"\n158\t    with db_connect() as conn:\n159\t        existing = {r[\"id\"] for r in conn.execute(\"SELECT id FROM files\")}\n160\t        for stl_file in STL_DIR.glob(\"*.stl\"):\n161\t            fid = stl_file.stem\n162\t            if fid in existing:\n163\t                continue\n164\t            conn.execute(\n165\t                \"INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) \"\n166\t                \"VALUES (?,?,?,?,?,?)\",\n167\t                (\n168\t                    fid,\n169\t                    stl_file.stem.replace(\"_\", \" \"),\n170\t                    \"[]\", \"{}\", \"pending\",\n171\t                    stl_file.stat().st_mtime,\n172\t                ),\n173\t            )\n174\t        conn.commit()\n175\t\n176\t\n177\t# ── Backups ────────────────────────────────────────────────\n178\t\n179\tdef snapshot_db(reason: str = \"auto\"):\n180\t    if not DB_FILE.exists():\n181\t        return\n182\t    ts = datetime.now().strftime(\"%Y%m%d-%H%M%S\")\n183\t    target = BACKUP_DIR / f\"printvault-{ts}-{reason}.db\"\n184\t    try:\n185\t        with db_connect() as src, sqlite3.connect(target) as dst:\n186\t            src.backup(dst)\n187\t    except Exception as e:\n188\t        print(f\"[backup] Snapshot fehlgeschlagen: {e}\")\n189\t        return\n190\t    rotate_backups()\n191\t\n192\t\n193\tdef rotate_backups():\n194\t    backups = sorted(BACKUP_DIR.glob(\"printvault-*.db\"))\n195\t    for old in backups[:-MAX_BACKUPS]:\n196\t        try:\n197\t            old.unlink()\n198\t        except Exception:\n199\t            pass\n200\t\n201\t\n202\t# ── STL-Analyse ────────────────────────────────────────────\n203\t\n204\tdef analyze_stl(path: Path) -> dict:\n205\t    try:\n206\t        mesh = trimesh.load(str(path))\n207\t        extent = mesh.bounding_box.extents\n208\t        x, y, z = float(extent[0]), float(extent[1]), float(extent[2])\n209\t        dims = sorted([x, y, z], reverse=True)\n210\t\n211\t        volume_mm3 = float(mesh.volume) if hasattr(mesh, \"volume\") and mesh.volume else 0\n212\t        volume_cm3 = volume_mm3 / 1000.0\n213\t        filament_grams = round(volume_cm3 * 1.24, 1)\n214\t        faces = len(mesh.faces) if hasattr(mesh, \"faces\") and mesh.faces is not None else 0\n215\t        watertight = bool(mesh.is_watertight) if hasattr(mesh, \"is_watertight\") else False\n216\t\n217\t        is_manifold = False\n218\t        try:\n219\t            from manifold3d import Manifold\n220\t            Manifold(*trimesh_to_manifold(mesh))\n221\t            is_manifold = True\n222\t        except Exception:\n223\t            is_manifold = watertight\n224\t\n225\t        return {\n226\t            \"width_mm\": round(dims[0], 1),\n227\t            \"depth_mm\": round(dims[1], 1),\n228\t            \"height_mm\": round(dims[2], 1),\n229\t            \"volume_cm3\": round(volume_cm3, 2),\n230\t            \"filament_grams\": filament_grams,\n231\t            \"faces\": faces,\n232\t            \"watertight\": watertight,\n233\t            \"manifold\": is_manifold,\n234\t        }\n235\t    except Exception as e:\n236\t        return {\n237\t            \"width_mm\": 0, \"depth_mm\": 0, \"height_mm\": 0,\n238\t            \"volume_cm3\": 0, \"faces\": 0,\n239\t            \"watertight\": False, \"manifold\": False,\n240\t            \"error\": str(e),\n241\t        }\n242\t\n243\t\n244\tdef trimesh_to_manifold(mesh):\n245\t    verts = np.array(mesh.vertices, dtype=np.float64)\n246\t    faces = np.array(mesh.faces, dtype=np.int32)\n247\t    return verts, faces\n248\t\n249\t\n250\tdef generate_thumbnail(stl_path: Path, thumb_path: Path) -> bool:\n251\t    \"\"\"Schönes Diffuse-Shaded PNG mit transparentem Hintergrund.\"\"\"\n252\t    try:\n253\t        import matplotlib\n254\t        matplotlib.use(\"Agg\")\n255\t        import matplotlib.pyplot as plt\n256\t        from mpl_toolkits.mplot3d.art3d import Poly3DCollection\n257\t\n258\t        mesh = trimesh.load(str(stl_path))\n259\t\n260\t        # Auf den Ursprung zentrieren\n261\t        mesh.apply_translation(-mesh.centroid)\n262\t\n263\t        verts = np.asarray(mesh.vertices, dtype=np.float32)\n264\t        faces = np.asarray(mesh.faces, dtype=np.int32)\n265\t        polys = verts[faces]  # (N, 3, 3)\n266\t\n267\t        # Face-Normalen für Diffuse-Lighting\n268\t        normals = np.asarray(mesh.face_normals, dtype=np.float32)\n269\t        light_dir = np.array([0.35, -0.55, 0.75], dtype=np.float32)\n270\t        light_dir /= np.linalg.norm(light_dir)\n271\t\n272\t        # Lambert-Shading + Ambient + sanfter Rim-Light\n273\t        diffuse = np.clip(normals @ light_dir, 0.0, 1.0)\n274\t        rim = np.clip(1.0 - np.abs(normals @ np.array([0, 0, 1], dtype=np.float32)), 0, 1) ** 3\n275\t        shade = 0.28 + 0.72 * diffuse + 0.08 * rim\n276\t        shade = np.clip(shade, 0.0, 1.05)\n277\t\n278\t        # Markenfarbe (#3B82F6 → #60A5FA an hellen Stellen)\n279\t        base = np.array([0.231, 0.510, 0.965], dtype=np.float32)\n280\t        highlight = np.array([0.376, 0.647, 0.980], dtype=np.float32)\n281\t        # Mix zwischen base und highlight je nach Helligkeit\n282\t        mix = np.clip((shade - 0.4) / 0.6, 0, 1)[:, None]\n283\t        colors = base * (1 - mix) + highlight * mix\n284\t        colors *= shade[:, None]\n285\t        colors = np.clip(colors, 0, 1)\n286\t        face_colors = np.hstack([colors, np.full((len(colors), 1), 0.98, dtype=np.float32)])\n287\t\n288\t        fig = plt.figure(figsize=(6, 6), dpi=140)\n289\t        ax = fig.add_subplot(111, projection=\"3d\")\n290\t        fig.patch.set_alpha(0.0)\n291\t        ax.set_facecolor((1, 1, 1, 0))\n292\t\n293\t        coll = Poly3DCollection(polys, facecolors=face_colors, edgecolors=\"none\", linewidths=0)\n294\t        coll.set_zsort(\"average\")\n295\t        ax.add_collection3d(coll)\n296\t\n297\t        extent = float(mesh.extents.max()) * 0.58\n298\t        ax.set_xlim(-extent, extent)\n299\t        ax.set_ylim(-extent, extent)\n300\t        ax.set_zlim(-extent, extent)\n301\t        try:\n302\t            ax.set_box_aspect((1, 1, 1))\n303\t        except Exception:\n304\t            pass\n305\t        ax.view_init(elev=28, azim=-55)\n306\t        ax.axis(\"off\")\n307\t        ax.set_proj_type(\"ortho\")\n308\t        for pane in (ax.xaxis, ax.yaxis, ax.zaxis):\n309\t            pane.pane.fill = False\n310\t            pane.pane.set_edgecolor((0, 0, 0, 0))\n311\t\n312\t        buf = io.BytesIO()\n313\t        plt.savefig(buf, format=\"png\", bbox_inches=\"tight\", pad_inches=0.05, transparent=True, dpi=140)\n314\t        buf.seek(0)\n315\t        atomic_write_bytes(thumb_path, buf.read())\n316\t        plt.close(fig)\n317\t        return True\n318\t    except Exception as e:\n319\t        print(f\"Thumbnail error: {e}\")\n320\t        return False\n321\t\n322\t\n323\tdef process_stl_background(file_id: str):\n324\t    \"\"\"Analyse + Thumbnail im Hintergrund.\"\"\"\n325\t    stl_path = STL_DIR / f\"{file_id}.stl\"\n326\t    if not stl_path.exists():\n327\t        return\n328\t    analysis = analyze_stl(stl_path)\n329\t    thumb_path = THUMB_DIR / f\"{file_id}.png\"\n330\t    generate_thumbnail(stl_path, thumb_path)\n331\t    with db_connect() as conn:\n332\t        conn.execute(\n333\t            \"UPDATE files SET analysis_json = ?, analysis_status = ? WHERE id = ?\",\n334\t            (json.dumps(analysis), \"error\" if \"error\" in analysis else \"done\", file_id),\n335\t        )\n336\t        conn.commit()\n337\t\n338\t\n339\t# ── API: Files ─────────────────────────────────────────────\n340\t\n341\t@app.get(\"/api/files\")\n342\tdef list_files(\n343\t    sort: str = Query(\"date\", regex=\"^(name|date|size|volume)$\"),\n344\t    search: str = Query(\"\"),\n345\t    tags: str = Query(\"\"),\n346\t):\n347\t    with db_connect() as conn:\n348\t        rows = conn.execute(\"SELECT * FROM files\").fetchall()\n349\t\n350\t    results = [row_to_dict(r) for r in rows]\n351\t\n352\t    if search:\n353\t        q = search.lower()\n354\t        results = [\n355\t            r for r in results\n356\t            if q in r[\"name\"].lower()\n357\t            or q in (r.get(\"note\") or \"\").lower()\n358\t            or any(q in t.lower() for t in r.get(\"tags\", []))\n359\t        ]\n360\t\n361\t    if tags:\n362\t        required = {t.strip().lower() for t in tags.split(\",\") if t.strip()}\n363\t        if required:\n364\t            results = [\n365\t                r for r in results\n366\t                if required.issubset({t.lower() for t in r.get(\"tags\", [])})\n367\t            ]\n368\t\n369\t    if sort == \"name\":\n370\t        results.sort(key=lambda r: r[\"name\"].lower())\n371\t    elif sort == \"size\":\n372\t        results.sort(key=lambda r: r[\"size_kb\"], reverse=True)\n373\t    elif sort == \"volume\":\n374\t        results.sort(key=lambda r: r.get(\"analysis\", {}).get(\"volume_cm3\", 0), reverse=True)\n375\t    else:\n376\t        results.sort(key=lambda r: r[\"uploaded_at\"], reverse=True)\n377\t\n378\t    return results\n379\t\n380\t\n381\t@app.get(\"/api/files/{file_id}\")\n382\tdef get_file_detail(file_id: str):\n383\t    with db_connect() as conn:\n384\t        row = conn.execute(\"SELECT * FROM files WHERE id = ?\", (file_id,)).fetchone()\n385\t    if not row:\n386\t        raise HTTPException(404, \"Datei nicht gefunden\")\n387\t    return row_to_dict(row)\n388\t\n389\t\n390\t@app.post(\"/api/upload\")\n391\tasync def upload_stl(background: BackgroundTasks, file: UploadFile = File(...)):\n392\t    if not file.filename.lower().endswith(\".stl\"):\n393\t        raise HTTPException(400, \"Nur .stl-Dateien erlaubt\")\n394\t\n395\t    fid = str(uuid.uuid4())[:8]\n396\t    stl_path = STL_DIR / f\"{fid}.stl\"\n397\t    content = await file.read()\n398\t    if not content:\n399\t        raise HTTPException(400, \"Leere Datei\")\n400\t    atomic_write_bytes(stl_path, content)\n401\t\n402\t    name = file.filename.rsplit(\".\", 1)[0].replace(\"_\", \" \")\n403\t    uploaded_at = time.time()\n404\t\n405\t    with db_connect() as conn:\n406\t        conn.execute(\n407\t            \"INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) \"\n408\t            \"VALUES (?,?,?,?,?,?)\",\n409\t            (fid, name, \"[]\", \"{}\", \"pending\", uploaded_at),\n410\t        )\n411\t        conn.commit()\n412\t\n413\t    background.add_task(process_stl_background, fid)\n414\t\n415\t    return {\n416\t        \"id\": fid,\n417\t        \"name\": name,\n418\t        \"size_kb\": round(stl_path.stat().st_size / 1024, 1),\n419\t        \"analysis\": {},\n420\t        \"analysis_status\": \"pending\",\n421\t        \"has_thumb\": False,\n422\t    }\n423\t\n424\t\n425\t@app.patch(\"/api/files/{file_id}\")\n426\tasync def update_file(file_id: str, body: dict):\n427\t    with db_connect() as conn:\n428\t        row = conn.execute(\"SELECT * FROM files WHERE id = ?\", (file_id,)).fetchone()\n429\t        if not row:\n430\t            raise HTTPException(404, \"Datei nicht gefunden\")\n431\t\n432\t        fields, values = [], []\n433\t        if \"name\" in body:\n434\t            fields.append(\"name = ?\"); values.append(str(body[\"name\"]))\n435\t        if \"note\" in body:\n436\t            fields.append(\"note = ?\"); values.append(str(body[\"note\"]))\n437\t        if \"tags\" in body:\n438\t            tags = body[\"tags\"] if isinstance(body[\"tags\"], list) else []\n439\t            tags = [str(t).strip() for t in tags if str(t).strip()]\n440\t            fields.append(\"tags_json = ?\"); values.append(json.dumps(tags))\n441\t\n442\t        if not fields:\n443\t            return {\"ok\": True}\n444\t\n445\t        values.append(file_id)\n446\t        conn.execute(f\"UPDATE files SET {', '.join(fields)} WHERE id = ?\", values)\n447\t        conn.commit()\n448\t    return {\"ok\": True}\n449\t\n450\t\n451\t@app.delete(\"/api/files/{file_id}\")\n452\tdef delete_file(file_id: str):\n453\t    \"\"\"Soft-Delete: STL + Thumbnail in /trash verschieben, DB-Eintrag löschen.\"\"\"\n454\t    stl_path = STL_DIR / f\"{file_id}.stl\"\n455\t    thumb_path = THUMB_DIR / f\"{file_id}.png\"\n456\t\n457\t    ts = datetime.now().strftime(\"%Y%m%d-%H%M%S\")\n458\t    if stl_path.exists():\n459\t        shutil.move(str(stl_path), str(TRASH_DIR / f\"{file_id}-{ts}.stl\"))\n460\t    if thumb_path.exists():\n461\t        shutil.move(str(thumb_path), str(TRASH_DIR / f\"{file_id}-{ts}.png\"))\n462\t\n463\t    with db_connect() as conn:\n464\t        conn.execute(\"DELETE FROM files WHERE id = ?\", (file_id,))\n465\t        conn.commit()\n466\t\n467\t    return {\"ok\": True}\n468\t\n469\t\n470\t@app.get(\"/api/thumbs/{file_id}.png\")\n471\tdef get_thumbnail(file_id: str):\n472\t    thumb_path = THUMB_DIR / f\"{file_id}.png\"\n473\t    if not thumb_path.exists():\n474\t        stl_path = STL_DIR / f\"{file_id}.stl\"\n475\t        if not stl_path.exists():\n476\t            raise HTTPException(404, \"Kein Thumbnail\")\n477\t        generate_thumbnail(stl_path, thumb_path)\n478\t    return FileResponse(thumb_path, media_type=\"image/png\")\n479\t\n480\t\n481\t@app.get(\"/api/stats\")\n482\tdef get_stats():\n483\t    with db_connect() as conn:\n484\t        rows = conn.execute(\"SELECT analysis_json FROM files\").fetchall()\n485\t    total = len(rows)\n486\t    watertight = 0\n487\t    total_volume = 0.0\n488\t    total_filament = 0.0\n489\t    for r in rows:\n490\t        try:\n491\t            a = json.loads(r[\"analysis_json\"] or \"{}\")\n492\t        except Exception:\n493\t            a = {}\n494\t        if a.get(\"watertight\"):\n495\t            watertight += 1\n496\t        total_volume += float(a.get(\"volume_cm3\") or 0)\n497\t        total_filament += float(a.get(\"filament_grams\") or 0)\n498\t\n499\t    total_size = sum(f.stat().st_size for f in STL_DIR.glob(\"*.stl\"))\n500\t\n501\t    return {\n502\t        \"total_files\": total,\n503\t        \"total_size_mb\": round(total_size / 1024 / 1024, 1),\n504\t        \"watertight\": watertight,\n505\t        \"not_watertight\": total - watertight,\n506\t        \"total_volume_cm3\": round(total_volume, 1),\n507\t        \"total_filament_grams\": round(total_filament, 1),\n508\t    }\n509\t\n510\t\n511\t@app.get(\"/api/tags\")\n512\tdef list_tags():\n513\t    with db_connect() as conn:\n514\t        rows = conn.execute(\"SELECT tags_json FROM files\").fetchall()\n515\t    counts: dict = {}\n516\t    for r in rows:\n517\t        try:\n518\t            for t in json.loads(r[\"tags_json\"] or \"[]\"):\n519\t                counts[t] = counts.get(t, 0) + 1\n520\t        except Exception:\n521\t            pass\n522\t    return [{\"tag\": t, \"count\": c} for t, c in sorted(counts.items(), key=lambda x: -x[1])]\n523\t\n524\t\n525\t@app.get(\"/api/download/{file_id}\")\n526\tdef download_stl(file_id: str):\n527\t    stl_path = STL_DIR / f\"{file_id}.stl\"\n528\t    if not stl_path.exists():\n529\t        raise HTTPException(404, \"Datei nicht gefunden\")\n530\t    with db_connect() as conn:\n531\t        row = conn.execute(\"SELECT name FROM files WHERE id = ?\", (file_id,)).fetchone()\n532\t    name = (row[\"name\"] if row else file_id).replace(\" \", \"_\") + \".stl\"\n533\t    return FileResponse(stl_path, media_type=\"application/octet-stream\", filename=name)\n534\t\n535\t\n536\t# ── Backup / Restore ──────────────────────────────────────\n537\t\n538\t@app.get(\"/api/backup\")\n539\tdef backup_zip():\n540\t    \"\"\"Komplette Bibliothek als ZIP herunterladen (DB + STLs + Thumbnails).\"\"\"\n541\t    snapshot_db(\"manual\")\n542\t\n543\t    buf = io.BytesIO()\n544\t    with zipfile.ZipFile(buf, \"w\", zipfile.ZIP_DEFLATED) as zf:\n545\t        if DB_FILE.exists():\n546\t            zf.write(DB_FILE, \"printvault.db\")\n547\t        for p in STL_DIR.glob(\"*.stl\"):\n548\t            zf.write(p, f\"stls/{p.name}\")\n549\t        for p in THUMB_DIR.glob(\"*.png\"):\n550\t            zf.write(p, f\"thumbs/{p.name}\")\n551\t        manifest = {\n552\t            \"version\": \"2.0.0\",\n553\t            \"created_at\": datetime.now().isoformat(),\n554\t            \"files\": len(list(STL_DIR.glob(\"*.stl\"))),\n555\t        }\n556\t        zf.writestr(\"manifest.json\", json.dumps(manifest, indent=2))\n557\t    buf.seek(0)\n558\t\n559\t    fname = f\"printvault-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip\"\n560\t    return StreamingResponse(\n561\t        buf,\n562\t        media_type=\"application/zip\",\n563\t        headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'},\n564\t    )\n565\t\n566\t\n567\t@app.post(\"/api/restore\")\n568\tasync def restore_zip(file: UploadFile = File(...)):\n569\t    \"\"\"Backup-ZIP einspielen. Vorher wird automatisch ein Sicherheits-Snapshot angelegt.\"\"\"\n570\t    if not file.filename.lower().endswith(\".zip\"):\n571\t        raise HTTPException(400, \"Nur .zip erlaubt\")\n572\t\n573\t    snapshot_db(\"pre-restore\")\n574\t\n575\t    data = await file.read()\n576\t    with tempfile.TemporaryDirectory() as tmpdir:\n577\t        tmp = Path(tmpdir)\n578\t        try:\n579\t            with zipfile.ZipFile(io.BytesIO(data)) as zf:\n580\t                # Validierung\n581\t                names = zf.namelist()\n582\t                if \"printvault.db\" not in names:\n583\t                    raise HTTPException(400, \"ZIP enthält keine printvault.db\")\n584\t                zf.extractall(tmp)\n585\t        except zipfile.BadZipFile:\n586\t            raise HTTPException(400, \"Ungültige ZIP-Datei\")\n587\t\n588\t        # DB validieren bevor wir sie übernehmen\n589\t        try:\n590\t            test_conn = sqlite3.connect(tmp / \"printvault.db\")\n591\t            test_conn.execute(\"SELECT COUNT(*) FROM files\").fetchone()\n592\t            test_conn.close()\n593\t        except Exception as e:\n594\t            raise HTTPException(400, f\"DB im Backup ungültig: {e}\")\n595\t\n596\t        # STL & Thumbs einspielen (überschreiben)\n597\t        if (tmp / \"stls\").exists():\n598\t            for src in (tmp / \"stls\").glob(\"*.stl\"):\n599\t                atomic_write_bytes(STL_DIR / src.name, src.read_bytes())\n600\t        if (tmp / \"thumbs\").exists():\n601\t            for src in (tmp / \"thumbs\").glob(\"*.png\"):\n602\t                atomic_write_bytes(THUMB_DIR / src.name, src.read_bytes())\n603\t\n604\t        # DB atomar ersetzen\n605\t        atomic_write_bytes(DB_FILE, (tmp / \"printvault.db\").read_bytes())\n606\t\n607\t    return {\"ok\": True}\n608\t\n609\t\n610\t@app.get(\"/api/backups\")\n611\tdef list_backups():\n612\t    return [\n613\t        {\n614\t            \"name\": p.name,\n615\t            \"size_kb\": round(p.stat().st_size / 1024, 1),\n616\t            \"created_at\": datetime.fromtimestamp(p.stat().st_mtime).isoformat(),\n617\t        }\n618\t        for p in sorted(BACKUP_DIR.glob(\"printvault-*.db\"), reverse=True)\n619\t    ]\n620\t\n621\t\n622\t# ── Static Frontend ────────────────────────────────────────\n623\t\n624\tFRONTEND_DIR = Path(__file__).parent / \"frontend\"\n625\tFRONTEND_DIR.mkdir(exist_ok=True)\n626\t\n627\t\n628\t@app.get(\"/\")\n629\tdef serve_frontend():\n630\t    return FileResponse(FRONTEND_DIR / \"index.html\")\n631\t\n632\t\n633\tif (FRONTEND_DIR / \"assets\").exists():\n634\t    app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIR / \"assets\"), name=\"assets\")\n635\t\n636\t\n637\t@app.get(\"/icon.svg\")\n638\tdef serve_icon():\n639\t    return FileResponse(FRONTEND_DIR / \"icon.svg\", media_type=\"image/svg+xml\")\n640\t\n641\t\n642\t# ── Startup ────────────────────────────────────────────────\n643\t\n644\t@app.on_event(\"startup\")\n645\tdef on_startup():\n646\t    init_db()\n647\t    migrate_legacy_json()\n648\t    import_orphan_stls()\n649\t    snapshot_db(\"startup\")\n650\t\n651\t\n652\tif __name__ == \"__main__\":\n653\t    import uvicorn\n654\t    uvicorn.run(app, host=\"0.0.0.0\", port=3001)\n655\t"}]},"uuid":"114554a9-b83d-4784-b379-000502f6ed48","timestamp":"2026-05-20T17:20:05.959Z","toolUseResult":{"type":"text","file":{"filePath":"/media/HDD_1TB/PrintVault/main.py","content":"\"\"\"\nPrintVault — STL-Bibliothek für ZimaOS\nFastAPI Backend + Static Frontend\n\"\"\"\n\nimport json\nimport os\nimport io\nimport uuid\nimport shutil\nimport sqlite3\nimport zipfile\nimport tempfile\nimport time\nimport asyncio\nfrom pathlib import Path\nfrom datetime import datetime\nfrom typing import Optional, List\n\nfrom fastapi import FastAPI, File, UploadFile, HTTPException, Query, Request, BackgroundTasks\nfrom fastapi.responses import FileResponse, JSONResponse, HTMLResponse, StreamingResponse\nfrom fastapi.staticfiles import StaticFiles\nfrom fastapi.middleware.cors import CORSMiddleware\nimport trimesh\nimport numpy as np\n\n# ── Konfiguration ──────────────────────────────────────────\n\nBASE_DIR = Path(os.environ.get(\"DATA_DIR\", \"/data\"))\nSTL_DIR = BASE_DIR / \"stls\"\nTHUMB_DIR = BASE_DIR / \"thumbs\"\nTRASH_DIR = BASE_DIR / \"trash\"\nBACKUP_DIR = BASE_DIR / \"backups\"\nMETA_FILE = BASE_DIR / \"metadata.json\"       # Legacy\nDB_FILE = BASE_DIR / \"printvault.db\"\n\nfor d in (BASE_DIR, STL_DIR, THUMB_DIR, TRASH_DIR, BACKUP_DIR):\n    d.mkdir(parents=True, exist_ok=True)\n\nMAX_BACKUPS = 10\n\napp = FastAPI(title=\"PrintVault\", version=\"2.0.0\")\n\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\"*\"],\n    allow_methods=[\"*\"],\n    allow_headers=[\"*\"],\n)\n\n\n@app.middleware(\"http\")\nasync def add_frame_options(request: Request, call_next):\n    response = await call_next(request)\n    response.headers[\"X-Frame-Options\"] = \"ALLOWALL\"\n    response.headers[\"Content-Security-Policy\"] = \"frame-ancestors 'self' http://*:* https://*:*;\"\n    return response\n\n\n# ── Atomic helpers ─────────────────────────────────────────\n\ndef atomic_write_bytes(path: Path, data: bytes):\n    tmp = path.with_suffix(path.suffix + \".tmp\")\n    tmp.write_bytes(data)\n    os.replace(tmp, path)\n\n\ndef atomic_write_text(path: Path, text: str):\n    atomic_write_bytes(path, text.encode(\"utf-8\"))\n\n\n# ── Database ───────────────────────────────────────────────\n\ndef db_connect() -> sqlite3.Connection:\n    conn = sqlite3.connect(DB_FILE, timeout=15.0)\n    conn.row_factory = sqlite3.Row\n    conn.execute(\"PRAGMA foreign_keys = ON\")\n    conn.execute(\"PRAGMA journal_mode = WAL\")\n    conn.execute(\"PRAGMA synchronous = NORMAL\")\n    return conn\n\n\ndef init_db():\n    with db_connect() as conn:\n        conn.execute(\"\"\"\n            CREATE TABLE IF NOT EXISTS files (\n                id              TEXT PRIMARY KEY,\n                name            TEXT NOT NULL,\n                note            TEXT NOT NULL DEFAULT '',\n                tags_json       TEXT NOT NULL DEFAULT '[]',\n                analysis_json   TEXT NOT NULL DEFAULT '{}',\n                analysis_status TEXT NOT NULL DEFAULT 'done',\n                uploaded_at     REAL NOT NULL\n            )\n        \"\"\")\n        conn.execute(\"CREATE INDEX IF NOT EXISTS idx_files_uploaded_at ON files(uploaded_at DESC)\")\n\n\ndef row_to_dict(row: sqlite3.Row, include_path_info: bool = True) -> dict:\n    d = {\n        \"id\": row[\"id\"],\n        \"name\": row[\"name\"],\n        \"note\": row[\"note\"],\n        \"tags\": json.loads(row[\"tags_json\"] or \"[]\"),\n        \"analysis\": json.loads(row[\"analysis_json\"] or \"{}\"),\n        \"analysis_status\": row[\"analysis_status\"],\n        \"uploaded_at\": datetime.fromtimestamp(row[\"uploaded_at\"]).isoformat(),\n    }\n    if include_path_info:\n        stl_path = STL_DIR / f\"{row['id']}.stl\"\n        d[\"size_kb\"] = round(stl_path.stat().st_size / 1024, 1) if stl_path.exists() else 0\n        d[\"has_thumb\"] = (THUMB_DIR / f\"{row['id']}.png\").exists()\n        d[\"original_name\"] = stl_path.name if stl_path.exists() else \"\"\n    return d\n\n\n# ── Migration: metadata.json → SQLite ─────────────────────\n\ndef migrate_legacy_json():\n    if not META_FILE.exists():\n        return\n    try:\n        meta = json.loads(META_FILE.read_text() or \"{}\")\n    except Exception:\n        return\n    if not meta:\n        return\n    imported = 0\n    with db_connect() as conn:\n        existing = {r[\"id\"] for r in conn.execute(\"SELECT id FROM files\")}\n        for fid, entry in meta.items():\n            if fid in existing:\n                continue\n            stl_path = STL_DIR / f\"{fid}.stl\"\n            uploaded_at = stl_path.stat().st_mtime if stl_path.exists() else time.time()\n            conn.execute(\n                \"INSERT INTO files (id,name,note,tags_json,analysis_json,analysis_status,uploaded_at) \"\n                \"VALUES (?,?,?,?,?,?,?)\",\n                (\n                    fid,\n                    entry.get(\"name\", fid),\n                    entry.get(\"note\", \"\"),\n                    json.dumps(entry.get(\"tags\", [])),\n                    json.dumps(entry.get(\"analysis\", {})),\n                    \"done\" if entry.get(\"analysis\") else \"pending\",\n                    uploaded_at,\n                ),\n            )\n            imported += 1\n        conn.commit()\n    archive = META_FILE.with_name(\"metadata.migrated.json\")\n    META_FILE.rename(archive)\n    print(f\"[migrate] {imported} Einträge aus metadata.json nach SQLite importiert. Original → {archive.name}\")\n\n\ndef import_orphan_stls():\n    \"\"\"STLs auf der Platte ohne DB-Eintrag automatisch erfassen.\"\"\"\n    with db_connect() as conn:\n        existing = {r[\"id\"] for r in conn.execute(\"SELECT id FROM files\")}\n        for stl_file in STL_DIR.glob(\"*.stl\"):\n            fid = stl_file.stem\n            if fid in existing:\n                continue\n            conn.execute(\n                \"INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) \"\n                \"VALUES (?,?,?,?,?,?)\",\n                (\n                    fid,\n                    stl_file.stem.replace(\"_\", \" \"),\n                    \"[]\", \"{}\", \"pending\",\n                    stl_file.stat().st_mtime,\n                ),\n            )\n        conn.commit()\n\n\n# ── Backups ────────────────────────────────────────────────\n\ndef snapshot_db(reason: str = \"auto\"):\n    if not DB_FILE.exists():\n        return\n    ts = datetime.now().strftime(\"%Y%m%d-%H%M%S\")\n    target = BACKUP_DIR / f\"printvault-{ts}-{reason}.db\"\n    try:\n        with db_connect() as src, sqlite3.connect(target) as dst:\n            src.backup(dst)\n    except Exception as e:\n        print(f\"[backup] Snapshot fehlgeschlagen: {e}\")\n        return\n    rotate_backups()\n\n\ndef rotate_backups():\n    backups = sorted(BACKUP_DIR.glob(\"printvault-*.db\"))\n    for old in backups[:-MAX_BACKUPS]:\n        try:\n            old.unlink()\n        except Exception:\n            pass\n\n\n# ── STL-Analyse ────────────────────────────────────────────\n\ndef analyze_stl(path: Path) -> dict:\n    try:\n        mesh = trimesh.load(str(path))\n        extent = mesh.bounding_box.extents\n        x, y, z = float(extent[0]), float(extent[1]), float(extent[2])\n        dims = sorted([x, y, z], reverse=True)\n\n        volume_mm3 = float(mesh.volume) if hasattr(mesh, \"volume\") and mesh.volume else 0\n        volume_cm3 = volume_mm3 / 1000.0\n        filament_grams = round(volume_cm3 * 1.24, 1)\n        faces = len(mesh.faces) if hasattr(mesh, \"faces\") and mesh.faces is not None else 0\n        watertight = bool(mesh.is_watertight) if hasattr(mesh, \"is_watertight\") else False\n\n        is_manifold = False\n        try:\n            from manifold3d import Manifold\n            Manifold(*trimesh_to_manifold(mesh))\n            is_manifold = True\n        except Exception:\n            is_manifold = watertight\n\n        return {\n            \"width_mm\": round(dims[0], 1),\n            \"depth_mm\": round(dims[1], 1),\n            \"height_mm\": round(dims[2], 1),\n            \"volume_cm3\": round(volume_cm3, 2),\n            \"filament_grams\": filament_grams,\n            \"faces\": faces,\n            \"watertight\": watertight,\n            \"manifold\": is_manifold,\n        }\n    except Exception as e:\n        return {\n            \"width_mm\": 0, \"depth_mm\": 0, \"height_mm\": 0,\n            \"volume_cm3\": 0, \"faces\": 0,\n            \"watertight\": False, \"manifold\": False,\n            \"error\": str(e),\n        }\n\n\ndef trimesh_to_manifold(mesh):\n    verts = np.array(mesh.vertices, dtype=np.float64)\n    faces = np.array(mesh.faces, dtype=np.int32)\n    return verts, faces\n\n\ndef generate_thumbnail(stl_path: Path, thumb_path: Path) -> bool:\n    \"\"\"Schönes Diffuse-Shaded PNG mit transparentem Hintergrund.\"\"\"\n    try:\n        import matplotlib\n        matplotlib.use(\"Agg\")\n        import matplotlib.pyplot as plt\n        from mpl_toolkits.mplot3d.art3d import Poly3DCollection\n\n        mesh = trimesh.load(str(stl_path))\n\n        # Auf den Ursprung zentrieren\n        mesh.apply_translation(-mesh.centroid)\n\n        verts = np.asarray(mesh.vertices, dtype=np.float32)\n        faces = np.asarray(mesh.faces, dtype=np.int32)\n        polys = verts[faces]  # (N, 3, 3)\n\n        # Face-Normalen für Diffuse-Lighting\n        normals = np.asarray(mesh.face_normals, dtype=np.float32)\n        light_dir = np.array([0.35, -0.55, 0.75], dtype=np.float32)\n        light_dir /= np.linalg.norm(light_dir)\n\n        # Lambert-Shading + Ambient + sanfter Rim-Light\n        diffuse = np.clip(normals @ light_dir, 0.0, 1.0)\n        rim = np.clip(1.0 - np.abs(normals @ np.array([0, 0, 1], dtype=np.float32)), 0, 1) ** 3\n        shade = 0.28 + 0.72 * diffuse + 0.08 * rim\n        shade = np.clip(shade, 0.0, 1.05)\n\n        # Markenfarbe (#3B82F6 → #60A5FA an hellen Stellen)\n        base = np.array([0.231, 0.510, 0.965], dtype=np.float32)\n        highlight = np.array([0.376, 0.647, 0.980], dtype=np.float32)\n        # Mix zwischen base und highlight je nach Helligkeit\n        mix = np.clip((shade - 0.4) / 0.6, 0, 1)[:, None]\n        colors = base * (1 - mix) + highlight * mix\n        colors *= shade[:, None]\n        colors = np.clip(colors, 0, 1)\n        face_colors = np.hstack([colors, np.full((len(colors), 1), 0.98, dtype=np.float32)])\n\n        fig = plt.figure(figsize=(6, 6), dpi=140)\n        ax = fig.add_subplot(111, projection=\"3d\")\n        fig.patch.set_alpha(0.0)\n        ax.set_facecolor((1, 1, 1, 0))\n\n        coll = Poly3DCollection(polys, facecolors=face_colors, edgecolors=\"none\", linewidths=0)\n        coll.set_zsort(\"average\")\n        ax.add_collection3d(coll)\n\n        extent = float(mesh.extents.max()) * 0.58\n        ax.set_xlim(-extent, extent)\n        ax.set_ylim(-extent, extent)\n        ax.set_zlim(-extent, extent)\n        try:\n            ax.set_box_aspect((1, 1, 1))\n        except Exception:\n            pass\n        ax.view_init(elev=28, azim=-55)\n        ax.axis(\"off\")\n        ax.set_proj_type(\"ortho\")\n        for pane in (ax.xaxis, ax.yaxis, ax.zaxis):\n            pane.pane.fill = False\n            pane.pane.set_edgecolor((0, 0, 0, 0))\n\n        buf = io.BytesIO()\n        plt.savefig(buf, format=\"png\", bbox_inches=\"tight\", pad_inches=0.05, transparent=True, dpi=140)\n        buf.seek(0)\n        atomic_write_bytes(thumb_path, buf.read())\n        plt.close(fig)\n        return True\n    except Exception as e:\n        print(f\"Thumbnail error: {e}\")\n        return False\n\n\ndef process_stl_background(file_id: str):\n    \"\"\"Analyse + Thumbnail im Hintergrund.\"\"\"\n    stl_path = STL_DIR / f\"{file_id}.stl\"\n    if not stl_path.exists():\n        return\n    analysis = analyze_stl(stl_path)\n    thumb_path = THUMB_DIR / f\"{file_id}.png\"\n    generate_thumbnail(stl_path, thumb_path)\n    with db_connect() as conn:\n        conn.execute(\n            \"UPDATE files SET analysis_json = ?, analysis_status = ? WHERE id = ?\",\n            (json.dumps(analysis), \"error\" if \"error\" in analysis else \"done\", file_id),\n        )\n        conn.commit()\n\n\n# ── API: Files ─────────────────────────────────────────────\n\n@app.get(\"/api/files\")\ndef list_files(\n    sort: str = Query(\"date\", regex=\"^(name|date|size|volume)$\"),\n    search: str = Query(\"\"),\n    tags: str = Query(\"\"),\n):\n    with db_connect() as conn:\n        rows = conn.execute(\"SELECT * FROM files\").fetchall()\n\n    results = [row_to_dict(r) for r in rows]\n\n    if search:\n        q = search.lower()\n        results = [\n            r for r in results\n            if q in r[\"name\"].lower()\n            or q in (r.get(\"note\") or \"\").lower()\n            or any(q in t.lower() for t in r.get(\"tags\", []))\n        ]\n\n    if tags:\n        required = {t.strip().lower() for t in tags.split(\",\") if t.strip()}\n        if required:\n            results = [\n                r for r in results\n                if required.issubset({t.lower() for t in r.get(\"tags\", [])})\n            ]\n\n    if sort == \"name\":\n        results.sort(key=lambda r: r[\"name\"].lower())\n    elif sort == \"size\":\n        results.sort(key=lambda r: r[\"size_kb\"], reverse=True)\n    elif sort == \"volume\":\n        results.sort(key=lambda r: r.get(\"analysis\", {}).get(\"volume_cm3\", 0), reverse=True)\n    else:\n        results.sort(key=lambda r: r[\"uploaded_at\"], reverse=True)\n\n    return results\n\n\n@app.get(\"/api/files/{file_id}\")\ndef get_file_detail(file_id: str):\n    with db_connect() as conn:\n        row = conn.execute(\"SELECT * FROM files WHERE id = ?\", (file_id,)).fetchone()\n    if not row:\n        raise HTTPException(404, \"Datei nicht gefunden\")\n    return row_to_dict(row)\n\n\n@app.post(\"/api/upload\")\nasync def upload_stl(background: BackgroundTasks, file: UploadFile = File(...)):\n    if not file.filename.lower().endswith(\".stl\"):\n        raise HTTPException(400, \"Nur .stl-Dateien erlaubt\")\n\n    fid = str(uuid.uuid4())[:8]\n    stl_path = STL_DIR / f\"{fid}.stl\"\n    content = await file.read()\n    if not content:\n        raise HTTPException(400, \"Leere Datei\")\n    atomic_write_bytes(stl_path, content)\n\n    name = file.filename.rsplit(\".\", 1)[0].replace(\"_\", \" \")\n    uploaded_at = time.time()\n\n    with db_connect() as conn:\n        conn.execute(\n            \"INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) \"\n            \"VALUES (?,?,?,?,?,?)\",\n            (fid, name, \"[]\", \"{}\", \"pending\", uploaded_at),\n        )\n        conn.commit()\n\n    background.add_task(process_stl_background, fid)\n\n    return {\n        \"id\": fid,\n        \"name\": name,\n        \"size_kb\": round(stl_path.stat().st_size / 1024, 1),\n        \"analysis\": {},\n        \"analysis_status\": \"pending\",\n        \"has_thumb\": False,\n    }\n\n\n@app.patch(\"/api/files/{file_id}\")\nasync def update_file(file_id: str, body: dict):\n    with db_connect() as conn:\n        row = conn.execute(\"SELECT * FROM files WHERE id = ?\", (file_id,)).fetchone()\n        if not row:\n            raise HTTPException(404, \"Datei nicht gefunden\")\n\n        fields, values = [], []\n        if \"name\" in body:\n            fields.append(\"name = ?\"); values.append(str(body[\"name\"]))\n        if \"note\" in body:\n            fields.append(\"note = ?\"); values.append(str(body[\"note\"]))\n        if \"tags\" in body:\n            tags = body[\"tags\"] if isinstance(body[\"tags\"], list) else []\n            tags = [str(t).strip() for t in tags if str(t).strip()]\n            fields.append(\"tags_json = ?\"); values.append(json.dumps(tags))\n\n        if not fields:\n            return {\"ok\": True}\n\n        values.append(file_id)\n        conn.execute(f\"UPDATE files SET {', '.join(fields)} WHERE id = ?\", values)\n        conn.commit()\n    return {\"ok\": True}\n\n\n@app.delete(\"/api/files/{file_id}\")\ndef delete_file(file_id: str):\n    \"\"\"Soft-Delete: STL + Thumbnail in /trash verschieben, DB-Eintrag löschen.\"\"\"\n    stl_path = STL_DIR / f\"{file_id}.stl\"\n    thumb_path = THUMB_DIR / f\"{file_id}.png\"\n\n    ts = datetime.now().strftime(\"%Y%m%d-%H%M%S\")\n    if stl_path.exists():\n        shutil.move(str(stl_path), str(TRASH_DIR / f\"{file_id}-{ts}.stl\"))\n    if thumb_path.exists():\n        shutil.move(str(thumb_path), str(TRASH_DIR / f\"{file_id}-{ts}.png\"))\n\n    with db_connect() as conn:\n        conn.execute(\"DELETE FROM files WHERE id = ?\", (file_id,))\n        conn.commit()\n\n    return {\"ok\": True}\n\n\n@app.get(\"/api/thumbs/{file_id}.png\")\ndef get_thumbnail(file_id: str):\n    thumb_path = THUMB_DIR / f\"{file_id}.png\"\n    if not thumb_path.exists():\n        stl_path = STL_DIR / f\"{file_id}.stl\"\n        if not stl_path.exists():\n            raise HTTPException(404, \"Kein Thumbnail\")\n        generate_thumbnail(stl_path, thumb_path)\n    return FileResponse(thumb_path, media_type=\"image/png\")\n\n\n@app.get(\"/api/stats\")\ndef get_stats():\n    with db_connect() as conn:\n        rows = conn.execute(\"SELECT analysis_json FROM files\").fetchall()\n    total = len(rows)\n    watertight = 0\n    total_volume = 0.0\n    total_filament = 0.0\n    for r in rows:\n        try:\n            a = json.loads(r[\"analysis_json\"] or \"{}\")\n        except Exception:\n            a = {}\n        if a.get(\"watertight\"):\n            watertight += 1\n        total_volume += float(a.get(\"volume_cm3\") or 0)\n        total_filament += float(a.get(\"filament_grams\") or 0)\n\n    total_size = sum(f.stat().st_size for f in STL_DIR.glob(\"*.stl\"))\n\n    return {\n        \"total_files\": total,\n        \"total_size_mb\": round(total_size / 1024 / 1024, 1),\n        \"watertight\": watertight,\n        \"not_watertight\": total - watertight,\n        \"total_volume_cm3\": round(total_volume, 1),\n        \"total_filament_grams\": round(total_filament, 1),\n    }\n\n\n@app.get(\"/api/tags\")\ndef list_tags():\n    with db_connect() as conn:\n        rows = conn.execute(\"SELECT tags_json FROM files\").fetchall()\n    counts: dict = {}\n    for r in rows:\n        try:\n            for t in json.loads(r[\"tags_json\"] or \"[]\"):\n                counts[t] = counts.get(t, 0) + 1\n        except Exception:\n            pass\n    return [{\"tag\": t, \"count\": c} for t, c in sorted(counts.items(), key=lambda x: -x[1])]\n\n\n@app.get(\"/api/download/{file_id}\")\ndef download_stl(file_id: str):\n    stl_path = STL_DIR / f\"{file_id}.stl\"\n    if not stl_path.exists():\n        raise HTTPException(404, \"Datei nicht gefunden\")\n    with db_connect() as conn:\n        row = conn.execute(\"SELECT name FROM files WHERE id = ?\", (file_id,)).fetchone()\n    name = (row[\"name\"] if row else file_id).replace(\" \", \"_\") + \".stl\"\n    return FileResponse(stl_path, media_type=\"application/octet-stream\", filename=name)\n\n\n# ── Backup / Restore ──────────────────────────────────────\n\n@app.get(\"/api/backup\")\ndef backup_zip():\n    \"\"\"Komplette Bibliothek als ZIP herunterladen (DB + STLs + Thumbnails).\"\"\"\n    snapshot_db(\"manual\")\n\n    buf = io.BytesIO()\n    with zipfile.ZipFile(buf, \"w\", zipfile.ZIP_DEFLATED) as zf:\n        if DB_FILE.exists():\n            zf.write(DB_FILE, \"printvault.db\")\n        for p in STL_DIR.glob(\"*.stl\"):\n            zf.write(p, f\"stls/{p.name}\")\n        for p in THUMB_DIR.glob(\"*.png\"):\n            zf.write(p, f\"thumbs/{p.name}\")\n        manifest = {\n            \"version\": \"2.0.0\",\n            \"created_at\": datetime.now().isoformat(),\n            \"files\": len(list(STL_DIR.glob(\"*.stl\"))),\n        }\n        zf.writestr(\"manifest.json\", json.dumps(manifest, indent=2))\n    buf.seek(0)\n\n    fname = f\"printvault-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip\"\n    return StreamingResponse(\n        buf,\n        media_type=\"application/zip\",\n        headers={\"Content-Disposition\": f'attachment; filename=\"{fname}\"'},\n    )\n\n\n@app.post(\"/api/restore\")\nasync def restore_zip(file: UploadFile = File(...)):\n    \"\"\"Backup-ZIP einspielen. Vorher wird automatisch ein Sicherheits-Snapshot angelegt.\"\"\"\n    if not file.filename.lower().endswith(\".zip\"):\n        raise HTTPException(400, \"Nur .zip erlaubt\")\n\n    snapshot_db(\"pre-restore\")\n\n    data = await file.read()\n    with tempfile.TemporaryDirectory() as tmpdir:\n        tmp = Path(tmpdir)\n        try:\n            with zipfile.ZipFile(io.BytesIO(data)) as zf:\n                # Validierung\n                names = zf.namelist()\n                if \"printvault.db\" not in names:\n                    raise HTTPException(400, \"ZIP enthält keine printvault.db\")\n                zf.extractall(tmp)\n        except zipfile.BadZipFile:\n            raise HTTPException(400, \"Ungültige ZIP-Datei\")\n\n        # DB validieren bevor wir sie übernehmen\n        try:\n            test_conn = sqlite3.connect(tmp / \"printvault.db\")\n            test_conn.execute(\"SELECT COUNT(*) FROM files\").fetchone()\n            test_conn.close()\n        except Exception as e:\n            raise HTTPException(400, f\"DB im Backup ungültig: {e}\")\n\n        # STL & Thumbs einspielen (überschreiben)\n        if (tmp / \"stls\").exists():\n            for src in (tmp / \"stls\").glob(\"*.stl\"):\n                atomic_write_bytes(STL_DIR / src.name, src.read_bytes())\n        if (tmp / \"thumbs\").exists():\n            for src in (tmp / \"thumbs\").glob(\"*.png\"):\n                atomic_write_bytes(THUMB_DIR / src.name, src.read_bytes())\n\n        # DB atomar ersetzen\n        atomic_write_bytes(DB_FILE, (tmp / \"printvault.db\").read_bytes())\n\n    return {\"ok\": True}\n\n\n@app.get(\"/api/backups\")\ndef list_backups():\n    return [\n        {\n            \"name\": p.name,\n            \"size_kb\": round(p.stat().st_size / 1024, 1),\n            \"created_at\": datetime.fromtimestamp(p.stat().st_mtime).isoformat(),\n        }\n        for p in sorted(BACKUP_DIR.glob(\"printvault-*.db\"), reverse=True)\n    ]\n\n\n# ── Static Frontend ────────────────────────────────────────\n\nFRONTEND_DIR = Path(__file__).parent / \"frontend\"\nFRONTEND_DIR.mkdir(exist_ok=True)\n\n\n@app.get(\"/\")\ndef serve_frontend():\n    return FileResponse(FRONTEND_DIR / \"index.html\")\n\n\nif (FRONTEND_DIR / \"assets\").exists():\n    app.mount(\"/assets\", StaticFiles(directory=FRONTEND_DIR / \"assets\"), name=\"assets\")\n\n\n@app.get(\"/icon.svg\")\ndef serve_icon():\n    return FileResponse(FRONTEND_DIR / \"icon.svg\", media_type=\"image/svg+xml\")\n\n\n# ── Startup ────────────────────────────────────────────────\n\n@app.on_event(\"startup\")\ndef on_startup():\n    init_db()\n    migrate_legacy_json()\n    import_orphan_stls()\n    snapshot_db(\"startup\")\n\n\nif __name__ == \"__main__\":\n    import uvicorn\n    uvicorn.run(app, host=\"0.0.0.0\", port=3001)\n","numLines":655,"startLine":1,"totalLines":655}},"sourceToolAssistantUUID":"27681d28-e7cb-46b6-ab9f-9d6b68e40846","userType":"external","entrypoint":"sdk-cli","cwd":"/DATA","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2","version":"2.1.145","gitBranch":"HEAD"}
{"parentUuid":"e76fa159-98fb-485b-91ae-42a3c34a8fbc","isSidechain":false,"promptId":"998409ec-ab4b-4227-8986-411c0f339ff3","type":"user","message":{"role":"user","content":[{"tool_use_id":"functions.Read:1","type":"tool_result","content":"1\t<!DOCTYPE html>\n2\t<html lang=\"de\">\n3\t<head>\n4\t<meta charset=\"UTF-8\">\n5\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no\">\n6\t<title>PrintVault</title>\n7\t<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n8\t<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n9\t<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap\" rel=\"stylesheet\">\n10\t<script type=\"importmap\">\n11\t{\n12\t  \"imports\": {\n13\t    \"three\": \"https://unpkg.com/three@0.160.0/build/three.module.js\",\n14\t    \"three/addons/\": \"https://unpkg.com/three@0.160.0/examples/jsm/\"\n15\t  }\n16\t}\n17\t</script>\n18\t<style>\n19\t*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n20\t\n21\t:root {\n22\t  --blue-300: #93C5FD;\n23\t  --blue-400: #60A5FA;\n24\t  --blue-500: #3B82F6;\n25\t  --blue-600: #2563EB;\n26\t  --blue-700: #1D4ED8;\n27\t  --green: #10B981;\n28\t  --orange: #F59E0B;\n29\t  --red: #EF4444;\n30\t  --bg: #FAFBFC;\n31\t  --bg-elev: #FFFFFF;\n32\t  --card: rgba(255,255,255,0.78);\n33\t  --card-hover: rgba(255,255,255,0.95);\n34\t  --card-border: rgba(15,23,42,0.06);\n35\t  --card-border-hover: rgba(59,130,246,0.3);\n36\t  --text: #0B1220;\n37\t  --text-dim: #475569;\n38\t  --text-muted: #94A3B8;\n39\t  --modal-bg: rgba(255,255,255,0.95);\n40\t  --thumb-bg: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);\n41\t  --shimmer: rgba(0,0,0,0.04);\n42\t  --shadow-sm: 0 1px 2px rgba(15,23,42,0.04);\n43\t  --shadow-md: 0 4px 14px rgba(15,23,42,0.06);\n44\t  --shadow-lg: 0 12px 32px rgba(37,99,235,0.12);\n45\t  --shadow-xl: 0 24px 60px rgba(15,23,42,0.18);\n46\t  --radius: 20px;\n47\t  --radius-sm: 14px;\n48\t  --radius-xs: 10px;\n49\t  --transition: 0.28s cubic-bezier(0.16, 1, 0.3, 1);\n50\t  --transition-fast: 0.18s cubic-bezier(0.4, 0, 0.2, 1);\n51\t}\n52\t\n53\t@media (prefers-color-scheme: dark) {\n54\t  :root {\n55\t    --bg: #050810;\n56\t    --bg-elev: #0F172A;\n57\t    --card: rgba(255,255,255,0.04);\n58\t    --card-hover: rgba(255,255,255,0.08);\n59\t    --card-border: rgba(255,255,255,0.07);\n60\t    --card-border-hover: rgba(96,165,250,0.45);\n61\t    --text: #F1F5F9;\n62\t    --text-dim: #94A3B8;\n63\t    --text-muted: #64748B;\n64\t    --modal-bg: rgba(15,23,42,0.96);\n65\t    --thumb-bg: linear-gradient(135deg, rgba(30,58,138,0.35) 0%, rgba(15,23,42,0.6) 100%);\n66\t    --shimmer: rgba(255,255,255,0.05);\n67\t    --shadow-sm: 0 1px 2px rgba(0,0,0,0.4);\n68\t    --shadow-md: 0 4px 14px rgba(0,0,0,0.35);\n69\t    --shadow-lg: 0 12px 32px rgba(0,0,0,0.5);\n70\t    --shadow-xl: 0 24px 60px rgba(0,0,0,0.6);\n71\t  }\n72\t}\n73\t\n74\thtml, body {\n75\t  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;\n76\t  font-feature-settings: 'cv11', 'ss01', 'ss03';\n77\t  background: var(--bg);\n78\t  color: var(--text);\n79\t  min-height: 100vh;\n80\t  min-height: 100dvh;\n81\t  -webkit-font-smoothing: antialiased;\n82\t  -moz-osx-font-smoothing: grayscale;\n83\t  letter-spacing: -0.011em;\n84\t}\n85\t\n86\tbody::before {\n87\t  content: '';\n88\t  position: fixed; inset: 0;\n89\t  background:\n90\t    radial-gradient(ellipse 70% 50% at 10% -10%, rgba(59,130,246,0.18) 0%, transparent 55%),\n91\t    radial-gradient(ellipse 50% 40% at 100% 100%, rgba(37,99,235,0.10) 0%, transparent 60%);\n92\t  pointer-events: none; z-index: 0;\n93\t}\n94\tbody::after {\n95\t  content: '';\n96\t  position: fixed; inset: 0;\n97\t  background-image:\n98\t    radial-gradient(circle at 1px 1px, rgba(15,23,42,0.04) 1px, transparent 0);\n99\t  background-size: 24px 24px;\n100\t  pointer-events: none; z-index: 0;\n101\t  mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black, transparent 70%);\n102\t}\n103\t@media (prefers-color-scheme: dark) {\n104\t  body::after {\n105\t    background-image:\n106\t      radial-gradient(circle at 1px 1px, rgba(255,255,255,0.05) 1px, transparent 0);\n107\t  }\n108\t}\n109\t\n110\t/* ── Header ─────────────────────────────── */\n111\theader {\n112\t  position: sticky; top: 0; z-index: 200;\n113\t  background: rgba(250,251,252,0.72);\n114\t  backdrop-filter: blur(28px) saturate(180%);\n115\t  -webkit-backdrop-filter: blur(28px) saturate(180%);\n116\t  border-bottom: 1px solid var(--card-border);\n117\t  padding: 16px 24px;\n118\t}\n119\t@media (prefers-color-scheme: dark) {\n120\t  header { background: rgba(5,8,16,0.72); }\n121\t}\n122\t.header-inner {\n123\t  display: flex; align-items: center; justify-content: space-between;\n124\t  position: relative; z-index: 1;\n125\t  max-width: 1400px; margin: 0 auto;\n126\t}\n127\t.header-brand {\n128\t  display: flex; align-items: center; gap: 12px;\n129\t}\n130\t.header-icon {\n131\t  width: 38px; height: 38px;\n132\t  background: linear-gradient(135deg, #3B82F6 0%, #1E40AF 100%);\n133\t  border-radius: 11px;\n134\t  display: flex; align-items: center; justify-content: center;\n135\t  box-shadow: 0 4px 14px rgba(37,99,235,0.4), inset 0 1px 0 rgba(255,255,255,0.25);\n136\t  position: relative;\n137\t}\n138\t.header-icon::after {\n139\t  content: ''; position: absolute; inset: 0;\n140\t  border-radius: 11px;\n141\t  background: linear-gradient(135deg, rgba(255,255,255,0.3) 0%, transparent 50%);\n142\t  pointer-events: none;\n143\t}\n144\t.header-icon svg { width: 20px; height: 20px; color: white; position: relative; z-index: 1; }\n145\t.header-brand h1 {\n146\t  font-size: 19px; font-weight: 700;\n147\t  letter-spacing: -0.02em;\n148\t  color: var(--text);\n149\t}\n150\t.header-brand .sub {\n151\t  font-size: 11px; font-weight: 500;\n152\t  color: var(--text-muted);\n153\t  letter-spacing: 0.02em;\n154\t}\n155\t.header-actions { display: flex; align-items: center; gap: 10px; }\n156\t.stats-pills { display: flex; gap: 8px; }\n157\t.stat-pill {\n158\t  font-size: 12px; font-weight: 600; padding: 7px 13px;\n159\t  border-radius: 999px;\n160\t  background: var(--card);\n161\t  border: 1px solid var(--card-border);\n162\t  color: var(--text-dim);\n163\t  display: flex; align-items: center; gap: 7px;\n164\t  white-space: nowrap;\n165\t  box-shadow: var(--shadow-sm);\n166\t}\n167\t.stat-pill .dot { width: 6px; height: 6px; border-radius: 50%; }\n168\t.dot-ok { background: var(--green); box-shadow: 0 0 8px rgba(16,185,129,0.6); }\n169\t.stat-pill .val { color: var(--text); font-weight: 700; }\n170\t\n171\t.price-pill {\n172\t  font-size: 12px; font-weight: 700;\n173\t  padding: 7px 13px;\n174\t  border-radius: 999px;\n175\t  background: linear-gradient(135deg, rgba(245,158,11,0.18), rgba(245,158,11,0.06));\n176\t  border: 1px solid rgba(245,158,11,0.35);\n177\t  color: var(--orange);\n178\t  display: inline-flex; align-items: center; gap: 7px;\n179\t  cursor: pointer; transition: var(--transition);\n180\t  white-space: nowrap;\n181\t  box-shadow: var(--shadow-sm);\n182\t}\n183\t.price-pill:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); border-color: rgba(245,158,11,0.55); }\n184\t.price-pill svg { width: 13px; height: 13px; }\n185\t.price-pill .edit-hint { font-size: 10px; opacity: 0.65; font-weight: 600; }\n186\t\n187\t.price-chip {\n188\t  display: inline-flex; align-items: center;\n189\t  font-size: 11px; font-weight: 700;\n190\t  color: var(--orange);\n191\t  background: rgba(245,158,11,0.10);\n192\t  border: 1px solid rgba(245,158,11,0.22);\n193\t  padding: 2px 8px;\n194\t  border-radius: 999px;\n195\t  letter-spacing: -0.01em;\n196\t}\n197\t\n198\t/* Settings dialog */\n199\t.settings-dialog {\n200\t  position: fixed; inset: 0; z-index: 600;\n201\t  background: rgba(8,12,24,0.55);\n202\t  backdrop-filter: blur(10px);\n203\t  display: flex; align-items: center; justify-content: center;\n204\t  animation: fadeIn 0.2s ease;\n205\t  padding: 20px;\n206\t}\n207\t.settings-card {\n208\t  background: var(--modal-bg);\n209\t  backdrop-filter: blur(28px) saturate(180%);\n210\t  border: 1px solid var(--card-border);\n211\t  border-radius: 20px;\n212\t  padding: 24px;\n213\t  width: 100%; max-width: 380px;\n214\t  box-shadow: var(--shadow-xl);\n215\t  animation: slideUp 0.3s var(--transition);\n216\t}\n217\t.settings-card h3 {\n218\t  font-size: 17px; font-weight: 800;\n219\t  letter-spacing: -0.02em; margin-bottom: 4px;\n220\t}\n221\t.settings-card .desc { font-size: 12px; color: var(--text-muted); margin-bottom: 18px; }\n222\t.settings-card .row { display: flex; gap: 8px; align-items: end; margin-bottom: 14px; }\n223\t.settings-card .row .field { flex: 1; margin-bottom: 0; }\n224\t.settings-card .preview {\n225\t  background: linear-gradient(135deg, rgba(59,130,246,0.08), rgba(37,99,235,0.02));\n226\t  border: 1px solid rgba(59,130,246,0.18);\n227\t  border-radius: 12px; padding: 12px 14px;\n228\t  font-size: 13px; color: var(--text-dim); font-weight: 500;\n229\t  margin-bottom: 16px;\n230\t}\n231\t.settings-card .preview b { color: var(--blue-600); font-weight: 800; }\n232\t@media (prefers-color-scheme: dark) { .settings-card .preview b { color: var(--blue-400); } }\n233\t.settings-card .actions { margin-top: 0; }\n234\t\n235\t.icon-btn {\n236\t  width: 36px; height: 36px; border-radius: 11px;\n237\t  background: var(--card); border: 1px solid var(--card-border);\n238\t  color: var(--text-dim); cursor: pointer;\n239\t  display: flex; align-items: center; justify-content: center;\n240\t  transition: var(--transition);\n241\t  box-shadow: var(--shadow-sm);\n242\t}\n243\t.icon-btn:hover {\n244\t  color: var(--blue-500); border-color: var(--card-border-hover);\n245\t  transform: translateY(-1px); box-shadow: var(--shadow-md);\n246\t}\n247\t.icon-btn svg { width: 16px; height: 16px; }\n248\t\n249\t/* ── Content ────────────────────────────── */\n250\t.content {\n251\t  position: relative; z-index: 1;\n252\t  padding: 24px;\n253\t  max-width: 1400px; margin: 0 auto;\n254\t}\n255\t\n256\t.page-title {\n257\t  font-size: 28px; font-weight: 800;\n258\t  letter-spacing: -0.03em; margin-bottom: 4px;\n259\t  background: linear-gradient(135deg, var(--text) 0%, var(--text-dim) 100%);\n260\t  -webkit-background-clip: text;\n261\t  background-clip: text;\n262\t  -webkit-text-fill-color: transparent;\n263\t}\n264\t.page-subtitle {\n265\t  font-size: 14px; color: var(--text-muted);\n266\t  margin-bottom: 24px;\n267\t}\n268\t\n269\t/* ── Controls ───────────────────────────── */\n270\t.controls {\n271\t  display: flex; gap: 10px; margin-bottom: 14px; align-items: center;\n272\t}\n273\t.search-wrap {\n274\t  flex: 1; position: relative;\n275\t}\n276\t.search-wrap svg {\n277\t  position: absolute; left: 14px; top: 50%; transform: translateY(-50%);\n278\t  width: 16px; height: 16px; color: var(--text-muted); pointer-events: none;\n279\t}\n280\t.search-box {\n281\t  width: 100%;\n282\t  background: var(--card);\n283\t  border: 1px solid var(--card-border);\n284\t  border-radius: var(--radius-sm);\n285\t  padding: 12px 16px 12px 40px;\n286\t  font-size: 14px; font-weight: 500; color: var(--text);\n287\t  font-family: inherit; outline: none;\n288\t  -webkit-appearance: none;\n289\t  transition: var(--transition);\n290\t  box-shadow: var(--shadow-sm);\n291\t}\n292\t.search-box::placeholder { color: var(--text-muted); font-weight: 400; }\n293\t.search-box:focus {\n294\t  border-color: var(--blue-500);\n295\t  box-shadow: 0 0 0 4px rgba(59,130,246,0.12);\n296\t  background: var(--bg-elev);\n297\t}\n298\t.sort-btn {\n299\t  background: var(--card);\n300\t  border: 1px solid var(--card-border);\n301\t  border-radius: var(--radius-sm);\n302\t  padding: 12px 14px;\n303\t  font-size: 13px; font-weight: 600; color: var(--text-dim);\n304\t  font-family: inherit; cursor: pointer;\n305\t  -webkit-appearance: none; outline: none;\n306\t  box-shadow: var(--shadow-sm);\n307\t  transition: var(--transition);\n308\t}\n309\t.sort-btn:hover { color: var(--text); }\n310\t\n311\t/* Tag Filter Chips */\n312\t.tag-chips {\n313\t  display: flex; gap: 6px; overflow-x: auto;\n314\t  margin-bottom: 14px; padding: 2px 0 6px;\n315\t  scrollbar-width: none;\n316\t}\n317\t.tag-chips::-webkit-scrollbar { display: none; }\n318\t.tag-chip {\n319\t  flex-shrink: 0;\n320\t  background: var(--card);\n321\t  border: 1px solid var(--card-border);\n322\t  border-radius: 999px;\n323\t  padding: 6px 12px 6px 12px;\n324\t  font-size: 12px; font-weight: 600;\n325\t  color: var(--text-dim);\n326\t  cursor: pointer;\n327\t  transition: var(--transition);\n328\t  white-space: nowrap;\n329\t  display: flex; align-items: center; gap: 6px;\n330\t}\n331\t.tag-chip:hover { color: var(--text); border-color: var(--card-border-hover); transform: translateY(-1px); }\n332\t.tag-chip.active {\n333\t  background: linear-gradient(135deg, #3B82F6, #2563EB);\n334\t  border-color: transparent;\n335\t  color: white;\n336\t  box-shadow: 0 4px 14px rgba(59,130,246,0.35);\n337\t}\n338\t.tag-chip .count {\n339\t  background: rgba(15,23,42,0.06);\n340\t  border-radius: 10px; padding: 1px 7px;\n341\t  font-size: 10px; font-weight: 700;\n342\t  color: var(--text-muted);\n343\t}\n344\t.tag-chip.active .count {\n345\t  background: rgba(255,255,255,0.22); color: white;\n346\t}\n347\t\n348\t.active-filter-bar {\n349\t  display: flex; flex-wrap: wrap; gap: 6px; align-items: center;\n350\t  margin-bottom: 16px;\n351\t}\n352\t.active-filter {\n353\t  background: rgba(59,130,246,0.1);\n354\t  border: 1px solid rgba(59,130,246,0.25);\n355\t  color: var(--blue-600);\n356\t  border-radius: 999px; padding: 4px 6px 4px 11px;\n357\t  font-size: 12px; font-weight: 600;\n358\t  display: flex; align-items: center; gap: 5px;\n359\t}\n360\t@media (prefers-color-scheme: dark) { .active-filter { color: var(--blue-400); } }\n361\t.active-filter .x {\n362\t  cursor: pointer; width: 18px; height: 18px;\n363\t  display: flex; align-items: center; justify-content: center;\n364\t  border-radius: 50%; opacity: 0.7;\n365\t  font-size: 13px; line-height: 1;\n366\t}\n367\t.active-filter .x:hover { opacity: 1; background: rgba(59,130,246,0.15); }\n368\t.clear-all-filters {\n369\t  background: transparent; border: none;\n370\t  color: var(--text-muted); font-size: 12px; cursor: pointer;\n371\t  padding: 4px 10px; font-family: inherit; font-weight: 600;\n372\t}\n373\t.clear-all-filters:hover { color: var(--text); }\n374\t\n375\t/* Upload */\n376\t.upload-zone { margin-bottom: 24px; }\n377\t.upload-box {\n378\t  display: flex; flex-direction: column; align-items: center;\n379\t  gap: 8px;\n380\t  border: 1.5px dashed rgba(59,130,246,0.3);\n381\t  border-radius: var(--radius);\n382\t  padding: 32px 20px;\n383\t  text-align: center; cursor: pointer;\n384\t  transition: var(--transition);\n385\t  background: linear-gradient(135deg, rgba(59,130,246,0.04), rgba(59,130,246,0.01));\n386\t  position: relative; overflow: hidden;\n387\t}\n388\t.upload-box::before {\n389\t  content: ''; position: absolute; inset: 0;\n390\t  background: radial-gradient(circle at center, rgba(59,130,246,0.08), transparent 60%);\n391\t  opacity: 0; transition: var(--transition);\n392\t}\n393\t.upload-box:hover::before, .upload-box.drag-over::before { opacity: 1; }\n394\t.upload-box:hover, .upload-box.drag-over {\n395\t  border-color: var(--blue-500);\n396\t  transform: translateY(-2px);\n397\t  box-shadow: var(--shadow-lg);\n398\t}\n399\t.upload-box .icon-circle {\n400\t  width: 48px; height: 48px;\n401\t  background: linear-gradient(135deg, #3B82F6, #1D4ED8);\n402\t  border-radius: 14px;\n403\t  display: flex; align-items: center; justify-content: center;\n404\t  box-shadow: 0 6px 20px rgba(59,130,246,0.4), inset 0 1px 0 rgba(255,255,255,0.25);\n405\t  position: relative; z-index: 1;\n406\t}\n407\t.upload-box .icon-circle svg { width: 22px; height: 22px; color: white; }\n408\t.upload-box .title { font-size: 15px; font-weight: 700; color: var(--text); position: relative; z-index: 1; }\n409\t.upload-box .hint { font-size: 12px; color: var(--text-muted); position: relative; z-index: 1; }\n410\t.upload-box input { display: none; }\n411\t\n412\t/* Upload Progress */\n413\t.upload-progress {\n414\t  margin-bottom: 18px;\n415\t  display: flex; flex-direction: column; gap: 8px;\n416\t}\n417\t.upload-item {\n418\t  background: var(--card);\n419\t  border: 1px solid var(--card-border);\n420\t  border-radius: var(--radius-sm);\n421\t  padding: 11px 14px;\n422\t  font-size: 13px;\n423\t  display: flex; align-items: center; gap: 12px;\n424\t  box-shadow: var(--shadow-sm);\n425\t  animation: slideInUp 0.3s var(--transition);\n426\t}\n427\t@keyframes slideInUp { from { opacity: 0; transform: translateY(6px); } }\n428\t.upload-item .upload-name {\n429\t  flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n430\t  color: var(--text); font-weight: 500;\n431\t}\n432\t.upload-item .upload-bar {\n433\t  width: 100px; height: 5px;\n434\t  background: var(--card-border); border-radius: 999px;\n435\t  overflow: hidden;\n436\t}\n437\t.upload-item .upload-bar-fill {\n438\t  height: 100%; background: linear-gradient(90deg, var(--blue-500), var(--blue-700));\n439\t  width: 0%; transition: width 0.25s linear;\n440\t  border-radius: 999px;\n441\t}\n442\t.upload-item.done .upload-bar-fill { background: var(--green); width: 100%; }\n443\t.upload-item.error { border-color: rgba(239,68,68,0.3); }\n444\t.upload-item.error .upload-bar-fill { background: var(--red); width: 100%; }\n445\t.upload-item .status-icon {\n446\t  width: 22px; height: 22px; border-radius: 50%;\n447\t  display: flex; align-items: center; justify-content: center;\n448\t  font-size: 12px; font-weight: 700;\n449\t}\n450\t.upload-item.done .status-icon { background: rgba(16,185,129,0.15); color: var(--green); }\n451\t.upload-item.error .status-icon { background: rgba(239,68,68,0.15); color: var(--red); }\n452\t\n453\t/* ── Grid ────────────────────────────────── */\n454\t.grid {\n455\t  display: grid;\n456\t  grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));\n457\t  gap: 16px;\n458\t  padding-bottom: 60px;\n459\t}\n460\t\n461\t.card {\n462\t  position: relative;\n463\t  background: var(--card);\n464\t  border: 1px solid var(--card-border);\n465\t  border-radius: var(--radius);\n466\t  overflow: hidden;\n467\t  transition: var(--transition);\n468\t  cursor: pointer;\n469\t  -webkit-tap-highlight-color: transparent;\n470\t  backdrop-filter: blur(20px);\n471\t  -webkit-backdrop-filter: blur(20px);\n472\t  box-shadow: var(--shadow-sm);\n473\t}\n474\t.card:hover {\n475\t  border-color: var(--card-border-hover);\n476\t  background: var(--card-hover);\n477\t  transform: translateY(-3px);\n478\t  box-shadow: var(--shadow-lg);\n479\t}\n480\t.card:hover .quick-actions { opacity: 1; transform: translateY(0); }\n481\t.card:active { transform: translateY(-1px) scale(0.99); }\n482\t.card.removing { opacity: 0; transform: scale(0.92); pointer-events: none; }\n483\t\n484\t.card .quick-actions {\n485\t  position: absolute; top: 10px; right: 10px;\n486\t  display: flex; gap: 5px;\n487\t  opacity: 0; transform: translateY(-6px);\n488\t  transition: var(--transition);\n489\t  z-index: 2;\n490\t}\n491\t.quick-btn {\n492\t  width: 32px; height: 32px; border-radius: 10px;\n493\t  background: rgba(15,23,42,0.7);\n494\t  backdrop-filter: blur(12px);\n495\t  -webkit-backdrop-filter: blur(12px);\n496\t  border: 1px solid rgba(255,255,255,0.12);\n497\t  color: white; cursor: pointer;\n498\t  display: flex; align-items: center; justify-content: center;\n499\t  transition: var(--transition-fast);\n500\t  box-shadow: 0 4px 12px rgba(0,0,0,0.25);\n501\t}\n502\t.quick-btn:hover { background: var(--blue-600); transform: scale(1.06); }\n503\t.quick-btn.danger:hover { background: var(--red); }\n504\t.quick-btn svg { width: 14px; height: 14px; }\n505\t\n506\t@media (hover: none) {\n507\t  .card .quick-actions { opacity: 1; transform: none; }\n508\t}\n509\t\n510\t.card .thumb {\n511\t  width: 100%; aspect-ratio: 1;\n512\t  background: var(--thumb-bg);\n513\t  display: flex; align-items: center; justify-content: center;\n514\t  position: relative;\n515\t  overflow: hidden;\n516\t}\n517\t.card .thumb::before {\n518\t  content: ''; position: absolute; inset: 0;\n519\t  background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.5), transparent 50%);\n520\t  pointer-events: none;\n521\t}\n522\t@media (prefers-color-scheme: dark) {\n523\t  .card .thumb::before {\n524\t    background: radial-gradient(circle at 30% 25%, rgba(96,165,250,0.15), transparent 50%);\n525\t  }\n526\t}\n527\t.card .thumb img {\n528\t  width: 92%; height: 92%; object-fit: contain;\n529\t  filter: drop-shadow(0 6px 12px rgba(37,99,235,0.25));\n530\t  transition: var(--transition);\n531\t}\n532\t.card:hover .thumb img { transform: scale(1.04); }\n533\t.card .thumb .placeholder {\n534\t  font-size: 38px; opacity: 0.5;\n535\t}\n536\t.card .pending-overlay {\n537\t  position: absolute; inset: 0;\n538\t  display: flex; flex-direction: column;\n539\t  align-items: center; justify-content: center;\n540\t  gap: 6px;\n541\t  background: rgba(15,23,42,0.35);\n542\t  backdrop-filter: blur(2px);\n543\t  color: white; font-size: 10px; font-weight: 700;\n544\t  letter-spacing: 0.08em;\n545\t  text-transform: uppercase;\n546\t}\n547\t.mini-spinner {\n548\t  width: 18px; height: 18px;\n549\t  border: 2px solid rgba(255,255,255,0.25);\n550\t  border-top-color: white;\n551\t  border-radius: 50%;\n552\t  animation: spin 0.8s linear infinite;\n553\t}\n554\t.card .info { padding: 12px 14px 14px; }\n555\t.card .name {\n556\t  font-size: 13px; font-weight: 600; line-height: 1.35;\n557\t  display: -webkit-box;\n558\t  -webkit-line-clamp: 2; -webkit-box-orient: vertical;\n559\t  overflow: hidden;\n560\t  margin-bottom: 8px;\n561\t  letter-spacing: -0.01em;\n562\t}\n563\t.card .meta {\n564\t  display: flex; align-items: center; justify-content: space-between;\n565\t  font-size: 11px; color: var(--text-muted); font-weight: 500;\n566\t}\n567\t.card .badge {\n568\t  display: inline-flex; align-items: center; gap: 3px;\n569\t  font-size: 10px; font-weight: 700; padding: 3px 8px;\n570\t  border-radius: 999px;\n571\t  letter-spacing: 0.02em;\n572\t}\n573\t.badge-ok { background: rgba(16,185,129,0.12); color: var(--green); }\n574\t.badge-warn { background: rgba(245,158,11,0.12); color: var(--orange); }\n575\t.badge-pending { background: rgba(148,163,184,0.15); color: var(--text-muted); }\n576\t\n577\t/* Skeleton */\n578\t.skeleton-card {\n579\t  background: var(--card);\n580\t  border: 1px solid var(--card-border);\n581\t  border-radius: var(--radius);\n582\t  overflow: hidden;\n583\t  position: relative;\n584\t}\n585\t.skeleton-card .sk-thumb { width: 100%; aspect-ratio: 1; background: var(--shimmer); }\n586\t.skeleton-card .sk-info { padding: 12px 14px 14px; }\n587\t.skeleton-card .sk-line {\n588\t  height: 11px; background: var(--shimmer);\n589\t  border-radius: 4px; margin-bottom: 7px;\n590\t}\n591\t.skeleton-card .sk-line.short { width: 50%; height: 9px; }\n592\t.skeleton-card::after {\n593\t  content: ''; position: absolute; inset: 0;\n594\t  background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent);\n595\t  animation: shimmer 1.6s infinite;\n596\t  pointer-events: none;\n597\t}\n598\t@media (prefers-color-scheme: dark) {\n599\t  .skeleton-card::after {\n600\t    background: linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent);\n601\t  }\n602\t}\n603\t@keyframes shimmer {\n604\t  from { transform: translateX(-100%); }\n605\t  to { transform: translateX(100%); }\n606\t}\n607\t@keyframes spin { to { transform: rotate(360deg); } }\n608\t\n609\t/* Empty State */\n610\t.empty-state {\n611\t  text-align: center; padding: 80px 20px;\n612\t  grid-column: 1 / -1;\n613\t}\n614\t.empty-state .icon {\n615\t  width: 72px; height: 72px; margin: 0 auto 20px;\n616\t  background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(59,130,246,0.02));\n617\t  border-radius: 22px;\n618\t  display: flex; align-items: center; justify-content: center;\n619\t  border: 1px solid rgba(59,130,246,0.15);\n620\t}\n621\t.empty-state .icon svg { width: 32px; height: 32px; color: var(--blue-500); }\n622\t.empty-state h3 { font-size: 17px; font-weight: 700; margin-bottom: 6px; letter-spacing: -0.01em; }\n623\t.empty-state p { font-size: 13px; color: var(--text-muted); max-width: 280px; margin: 0 auto; }\n624\t\n625\t/* ── Modal ──────────────────────────────── */\n626\t.modal-overlay {\n627\t  position: fixed; inset: 0; z-index: 500;\n628\t  background: rgba(8,12,24,0.55);\n629\t  backdrop-filter: blur(10px);\n630\t  -webkit-backdrop-filter: blur(10px);\n631\t  display: flex; align-items: flex-end; justify-content: center;\n632\t  animation: fadeIn 0.25s ease;\n633\t}\n634\t@keyframes fadeIn { from { opacity: 0; } }\n635\t@keyframes slideUp { from { transform: translateY(100%); } }\n636\t\n637\t.modal {\n638\t  background: var(--modal-bg);\n639\t  backdrop-filter: blur(28px) saturate(180%);\n640\t  -webkit-backdrop-filter: blur(28px) saturate(180%);\n641\t  border: 1px solid var(--card-border);\n642\t  border-bottom: none;\n643\t  border-radius: 24px 24px 0 0;\n644\t  width: 100%; max-width: 560px;\n645\t  max-height: 92vh; max-height: 92dvh;\n646\t  overflow-y: auto;\n647\t  animation: slideUp 0.4s cubic-bezier(0.32, 0.72, 0, 1);\n648\t  padding: 18px 22px 44px;\n649\t  -webkit-overflow-scrolling: touch;\n650\t  box-shadow: 0 -16px 60px rgba(0,0,0,0.35);\n651\t  position: relative;\n652\t}\n653\t.modal-handle {\n654\t  width: 40px; height: 4px; border-radius: 999px;\n655\t  background: var(--card-border);\n656\t  margin: 0 auto 18px;\n657\t}\n658\t.modal h2 {\n659\t  font-size: 20px; font-weight: 800; margin-bottom: 4px;\n660\t  padding-right: 44px; color: var(--text);\n661\t  letter-spacing: -0.02em;\n662\t}\n663\t.modal .modal-sub {\n664\t  font-size: 12px; color: var(--text-muted);\n665\t  margin-bottom: 16px; font-weight: 500;\n666\t}\n667\t.modal .close-btn {\n668\t  position: absolute; top: 18px; right: 18px;\n669\t  width: 36px; height: 36px; border-radius: 50%;\n670\t  background: var(--card);\n671\t  border: 1px solid var(--card-border);\n672\t  color: var(--text-dim); font-size: 15px;\n673\t  cursor: pointer; display: flex;\n674\t  align-items: center; justify-content: center;\n675\t  transition: var(--transition);\n676\t  z-index: 2;\n677\t}\n678\t.modal .close-btn:hover { background: var(--bg-elev); color: var(--text); }\n679\t\n680\t/* 3D Viewer */\n681\t.viewer {\n682\t  width: 100%; aspect-ratio: 1;\n683\t  background: var(--thumb-bg);\n684\t  border-radius: var(--radius-sm);\n685\t  margin-bottom: 18px;\n686\t  position: relative;\n687\t  overflow: hidden;\n688\t  border: 1px solid var(--card-border);\n689\t}\n690\t.viewer canvas { display: block; width: 100% !important; height: 100% !important; }\n691\t.viewer .viewer-overlay {\n692\t  position: absolute; inset: 0;\n693\t  display: flex; align-items: center; justify-content: center;\n694\t  color: var(--text-muted); font-size: 12px;\n695\t  pointer-events: none;\n696\t}\n697\t.viewer .viewer-hint {\n698\t  position: absolute; bottom: 10px; left: 12px;\n699\t  font-size: 10px; font-weight: 600; letter-spacing: 0.04em;\n700\t  color: var(--text-muted); text-transform: uppercase;\n701\t  background: var(--card); border: 1px solid var(--card-border);\n702\t  border-radius: 999px; padding: 4px 10px;\n703\t  pointer-events: none;\n704\t  opacity: 0.85;\n705\t}\n706\t.viewer .reset-cam {\n707\t  position: absolute; top: 10px; right: 10px;\n708\t  width: 30px; height: 30px; border-radius: 9px;\n709\t  background: var(--card); border: 1px solid var(--card-border);\n710\t  color: var(--text-dim); cursor: pointer;\n711\t  display: flex; align-items: center; justify-content: center;\n712\t  z-index: 3;\n713\t}\n714\t.viewer .reset-cam:hover { color: var(--blue-500); }\n715\t.viewer .reset-cam svg { width: 14px; height: 14px; }\n716\t\n717\t.stats-grid {\n718\t  display: grid; grid-template-columns: 1fr 1fr;\n719\t  gap: 8px; margin-bottom: 20px;\n720\t}\n721\t.stat-card {\n722\t  background: var(--card);\n723\t  border: 1px solid var(--card-border);\n724\t  border-radius: var(--radius-xs);\n725\t  padding: 12px 14px;\n726\t  transition: var(--transition);\n727\t}\n728\t.stat-card:hover { border-color: var(--card-border-hover); transform: translateY(-1px); }\n729\t.stat-card .label {\n730\t  font-size: 10px; font-weight: 700;\n731\t  color: var(--text-muted);\n732\t  text-transform: uppercase; letter-spacing: 0.06em;\n733\t}\n734\t.stat-card .value {\n735\t  font-size: 15px; font-weight: 700; margin-top: 4px;\n736\t  color: var(--text); letter-spacing: -0.01em;\n737\t}\n738\t.stat-card.full { grid-column: 1 / -1; }\n739\t.stat-card.highlight {\n740\t  background: linear-gradient(135deg, rgba(59,130,246,0.08), rgba(37,99,235,0.02));\n741\t  border-color: rgba(59,130,246,0.2);\n742\t}\n743\t.stat-card.highlight .value { color: var(--blue-600); }\n744\t@media (prefers-color-scheme: dark) { .stat-card.highlight .value { color: var(--blue-400); } }\n745\t\n746\t.field { margin-bottom: 14px; }\n747\t.field label {\n748\t  display: block; font-size: 10px; font-weight: 700;\n749\t  color: var(--text-muted); margin-bottom: 6px;\n750\t  text-transform: uppercase; letter-spacing: 0.06em;\n751\t}\n752\t.field input, .field textarea {\n753\t  width: 100%; padding: 11px 14px;\n754\t  background: var(--card);\n755\t  border: 1px solid var(--card-border);\n756\t  border-radius: var(--radius-xs);\n757\t  font-size: 14px; color: var(--text);\n758\t  font-family: inherit; font-weight: 500; outline: none;\n759\t  -webkit-appearance: none;\n760\t  transition: var(--transition);\n761\t}\n762\t.field input:focus, .field textarea:focus {\n763\t  border-color: var(--blue-500);\n764\t  background: var(--bg-elev);\n765\t  box-shadow: 0 0 0 4px rgba(59,130,246,0.12);\n766\t}\n767\t.field textarea { resize: vertical; min-height: 70px; font-weight: 400; }\n768\t\n769\t.tag-bar {\n770\t  display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; min-height: 30px;\n771\t}\n772\t.tag {\n773\t  background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(37,99,235,0.06));\n774\t  border: 1px solid rgba(59,130,246,0.22);\n775\t  border-radius: 999px; padding: 5px 6px 5px 13px;\n776\t  font-size: 12px; font-weight: 600;\n777\t  color: var(--blue-600);\n778\t  display: inline-flex; align-items: center; gap: 5px;\n779\t  animation: tagIn 0.25s var(--transition);\n780\t}\n781\t@media (prefers-color-scheme: dark) { .tag { color: var(--blue-300); } }\n782\t@keyframes tagIn { from { opacity: 0; transform: scale(0.85); } }\n783\t.tag .remove {\n784\t  cursor: pointer; width: 18px; height: 18px; border-radius: 50%;\n785\t  display: flex; align-items: center; justify-content: center;\n786\t  opacity: 0.6; font-size: 13px; line-height: 1;\n787\t}\n788\t.tag .remove:hover { opacity: 1; background: rgba(59,130,246,0.18); }\n789\t\n790\t.tag-add {\n791\t  display: flex; gap: 6px; margin-top: 8px;\n792\t}\n793\t.tag-add input {\n794\t  flex: 1; padding: 10px 14px;\n795\t  background: var(--card);\n796\t  border: 1px solid var(--card-border);\n797\t  border-radius: 999px; font-size: 13px;\n798\t  color: var(--text); font-family: inherit; outline: none;\n799\t  -webkit-appearance: none; font-weight: 500;\n800\t  transition: var(--transition);\n801\t}\n802\t.tag-add input:focus { border-color: var(--blue-500); background: var(--bg-elev); }\n803\t.tag-add button {\n804\t  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));\n805\t  color: white; border: none; border-radius: 999px;\n806\t  padding: 10px 18px; font-size: 13px;\n807\t  font-weight: 700; cursor: pointer;\n808\t  transition: var(--transition);\n809\t  box-shadow: 0 4px 14px rgba(59,130,246,0.3);\n810\t}\n811\t.tag-add button:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(59,130,246,0.4); }\n812\t.tag-add button:active { transform: translateY(0); }\n813\t\n814\t.actions {\n815\t  display: flex; gap: 8px; margin-top: 24px;\n816\t}\n817\t.actions button {\n818\t  flex: 1; padding: 13px 16px;\n819\t  border-radius: var(--radius-xs);\n820\t  font-size: 14px; font-weight: 700;\n821\t  border: none; cursor: pointer;\n822\t  font-family: inherit;\n823\t  transition: var(--transition);\n824\t  display: flex; align-items: center; justify-content: center; gap: 7px;\n825\t}\n826\t.actions button svg { width: 15px; height: 15px; }\n827\t.btn-save {\n828\t  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));\n829\t  color: white;\n830\t  box-shadow: 0 6px 18px rgba(59,130,246,0.35);\n831\t}\n832\t.btn-save:hover { transform: translateY(-1px); box-shadow: 0 8px 22px rgba(59,130,246,0.45); }\n833\t.btn-delete {\n834\t  background: rgba(239,68,68,0.1);\n835\t  border: 1px solid rgba(239,68,68,0.22) !important;\n836\t  color: var(--red);\n837\t}\n838\t.btn-delete:hover { background: rgba(239,68,68,0.18); }\n839\t.btn-download {\n840\t  background: var(--card);\n841\t  border: 1px solid var(--card-border);\n842\t  color: var(--text-dim);\n843\t}\n844\t.btn-download:hover { color: var(--text); border-color: var(--card-border-hover); }\n845\t\n846\t/* Toast */\n847\t.toast {\n848\t  position: fixed; top: 24px; left: 50%; transform: translateX(-50%);\n849\t  background: var(--modal-bg);\n850\t  backdrop-filter: blur(20px);\n851\t  -webkit-backdrop-filter: blur(20px);\n852\t  border: 1px solid var(--card-border);\n853\t  color: var(--text);\n854\t  padding: 12px 18px;\n855\t  border-radius: 999px;\n856\t  font-size: 13px; font-weight: 600;\n857\t  z-index: 1100;\n858\t  box-shadow: var(--shadow-xl);\n859\t  display: flex; align-items: center; gap: 12px;\n860\t  animation: toastIn 0.3s var(--transition);\n861\t  max-width: calc(100vw - 32px);\n862\t}\n863\t.toast-action {\n864\t  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));\n865\t  color: white; border: none; border-radius: 999px;\n866\t  padding: 6px 14px; font-size: 12px; font-weight: 700;\n867\t  cursor: pointer; font-family: inherit;\n868\t  box-shadow: 0 4px 10px rgba(59,130,246,0.3);\n869\t}\n870\t.toast.fade-out { animation: toastOut 0.3s ease forwards; }\n871\t@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(-12px); } }\n872\t@keyframes toastOut { to { opacity: 0; transform: translateX(-50%) translateY(-12px); } }\n873\t\n874\t/* Mobile */\n875\t@media (max-width: 480px) {\n876\t  .content { padding: 16px; }\n877\t  .grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }\n878\t  .page-title { font-size: 22px; }\n879\t  .header-brand h1 { font-size: 17px; }\n880\t  .stat-pill { font-size: 11px; padding: 5px 10px; }\n881\t  .stats-grid { grid-template-columns: 1fr 1fr; gap: 6px; }\n882\t}\n883\t</style>\n884\t</head>\n885\t<body>\n886\t\n887\t<header>\n888\t  <div class=\"header-inner\">\n889\t    <div class=\"header-brand\">\n890\t      <div class=\"header-icon\">\n891\t        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n892\t          <path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path>\n893\t          <polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"></polyline>\n894\t          <line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"></line>\n895\t        </svg>\n896\t      </div>\n897\t      <div>\n898\t        <h1>PrintVault</h1>\n899\t        <div class=\"sub\">STL-Bibliothek</div>\n900\t      </div>\n901\t    </div>\n902\t    <div class=\"header-actions\">\n903\t      <div class=\"stats-pills\">\n904\t        <span class=\"stat-pill\"><span class=\"dot dot-ok\"></span> <span class=\"val\" id=\"stat-ok\">0</span> druckbar</span>\n905\t        <span class=\"stat-pill\"><span class=\"val\" id=\"stat-total\">0</span> Modelle</span>\n906\t        <span class=\"stat-pill\" title=\"Gesamtkosten aller Modelle\">≈ <span class=\"val\" id=\"stat-cost\">0,00 €</span></span>\n907\t      </div>\n908\t      <button class=\"price-pill\" id=\"price-pill\" title=\"Filamentpreis bearbeiten\">\n909\t        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"></circle><path d=\"M9 9h6M9 15h6M12 7v10\"></path></svg>\n910\t        <span id=\"price-pill-text\">14,71 €/Rolle</span>\n911\t        <span class=\"edit-hint\">bearbeiten</span>\n912\t      </button>\n913\t      <button class=\"icon-btn\" id=\"backup-btn\" title=\"Backup herunterladen\" aria-label=\"Backup\">\n914\t        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n915\t          <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"7 10 12 15 17 10\"></polyline><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line>\n916\t        </svg>\n917\t      </button>\n918\t    </div>\n919\t  </div>\n920\t</header>\n921\t\n922\t<div class=\"content\">\n923\t  <div class=\"page-title\">Deine Modelle</div>\n924\t  <div class=\"page-subtitle\">Suche, organisiere und drucke deine STL-Sammlung.</div>\n925\t\n926\t  <div class=\"controls\">\n927\t    <div class=\"search-wrap\">\n928\t      <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"></circle><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line></svg>\n929\t      <input type=\"text\" class=\"search-box\" placeholder=\"Suchen…\" id=\"search\" oninput=\"onSearchInput()\" autocomplete=\"off\">\n930\t    </div>\n931\t    <select class=\"sort-btn\" id=\"sort\" onchange=\"applyFilters()\">\n932\t      <option value=\"date\">Neueste</option>\n933\t      <option value=\"name\">Name</option>\n934\t      <option value=\"size\">Größe</option>\n935\t      <option value=\"volume\">Volumen</option>\n936\t    </select>\n937\t  </div>\n938\t\n939\t  <div class=\"tag-chips\" id=\"tag-chips\"></div>\n940\t  <div class=\"active-filter-bar\" id=\"active-filters\" style=\"display:none\"></div>\n941\t\n942\t  <div class=\"upload-zone\">\n943\t    <label class=\"upload-box\" id=\"dropZone\">\n944\t      <div class=\"icon-circle\">\n945\t        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"17 8 12 3 7 8\"></polyline><line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"></line></svg>\n946\t      </div>\n947\t      <div class=\"title\">STL hochladen</div>\n948\t      <div class=\"hint\">Drag & Drop oder klicken — auch mehrere Dateien</div>\n949\t      <input type=\"file\" accept=\".stl\" id=\"fileInput\" multiple onchange=\"onFilesPicked(this)\">\n950\t    </label>\n951\t  </div>\n952\t\n953\t  <div class=\"upload-progress\" id=\"upload-progress\"></div>\n954\t\n955\t  <div class=\"grid\" id=\"grid\"></div>\n956\t</div>\n957\t\n958\t<script type=\"module\">\n959\timport * as THREE from 'three';\n960\timport { OrbitControls } from 'three/addons/controls/OrbitControls.js';\n961\timport { STLLoader } from 'three/addons/loaders/STLLoader.js';\n962\t\n963\tlet viewerState = null;\n964\t\n965\tfunction disposeViewer() {\n966\t  if (!viewerState) return;\n967\t  cancelAnimationFrame(viewerState.raf);\n968\t  viewerState.controls.dispose();\n969\t  viewerState.renderer.dispose();\n970\t  viewerState.geometry?.dispose();\n971\t  viewerState.material?.dispose();\n972\t  if (viewerState.resizeObs) viewerState.resizeObs.disconnect();\n973\t  if (viewerState.renderer.domElement.parentNode) {\n974\t    viewerState.renderer.domElement.parentNode.removeChild(viewerState.renderer.domElement);\n975\t  }\n976\t  viewerState = null;\n977\t}\n978\t\n979\twindow.openSTLViewer = async function(container, url) {\n980\t  disposeViewer();\n981\t  const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n982\t  const scene = new THREE.Scene();\n983\t\n984\t  const w = container.clientWidth || 400;\n985\t  const h = container.clientHeight || 400;\n986\t  const camera = new THREE.PerspectiveCamera(40, w / h, 0.1, 5000);\n987\t\n988\t  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });\n989\t  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n990\t  renderer.setSize(w, h);\n991\t  renderer.setClearColor(0x000000, 0);\n992\t  container.appendChild(renderer.domElement);\n993\t\n994\t  // Lights\n995\t  const ambient = new THREE.AmbientLight(0xffffff, dark ? 0.35 : 0.55);\n996\t  scene.add(ambient);\n997\t  const key = new THREE.DirectionalLight(0xffffff, dark ? 1.1 : 0.9);\n998\t  key.position.set(3, 5, 4); scene.add(key);\n999\t  const fill = new THREE.DirectionalLight(0x93C5FD, dark ? 0.55 : 0.35);\n1000\t  fill.position.set(-4, -2, 3); scene.add(fill);\n1001\t  const rim = new THREE.DirectionalLight(0x60A5FA, dark ? 0.6 : 0.3);\n1002\t  rim.position.set(0, -3, -4); scene.add(rim);\n1003\t\n1004\t  const controls = new OrbitControls(camera, renderer.domElement);\n1005\t  controls.enableDamping = true;\n1006\t  controls.dampingFactor = 0.1;\n1007\t  controls.rotateSpeed = 0.9;\n1008\t  controls.minDistance = 1;\n1009\t  controls.maxDistance = 5000;\n1010\t  controls.enablePan = false;\n1011\t\n1012\t  let geometry = null, material = null, mesh = null;\n1013\t  const initialCam = new THREE.Vector3();\n1014\t\n1015\t  try {\n1016\t    const loader = new STLLoader();\n1017\t    const buf = await fetch(url).then(r => r.arrayBuffer());\n1018\t    geometry = loader.parse(buf);\n1019\t    geometry.computeVertexNormals();\n1020\t    geometry.center();\n1021\t\n1022\t    material = new THREE.MeshStandardMaterial({\n1023\t      color: 0x3B82F6,\n1024\t      metalness: 0.15,\n1025\t      roughness: 0.55,\n1026\t      flatShading: false,\n1027\t    });\n1028\t    mesh = new THREE.Mesh(geometry, material);\n1029\t\n1030\t    // STL ist typischerweise Z-up, three.js Y-up → 90° kippen\n1031\t    mesh.rotation.x = -Math.PI / 2;\n1032\t    scene.add(mesh);\n1033\t\n1034\t    // Fit camera\n1035\t    const box = new THREE.Box3().setFromObject(mesh);\n1036\t    const size = box.getSize(new THREE.Vector3());\n1037\t    const maxDim = Math.max(size.x, size.y, size.z);\n1038\t    const dist = maxDim / (2 * Math.tan(Math.PI * camera.fov / 360)) * 1.6;\n1039\t    camera.position.set(dist * 0.7, dist * 0.55, dist * 0.9);\n1040\t    camera.lookAt(0, 0, 0);\n1041\t    controls.target.set(0, 0, 0);\n1042\t    initialCam.copy(camera.position);\n1043\t    controls.update();\n1044\t  } catch (e) {\n1045\t    console.error('STL load error', e);\n1046\t  }\n1047\t\n1048\t  function onResize() {\n1049\t    const W = container.clientWidth, H = container.clientHeight;\n1050\t    if (!W || !H) return;\n1051\t    camera.aspect = W / H;\n1052\t    camera.updateProjectionMatrix();\n1053\t    renderer.setSize(W, H);\n1054\t  }\n1055\t  const resizeObs = new ResizeObserver(onResize);\n1056\t  resizeObs.observe(container);\n1057\t\n1058\t  let raf;\n1059\t  function animate() {\n1060\t    raf = requestAnimationFrame(animate);\n1061\t    controls.update();\n1062\t    renderer.render(scene, camera);\n1063\t  }\n1064\t  animate();\n1065\t\n1066\t  const resetBtn = container.querySelector('.reset-cam');\n1067\t  if (resetBtn) {\n1068\t    resetBtn.onclick = e => {\n1069\t      e.stopPropagation();\n1070\t      camera.position.copy(initialCam);\n1071\t      controls.target.set(0, 0, 0);\n1072\t      controls.update();\n1073\t    };\n1074\t  }\n1075\t\n1076\t  viewerState = { renderer, controls, scene, camera, raf, geometry, material, mesh, resizeObs };\n1077\t};\n1078\t\n1079\twindow.closeSTLViewer = disposeViewer;\n1080\t</script>\n1081\t\n1082\t<script>\n1083\tconst API = '/api';\n1084\tlet allFiles = [];\n1085\tlet activeTags = new Set();\n1086\tlet pendingDeletes = {};\n1087\tlet pollTimer = null;\n1088\tlet searchDebounce = null;\n1089\t\n1090\t// ─── Filament-Preis (lokal gespeichert) ─────────────────\n1091\tconst PRICE_KEY = 'printvault.filament_price_eur';\n1092\tconst ROLL_KEY = 'printvault.roll_weight_g';\n1093\tfunction getPricePerRoll() { return parseFloat(localStorage.getItem(PRICE_KEY) ?? '14.71'); }\n1094\tfunction getRollWeight() { return parseFloat(localStorage.getItem(ROLL_KEY) ?? '1000'); }\n1095\tfunction pricePerGram() { return getPricePerRoll() / getRollWeight(); }\n1096\tfunction fmtEUR(v) {\n1097\t  if (v == null || isNaN(v)) return '—';\n1098\t  return v.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2 });\n1099\t}\n1100\tfunction costOf(grams) {\n1101\t  if (!grams) return 0;\n1102\t  return grams * pricePerGram();\n1103\t}\n1104\tfunction updatePricePill() {\n1105\t  const t = document.getElementById('price-pill-text');\n1106\t  if (t) t.textContent = fmtEUR(getPricePerRoll()) + '/Rolle';\n1107\t}\n1108\tfunction openPriceDialog() {\n1109\t  const dlg = document.createElement('div');\n1110\t  dlg.className = 'settings-dialog';\n1111\t  dlg.onclick = e => { if (e.target === dlg) dlg.remove(); };\n1112\t  dlg.innerHTML = `\n1113\t    <div class=\"settings-card\" onclick=\"event.stopPropagation()\">\n1114\t      <h3>Filamentpreis</h3>\n1115\t      <p class=\"desc\">Preis pro Rolle und Rollengewicht. Wird zur Kostenberechnung pro Modell verwendet.</p>\n1116\t      <div class=\"row\">\n1117\t        <div class=\"field\"><label>Preis pro Rolle (€)</label>\n1118\t          <input type=\"number\" id=\"set-price\" step=\"0.01\" min=\"0\" value=\"${getPricePerRoll().toFixed(2)}\">\n1119\t        </div>\n1120\t        <div class=\"field\"><label>Rollengewicht (g)</label>\n1121\t          <input type=\"number\" id=\"set-weight\" step=\"50\" min=\"50\" value=\"${getRollWeight()}\">\n1122\t        </div>\n1123\t      </div>\n1124\t      <div class=\"preview\" id=\"set-preview\"></div>\n1125\t      <div class=\"actions\">\n1126\t        <button class=\"btn-download\" id=\"set-cancel\">Abbrechen</button>\n1127\t        <button class=\"btn-save\" id=\"set-save\">Speichern</button>\n1128\t      </div>\n1129\t    </div>`;\n1130\t  document.body.appendChild(dlg);\n1131\t  const pInput = dlg.querySelector('#set-price');\n1132\t  const wInput = dlg.querySelector('#set-weight');\n1133\t  const preview = dlg.querySelector('#set-preview');\n1134\t  const refresh = () => {\n1135\t    const p = parseFloat(pInput.value) || 0;\n1136\t    const w = parseFloat(wInput.value) || 1;\n1137\t    const perG = p / w;\n1138\t    preview.innerHTML = `Pro Gramm: <b>${fmtEUR(perG)}</b> · 100 g Druck ≈ <b>${fmtEUR(perG * 100)}</b>`;\n1139\t  };\n1140\t  pInput.oninput = refresh; wInput.oninput = refresh; refresh();\n1141\t  dlg.querySelector('#set-cancel').onclick = () => dlg.remove();\n1142\t  dlg.querySelector('#set-save').onclick = () => {\n1143\t    const p = parseFloat(pInput.value);\n1144\t    const w = parseFloat(wInput.value);\n1145\t    if (p > 0 && w > 0) {\n1146\t      localStorage.setItem(PRICE_KEY, String(p));\n1147\t      localStorage.setItem(ROLL_KEY, String(w));\n1148\t      updatePricePill();\n1149\t      applyFilters();\n1150\t      updateTotalCost();\n1151\t      toast(`Filamentpreis: ${fmtEUR(p)} / ${w} g`);\n1152\t    }\n1153\t    dlg.remove();\n1154\t  };\n1155\t}\n1156\tfunction updateTotalCost() {\n1157\t  const total = allFiles.reduce((s, f) => s + costOf(f.analysis?.filament_grams || 0), 0);\n1158\t  const el = document.getElementById('stat-cost');\n1159\t  if (el) el.textContent = fmtEUR(total);\n1160\t}\n1161\t\n1162\tfunction toast(msg, opts = {}) {\n1163\t  document.getElementById('toast')?.remove();\n1164\t  const t = document.createElement('div');\n1165\t  t.id = 'toast'; t.className = 'toast';\n1166\t  t.innerHTML = `<span>${escapeHTML(msg)}</span>`;\n1167\t  if (opts.action) {\n1168\t    const btn = document.createElement('button');\n1169\t    btn.className = 'toast-action';\n1170\t    btn.textContent = opts.action.label;\n1171\t    btn.onclick = () => { opts.action.onClick(); t.remove(); };\n1172\t    t.appendChild(btn);\n1173\t  }\n1174\t  document.body.appendChild(t);\n1175\t  setTimeout(() => {\n1176\t    t.classList.add('fade-out');\n1177\t    setTimeout(() => t.remove(), 300);\n1178\t  }, opts.duration || 2600);\n1179\t  return t;\n1180\t}\n1181\t\n1182\tfunction escapeHTML(s) {\n1183\t  const d = document.createElement('div');\n1184\t  d.textContent = String(s ?? '');\n1185\t  return d.innerHTML;\n1186\t}\n1187\t\n1188\tconst dropZone = document.getElementById('dropZone');\n1189\tdropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });\n1190\tdropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));\n1191\tdropZone.addEventListener('drop', e => {\n1192\t  e.preventDefault(); dropZone.classList.remove('drag-over');\n1193\t  if (e.dataTransfer.files.length) uploadMany([...e.dataTransfer.files]);\n1194\t});\n1195\t\n1196\tfunction onFilesPicked(input) {\n1197\t  if (input.files.length) uploadMany([...input.files]);\n1198\t  input.value = '';\n1199\t}\n1200\t\n1201\tasync function uploadMany(files) {\n1202\t  const stls = files.filter(f => f.name.toLowerCase().endsWith('.stl'));\n1203\t  const skipped = files.length - stls.length;\n1204\t  if (skipped > 0) toast(`${skipped} nicht-STL Datei(en) übersprungen`);\n1205\t  if (!stls.length) return;\n1206\t  const queue = stls.slice();\n1207\t  const PARALLEL = 3;\n1208\t  const workers = Array.from({length: Math.min(PARALLEL, queue.length)}, () => (async () => {\n1209\t    while (queue.length) await uploadOne(queue.shift());\n1210\t  })());\n1211\t  await Promise.all(workers);\n1212\t  toast(`${stls.length} Datei(en) hochgeladen`);\n1213\t  await loadFiles();\n1214\t}\n1215\t\n1216\tfunction uploadOne(file) {\n1217\t  return new Promise(resolve => {\n1218\t    const item = document.createElement('div');\n1219\t    item.className = 'upload-item';\n1220\t    item.innerHTML = `\n1221\t      <span class=\"upload-name\">${escapeHTML(file.name)}</span>\n1222\t      <div class=\"upload-bar\"><div class=\"upload-bar-fill\"></div></div>\n1223\t      <span class=\"status-icon\">⏳</span>`;\n1224\t    document.getElementById('upload-progress').appendChild(item);\n1225\t    const fill = item.querySelector('.upload-bar-fill');\n1226\t    const statusIcon = item.querySelector('.status-icon');\n1227\t    const form = new FormData(); form.append('file', file);\n1228\t    const xhr = new XMLHttpRequest();\n1229\t    xhr.open('POST', API + '/upload');\n1230\t    xhr.upload.onprogress = e => { if (e.lengthComputable) fill.style.width = ((e.loaded / e.total) * 100) + '%'; };\n1231\t    xhr.onload = () => {\n1232\t      if (xhr.status >= 200 && xhr.status < 300) { item.classList.add('done'); statusIcon.textContent = '✓'; }\n1233\t      else { item.classList.add('error'); statusIcon.textContent = '✕'; }\n1234\t      setTimeout(() => item.remove(), 2500); resolve();\n1235\t    };\n1236\t    xhr.onerror = () => { item.classList.add('error'); statusIcon.textContent = '✕'; setTimeout(() => item.remove(), 2500); resolve(); };\n1237\t    xhr.send(form);\n1238\t  });\n1239\t}\n1240\t\n1241\tfunction onSearchInput() {\n1242\t  clearTimeout(searchDebounce);\n1243\t  searchDebounce = setTimeout(applyFilters, 180);\n1244\t}\n1245\t\n1246\tasync function loadFiles({silent = false} = {}) {\n1247\t  if (!silent && allFiles.length === 0) renderSkeletons(8);\n1248\t  const res = await fetch(API + '/files?sort=date');\n1249\t  allFiles = await res.json();\n1250\t  fetch(API + '/stats').then(r => r.json()).then(s => {\n1251\t    document.getElementById('stat-ok').textContent = s.watertight;\n1252\t    document.getElementById('stat-total').textContent = s.total_files;\n1253\t  });\n1254\t  updateTotalCost();\n1255\t  renderTagChips();\n1256\t  applyFilters();\n1257\t  schedulePollIfNeeded();\n1258\t}\n1259\t\n1260\tfunction renderSkeletons(n) {\n1261\t  document.getElementById('grid').innerHTML = Array.from({length: n}, () => `\n1262\t    <div class=\"skeleton-card\">\n1263\t      <div class=\"sk-thumb\"></div>\n1264\t      <div class=\"sk-info\">\n1265\t        <div class=\"sk-line\"></div>\n1266\t        <div class=\"sk-line short\"></div>\n1267\t      </div>\n1268\t    </div>`).join('');\n1269\t}\n1270\t\n1271\tfunction applyFilters() {\n1272\t  const search = document.getElementById('search').value.toLowerCase().trim();\n1273\t  const sort = document.getElementById('sort').value;\n1274\t  let list = allFiles.slice();\n1275\t  if (search) {\n1276\t    list = list.filter(f =>\n1277\t      f.name.toLowerCase().includes(search) ||\n1278\t      (f.note || '').toLowerCase().includes(search) ||\n1279\t      (f.tags || []).some(t => t.toLowerCase().includes(search))\n1280\t    );\n1281\t  }\n1282\t  if (activeTags.size) {\n1283\t    list = list.filter(f => {\n1284\t      const ft = new Set((f.tags || []).map(t => t.toLowerCase()));\n1285\t      return [...activeTags].every(t => ft.has(t.toLowerCase()));\n1286\t    });\n1287\t  }\n1288\t  if (sort === 'name') list.sort((a,b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));\n1289\t  else if (sort === 'size') list.sort((a,b) => b.size_kb - a.size_kb);\n1290\t  else if (sort === 'volume') list.sort((a,b) => (b.analysis?.volume_cm3 || 0) - (a.analysis?.volume_cm3 || 0));\n1291\t  else list.sort((a,b) => new Date(b.uploaded_at) - new Date(a.uploaded_at));\n1292\t  renderActiveFilters();\n1293\t  renderGrid(list);\n1294\t}\n1295\t\n1296\tfunction renderActiveFilters() {\n1297\t  const bar = document.getElementById('active-filters');\n1298\t  if (!activeTags.size) { bar.style.display = 'none'; return; }\n1299\t  bar.style.display = 'flex';\n1300\t  bar.innerHTML = [...activeTags].map(t =>\n1301\t    `<span class=\"active-filter\">#${escapeHTML(t)} <span class=\"x\" data-tag=\"${escapeHTML(t)}\">×</span></span>`\n1302\t  ).join('') + `<button class=\"clear-all-filters\" onclick=\"clearTagFilters()\">Alle zurücksetzen</button>`;\n1303\t  bar.querySelectorAll('.x').forEach(x => { x.onclick = () => toggleTagFilter(x.dataset.tag); });\n1304\t}\n1305\t\n1306\tfunction renderTagChips() {\n1307\t  const counts = {};\n1308\t  allFiles.forEach(f => (f.tags || []).forEach(t => { counts[t] = (counts[t] || 0) + 1; }));\n1309\t  const sorted = Object.entries(counts).sort((a,b) => b[1] - a[1]).slice(0, 30);\n1310\t  const wrap = document.getElementById('tag-chips');\n1311\t  if (!sorted.length) { wrap.innerHTML = ''; return; }\n1312\t  wrap.innerHTML = sorted.map(([t, c]) => {\n1313\t    const active = activeTags.has(t) ? 'active' : '';\n1314\t    return `<span class=\"tag-chip ${active}\" data-tag=\"${escapeHTML(t)}\">#${escapeHTML(t)} <span class=\"count\">${c}</span></span>`;\n1315\t  }).join('');\n1316\t  wrap.querySelectorAll('.tag-chip').forEach(c => { c.onclick = () => toggleTagFilter(c.dataset.tag); });\n1317\t}\n1318\t\n1319\tfunction toggleTagFilter(tag) {\n1320\t  if (activeTags.has(tag)) activeTags.delete(tag); else activeTags.add(tag);\n1321\t  renderTagChips(); applyFilters();\n1322\t}\n1323\tfunction clearTagFilters() { activeTags.clear(); renderTagChips(); applyFilters(); }\n1324\t\n1325\tfunction renderGrid(list) {\n1326\t  const grid = document.getElementById('grid');\n1327\t  if (!list.length) {\n1328\t    grid.innerHTML = `<div class=\"empty-state\">\n1329\t      <div class=\"icon\">\n1330\t        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path><polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"></polyline><line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"></line></svg>\n1331\t      </div>\n1332\t      <h3>${allFiles.length ? 'Keine Treffer' : 'Noch keine Modelle'}</h3>\n1333\t      <p>${allFiles.length ? 'Filter anpassen oder zurücksetzen — versuche es mit anderen Stichworten.' : 'Lade deine erste STL-Datei hoch, um loszulegen.'}</p>\n1334\t    </div>`;\n1335\t    return;\n1336\t  }\n1337\t  grid.innerHTML = list.map(cardHTML).join('');\n1338\t\n1339\t  grid.querySelectorAll('.card').forEach(card => {\n1340\t    const id = card.dataset.id;\n1341\t    card.addEventListener('click', e => {\n1342\t      if (e.target.closest('.quick-actions')) return;\n1343\t      openDetail(id);\n1344\t    });\n1345\t    card.querySelector('[data-act=\"download\"]').onclick = e => { e.stopPropagation(); window.open(API + '/download/' + id, '_blank'); };\n1346\t    card.querySelector('[data-act=\"delete\"]').onclick = e => { e.stopPropagation(); softDelete(id); };\n1347\t  });\n1348\t}\n1349\t\n1350\tfunction cardHTML(f) {\n1351\t  const a = f.analysis || {};\n1352\t  const pending = f.analysis_status === 'pending';\n1353\t  let badge;\n1354\t  if (pending) badge = '<span class=\"badge badge-pending\">…</span>';\n1355\t  else if (a.watertight) badge = '<span class=\"badge badge-ok\">✓ druckbar</span>';\n1356\t  else badge = '<span class=\"badge badge-warn\">⚠ check</span>';\n1357\t  const cost = a.filament_grams ? `<span class=\"price-chip\">${fmtEUR(costOf(a.filament_grams))}</span>` : '';\n1358\t\n1359\t  return `\n1360\t    <div class=\"card\" data-id=\"${f.id}\">\n1361\t      <div class=\"quick-actions\">\n1362\t        <button class=\"quick-btn\" data-act=\"download\" title=\"Download\" aria-label=\"Download\">\n1363\t          <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"7 10 12 15 17 10\"></polyline><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line></svg>\n1364\t        </button>\n1365\t        <button class=\"quick-btn danger\" data-act=\"delete\" title=\"Löschen\" aria-label=\"Löschen\">\n1366\t          <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"></polyline><path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"></path><path d=\"M10 11v6M14 11v6\"></path></svg>\n1367\t        </button>\n1368\t      </div>\n1369\t      <div class=\"thumb\">\n1370\t        ${f.has_thumb ? `<img src=\"${API}/thumbs/${f.id}.png?v=${f.analysis_status}\" loading=\"lazy\" alt=\"\">` : '<span class=\"placeholder\">🔷</span>'}\n1371\t        ${pending ? '<div class=\"pending-overlay\"><div class=\"mini-spinner\"></div>Analyse</div>' : ''}\n1372\t      </div>\n1373\t      <div class=\"info\">\n1374\t        <div class=\"name\">${escapeHTML(f.name)}</div>\n1375\t        <div class=\"meta\">\n1376\t          <span>${a.filament_grams ? a.filament_grams + ' g' : '—'}</span>\n1377\t          ${cost}\n1378\t        </div>\n1379\t        <div class=\"meta\" style=\"margin-top:5px\">\n1380\t          ${badge}\n1381\t        </div>\n1382\t      </div>\n1383\t    </div>`;\n1384\t}\n1385\t\n1386\tfunction schedulePollIfNeeded() {\n1387\t  if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }\n1388\t  if (allFiles.some(f => f.analysis_status === 'pending')) {\n1389\t    pollTimer = setTimeout(() => loadFiles({silent: true}), 2000);\n1390\t  }\n1391\t}\n1392\t\n1393\tfunction openDetail(id) {\n1394\t  const f = allFiles.find(x => x.id === id);\n1395\t  if (!f) return;\n1396\t  const overlay = document.createElement('div');\n1397\t  overlay.className = 'modal-overlay';\n1398\t  overlay.onclick = e => {\n1399\t    if (e.target === overlay) { window.closeSTLViewer?.(); overlay.remove(); }\n1400\t  };\n1401\t  overlay.innerHTML = `<div class=\"modal\" id=\"modal\" onclick=\"event.stopPropagation()\"></div>`;\n1402\t  document.body.appendChild(overlay);\n1403\t  renderModal(f);\n1404\t}\n1405\t\n1406\tfunction renderModal(f) {\n1407\t  const modal = document.getElementById('modal');\n1408\t  if (!modal) return;\n1409\t  const a = f.analysis || {};\n1410\t  const pending = f.analysis_status === 'pending';\n1411\t  const dims = a.width_mm ? `${a.width_mm} × ${a.depth_mm} × ${a.height_mm} mm` : (pending ? 'wird analysiert…' : '—');\n1412\t  const printable = a.watertight ? '✓ druckbar' : (pending ? 'wird geprüft…' : '⚠ Mesh prüfen');\n1413\t\n1414\t  modal.innerHTML = `\n1415\t    <div class=\"modal-handle\"></div>\n1416\t    <button class=\"close-btn\" onclick=\"window.closeSTLViewer?.(); this.closest('.modal-overlay').remove()\" aria-label=\"Schließen\">✕</button>\n1417\t    <h2>${escapeHTML(f.name)}</h2>\n1418\t    <div class=\"modal-sub\">${escapeHTML(printable)} · ${f.size_kb} KB</div>\n1419\t\n1420\t    <div class=\"viewer\" id=\"stl-viewer\">\n1421\t      <button class=\"reset-cam\" title=\"Ansicht zurücksetzen\" aria-label=\"Reset\">\n1422\t        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"1 4 1 10 7 10\"></polyline><path d=\"M3.51 15a9 9 0 1 0 2.13-9.36L1 10\"></path></svg>\n1423\t      </button>\n1424\t      <div class=\"viewer-hint\">⤺ ziehen · ⊕ scrollen</div>\n1425\t    </div>\n1426\t\n1427\t    <div class=\"stats-grid\">\n1428\t      <div class=\"stat-card full\"><div class=\"label\">Maße</div><div class=\"value\">${dims}</div></div>\n1429\t      <div class=\"stat-card highlight full\" style=\"background:linear-gradient(135deg,rgba(245,158,11,0.12),rgba(245,158,11,0.03));border-color:rgba(245,158,11,0.25)\">\n1430\t        <div class=\"label\">Druckkosten</div>\n1431\t        <div class=\"value\" style=\"color:var(--orange);font-size:22px\">${a.filament_grams ? fmtEUR(costOf(a.filament_grams)) : '—'}</div>\n1432\t        <div style=\"font-size:11px;color:var(--text-muted);margin-top:3px;font-weight:500\">\n1433\t          ${a.filament_grams ? `${a.filament_grams} g · ${fmtEUR(pricePerGram())}/g · Rolle ${fmtEUR(getPricePerRoll())}/${getRollWeight()} g` : 'Daten noch nicht verfügbar'}\n1434\t        </div>\n1435\t      </div>\n1436\t      <div class=\"stat-card highlight full\"><div class=\"label\">Filament (PLA)</div><div class=\"value\">${a.filament_grams ? a.filament_grams + ' g' : '—'}</div></div>\n1437\t    </div>\n1438\t\n1439\t    <div class=\"field\"><label>Name</label><input type=\"text\" id=\"edit-name\" value=\"${escapeHTML(f.name)}\"></div>\n1440\t    <div class=\"field\">\n1441\t      <label>Tags</label>\n1442\t      <div class=\"tag-bar\" id=\"tag-bar\"></div>\n1443\t      <div class=\"tag-add\">\n1444\t        <input type=\"text\" id=\"tag-input\" placeholder=\"Tag hinzufügen…\">\n1445\t        <button id=\"tag-add-btn\">Hinzufügen</button>\n1446\t      </div>\n1447\t    </div>\n1448\t    <div class=\"field\"><label>Notiz</label><textarea id=\"edit-note\" placeholder=\"Notizen, Druckeinstellungen…\">${escapeHTML(f.note || '')}</textarea></div>\n1449\t\n1450\t    <div class=\"actions\">\n1451\t      <button class=\"btn-delete\" id=\"btn-delete\">\n1452\t        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"></polyline><path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"></path></svg>\n1453\t        Löschen\n1454\t      </button>\n1455\t      <button class=\"btn-download\" id=\"btn-download\">\n1456\t        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"7 10 12 15 17 10\"></polyline><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line></svg>\n1457\t        Download\n1458\t      </button>\n1459\t      <button class=\"btn-save\" id=\"btn-save\">\n1460\t        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg>\n1461\t        Speichern\n1462\t      </button>\n1463\t    </div>`;\n1464\t\n1465\t  renderModalTags(f);\n1466\t\n1467\t  const viewerEl = modal.querySelector('#stl-viewer');\n1468\t  if (viewerEl && window.openSTLViewer) {\n1469\t    window.openSTLViewer(viewerEl, API + '/download/' + f.id);\n1470\t  }\n1471\t\n1472\t  const nameInput = modal.querySelector('#edit-name');\n1473\t  const noteInput = modal.querySelector('#edit-note');\n1474\t  let nameTimer, noteTimer;\n1475\t  nameInput.addEventListener('input', () => { clearTimeout(nameTimer); nameTimer = setTimeout(() => optimisticPatch(f.id, {name: nameInput.value}, ['name']), 500); });\n1476\t  noteInput.addEventListener('input', () => { clearTimeout(noteTimer); noteTimer = setTimeout(() => optimisticPatch(f.id, {note: noteInput.value}, ['note']), 500); });\n1477\t\n1478\t  modal.querySelector('#tag-add-btn').onclick = () => addTagFromInput(f.id);\n1479\t  modal.querySelector('#tag-input').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); addTagFromInput(f.id); } });\n1480\t  modal.querySelector('#btn-save').onclick = () => {\n1481\t    optimisticPatch(f.id, {name: nameInput.value, note: noteInput.value}, ['name','note']);\n1482\t    window.closeSTLViewer?.();\n1483\t    document.querySelector('.modal-overlay')?.remove();\n1484\t    toast('Gespeichert');\n1485\t  };\n1486\t  modal.querySelector('#btn-download').onclick = () => window.open(API + '/download/' + f.id, '_blank');\n1487\t  modal.querySelector('#btn-delete').onclick = () => {\n1488\t    window.closeSTLViewer?.();\n1489\t    document.querySelector('.modal-overlay')?.remove();\n1490\t    softDelete(f.id);\n1491\t  };\n1492\t}\n1493\t\n1494\tfunction renderModalTags(f) {\n1495\t  const bar = document.getElementById('tag-bar');\n1496\t  if (!bar) return;\n1497\t  bar.innerHTML = (f.tags || []).map(t =>\n1498\t    `<span class=\"tag\">${escapeHTML(t)} <span class=\"remove\" data-tag=\"${escapeHTML(t)}\">×</span></span>`\n1499\t  ).join('') || '<span style=\"font-size:12px;color:var(--text-muted)\">Noch keine Tags</span>';\n1500\t  bar.querySelectorAll('.remove').forEach(x => { x.onclick = () => removeTag(f.id, x.dataset.tag); });\n1501\t}\n1502\t\n1503\tasync function optimisticPatch(id, patch, fields) {\n1504\t  const f = allFiles.find(x => x.id === id);\n1505\t  if (!f) return;\n1506\t  const prev = {};\n1507\t  fields.forEach(k => { prev[k] = f[k]; f[k] = patch[k]; });\n1508\t  try {\n1509\t    const res = await fetch(API + '/files/' + id, {\n1510\t      method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(patch),\n1511\t    });\n1512\t    if (!res.ok) throw new Error();\n1513\t    renderTagChips(); applyFilters();\n1514\t  } catch {\n1515\t    fields.forEach(k => { f[k] = prev[k]; });\n1516\t    renderModalTags(f); applyFilters();\n1517\t    toast('Speichern fehlgeschlagen');\n1518\t  }\n1519\t}\n1520\t\n1521\tasync function addTagFromInput(id) {\n1522\t  const input = document.getElementById('tag-input');\n1523\t  const tag = input.value.trim();\n1524\t  if (!tag) return;\n1525\t  input.value = '';\n1526\t  const f = allFiles.find(x => x.id === id);\n1527\t  if (!f) return;\n1528\t  if ((f.tags || []).map(t => t.toLowerCase()).includes(tag.toLowerCase())) return;\n1529\t  await optimisticPatch(id, {tags: [...(f.tags || []), tag]}, ['tags']);\n1530\t  renderModalTags(f);\n1531\t}\n1532\t\n1533\tasync function removeTag(id, tag) {\n1534\t  const f = allFiles.find(x => x.id === id);\n1535\t  if (!f) return;\n1536\t  await optimisticPatch(id, {tags: (f.tags || []).filter(t => t !== tag)}, ['tags']);\n1537\t  renderModalTags(f);\n1538\t}\n1539\t\n1540\tfunction softDelete(id) {\n1541\t  const f = allFiles.find(x => x.id === id);\n1542\t  if (!f) return;\n1543\t  const idx = allFiles.indexOf(f);\n1544\t  allFiles = allFiles.filter(x => x.id !== id);\n1545\t  renderTagChips(); applyFilters();\n1546\t  const timeoutId = setTimeout(async () => {\n1547\t    delete pendingDeletes[id];\n1548\t    try { await fetch(API + '/files/' + id, {method: 'DELETE'}); }\n1549\t    catch { loadFiles({silent: true}); }\n1550\t  }, 5000);\n1551\t  pendingDeletes[id] = { file: f, idx, timeoutId };\n1552\t  toast(`„${f.name}\" gelöscht`, {\n1553\t    duration: 5000,\n1554\t    action: { label: 'Rückgängig', onClick: () => undoDelete(id) },\n1555\t  });\n1556\t}\n1557\t\n1558\tfunction undoDelete(id) {\n1559\t  const p = pendingDeletes[id];\n1560\t  if (!p) return;\n1561\t  clearTimeout(p.timeoutId);\n1562\t  allFiles.splice(Math.min(p.idx, allFiles.length), 0, p.file);\n1563\t  delete pendingDeletes[id];\n1564\t  renderTagChips(); applyFilters();\n1565\t  toast('Wiederhergestellt');\n1566\t}\n1567\t\n1568\tdocument.getElementById('backup-btn').onclick = () => {\n1569\t  toast('Backup wird erstellt…');\n1570\t  window.location.href = API + '/backup';\n1571\t};\n1572\tdocument.getElementById('price-pill').onclick = openPriceDialog;\n1573\tupdatePricePill();\n1574\t\n1575\twindow.clearTagFilters = clearTagFilters;\n1576\twindow.applyFilters = applyFilters;\n1577\twindow.onSearchInput = onSearchInput;\n1578\twindow.onFilesPicked = onFilesPicked;\n1579\t\n1580\tloadFiles();\n1581\tdocument.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });\n1582\t</script>\n1583\t</body>\n1584\t</html>\n1585\t"}]},"uuid":"851dd2e6-7960-41fc-8821-8d0b690bf64b","timestamp":"2026-05-20T17:20:06.007Z","toolUseResult":{"type":"text","file":{"filePath":"/media/HDD_1TB/PrintVault/frontend/index.html","content":"<!DOCTYPE html>\n<html lang=\"de\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no\">\n<title>PrintVault</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap\" rel=\"stylesheet\">\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"three\": \"https://unpkg.com/three@0.160.0/build/three.module.js\",\n    \"three/addons/\": \"https://unpkg.com/three@0.160.0/examples/jsm/\"\n  }\n}\n</script>\n<style>\n*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n:root {\n  --blue-300: #93C5FD;\n  --blue-400: #60A5FA;\n  --blue-500: #3B82F6;\n  --blue-600: #2563EB;\n  --blue-700: #1D4ED8;\n  --green: #10B981;\n  --orange: #F59E0B;\n  --red: #EF4444;\n  --bg: #FAFBFC;\n  --bg-elev: #FFFFFF;\n  --card: rgba(255,255,255,0.78);\n  --card-hover: rgba(255,255,255,0.95);\n  --card-border: rgba(15,23,42,0.06);\n  --card-border-hover: rgba(59,130,246,0.3);\n  --text: #0B1220;\n  --text-dim: #475569;\n  --text-muted: #94A3B8;\n  --modal-bg: rgba(255,255,255,0.95);\n  --thumb-bg: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);\n  --shimmer: rgba(0,0,0,0.04);\n  --shadow-sm: 0 1px 2px rgba(15,23,42,0.04);\n  --shadow-md: 0 4px 14px rgba(15,23,42,0.06);\n  --shadow-lg: 0 12px 32px rgba(37,99,235,0.12);\n  --shadow-xl: 0 24px 60px rgba(15,23,42,0.18);\n  --radius: 20px;\n  --radius-sm: 14px;\n  --radius-xs: 10px;\n  --transition: 0.28s cubic-bezier(0.16, 1, 0.3, 1);\n  --transition-fast: 0.18s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --bg: #050810;\n    --bg-elev: #0F172A;\n    --card: rgba(255,255,255,0.04);\n    --card-hover: rgba(255,255,255,0.08);\n    --card-border: rgba(255,255,255,0.07);\n    --card-border-hover: rgba(96,165,250,0.45);\n    --text: #F1F5F9;\n    --text-dim: #94A3B8;\n    --text-muted: #64748B;\n    --modal-bg: rgba(15,23,42,0.96);\n    --thumb-bg: linear-gradient(135deg, rgba(30,58,138,0.35) 0%, rgba(15,23,42,0.6) 100%);\n    --shimmer: rgba(255,255,255,0.05);\n    --shadow-sm: 0 1px 2px rgba(0,0,0,0.4);\n    --shadow-md: 0 4px 14px rgba(0,0,0,0.35);\n    --shadow-lg: 0 12px 32px rgba(0,0,0,0.5);\n    --shadow-xl: 0 24px 60px rgba(0,0,0,0.6);\n  }\n}\n\nhtml, body {\n  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;\n  font-feature-settings: 'cv11', 'ss01', 'ss03';\n  background: var(--bg);\n  color: var(--text);\n  min-height: 100vh;\n  min-height: 100dvh;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  letter-spacing: -0.011em;\n}\n\nbody::before {\n  content: '';\n  position: fixed; inset: 0;\n  background:\n    radial-gradient(ellipse 70% 50% at 10% -10%, rgba(59,130,246,0.18) 0%, transparent 55%),\n    radial-gradient(ellipse 50% 40% at 100% 100%, rgba(37,99,235,0.10) 0%, transparent 60%);\n  pointer-events: none; z-index: 0;\n}\nbody::after {\n  content: '';\n  position: fixed; inset: 0;\n  background-image:\n    radial-gradient(circle at 1px 1px, rgba(15,23,42,0.04) 1px, transparent 0);\n  background-size: 24px 24px;\n  pointer-events: none; z-index: 0;\n  mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black, transparent 70%);\n}\n@media (prefers-color-scheme: dark) {\n  body::after {\n    background-image:\n      radial-gradient(circle at 1px 1px, rgba(255,255,255,0.05) 1px, transparent 0);\n  }\n}\n\n/* ── Header ─────────────────────────────── */\nheader {\n  position: sticky; top: 0; z-index: 200;\n  background: rgba(250,251,252,0.72);\n  backdrop-filter: blur(28px) saturate(180%);\n  -webkit-backdrop-filter: blur(28px) saturate(180%);\n  border-bottom: 1px solid var(--card-border);\n  padding: 16px 24px;\n}\n@media (prefers-color-scheme: dark) {\n  header { background: rgba(5,8,16,0.72); }\n}\n.header-inner {\n  display: flex; align-items: center; justify-content: space-between;\n  position: relative; z-index: 1;\n  max-width: 1400px; margin: 0 auto;\n}\n.header-brand {\n  display: flex; align-items: center; gap: 12px;\n}\n.header-icon {\n  width: 38px; height: 38px;\n  background: linear-gradient(135deg, #3B82F6 0%, #1E40AF 100%);\n  border-radius: 11px;\n  display: flex; align-items: center; justify-content: center;\n  box-shadow: 0 4px 14px rgba(37,99,235,0.4), inset 0 1px 0 rgba(255,255,255,0.25);\n  position: relative;\n}\n.header-icon::after {\n  content: ''; position: absolute; inset: 0;\n  border-radius: 11px;\n  background: linear-gradient(135deg, rgba(255,255,255,0.3) 0%, transparent 50%);\n  pointer-events: none;\n}\n.header-icon svg { width: 20px; height: 20px; color: white; position: relative; z-index: 1; }\n.header-brand h1 {\n  font-size: 19px; font-weight: 700;\n  letter-spacing: -0.02em;\n  color: var(--text);\n}\n.header-brand .sub {\n  font-size: 11px; font-weight: 500;\n  color: var(--text-muted);\n  letter-spacing: 0.02em;\n}\n.header-actions { display: flex; align-items: center; gap: 10px; }\n.stats-pills { display: flex; gap: 8px; }\n.stat-pill {\n  font-size: 12px; font-weight: 600; padding: 7px 13px;\n  border-radius: 999px;\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  color: var(--text-dim);\n  display: flex; align-items: center; gap: 7px;\n  white-space: nowrap;\n  box-shadow: var(--shadow-sm);\n}\n.stat-pill .dot { width: 6px; height: 6px; border-radius: 50%; }\n.dot-ok { background: var(--green); box-shadow: 0 0 8px rgba(16,185,129,0.6); }\n.stat-pill .val { color: var(--text); font-weight: 700; }\n\n.price-pill {\n  font-size: 12px; font-weight: 700;\n  padding: 7px 13px;\n  border-radius: 999px;\n  background: linear-gradient(135deg, rgba(245,158,11,0.18), rgba(245,158,11,0.06));\n  border: 1px solid rgba(245,158,11,0.35);\n  color: var(--orange);\n  display: inline-flex; align-items: center; gap: 7px;\n  cursor: pointer; transition: var(--transition);\n  white-space: nowrap;\n  box-shadow: var(--shadow-sm);\n}\n.price-pill:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); border-color: rgba(245,158,11,0.55); }\n.price-pill svg { width: 13px; height: 13px; }\n.price-pill .edit-hint { font-size: 10px; opacity: 0.65; font-weight: 600; }\n\n.price-chip {\n  display: inline-flex; align-items: center;\n  font-size: 11px; font-weight: 700;\n  color: var(--orange);\n  background: rgba(245,158,11,0.10);\n  border: 1px solid rgba(245,158,11,0.22);\n  padding: 2px 8px;\n  border-radius: 999px;\n  letter-spacing: -0.01em;\n}\n\n/* Settings dialog */\n.settings-dialog {\n  position: fixed; inset: 0; z-index: 600;\n  background: rgba(8,12,24,0.55);\n  backdrop-filter: blur(10px);\n  display: flex; align-items: center; justify-content: center;\n  animation: fadeIn 0.2s ease;\n  padding: 20px;\n}\n.settings-card {\n  background: var(--modal-bg);\n  backdrop-filter: blur(28px) saturate(180%);\n  border: 1px solid var(--card-border);\n  border-radius: 20px;\n  padding: 24px;\n  width: 100%; max-width: 380px;\n  box-shadow: var(--shadow-xl);\n  animation: slideUp 0.3s var(--transition);\n}\n.settings-card h3 {\n  font-size: 17px; font-weight: 800;\n  letter-spacing: -0.02em; margin-bottom: 4px;\n}\n.settings-card .desc { font-size: 12px; color: var(--text-muted); margin-bottom: 18px; }\n.settings-card .row { display: flex; gap: 8px; align-items: end; margin-bottom: 14px; }\n.settings-card .row .field { flex: 1; margin-bottom: 0; }\n.settings-card .preview {\n  background: linear-gradient(135deg, rgba(59,130,246,0.08), rgba(37,99,235,0.02));\n  border: 1px solid rgba(59,130,246,0.18);\n  border-radius: 12px; padding: 12px 14px;\n  font-size: 13px; color: var(--text-dim); font-weight: 500;\n  margin-bottom: 16px;\n}\n.settings-card .preview b { color: var(--blue-600); font-weight: 800; }\n@media (prefers-color-scheme: dark) { .settings-card .preview b { color: var(--blue-400); } }\n.settings-card .actions { margin-top: 0; }\n\n.icon-btn {\n  width: 36px; height: 36px; border-radius: 11px;\n  background: var(--card); border: 1px solid var(--card-border);\n  color: var(--text-dim); cursor: pointer;\n  display: flex; align-items: center; justify-content: center;\n  transition: var(--transition);\n  box-shadow: var(--shadow-sm);\n}\n.icon-btn:hover {\n  color: var(--blue-500); border-color: var(--card-border-hover);\n  transform: translateY(-1px); box-shadow: var(--shadow-md);\n}\n.icon-btn svg { width: 16px; height: 16px; }\n\n/* ── Content ────────────────────────────── */\n.content {\n  position: relative; z-index: 1;\n  padding: 24px;\n  max-width: 1400px; margin: 0 auto;\n}\n\n.page-title {\n  font-size: 28px; font-weight: 800;\n  letter-spacing: -0.03em; margin-bottom: 4px;\n  background: linear-gradient(135deg, var(--text) 0%, var(--text-dim) 100%);\n  -webkit-background-clip: text;\n  background-clip: text;\n  -webkit-text-fill-color: transparent;\n}\n.page-subtitle {\n  font-size: 14px; color: var(--text-muted);\n  margin-bottom: 24px;\n}\n\n/* ── Controls ───────────────────────────── */\n.controls {\n  display: flex; gap: 10px; margin-bottom: 14px; align-items: center;\n}\n.search-wrap {\n  flex: 1; position: relative;\n}\n.search-wrap svg {\n  position: absolute; left: 14px; top: 50%; transform: translateY(-50%);\n  width: 16px; height: 16px; color: var(--text-muted); pointer-events: none;\n}\n.search-box {\n  width: 100%;\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  border-radius: var(--radius-sm);\n  padding: 12px 16px 12px 40px;\n  font-size: 14px; font-weight: 500; color: var(--text);\n  font-family: inherit; outline: none;\n  -webkit-appearance: none;\n  transition: var(--transition);\n  box-shadow: var(--shadow-sm);\n}\n.search-box::placeholder { color: var(--text-muted); font-weight: 400; }\n.search-box:focus {\n  border-color: var(--blue-500);\n  box-shadow: 0 0 0 4px rgba(59,130,246,0.12);\n  background: var(--bg-elev);\n}\n.sort-btn {\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  border-radius: var(--radius-sm);\n  padding: 12px 14px;\n  font-size: 13px; font-weight: 600; color: var(--text-dim);\n  font-family: inherit; cursor: pointer;\n  -webkit-appearance: none; outline: none;\n  box-shadow: var(--shadow-sm);\n  transition: var(--transition);\n}\n.sort-btn:hover { color: var(--text); }\n\n/* Tag Filter Chips */\n.tag-chips {\n  display: flex; gap: 6px; overflow-x: auto;\n  margin-bottom: 14px; padding: 2px 0 6px;\n  scrollbar-width: none;\n}\n.tag-chips::-webkit-scrollbar { display: none; }\n.tag-chip {\n  flex-shrink: 0;\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  border-radius: 999px;\n  padding: 6px 12px 6px 12px;\n  font-size: 12px; font-weight: 600;\n  color: var(--text-dim);\n  cursor: pointer;\n  transition: var(--transition);\n  white-space: nowrap;\n  display: flex; align-items: center; gap: 6px;\n}\n.tag-chip:hover { color: var(--text); border-color: var(--card-border-hover); transform: translateY(-1px); }\n.tag-chip.active {\n  background: linear-gradient(135deg, #3B82F6, #2563EB);\n  border-color: transparent;\n  color: white;\n  box-shadow: 0 4px 14px rgba(59,130,246,0.35);\n}\n.tag-chip .count {\n  background: rgba(15,23,42,0.06);\n  border-radius: 10px; padding: 1px 7px;\n  font-size: 10px; font-weight: 700;\n  color: var(--text-muted);\n}\n.tag-chip.active .count {\n  background: rgba(255,255,255,0.22); color: white;\n}\n\n.active-filter-bar {\n  display: flex; flex-wrap: wrap; gap: 6px; align-items: center;\n  margin-bottom: 16px;\n}\n.active-filter {\n  background: rgba(59,130,246,0.1);\n  border: 1px solid rgba(59,130,246,0.25);\n  color: var(--blue-600);\n  border-radius: 999px; padding: 4px 6px 4px 11px;\n  font-size: 12px; font-weight: 600;\n  display: flex; align-items: center; gap: 5px;\n}\n@media (prefers-color-scheme: dark) { .active-filter { color: var(--blue-400); } }\n.active-filter .x {\n  cursor: pointer; width: 18px; height: 18px;\n  display: flex; align-items: center; justify-content: center;\n  border-radius: 50%; opacity: 0.7;\n  font-size: 13px; line-height: 1;\n}\n.active-filter .x:hover { opacity: 1; background: rgba(59,130,246,0.15); }\n.clear-all-filters {\n  background: transparent; border: none;\n  color: var(--text-muted); font-size: 12px; cursor: pointer;\n  padding: 4px 10px; font-family: inherit; font-weight: 600;\n}\n.clear-all-filters:hover { color: var(--text); }\n\n/* Upload */\n.upload-zone { margin-bottom: 24px; }\n.upload-box {\n  display: flex; flex-direction: column; align-items: center;\n  gap: 8px;\n  border: 1.5px dashed rgba(59,130,246,0.3);\n  border-radius: var(--radius);\n  padding: 32px 20px;\n  text-align: center; cursor: pointer;\n  transition: var(--transition);\n  background: linear-gradient(135deg, rgba(59,130,246,0.04), rgba(59,130,246,0.01));\n  position: relative; overflow: hidden;\n}\n.upload-box::before {\n  content: ''; position: absolute; inset: 0;\n  background: radial-gradient(circle at center, rgba(59,130,246,0.08), transparent 60%);\n  opacity: 0; transition: var(--transition);\n}\n.upload-box:hover::before, .upload-box.drag-over::before { opacity: 1; }\n.upload-box:hover, .upload-box.drag-over {\n  border-color: var(--blue-500);\n  transform: translateY(-2px);\n  box-shadow: var(--shadow-lg);\n}\n.upload-box .icon-circle {\n  width: 48px; height: 48px;\n  background: linear-gradient(135deg, #3B82F6, #1D4ED8);\n  border-radius: 14px;\n  display: flex; align-items: center; justify-content: center;\n  box-shadow: 0 6px 20px rgba(59,130,246,0.4), inset 0 1px 0 rgba(255,255,255,0.25);\n  position: relative; z-index: 1;\n}\n.upload-box .icon-circle svg { width: 22px; height: 22px; color: white; }\n.upload-box .title { font-size: 15px; font-weight: 700; color: var(--text); position: relative; z-index: 1; }\n.upload-box .hint { font-size: 12px; color: var(--text-muted); position: relative; z-index: 1; }\n.upload-box input { display: none; }\n\n/* Upload Progress */\n.upload-progress {\n  margin-bottom: 18px;\n  display: flex; flex-direction: column; gap: 8px;\n}\n.upload-item {\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  border-radius: var(--radius-sm);\n  padding: 11px 14px;\n  font-size: 13px;\n  display: flex; align-items: center; gap: 12px;\n  box-shadow: var(--shadow-sm);\n  animation: slideInUp 0.3s var(--transition);\n}\n@keyframes slideInUp { from { opacity: 0; transform: translateY(6px); } }\n.upload-item .upload-name {\n  flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n  color: var(--text); font-weight: 500;\n}\n.upload-item .upload-bar {\n  width: 100px; height: 5px;\n  background: var(--card-border); border-radius: 999px;\n  overflow: hidden;\n}\n.upload-item .upload-bar-fill {\n  height: 100%; background: linear-gradient(90deg, var(--blue-500), var(--blue-700));\n  width: 0%; transition: width 0.25s linear;\n  border-radius: 999px;\n}\n.upload-item.done .upload-bar-fill { background: var(--green); width: 100%; }\n.upload-item.error { border-color: rgba(239,68,68,0.3); }\n.upload-item.error .upload-bar-fill { background: var(--red); width: 100%; }\n.upload-item .status-icon {\n  width: 22px; height: 22px; border-radius: 50%;\n  display: flex; align-items: center; justify-content: center;\n  font-size: 12px; font-weight: 700;\n}\n.upload-item.done .status-icon { background: rgba(16,185,129,0.15); color: var(--green); }\n.upload-item.error .status-icon { background: rgba(239,68,68,0.15); color: var(--red); }\n\n/* ── Grid ────────────────────────────────── */\n.grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));\n  gap: 16px;\n  padding-bottom: 60px;\n}\n\n.card {\n  position: relative;\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  border-radius: var(--radius);\n  overflow: hidden;\n  transition: var(--transition);\n  cursor: pointer;\n  -webkit-tap-highlight-color: transparent;\n  backdrop-filter: blur(20px);\n  -webkit-backdrop-filter: blur(20px);\n  box-shadow: var(--shadow-sm);\n}\n.card:hover {\n  border-color: var(--card-border-hover);\n  background: var(--card-hover);\n  transform: translateY(-3px);\n  box-shadow: var(--shadow-lg);\n}\n.card:hover .quick-actions { opacity: 1; transform: translateY(0); }\n.card:active { transform: translateY(-1px) scale(0.99); }\n.card.removing { opacity: 0; transform: scale(0.92); pointer-events: none; }\n\n.card .quick-actions {\n  position: absolute; top: 10px; right: 10px;\n  display: flex; gap: 5px;\n  opacity: 0; transform: translateY(-6px);\n  transition: var(--transition);\n  z-index: 2;\n}\n.quick-btn {\n  width: 32px; height: 32px; border-radius: 10px;\n  background: rgba(15,23,42,0.7);\n  backdrop-filter: blur(12px);\n  -webkit-backdrop-filter: blur(12px);\n  border: 1px solid rgba(255,255,255,0.12);\n  color: white; cursor: pointer;\n  display: flex; align-items: center; justify-content: center;\n  transition: var(--transition-fast);\n  box-shadow: 0 4px 12px rgba(0,0,0,0.25);\n}\n.quick-btn:hover { background: var(--blue-600); transform: scale(1.06); }\n.quick-btn.danger:hover { background: var(--red); }\n.quick-btn svg { width: 14px; height: 14px; }\n\n@media (hover: none) {\n  .card .quick-actions { opacity: 1; transform: none; }\n}\n\n.card .thumb {\n  width: 100%; aspect-ratio: 1;\n  background: var(--thumb-bg);\n  display: flex; align-items: center; justify-content: center;\n  position: relative;\n  overflow: hidden;\n}\n.card .thumb::before {\n  content: ''; position: absolute; inset: 0;\n  background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.5), transparent 50%);\n  pointer-events: none;\n}\n@media (prefers-color-scheme: dark) {\n  .card .thumb::before {\n    background: radial-gradient(circle at 30% 25%, rgba(96,165,250,0.15), transparent 50%);\n  }\n}\n.card .thumb img {\n  width: 92%; height: 92%; object-fit: contain;\n  filter: drop-shadow(0 6px 12px rgba(37,99,235,0.25));\n  transition: var(--transition);\n}\n.card:hover .thumb img { transform: scale(1.04); }\n.card .thumb .placeholder {\n  font-size: 38px; opacity: 0.5;\n}\n.card .pending-overlay {\n  position: absolute; inset: 0;\n  display: flex; flex-direction: column;\n  align-items: center; justify-content: center;\n  gap: 6px;\n  background: rgba(15,23,42,0.35);\n  backdrop-filter: blur(2px);\n  color: white; font-size: 10px; font-weight: 700;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n}\n.mini-spinner {\n  width: 18px; height: 18px;\n  border: 2px solid rgba(255,255,255,0.25);\n  border-top-color: white;\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n}\n.card .info { padding: 12px 14px 14px; }\n.card .name {\n  font-size: 13px; font-weight: 600; line-height: 1.35;\n  display: -webkit-box;\n  -webkit-line-clamp: 2; -webkit-box-orient: vertical;\n  overflow: hidden;\n  margin-bottom: 8px;\n  letter-spacing: -0.01em;\n}\n.card .meta {\n  display: flex; align-items: center; justify-content: space-between;\n  font-size: 11px; color: var(--text-muted); font-weight: 500;\n}\n.card .badge {\n  display: inline-flex; align-items: center; gap: 3px;\n  font-size: 10px; font-weight: 700; padding: 3px 8px;\n  border-radius: 999px;\n  letter-spacing: 0.02em;\n}\n.badge-ok { background: rgba(16,185,129,0.12); color: var(--green); }\n.badge-warn { background: rgba(245,158,11,0.12); color: var(--orange); }\n.badge-pending { background: rgba(148,163,184,0.15); color: var(--text-muted); }\n\n/* Skeleton */\n.skeleton-card {\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  border-radius: var(--radius);\n  overflow: hidden;\n  position: relative;\n}\n.skeleton-card .sk-thumb { width: 100%; aspect-ratio: 1; background: var(--shimmer); }\n.skeleton-card .sk-info { padding: 12px 14px 14px; }\n.skeleton-card .sk-line {\n  height: 11px; background: var(--shimmer);\n  border-radius: 4px; margin-bottom: 7px;\n}\n.skeleton-card .sk-line.short { width: 50%; height: 9px; }\n.skeleton-card::after {\n  content: ''; position: absolute; inset: 0;\n  background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent);\n  animation: shimmer 1.6s infinite;\n  pointer-events: none;\n}\n@media (prefers-color-scheme: dark) {\n  .skeleton-card::after {\n    background: linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent);\n  }\n}\n@keyframes shimmer {\n  from { transform: translateX(-100%); }\n  to { transform: translateX(100%); }\n}\n@keyframes spin { to { transform: rotate(360deg); } }\n\n/* Empty State */\n.empty-state {\n  text-align: center; padding: 80px 20px;\n  grid-column: 1 / -1;\n}\n.empty-state .icon {\n  width: 72px; height: 72px; margin: 0 auto 20px;\n  background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(59,130,246,0.02));\n  border-radius: 22px;\n  display: flex; align-items: center; justify-content: center;\n  border: 1px solid rgba(59,130,246,0.15);\n}\n.empty-state .icon svg { width: 32px; height: 32px; color: var(--blue-500); }\n.empty-state h3 { font-size: 17px; font-weight: 700; margin-bottom: 6px; letter-spacing: -0.01em; }\n.empty-state p { font-size: 13px; color: var(--text-muted); max-width: 280px; margin: 0 auto; }\n\n/* ── Modal ──────────────────────────────── */\n.modal-overlay {\n  position: fixed; inset: 0; z-index: 500;\n  background: rgba(8,12,24,0.55);\n  backdrop-filter: blur(10px);\n  -webkit-backdrop-filter: blur(10px);\n  display: flex; align-items: flex-end; justify-content: center;\n  animation: fadeIn 0.25s ease;\n}\n@keyframes fadeIn { from { opacity: 0; } }\n@keyframes slideUp { from { transform: translateY(100%); } }\n\n.modal {\n  background: var(--modal-bg);\n  backdrop-filter: blur(28px) saturate(180%);\n  -webkit-backdrop-filter: blur(28px) saturate(180%);\n  border: 1px solid var(--card-border);\n  border-bottom: none;\n  border-radius: 24px 24px 0 0;\n  width: 100%; max-width: 560px;\n  max-height: 92vh; max-height: 92dvh;\n  overflow-y: auto;\n  animation: slideUp 0.4s cubic-bezier(0.32, 0.72, 0, 1);\n  padding: 18px 22px 44px;\n  -webkit-overflow-scrolling: touch;\n  box-shadow: 0 -16px 60px rgba(0,0,0,0.35);\n  position: relative;\n}\n.modal-handle {\n  width: 40px; height: 4px; border-radius: 999px;\n  background: var(--card-border);\n  margin: 0 auto 18px;\n}\n.modal h2 {\n  font-size: 20px; font-weight: 800; margin-bottom: 4px;\n  padding-right: 44px; color: var(--text);\n  letter-spacing: -0.02em;\n}\n.modal .modal-sub {\n  font-size: 12px; color: var(--text-muted);\n  margin-bottom: 16px; font-weight: 500;\n}\n.modal .close-btn {\n  position: absolute; top: 18px; right: 18px;\n  width: 36px; height: 36px; border-radius: 50%;\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  color: var(--text-dim); font-size: 15px;\n  cursor: pointer; display: flex;\n  align-items: center; justify-content: center;\n  transition: var(--transition);\n  z-index: 2;\n}\n.modal .close-btn:hover { background: var(--bg-elev); color: var(--text); }\n\n/* 3D Viewer */\n.viewer {\n  width: 100%; aspect-ratio: 1;\n  background: var(--thumb-bg);\n  border-radius: var(--radius-sm);\n  margin-bottom: 18px;\n  position: relative;\n  overflow: hidden;\n  border: 1px solid var(--card-border);\n}\n.viewer canvas { display: block; width: 100% !important; height: 100% !important; }\n.viewer .viewer-overlay {\n  position: absolute; inset: 0;\n  display: flex; align-items: center; justify-content: center;\n  color: var(--text-muted); font-size: 12px;\n  pointer-events: none;\n}\n.viewer .viewer-hint {\n  position: absolute; bottom: 10px; left: 12px;\n  font-size: 10px; font-weight: 600; letter-spacing: 0.04em;\n  color: var(--text-muted); text-transform: uppercase;\n  background: var(--card); border: 1px solid var(--card-border);\n  border-radius: 999px; padding: 4px 10px;\n  pointer-events: none;\n  opacity: 0.85;\n}\n.viewer .reset-cam {\n  position: absolute; top: 10px; right: 10px;\n  width: 30px; height: 30px; border-radius: 9px;\n  background: var(--card); border: 1px solid var(--card-border);\n  color: var(--text-dim); cursor: pointer;\n  display: flex; align-items: center; justify-content: center;\n  z-index: 3;\n}\n.viewer .reset-cam:hover { color: var(--blue-500); }\n.viewer .reset-cam svg { width: 14px; height: 14px; }\n\n.stats-grid {\n  display: grid; grid-template-columns: 1fr 1fr;\n  gap: 8px; margin-bottom: 20px;\n}\n.stat-card {\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  border-radius: var(--radius-xs);\n  padding: 12px 14px;\n  transition: var(--transition);\n}\n.stat-card:hover { border-color: var(--card-border-hover); transform: translateY(-1px); }\n.stat-card .label {\n  font-size: 10px; font-weight: 700;\n  color: var(--text-muted);\n  text-transform: uppercase; letter-spacing: 0.06em;\n}\n.stat-card .value {\n  font-size: 15px; font-weight: 700; margin-top: 4px;\n  color: var(--text); letter-spacing: -0.01em;\n}\n.stat-card.full { grid-column: 1 / -1; }\n.stat-card.highlight {\n  background: linear-gradient(135deg, rgba(59,130,246,0.08), rgba(37,99,235,0.02));\n  border-color: rgba(59,130,246,0.2);\n}\n.stat-card.highlight .value { color: var(--blue-600); }\n@media (prefers-color-scheme: dark) { .stat-card.highlight .value { color: var(--blue-400); } }\n\n.field { margin-bottom: 14px; }\n.field label {\n  display: block; font-size: 10px; font-weight: 700;\n  color: var(--text-muted); margin-bottom: 6px;\n  text-transform: uppercase; letter-spacing: 0.06em;\n}\n.field input, .field textarea {\n  width: 100%; padding: 11px 14px;\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  border-radius: var(--radius-xs);\n  font-size: 14px; color: var(--text);\n  font-family: inherit; font-weight: 500; outline: none;\n  -webkit-appearance: none;\n  transition: var(--transition);\n}\n.field input:focus, .field textarea:focus {\n  border-color: var(--blue-500);\n  background: var(--bg-elev);\n  box-shadow: 0 0 0 4px rgba(59,130,246,0.12);\n}\n.field textarea { resize: vertical; min-height: 70px; font-weight: 400; }\n\n.tag-bar {\n  display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; min-height: 30px;\n}\n.tag {\n  background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(37,99,235,0.06));\n  border: 1px solid rgba(59,130,246,0.22);\n  border-radius: 999px; padding: 5px 6px 5px 13px;\n  font-size: 12px; font-weight: 600;\n  color: var(--blue-600);\n  display: inline-flex; align-items: center; gap: 5px;\n  animation: tagIn 0.25s var(--transition);\n}\n@media (prefers-color-scheme: dark) { .tag { color: var(--blue-300); } }\n@keyframes tagIn { from { opacity: 0; transform: scale(0.85); } }\n.tag .remove {\n  cursor: pointer; width: 18px; height: 18px; border-radius: 50%;\n  display: flex; align-items: center; justify-content: center;\n  opacity: 0.6; font-size: 13px; line-height: 1;\n}\n.tag .remove:hover { opacity: 1; background: rgba(59,130,246,0.18); }\n\n.tag-add {\n  display: flex; gap: 6px; margin-top: 8px;\n}\n.tag-add input {\n  flex: 1; padding: 10px 14px;\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  border-radius: 999px; font-size: 13px;\n  color: var(--text); font-family: inherit; outline: none;\n  -webkit-appearance: none; font-weight: 500;\n  transition: var(--transition);\n}\n.tag-add input:focus { border-color: var(--blue-500); background: var(--bg-elev); }\n.tag-add button {\n  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));\n  color: white; border: none; border-radius: 999px;\n  padding: 10px 18px; font-size: 13px;\n  font-weight: 700; cursor: pointer;\n  transition: var(--transition);\n  box-shadow: 0 4px 14px rgba(59,130,246,0.3);\n}\n.tag-add button:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(59,130,246,0.4); }\n.tag-add button:active { transform: translateY(0); }\n\n.actions {\n  display: flex; gap: 8px; margin-top: 24px;\n}\n.actions button {\n  flex: 1; padding: 13px 16px;\n  border-radius: var(--radius-xs);\n  font-size: 14px; font-weight: 700;\n  border: none; cursor: pointer;\n  font-family: inherit;\n  transition: var(--transition);\n  display: flex; align-items: center; justify-content: center; gap: 7px;\n}\n.actions button svg { width: 15px; height: 15px; }\n.btn-save {\n  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));\n  color: white;\n  box-shadow: 0 6px 18px rgba(59,130,246,0.35);\n}\n.btn-save:hover { transform: translateY(-1px); box-shadow: 0 8px 22px rgba(59,130,246,0.45); }\n.btn-delete {\n  background: rgba(239,68,68,0.1);\n  border: 1px solid rgba(239,68,68,0.22) !important;\n  color: var(--red);\n}\n.btn-delete:hover { background: rgba(239,68,68,0.18); }\n.btn-download {\n  background: var(--card);\n  border: 1px solid var(--card-border);\n  color: var(--text-dim);\n}\n.btn-download:hover { color: var(--text); border-color: var(--card-border-hover); }\n\n/* Toast */\n.toast {\n  position: fixed; top: 24px; left: 50%; transform: translateX(-50%);\n  background: var(--modal-bg);\n  backdrop-filter: blur(20px);\n  -webkit-backdrop-filter: blur(20px);\n  border: 1px solid var(--card-border);\n  color: var(--text);\n  padding: 12px 18px;\n  border-radius: 999px;\n  font-size: 13px; font-weight: 600;\n  z-index: 1100;\n  box-shadow: var(--shadow-xl);\n  display: flex; align-items: center; gap: 12px;\n  animation: toastIn 0.3s var(--transition);\n  max-width: calc(100vw - 32px);\n}\n.toast-action {\n  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));\n  color: white; border: none; border-radius: 999px;\n  padding: 6px 14px; font-size: 12px; font-weight: 700;\n  cursor: pointer; font-family: inherit;\n  box-shadow: 0 4px 10px rgba(59,130,246,0.3);\n}\n.toast.fade-out { animation: toastOut 0.3s ease forwards; }\n@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(-12px); } }\n@keyframes toastOut { to { opacity: 0; transform: translateX(-50%) translateY(-12px); } }\n\n/* Mobile */\n@media (max-width: 480px) {\n  .content { padding: 16px; }\n  .grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }\n  .page-title { font-size: 22px; }\n  .header-brand h1 { font-size: 17px; }\n  .stat-pill { font-size: 11px; padding: 5px 10px; }\n  .stats-grid { grid-template-columns: 1fr 1fr; gap: 6px; }\n}\n</style>\n</head>\n<body>\n\n<header>\n  <div class=\"header-inner\">\n    <div class=\"header-brand\">\n      <div class=\"header-icon\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n          <path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path>\n          <polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"></polyline>\n          <line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"></line>\n        </svg>\n      </div>\n      <div>\n        <h1>PrintVault</h1>\n        <div class=\"sub\">STL-Bibliothek</div>\n      </div>\n    </div>\n    <div class=\"header-actions\">\n      <div class=\"stats-pills\">\n        <span class=\"stat-pill\"><span class=\"dot dot-ok\"></span> <span class=\"val\" id=\"stat-ok\">0</span> druckbar</span>\n        <span class=\"stat-pill\"><span class=\"val\" id=\"stat-total\">0</span> Modelle</span>\n        <span class=\"stat-pill\" title=\"Gesamtkosten aller Modelle\">≈ <span class=\"val\" id=\"stat-cost\">0,00 €</span></span>\n      </div>\n      <button class=\"price-pill\" id=\"price-pill\" title=\"Filamentpreis bearbeiten\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"></circle><path d=\"M9 9h6M9 15h6M12 7v10\"></path></svg>\n        <span id=\"price-pill-text\">14,71 €/Rolle</span>\n        <span class=\"edit-hint\">bearbeiten</span>\n      </button>\n      <button class=\"icon-btn\" id=\"backup-btn\" title=\"Backup herunterladen\" aria-label=\"Backup\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n          <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"7 10 12 15 17 10\"></polyline><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line>\n        </svg>\n      </button>\n    </div>\n  </div>\n</header>\n\n<div class=\"content\">\n  <div class=\"page-title\">Deine Modelle</div>\n  <div class=\"page-subtitle\">Suche, organisiere und drucke deine STL-Sammlung.</div>\n\n  <div class=\"controls\">\n    <div class=\"search-wrap\">\n      <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"></circle><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line></svg>\n      <input type=\"text\" class=\"search-box\" placeholder=\"Suchen…\" id=\"search\" oninput=\"onSearchInput()\" autocomplete=\"off\">\n    </div>\n    <select class=\"sort-btn\" id=\"sort\" onchange=\"applyFilters()\">\n      <option value=\"date\">Neueste</option>\n      <option value=\"name\">Name</option>\n      <option value=\"size\">Größe</option>\n      <option value=\"volume\">Volumen</option>\n    </select>\n  </div>\n\n  <div class=\"tag-chips\" id=\"tag-chips\"></div>\n  <div class=\"active-filter-bar\" id=\"active-filters\" style=\"display:none\"></div>\n\n  <div class=\"upload-zone\">\n    <label class=\"upload-box\" id=\"dropZone\">\n      <div class=\"icon-circle\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"17 8 12 3 7 8\"></polyline><line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"></line></svg>\n      </div>\n      <div class=\"title\">STL hochladen</div>\n      <div class=\"hint\">Drag & Drop oder klicken — auch mehrere Dateien</div>\n      <input type=\"file\" accept=\".stl\" id=\"fileInput\" multiple onchange=\"onFilesPicked(this)\">\n    </label>\n  </div>\n\n  <div class=\"upload-progress\" id=\"upload-progress\"></div>\n\n  <div class=\"grid\" id=\"grid\"></div>\n</div>\n\n<script type=\"module\">\nimport * as THREE from 'three';\nimport { OrbitControls } from 'three/addons/controls/OrbitControls.js';\nimport { STLLoader } from 'three/addons/loaders/STLLoader.js';\n\nlet viewerState = null;\n\nfunction disposeViewer() {\n  if (!viewerState) return;\n  cancelAnimationFrame(viewerState.raf);\n  viewerState.controls.dispose();\n  viewerState.renderer.dispose();\n  viewerState.geometry?.dispose();\n  viewerState.material?.dispose();\n  if (viewerState.resizeObs) viewerState.resizeObs.disconnect();\n  if (viewerState.renderer.domElement.parentNode) {\n    viewerState.renderer.domElement.parentNode.removeChild(viewerState.renderer.domElement);\n  }\n  viewerState = null;\n}\n\nwindow.openSTLViewer = async function(container, url) {\n  disposeViewer();\n  const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n  const scene = new THREE.Scene();\n\n  const w = container.clientWidth || 400;\n  const h = container.clientHeight || 400;\n  const camera = new THREE.PerspectiveCamera(40, w / h, 0.1, 5000);\n\n  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });\n  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n  renderer.setSize(w, h);\n  renderer.setClearColor(0x000000, 0);\n  container.appendChild(renderer.domElement);\n\n  // Lights\n  const ambient = new THREE.AmbientLight(0xffffff, dark ? 0.35 : 0.55);\n  scene.add(ambient);\n  const key = new THREE.DirectionalLight(0xffffff, dark ? 1.1 : 0.9);\n  key.position.set(3, 5, 4); scene.add(key);\n  const fill = new THREE.DirectionalLight(0x93C5FD, dark ? 0.55 : 0.35);\n  fill.position.set(-4, -2, 3); scene.add(fill);\n  const rim = new THREE.DirectionalLight(0x60A5FA, dark ? 0.6 : 0.3);\n  rim.position.set(0, -3, -4); scene.add(rim);\n\n  const controls = new OrbitControls(camera, renderer.domElement);\n  controls.enableDamping = true;\n  controls.dampingFactor = 0.1;\n  controls.rotateSpeed = 0.9;\n  controls.minDistance = 1;\n  controls.maxDistance = 5000;\n  controls.enablePan = false;\n\n  let geometry = null, material = null, mesh = null;\n  const initialCam = new THREE.Vector3();\n\n  try {\n    const loader = new STLLoader();\n    const buf = await fetch(url).then(r => r.arrayBuffer());\n    geometry = loader.parse(buf);\n    geometry.computeVertexNormals();\n    geometry.center();\n\n    material = new THREE.MeshStandardMaterial({\n      color: 0x3B82F6,\n      metalness: 0.15,\n      roughness: 0.55,\n      flatShading: false,\n    });\n    mesh = new THREE.Mesh(geometry, material);\n\n    // STL ist typischerweise Z-up, three.js Y-up → 90° kippen\n    mesh.rotation.x = -Math.PI / 2;\n    scene.add(mesh);\n\n    // Fit camera\n    const box = new THREE.Box3().setFromObject(mesh);\n    const size = box.getSize(new THREE.Vector3());\n    const maxDim = Math.max(size.x, size.y, size.z);\n    const dist = maxDim / (2 * Math.tan(Math.PI * camera.fov / 360)) * 1.6;\n    camera.position.set(dist * 0.7, dist * 0.55, dist * 0.9);\n    camera.lookAt(0, 0, 0);\n    controls.target.set(0, 0, 0);\n    initialCam.copy(camera.position);\n    controls.update();\n  } catch (e) {\n    console.error('STL load error', e);\n  }\n\n  function onResize() {\n    const W = container.clientWidth, H = container.clientHeight;\n    if (!W || !H) return;\n    camera.aspect = W / H;\n    camera.updateProjectionMatrix();\n    renderer.setSize(W, H);\n  }\n  const resizeObs = new ResizeObserver(onResize);\n  resizeObs.observe(container);\n\n  let raf;\n  function animate() {\n    raf = requestAnimationFrame(animate);\n    controls.update();\n    renderer.render(scene, camera);\n  }\n  animate();\n\n  const resetBtn = container.querySelector('.reset-cam');\n  if (resetBtn) {\n    resetBtn.onclick = e => {\n      e.stopPropagation();\n      camera.position.copy(initialCam);\n      controls.target.set(0, 0, 0);\n      controls.update();\n    };\n  }\n\n  viewerState = { renderer, controls, scene, camera, raf, geometry, material, mesh, resizeObs };\n};\n\nwindow.closeSTLViewer = disposeViewer;\n</script>\n\n<script>\nconst API = '/api';\nlet allFiles = [];\nlet activeTags = new Set();\nlet pendingDeletes = {};\nlet pollTimer = null;\nlet searchDebounce = null;\n\n// ─── Filament-Preis (lokal gespeichert) ─────────────────\nconst PRICE_KEY = 'printvault.filament_price_eur';\nconst ROLL_KEY = 'printvault.roll_weight_g';\nfunction getPricePerRoll() { return parseFloat(localStorage.getItem(PRICE_KEY) ?? '14.71'); }\nfunction getRollWeight() { return parseFloat(localStorage.getItem(ROLL_KEY) ?? '1000'); }\nfunction pricePerGram() { return getPricePerRoll() / getRollWeight(); }\nfunction fmtEUR(v) {\n  if (v == null || isNaN(v)) return '—';\n  return v.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2 });\n}\nfunction costOf(grams) {\n  if (!grams) return 0;\n  return grams * pricePerGram();\n}\nfunction updatePricePill() {\n  const t = document.getElementById('price-pill-text');\n  if (t) t.textContent = fmtEUR(getPricePerRoll()) + '/Rolle';\n}\nfunction openPriceDialog() {\n  const dlg = document.createElement('div');\n  dlg.className = 'settings-dialog';\n  dlg.onclick = e => { if (e.target === dlg) dlg.remove(); };\n  dlg.innerHTML = `\n    <div class=\"settings-card\" onclick=\"event.stopPropagation()\">\n      <h3>Filamentpreis</h3>\n      <p class=\"desc\">Preis pro Rolle und Rollengewicht. Wird zur Kostenberechnung pro Modell verwendet.</p>\n      <div class=\"row\">\n        <div class=\"field\"><label>Preis pro Rolle (€)</label>\n          <input type=\"number\" id=\"set-price\" step=\"0.01\" min=\"0\" value=\"${getPricePerRoll().toFixed(2)}\">\n        </div>\n        <div class=\"field\"><label>Rollengewicht (g)</label>\n          <input type=\"number\" id=\"set-weight\" step=\"50\" min=\"50\" value=\"${getRollWeight()}\">\n        </div>\n      </div>\n      <div class=\"preview\" id=\"set-preview\"></div>\n      <div class=\"actions\">\n        <button class=\"btn-download\" id=\"set-cancel\">Abbrechen</button>\n        <button class=\"btn-save\" id=\"set-save\">Speichern</button>\n      </div>\n    </div>`;\n  document.body.appendChild(dlg);\n  const pInput = dlg.querySelector('#set-price');\n  const wInput = dlg.querySelector('#set-weight');\n  const preview = dlg.querySelector('#set-preview');\n  const refresh = () => {\n    const p = parseFloat(pInput.value) || 0;\n    const w = parseFloat(wInput.value) || 1;\n    const perG = p / w;\n    preview.innerHTML = `Pro Gramm: <b>${fmtEUR(perG)}</b> · 100 g Druck ≈ <b>${fmtEUR(perG * 100)}</b>`;\n  };\n  pInput.oninput = refresh; wInput.oninput = refresh; refresh();\n  dlg.querySelector('#set-cancel').onclick = () => dlg.remove();\n  dlg.querySelector('#set-save').onclick = () => {\n    const p = parseFloat(pInput.value);\n    const w = parseFloat(wInput.value);\n    if (p > 0 && w > 0) {\n      localStorage.setItem(PRICE_KEY, String(p));\n      localStorage.setItem(ROLL_KEY, String(w));\n      updatePricePill();\n      applyFilters();\n      updateTotalCost();\n      toast(`Filamentpreis: ${fmtEUR(p)} / ${w} g`);\n    }\n    dlg.remove();\n  };\n}\nfunction updateTotalCost() {\n  const total = allFiles.reduce((s, f) => s + costOf(f.analysis?.filament_grams || 0), 0);\n  const el = document.getElementById('stat-cost');\n  if (el) el.textContent = fmtEUR(total);\n}\n\nfunction toast(msg, opts = {}) {\n  document.getElementById('toast')?.remove();\n  const t = document.createElement('div');\n  t.id = 'toast'; t.className = 'toast';\n  t.innerHTML = `<span>${escapeHTML(msg)}</span>`;\n  if (opts.action) {\n    const btn = document.createElement('button');\n    btn.className = 'toast-action';\n    btn.textContent = opts.action.label;\n    btn.onclick = () => { opts.action.onClick(); t.remove(); };\n    t.appendChild(btn);\n  }\n  document.body.appendChild(t);\n  setTimeout(() => {\n    t.classList.add('fade-out');\n    setTimeout(() => t.remove(), 300);\n  }, opts.duration || 2600);\n  return t;\n}\n\nfunction escapeHTML(s) {\n  const d = document.createElement('div');\n  d.textContent = String(s ?? '');\n  return d.innerHTML;\n}\n\nconst dropZone = document.getElementById('dropZone');\ndropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });\ndropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));\ndropZone.addEventListener('drop', e => {\n  e.preventDefault(); dropZone.classList.remove('drag-over');\n  if (e.dataTransfer.files.length) uploadMany([...e.dataTransfer.files]);\n});\n\nfunction onFilesPicked(input) {\n  if (input.files.length) uploadMany([...input.files]);\n  input.value = '';\n}\n\nasync function uploadMany(files) {\n  const stls = files.filter(f => f.name.toLowerCase().endsWith('.stl'));\n  const skipped = files.length - stls.length;\n  if (skipped > 0) toast(`${skipped} nicht-STL Datei(en) übersprungen`);\n  if (!stls.length) return;\n  const queue = stls.slice();\n  const PARALLEL = 3;\n  const workers = Array.from({length: Math.min(PARALLEL, queue.length)}, () => (async () => {\n    while (queue.length) await uploadOne(queue.shift());\n  })());\n  await Promise.all(workers);\n  toast(`${stls.length} Datei(en) hochgeladen`);\n  await loadFiles();\n}\n\nfunction uploadOne(file) {\n  return new Promise(resolve => {\n    const item = document.createElement('div');\n    item.className = 'upload-item';\n    item.innerHTML = `\n      <span class=\"upload-name\">${escapeHTML(file.name)}</span>\n      <div class=\"upload-bar\"><div class=\"upload-bar-fill\"></div></div>\n      <span class=\"status-icon\">⏳</span>`;\n    document.getElementById('upload-progress').appendChild(item);\n    const fill = item.querySelector('.upload-bar-fill');\n    const statusIcon = item.querySelector('.status-icon');\n    const form = new FormData(); form.append('file', file);\n    const xhr = new XMLHttpRequest();\n    xhr.open('POST', API + '/upload');\n    xhr.upload.onprogress = e => { if (e.lengthComputable) fill.style.width = ((e.loaded / e.total) * 100) + '%'; };\n    xhr.onload = () => {\n      if (xhr.status >= 200 && xhr.status < 300) { item.classList.add('done'); statusIcon.textContent = '✓'; }\n      else { item.classList.add('error'); statusIcon.textContent = '✕'; }\n      setTimeout(() => item.remove(), 2500); resolve();\n    };\n    xhr.onerror = () => { item.classList.add('error'); statusIcon.textContent = '✕'; setTimeout(() => item.remove(), 2500); resolve(); };\n    xhr.send(form);\n  });\n}\n\nfunction onSearchInput() {\n  clearTimeout(searchDebounce);\n  searchDebounce = setTimeout(applyFilters, 180);\n}\n\nasync function loadFiles({silent = false} = {}) {\n  if (!silent && allFiles.length === 0) renderSkeletons(8);\n  const res = await fetch(API + '/files?sort=date');\n  allFiles = await res.json();\n  fetch(API + '/stats').then(r => r.json()).then(s => {\n    document.getElementById('stat-ok').textContent = s.watertight;\n    document.getElementById('stat-total').textContent = s.total_files;\n  });\n  updateTotalCost();\n  renderTagChips();\n  applyFilters();\n  schedulePollIfNeeded();\n}\n\nfunction renderSkeletons(n) {\n  document.getElementById('grid').innerHTML = Array.from({length: n}, () => `\n    <div class=\"skeleton-card\">\n      <div class=\"sk-thumb\"></div>\n      <div class=\"sk-info\">\n        <div class=\"sk-line\"></div>\n        <div class=\"sk-line short\"></div>\n      </div>\n    </div>`).join('');\n}\n\nfunction applyFilters() {\n  const search = document.getElementById('search').value.toLowerCase().trim();\n  const sort = document.getElementById('sort').value;\n  let list = allFiles.slice();\n  if (search) {\n    list = list.filter(f =>\n      f.name.toLowerCase().includes(search) ||\n      (f.note || '').toLowerCase().includes(search) ||\n      (f.tags || []).some(t => t.toLowerCase().includes(search))\n    );\n  }\n  if (activeTags.size) {\n    list = list.filter(f => {\n      const ft = new Set((f.tags || []).map(t => t.toLowerCase()));\n      return [...activeTags].every(t => ft.has(t.toLowerCase()));\n    });\n  }\n  if (sort === 'name') list.sort((a,b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));\n  else if (sort === 'size') list.sort((a,b) => b.size_kb - a.size_kb);\n  else if (sort === 'volume') list.sort((a,b) => (b.analysis?.volume_cm3 || 0) - (a.analysis?.volume_cm3 || 0));\n  else list.sort((a,b) => new Date(b.uploaded_at) - new Date(a.uploaded_at));\n  renderActiveFilters();\n  renderGrid(list);\n}\n\nfunction renderActiveFilters() {\n  const bar = document.getElementById('active-filters');\n  if (!activeTags.size) { bar.style.display = 'none'; return; }\n  bar.style.display = 'flex';\n  bar.innerHTML = [...activeTags].map(t =>\n    `<span class=\"active-filter\">#${escapeHTML(t)} <span class=\"x\" data-tag=\"${escapeHTML(t)}\">×</span></span>`\n  ).join('') + `<button class=\"clear-all-filters\" onclick=\"clearTagFilters()\">Alle zurücksetzen</button>`;\n  bar.querySelectorAll('.x').forEach(x => { x.onclick = () => toggleTagFilter(x.dataset.tag); });\n}\n\nfunction renderTagChips() {\n  const counts = {};\n  allFiles.forEach(f => (f.tags || []).forEach(t => { counts[t] = (counts[t] || 0) + 1; }));\n  const sorted = Object.entries(counts).sort((a,b) => b[1] - a[1]).slice(0, 30);\n  const wrap = document.getElementById('tag-chips');\n  if (!sorted.length) { wrap.innerHTML = ''; return; }\n  wrap.innerHTML = sorted.map(([t, c]) => {\n    const active = activeTags.has(t) ? 'active' : '';\n    return `<span class=\"tag-chip ${active}\" data-tag=\"${escapeHTML(t)}\">#${escapeHTML(t)} <span class=\"count\">${c}</span></span>`;\n  }).join('');\n  wrap.querySelectorAll('.tag-chip').forEach(c => { c.onclick = () => toggleTagFilter(c.dataset.tag); });\n}\n\nfunction toggleTagFilter(tag) {\n  if (activeTags.has(tag)) activeTags.delete(tag); else activeTags.add(tag);\n  renderTagChips(); applyFilters();\n}\nfunction clearTagFilters() { activeTags.clear(); renderTagChips(); applyFilters(); }\n\nfunction renderGrid(list) {\n  const grid = document.getElementById('grid');\n  if (!list.length) {\n    grid.innerHTML = `<div class=\"empty-state\">\n      <div class=\"icon\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path><polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"></polyline><line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"></line></svg>\n      </div>\n      <h3>${allFiles.length ? 'Keine Treffer' : 'Noch keine Modelle'}</h3>\n      <p>${allFiles.length ? 'Filter anpassen oder zurücksetzen — versuche es mit anderen Stichworten.' : 'Lade deine erste STL-Datei hoch, um loszulegen.'}</p>\n    </div>`;\n    return;\n  }\n  grid.innerHTML = list.map(cardHTML).join('');\n\n  grid.querySelectorAll('.card').forEach(card => {\n    const id = card.dataset.id;\n    card.addEventListener('click', e => {\n      if (e.target.closest('.quick-actions')) return;\n      openDetail(id);\n    });\n    card.querySelector('[data-act=\"download\"]').onclick = e => { e.stopPropagation(); window.open(API + '/download/' + id, '_blank'); };\n    card.querySelector('[data-act=\"delete\"]').onclick = e => { e.stopPropagation(); softDelete(id); };\n  });\n}\n\nfunction cardHTML(f) {\n  const a = f.analysis || {};\n  const pending = f.analysis_status === 'pending';\n  let badge;\n  if (pending) badge = '<span class=\"badge badge-pending\">…</span>';\n  else if (a.watertight) badge = '<span class=\"badge badge-ok\">✓ druckbar</span>';\n  else badge = '<span class=\"badge badge-warn\">⚠ check</span>';\n  const cost = a.filament_grams ? `<span class=\"price-chip\">${fmtEUR(costOf(a.filament_grams))}</span>` : '';\n\n  return `\n    <div class=\"card\" data-id=\"${f.id}\">\n      <div class=\"quick-actions\">\n        <button class=\"quick-btn\" data-act=\"download\" title=\"Download\" aria-label=\"Download\">\n          <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"7 10 12 15 17 10\"></polyline><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line></svg>\n        </button>\n        <button class=\"quick-btn danger\" data-act=\"delete\" title=\"Löschen\" aria-label=\"Löschen\">\n          <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"></polyline><path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"></path><path d=\"M10 11v6M14 11v6\"></path></svg>\n        </button>\n      </div>\n      <div class=\"thumb\">\n        ${f.has_thumb ? `<img src=\"${API}/thumbs/${f.id}.png?v=${f.analysis_status}\" loading=\"lazy\" alt=\"\">` : '<span class=\"placeholder\">🔷</span>'}\n        ${pending ? '<div class=\"pending-overlay\"><div class=\"mini-spinner\"></div>Analyse</div>' : ''}\n      </div>\n      <div class=\"info\">\n        <div class=\"name\">${escapeHTML(f.name)}</div>\n        <div class=\"meta\">\n          <span>${a.filament_grams ? a.filament_grams + ' g' : '—'}</span>\n          ${cost}\n        </div>\n        <div class=\"meta\" style=\"margin-top:5px\">\n          ${badge}\n        </div>\n      </div>\n    </div>`;\n}\n\nfunction schedulePollIfNeeded() {\n  if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }\n  if (allFiles.some(f => f.analysis_status === 'pending')) {\n    pollTimer = setTimeout(() => loadFiles({silent: true}), 2000);\n  }\n}\n\nfunction openDetail(id) {\n  const f = allFiles.find(x => x.id === id);\n  if (!f) return;\n  const overlay = document.createElement('div');\n  overlay.className = 'modal-overlay';\n  overlay.onclick = e => {\n    if (e.target === overlay) { window.closeSTLViewer?.(); overlay.remove(); }\n  };\n  overlay.innerHTML = `<div class=\"modal\" id=\"modal\" onclick=\"event.stopPropagation()\"></div>`;\n  document.body.appendChild(overlay);\n  renderModal(f);\n}\n\nfunction renderModal(f) {\n  const modal = document.getElementById('modal');\n  if (!modal) return;\n  const a = f.analysis || {};\n  const pending = f.analysis_status === 'pending';\n  const dims = a.width_mm ? `${a.width_mm} × ${a.depth_mm} × ${a.height_mm} mm` : (pending ? 'wird analysiert…' : '—');\n  const printable = a.watertight ? '✓ druckbar' : (pending ? 'wird geprüft…' : '⚠ Mesh prüfen');\n\n  modal.innerHTML = `\n    <div class=\"modal-handle\"></div>\n    <button class=\"close-btn\" onclick=\"window.closeSTLViewer?.(); this.closest('.modal-overlay').remove()\" aria-label=\"Schließen\">✕</button>\n    <h2>${escapeHTML(f.name)}</h2>\n    <div class=\"modal-sub\">${escapeHTML(printable)} · ${f.size_kb} KB</div>\n\n    <div class=\"viewer\" id=\"stl-viewer\">\n      <button class=\"reset-cam\" title=\"Ansicht zurücksetzen\" aria-label=\"Reset\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"1 4 1 10 7 10\"></polyline><path d=\"M3.51 15a9 9 0 1 0 2.13-9.36L1 10\"></path></svg>\n      </button>\n      <div class=\"viewer-hint\">⤺ ziehen · ⊕ scrollen</div>\n    </div>\n\n    <div class=\"stats-grid\">\n      <div class=\"stat-card full\"><div class=\"label\">Maße</div><div class=\"value\">${dims}</div></div>\n      <div class=\"stat-card highlight full\" style=\"background:linear-gradient(135deg,rgba(245,158,11,0.12),rgba(245,158,11,0.03));border-color:rgba(245,158,11,0.25)\">\n        <div class=\"label\">Druckkosten</div>\n        <div class=\"value\" style=\"color:var(--orange);font-size:22px\">${a.filament_grams ? fmtEUR(costOf(a.filament_grams)) : '—'}</div>\n        <div style=\"font-size:11px;color:var(--text-muted);margin-top:3px;font-weight:500\">\n          ${a.filament_grams ? `${a.filament_grams} g · ${fmtEUR(pricePerGram())}/g · Rolle ${fmtEUR(getPricePerRoll())}/${getRollWeight()} g` : 'Daten noch nicht verfügbar'}\n        </div>\n      </div>\n      <div class=\"stat-card highlight full\"><div class=\"label\">Filament (PLA)</div><div class=\"value\">${a.filament_grams ? a.filament_grams + ' g' : '—'}</div></div>\n    </div>\n\n    <div class=\"field\"><label>Name</label><input type=\"text\" id=\"edit-name\" value=\"${escapeHTML(f.name)}\"></div>\n    <div class=\"field\">\n      <label>Tags</label>\n      <div class=\"tag-bar\" id=\"tag-bar\"></div>\n      <div class=\"tag-add\">\n        <input type=\"text\" id=\"tag-input\" placeholder=\"Tag hinzufügen…\">\n        <button id=\"tag-add-btn\">Hinzufügen</button>\n      </div>\n    </div>\n    <div class=\"field\"><label>Notiz</label><textarea id=\"edit-note\" placeholder=\"Notizen, Druckeinstellungen…\">${escapeHTML(f.note || '')}</textarea></div>\n\n    <div class=\"actions\">\n      <button class=\"btn-delete\" id=\"btn-delete\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"></polyline><path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"></path></svg>\n        Löschen\n      </button>\n      <button class=\"btn-download\" id=\"btn-download\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"7 10 12 15 17 10\"></polyline><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line></svg>\n        Download\n      </button>\n      <button class=\"btn-save\" id=\"btn-save\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg>\n        Speichern\n      </button>\n    </div>`;\n\n  renderModalTags(f);\n\n  const viewerEl = modal.querySelector('#stl-viewer');\n  if (viewerEl && window.openSTLViewer) {\n    window.openSTLViewer(viewerEl, API + '/download/' + f.id);\n  }\n\n  const nameInput = modal.querySelector('#edit-name');\n  const noteInput = modal.querySelector('#edit-note');\n  let nameTimer, noteTimer;\n  nameInput.addEventListener('input', () => { clearTimeout(nameTimer); nameTimer = setTimeout(() => optimisticPatch(f.id, {name: nameInput.value}, ['name']), 500); });\n  noteInput.addEventListener('input', () => { clearTimeout(noteTimer); noteTimer = setTimeout(() => optimisticPatch(f.id, {note: noteInput.value}, ['note']), 500); });\n\n  modal.querySelector('#tag-add-btn').onclick = () => addTagFromInput(f.id);\n  modal.querySelector('#tag-input').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); addTagFromInput(f.id); } });\n  modal.querySelector('#btn-save').onclick = () => {\n    optimisticPatch(f.id, {name: nameInput.value, note: noteInput.value}, ['name','note']);\n    window.closeSTLViewer?.();\n    document.querySelector('.modal-overlay')?.remove();\n    toast('Gespeichert');\n  };\n  modal.querySelector('#btn-download').onclick = () => window.open(API + '/download/' + f.id, '_blank');\n  modal.querySelector('#btn-delete').onclick = () => {\n    window.closeSTLViewer?.();\n    document.querySelector('.modal-overlay')?.remove();\n    softDelete(f.id);\n  };\n}\n\nfunction renderModalTags(f) {\n  const bar = document.getElementById('tag-bar');\n  if (!bar) return;\n  bar.innerHTML = (f.tags || []).map(t =>\n    `<span class=\"tag\">${escapeHTML(t)} <span class=\"remove\" data-tag=\"${escapeHTML(t)}\">×</span></span>`\n  ).join('') || '<span style=\"font-size:12px;color:var(--text-muted)\">Noch keine Tags</span>';\n  bar.querySelectorAll('.remove').forEach(x => { x.onclick = () => removeTag(f.id, x.dataset.tag); });\n}\n\nasync function optimisticPatch(id, patch, fields) {\n  const f = allFiles.find(x => x.id === id);\n  if (!f) return;\n  const prev = {};\n  fields.forEach(k => { prev[k] = f[k]; f[k] = patch[k]; });\n  try {\n    const res = await fetch(API + '/files/' + id, {\n      method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(patch),\n    });\n    if (!res.ok) throw new Error();\n    renderTagChips(); applyFilters();\n  } catch {\n    fields.forEach(k => { f[k] = prev[k]; });\n    renderModalTags(f); applyFilters();\n    toast('Speichern fehlgeschlagen');\n  }\n}\n\nasync function addTagFromInput(id) {\n  const input = document.getElementById('tag-input');\n  const tag = input.value.trim();\n  if (!tag) return;\n  input.value = '';\n  const f = allFiles.find(x => x.id === id);\n  if (!f) return;\n  if ((f.tags || []).map(t => t.toLowerCase()).includes(tag.toLowerCase())) return;\n  await optimisticPatch(id, {tags: [...(f.tags || []), tag]}, ['tags']);\n  renderModalTags(f);\n}\n\nasync function removeTag(id, tag) {\n  const f = allFiles.find(x => x.id === id);\n  if (!f) return;\n  await optimisticPatch(id, {tags: (f.tags || []).filter(t => t !== tag)}, ['tags']);\n  renderModalTags(f);\n}\n\nfunction softDelete(id) {\n  const f = allFiles.find(x => x.id === id);\n  if (!f) return;\n  const idx = allFiles.indexOf(f);\n  allFiles = allFiles.filter(x => x.id !== id);\n  renderTagChips(); applyFilters();\n  const timeoutId = setTimeout(async () => {\n    delete pendingDeletes[id];\n    try { await fetch(API + '/files/' + id, {method: 'DELETE'}); }\n    catch { loadFiles({silent: true}); }\n  }, 5000);\n  pendingDeletes[id] = { file: f, idx, timeoutId };\n  toast(`„${f.name}\" gelöscht`, {\n    duration: 5000,\n    action: { label: 'Rückgängig', onClick: () => undoDelete(id) },\n  });\n}\n\nfunction undoDelete(id) {\n  const p = pendingDeletes[id];\n  if (!p) return;\n  clearTimeout(p.timeoutId);\n  allFiles.splice(Math.min(p.idx, allFiles.length), 0, p.file);\n  delete pendingDeletes[id];\n  renderTagChips(); applyFilters();\n  toast('Wiederhergestellt');\n}\n\ndocument.getElementById('backup-btn').onclick = () => {\n  toast('Backup wird erstellt…');\n  window.location.href = API + '/backup';\n};\ndocument.getElementById('price-pill').onclick = openPriceDialog;\nupdatePricePill();\n\nwindow.clearTagFilters = clearTagFilters;\nwindow.applyFilters = applyFilters;\nwindow.onSearchInput = onSearchInput;\nwindow.onFilesPicked = onFilesPicked;\n\nloadFiles();\ndocument.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });\n</script>\n</body>\n</html>\n","numLines":1585,"startLine":1,"totalLines":1585}},"sourceToolAssistantUUID":"e76fa159-98fb-485b-91ae-42a3c34a8fbc","userType":"external","entrypoint":"sdk-cli","cwd":"/DATA","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2","version":"2.1.145","gitBranch":"HEAD"}
{"type":"last-prompt","lastPrompt":"Implementiere Idee #7: Papierkorb-Seite (Trash UI) für PrintVault.\\n\\nBACKEND (main.py unter /media/HDD_1TB/PrintVault/main.py):\\n- TRASH_DIR existiert bereits, Soft-Delete verschiebt .stl/.png nach /…","leafUuid":"851dd2e6-7960-41fc-8821-8d0b690bf64b","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2"}
{"type":"ai-title","aiTitle":"Add trash page and restore functionality","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2"}
{"type":"last-prompt","lastPrompt":"Implementiere Idee #7: Papierkorb-Seite (Trash UI) für PrintVault.\\n\\nBACKEND (main.py unter /media/HDD_1TB/PrintVault/main.py):\\n- TRASH_DIR existiert bereits, Soft-Delete verschiebt .stl/.png nach /…","leafUuid":"851dd2e6-7960-41fc-8821-8d0b690bf64b","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2"}
{"type":"ai-title","aiTitle":"Add trash page and restore functionality","sessionId":"58e15d46-8cfa-4bcd-9455-8bf6c0e667a2"}
