Implementiere Idee #7: Trash-UI/Papierkorb-Seite für PrintVault. BACKEND: 3 neue Endpoints in main.py (GET /api/trash, POST /api/trash/{filename}/restore, DELETE /api/trash/{filename}). FRONTEND: Tras…


Add trash page with restore and delete for PrintVault


TaskCreate

Backend: Trash-Endpunkte in main.py

In main.py die 3 neuen Endpoints hinzufügen: GET /api/trash, POST /api/trash/{filename}/restore, DELETE /api/trash/{filename}.


Frontend: Trash-Ansicht in index.html

In index.html Trash-Icon Badge im Header, Trash-Ansicht mit Karten, Wiederherstellen/Löschen-Buttons, Bestätigungsdialog, Leerzustand und Navigation ergänzen.


Task #1 created successfully: Backend: Trash-Endpunkte in main.py

Backend: Trash-Endpunkte in main.py


Task #2 created successfully: Frontend: Trash-Ansicht in index.html

Frontend: Trash-Ansicht in index.html


Ich habe beide Dateien analysiert. Jetzt implementiere ich die 3 Trash-Endpunkte im Backend und danach die vollständige Trash-UI im Frontend.


Updated task #1 status


Edit

@app.get("/api/thumbs/{file_id}.png")
def get_thumbnail(file_id: str):
    thumb_path = THUMB_DIR / f"{file_id}.png"
    if not thumb_path.exists():
        stl_path = STL_DIR / f"{file_id}.stl"
        if not stl_path.exists():
            raise HTTPException(404, "Kein Thumbnail")
        generate_thumbnail(stl_path, thumb_path)
    return FileResponse(thumb_path, media_type="image/png")

@app.get("/api/thumbs/{file_id}.png")
def get_thumbnail(file_id: str):
    thumb_path = THUMB_DIR / f"{file_id}.png"
    if not thumb_path.exists():
        stl_path = STL_DIR / f"{file_id}.stl"
        if not stl_path.exists():
            raise HTTPException(404, "Kein Thumbnail")
        generate_thumbnail(stl_path, thumb_path)
    return FileResponse(thumb_path, media_type="image/png")

@app.get("/api/trash")
def list_trash():
    """Liste alle Dateien im Papierkorb auf."""
    items = []
    for stl_file in TRASH_DIR.glob("*.stl"):
        name = stl_file.name
        # Parse: {file_id}-{timestamp}.stl
        parts = name.rsplit("-", 1)
        file_id = parts[0] if len(parts) == 2 else name.replace(".stl", "")
        ts_str = parts[1].replace(".stl", "") if len(parts) == 2 else ""
        try:
            deleted_at = datetime.strptime(ts_str, "%Y%m%d-%H%M%S").isoformat() if ts_str else datetime.now().isoformat()
        except ValueError:
            deleted_at = datetime.now().isoformat()
        thumb_file = TRASH_DIR / f"{file_id}-{ts_str}.png"
        size_kb = round(stl_file.stat().st_size / 1024, 1)
        items.append({
            "filename": name,
            "file_id": file_id,
            "deleted_at": deleted_at,
            "size_kb": size_kb,
            "has_thumb": thumb_file.exists(),
        })
    items.sort(key=lambda x: x["deleted_at"], reverse=True)
    return items

@app.post("/api/trash/{filename}/restore")
def restore_file(filename: str):
    """STL + Thumb aus /trash zurück in /stls und /thumbs verschieben."""
    stl_src = TRASH_DIR / filename
    if not stl_src.exists():
        raise HTTPException(404, "Datei nicht im Papierkorb")

    # Parse file_id und timestamp aus dem Dateinamen
    name = stl_src.name
    parts = name.rsplit("-", 1)
    file_id = parts[0] if len(parts) == 2 else name.replace(".stl", "")
    ts_str = parts[1].replace(".stl", "") if len(parts) == 2 else ""

    # Target paths
    stl_dest = STL_DIR / f"{file_id}.stl"
    thumb_src = TRASH_DIR / f"{file_id}-{ts_str}.png"
    thumb_dest = THUMB_DIR / f"{file_id}.png"

    # Schützen vor Überschreiben
    if stl_dest.exists():
        # Wenn Ziel schon existiert, generiere neue ID
        new_id = str(uuid.uuid4())[:8]
        stl_dest = STL_DIR / f"{new_id}.stl"
        if thumb_dest.exists():
            thumb_dest = THUMB_DIR / f"{new_id}.png"
        file_id = new_id

    shutil.move(str(stl_src), str(stl_dest))
    if thumb_src.exists():
        shutil.move(str(thumb_src), str(thumb_dest))

    # Als Orphan markieren damit es in der DB wieder auftaucht
    # (import_orphan_stls wird beim nächsten Startup oder manuell aufgerufen)
    with db_connect() as conn:
        existing = conn.execute("SELECT id FROM files WHERE id = ?", (file_id,)).fetchone()
        if not existing:
            conn.execute(
                "INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) "
                "VALUES (?,?,?,?,?,?)",
                (
                    file_id,
                    file_id.replace("_", " "),
                    "[]", "{}", "pending",
                    stl_dest.stat().st_mtime,
                ),
            )
            conn.commit()

    return {"ok": True, "file_id": file_id}

@app.delete("/api/trash/{filename}")
def permanently_delete_file(filename: str):
    """STL + Thumb endgültig aus dem Papierkorb löschen."""
    stl_path = TRASH_DIR / filename
    if not stl_path.exists():
        raise HTTPException(404, "Datei nicht im Papierkorb")

    # Associated thumbnail herausfinden
    name = stl_path.name
    parts = name.rsplit("-", 1)
    file_id = parts[0] if len(parts) == 2 else name.replace(".stl", "")
    ts_str = parts[1].replace(".stl", "") if len(parts) == 2 else ""
    thumb_path = TRASH_DIR / f"{file_id}-{ts_str}.png"

    try:
        stl_path.unlink()
    except Exception as e:
        raise HTTPException(500, f"STL löschen fehlgeschlagen: {e}")

    if thumb_path.exists():
        try:
            thumb_path.unlink()
        except Exception:
            pass

    # Ensure DB cleanup for restored-then-re-deleted files
    with db_connect() as conn:
        conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
        conn.commit()

    return {"ok": True}


The file .media/HDD_1TB/PrintVault/main.py has been updated successfully. (file state is current in your context — no need to Read it back)

@app.get("/api/thumbs/{file_id}.png")
def get_thumbnail(file_id: str):
    thumb_path = THUMB_DIR / f"{file_id}.png"
    if not thumb_path.exists():
        stl_path = STL_DIR / f"{file_id}.stl"
        if not stl_path.exists():
            raise HTTPException(404, "Kein Thumbnail")
        generate_thumbnail(stl_path, thumb_path)
    return FileResponse(thumb_path, media_type="image/png")

@app.get("/api/trash")
def list_trash():
    """Liste alle Dateien im Papierkorb auf."""
    items = []
    for stl_file in TRASH_DIR.glob("*.stl"):
        name = stl_file.name
        # Parse: {file_id}-{timestamp}.stl
        parts = name.rsplit("-", 1)
        file_id = parts[0] if len(parts) == 2 else name.replace(".stl", "")
        ts_str = parts[1].replace(".stl", "") if len(parts) == 2 else ""
        try:
            deleted_at = datetime.strptime(ts_str, "%Y%m%d-%H%M%S").isoformat() if ts_str else datetime.now().isoformat()
        except ValueError:
            deleted_at = datetime.now().isoformat()
        thumb_file = TRASH_DIR / f"{file_id}-{ts_str}.png"
        size_kb = round(stl_file.stat().st_size / 1024, 1)
        items.append({
            "filename": name,
            "file_id": file_id,
            "deleted_at": deleted_at,
            "size_kb": size_kb,
            "has_thumb": thumb_file.exists(),
        })
    items.sort(key=lambda x: x["deleted_at"], reverse=True)
    return items

@app.post("/api/trash/{filename}/restore")
def restore_file(filename: str):
    """STL + Thumb aus /trash zurück in /stls und /thumbs verschieben."""
    stl_src = TRASH_DIR / filename
    if not stl_src.exists():
        raise HTTPException(404, "Datei nicht im Papierkorb")

    # Parse file_id und timestamp aus dem Dateinamen
    name = stl_src.name
    parts = name.rsplit("-", 1)
    file_id = parts[0] if len(parts) == 2 else name.replace(".stl", "")
    ts_str = parts[1].replace(".stl", "") if len(parts) == 2 else ""

    # Target paths
    stl_dest = STL_DIR / f"{file_id}.stl"
    thumb_src = TRASH_DIR / f"{file_id}-{ts_str}.png"
    thumb_dest = THUMB_DIR / f"{file_id}.png"

    # Schützen vor Überschreiben
    if stl_dest.exists():
        # Wenn Ziel schon existiert, generiere neue ID
        new_id = str(uuid.uuid4())[:8]
        stl_dest = STL_DIR / f"{new_id}.stl"
        if thumb_dest.exists():
            thumb_dest = THUMB_DIR / f"{new_id}.png"
        file_id = new_id

    shutil.move(str(stl_src), str(stl_dest))
    if thumb_src.exists():
        shutil.move(str(thumb_src), str(thumb_dest))

    # Als Orphan markieren damit es in der DB wieder auftaucht
    # (import_orphan_stls wird beim nächsten Startup oder manuell aufgerufen)
    with db_connect() as conn:
        existing = conn.execute("SELECT id FROM files WHERE id = ?", (file_id,)).fetchone()
        if not existing:
            conn.execute(
                "INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) "
                "VALUES (?,?,?,?,?,?)",
                (
                    file_id,
                    file_id.replace("_", " "),
                    "[]", "{}", "pending",
                    stl_dest.stat().st_mtime,
                ),
            )
            conn.commit()

    return {"ok": True, "file_id": file_id}

@app.delete("/api/trash/{filename}")
def permanently_delete_file(filename: str):
    """STL + Thumb endgültig aus dem Papierkorb löschen."""
    stl_path = TRASH_DIR / filename
    if not stl_path.exists():
        raise HTTPException(404, "Datei nicht im Papierkorb")

    # Associated thumbnail herausfinden
    name = stl_path.name
    parts = name.rsplit("-", 1)
    file_id = parts[0] if len(parts) == 2 else name.replace(".stl", "")
    ts_str = parts[1].replace(".stl", "") if len(parts) == 2 else ""
    thumb_path = TRASH_DIR / f"{file_id}-{ts_str}.png"

    try:
        stl_path.unlink()
    except Exception as e:
        raise HTTPException(500, f"STL löschen fehlgeschlagen: {e}")

    if thumb_path.exists():
        try:
            thumb_path.unlink()
        except Exception:
            pass

    # Ensure DB cleanup for restored-then-re-deleted files
    with db_connect() as conn:
        conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
        conn.commit()

    return {"ok": True}

+    """Liste alle Dateien im Papierkorb auf."""

+    for stl_file in TRASH_DIR.glob("*.stl"):

+        # Parse: {file_id}-{timestamp}.stl

+        file_id = parts[0] if len(parts) == 2 else name.replace(".stl", "")

+        ts_str = parts[1].replace(".stl", "") if len(parts) == 2 else ""

+            deleted_at = datetime.strptime(ts_str, "%Y%m%d-%H%M%S").isoformat() if ts_str else datetime.now().isoformat()

+            deleted_at = datetime.now().isoformat()

+        thumb_file = TRASH_DIR / f"{file_id}-{ts_str}.png"

+        size_kb = round(stl_file.stat().st_size / 1024, 1)

+            "has_thumb": thumb_file.exists(),

+    items.sort(key=lambda x: x["deleted_at"], reverse=True)

+    """STL + Thumb aus /trash zurück in /stls und /thumbs verschieben."""

+        raise HTTPException(404, "Datei nicht im Papierkorb")

+    # Parse file_id und timestamp aus dem Dateinamen

+    stl_dest = STL_DIR / f"{file_id}.stl"

+    thumb_src = TRASH_DIR / f"{file_id}-{ts_str}.png"

+    thumb_dest = THUMB_DIR / f"{file_id}.png"

+        # Wenn Ziel schon existiert, generiere neue ID

+        stl_dest = STL_DIR / f"{new_id}.stl"

+            thumb_dest = THUMB_DIR / f"{new_id}.png"

+    shutil.move(str(stl_src), str(stl_dest))

+        shutil.move(str(thumb_src), str(thumb_dest))

+    # Als Orphan markieren damit es in der DB wieder auftaucht

+    # (import_orphan_stls wird beim nächsten Startup oder manuell aufgerufen)

+        existing = conn.execute("SELECT id FROM files WHERE id = ?", (file_id,)).fetchone()

+                "INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) "

+                    file_id.replace("_", " "),

+                    stl_dest.stat().st_mtime,

+    return {"ok": True, "file_id": file_id}

+    """STL + Thumb endgültig aus dem Papierkorb löschen."""

+    # Associated thumbnail herausfinden

+    thumb_path = TRASH_DIR / f"{file_id}-{ts_str}.png"

+        raise HTTPException(500, f"STL löschen fehlgeschlagen: {e}")

+    # Ensure DB cleanup for restored-then-re-deleted files

+        conn.execute("DELETE FROM files WHERE id = ?", (file_id,))


Backend ist erledigt. Jetzt kommt der Frontend-Teil. Ich füge zuerst den Trash-Thumbnail-Endpoint im Backend hinzu und danach die Frontend-Änderungen.


from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Request, BackgroundTasks
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
import trimesh
import numpy as np

from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Request, BackgroundTasks
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
import trimesh
import numpy as np

# Hilfsfunktion zur Thumbnail-Pfades mit Fallback auf Trash
# (moved early for use in endpoints)


from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Request, BackgroundTasks
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
import trimesh
import numpy as np

# Hilfsfunktion zur Thumbnail-Pfades mit Fallback auf Trash
# (moved early for use in endpoints)

+# Hilfsfunktion zur Thumbnail-Pfades mit Fallback auf Trash

# ── Konfiguration ──────────────────────────────────────────

BASE_DIR = Path(os.environ.get("DATA_DIR", "/data"))


# ── Backup / Restore ──────────────────────────────────────

@app.get("/api/trash-thumbs/{filename}")
def get_trash_thumbnail(filename: str):
    """Thumbnail einer Datei im Papierkorb ausliefern."""
    # filename ist z.B. "abc12345-20260520-143022.png"
    thumb_path = TRASH_DIR / filename
    if thumb_path.exists():
        return FileResponse(thumb_path, media_type="image/png")
    # Versuche das STL herauszufinden und ein Thumb zu generieren
    parts = filename.rsplit("-", 1)
    file_id = parts[0] if len(parts) == 2 else filename.replace(".png", "")
    ts_str = parts[1].replace(".png", "") if len(parts) == 2 else ""
    stl_path = TRASH_DIR / f"{file_id}-{ts_str}.stl"
    if stl_path.exists():
        generate_thumbnail(stl_path, thumb_path)
        if thumb_path.exists():
            return FileResponse(thumb_path, media_type="image/png")
    raise HTTPException(404, "Thumbnail nicht gefunden")

# ── Backup / Restore ──────────────────────────────────────


@app.get("/api/trash-thumbs/{filename}")
def get_trash_thumbnail(filename: str):
    """Thumbnail einer Datei im Papierkorb ausliefern."""
    # filename ist z.B. "abc12345-20260520-143022.png"
    thumb_path = TRASH_DIR / filename
    if thumb_path.exists():
        return FileResponse(thumb_path, media_type="image/png")
    # Versuche das STL herauszufinden und ein Thumb zu generieren
    parts = filename.rsplit("-", 1)
    file_id = parts[0] if len(parts) == 2 else filename.replace(".png", "")
    ts_str = parts[1].replace(".png", "") if len(parts) == 2 else ""
    stl_path = TRASH_DIR / f"{file_id}-{ts_str}.stl"
    if stl_path.exists():
        generate_thumbnail(stl_path, thumb_path)
        if thumb_path.exists():
            return FileResponse(thumb_path, media_type="image/png")
    raise HTTPException(404, "Thumbnail nicht gefunden")

# ── Backup / Restore ──────────────────────────────────────

return FileResponse(stl_path, media_type="application/octet-stream", filename=name)

+    """Thumbnail einer Datei im Papierkorb ausliefern."""

+        return FileResponse(thumb_path, media_type="image/png")

+    # Versuche das STL herauszufinden und ein Thumb zu generieren

+    file_id = parts[0] if len(parts) == 2 else filename.replace(".png", "")

+    ts_str = parts[1].replace(".png", "") if len(parts) == 2 else ""

+    stl_path = TRASH_DIR / f"{file_id}-{ts_str}.stl"

+        generate_thumbnail(stl_path, thumb_path)

+    raise HTTPException(404, "Thumbnail nicht gefunden")


Jetzt implementiere ich das Frontend. Ich werde CSS, HTML-Body und JavaScript Schritt für Schritt anpassen.


Updated task #2 status


/* Mobile */
@media (max-width: 480px) {
  .content { padding: 16px; }
  .grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
  .page-title { font-size: 22px; }
  .header-brand h1 { font-size: 17px; }
  .stat-pill { font-size: 11px; padding: 5px 10px; }
  .stats-grid { grid-template-columns: 1fr 1fr; gap: 6px; }
}
</style>

/* Mobile */
@media (max-width: 480px) {
  .content { padding: 16px; }
  .grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
  .page-title { font-size: 22px; }
  .header-brand h1 { font-size: 17px; }
  .stat-pill { font-size: 11px; padding: 5px 10px; }
  .stats-grid { grid-template-columns: 1fr 1fr; gap: 6px; }
}

/* ── Trash UI ─────────────────────────── */
.trash-btn {
  position: relative;
  width: 36px; height: 36px; border-radius: 11px;
  background: var(--card); border: 1px solid var(--card-border);
  color: var(--text-dim); cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: var(--transition);
  box-shadow: var(--shadow-sm);
  flex-shrink: 0;
}
.trash-btn:hover {
  color: var(--red); border-color: rgba(239,68,68,0.3);
  transform: translateY(-1px); box-shadow: var(--shadow-md);
}
.trash-btn.active {
  color: var(--red);
  background: rgba(239,68,68,0.08);
  border-color: rgba(239,68,68,0.3);
}
.trash-btn svg { width: 16px; height: 16px; }
.trash-btn .badge-count {
  position: absolute; top: -4px; right: -4px;
  min-width: 16px; height: 16px; border-radius: 999px;
  background: var(--red); color: white;
  font-size: 10px; font-weight: 800;
  display: flex; align-items: center; justify-content: center;
  padding: 0 4px;
  box-shadow: 0 2px 6px rgba(239,68,68,0.4);
  pointer-events: none;
}

/* Trash card overrides */
.trash-card .trash-actions {
  position: absolute; bottom: 10px; left: 10px; right: 10px;
  display: flex; gap: 6px;
  opacity: 0; transform: translateY(6px);
  transition: var(--transition);
  z-index: 2;
}
.trash-card:hover .trash-actions { opacity: 1; transform: translateY(0); }
@media (hover: none) {
  .trash-card .trash-actions { opacity: 1; transform: none; }
}
.trash-actions button {
  flex: 1; padding: 8px 10px; border-radius: 10px;
  font-size: 12px; font-weight: 700; font-family: inherit;
  border: none; cursor: pointer; transition: var(--transition);
  display: flex; align-items: center; justify-content: center; gap: 5px;
}
.trash-actions .btn-restore {
  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));
  color: white;
  box-shadow: 0 4px 12px rgba(59,130,246,0.3);
}
.trash-actions .btn-restore:hover { transform: translateY(-1px); box-shadow: 0 6px 16px rgba(59,130,246,0.4); }
.trash-actions .btn-perm-delete {
  background: rgba(239,68,68,0.1); color: var(--red);
  border: 1px solid rgba(239,68,68,0.22);
}
.trash-actions .btn-perm-delete:hover { background: rgba(239,68,68,0.18); }

/* Trash empty-state overrides */
.empty-trash .icon {
  background: linear-gradient(135deg, rgba(239,68,68,0.12), rgba(239,68,68,0.02));
  border-color: rgba(239,68,68,0.15);
}
.empty-trash .icon svg { color: var(--red); }

/* View Tabs */
.view-tabs {
  display: flex; gap: 4px; margin-bottom: 24px;
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: var(--radius-sm);
  padding: 4px;
  width: fit-content;
}
.view-tab {
  padding: 8px 16px; border-radius: 10px; font-size: 13px;
  font-weight: 600; color: var(--text-dim); cursor: pointer;
  border: none; background: transparent; font-family: inherit;
  transition: var(--transition);
  display: flex; align-items: center; gap: 6px;
}
.view-tab:hover { color: var(--text); }
.view-tab.active {
  background: var(--bg-elev);
  color: var(--text);
  box-shadow: var(--shadow-sm);
}
</style>


The file .media/HDD_1TB/PrintVault/frontend/index.html has been updated successfully. (file state is current in your context — no need to Read it back)

/* Mobile */
@media (max-width: 480px) {
  .content { padding: 16px; }
  .grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
  .page-title { font-size: 22px; }
  .header-brand h1 { font-size: 17px; }
  .stat-pill { font-size: 11px; padding: 5px 10px; }
  .stats-grid { grid-template-columns: 1fr 1fr; gap: 6px; }
}
</style>

/* Mobile */
@media (max-width: 480px) {
  .content { padding: 16px; }
  .grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
  .page-title { font-size: 22px; }
  .header-brand h1 { font-size: 17px; }
  .stat-pill { font-size: 11px; padding: 5px 10px; }
  .stats-grid { grid-template-columns: 1fr 1fr; gap: 6px; }
}

/* ── Trash UI ─────────────────────────── */
.trash-btn {
  position: relative;
  width: 36px; height: 36px; border-radius: 11px;
  background: var(--card); border: 1px solid var(--card-border);
  color: var(--text-dim); cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: var(--transition);
  box-shadow: var(--shadow-sm);
  flex-shrink: 0;
}
.trash-btn:hover {
  color: var(--red); border-color: rgba(239,68,68,0.3);
  transform: translateY(-1px); box-shadow: var(--shadow-md);
}
.trash-btn.active {
  color: var(--red);
  background: rgba(239,68,68,0.08);
  border-color: rgba(239,68,68,0.3);
}
.trash-btn svg { width: 16px; height: 16px; }
.trash-btn .badge-count {
  position: absolute; top: -4px; right: -4px;
  min-width: 16px; height: 16px; border-radius: 999px;
  background: var(--red); color: white;
  font-size: 10px; font-weight: 800;
  display: flex; align-items: center; justify-content: center;
  padding: 0 4px;
  box-shadow: 0 2px 6px rgba(239,68,68,0.4);
  pointer-events: none;
}

/* Trash card overrides */
.trash-card .trash-actions {
  position: absolute; bottom: 10px; left: 10px; right: 10px;
  display: flex; gap: 6px;
  opacity: 0; transform: translateY(6px);
  transition: var(--transition);
  z-index: 2;
}
.trash-card:hover .trash-actions { opacity: 1; transform: translateY(0); }
@media (hover: none) {
  .trash-card .trash-actions { opacity: 1; transform: none; }
}
.trash-actions button {
  flex: 1; padding: 8px 10px; border-radius: 10px;
  font-size: 12px; font-weight: 700; font-family: inherit;
  border: none; cursor: pointer; transition: var(--transition);
  display: flex; align-items: center; justify-content: center; gap: 5px;
}
.trash-actions .btn-restore {
  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));
  color: white;
  box-shadow: 0 4px 12px rgba(59,130,246,0.3);
}
.trash-actions .btn-restore:hover { transform: translateY(-1px); box-shadow: 0 6px 16px rgba(59,130,246,0.4); }
.trash-actions .btn-perm-delete {
  background: rgba(239,68,68,0.1); color: var(--red);
  border: 1px solid rgba(239,68,68,0.22);
}
.trash-actions .btn-perm-delete:hover { background: rgba(239,68,68,0.18); }

/* Trash empty-state overrides */
.empty-trash .icon {
  background: linear-gradient(135deg, rgba(239,68,68,0.12), rgba(239,68,68,0.02));
  border-color: rgba(239,68,68,0.15);
}
.empty-trash .icon svg { color: var(--red); }

/* View Tabs */
.view-tabs {
  display: flex; gap: 4px; margin-bottom: 24px;
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: var(--radius-sm);
  padding: 4px;
  width: fit-content;
}
.view-tab {
  padding: 8px 16px; border-radius: 10px; font-size: 13px;
  font-weight: 600; color: var(--text-dim); cursor: pointer;
  border: none; background: transparent; font-family: inherit;
  transition: var(--transition);
  display: flex; align-items: center; gap: 6px;
}
.view-tab:hover { color: var(--text); }
.view-tab.active {
  background: var(--bg-elev);
  color: var(--text);
  box-shadow: var(--shadow-sm);
}
</style>

+/* ── Trash UI ─────────────────────────── */

+  width: 36px; height: 36px; border-radius: 11px;

+  background: var(--card); border: 1px solid var(--card-border);

+  color: var(--text-dim); cursor: pointer;

+  color: var(--red); border-color: rgba(239,68,68,0.3);

+  transform: translateY(-1px); box-shadow: var(--shadow-md);

+.trash-btn svg { width: 16px; height: 16px; }

+  position: absolute; top: -4px; right: -4px;

+  min-width: 16px; height: 16px; border-radius: 999px;

+  background: var(--red); color: white;

+  position: absolute; bottom: 10px; left: 10px; right: 10px;

+  opacity: 0; transform: translateY(6px);

+.trash-card:hover .trash-actions { opacity: 1; transform: translateY(0); }

+  .trash-card .trash-actions { opacity: 1; transform: none; }

+  flex: 1; padding: 8px 10px; border-radius: 10px;

+  font-size: 12px; font-weight: 700; font-family: inherit;

+  border: none; cursor: pointer; transition: var(--transition);

+  display: flex; align-items: center; justify-content: center; gap: 5px;

+  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));

+.trash-actions .btn-restore:hover { transform: translateY(-1px); box-shadow: 0 6px 16px rgba(59,130,246,0.4); }

+  background: rgba(239,68,68,0.1); color: var(--red);

+.trash-actions .btn-perm-delete:hover { background: rgba(239,68,68,0.18); }

+.empty-trash .icon svg { color: var(--red); }

+  display: flex; gap: 4px; margin-bottom: 24px;

+  border: 1px solid var(--card-border);

+  padding: 8px 16px; border-radius: 10px; font-size: 13px;

+  font-weight: 600; color: var(--text-dim); cursor: pointer;

+  border: none; background: transparent; font-family: inherit;

+  display: flex; align-items: center; gap: 6px;

+.view-tab:hover { color: var(--text); }


      <button class="icon-btn" id="backup-btn" title="Backup herunterladen" aria-label="Backup">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" 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>
      </button>
    </div>
  </div>
</header>

<button class="icon-btn" id="backup-btn" title="Backup herunterladen" aria-label="Backup">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" 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>
      </button>
      <button class="trash-btn" id="trash-btn" title="Papierkorb" aria-label="Papierkorb">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" 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>
        0
      </button>


      <button class="icon-btn" id="backup-btn" title="Backup herunterladen" aria-label="Backup">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" 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>
      </button>
    </div>
  </div>
</header>

<button class="icon-btn" id="backup-btn" title="Backup herunterladen" aria-label="Backup">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" 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>
      </button>
      <button class="trash-btn" id="trash-btn" title="Papierkorb" aria-label="Papierkorb">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" 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>
        0
      </button>

+      <button class="trash-btn" id="trash-btn" title="Papierkorb" aria-label="Papierkorb">

+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">

+          <polyline points="3 6 5 6 21 6"></polyline>


Deine Modelle

Suche, organisiere und drucke deine STL-Sammlung.

      <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>
      <input type="text" class="search-box" placeholder="Suchen…" id="search" oninput="onSearchInput()" autocomplete="off">

    <select class="sort-btn" id="sort" onchange="applyFilters()">
      <option value="date">Neueste</option>
      <option value="name">Name</option>
      <option value="size">Größe</option>
      <option value="volume">Volumen</option>
    </select>

        <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>

STL hochladen

Drag & Drop oder klicken — auch mehrere Dateien

      <input type="file" accept=".stl" id="fileInput" multiple onchange="onFilesPicked(this)">

    <button class="view-tab active" id="tab-library" onclick="switchView('library')">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px">
        <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>
      Bibliothek
    </button>
    <button class="view-tab" id="tab-trash" onclick="switchView('trash')">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px">
        <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>
      Papierkorb
    </button>

Deine Modelle

Suche, organisiere und drucke deine STL-Sammlung.

        <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>
        <input type="text" class="search-box" placeholder="Suchen…" id="search" oninput="onSearchInput()" autocomplete="off">

      <select class="sort-btn" id="sort" onchange="applyFilters()">
        <option value="date">Neueste</option>
        <option value="name">Name</option>
        <option value="size">Größe</option>
        <option value="volume">Volumen</option>
      </select>

          <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>

STL hochladen

Drag & Drop oder klicken — auch mehrere Dateien

        <input type="file" accept=".stl" id="fileInput" multiple onchange="onFilesPicked(this)">

Papierkorb

Gelöschte Modelle werden hier gesammelt.

        <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>
        <input type="text" class="search-box" placeholder="Im Papierkorb suchen…" id="trash-search" oninput="onTrashSearch()" autocomplete="off">

      <select class="sort-btn" id="trash-sort" onchange="renderTrashItems()">
        <option value="date">Neueste zuerst</option>
        <option value="name">Name</option>
        <option value="size">Größe</option>
      </select>


    <button class="view-tab active" id="tab-library" onclick="switchView('library')">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px">
        <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>
      Bibliothek
    </button>
    <button class="view-tab" id="tab-trash" onclick="switchView('trash')">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px">
        <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>
      Papierkorb
    </button>

Deine Modelle

Suche, organisiere und drucke deine STL-Sammlung.

        <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>
        <input type="text" class="search-box" placeholder="Suchen…" id="search" oninput="onSearchInput()" autocomplete="off">

      <select class="sort-btn" id="sort" onchange="applyFilters()">
        <option value="date">Neueste</option>
        <option value="name">Name</option>
        <option value="size">Größe</option>
        <option value="volume">Volumen</option>
      </select>

          <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>

STL hochladen

Drag & Drop oder klicken — auch mehrere Dateien

        <input type="file" accept=".stl" id="fileInput" multiple onchange="onFilesPicked(this)">

Papierkorb

Gelöschte Modelle werden hier gesammelt.

        <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>
        <input type="text" class="search-box" placeholder="Im Papierkorb suchen…" id="trash-search" oninput="onTrashSearch()" autocomplete="off">

      <select class="sort-btn" id="trash-sort" onchange="renderTrashItems()">
        <option value="date">Neueste zuerst</option>
        <option value="name">Name</option>
        <option value="size">Größe</option>
      </select>

-
Suche, organisiere und drucke deine STL-Sammlung.

-      <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>

-      <input type="text" class="search-box" placeholder="Suchen…" id="search" oninput="onSearchInput()" autocomplete="off">

-    <select class="sort-btn" id="sort" onchange="applyFilters()">

-      <option value="date">Neueste</option>

-      <option value="name">Name</option>

-      <option value="size">Größe</option>

-      <option value="volume">Volumen</option>

+  <div class="view-tabs" id="view-tabs">

+    <button class="view-tab active" id="tab-library" onclick="switchView('library')">

+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px">

+    <button class="view-tab" id="tab-trash" onclick="switchView('trash')">

+        <polyline points="3 6 5 6 21 6"></polyline>

+
Suche, organisiere und drucke deine STL-Sammlung.

-    <label class="upload-box" id="dropZone">

-        <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>

+        <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>

+        <input type="text" class="search-box" placeholder="Suchen…" id="search" oninput="onSearchInput()" autocomplete="off">

-
Drag & Drop oder klicken — auch mehrere Dateien

-      <input type="file" accept=".stl" id="fileInput" multiple onchange="onFilesPicked(this)">

+      <select class="sort-btn" id="sort" onchange="applyFilters()">

+        <option value="date">Neueste</option>

+        <option value="name">Name</option>

+        <option value="size">Größe</option>

+        <option value="volume">Volumen</option>

+      <label class="upload-box" id="dropZone">

+          <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>

+
Drag & Drop oder klicken — auch mehrere Dateien

+        <input type="file" accept=".stl" id="fileInput" multiple onchange="onFilesPicked(this)">

+  <div id="view-trash" style="display:none">

+
Gelöschte Modelle werden hier gesammelt.

+        <input type="text" class="search-box" placeholder="Im Papierkorb suchen…" id="trash-search" oninput="onTrashSearch()" autocomplete="off">

+      <select class="sort-btn" id="trash-sort" onchange="renderTrashItems()">

+        <option value="date">Neueste zuerst</option>


document.getElementById('backup-btn').onclick = () => {
  toast('Backup wird erstellt…');
  window.location.href = API + '/backup';
};
document.getElementById('price-pill').onclick = openPriceDialog;
updatePricePill();

window.clearTagFilters = clearTagFilters;
window.applyFilters = applyFilters;
window.onSearchInput = onSearchInput;
window.onFilesPicked = onFilesPicked;

loadFiles();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });

document.getElementById('backup-btn').onclick = () => {
  toast('Backup wird erstellt…');
  window.location.href = API + '/backup';
};
document.getElementById('price-pill').onclick = openPriceDialog;
updatePricePill();

// ─── View Switching ─────────────────────────
let currentView = 'library';
let trashItems = [];
let trashSearchDebounce = null;

window.clearTagFilters = clearTagFilters;
window.applyFilters = applyFilters;
window.onSearchInput = onSearchInput;
window.onFilesPicked = onFilesPicked;
window.switchView = switchView;
window.onTrashSearch = onTrashSearch;

function switchView(view) {
  currentView = view;
  document.getElementById('tab-library').classList.toggle('active', view === 'library');
  document.getElementById('tab-trash').classList.toggle('active', view === 'trash');
  document.getElementById('trash-btn').classList.toggle('active', view === 'trash');
  document.getElementById('view-library').style.display = view === 'library' ? '' : 'none';
  document.getElementById('view-trash').style.display = view === 'trash' ? '' : 'none';
  if (view === 'trash') {
    loadTrash();
  }
}

document.getElementById('trash-btn').onclick = () => {
  const newView = currentView === 'trash' ? 'library' : 'trash';
  switchView(newView);
};

// ─── Trash Logic ────────────────────────────
async function loadTrash() {
  const container = document.getElementById('trash-grid');
  container.innerHTML = `

  `;
  try {
    const res = await fetch(API + '/trash');
    trashItems = await res.json();
  } catch (e) {
    toast('Papierkorb konnte nicht geladen werden');
    trashItems = [];
  }
  updateTrashBadge();
  renderTrashItems();
}

function onTrashSearch() {
  clearTimeout(trashSearchDebounce);
  trashSearchDebounce = setTimeout(renderTrashItems, 180);
}

function fmtDate(iso) {
  if (!iso) return '—';
  try {
    const d = new Date(iso);
    return d.toLocaleString('de-DE', {
      year: 'numeric', month: '2-digit', day: '2-digit',
      hour: '2-digit', minute: '2-digit'
    });
  } catch { return iso; }
}

function trashCardHTML(item) {
  const thumbUrl = item.has_thumb
    ? (API + '/trash-thumbs/' + encodeURIComponent(item.filename.replace('.stl', '.png')))
    : null;
  return `

        <button class="btn-restore" onclick="restoreItem('${escapeHTML(item.filename)}')" title="Wiederherstellen">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><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>
          Wiederherstellen
        </button>
        <button class="btn-perm-delete" onclick="openTrashConfirm('${escapeHTML(item.filename)}', '${escapeHTML(item.file_id)}')" title="Endgültig löschen">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><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>
          Löschen
        </button>

        ${thumbUrl ? `<img src="${thumbUrl}" loading="lazy" alt="" style="width:92%;height:92%;object-fit:contain;filter:drop-shadow(0 6px 12px rgba(37,99,235,0.25))">` : '🔷'}

${escapeHTML(item.file_id)}

          ${item.size_kb} KB
          ${fmtDate(item.deleted_at)}

;
}
// Fix broken concatenation caused by > → > escaping


document.getElementById('backup-btn').onclick = () => {
  toast('Backup wird erstellt…');
  window.location.href = API + '/backup';
};
document.getElementById('price-pill').onclick = openPriceDialog;
updatePricePill();

window.clearTagFilters = clearTagFilters;
window.applyFilters = applyFilters;
window.onSearchInput = onSearchInput;
window.onFilesPicked = onFilesPicked;

loadFiles();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });

document.getElementById('backup-btn').onclick = () => {
  toast('Backup wird erstellt…');
  window.location.href = API + '/backup';
};
document.getElementById('price-pill').onclick = openPriceDialog;
updatePricePill();

// ─── View Switching ─────────────────────────
let currentView = 'library';
let trashItems = [];
let trashSearchDebounce = null;

window.clearTagFilters = clearTagFilters;
window.applyFilters = applyFilters;
window.onSearchInput = onSearchInput;
window.onFilesPicked = onFilesPicked;
window.switchView = switchView;
window.onTrashSearch = onTrashSearch;

function switchView(view) {
  currentView = view;
  document.getElementById('tab-library').classList.toggle('active', view === 'library');
  document.getElementById('tab-trash').classList.toggle('active', view === 'trash');
  document.getElementById('trash-btn').classList.toggle('active', view === 'trash');
  document.getElementById('view-library').style.display = view === 'library' ? '' : 'none';
  document.getElementById('view-trash').style.display = view === 'trash' ? '' : 'none';
  if (view === 'trash') {
    loadTrash();
  }
}

document.getElementById('trash-btn').onclick = () => {
  const newView = currentView === 'trash' ? 'library' : 'trash';
  switchView(newView);
};

// ─── Trash Logic ────────────────────────────
async function loadTrash() {
  const container = document.getElementById('trash-grid');
  container.innerHTML = `

  `;
  try {
    const res = await fetch(API + '/trash');
    trashItems = await res.json();
  } catch (e) {
    toast('Papierkorb konnte nicht geladen werden');
    trashItems = [];
  }
  updateTrashBadge();
  renderTrashItems();
}

function onTrashSearch() {
  clearTimeout(trashSearchDebounce);
  trashSearchDebounce = setTimeout(renderTrashItems, 180);
}

function fmtDate(iso) {
  if (!iso) return '—';
  try {
    const d = new Date(iso);
    return d.toLocaleString('de-DE', {
      year: 'numeric', month: '2-digit', day: '2-digit',
      hour: '2-digit', minute: '2-digit'
    });
  } catch { return iso; }
}

function trashCardHTML(item) {
  const thumbUrl = item.has_thumb
    ? (API + '/trash-thumbs/' + encodeURIComponent(item.filename.replace('.stl', '.png')))
    : null;
  return `

        <button class="btn-restore" onclick="restoreItem('${escapeHTML(item.filename)}')" title="Wiederherstellen">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><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>
          Wiederherstellen
        </button>
        <button class="btn-perm-delete" onclick="openTrashConfirm('${escapeHTML(item.filename)}', '${escapeHTML(item.file_id)}')" title="Endgültig löschen">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><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>
          Löschen
        </button>

        ${thumbUrl ? `<img src="${thumbUrl}" loading="lazy" alt="" style="width:92%;height:92%;object-fit:contain;filter:drop-shadow(0 6px 12px rgba(37,99,235,0.25))">` : '🔷'}

${escapeHTML(item.file_id)}

          ${item.size_kb} KB
          ${fmtDate(item.deleted_at)}

;
}
// Fix broken concatenation caused by > → > escaping

+// ─── View Switching ─────────────────────────

-document.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });

+  document.getElementById('tab-library').classList.toggle('active', view === 'library');

+  document.getElementById('tab-trash').classList.toggle('active', view === 'trash');

+  document.getElementById('trash-btn').classList.toggle('active', view === 'trash');

+  document.getElementById('view-library').style.display = view === 'library' ? '' : 'none';

+  document.getElementById('view-trash').style.display = view === 'trash' ? '' : 'none';

+document.getElementById('trash-btn').onclick = () => {

+  const newView = currentView === 'trash' ? 'library' : 'trash';

+// ─── Trash Logic ────────────────────────────

+  const container = document.getElementById('trash-grid');

+    const res = await fetch(API + '/trash');

+    toast('Papierkorb konnte nicht geladen werden');

+  trashSearchDebounce = setTimeout(renderTrashItems, 180);

+      year: 'numeric', month: '2-digit', day: '2-digit',

+      hour: '2-digit', minute: '2-digit'

+    ? (API + '/trash-thumbs/' + encodeURIComponent(item.filename.replace('.stl', '.png')))

+    <div class="card trash-card" data-filename="${escapeHTML(item.filename)}">

+        <button class="btn-restore" onclick="restoreItem('${escapeHTML(item.filename)}')" title="Wiederherstellen">

+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><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>

+        <button class="btn-perm-delete" onclick="openTrashConfirm('${escapeHTML(item.filename)}', '${escapeHTML(item.file_id)}')" title="Endgültig löschen">

+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><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>

+        ${thumbUrl ? `<img src="${thumbUrl}" loading="lazy" alt="" style="width:92%;height:92%;object-fit:contain;filter:drop-shadow(0 6px 12px rgba(37,99,235,0.25))">` : '🔷'}

+      <div class="info" style="padding-bottom: 60px;">

+        <div class="meta" style="margin-top:6px">

+          <span>${item.size_kb} KB</span>

+// Fix broken concatenation caused by > → > escaping


1590
Notiz<textarea id="edit-note" placeholder="Notizen, Druckeinstellungen…">${escapeHTML(f.note || '')}</textarea>

1591
1592

1593	      <button class="btn-delete" id="btn-delete">
1594	        <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>
1595	        Löschen
1596	      </button>
1597	      <button class="btn-download" id="btn-download">
1598	        <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>
1599	        Download
1600	      </button>
1601	      <button class="btn-save" id="btn-save">
1602	        <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>
1603	        Speichern
1604	      </button>
1605
`;
1606
1607	  renderModalTags(f);
1608
1609	  const viewerEl = modal.querySelector('#stl-viewer');
1610	  if (viewerEl && window.openSTLViewer) {
1611	    window.openSTLViewer(viewerEl, API + '/download/' + f.id);
1612	  }
1613
1614	  const nameInput = modal.querySelector('#edit-name');
1615	  const noteInput = modal.querySelector('#edit-note');
1616	  let nameTimer, noteTimer;
1617	  nameInput.addEventListener('input', () => { clearTimeout(nameTimer); nameTimer = setTimeout(() => optimisticPatch(f.id, {name: nameInput.value}, ['name']), 500); });
1618	  noteInput.addEventListener('input', () => { clearTimeout(noteTimer); noteTimer = setTimeout(() => optimisticPatch(f.id, {note: noteInput.value}, ['note']), 500); });
1619
1620	  modal.querySelector('#tag-add-btn').onclick = () => addTagFromInput(f.id);
1621	  modal.querySelector('#tag-input').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); addTagFromInput(f.id); } });
1622	  modal.querySelector('#btn-save').onclick = () => {
1623	    optimisticPatch(f.id, {name: nameInput.value, note: noteInput.value}, ['name','note']);
1624	    window.closeSTLViewer?.();
1625	    document.querySelector('.modal-overlay')?.remove();
1626	    toast('Gespeichert');
1627	  };
1628	  modal.querySelector('#btn-download').onclick = () => window.open(API + '/download/' + f.id, '_blank');
1629	  modal.querySelector('#btn-delete').onclick = () => {
1630	    window.closeSTLViewer?.();
1631	    document.querySelector('.modal-overlay')?.remove();
1632	    softDelete(f.id);
1633	  };
1634	}
1635
1636	function renderModalTags(f) {
1637	  const bar = document.getElementById('tag-bar');
1638	  if (!bar) return;
1639	  bar.innerHTML = (f.tags || []).map(t =>
1640	    `<span class="tag">${escapeHTML(t)} <span class="remove" data-tag="${escapeHTML(t)}">×</span></span>`
1641	  ).join('') || 'Noch keine Tags';
1642	  bar.querySelectorAll('.remove').forEach(x => { x.onclick = () => removeTag(f.id, x.dataset.tag); });
1643	}
1644
1645	async function optimisticPatch(id, patch, fields) {
1646	  const f = allFiles.find(x => x.id === id);
1647	  if (!f) return;
1648	  const prev = {};
1649	  fields.forEach(k => { prev[k] = f[k]; f[k] = patch[k]; });
1650	  try {
1651	    const res = await fetch(API + '/files/' + id, {
1652	      method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(patch),
1653	    });
1654	    if (!res.ok) throw new Error();
1655	    renderTagChips(); applyFilters();
1656	  } catch {
1657	    fields.forEach(k => { f[k] = prev[k]; });
1658	    renderModalTags(f); applyFilters();
1659	    toast('Speichern fehlgeschlagen');
1660	  }
1661	}
1662
1663	async function addTagFromInput(id) {
1664	  const input = document.getElementById('tag-input');
1665	  const tag = input.value.trim();
1666	  if (!tag) return;
1667	  input.value = '';
1668	  const f = allFiles.find(x => x.id === id);
1669	  if (!f) return;
1670	  if ((f.tags || []).map(t => t.toLowerCase()).includes(tag.toLowerCase())) return;
1671	  await optimisticPatch(id, {tags: [...(f.tags || []), tag]}, ['tags']);
1672	  renderModalTags(f);
1673	}
1674
1675	async function removeTag(id, tag) {
1676	  const f = allFiles.find(x => x.id === id);
1677	  if (!f) return;
1678	  await optimisticPatch(id, {tags: (f.tags || []).filter(t => t !== tag)}, ['tags']);
1679	  renderModalTags(f);
1680	}
1681
1682	function softDelete(id) {
1683	  const f = allFiles.find(x => x.id === id);
1684	  if (!f) return;
1685	  const idx = allFiles.indexOf(f);
1686	  allFiles = allFiles.filter(x => x.id !== id);
1687	  renderTagChips(); applyFilters();
1688	  const timeoutId = setTimeout(async () => {
1689	    delete pendingDeletes[id];

Notiz<textarea id="edit-note" placeholder="Notizen, Druckeinstellungen…">${escapeHTML(f.note || '')}</textarea>

      <button class="btn-delete" id="btn-delete">
        <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>
        Löschen
      </button>
      <button class="btn-download" id="btn-download">
        <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>
        Download
      </button>
      <button class="btn-save" id="btn-save">
        <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>
        Speichern
      </button>

`;

  renderModalTags(f);

  const viewerEl = modal.querySelector('#stl-viewer');
  if (viewerEl && window.openSTLViewer) {
    window.openSTLViewer(viewerEl, API + '/download/' + f.id);
  }

  const nameInput = modal.querySelector('#edit-name');
  const noteInput = modal.querySelector('#edit-note');
  let nameTimer, noteTimer;
  nameInput.addEventListener('input', () => { clearTimeout(nameTimer); nameTimer = setTimeout(() => optimisticPatch(f.id, {name: nameInput.value}, ['name']), 500); });
  noteInput.addEventListener('input', () => { clearTimeout(noteTimer); noteTimer = setTimeout(() => optimisticPatch(f.id, {note: noteInput.value}, ['note']), 500); });

  modal.querySelector('#tag-add-btn').onclick = () => addTagFromInput(f.id);
  modal.querySelector('#tag-input').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); addTagFromInput(f.id); } });
  modal.querySelector('#btn-save').onclick = () => {
    optimisticPatch(f.id, {name: nameInput.value, note: noteInput.value}, ['name','note']);
    window.closeSTLViewer?.();
    document.querySelector('.modal-overlay')?.remove();
    toast('Gespeichert');
  };
  modal.querySelector('#btn-download').onclick = () => window.open(API + '/download/' + f.id, '_blank');
  modal.querySelector('#btn-delete').onclick = () => {
    window.closeSTLViewer?.();
    document.querySelector('.modal-overlay')?.remove();
    softDelete(f.id);
  };
}

function renderModalTags(f) {
  const bar = document.getElementById('tag-bar');
  if (!bar) return;
  bar.innerHTML = (f.tags || []).map(t =>
    `<span class="tag">${escapeHTML(t)} <span class="remove" data-tag="${escapeHTML(t)}">×</span></span>`
  ).join('') || 'Noch keine Tags';
  bar.querySelectorAll('.remove').forEach(x => { x.onclick = () => removeTag(f.id, x.dataset.tag); });
}

async function optimisticPatch(id, patch, fields) {
  const f = allFiles.find(x => x.id === id);
  if (!f) return;
  const prev = {};
  fields.forEach(k => { prev[k] = f[k]; f[k] = patch[k]; });
  try {
    const res = await fetch(API + '/files/' + id, {
      method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(patch),
    });
    if (!res.ok) throw new Error();
    renderTagChips(); applyFilters();
  } catch {
    fields.forEach(k => { f[k] = prev[k]; });
    renderModalTags(f); applyFilters();
    toast('Speichern fehlgeschlagen');
  }
}

async function addTagFromInput(id) {
  const input = document.getElementById('tag-input');
  const tag = input.value.trim();
  if (!tag) return;
  input.value = '';
  const f = allFiles.find(x => x.id === id);
  if (!f) return;
  if ((f.tags || []).map(t => t.toLowerCase()).includes(tag.toLowerCase())) return;
  await optimisticPatch(id, {tags: [...(f.tags || []), tag]}, ['tags']);
  renderModalTags(f);
}

async function removeTag(id, tag) {
  const f = allFiles.find(x => x.id === id);
  if (!f) return;
  await optimisticPatch(id, {tags: (f.tags || []).filter(t => t !== tag)}, ['tags']);
  renderModalTags(f);
}

function softDelete(id) {
  const f = allFiles.find(x => x.id === id);
  if (!f) return;
  const idx = allFiles.indexOf(f);
  allFiles = allFiles.filter(x => x.id !== id);
  renderTagChips(); applyFilters();
  const timeoutId = setTimeout(async () => {
    delete pendingDeletes[id];


1710	document.getElementById('backup-btn').onclick = () => {
1711	  toast('Backup wird erstellt…');
1712	  window.location.href = API + '/backup';
1713	};
1714	document.getElementById('price-pill').onclick = openPriceDialog;
1715	updatePricePill();
1716
1717	// ─── View Switching ─────────────────────────
1718	let currentView = 'library';
1719	let trashItems = [];
1720	let trashSearchDebounce = null;
1721
1722	window.clearTagFilters = clearTagFilters;
1723	window.applyFilters = applyFilters;
1724	window.onSearchInput = onSearchInput;
1725	window.onFilesPicked = onFilesPicked;
1726	window.switchView = switchView;
1727	window.onTrashSearch = onTrashSearch;
1728
1729	function switchView(view) {
1730	  currentView = view;
1731	  document.getElementById('tab-library').classList.toggle('active', view === 'library');
1732	  document.getElementById('tab-trash').classList.toggle('active', view === 'trash');
1733	  document.getElementById('trash-btn').classList.toggle('active', view === 'trash');
1734	  document.getElementById('view-library').style.display = view === 'library' ? '' : 'none';
1735	  document.getElementById('view-trash').style.display = view === 'trash' ? '' : 'none';
1736	  if (view === 'trash') {
1737	    loadTrash();
1738	  }
1739	}
1740
1741	document.getElementById('trash-btn').onclick = () => {
1742	  const newView = currentView === 'trash' ? 'library' : 'trash';
1743	  switchView(newView);
1744	};
1745
1746	// ─── Trash Logic ────────────────────────────
1747	async function loadTrash() {
1748	  const container = document.getElementById('trash-grid');
1749	  container.innerHTML = `
1750

1751

1752

1753

1754	  `;
1755	  try {
1756	    const res = await fetch(API + '/trash');
1757	    trashItems = await res.json();
1758	  } catch (e) {
1759	    toast('Papierkorb konnte nicht geladen werden');
1760	    trashItems = [];
1761	  }
1762	  updateTrashBadge();
1763	  renderTrashItems();
1764	}
1765
1766	function onTrashSearch() {
1767	  clearTimeout(trashSearchDebounce);
1768	  trashSearchDebounce = setTimeout(renderTrashItems, 180);
1769	}
1770
1771	function fmtDate(iso) {
1772	  if (!iso) return '—';
1773	  try {
1774	    const d = new Date(iso);
1775	    return d.toLocaleString('de-DE', {
1776	      year: 'numeric', month: '2-digit', day: '2-digit',
1777	      hour: '2-digit', minute: '2-digit'
1778	    });
1779	  } catch { return iso; }
1780	}
1781
1782	function trashCardHTML(item) {
1783	  const thumbUrl = item.has_thumb
1784	    ? (API + '/trash-thumbs/' + encodeURIComponent(item.filename.replace('.stl', '.png')))
1785	    : null;
1786	  return `
1787

1788

1789	        <button class="btn-restore" onclick="restoreItem('${escapeHTML(item.filename)}')" title="Wiederherstellen">
1790	          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><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>
1791	          Wiederherstellen
1792	        </button>
1793	        <button class="btn-perm-delete" onclick="openTrashConfirm('${escapeHTML(item.filename)}', '${escapeHTML(item.file_id)}')" title="Endgültig löschen">
1794	          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><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>
1795	          Löschen
1796	        </button>
1797

1798

1799	        ${thumbUrl ? `<img src="${thumbUrl}" loading="lazy" alt="" style="width:92%;height:92%;object-fit:contain;filter:drop-shadow(0 6px 12px rgba(37,99,235,0.25))">` : '🔷'}
1800

1801

1802
${escapeHTML(item.file_id)}

1803

1804	          ${item.size_kb} KB
1805	          ${fmtDate(item.deleted_at)}
1806

1807

1808
;
1809	}
1810	// Fix broken concatenation caused by > → > escaping
1811
1812	</script>
1813
1814
1815

document.getElementById('backup-btn').onclick = () => {
  toast('Backup wird erstellt…');
  window.location.href = API + '/backup';
};
document.getElementById('price-pill').onclick = openPriceDialog;
updatePricePill();

// ─── View Switching ─────────────────────────
let currentView = 'library';
let trashItems = [];
let trashSearchDebounce = null;

window.clearTagFilters = clearTagFilters;
window.applyFilters = applyFilters;
window.onSearchInput = onSearchInput;
window.onFilesPicked = onFilesPicked;
window.switchView = switchView;
window.onTrashSearch = onTrashSearch;

function switchView(view) {
  currentView = view;
  document.getElementById('tab-library').classList.toggle('active', view === 'library');
  document.getElementById('tab-trash').classList.toggle('active', view === 'trash');
  document.getElementById('trash-btn').classList.toggle('active', view === 'trash');
  document.getElementById('view-library').style.display = view === 'library' ? '' : 'none';
  document.getElementById('view-trash').style.display = view === 'trash' ? '' : 'none';
  if (view === 'trash') {
    loadTrash();
  }
}

document.getElementById('trash-btn').onclick = () => {
  const newView = currentView === 'trash' ? 'library' : 'trash';
  switchView(newView);
};

// ─── Trash Logic ────────────────────────────
async function loadTrash() {
  const container = document.getElementById('trash-grid');
  container.innerHTML = `

  `;
  try {
    const res = await fetch(API + '/trash');
    trashItems = await res.json();
  } catch (e) {
    toast('Papierkorb konnte nicht geladen werden');
    trashItems = [];
  }
  updateTrashBadge();
  renderTrashItems();
}

function onTrashSearch() {
  clearTimeout(trashSearchDebounce);
  trashSearchDebounce = setTimeout(renderTrashItems, 180);
}

function fmtDate(iso) {
  if (!iso) return '—';
  try {
    const d = new Date(iso);
    return d.toLocaleString('de-DE', {
      year: 'numeric', month: '2-digit', day: '2-digit',
      hour: '2-digit', minute: '2-digit'
    });
  } catch { return iso; }
}

function trashCardHTML(item) {
  const thumbUrl = item.has_thumb
    ? (API + '/trash-thumbs/' + encodeURIComponent(item.filename.replace('.stl', '.png')))
    : null;
  return `

        <button class="btn-restore" onclick="restoreItem('${escapeHTML(item.filename)}')" title="Wiederherstellen">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><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>
          Wiederherstellen
        </button>
        <button class="btn-perm-delete" onclick="openTrashConfirm('${escapeHTML(item.filename)}', '${escapeHTML(item.file_id)}')" title="Endgültig löschen">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><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>
          Löschen
        </button>

        ${thumbUrl ? `<img src="${thumbUrl}" loading="lazy" alt="" style="width:92%;height:92%;object-fit:contain;filter:drop-shadow(0 6px 12px rgba(37,99,235,0.25))">` : '🔷'}

${escapeHTML(item.file_id)}

          ${item.size_kb} KB
          ${fmtDate(item.deleted_at)}

;
}
// Fix broken concatenation caused by > → > escaping

</script>


;
}
// Fix broken concatenation caused by > → > escaping

</script>

  `;
}

function renderTrashItems() {
  const grid = document.getElementById('trash-grid');
  const search = document.getElementById('trash-search').value.toLowerCase().trim();
  const sort = document.getElementById('trash-sort').value;

  // Update subtitle
  document.getElementById('trash-subtitle').textContent =
    trashItems.length ? `${trashItems.length} gelöschte(s) Modell(e) · Speicherplatz: ${Math.round(trashItems.reduce((s,i)=>s+i.size_kb,0)/1024*10)/10} MB` :
    'Gelöschte Modelle werden hier gesammelt.';

  let list = trashItems.slice();
  if (search) {
    list = list.filter(i =>
      i.file_id.toLowerCase().includes(search) ||
      i.filename.toLowerCase().includes(search)
    );
  }
  if (sort === 'name') list.sort((a,b) => a.file_id.toLowerCase().localeCompare(b.file_id.toLowerCase()));
  else if (sort === 'size') list.sort((a,b) => b.size_kb - a.size_kb);
  else list.sort((a,b) => new Date(b.deleted_at) - new Date(a.deleted_at));

  if (!list.length) {
    grid.innerHTML = `

          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>

Papierkorb ist leer

Gelöschte Modelle erscheinen hier. Sie werden erst nach "Endgültig löschen" entfernt.

`;
    return;
  }
  grid.innerHTML = list.map(trashCardHTML).join('');
}

async function restoreItem(filename) {
  const card = document.querySelector(`.trash-card[data-filename="${CSS.escape(filename)}"]`);
  if (card) { card.classList.add('removing'); card.style.opacity = '0.5'; }
  try {
    const res = await fetch(API + '/trash/' + encodeURIComponent(filename) + '/restore', { method: 'POST' });
    const data = await res.json();
    if (data.ok) {
      trashItems = trashItems.filter(i => i.filename !== filename);
      updateTrashBadge();
      renderTrashItems();
      toast('Wiederhergestellt');
    } else {
      throw new Error();
    }
  } catch {
    if (card) card.classList.remove('removing');
    toast('Wiederherstellung fehlgeschlagen');
  }
}

function openTrashConfirm(filename, fileId) {
  const overlay = document.createElement('div');
  overlay.className = 'modal-overlay';
  overlay.style.background = 'rgba(8,12,24,0.7)';
  overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
  overlay.innerHTML = `

        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;color:var(--red)">
          <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>

Endgültig löschen

        „${escapeHTML(fileId)}" unwiderruflich aus dem Papierkorb entfernen?

        <button class="btn-download" id="trash-confirm-cancel">Abbrechen</button>
        <button class="btn-delete" id="trash-confirm-delete" style="background:var(--red);color:white;border-color:transparent">Endgültig löschen</button>

`;
  document.body.appendChild(overlay);
  overlay.querySelector('#trash-confirm-cancel').onclick = () => overlay.remove();
  overlay.querySelector('#trash-confirm-delete').onclick = () => {
    overlay.remove();
    permanentlyDeleteItem(filename);
  };
}

async function permanentlyDeleteItem(filename) {
  const card = document.querySelector(`.trash-card[data-filename="${CSS.escape(filename)}"]`);
  if (card) { card.classList.add('removing'); card.style.opacity = '0.5'; }
  try {
    const res = await fetch(API + '/trash/' + encodeURIComponent(filename), { method: 'DELETE' });
    if (res.ok) {
      trashItems = trashItems.filter(i => i.filename !== filename);
      updateTrashBadge();
      renderTrashItems();
      toast('Endgültig gelöscht');
    } else {
      throw new Error();
    }
  } catch {
    if (card) card.classList.remove('removing');
    toast('Löschen fehlgeschlagen');
  }
}

function updateTrashBadge() {
  const badge = document.getElementById('trash-badge');
  if (trashItems.length > 0) {
    badge.style.display = '';
    badge.textContent = trashItems.length > 99 ? '99+' : String(trashItems.length);
  } else {
    badge.style.display = 'none';
  }
}

loadFiles();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });
</script>


<tool_use_error>String to replace not found in file.
String:
;
}
// Fix broken concatenation caused by > → > escaping

</script>
(note: Edit also tried swapping \uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)</tool_use_error>

Error: String to replace not found in file.
String:
;
}
// Fix broken concatenation caused by > → > escaping

</script>
(note: Edit also tried swapping \uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)


1800

1801

1802
${escapeHTML(item.file_id)}

1803

1804	          ${item.size_kb} KB
1805	          ${fmtDate(item.deleted_at)}
1806

1807

1808
;
1809	}
1810	// Fix broken concatenation caused by > → > escaping
1811
1812	</script>
1813
1814
1815

${escapeHTML(item.file_id)}

          ${item.size_kb} KB
          ${fmtDate(item.deleted_at)}

;
}
// Fix broken concatenation caused by > → > escaping

</script>


Bash

sed -n '1807,1814p' .media/HDD_1TB/PrintVault/frontend/index.html | cat -A

Show exact characters including hidden ones


      </div>$
    </div>;$
}$
// Fix broken concatenation caused by > M-bM-^FM-^R > escaping$
$
</script>$
</body>$
</html>$


sed -i 's/Fix broken concatenation caused by .* escaping.*/\/\/ ── END ──/' .media/HDD_1TB/PrintVault/frontend/index.html

Replace problematic comment line


(Bash completed with no output)


Backend: Trash-Endpunkte in main.py

In main.py die 3 neuen Endpoints hinzufügen: GET /api/trash, POST /api/trash/{filename}/restore, DELETE /api/trash/{filename}.

Frontend: Trash-Ansicht in index.html

In index.html Trash-Icon Badge im Header, Trash-Ansicht mit Karten, Wiederherstellen/Löschen-Buttons, Bestätigungsdialog, Leerzustand und Navigation ergänzen.


tail -n 15 .media/HDD_1TB/PrintVault/frontend/index.html | cat -n

Check end of file after replacement


1

     2

     3
${escapeHTML(item.file_id)}

     4

     5	          ${item.size_kb} KB
     6	          ${fmtDate(item.deleted_at)}
     7

     8

     9
;
    10	}
    11	// // ── END ──
    12
    13	</script>
    14
    15


Edit

  `;
}

function renderTrashItems() {
  const grid = document.getElementById('trash-grid');
  const search = document.getElementById('trash-search').value.toLowerCase().trim();
  const sort = document.getElementById('trash-sort').value;

  // Update subtitle
  document.getElementById('trash-subtitle').textContent =
    trashItems.length ? `${trashItems.length} geloeschte(s) Modell(e)  Speicherplatz: ${Math.round(trashItems.reduce((s,i)=>s+i.size_kb,0)/1024*10)/10} MB` :
    'Geloeschte Modelle werden hier gesammelt.';

  let list = trashItems.slice();
  if (search) {
    list = list.filter(i =>
      i.file_id.toLowerCase().includes(search) ||
      i.filename.toLowerCase().includes(search)
    );
  }
  if (sort === 'name') list.sort((a,b) => a.file_id.toLowerCase().localeCompare(b.file_id.toLowerCase()));
  else if (sort === 'size') list.sort((a,b) => b.size_kb - a.size_kb);
  else list.sort((a,b) => new Date(b.deleted_at) - new Date(a.deleted_at));

  if (!list.length) {
    grid.innerHTML = `

          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>

Papierkorb ist leer

Geloeschte Modelle erscheinen hier. Sie werden erst nach "Endgueltig loeschen" entfernt.

`;
    return;
  }
  grid.innerHTML = list.map(trashCardHTML).join('');
}

async function restoreItem(filename) {
  const card = document.querySelector(`.trash-card[data-filename="${CSS.escape(filename)}"]`);
  if (card) { card.classList.add('removing'); card.style.opacity = '0.5'; }
  try {
    const res = await fetch(API + '/trash/' + encodeURIComponent(filename) + '/restore', { method: 'POST' });
    const data = await res.json();
    if (data.ok) {
      trashItems = trashItems.filter(i => i.filename !== filename);
      updateTrashBadge();
      renderTrashItems();
      toast('Wiederhergestellt');
    } else {
      throw new Error();
    }
  } catch {
    if (card) card.classList.remove('removing');
    toast('Wiederherstellung fehlgeschlagen');
  }
}

function openTrashConfirm(filename, fileId) {
  const overlay = document.createElement('div');
  overlay.className = 'modal-overlay';
  overlay.style.background = 'rgba(8,12,24,0.7)';
  overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
  overlay.innerHTML = `

        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;color:var(--red)">
          <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>

Endgueltig loeschen

        ${escapeHTML(fileId)} unwiderruflich aus dem Papierkorb entfernen?

        <button class="btn-download" id="trash-confirm-cancel">Abbrechen</button>
        <button class="btn-delete" id="trash-confirm-delete" style="background:var(--red);color:white;border-color:transparent;flex:1">Endgueltig loeschen</button>

`;
  document.body.appendChild(overlay);
  overlay.querySelector('#trash-confirm-cancel').onclick = () => overlay.remove();
  overlay.querySelector('#trash-confirm-delete').onclick = () => {
    overlay.remove();
    permanentlyDeleteItem(filename);
  };
}

async function permanentlyDeleteItem(filename) {
  const card = document.querySelector(`.trash-card[data-filename="${CSS.escape(filename)}"]`);
  if (card) { card.classList.add('removing'); card.style.opacity = '0.5'; }
  try {
    const res = await fetch(API + '/trash/' + encodeURIComponent(filename), { method: 'DELETE' });
    if (res.ok) {
      trashItems = trashItems.filter(i => i.filename !== filename);
      updateTrashBadge();
      renderTrashItems();
      toast('Endgueltig geloescht');
    } else {
      throw new Error();
    }
  } catch {
    if (card) card.classList.remove('removing');
    toast('Loeschen fehlgeschlagen');
  }
}

function updateTrashBadge() {
  const badge = document.getElementById('trash-badge');
  if (trashItems.length > 0) {
    badge.style.display = '';
    badge.textContent = trashItems.length > 99 ? '99+' : String(trashItems.length);
  } else {
    badge.style.display = 'none';
  }
}

loadFiles();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });

// Initial auch den Badge laden
loadTrash();
</script>


<tool_use_error>File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.</tool_use_error>

Error: File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.


1800

1801

1802
${escapeHTML(item.file_id)}

1803

1804	          ${item.size_kb} KB
1805	          ${fmtDate(item.deleted_at)}
1806

1807

1808
;
1809	}
1810	// // ── END ──
1811
1812	</script>
1813
1814
1815

${escapeHTML(item.file_id)}

          ${item.size_kb} KB
          ${fmtDate(item.deleted_at)}

;
}
// // ── END ──

</script>


    </div>;
}
// // ── END ──

</script>
</body>
</html>

  `;
}

function renderTrashItems() {
  const grid = document.getElementById('trash-grid');
  const search = document.getElementById('trash-search').value.toLowerCase().trim();
  const sort = document.getElementById('trash-sort').value;

  document.getElementById('trash-subtitle').textContent =
    trashItems.length ? `${trashItems.length} geloeschte(s) Modell(e) – Speicherplatz: ${Math.round(trashItems.reduce((s,i)=>s+i.size_kb,0)/1024*10)/10} MB` :
    'Geloeschte Modelle werden hier gesammelt.';

  let list = trashItems.slice();
  if (search) {
    list = list.filter(i =>
      i.file_id.toLowerCase().includes(search) ||
      i.filename.toLowerCase().includes(search)
    );
  }
  if (sort === 'name') list.sort((a,b) => a.file_id.toLowerCase().localeCompare(b.file_id.toLowerCase()));
  else if (sort === 'size') list.sort((a,b) => b.size_kb - a.size_kb);
  else list.sort((a,b) => new Date(b.deleted_at) - new Date(a.deleted_at));

  if (!list.length) {
    grid.innerHTML = `

          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>

Papierkorb ist leer

Geloeschte Modelle erscheinen hier. Sie werden erst nach "Endgueltig loeschen" entfernt.

`;
    return;
  }
  grid.innerHTML = list.map(trashCardHTML).join('');
}

async function restoreItem(filename) {
  const card = document.querySelector(`.trash-card[data-filename="${CSS.escape(filename)}"]`);
  if (card) { card.classList.add('removing'); card.style.opacity = '0.5'; }
  try {
    const res = await fetch(API + '/trash/' + encodeURIComponent(filename) + '/restore', { method: 'POST' });
    const data = await res.json();
    if (data.ok) {
      trashItems = trashItems.filter(i => i.filename !== filename);
      updateTrashBadge();
      renderTrashItems();
      toast('Wiederhergestellt');
    } else {
      throw new Error();
    }
  } catch {
    if (card) card.classList.remove('removing');
    toast('Wiederherstellung fehlgeschlagen');
  }
}

function openTrashConfirm(filename, fileId) {
  const overlay = document.createElement('div');
  overlay.className = 'modal-overlay';
  overlay.style.background = 'rgba(8,12,24,0.7)';
  overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
  overlay.innerHTML = `

        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;color:var(--red)">
          <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>

Endgueltig loeschen

        ${escapeHTML(fileId)} unwiderruflich aus dem Papierkorb entfernen?

        <button class="btn-download" id="trash-confirm-cancel">Abbrechen</button>
        <button class="btn-delete" id="trash-confirm-delete" style="background:var(--red);color:white;border-color:transparent;flex:1">Endgueltig loeschen</button>

`;
  document.body.appendChild(overlay);
  overlay.querySelector('#trash-confirm-cancel').onclick = () => overlay.remove();
  overlay.querySelector('#trash-confirm-delete').onclick = () => {
    overlay.remove();
    permanentlyDeleteItem(filename);
  };
}

async function permanentlyDeleteItem(filename) {
  const card = document.querySelector(`.trash-card[data-filename="${CSS.escape(filename)}"]`);
  if (card) { card.classList.add('removing'); card.style.opacity = '0.5'; }
  try {
    const res = await fetch(API + '/trash/' + encodeURIComponent(filename), { method: 'DELETE' });
    if (res.ok) {
      trashItems = trashItems.filter(i => i.filename !== filename);
      updateTrashBadge();
      renderTrashItems();
      toast('Endgueltig geloescht');
    } else {
      throw new Error();
    }
  } catch {
    if (card) card.classList.remove('removing');
    toast('Loeschen fehlgeschlagen');
  }
}

function updateTrashBadge() {
  const badge = document.getElementById('trash-badge');
  if (trashItems.length > 0) {
    badge.style.display = '';
    badge.textContent = trashItems.length > 99 ? '99+' : String(trashItems.length);
  } else {
    badge.style.display = 'none';
  }
}

loadFiles();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });

// Initial auch den Badge laden
loadTrash();
</script>


    </div>;
}
// // ── END ──

</script>
</body>
</html>

  `;
}

function renderTrashItems() {
  const grid = document.getElementById('trash-grid');
  const search = document.getElementById('trash-search').value.toLowerCase().trim();
  const sort = document.getElementById('trash-sort').value;

  document.getElementById('trash-subtitle').textContent =
    trashItems.length ? `${trashItems.length} geloeschte(s) Modell(e) – Speicherplatz: ${Math.round(trashItems.reduce((s,i)=>s+i.size_kb,0)/1024*10)/10} MB` :
    'Geloeschte Modelle werden hier gesammelt.';

  let list = trashItems.slice();
  if (search) {
    list = list.filter(i =>
      i.file_id.toLowerCase().includes(search) ||
      i.filename.toLowerCase().includes(search)
    );
  }
  if (sort === 'name') list.sort((a,b) => a.file_id.toLowerCase().localeCompare(b.file_id.toLowerCase()));
  else if (sort === 'size') list.sort((a,b) => b.size_kb - a.size_kb);
  else list.sort((a,b) => new Date(b.deleted_at) - new Date(a.deleted_at));

  if (!list.length) {
    grid.innerHTML = `

          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>

Papierkorb ist leer

Geloeschte Modelle erscheinen hier. Sie werden erst nach "Endgueltig loeschen" entfernt.

`;
    return;
  }
  grid.innerHTML = list.map(trashCardHTML).join('');
}

async function restoreItem(filename) {
  const card = document.querySelector(`.trash-card[data-filename="${CSS.escape(filename)}"]`);
  if (card) { card.classList.add('removing'); card.style.opacity = '0.5'; }
  try {
    const res = await fetch(API + '/trash/' + encodeURIComponent(filename) + '/restore', { method: 'POST' });
    const data = await res.json();
    if (data.ok) {
      trashItems = trashItems.filter(i => i.filename !== filename);
      updateTrashBadge();
      renderTrashItems();
      toast('Wiederhergestellt');
    } else {
      throw new Error();
    }
  } catch {
    if (card) card.classList.remove('removing');
    toast('Wiederherstellung fehlgeschlagen');
  }
}

function openTrashConfirm(filename, fileId) {
  const overlay = document.createElement('div');
  overlay.className = 'modal-overlay';
  overlay.style.background = 'rgba(8,12,24,0.7)';
  overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
  overlay.innerHTML = `

        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;color:var(--red)">
          <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>

Endgueltig loeschen

        ${escapeHTML(fileId)} unwiderruflich aus dem Papierkorb entfernen?

        <button class="btn-download" id="trash-confirm-cancel">Abbrechen</button>
        <button class="btn-delete" id="trash-confirm-delete" style="background:var(--red);color:white;border-color:transparent;flex:1">Endgueltig loeschen</button>

`;
  document.body.appendChild(overlay);
  overlay.querySelector('#trash-confirm-cancel').onclick = () => overlay.remove();
  overlay.querySelector('#trash-confirm-delete').onclick = () => {
    overlay.remove();
    permanentlyDeleteItem(filename);
  };
}

async function permanentlyDeleteItem(filename) {
  const card = document.querySelector(`.trash-card[data-filename="${CSS.escape(filename)}"]`);
  if (card) { card.classList.add('removing'); card.style.opacity = '0.5'; }
  try {
    const res = await fetch(API + '/trash/' + encodeURIComponent(filename), { method: 'DELETE' });
    if (res.ok) {
      trashItems = trashItems.filter(i => i.filename !== filename);
      updateTrashBadge();
      renderTrashItems();
      toast('Endgueltig geloescht');
    } else {
      throw new Error();
    }
  } catch {
    if (card) card.classList.remove('removing');
    toast('Loeschen fehlgeschlagen');
  }
}

function updateTrashBadge() {
  const badge = document.getElementById('trash-badge');
  if (trashItems.length > 0) {
    badge.style.display = '';
    badge.textContent = trashItems.length > 99 ? '99+' : String(trashItems.length);
  } else {
    badge.style.display = 'none';
  }
}

loadFiles();
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });

// Initial auch den Badge laden
loadTrash();
</script>

+  const grid = document.getElementById('trash-grid');

+  const search = document.getElementById('trash-search').value.toLowerCase().trim();

+  const sort = document.getElementById('trash-sort').value;

+  document.getElementById('trash-subtitle').textContent =

+    trashItems.length ? `${trashItems.length} geloeschte(s) Modell(e) – Speicherplatz: ${Math.round(trashItems.reduce((s,i)=>s+i.size_kb,0)/1024*10)/10} MB` :

+    'Geloeschte Modelle werden hier gesammelt.';

+      i.file_id.toLowerCase().includes(search) ||

+      i.filename.toLowerCase().includes(search)

+  if (sort === 'name') list.sort((a,b) => a.file_id.toLowerCase().localeCompare(b.file_id.toLowerCase()));

+  else if (sort === 'size') list.sort((a,b) => b.size_kb - a.size_kb);

+  else list.sort((a,b) => new Date(b.deleted_at) - new Date(a.deleted_at));

+      <div class="empty-state empty-trash">

+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">

+            <polyline points="3 6 5 6 21 6"></polyline>

+
Geloeschte Modelle erscheinen hier. Sie werden erst nach "Endgueltig loeschen" entfernt.

+  grid.innerHTML = list.map(trashCardHTML).join('');

+  const card = document.querySelector(`.trash-card[data-filename="${CSS.escape(filename)}"]`);

+  if (card) { card.classList.add('removing'); card.style.opacity = '0.5'; }

+    const res = await fetch(API + '/trash/' + encodeURIComponent(filename) + '/restore', { method: 'POST' });

+      trashItems = trashItems.filter(i => i.filename !== filename);

+    if (card) card.classList.remove('removing');

+    toast('Wiederherstellung fehlgeschlagen');

+function openTrashConfirm(filename, fileId) {

+  const overlay = document.createElement('div');

+  overlay.style.background = 'rgba(8,12,24,0.7)';

+  overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };

+    <div class="modal" style="max-width:380px;padding:24px;border-radius:20px" onclick="event.stopPropagation()">

+      <div style="width:48px;height:48px;border-radius:14px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.2);display:flex;align-items:center;justify-content:center;margin:0 auto 16px">

+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;color:var(--red)">

+      <p style="text-align:center;font-size:13px;color:var(--text-dim);margin:8px 0 20px">

+        <strong>${escapeHTML(fileId)}</strong> unwiderruflich aus dem Papierkorb entfernen?

+      <div class="actions" style="margin-top:0">

+        <button class="btn-download" id="trash-confirm-cancel">Abbrechen</button>

+        <button class="btn-delete" id="trash-confirm-delete" style="background:var(--red);color:white;border-color:transparent;flex:1">Endgueltig loeschen</button>

+  overlay.querySelector('#trash-confirm-cancel').onclick = () => overlay.remove();

+  overlay.querySelector('#trash-confirm-delete').onclick = () => {

+async function permanentlyDeleteItem(filename) {

+    const res = await fetch(API + '/trash/' + encodeURIComponent(filename), { method: 'DELETE' });

+  const badge = document.getElementById('trash-badge');

+    badge.textContent = trashItems.length > 99 ? '99+' : String(trashItems.length);

+document.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });


Bash

tail -n 20 ".media/HDD_1TB/PrintVault/frontend/index.html" | cat -A | head -n 20

Check end of file for escaped entities


}$
$
function updateTrashBadge() {$
  const badge = document.getElementById('trash-badge');$
  if (trashItems.length > 0) {$
    badge.style.display = '';$
    badge.textContent = trashItems.length > 99 ? '99+' : String(trashItems.length);$
  } else {$
    badge.style.display = 'none';$
  }$
}$
$
loadFiles();$
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });$
$
// Initial auch den Badge laden$
loadTrash();$
</script>$
</body>$
</html>$


cd "/media/HDD_1TB/PrintVault" && python3 -c "
import re
with open('frontend/index.html', 'r', encoding='utf-8') as f:
    content = f.read()

# In script-bereich: gt; und lt; durch echte Zeichen
# Wir ersetzen alles zwischen <script> und </script>
def fix_script(m):
    s = m.group(0)
    s = s.replace('gt;', '>')
    s = s.replace('lt;', '<')
    # ABER wir haben bereits echte > die nicht ersetzt werden sollen
    # gt; ist der entity-string ohne & – wait, the entities are gt; with preceding &
    # Actually in the output it shows >
    # Let's check what exactly exists
    return s

# Safer: just replace remaining > and < entities that aren't part of HTML tags
# But for simplicity, just replace all > -> > and < -> <
# since any real HTML entities inside script were wrong anyway.
# But there might be HTML outside script that uses entities. We shouldn't touch those.
# However, PrintVault index.html has no entities outside script.

# Let's be surgical: split by <script and </script>
parts = re.split(r'(<script[^>]*>)|(</script>)', content, flags=re.IGNORECASE)
# Actually use a simpler approach: replace only inside <script> blocks
result = []
in_script = False
for line in content.split('\n'):
    llower = line.lower()
    if '<script' in llower and '</script>' not in line:
        in_script = True
    if '</script>' in llower:
        in_script = False
        result.append(line)
        continue
    if in_script:
        line = line.replace('&gt;', '>').replace('&lt;', '<')
        line = line.replace('>', '>').replace('<', '<')
    result.append(line)

fixed = '\n'.join(result)
with open('frontend/index.html', 'w', encoding='utf-8') as f:
    f.write(fixed)
print('Fixed', len(fixed), 'chars')
"

Fix HTML-entity-encoded JS operators in script blocks


Fixed 73918 chars