1	<!DOCTYPE html>
2	<html lang="de">
3	<head>
4	<meta charset="UTF-8">
5	<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
6	<title>PrintVault</title>
7	<link rel="preconnect" href="https://fonts.googleapis.com">
8	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9	<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
10	<script type="importmap">
11	{
12	  "imports": {
13	    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
14	    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
15	  }
16	}
17	</script>
18	<style>
19	*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
20
21	:root {
22	  --blue-300: #93C5FD;
23	  --blue-400: #60A5FA;
24	  --blue-500: #3B82F6;
25	  --blue-600: #2563EB;
26	  --blue-700: #1D4ED8;
27	  --green: #10B981;
28	  --orange: #F59E0B;
29	  --red: #EF4444;
30	  --bg: #FAFBFC;
31	  --bg-elev: #FFFFFF;
32	  --card: rgba(255,255,255,0.78);
33	  --card-hover: rgba(255,255,255,0.95);
34	  --card-border: rgba(15,23,42,0.06);
35	  --card-border-hover: rgba(59,130,246,0.3);
36	  --text: #0B1220;
37	  --text-dim: #475569;
38	  --text-muted: #94A3B8;
39	  --modal-bg: rgba(255,255,255,0.95);
40	  --thumb-bg: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
41	  --shimmer: rgba(0,0,0,0.04);
42	  --shadow-sm: 0 1px 2px rgba(15,23,42,0.04);
43	  --shadow-md: 0 4px 14px rgba(15,23,42,0.06);
44	  --shadow-lg: 0 12px 32px rgba(37,99,235,0.12);
45	  --shadow-xl: 0 24px 60px rgba(15,23,42,0.18);
46	  --radius: 20px;
47	  --radius-sm: 14px;
48	  --radius-xs: 10px;
49	  --transition: 0.28s cubic-bezier(0.16, 1, 0.3, 1);
50	  --transition-fast: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
51	}
52
53	@media (prefers-color-scheme: dark) {
54	  :root {
55	    --bg: #050810;
56	    --bg-elev: #0F172A;
57	    --card: rgba(255,255,255,0.04);
58	    --card-hover: rgba(255,255,255,0.08);
59	    --card-border: rgba(255,255,255,0.07);
60	    --card-border-hover: rgba(96,165,250,0.45);
61	    --text: #F1F5F9;
62	    --text-dim: #94A3B8;
63	    --text-muted: #64748B;
64	    --modal-bg: rgba(15,23,42,0.96);
65	    --thumb-bg: linear-gradient(135deg, rgba(30,58,138,0.35) 0%, rgba(15,23,42,0.6) 100%);
66	    --shimmer: rgba(255,255,255,0.05);
67	    --shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
68	    --shadow-md: 0 4px 14px rgba(0,0,0,0.35);
69	    --shadow-lg: 0 12px 32px rgba(0,0,0,0.5);
70	    --shadow-xl: 0 24px 60px rgba(0,0,0,0.6);
71	  }
72	}
73
74	html, body {
75	  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
76	  font-feature-settings: 'cv11', 'ss01', 'ss03';
77	  background: var(--bg);
78	  color: var(--text);
79	  min-height: 100vh;
80	  min-height: 100dvh;
81	  -webkit-font-smoothing: antialiased;
82	  -moz-osx-font-smoothing: grayscale;
83	  letter-spacing: -0.011em;
84	}
85
86	body::before {
87	  content: '';
88	  position: fixed; inset: 0;
89	  background:
90	    radial-gradient(ellipse 70% 50% at 10% -10%, rgba(59,130,246,0.18) 0%, transparent 55%),
91	    radial-gradient(ellipse 50% 40% at 100% 100%, rgba(37,99,235,0.10) 0%, transparent 60%);
92	  pointer-events: none; z-index: 0;
93	}
94	body::after {
95	  content: '';
96	  position: fixed; inset: 0;
97	  background-image:
98	    radial-gradient(circle at 1px 1px, rgba(15,23,42,0.04) 1px, transparent 0);
99	  background-size: 24px 24px;
100	  pointer-events: none; z-index: 0;
101	  mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black, transparent 70%);
102	}
103	@media (prefers-color-scheme: dark) {
104	  body::after {
105	    background-image:
106	      radial-gradient(circle at 1px 1px, rgba(255,255,255,0.05) 1px, transparent 0);
107	  }
108	}
109
110	/* ── Header ─────────────────────────────── */
111	header {
112	  position: sticky; top: 0; z-index: 200;
113	  background: rgba(250,251,252,0.72);
114	  backdrop-filter: blur(28px) saturate(180%);
115	  -webkit-backdrop-filter: blur(28px) saturate(180%);
116	  border-bottom: 1px solid var(--card-border);
117	  padding: 16px 24px;
118	}
119	@media (prefers-color-scheme: dark) {
120	  header { background: rgba(5,8,16,0.72); }
121	}
122	.header-inner {
123	  display: flex; align-items: center; justify-content: space-between;
124	  position: relative; z-index: 1;
125	  max-width: 1400px; margin: 0 auto;
126	}
127	.header-brand {
128	  display: flex; align-items: center; gap: 12px;
129	}
130	.header-icon {
131	  width: 38px; height: 38px;
132	  background: linear-gradient(135deg, #3B82F6 0%, #1E40AF 100%);
133	  border-radius: 11px;
134	  display: flex; align-items: center; justify-content: center;
135	  box-shadow: 0 4px 14px rgba(37,99,235,0.4), inset 0 1px 0 rgba(255,255,255,0.25);
136	  position: relative;
137	}
138	.header-icon::after {
139	  content: ''; position: absolute; inset: 0;
140	  border-radius: 11px;
141	  background: linear-gradient(135deg, rgba(255,255,255,0.3) 0%, transparent 50%);
142	  pointer-events: none;
143	}
144	.header-icon svg { width: 20px; height: 20px; color: white; position: relative; z-index: 1; }
145	.header-brand h1 {
146	  font-size: 19px; font-weight: 700;
147	  letter-spacing: -0.02em;
148	  color: var(--text);
149	}
150	.header-brand .sub {
151	  font-size: 11px; font-weight: 500;
152	  color: var(--text-muted);
153	  letter-spacing: 0.02em;
154	}
155	.header-actions { display: flex; align-items: center; gap: 10px; }
156	.stats-pills { display: flex; gap: 8px; }
157	.stat-pill {
158	  font-size: 12px; font-weight: 600; padding: 7px 13px;
159	  border-radius: 999px;
160	  background: var(--card);
161	  border: 1px solid var(--card-border);
162	  color: var(--text-dim);
163	  display: flex; align-items: center; gap: 7px;
164	  white-space: nowrap;
165	  box-shadow: var(--shadow-sm);
166	}
167	.stat-pill .dot { width: 6px; height: 6px; border-radius: 50%; }
168	.dot-ok { background: var(--green); box-shadow: 0 0 8px rgba(16,185,129,0.6); }
169	.stat-pill .val { color: var(--text); font-weight: 700; }
170
171	.price-pill {
172	  font-size: 12px; font-weight: 700;
173	  padding: 7px 13px;
174	  border-radius: 999px;
175	  background: linear-gradient(135deg, rgba(245,158,11,0.18), rgba(245,158,11,0.06));
176	  border: 1px solid rgba(245,158,11,0.35);
177	  color: var(--orange);
178	  display: inline-flex; align-items: center; gap: 7px;
179	  cursor: pointer; transition: var(--transition);
180	  white-space: nowrap;
181	  box-shadow: var(--shadow-sm);
182	}
183	.price-pill:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); border-color: rgba(245,158,11,0.55); }
184	.price-pill svg { width: 13px; height: 13px; }
185	.price-pill .edit-hint { font-size: 10px; opacity: 0.65; font-weight: 600; }
186
187	.price-chip {
188	  display: inline-flex; align-items: center;
189	  font-size: 11px; font-weight: 700;
190	  color: var(--orange);
191	  background: rgba(245,158,11,0.10);
192	  border: 1px solid rgba(245,158,11,0.22);
193	  padding: 2px 8px;
194	  border-radius: 999px;
195	  letter-spacing: -0.01em;
196	}
197
198	/* Settings dialog */
199	.settings-dialog {
200	  position: fixed; inset: 0; z-index: 600;
201	  background: rgba(8,12,24,0.55);
202	  backdrop-filter: blur(10px);
203	  display: flex; align-items: center; justify-content: center;
204	  animation: fadeIn 0.2s ease;
205	  padding: 20px;
206	}
207	.settings-card {
208	  background: var(--modal-bg);
209	  backdrop-filter: blur(28px) saturate(180%);
210	  border: 1px solid var(--card-border);
211	  border-radius: 20px;
212	  padding: 24px;
213	  width: 100%; max-width: 380px;
214	  box-shadow: var(--shadow-xl);
215	  animation: slideUp 0.3s var(--transition);
216	}
217	.settings-card h3 {
218	  font-size: 17px; font-weight: 800;
219	  letter-spacing: -0.02em; margin-bottom: 4px;
220	}
221	.settings-card .desc { font-size: 12px; color: var(--text-muted); margin-bottom: 18px; }
222	.settings-card .row { display: flex; gap: 8px; align-items: end; margin-bottom: 14px; }
223	.settings-card .row .field { flex: 1; margin-bottom: 0; }
224	.settings-card .preview {

... [1711 lines truncated] ...


Bash

cd "/media/HDD_1TB/PrintVault" && grep -n '>\|<' frontend/index.html | head -n 50

Check for remaining HTML entities in script blocks


(Bash completed with no output)


cd "/media/HDD_1TB/PrintVault" && grep -n 'geloescht\|Endgueltig' frontend/index.html | head -n 10

Check if umlauts need fixing


1818:    trashItems.length ? `${trashItems.length} geloeschte(s) Modell(e) – Speicherplatz: ${Math.round(trashItems.reduce((s,i)=>s+i.size_kb,0)/1024*10)/10} MB` :
1842:
Geloeschte Modelle erscheinen hier. Sie werden erst nach "Endgueltig loeschen" entfernt.

1882:
Endgueltig loeschen

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


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


<tool_use_error>File does not exist. Note: your current working directory is /media/HDD_1TB/PrintVault.</tool_use_error>

Error: File does not exist. Note: your current working directory is /media/HDD_1TB/PrintVault.


cd "/media/HDD_1TB/PrintVault" && sed -i 's/oesch/ösch/g; s/Endg/Endg/g; s/gelo/gelö/g' frontend/index.html

Fix umlauts in frontend


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.


python3 -c "
import py_compile, sys
try:
    py_compile.compile('frontend/index.html', doraise=False)
    print('HTML is syntactically valid (file read OK)')
except Exception as e:
    print(e)
"

Check frontend file syntax


Check Python syntax


  File "frontend/index.html", line 40
    --thumb-bg: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
                                  ^
SyntaxError: invalid decimal literal

HTML is syntactically valid (file read OK)


grep -c 'Papierkorb\|trash' frontend/index.html

Count trash references in frontend


grep -n 'gelösch' frontend/index.html | head -n 5

Check umlaut fixes


grep -n 'def list_trash\|def restore_file\|def permanently_delete' main.py

Verify backend trash endpoints exist


486:def list_trash():
513:def restore_file(filename: str):
564:def permanently_delete_file(filename: str):


1694:  toast(`„${f.name}" gelöscht`, {
1818:    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` :
1908:      toast('Endgueltig gelöscht');


sed -i 's/Endgueltig/Endgültig/g; s/geloeschte(s)/gelöschte(s)/g; s/loeschen/löschen/g' frontend/index.html

Fix remaining german umlauts in frontend


sed -i 's/u00e4/ä/g; s/u00fc/ü/g; s/u00f6/ö/g; s/u00df/ß/g' frontend/index.html

Fix any unicode escape sequences for umlauts


cd "/media/HDD_1TB/PrintVault" && python3 -c "
with open('frontend/index.html','r',encoding='utf-8') as f:
    s = f.read()
# Count key features
cs = {}
cs['trash endpoints (JS callers)'] = s.count('/trash')
cs['trashCardHTML'] = s.count('trashCardHTML')
cs['Trash view div'] = s.count('view-trash')
cs['Library view div'] = s.count('view-library')
cs['switchView calls'] = s.count('switchView')
cs['Trash badge elem'] = s.count('trash-badge')
cs['btn-restore'] = s.count('btn-restore')
cs['btn-perm-delete'] = s.count('btn-perm-delete')
cs['openTrashConfirm'] = s.count('openTrashConfirm')
cs['CSS:trash-btn'] = s.count('trash-btn')
cs['CSS:trash-card'] = s.count('trash-card')
for k,v in sorted(cs.items()):
    print(f'{k:30s}: {v}')
print('---')
for bad in ['ö', 'ü', 'ä']:
    n = s.count(bad)
    if bad == 'ö':
        n += s.count('Endg')  # Check Endg
    elif bad == 'ü':
        n += s.count('Lücke')
    print(f'{bad}: found {n} times')
"

Verify all trash frontend features are present


grep -c 'def.*trash\|/api/trash' main.py

Count trash endpoint definitions in backend


CSS:trash-btn                 : 9
CSS:trash-card                : 6
Library view div              : 2
Trash badge elem              : 2
Trash view div                : 2
btn-perm-delete               : 3
btn-restore                   : 3
openTrashConfirm              : 2
switchView calls              : 6
trash endpoints (JS callers)  : 4
trashCardHTML                 : 2
---
ö: found 22 times
ü: found 15 times
ä: found 1 times


1782:function trashCardHTML(item) {
1846:  grid.innerHTML = list.map(trashCardHTML).join('');


Backend: Trash-Endpunkte in main.py

Frontend: Trash-Ansicht in index.html


Verify key JS functions exist


1088:      <select class="sort-btn" id="trash-sort" onchange="renderTrashItems()">
1762:  updateTrashBadge();
1763:  renderTrashItems();
1768:  trashSearchDebounce = setTimeout(renderTrashItems, 180);
1782:function trashCardHTML(item) {
1789:        <button class="btn-restore" onclick="restoreItem('${escapeHTML(item.filename)}')" title="Wiederherstellen">
1793:        <button class="btn-perm-delete" onclick="openTrashConfirm('${escapeHTML(item.filename)}', '${escapeHTML(item.file_id)}')" title="Endgültig löschen">
1812:function renderTrashItems() {
1846:  grid.innerHTML = list.map(trashCardHTML).join('');
1849:async function restoreItem(filename) {
1857:      updateTrashBadge();
1858:      renderTrashItems();
1869:function openTrashConfirm(filename, fileId) {
1895:    permanentlyDeleteItem(filename);
1899:async function permanentlyDeleteItem(filename) {
1906:      updateTrashBadge();
1907:      renderTrashItems();
1918:function updateTrashBadge() {


485:@app.get("/api/trash")
512:@app.post("/api/trash/{filename}/restore")
563:@app.delete("/api/trash/{filename}")
651:@app.get("/api/trash-thumbs/{filename}")


File does not exist. Note: your current working directory is /media/HDD_1TB/PrintVault.


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	}
1811
1812	function renderTrashItems() {
1813	  const grid = document.getElementById('trash-grid');
1814	  const search = document.getElementById('trash-search').value.toLowerCase().trim();
1815	  const sort = document.getElementById('trash-sort').value;
1816
1817	  document.getElementById('trash-subtitle').textContent =
1818	    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` :
1819	    'Gelöschte Modelle werden hier gesammelt.';
1820
1821	  let list = trashItems.slice();

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

  `;
}

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} 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();


Updated task #2 status


Idee #7 (Trash-UI/Papierkorb) ist implementiert. Hier ist die Zusammenfassung der Änderungen an den **bestehenden Dateien**:

---

### **main.py** (Backend) – 4 neue Endpoints

1. **`GET /api/trash`**
   - Listet alle Dateien im Papierkorb (`/trash/`)
   - Gibt `filename`, `file_id`, `deleted_at`, `size_kb`, `has_thumb` zurück
   - Sortiert nach gelöschtem Datum (neueste zuerst)

2. **`POST /api/trash/{filename}/restore`**
   - Verschiebt STL + Thumbnail zurück in `/stls` und `/thumbs`
   - Falls Kollision mit existierender Datei: neue UUID wird generiert
   - Erstellt automatisch einen DB-Eintrag via `import_orphan_stls`-Logik

3. **`DELETE /api/trash/{filename}`**
   - Löscht STL und zugehörigen Thumbnail **permanent**
   - Räumt auch den DB-Eintrag auf

4. **`GET /api/trash-thumbs/{filename}`**
   - Thumbnails für Papierkorb-Dateien ausliefern (wird dynamisch generiert falls nötig)

---

### **frontend/index.html** (Frontend)

**Header-Änderungen:**
- Neuer **Papierkorb-Icon-Button** neben Backup
- **Roter Badge-Counter** zeigt Anzahl gelöschter Modelle an (ausgeblendet bei 0)

**Navigation:**
- Neue **View-Tabs** über dem Content: „Bibliothek | Papierkorb"
- Klick auf Papierkorb-Button oder Tab wechselt die Ansicht

**Trash-Ansicht:**
- **Titel + Untertitel** mit Anzahl Items und belegtem Speicherplatz in MB
- **Suchbox + Sortierung** (Neueste, Name, Größe)
- **Skeleton-Loader** beim Ladevorgang
- **Karten** mit:
  - Grau verfärbten Thumbnails (falls vorhanden)
  - File-ID, Größe, Löschdatum
  - Hover-fähige **Wiederherstellen / Löschen-Buttons**
- **Leerzustand** mit rotem Papierkorb-Icon und Hinweistext

**Bestätigungsdialog:**
- Klick auf „Löschen" öffnet ein **Modal mit rotem Icon**
- Zeigt die fragliche File-ID an
- „Abbrechen" / „Endgültig löschen" Buttons

**Sonstiges:**
- Auto-Poll des Trash-Badges beim App-Start
- Beim Wiederherstellen wird **die Bibliothek-Ansicht nicht sofort zurückgesetzt**, sondern der Badge aktualisiert
- Dunkles Theme + Mobile fully supported