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 {
225	  background: linear-gradient(135deg, rgba(59,130,246,0.08), rgba(37,99,235,0.02));
226	  border: 1px solid rgba(59,130,246,0.18);
227	  border-radius: 12px; padding: 12px 14px;
228	  font-size: 13px; color: var(--text-dim); font-weight: 500;
229	  margin-bottom: 16px;
230	}
231	.settings-card .preview b { color: var(--blue-600); font-weight: 800; }
232	@media (prefers-color-scheme: dark) { .settings-card .preview b { color: var(--blue-400); } }
233	.settings-card .actions { margin-top: 0; }
234
235	.icon-btn {
236	  width: 36px; height: 36px; border-radius: 11px;
237	  background: var(--card); border: 1px solid var(--card-border);
238	  color: var(--text-dim); cursor: pointer;
239	  display: flex; align-items: center; justify-content: center;
240	  transition: var(--transition);
241	  box-shadow: var(--shadow-sm);
242	}
243	.icon-btn:hover {
244	  color: var(--blue-500); border-color: var(--card-border-hover);
245	  transform: translateY(-1px); box-shadow: var(--shadow-md);
246	}
247	.icon-btn svg { width: 16px; height: 16px; }
248
249	/* ── Content ────────────────────────────── */
250	.content {
251	  position: relative; z-index: 1;
252	  padding: 24px;
253	  max-width: 1400px; margin: 0 auto;
254	}
255
256	.page-title {
257	  font-size: 28px; font-weight: 800;
258	  letter-spacing: -0.03em; margin-bottom: 4px;
259	  background: linear-gradient(135deg, var(--text) 0%, var(--text-dim) 100%);
260	  -webkit-background-clip: text;
261	  background-clip: text;
262	  -webkit-text-fill-color: transparent;
263	}
264	.page-subtitle {
265	  font-size: 14px; color: var(--text-muted);
266	  margin-bottom: 24px;
267	}
268
269	/* ── Controls ───────────────────────────── */
270	.controls {
271	  display: flex; gap: 10px; margin-bottom: 14px; align-items: center;
272	}
273	.search-wrap {
274	  flex: 1; position: relative;
275	}
276	.search-wrap svg {
277	  position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
278	  width: 16px; height: 16px; color: var(--text-muted); pointer-events: none;
279	}
280	.search-box {
281	  width: 100%;
282	  background: var(--card);
283	  border: 1px solid var(--card-border);
284	  border-radius: var(--radius-sm);
285	  padding: 12px 16px 12px 40px;
286	  font-size: 14px; font-weight: 500; color: var(--text);
287	  font-family: inherit; outline: none;
288	  -webkit-appearance: none;
289	  transition: var(--transition);
290	  box-shadow: var(--shadow-sm);
291	}
292	.search-box::placeholder { color: var(--text-muted); font-weight: 400; }
293	.search-box:focus {
294	  border-color: var(--blue-500);
295	  box-shadow: 0 0 0 4px rgba(59,130,246,0.12);
296	  background: var(--bg-elev);
297	}
298	.sort-btn {
299	  background: var(--card);
300	  border: 1px solid var(--card-border);
301	  border-radius: var(--radius-sm);
302	  padding: 12px 14px;
303	  font-size: 13px; font-weight: 600; color: var(--text-dim);
304	  font-family: inherit; cursor: pointer;
305	  -webkit-appearance: none; outline: none;
306	  box-shadow: var(--shadow-sm);
307	  transition: var(--transition);
308	}
309	.sort-btn:hover { color: var(--text); }
310
311	/* Tag Filter Chips */
312	.tag-chips {
313	  display: flex; gap: 6px; overflow-x: auto;
314	  margin-bottom: 14px; padding: 2px 0 6px;
315	  scrollbar-width: none;
316	}
317	.tag-chips::-webkit-scrollbar { display: none; }
318	.tag-chip {
319	  flex-shrink: 0;
320	  background: var(--card);
321	  border: 1px solid var(--card-border);
322	  border-radius: 999px;
323	  padding: 6px 12px 6px 12px;
324	  font-size: 12px; font-weight: 600;
325	  color: var(--text-dim);
326	  cursor: pointer;
327	  transition: var(--transition);
328	  white-space: nowrap;
329	  display: flex; align-items: center; gap: 6px;
330	}
331	.tag-chip:hover { color: var(--text); border-color: var(--card-border-hover); transform: translateY(-1px); }
332	.tag-chip.active {
333	  background: linear-gradient(135deg, #3B82F6, #2563EB);
334	  border-color: transparent;
335	  color: white;
336	  box-shadow: 0 4px 14px rgba(59,130,246,0.35);
337	}
338	.tag-chip .count {
339	  background: rgba(15,23,42,0.06);
340	  border-radius: 10px; padding: 1px 7px;
341	  font-size: 10px; font-weight: 700;
342	  color: var(--text-muted);
343	}
344	.tag-chip.active .count {
345	  background: rgba(255,255,255,0.22); color: white;
346	}
347
348	.active-filter-bar {
349	  display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
350	  margin-bottom: 16px;
351	}
352	.active-filter {
353	  background: rgba(59,130,246,0.1);
354	  border: 1px solid rgba(59,130,246,0.25);
355	  color: var(--blue-600);
356	  border-radius: 999px; padding: 4px 6px 4px 11px;
357	  font-size: 12px; font-weight: 600;
358	  display: flex; align-items: center; gap: 5px;
359	}
360	@media (prefers-color-scheme: dark) { .active-filter { color: var(--blue-400); } }
361	.active-filter .x {
362	  cursor: pointer; width: 18px; height: 18px;
363	  display: flex; align-items: center; justify-content: center;
364	  border-radius: 50%; opacity: 0.7;
365	  font-size: 13px; line-height: 1;
366	}
367	.active-filter .x:hover { opacity: 1; background: rgba(59,130,246,0.15); }
368	.clear-all-filters {
369	  background: transparent; border: none;
370	  color: var(--text-muted); font-size: 12px; cursor: pointer;
371	  padding: 4px 10px; font-family: inherit; font-weight: 600;
372	}
373	.clear-all-filters:hover { color: var(--text); }
374
375	/* Upload */
376	.upload-zone { margin-bottom: 24px; }
377	.upload-box {
378	  display: flex; flex-direction: column; align-items: center;
379	  gap: 8px;
380	  border: 1.5px dashed rgba(59,130,246,0.3);
381	  border-radius: var(--radius);
382	  padding: 32px 20px;
383	  text-align: center; cursor: pointer;
384	  transition: var(--transition);
385	  background: linear-gradient(135deg, rgba(59,130,246,0.04), rgba(59,130,246,0.01));
386	  position: relative; overflow: hidden;
387	}
388	.upload-box::before {
389	  content: ''; position: absolute; inset: 0;
390	  background: radial-gradient(circle at center, rgba(59,130,246,0.08), transparent 60%);
391	  opacity: 0; transition: var(--transition);
392	}
393	.upload-box:hover::before, .upload-box.drag-over::before { opacity: 1; }
394	.upload-box:hover, .upload-box.drag-over {
395	  border-color: var(--blue-500);
396	  transform: translateY(-2px);
397	  box-shadow: var(--shadow-lg);
398	}
399	.upload-box .icon-circle {
400	  width: 48px; height: 48px;
401	  background: linear-gradient(135deg, #3B82F6, #1D4ED8);
402	  border-radius: 14px;
403	  display: flex; align-items: center; justify-content: center;
404	  box-shadow: 0 6px 20px rgba(59,130,246,0.4), inset 0 1px 0 rgba(255,255,255,0.25);
405	  position: relative; z-index: 1;
406	}
407	.upload-box .icon-circle svg { width: 22px; height: 22px; color: white; }
408	.upload-box .title { font-size: 15px; font-weight: 700; color: var(--text); position: relative; z-index: 1; }
409	.upload-box .hint { font-size: 12px; color: var(--text-muted); position: relative; z-index: 1; }
410	.upload-box input { display: none; }
411
412	/* Upload Progress */
413	.upload-progress {
414	  margin-bottom: 18px;
415	  display: flex; flex-direction: column; gap: 8px;
416	}
417	.upload-item {
418	  background: var(--card);
419	  border: 1px solid var(--card-border);
420	  border-radius: var(--radius-sm);
421	  padding: 11px 14px;
422	  font-size: 13px;
423	  display: flex; align-items: center; gap: 12px;
424	  box-shadow: var(--shadow-sm);
425	  animation: slideInUp 0.3s var(--transition);
426	}
427	@keyframes slideInUp { from { opacity: 0; transform: translateY(6px); } }
428	.upload-item .upload-name {
429	  flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
430	  color: var(--text); font-weight: 500;
431	}
432	.upload-item .upload-bar {
433	  width: 100px; height: 5px;
434	  background: var(--card-border); border-radius: 999px;
435	  overflow: hidden;
436	}
437	.upload-item .upload-bar-fill {
438	  height: 100%; background: linear-gradient(90deg, var(--blue-500), var(--blue-700));
439	  width: 0%; transition: width 0.25s linear;
440	  border-radius: 999px;
441	}
442	.upload-item.done .upload-bar-fill { background: var(--green); width: 100%; }
443	.upload-item.error { border-color: rgba(239,68,68,0.3); }
444	.upload-item.error .upload-bar-fill { background: var(--red); width: 100%; }
445	.upload-item .status-icon {
446	  width: 22px; height: 22px; border-radius: 50%;
447	  display: flex; align-items: center; justify-content: center;
448	  font-size: 12px; font-weight: 700;
449	}
450	.upload-item.done .status-icon { background: rgba(16,185,129,0.15); color: var(--green); }
451	.upload-item.error .status-icon { background: rgba(239,68,68,0.15); color: var(--red); }
452
453	/* ── Grid ────────────────────────────────── */
454	.grid {
455	  display: grid;
456	  grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));
457	  gap: 16px;
458	  padding-bottom: 60px;
459	}
460
461	.card {
462	  position: relative;
463	  background: var(--card);
464	  border: 1px solid var(--card-border);
465	  border-radius: var(--radius);
466	  overflow: hidden;
467	  transition: var(--transition);
468	  cursor: pointer;
469	  -webkit-tap-highlight-color: transparent;
470	  backdrop-filter: blur(20px);
471	  -webkit-backdrop-filter: blur(20px);
472	  box-shadow: var(--shadow-sm);
473	}
474	.card:hover {
475	  border-color: var(--card-border-hover);
476	  background: var(--card-hover);
477	  transform: translateY(-3px);
478	  box-shadow: var(--shadow-lg);
479	}
480	.card:hover .quick-actions { opacity: 1; transform: translateY(0); }
481	.card:active { transform: translateY(-1px) scale(0.99); }
482	.card.removing { opacity: 0; transform: scale(0.92); pointer-events: none; }
483
484	.card .quick-actions {
485	  position: absolute; top: 10px; right: 10px;
486	  display: flex; gap: 5px;
487	  opacity: 0; transform: translateY(-6px);
488	  transition: var(--transition);
489	  z-index: 2;
490	}
491	.quick-btn {
492	  width: 32px; height: 32px; border-radius: 10px;
493	  background: rgba(15,23,42,0.7);
494	  backdrop-filter: blur(12px);
495	  -webkit-backdrop-filter: blur(12px);
496	  border: 1px solid rgba(255,255,255,0.12);
497	  color: white; cursor: pointer;
498	  display: flex; align-items: center; justify-content: center;
499	  transition: var(--transition-fast);
500	  box-shadow: 0 4px 12px rgba(0,0,0,0.25);
501	}
502	.quick-btn:hover { background: var(--blue-600); transform: scale(1.06); }
503	.quick-btn.danger:hover { background: var(--red); }
504	.quick-btn svg { width: 14px; height: 14px; }
505
506	@media (hover: none) {
507	  .card .quick-actions { opacity: 1; transform: none; }
508	}
509
510	.card .thumb {
511	  width: 100%; aspect-ratio: 1;
512	  background: var(--thumb-bg);
513	  display: flex; align-items: center; justify-content: center;
514	  position: relative;
515	  overflow: hidden;
516	}
517	.card .thumb::before {
518	  content: ''; position: absolute; inset: 0;
519	  background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.5), transparent 50%);
520	  pointer-events: none;
521	}
522	@media (prefers-color-scheme: dark) {
523	  .card .thumb::before {
524	    background: radial-gradient(circle at 30% 25%, rgba(96,165,250,0.15), transparent 50%);
525	  }
526	}
527	.card .thumb img {
528	  width: 92%; height: 92%; object-fit: contain;
529	  filter: drop-shadow(0 6px 12px rgba(37,99,235,0.25));
530	  transition: var(--transition);
531	}
532	.card:hover .thumb img { transform: scale(1.04); }
533	.card .thumb .placeholder {
534	  font-size: 38px; opacity: 0.5;
535	}
536	.card .pending-overlay {
537	  position: absolute; inset: 0;
538	  display: flex; flex-direction: column;
539	  align-items: center; justify-content: center;
540	  gap: 6px;
541	  background: rgba(15,23,42,0.35);
542	  backdrop-filter: blur(2px);
543	  color: white; font-size: 10px; font-weight: 700;
544	  letter-spacing: 0.08em;
545	  text-transform: uppercase;
546	}
547	.mini-spinner {
548	  width: 18px; height: 18px;
549	  border: 2px solid rgba(255,255,255,0.25);
550	  border-top-color: white;
551	  border-radius: 50%;
552	  animation: spin 0.8s linear infinite;
553	}
554	.card .info { padding: 12px 14px 14px; }
555	.card .name {
556	  font-size: 13px; font-weight: 600; line-height: 1.35;
557	  display: -webkit-box;
558	  -webkit-line-clamp: 2; -webkit-box-orient: vertical;
559	  overflow: hidden;
560	  margin-bottom: 8px;
561	  letter-spacing: -0.01em;
562	}
563	.card .meta {
564	  display: flex; align-items: center; justify-content: space-between;
565	  font-size: 11px; color: var(--text-muted); font-weight: 500;
566	}
567	.card .badge {
568	  display: inline-flex; align-items: center; gap: 3px;
569	  font-size: 10px; font-weight: 700; padding: 3px 8px;
570	  border-radius: 999px;
571	  letter-spacing: 0.02em;
572	}
573	.badge-ok { background: rgba(16,185,129,0.12); color: var(--green); }
574	.badge-warn { background: rgba(245,158,11,0.12); color: var(--orange); }
575	.badge-pending { background: rgba(148,163,184,0.15); color: var(--text-muted); }
576
577	/* Skeleton */
578	.skeleton-card {
579	  background: var(--card);
580	  border: 1px solid var(--card-border);
581	  border-radius: var(--radius);
582	  overflow: hidden;
583	  position: relative;
584	}
585	.skeleton-card .sk-thumb { width: 100%; aspect-ratio: 1; background: var(--shimmer); }
586	.skeleton-card .sk-info { padding: 12px 14px 14px; }
587	.skeleton-card .sk-line {
588	  height: 11px; background: var(--shimmer);
589	  border-radius: 4px; margin-bottom: 7px;
590	}
591	.skeleton-card .sk-line.short { width: 50%; height: 9px; }
592	.skeleton-card::after {
593	  content: ''; position: absolute; inset: 0;
594	  background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent);
595	  animation: shimmer 1.6s infinite;
596	  pointer-events: none;
597	}
598	@media (prefers-color-scheme: dark) {
599	  .skeleton-card::after {
600	    background: linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent);
601	  }
602	}
603	@keyframes shimmer {
604	  from { transform: translateX(-100%); }
605	  to { transform: translateX(100%); }
606	}
607	@keyframes spin { to { transform: rotate(360deg); } }
608
609	/* Empty State */
610	.empty-state {
611	  text-align: center; padding: 80px 20px;
612	  grid-column: 1 / -1;
613	}
614	.empty-state .icon {
615	  width: 72px; height: 72px; margin: 0 auto 20px;
616	  background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(59,130,246,0.02));
617	  border-radius: 22px;
618	  display: flex; align-items: center; justify-content: center;
619	  border: 1px solid rgba(59,130,246,0.15);
620	}
621	.empty-state .icon svg { width: 32px; height: 32px; color: var(--blue-500); }
622	.empty-state h3 { font-size: 17px; font-weight: 700; margin-bottom: 6px; letter-spacing: -0.01em; }
623	.empty-state p { font-size: 13px; color: var(--text-muted); max-width: 280px; margin: 0 auto; }
624
625	/* ── Modal ──────────────────────────────── */
626	.modal-overlay {
627	  position: fixed; inset: 0; z-index: 500;
628	  background: rgba(8,12,24,0.55);
629	  backdrop-filter: blur(10px);
630	  -webkit-backdrop-filter: blur(10px);
631	  display: flex; align-items: flex-end; justify-content: center;
632	  animation: fadeIn 0.25s ease;
633	}
634	@keyframes fadeIn { from { opacity: 0; } }
635	@keyframes slideUp { from { transform: translateY(100%); } }
636
637	.modal {
638	  background: var(--modal-bg);
639	  backdrop-filter: blur(28px) saturate(180%);
640	  -webkit-backdrop-filter: blur(28px) saturate(180%);
641	  border: 1px solid var(--card-border);
642	  border-bottom: none;
643	  border-radius: 24px 24px 0 0;
644	  width: 100%; max-width: 560px;
645	  max-height: 92vh; max-height: 92dvh;
646	  overflow-y: auto;
647	  animation: slideUp 0.4s cubic-bezier(0.32, 0.72, 0, 1);
648	  padding: 18px 22px 44px;
649	  -webkit-overflow-scrolling: touch;
650	  box-shadow: 0 -16px 60px rgba(0,0,0,0.35);
651	  position: relative;
652	}
653	.modal-handle {
654	  width: 40px; height: 4px; border-radius: 999px;
655	  background: var(--card-border);
656	  margin: 0 auto 18px;
657	}
658	.modal h2 {
659	  font-size: 20px; font-weight: 800; margin-bottom: 4px;
660	  padding-right: 44px; color: var(--text);
661	  letter-spacing: -0.02em;
662	}
663	.modal .modal-sub {
664	  font-size: 12px; color: var(--text-muted);
665	  margin-bottom: 16px; font-weight: 500;
666	}
667	.modal .close-btn {
668	  position: absolute; top: 18px; right: 18px;
669	  width: 36px; height: 36px; border-radius: 50%;
670	  background: var(--card);
671	  border: 1px solid var(--card-border);
672	  color: var(--text-dim); font-size: 15px;
673	  cursor: pointer; display: flex;
674	  align-items: center; justify-content: center;
675	  transition: var(--transition);
676	  z-index: 2;
677	}
678	.modal .close-btn:hover { background: var(--bg-elev); color: var(--text); }
679
680	/* 3D Viewer */
681	.viewer {
682	  width: 100%; aspect-ratio: 1;
683	  background: var(--thumb-bg);
684	  border-radius: var(--radius-sm);
685	  margin-bottom: 18px;
686	  position: relative;
687	  overflow: hidden;
688	  border: 1px solid var(--card-border);
689	}
690	.viewer canvas { display: block; width: 100% !important; height: 100% !important; }
691	.viewer .viewer-overlay {
692	  position: absolute; inset: 0;
693	  display: flex; align-items: center; justify-content: center;
694	  color: var(--text-muted); font-size: 12px;
695	  pointer-events: none;
696	}
697	.viewer .viewer-hint {
698	  position: absolute; bottom: 10px; left: 12px;
699	  font-size: 10px; font-weight: 600; letter-spacing: 0.04em;
700	  color: var(--text-muted); text-transform: uppercase;
701	  background: var(--card); border: 1px solid var(--card-border);
702	  border-radius: 999px; padding: 4px 10px;
703	  pointer-events: none;
704	  opacity: 0.85;
705	}
706	.viewer .reset-cam {
707	  position: absolute; top: 10px; right: 10px;
708	  width: 30px; height: 30px; border-radius: 9px;
709	  background: var(--card); border: 1px solid var(--card-border);
710	  color: var(--text-dim); cursor: pointer;
711	  display: flex; align-items: center; justify-content: center;
712	  z-index: 3;
713	}
714	.viewer .reset-cam:hover { color: var(--blue-500); }
715	.viewer .reset-cam svg { width: 14px; height: 14px; }
716
717	.stats-grid {
718	  display: grid; grid-template-columns: 1fr 1fr;
719	  gap: 8px; margin-bottom: 20px;
720	}
721	.stat-card {
722	  background: var(--card);
723	  border: 1px solid var(--card-border);
724	  border-radius: var(--radius-xs);
725	  padding: 12px 14px;
726	  transition: var(--transition);
727	}
728	.stat-card:hover { border-color: var(--card-border-hover); transform: translateY(-1px); }
729	.stat-card .label {
730	  font-size: 10px; font-weight: 700;
731	  color: var(--text-muted);
732	  text-transform: uppercase; letter-spacing: 0.06em;
733	}
734	.stat-card .value {
735	  font-size: 15px; font-weight: 700; margin-top: 4px;
736	  color: var(--text); letter-spacing: -0.01em;
737	}
738	.stat-card.full { grid-column: 1 / -1; }
739	.stat-card.highlight {
740	  background: linear-gradient(135deg, rgba(59,130,246,0.08), rgba(37,99,235,0.02));
741	  border-color: rgba(59,130,246,0.2);
742	}
743	.stat-card.highlight .value { color: var(--blue-600); }
744	@media (prefers-color-scheme: dark) { .stat-card.highlight .value { color: var(--blue-400); } }
745
746	.field { margin-bottom: 14px; }
747	.field label {
748	  display: block; font-size: 10px; font-weight: 700;
749	  color: var(--text-muted); margin-bottom: 6px;
750	  text-transform: uppercase; letter-spacing: 0.06em;
751	}
752	.field input, .field textarea {
753	  width: 100%; padding: 11px 14px;
754	  background: var(--card);
755	  border: 1px solid var(--card-border);
756	  border-radius: var(--radius-xs);
757	  font-size: 14px; color: var(--text);
758	  font-family: inherit; font-weight: 500; outline: none;
759	  -webkit-appearance: none;
760	  transition: var(--transition);
761	}
762	.field input:focus, .field textarea:focus {
763	  border-color: var(--blue-500);
764	  background: var(--bg-elev);
765	  box-shadow: 0 0 0 4px rgba(59,130,246,0.12);
766	}
767	.field textarea { resize: vertical; min-height: 70px; font-weight: 400; }
768
769	.tag-bar {
770	  display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; min-height: 30px;
771	}
772	.tag {
773	  background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(37,99,235,0.06));
774	  border: 1px solid rgba(59,130,246,0.22);
775	  border-radius: 999px; padding: 5px 6px 5px 13px;
776	  font-size: 12px; font-weight: 600;
777	  color: var(--blue-600);
778	  display: inline-flex; align-items: center; gap: 5px;
779	  animation: tagIn 0.25s var(--transition);
780	}
781	@media (prefers-color-scheme: dark) { .tag { color: var(--blue-300); } }
782	@keyframes tagIn { from { opacity: 0; transform: scale(0.85); } }
783	.tag .remove {
784	  cursor: pointer; width: 18px; height: 18px; border-radius: 50%;
785	  display: flex; align-items: center; justify-content: center;
786	  opacity: 0.6; font-size: 13px; line-height: 1;
787	}
788	.tag .remove:hover { opacity: 1; background: rgba(59,130,246,0.18); }
789
790	.tag-add {
791	  display: flex; gap: 6px; margin-top: 8px;
792	}
793	.tag-add input {
794	  flex: 1; padding: 10px 14px;
795	  background: var(--card);
796	  border: 1px solid var(--card-border);
797	  border-radius: 999px; font-size: 13px;
798	  color: var(--text); font-family: inherit; outline: none;
799	  -webkit-appearance: none; font-weight: 500;
800	  transition: var(--transition);
801	}
802	.tag-add input:focus { border-color: var(--blue-500); background: var(--bg-elev); }
803	.tag-add button {
804	  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));
805	  color: white; border: none; border-radius: 999px;
806	  padding: 10px 18px; font-size: 13px;
807	  font-weight: 700; cursor: pointer;
808	  transition: var(--transition);
809	  box-shadow: 0 4px 14px rgba(59,130,246,0.3);
810	}
811	.tag-add button:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(59,130,246,0.4); }
812	.tag-add button:active { transform: translateY(0); }
813
814	.actions {
815	  display: flex; gap: 8px; margin-top: 24px;
816	}
817	.actions button {
818	  flex: 1; padding: 13px 16px;
819	  border-radius: var(--radius-xs);
820	  font-size: 14px; font-weight: 700;
821	  border: none; cursor: pointer;
822	  font-family: inherit;
823	  transition: var(--transition);
824	  display: flex; align-items: center; justify-content: center; gap: 7px;
825	}
826	.actions button svg { width: 15px; height: 15px; }
827	.btn-save {
828	  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));
829	  color: white;
830	  box-shadow: 0 6px 18px rgba(59,130,246,0.35);
831	}
832	.btn-save:hover { transform: translateY(-1px); box-shadow: 0 8px 22px rgba(59,130,246,0.45); }
833	.btn-delete {
834	  background: rgba(239,68,68,0.1);
835	  border: 1px solid rgba(239,68,68,0.22) !important;
836	  color: var(--red);
837	}
838	.btn-delete:hover { background: rgba(239,68,68,0.18); }
839	.btn-download {
840	  background: var(--card);
841	  border: 1px solid var(--card-border);
842	  color: var(--text-dim);
843	}
844	.btn-download:hover { color: var(--text); border-color: var(--card-border-hover); }
845
846	/* Toast */
847	.toast {
848	  position: fixed; top: 24px; left: 50%; transform: translateX(-50%);
849	  background: var(--modal-bg);
850	  backdrop-filter: blur(20px);
851	  -webkit-backdrop-filter: blur(20px);
852	  border: 1px solid var(--card-border);
853	  color: var(--text);
854	  padding: 12px 18px;
855	  border-radius: 999px;
856	  font-size: 13px; font-weight: 600;
857	  z-index: 1100;
858	  box-shadow: var(--shadow-xl);
859	  display: flex; align-items: center; gap: 12px;
860	  animation: toastIn 0.3s var(--transition);
861	  max-width: calc(100vw - 32px);
862	}
863	.toast-action {
864	  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));
865	  color: white; border: none; border-radius: 999px;
866	  padding: 6px 14px; font-size: 12px; font-weight: 700;
867	  cursor: pointer; font-family: inherit;
868	  box-shadow: 0 4px 10px rgba(59,130,246,0.3);
869	}
870	.toast.fade-out { animation: toastOut 0.3s ease forwards; }
871	@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(-12px); } }
872	@keyframes toastOut { to { opacity: 0; transform: translateX(-50%) translateY(-12px); } }
873
874	/* Mobile */
875	@media (max-width: 480px) {
876	  .content { padding: 16px; }
877	  .grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
878	  .page-title { font-size: 22px; }
879	  .header-brand h1 { font-size: 17px; }
880	  .stat-pill { font-size: 11px; padding: 5px 10px; }
881	  .stats-grid { grid-template-columns: 1fr 1fr; gap: 6px; }
882	}
883	</style>
884	</head>
885	<body>
886
887	<header>
888	  <div class="header-inner">
889	    <div class="header-brand">
890	      <div class="header-icon">
891	        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
892	          <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>
893	          <polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
894	          <line x1="12" y1="22.08" x2="12" y2="12"></line>
895	        </svg>
896	      </div>
897	      <div>
898	        <h1>PrintVault</h1>
899	        <div class="sub">STL-Bibliothek</div>
900	      </div>
901	    </div>
902	    <div class="header-actions">
903	      <div class="stats-pills">
904	        <span class="stat-pill"><span class="dot dot-ok"></span> <span class="val" id="stat-ok">0</span> druckbar</span>
905	        <span class="stat-pill"><span class="val" id="stat-total">0</span> Modelle</span>
906	        <span class="stat-pill" title="Gesamtkosten aller Modelle">≈ <span class="val" id="stat-cost">0,00 €</span></span>
907	      </div>
908	      <button class="price-pill" id="price-pill" title="Filamentpreis bearbeiten">
909	        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M9 9h6M9 15h6M12 7v10"></path></svg>
910	        <span id="price-pill-text">14,71 €/Rolle</span>
911	        <span class="edit-hint">bearbeiten</span>
912	      </button>
913	      <button class="icon-btn" id="backup-btn" title="Backup herunterladen" aria-label="Backup">
914	        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
915	          <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>
916	        </svg>
917	      </button>
918	    </div>
919	  </div>
920	</header>
921
922	<div class="content">
923	  <div class="page-title">Deine Modelle</div>
924	  <div class="page-subtitle">Suche, organisiere und drucke deine STL-Sammlung.</div>
925
926	  <div class="controls">
927	    <div class="search-wrap">
928	      <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>
929	      <input type="text" class="search-box" placeholder="Suchen…" id="search" oninput="onSearchInput()" autocomplete="off">
930	    </div>
931	    <select class="sort-btn" id="sort" onchange="applyFilters()">
932	      <option value="date">Neueste</option>
933	      <option value="name">Name</option>
934	      <option value="size">Größe</option>
935	      <option value="volume">Volumen</option>
936	    </select>
937	  </div>
938
939	  <div class="tag-chips" id="tag-chips"></div>
940	  <div class="active-filter-bar" id="active-filters" style="display:none"></div>
941
942	  <div class="upload-zone">
943	    <label class="upload-box" id="dropZone">
944	      <div class="icon-circle">
945	        <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>
946	      </div>
947	      <div class="title">STL hochladen</div>
948	      <div class="hint">Drag & Drop oder klicken — auch mehrere Dateien</div>
949	      <input type="file" accept=".stl" id="fileInput" multiple onchange="onFilesPicked(this)">
950	    </label>
951	  </div>
952
953	  <div class="upload-progress" id="upload-progress"></div>
954
955	  <div class="grid" id="grid"></div>
956	</div>
957
958	<script type="module">
959	import * as THREE from 'three';
960	import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
961	import { STLLoader } from 'three/addons/loaders/STLLoader.js';
962
963	let viewerState = null;
964
965	function disposeViewer() {
966	  if (!viewerState) return;
967	  cancelAnimationFrame(viewerState.raf);
968	  viewerState.controls.dispose();
969	  viewerState.renderer.dispose();
970	  viewerState.geometry?.dispose();
971	  viewerState.material?.dispose();
972	  if (viewerState.resizeObs) viewerState.resizeObs.disconnect();
973	  if (viewerState.renderer.domElement.parentNode) {
974	    viewerState.renderer.domElement.parentNode.removeChild(viewerState.renderer.domElement);
975	  }
976	  viewerState = null;
977	}
978
979	window.openSTLViewer = async function(container, url) {
980	  disposeViewer();
981	  const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
982	  const scene = new THREE.Scene();
983
984	  const w = container.clientWidth || 400;
985	  const h = container.clientHeight || 400;
986	  const camera = new THREE.PerspectiveCamera(40, w / h, 0.1, 5000);
987
988	  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
989	  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
990	  renderer.setSize(w, h);
991	  renderer.setClearColor(0x000000, 0);
992	  container.appendChild(renderer.domElement);
993
994	  // Lights
995	  const ambient = new THREE.AmbientLight(0xffffff, dark ? 0.35 : 0.55);
996	  scene.add(ambient);
997	  const key = new THREE.DirectionalLight(0xffffff, dark ? 1.1 : 0.9);
998	  key.position.set(3, 5, 4); scene.add(key);
999	  const fill = new THREE.DirectionalLight(0x93C5FD, dark ? 0.55 : 0.35);
1000	  fill.position.set(-4, -2, 3); scene.add(fill);
1001	  const rim = new THREE.DirectionalLight(0x60A5FA, dark ? 0.6 : 0.3);
1002	  rim.position.set(0, -3, -4); scene.add(rim);
1003
1004	  const controls = new OrbitControls(camera, renderer.domElement);
1005	  controls.enableDamping = true;
1006	  controls.dampingFactor = 0.1;
1007	  controls.rotateSpeed = 0.9;
1008	  controls.minDistance = 1;
1009	  controls.maxDistance = 5000;
1010	  controls.enablePan = false;
1011
1012	  let geometry = null, material = null, mesh = null;
1013	  const initialCam = new THREE.Vector3();
1014
1015	  try {
1016	    const loader = new STLLoader();
1017	    const buf = await fetch(url).then(r => r.arrayBuffer());
1018	    geometry = loader.parse(buf);
1019	    geometry.computeVertexNormals();
1020	    geometry.center();
1021
1022	    material = new THREE.MeshStandardMaterial({
1023	      color: 0x3B82F6,
1024	      metalness: 0.15,
1025	      roughness: 0.55,
1026	      flatShading: false,
1027	    });
1028	    mesh = new THREE.Mesh(geometry, material);
1029
1030	    // STL ist typischerweise Z-up, three.js Y-up → 90° kippen
1031	    mesh.rotation.x = -Math.PI / 2;
1032	    scene.add(mesh);
1033
1034	    // Fit camera
1035	    const box = new THREE.Box3().setFromObject(mesh);
1036	    const size = box.getSize(new THREE.Vector3());
1037	    const maxDim = Math.max(size.x, size.y, size.z);
1038	    const dist = maxDim / (2 * Math.tan(Math.PI * camera.fov / 360)) * 1.6;
1039	    camera.position.set(dist * 0.7, dist * 0.55, dist * 0.9);
1040	    camera.lookAt(0, 0, 0);
1041	    controls.target.set(0, 0, 0);
1042	    initialCam.copy(camera.position);
1043	    controls.update();
1044	  } catch (e) {
1045	    console.error('STL load error', e);
1046	  }
1047
1048	  function onResize() {
1049	    const W = container.clientWidth, H = container.clientHeight;
1050	    if (!W || !H) return;
1051	    camera.aspect = W / H;
1052	    camera.updateProjectionMatrix();
1053	    renderer.setSize(W, H);
1054	  }
1055	  const resizeObs = new ResizeObserver(onResize);
1056	  resizeObs.observe(container);
1057
1058	  let raf;
1059	  function animate() {
1060	    raf = requestAnimationFrame(animate);
1061	    controls.update();
1062	    renderer.render(scene, camera);
1063	  }
1064	  animate();
1065
1066	  const resetBtn = container.querySelector('.reset-cam');
1067	  if (resetBtn) {
1068	    resetBtn.onclick = e => {
1069	      e.stopPropagation();
1070	      camera.position.copy(initialCam);
1071	      controls.target.set(0, 0, 0);
1072	      controls.update();
1073	    };
1074	  }
1075
1076	  viewerState = { renderer, controls, scene, camera, raf, geometry, material, mesh, resizeObs };
1077	};
1078
1079	window.closeSTLViewer = disposeViewer;
1080	</script>
1081
1082	<script>
1083	const API = '/api';
1084	let allFiles = [];
1085	let activeTags = new Set();
1086	let pendingDeletes = {};
1087	let pollTimer = null;
1088	let searchDebounce = null;
1089
1090	// ─── Filament-Preis (lokal gespeichert) ─────────────────
1091	const PRICE_KEY = 'printvault.filament_price_eur';
1092	const ROLL_KEY = 'printvault.roll_weight_g';
1093	function getPricePerRoll() { return parseFloat(localStorage.getItem(PRICE_KEY) ?? '14.71'); }
1094	function getRollWeight() { return parseFloat(localStorage.getItem(ROLL_KEY) ?? '1000'); }
1095	function pricePerGram() { return getPricePerRoll() / getRollWeight(); }
1096	function fmtEUR(v) {
1097	  if (v == null || isNaN(v)) return '—';
1098	  return v.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2 });
1099	}
1100	function costOf(grams) {
1101	  if (!grams) return 0;
1102	  return grams * pricePerGram();
1103	}
1104	function updatePricePill() {
1105	  const t = document.getElementById('price-pill-text');
1106	  if (t) t.textContent = fmtEUR(getPricePerRoll()) + '/Rolle';
1107	}
1108	function openPriceDialog() {
1109	  const dlg = document.createElement('div');
1110	  dlg.className = 'settings-dialog';
1111	  dlg.onclick = e => { if (e.target === dlg) dlg.remove(); };
1112	  dlg.innerHTML = `
1113	    <div class="settings-card" onclick="event.stopPropagation()">
1114	      <h3>Filamentpreis</h3>
1115	      <p class="desc">Preis pro Rolle und Rollengewicht. Wird zur Kostenberechnung pro Modell verwendet.</p>
1116	      <div class="row">
1117	        <div class="field"><label>Preis pro Rolle (€)</label>
1118	          <input type="number" id="set-price" step="0.01" min="0" value="${getPricePerRoll().toFixed(2)}">
1119	        </div>
1120	        <div class="field"><label>Rollengewicht (g)</label>
1121	          <input type="number" id="set-weight" step="50" min="50" value="${getRollWeight()}">
1122	        </div>
1123	      </div>
1124	      <div class="preview" id="set-preview"></div>
1125	      <div class="actions">
1126	        <button class="btn-download" id="set-cancel">Abbrechen</button>
1127	        <button class="btn-save" id="set-save">Speichern</button>
1128	      </div>
1129	    </div>`;
1130	  document.body.appendChild(dlg);
1131	  const pInput = dlg.querySelector('#set-price');
1132	  const wInput = dlg.querySelector('#set-weight');
1133	  const preview = dlg.querySelector('#set-preview');
1134	  const refresh = () => {
1135	    const p = parseFloat(pInput.value) || 0;
1136	    const w = parseFloat(wInput.value) || 1;
1137	    const perG = p / w;
1138	    preview.innerHTML = `Pro Gramm: <b>${fmtEUR(perG)}</b> · 100 g Druck ≈ <b>${fmtEUR(perG * 100)}</b>`;
1139	  };
1140	  pInput.oninput = refresh; wInput.oninput = refresh; refresh();
1141	  dlg.querySelector('#set-cancel').onclick = () => dlg.remove();
1142	  dlg.querySelector('#set-save').onclick = () => {
1143	    const p = parseFloat(pInput.value);
1144	    const w = parseFloat(wInput.value);
1145	    if (p > 0 && w > 0) {
1146	      localStorage.setItem(PRICE_KEY, String(p));
1147	      localStorage.setItem(ROLL_KEY, String(w));
1148	      updatePricePill();
1149	      applyFilters();
1150	      updateTotalCost();
1151	      toast(`Filamentpreis: ${fmtEUR(p)} / ${w} g`);
1152	    }
1153	    dlg.remove();
1154	  };
1155	}
1156	function updateTotalCost() {
1157	  const total = allFiles.reduce((s, f) => s + costOf(f.analysis?.filament_grams || 0), 0);
1158	  const el = document.getElementById('stat-cost');
1159	  if (el) el.textContent = fmtEUR(total);
1160	}
1161
1162	function toast(msg, opts = {}) {
1163	  document.getElementById('toast')?.remove();
1164	  const t = document.createElement('div');
1165	  t.id = 'toast'; t.className = 'toast';
1166	  t.innerHTML = `<span>${escapeHTML(msg)}</span>`;
1167	  if (opts.action) {
1168	    const btn = document.createElement('button');
1169	    btn.className = 'toast-action';
1170	    btn.textContent = opts.action.label;
1171	    btn.onclick = () => { opts.action.onClick(); t.remove(); };
1172	    t.appendChild(btn);
1173	  }
1174	  document.body.appendChild(t);
1175	  setTimeout(() => {
1176	    t.classList.add('fade-out');
1177	    setTimeout(() => t.remove(), 300);
1178	  }, opts.duration || 2600);
1179	  return t;
1180	}
1181
1182	function escapeHTML(s) {
1183	  const d = document.createElement('div');
1184	  d.textContent = String(s ?? '');
1185	  return d.innerHTML;
1186	}
1187
1188	const dropZone = document.getElementById('dropZone');
1189	dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
1190	dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
1191	dropZone.addEventListener('drop', e => {
1192	  e.preventDefault(); dropZone.classList.remove('drag-over');
1193	  if (e.dataTransfer.files.length) uploadMany([...e.dataTransfer.files]);
1194	});
1195
1196	function onFilesPicked(input) {
1197	  if (input.files.length) uploadMany([...input.files]);
1198	  input.value = '';
1199	}
1200
1201	async function uploadMany(files) {
1202	  const stls = files.filter(f => f.name.toLowerCase().endsWith('.stl'));
1203	  const skipped = files.length - stls.length;
1204	  if (skipped > 0) toast(`${skipped} nicht-STL Datei(en) übersprungen`);
1205	  if (!stls.length) return;
1206	  const queue = stls.slice();
1207	  const PARALLEL = 3;
1208	  const workers = Array.from({length: Math.min(PARALLEL, queue.length)}, () => (async () => {
1209	    while (queue.length) await uploadOne(queue.shift());
1210	  })());
1211	  await Promise.all(workers);
1212	  toast(`${stls.length} Datei(en) hochgeladen`);
1213	  await loadFiles();
1214	}
1215
1216	function uploadOne(file) {
1217	  return new Promise(resolve => {
1218	    const item = document.createElement('div');
1219	    item.className = 'upload-item';
1220	    item.innerHTML = `
1221	      <span class="upload-name">${escapeHTML(file.name)}</span>
1222	      <div class="upload-bar"><div class="upload-bar-fill"></div></div>
1223	      <span class="status-icon">⏳</span>`;
1224	    document.getElementById('upload-progress').appendChild(item);
1225	    const fill = item.querySelector('.upload-bar-fill');
1226	    const statusIcon = item.querySelector('.status-icon');
1227	    const form = new FormData(); form.append('file', file);
1228	    const xhr = new XMLHttpRequest();
1229	    xhr.open('POST', API + '/upload');
1230	    xhr.upload.onprogress = e => { if (e.lengthComputable) fill.style.width = ((e.loaded / e.total) * 100) + '%'; };
1231	    xhr.onload = () => {
1232	      if (xhr.status >= 200 && xhr.status < 300) { item.classList.add('done'); statusIcon.textContent = '✓'; }
1233	      else { item.classList.add('error'); statusIcon.textContent = '✕'; }
1234	      setTimeout(() => item.remove(), 2500); resolve();
1235	    };
1236	    xhr.onerror = () => { item.classList.add('error'); statusIcon.textContent = '✕'; setTimeout(() => item.remove(), 2500); resolve(); };
1237	    xhr.send(form);
1238	  });
1239	}
1240
1241	function onSearchInput() {
1242	  clearTimeout(searchDebounce);
1243	  searchDebounce = setTimeout(applyFilters, 180);
1244	}
1245
1246	async function loadFiles({silent = false} = {}) {
1247	  if (!silent && allFiles.length === 0) renderSkeletons(8);
1248	  const res = await fetch(API + '/files?sort=date');
1249	  allFiles = await res.json();
1250	  fetch(API + '/stats').then(r => r.json()).then(s => {
1251	    document.getElementById('stat-ok').textContent = s.watertight;
1252	    document.getElementById('stat-total').textContent = s.total_files;
1253	  });
1254	  updateTotalCost();
1255	  renderTagChips();
1256	  applyFilters();
1257	  schedulePollIfNeeded();
1258	}
1259
1260	function renderSkeletons(n) {
1261	  document.getElementById('grid').innerHTML = Array.from({length: n}, () => `
1262	    <div class="skeleton-card">
1263	      <div class="sk-thumb"></div>
1264	      <div class="sk-info">
1265	        <div class="sk-line"></div>
1266	        <div class="sk-line short"></div>
1267	      </div>
1268	    </div>`).join('');
1269	}
1270
1271	function applyFilters() {
1272	  const search = document.getElementById('search').value.toLowerCase().trim();
1273	  const sort = document.getElementById('sort').value;
1274	  let list = allFiles.slice();
1275	  if (search) {
1276	    list = list.filter(f =>
1277	      f.name.toLowerCase().includes(search) ||
1278	      (f.note || '').toLowerCase().includes(search) ||
1279	      (f.tags || []).some(t => t.toLowerCase().includes(search))
1280	    );
1281	  }
1282	  if (activeTags.size) {
1283	    list = list.filter(f => {
1284	      const ft = new Set((f.tags || []).map(t => t.toLowerCase()));
1285	      return [...activeTags].every(t => ft.has(t.toLowerCase()));
1286	    });
1287	  }
1288	  if (sort === 'name') list.sort((a,b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
1289	  else if (sort === 'size') list.sort((a,b) => b.size_kb - a.size_kb);
1290	  else if (sort === 'volume') list.sort((a,b) => (b.analysis?.volume_cm3 || 0) - (a.analysis?.volume_cm3 || 0));
1291	  else list.sort((a,b) => new Date(b.uploaded_at) - new Date(a.uploaded_at));
1292	  renderActiveFilters();
1293	  renderGrid(list);
1294	}
1295
1296	function renderActiveFilters() {
1297	  const bar = document.getElementById('active-filters');
1298	  if (!activeTags.size) { bar.style.display = 'none'; return; }
1299	  bar.style.display = 'flex';
1300	  bar.innerHTML = [...activeTags].map(t =>
1301	    `<span class="active-filter">#${escapeHTML(t)} <span class="x" data-tag="${escapeHTML(t)}">×</span></span>`
1302	  ).join('') + `<button class="clear-all-filters" onclick="clearTagFilters()">Alle zurücksetzen</button>`;
1303	  bar.querySelectorAll('.x').forEach(x => { x.onclick = () => toggleTagFilter(x.dataset.tag); });
1304	}
1305
1306	function renderTagChips() {
1307	  const counts = {};
1308	  allFiles.forEach(f => (f.tags || []).forEach(t => { counts[t] = (counts[t] || 0) + 1; }));
1309	  const sorted = Object.entries(counts).sort((a,b) => b[1] - a[1]).slice(0, 30);
1310	  const wrap = document.getElementById('tag-chips');
1311	  if (!sorted.length) { wrap.innerHTML = ''; return; }
1312	  wrap.innerHTML = sorted.map(([t, c]) => {
1313	    const active = activeTags.has(t) ? 'active' : '';
1314	    return `<span class="tag-chip ${active}" data-tag="${escapeHTML(t)}">#${escapeHTML(t)} <span class="count">${c}</span></span>`;
1315	  }).join('');
1316	  wrap.querySelectorAll('.tag-chip').forEach(c => { c.onclick = () => toggleTagFilter(c.dataset.tag); });
1317	}
1318
1319	function toggleTagFilter(tag) {
1320	  if (activeTags.has(tag)) activeTags.delete(tag); else activeTags.add(tag);
1321	  renderTagChips(); applyFilters();
1322	}
1323	function clearTagFilters() { activeTags.clear(); renderTagChips(); applyFilters(); }
1324
1325	function renderGrid(list) {
1326	  const grid = document.getElementById('grid');
1327	  if (!list.length) {
1328	    grid.innerHTML = `<div class="empty-state">
1329	      <div class="icon">
1330	        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>
1331	      </div>
1332	      <h3>${allFiles.length ? 'Keine Treffer' : 'Noch keine Modelle'}</h3>
1333	      <p>${allFiles.length ? 'Filter anpassen oder zurücksetzen — versuche es mit anderen Stichworten.' : 'Lade deine erste STL-Datei hoch, um loszulegen.'}</p>
1334	    </div>`;
1335	    return;
1336	  }
1337	  grid.innerHTML = list.map(cardHTML).join('');
1338
1339	  grid.querySelectorAll('.card').forEach(card => {
1340	    const id = card.dataset.id;
1341	    card.addEventListener('click', e => {
1342	      if (e.target.closest('.quick-actions')) return;
1343	      openDetail(id);
1344	    });
1345	    card.querySelector('[data-act="download"]').onclick = e => { e.stopPropagation(); window.open(API + '/download/' + id, '_blank'); };
1346	    card.querySelector('[data-act="delete"]').onclick = e => { e.stopPropagation(); softDelete(id); };
1347	  });
1348	}
1349
1350	function cardHTML(f) {
1351	  const a = f.analysis || {};
1352	  const pending = f.analysis_status === 'pending';
1353	  let badge;
1354	  if (pending) badge = '<span class="badge badge-pending">…</span>';
1355	  else if (a.watertight) badge = '<span class="badge badge-ok">✓ druckbar</span>';
1356	  else badge = '<span class="badge badge-warn">⚠ check</span>';
1357	  const cost = a.filament_grams ? `<span class="price-chip">${fmtEUR(costOf(a.filament_grams))}</span>` : '';
1358
1359	  return `
1360	    <div class="card" data-id="${f.id}">
1361	      <div class="quick-actions">
1362	        <button class="quick-btn" data-act="download" title="Download" aria-label="Download">
1363	          <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>
1364	        </button>
1365	        <button class="quick-btn danger" data-act="delete" title="Löschen" aria-label="Löschen">
1366	          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6M14 11v6"></path></svg>
1367	        </button>
1368	      </div>
1369	      <div class="thumb">
1370	        ${f.has_thumb ? `<img src="${API}/thumbs/${f.id}.png?v=${f.analysis_status}" loading="lazy" alt="">` : '<span class="placeholder">🔷</span>'}
1371	        ${pending ? '<div class="pending-overlay"><div class="mini-spinner"></div>Analyse</div>' : ''}
1372	      </div>
1373	      <div class="info">
1374	        <div class="name">${escapeHTML(f.name)}</div>
1375	        <div class="meta">
1376	          <span>${a.filament_grams ? a.filament_grams + ' g' : '—'}</span>
1377	          ${cost}
1378	        </div>
1379	        <div class="meta" style="margin-top:5px">
1380	          ${badge}
1381	        </div>
1382	      </div>
1383	    </div>`;
1384	}
1385
1386	function schedulePollIfNeeded() {
1387	  if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
1388	  if (allFiles.some(f => f.analysis_status === 'pending')) {
1389	    pollTimer = setTimeout(() => loadFiles({silent: true}), 2000);
1390	  }
1391	}
1392
1393	function openDetail(id) {
1394	  const f = allFiles.find(x => x.id === id);
1395	  if (!f) return;
1396	  const overlay = document.createElement('div');
1397	  overlay.className = 'modal-overlay';
1398	  overlay.onclick = e => {
1399	    if (e.target === overlay) { window.closeSTLViewer?.(); overlay.remove(); }
1400	  };
1401	  overlay.innerHTML = `<div class="modal" id="modal" onclick="event.stopPropagation()"></div>`;
1402	  document.body.appendChild(overlay);
1403	  renderModal(f);
1404	}
1405
1406	function renderModal(f) {
1407	  const modal = document.getElementById('modal');
1408	  if (!modal) return;
1409	  const a = f.analysis || {};
1410	  const pending = f.analysis_status === 'pending';
1411	  const dims = a.width_mm ? `${a.width_mm} × ${a.depth_mm} × ${a.height_mm} mm` : (pending ? 'wird analysiert…' : '—');
1412	  const printable = a.watertight ? '✓ druckbar' : (pending ? 'wird geprüft…' : '⚠ Mesh prüfen');
1413
1414	  modal.innerHTML = `
1415	    <div class="modal-handle"></div>
1416	    <button class="close-btn" onclick="window.closeSTLViewer?.(); this.closest('.modal-overlay').remove()" aria-label="Schließen">✕</button>
1417	    <h2>${escapeHTML(f.name)}</h2>
1418	    <div class="modal-sub">${escapeHTML(printable)} · ${f.size_kb} KB</div>
1419
1420	    <div class="viewer" id="stl-viewer">
1421	      <button class="reset-cam" title="Ansicht zurücksetzen" aria-label="Reset">
1422	        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>
1423	      </button>
1424	      <div class="viewer-hint">⤺ ziehen · ⊕ scrollen</div>
1425	    </div>
1426
1427	    <div class="stats-grid">
1428	      <div class="stat-card full"><div class="label">Maße</div><div class="value">${dims}</div></div>
1429	      <div class="stat-card highlight full" style="background:linear-gradient(135deg,rgba(245,158,11,0.12),rgba(245,158,11,0.03));border-color:rgba(245,158,11,0.25)">
1430	        <div class="label">Druckkosten</div>
1431	        <div class="value" style="color:var(--orange);font-size:22px">${a.filament_grams ? fmtEUR(costOf(a.filament_grams)) : '—'}</div>
1432	        <div style="font-size:11px;color:var(--text-muted);margin-top:3px;font-weight:500">
1433	          ${a.filament_grams ? `${a.filament_grams} g · ${fmtEUR(pricePerGram())}/g · Rolle ${fmtEUR(getPricePerRoll())}/${getRollWeight()} g` : 'Daten noch nicht verfügbar'}
1434	        </div>
1435	      </div>
1436	      <div class="stat-card highlight full"><div class="label">Filament (PLA)</div><div class="value">${a.filament_grams ? a.filament_grams + ' g' : '—'}</div></div>
1437	    </div>
1438
1439	    <div class="field"><label>Name</label><input type="text" id="edit-name" value="${escapeHTML(f.name)}"></div>
1440	    <div class="field">
1441	      <label>Tags</label>
1442	      <div class="tag-bar" id="tag-bar"></div>
1443	      <div class="tag-add">
1444	        <input type="text" id="tag-input" placeholder="Tag hinzufügen…">
1445	        <button id="tag-add-btn">Hinzufügen</button>
1446	      </div>
1447	    </div>
1448	    <div class="field"><label>Notiz</label><textarea id="edit-note" placeholder="Notizen, Druckeinstellungen…">${escapeHTML(f.note || '')}</textarea></div>
1449
1450	    <div class="actions">
1451	      <button class="btn-delete" id="btn-delete">
1452	        <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>
1453	        Löschen
1454	      </button>
1455	      <button class="btn-download" id="btn-download">
1456	        <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>
1457	        Download
1458	      </button>
1459	      <button class="btn-save" id="btn-save">
1460	        <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>
1461	        Speichern
1462	      </button>
1463	    </div>`;
1464
1465	  renderModalTags(f);
1466
1467	  const viewerEl = modal.querySelector('#stl-viewer');
1468	  if (viewerEl && window.openSTLViewer) {
1469	    window.openSTLViewer(viewerEl, API + '/download/' + f.id);
1470	  }
1471
1472	  const nameInput = modal.querySelector('#edit-name');
1473	  const noteInput = modal.querySelector('#edit-note');
1474	  let nameTimer, noteTimer;
1475	  nameInput.addEventListener('input', () => { clearTimeout(nameTimer); nameTimer = setTimeout(() => optimisticPatch(f.id, {name: nameInput.value}, ['name']), 500); });
1476	  noteInput.addEventListener('input', () => { clearTimeout(noteTimer); noteTimer = setTimeout(() => optimisticPatch(f.id, {note: noteInput.value}, ['note']), 500); });
1477
1478	  modal.querySelector('#tag-add-btn').onclick = () => addTagFromInput(f.id);
1479	  modal.querySelector('#tag-input').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); addTagFromInput(f.id); } });
1480	  modal.querySelector('#btn-save').onclick = () => {
1481	    optimisticPatch(f.id, {name: nameInput.value, note: noteInput.value}, ['name','note']);
1482	    window.closeSTLViewer?.();
1483	    document.querySelector('.modal-overlay')?.remove();
1484	    toast('Gespeichert');
1485	  };
1486	  modal.querySelector('#btn-download').onclick = () => window.open(API + '/download/' + f.id, '_blank');
1487	  modal.querySelector('#btn-delete').onclick = () => {
1488	    window.closeSTLViewer?.();
1489	    document.querySelector('.modal-overlay')?.remove();
1490	    softDelete(f.id);
1491	  };
1492	}
1493
1494	function renderModalTags(f) {
1495	  const bar = document.getElementById('tag-bar');
1496	  if (!bar) return;
1497	  bar.innerHTML = (f.tags || []).map(t =>
1498	    `<span class="tag">${escapeHTML(t)} <span class="remove" data-tag="${escapeHTML(t)}">×</span></span>`
1499	  ).join('') || '<span style="font-size:12px;color:var(--text-muted)">Noch keine Tags</span>';
1500	  bar.querySelectorAll('.remove').forEach(x => { x.onclick = () => removeTag(f.id, x.dataset.tag); });
1501	}
1502
1503	async function optimisticPatch(id, patch, fields) {
1504	  const f = allFiles.find(x => x.id === id);
1505	  if (!f) return;
1506	  const prev = {};
1507	  fields.forEach(k => { prev[k] = f[k]; f[k] = patch[k]; });
1508	  try {
1509	    const res = await fetch(API + '/files/' + id, {
1510	      method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(patch),
1511	    });
1512	    if (!res.ok) throw new Error();
1513	    renderTagChips(); applyFilters();
1514	  } catch {
1515	    fields.forEach(k => { f[k] = prev[k]; });
1516	    renderModalTags(f); applyFilters();
1517	    toast('Speichern fehlgeschlagen');
1518	  }
1519	}
1520
1521	async function addTagFromInput(id) {
1522	  const input = document.getElementById('tag-input');
1523	  const tag = input.value.trim();
1524	  if (!tag) return;
1525	  input.value = '';
1526	  const f = allFiles.find(x => x.id === id);
1527	  if (!f) return;
1528	  if ((f.tags || []).map(t => t.toLowerCase()).includes(tag.toLowerCase())) return;
1529	  await optimisticPatch(id, {tags: [...(f.tags || []), tag]}, ['tags']);
1530	  renderModalTags(f);
1531	}
1532
1533	async function removeTag(id, tag) {
1534	  const f = allFiles.find(x => x.id === id);
1535	  if (!f) return;
1536	  await optimisticPatch(id, {tags: (f.tags || []).filter(t => t !== tag)}, ['tags']);
1537	  renderModalTags(f);
1538	}
1539
1540	function softDelete(id) {
1541	  const f = allFiles.find(x => x.id === id);
1542	  if (!f) return;
1543	  const idx = allFiles.indexOf(f);
1544	  allFiles = allFiles.filter(x => x.id !== id);
1545	  renderTagChips(); applyFilters();
1546	  const timeoutId = setTimeout(async () => {
1547	    delete pendingDeletes[id];
1548	    try { await fetch(API + '/files/' + id, {method: 'DELETE'}); }
1549	    catch { loadFiles({silent: true}); }
1550	  }, 5000);
1551	  pendingDeletes[id] = { file: f, idx, timeoutId };
1552	  toast(`„${f.name}" gelöscht`, {
1553	    duration: 5000,
1554	    action: { label: 'Rückgängig', onClick: () => undoDelete(id) },
1555	  });
1556	}
1557
1558	function undoDelete(id) {
1559	  const p = pendingDeletes[id];
1560	  if (!p) return;
1561	  clearTimeout(p.timeoutId);
1562	  allFiles.splice(Math.min(p.idx, allFiles.length), 0, p.file);
1563	  delete pendingDeletes[id];
1564	  renderTagChips(); applyFilters();
1565	  toast('Wiederhergestellt');
1566	}
1567
1568	document.getElementById('backup-btn').onclick = () => {
1569	  toast('Backup wird erstellt…');
1570	  window.location.href = API + '/backup';
1571	};
1572	document.getElementById('price-pill').onclick = openPriceDialog;
1573	updatePricePill();
1574
1575	window.clearTagFilters = clearTagFilters;
1576	window.applyFilters = applyFilters;
1577	window.onSearchInput = onSearchInput;
1578	window.onFilesPicked = onFilesPicked;
1579
1580	loadFiles();
1581	document.addEventListener('visibilitychange', () => { if (!document.hidden) loadFiles({silent: true}); });
1582	</script>
1583	</body>
1584	</html>
1585

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<title>PrintVault</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
  }
}
</script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  --blue-300: #93C5FD;
  --blue-400: #60A5FA;
  --blue-500: #3B82F6;
  --blue-600: #2563EB;
  --blue-700: #1D4ED8;
  --green: #10B981;
  --orange: #F59E0B;
  --red: #EF4444;
  --bg: #FAFBFC;
  --bg-elev: #FFFFFF;
  --card: rgba(255,255,255,0.78);
  --card-hover: rgba(255,255,255,0.95);
  --card-border: rgba(15,23,42,0.06);
  --card-border-hover: rgba(59,130,246,0.3);
  --text: #0B1220;
  --text-dim: #475569;
  --text-muted: #94A3B8;
  --modal-bg: rgba(255,255,255,0.95);
  --thumb-bg: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);
  --shimmer: rgba(0,0,0,0.04);
  --shadow-sm: 0 1px 2px rgba(15,23,42,0.04);
  --shadow-md: 0 4px 14px rgba(15,23,42,0.06);
  --shadow-lg: 0 12px 32px rgba(37,99,235,0.12);
  --shadow-xl: 0 24px 60px rgba(15,23,42,0.18);
  --radius: 20px;
  --radius-sm: 14px;
  --radius-xs: 10px;
  --transition: 0.28s cubic-bezier(0.16, 1, 0.3, 1);
  --transition-fast: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #050810;
    --bg-elev: #0F172A;
    --card: rgba(255,255,255,0.04);
    --card-hover: rgba(255,255,255,0.08);
    --card-border: rgba(255,255,255,0.07);
    --card-border-hover: rgba(96,165,250,0.45);
    --text: #F1F5F9;
    --text-dim: #94A3B8;
    --text-muted: #64748B;
    --modal-bg: rgba(15,23,42,0.96);
    --thumb-bg: linear-gradient(135deg, rgba(30,58,138,0.35) 0%, rgba(15,23,42,0.6) 100%);
    --shimmer: rgba(255,255,255,0.05);
    --shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
    --shadow-md: 0 4px 14px rgba(0,0,0,0.35);
    --shadow-lg: 0 12px 32px rgba(0,0,0,0.5);
    --shadow-xl: 0 24px 60px rgba(0,0,0,0.6);
  }
}

html, body {
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
  font-feature-settings: 'cv11', 'ss01', 'ss03';
  background: var(--bg);
  color: var(--text);
  min-height: 100vh;
  min-height: 100dvh;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  letter-spacing: -0.011em;
}

body::before {
  content: '';
  position: fixed; inset: 0;
  background:
    radial-gradient(ellipse 70% 50% at 10% -10%, rgba(59,130,246,0.18) 0%, transparent 55%),
    radial-gradient(ellipse 50% 40% at 100% 100%, rgba(37,99,235,0.10) 0%, transparent 60%);
  pointer-events: none; z-index: 0;
}
body::after {
  content: '';
  position: fixed; inset: 0;
  background-image:
    radial-gradient(circle at 1px 1px, rgba(15,23,42,0.04) 1px, transparent 0);
  background-size: 24px 24px;
  pointer-events: none; z-index: 0;
  mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black, transparent 70%);
}
@media (prefers-color-scheme: dark) {
  body::after {
    background-image:
      radial-gradient(circle at 1px 1px, rgba(255,255,255,0.05) 1px, transparent 0);
  }
}

/* ── Header ─────────────────────────────── */
header {
  position: sticky; top: 0; z-index: 200;
  background: rgba(250,251,252,0.72);
  backdrop-filter: blur(28px) saturate(180%);
  -webkit-backdrop-filter: blur(28px) saturate(180%);
  border-bottom: 1px solid var(--card-border);
  padding: 16px 24px;
}
@media (prefers-color-scheme: dark) {
  header { background: rgba(5,8,16,0.72); }
}
.header-inner {
  display: flex; align-items: center; justify-content: space-between;
  position: relative; z-index: 1;
  max-width: 1400px; margin: 0 auto;
}
.header-brand {
  display: flex; align-items: center; gap: 12px;
}
.header-icon {
  width: 38px; height: 38px;
  background: linear-gradient(135deg, #3B82F6 0%, #1E40AF 100%);
  border-radius: 11px;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 4px 14px rgba(37,99,235,0.4), inset 0 1px 0 rgba(255,255,255,0.25);
  position: relative;
}
.header-icon::after {
  content: ''; position: absolute; inset: 0;
  border-radius: 11px;
  background: linear-gradient(135deg, rgba(255,255,255,0.3) 0%, transparent 50%);
  pointer-events: none;
}
.header-icon svg { width: 20px; height: 20px; color: white; position: relative; z-index: 1; }
.header-brand h1 {
  font-size: 19px; font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--text);
}
.header-brand .sub {
  font-size: 11px; font-weight: 500;
  color: var(--text-muted);
  letter-spacing: 0.02em;
}
.header-actions { display: flex; align-items: center; gap: 10px; }
.stats-pills { display: flex; gap: 8px; }
.stat-pill {
  font-size: 12px; font-weight: 600; padding: 7px 13px;
  border-radius: 999px;
  background: var(--card);
  border: 1px solid var(--card-border);
  color: var(--text-dim);
  display: flex; align-items: center; gap: 7px;
  white-space: nowrap;
  box-shadow: var(--shadow-sm);
}
.stat-pill .dot { width: 6px; height: 6px; border-radius: 50%; }
.dot-ok { background: var(--green); box-shadow: 0 0 8px rgba(16,185,129,0.6); }
.stat-pill .val { color: var(--text); font-weight: 700; }

.price-pill {
  font-size: 12px; font-weight: 700;
  padding: 7px 13px;
  border-radius: 999px;
  background: linear-gradient(135deg, rgba(245,158,11,0.18), rgba(245,158,11,0.06));
  border: 1px solid rgba(245,158,11,0.35);
  color: var(--orange);
  display: inline-flex; align-items: center; gap: 7px;
  cursor: pointer; transition: var(--transition);
  white-space: nowrap;
  box-shadow: var(--shadow-sm);
}
.price-pill:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); border-color: rgba(245,158,11,0.55); }
.price-pill svg { width: 13px; height: 13px; }
.price-pill .edit-hint { font-size: 10px; opacity: 0.65; font-weight: 600; }

.price-chip {
  display: inline-flex; align-items: center;
  font-size: 11px; font-weight: 700;
  color: var(--orange);
  background: rgba(245,158,11,0.10);
  border: 1px solid rgba(245,158,11,0.22);
  padding: 2px 8px;
  border-radius: 999px;
  letter-spacing: -0.01em;
}

/* Settings dialog */
.settings-dialog {
  position: fixed; inset: 0; z-index: 600;
  background: rgba(8,12,24,0.55);
  backdrop-filter: blur(10px);
  display: flex; align-items: center; justify-content: center;
  animation: fadeIn 0.2s ease;
  padding: 20px;
}
.settings-card {
  background: var(--modal-bg);
  backdrop-filter: blur(28px) saturate(180%);
  border: 1px solid var(--card-border);
  border-radius: 20px;
  padding: 24px;
  width: 100%; max-width: 380px;
  box-shadow: var(--shadow-xl);
  animation: slideUp 0.3s var(--transition);
}
.settings-card h3 {
  font-size: 17px; font-weight: 800;
  letter-spacing: -0.02em; margin-bottom: 4px;
}
.settings-card .desc { font-size: 12px; color: var(--text-muted); margin-bottom: 18px; }
.settings-card .row { display: flex; gap: 8px; align-items: end; margin-bottom: 14px; }
.settings-card .row .field { flex: 1; margin-bottom: 0; }
.settings-card .preview {
  background: linear-gradient(135deg, rgba(59,130,246,0.08), rgba(37,99,235,0.02));
  border: 1px solid rgba(59,130,246,0.18);
  border-radius: 12px; padding: 12px 14px;
  font-size: 13px; color: var(--text-dim); font-weight: 500;
  margin-bottom: 16px;
}
.settings-card .preview b { color: var(--blue-600); font-weight: 800; }
@media (prefers-color-scheme: dark) { .settings-card .preview b { color: var(--blue-400); } }
.settings-card .actions { margin-top: 0; }

.icon-btn {
  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);
}
.icon-btn:hover {
  color: var(--blue-500); border-color: var(--card-border-hover);
  transform: translateY(-1px); box-shadow: var(--shadow-md);
}
.icon-btn svg { width: 16px; height: 16px; }

/* ── Content ────────────────────────────── */
.content {
  position: relative; z-index: 1;
  padding: 24px;
  max-width: 1400px; margin: 0 auto;
}

.page-title {
  font-size: 28px; font-weight: 800;
  letter-spacing: -0.03em; margin-bottom: 4px;
  background: linear-gradient(135deg, var(--text) 0%, var(--text-dim) 100%);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}
.page-subtitle {
  font-size: 14px; color: var(--text-muted);
  margin-bottom: 24px;
}

/* ── Controls ───────────────────────────── */
.controls {
  display: flex; gap: 10px; margin-bottom: 14px; align-items: center;
}
.search-wrap {
  flex: 1; position: relative;
}
.search-wrap svg {
  position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
  width: 16px; height: 16px; color: var(--text-muted); pointer-events: none;
}
.search-box {
  width: 100%;
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: var(--radius-sm);
  padding: 12px 16px 12px 40px;
  font-size: 14px; font-weight: 500; color: var(--text);
  font-family: inherit; outline: none;
  -webkit-appearance: none;
  transition: var(--transition);
  box-shadow: var(--shadow-sm);
}
.search-box::placeholder { color: var(--text-muted); font-weight: 400; }
.search-box:focus {
  border-color: var(--blue-500);
  box-shadow: 0 0 0 4px rgba(59,130,246,0.12);
  background: var(--bg-elev);
}
.sort-btn {
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: var(--radius-sm);
  padding: 12px 14px;
  font-size: 13px; font-weight: 600; color: var(--text-dim);
  font-family: inherit; cursor: pointer;
  -webkit-appearance: none; outline: none;
  box-shadow: var(--shadow-sm);
  transition: var(--transition);
}
.sort-btn:hover { color: var(--text); }

/* Tag Filter Chips */
.tag-chips {
  display: flex; gap: 6px; overflow-x: auto;
  margin-bottom: 14px; padding: 2px 0 6px;
  scrollbar-width: none;
}
.tag-chips::-webkit-scrollbar { display: none; }
.tag-chip {
  flex-shrink: 0;
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: 999px;
  padding: 6px 12px 6px 12px;
  font-size: 12px; font-weight: 600;
  color: var(--text-dim);
  cursor: pointer;
  transition: var(--transition);
  white-space: nowrap;
  display: flex; align-items: center; gap: 6px;
}
.tag-chip:hover { color: var(--text); border-color: var(--card-border-hover); transform: translateY(-1px); }
.tag-chip.active {
  background: linear-gradient(135deg, #3B82F6, #2563EB);
  border-color: transparent;
  color: white;
  box-shadow: 0 4px 14px rgba(59,130,246,0.35);
}
.tag-chip .count {
  background: rgba(15,23,42,0.06);
  border-radius: 10px; padding: 1px 7px;
  font-size: 10px; font-weight: 700;
  color: var(--text-muted);
}
.tag-chip.active .count {
  background: rgba(255,255,255,0.22); color: white;
}

.active-filter-bar {
  display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
  margin-bottom: 16px;
}
.active-filter {
  background: rgba(59,130,246,0.1);
  border: 1px solid rgba(59,130,246,0.25);
  color: var(--blue-600);
  border-radius: 999px; padding: 4px 6px 4px 11px;
  font-size: 12px; font-weight: 600;
  display: flex; align-items: center; gap: 5px;
}
@media (prefers-color-scheme: dark) { .active-filter { color: var(--blue-400); } }
.active-filter .x {
  cursor: pointer; width: 18px; height: 18px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%; opacity: 0.7;
  font-size: 13px; line-height: 1;
}
.active-filter .x:hover { opacity: 1; background: rgba(59,130,246,0.15); }
.clear-all-filters {
  background: transparent; border: none;
  color: var(--text-muted); font-size: 12px; cursor: pointer;
  padding: 4px 10px; font-family: inherit; font-weight: 600;
}
.clear-all-filters:hover { color: var(--text); }

/* Upload */
.upload-zone { margin-bottom: 24px; }
.upload-box {
  display: flex; flex-direction: column; align-items: center;
  gap: 8px;
  border: 1.5px dashed rgba(59,130,246,0.3);
  border-radius: var(--radius);
  padding: 32px 20px;
  text-align: center; cursor: pointer;
  transition: var(--transition);
  background: linear-gradient(135deg, rgba(59,130,246,0.04), rgba(59,130,246,0.01));
  position: relative; overflow: hidden;
}
.upload-box::before {
  content: ''; position: absolute; inset: 0;
  background: radial-gradient(circle at center, rgba(59,130,246,0.08), transparent 60%);
  opacity: 0; transition: var(--transition);
}
.upload-box:hover::before, .upload-box.drag-over::before { opacity: 1; }
.upload-box:hover, .upload-box.drag-over {
  border-color: var(--blue-500);
  transform: translateY(-2px);
  box-shadow: var(--shadow-lg);
}
.upload-box .icon-circle {
  width: 48px; height: 48px;
  background: linear-gradient(135deg, #3B82F6, #1D4ED8);
  border-radius: 14px;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 6px 20px rgba(59,130,246,0.4), inset 0 1px 0 rgba(255,255,255,0.25);
  position: relative; z-index: 1;
}
.upload-box .icon-circle svg { width: 22px; height: 22px; color: white; }
.upload-box .title { font-size: 15px; font-weight: 700; color: var(--text); position: relative; z-index: 1; }
.upload-box .hint { font-size: 12px; color: var(--text-muted); position: relative; z-index: 1; }
.upload-box input { display: none; }

/* Upload Progress */
.upload-progress {
  margin-bottom: 18px;
  display: flex; flex-direction: column; gap: 8px;
}
.upload-item {
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: var(--radius-sm);
  padding: 11px 14px;
  font-size: 13px;
  display: flex; align-items: center; gap: 12px;
  box-shadow: var(--shadow-sm);
  animation: slideInUp 0.3s var(--transition);
}
@keyframes slideInUp { from { opacity: 0; transform: translateY(6px); } }
.upload-item .upload-name {
  flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--text); font-weight: 500;
}
.upload-item .upload-bar {
  width: 100px; height: 5px;
  background: var(--card-border); border-radius: 999px;
  overflow: hidden;
}
.upload-item .upload-bar-fill {
  height: 100%; background: linear-gradient(90deg, var(--blue-500), var(--blue-700));
  width: 0%; transition: width 0.25s linear;
  border-radius: 999px;
}
.upload-item.done .upload-bar-fill { background: var(--green); width: 100%; }
.upload-item.error { border-color: rgba(239,68,68,0.3); }
.upload-item.error .upload-bar-fill { background: var(--red); width: 100%; }
.upload-item .status-icon {
  width: 22px; height: 22px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 12px; font-weight: 700;
}
.upload-item.done .status-icon { background: rgba(16,185,129,0.15); color: var(--green); }
.upload-item.error .status-icon { background: rgba(239,68,68,0.15); color: var(--red); }

/* ── Grid ────────────────────────────────── */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));
  gap: 16px;
  padding-bottom: 60px;
}

.card {
  position: relative;
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: var(--radius);
  overflow: hidden;
  transition: var(--transition);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  box-shadow: var(--shadow-sm);
}
.card:hover {
  border-color: var(--card-border-hover);
  background: var(--card-hover);
  transform: translateY(-3px);
  box-shadow: var(--shadow-lg);
}
.card:hover .quick-actions { opacity: 1; transform: translateY(0); }
.card:active { transform: translateY(-1px) scale(0.99); }
.card.removing { opacity: 0; transform: scale(0.92); pointer-events: none; }

.card .quick-actions {
  position: absolute; top: 10px; right: 10px;
  display: flex; gap: 5px;
  opacity: 0; transform: translateY(-6px);
  transition: var(--transition);
  z-index: 2;
}
.quick-btn {
  width: 32px; height: 32px; border-radius: 10px;
  background: rgba(15,23,42,0.7);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid rgba(255,255,255,0.12);
  color: white; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: var(--transition-fast);
  box-shadow: 0 4px 12px rgba(0,0,0,0.25);
}
.quick-btn:hover { background: var(--blue-600); transform: scale(1.06); }
.quick-btn.danger:hover { background: var(--red); }
.quick-btn svg { width: 14px; height: 14px; }

@media (hover: none) {
  .card .quick-actions { opacity: 1; transform: none; }
}

.card .thumb {
  width: 100%; aspect-ratio: 1;
  background: var(--thumb-bg);
  display: flex; align-items: center; justify-content: center;
  position: relative;
  overflow: hidden;
}
.card .thumb::before {
  content: ''; position: absolute; inset: 0;
  background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.5), transparent 50%);
  pointer-events: none;
}
@media (prefers-color-scheme: dark) {
  .card .thumb::before {
    background: radial-gradient(circle at 30% 25%, rgba(96,165,250,0.15), transparent 50%);
  }
}
.card .thumb img {
  width: 92%; height: 92%; object-fit: contain;
  filter: drop-shadow(0 6px 12px rgba(37,99,235,0.25));
  transition: var(--transition);
}
.card:hover .thumb img { transform: scale(1.04); }
.card .thumb .placeholder {
  font-size: 38px; opacity: 0.5;
}
.card .pending-overlay {
  position: absolute; inset: 0;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: 6px;
  background: rgba(15,23,42,0.35);
  backdrop-filter: blur(2px);
  color: white; font-size: 10px; font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.mini-spinner {
  width: 18px; height: 18px;
  border: 2px solid rgba(255,255,255,0.25);
  border-top-color: white;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
.card .info { padding: 12px 14px 14px; }
.card .name {
  font-size: 13px; font-weight: 600; line-height: 1.35;
  display: -webkit-box;
  -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
  margin-bottom: 8px;
  letter-spacing: -0.01em;
}
.card .meta {
  display: flex; align-items: center; justify-content: space-between;
  font-size: 11px; color: var(--text-muted); font-weight: 500;
}
.card .badge {
  display: inline-flex; align-items: center; gap: 3px;
  font-size: 10px; font-weight: 700; padding: 3px 8px;
  border-radius: 999px;
  letter-spacing: 0.02em;
}
.badge-ok { background: rgba(16,185,129,0.12); color: var(--green); }
.badge-warn { background: rgba(245,158,11,0.12); color: var(--orange); }
.badge-pending { background: rgba(148,163,184,0.15); color: var(--text-muted); }

/* Skeleton */
.skeleton-card {
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: var(--radius);
  overflow: hidden;
  position: relative;
}
.skeleton-card .sk-thumb { width: 100%; aspect-ratio: 1; background: var(--shimmer); }
.skeleton-card .sk-info { padding: 12px 14px 14px; }
.skeleton-card .sk-line {
  height: 11px; background: var(--shimmer);
  border-radius: 4px; margin-bottom: 7px;
}
.skeleton-card .sk-line.short { width: 50%; height: 9px; }
.skeleton-card::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent);
  animation: shimmer 1.6s infinite;
  pointer-events: none;
}
@media (prefers-color-scheme: dark) {
  .skeleton-card::after {
    background: linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent);
  }
}
@keyframes shimmer {
  from { transform: translateX(-100%); }
  to { transform: translateX(100%); }
}
@keyframes spin { to { transform: rotate(360deg); } }

/* Empty State */
.empty-state {
  text-align: center; padding: 80px 20px;
  grid-column: 1 / -1;
}
.empty-state .icon {
  width: 72px; height: 72px; margin: 0 auto 20px;
  background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(59,130,246,0.02));
  border-radius: 22px;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid rgba(59,130,246,0.15);
}
.empty-state .icon svg { width: 32px; height: 32px; color: var(--blue-500); }
.empty-state h3 { font-size: 17px; font-weight: 700; margin-bottom: 6px; letter-spacing: -0.01em; }
.empty-state p { font-size: 13px; color: var(--text-muted); max-width: 280px; margin: 0 auto; }

/* ── Modal ──────────────────────────────── */
.modal-overlay {
  position: fixed; inset: 0; z-index: 500;
  background: rgba(8,12,24,0.55);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  display: flex; align-items: flex-end; justify-content: center;
  animation: fadeIn 0.25s ease;
}
@keyframes fadeIn { from { opacity: 0; } }
@keyframes slideUp { from { transform: translateY(100%); } }

.modal {
  background: var(--modal-bg);
  backdrop-filter: blur(28px) saturate(180%);
  -webkit-backdrop-filter: blur(28px) saturate(180%);
  border: 1px solid var(--card-border);
  border-bottom: none;
  border-radius: 24px 24px 0 0;
  width: 100%; max-width: 560px;
  max-height: 92vh; max-height: 92dvh;
  overflow-y: auto;
  animation: slideUp 0.4s cubic-bezier(0.32, 0.72, 0, 1);
  padding: 18px 22px 44px;
  -webkit-overflow-scrolling: touch;
  box-shadow: 0 -16px 60px rgba(0,0,0,0.35);
  position: relative;
}
.modal-handle {
  width: 40px; height: 4px; border-radius: 999px;
  background: var(--card-border);
  margin: 0 auto 18px;
}
.modal h2 {
  font-size: 20px; font-weight: 800; margin-bottom: 4px;
  padding-right: 44px; color: var(--text);
  letter-spacing: -0.02em;
}
.modal .modal-sub {
  font-size: 12px; color: var(--text-muted);
  margin-bottom: 16px; font-weight: 500;
}
.modal .close-btn {
  position: absolute; top: 18px; right: 18px;
  width: 36px; height: 36px; border-radius: 50%;
  background: var(--card);
  border: 1px solid var(--card-border);
  color: var(--text-dim); font-size: 15px;
  cursor: pointer; display: flex;
  align-items: center; justify-content: center;
  transition: var(--transition);
  z-index: 2;
}
.modal .close-btn:hover { background: var(--bg-elev); color: var(--text); }

/* 3D Viewer */
.viewer {
  width: 100%; aspect-ratio: 1;
  background: var(--thumb-bg);
  border-radius: var(--radius-sm);
  margin-bottom: 18px;
  position: relative;
  overflow: hidden;
  border: 1px solid var(--card-border);
}
.viewer canvas { display: block; width: 100% !important; height: 100% !important; }
.viewer .viewer-overlay {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  color: var(--text-muted); font-size: 12px;
  pointer-events: none;
}
.viewer .viewer-hint {
  position: absolute; bottom: 10px; left: 12px;
  font-size: 10px; font-weight: 600; letter-spacing: 0.04em;
  color: var(--text-muted); text-transform: uppercase;
  background: var(--card); border: 1px solid var(--card-border);
  border-radius: 999px; padding: 4px 10px;
  pointer-events: none;
  opacity: 0.85;
}
.viewer .reset-cam {
  position: absolute; top: 10px; right: 10px;
  width: 30px; height: 30px; border-radius: 9px;
  background: var(--card); border: 1px solid var(--card-border);
  color: var(--text-dim); cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  z-index: 3;
}
.viewer .reset-cam:hover { color: var(--blue-500); }
.viewer .reset-cam svg { width: 14px; height: 14px; }

.stats-grid {
  display: grid; grid-template-columns: 1fr 1fr;
  gap: 8px; margin-bottom: 20px;
}
.stat-card {
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: var(--radius-xs);
  padding: 12px 14px;
  transition: var(--transition);
}
.stat-card:hover { border-color: var(--card-border-hover); transform: translateY(-1px); }
.stat-card .label {
  font-size: 10px; font-weight: 700;
  color: var(--text-muted);
  text-transform: uppercase; letter-spacing: 0.06em;
}
.stat-card .value {
  font-size: 15px; font-weight: 700; margin-top: 4px;
  color: var(--text); letter-spacing: -0.01em;
}
.stat-card.full { grid-column: 1 / -1; }
.stat-card.highlight {
  background: linear-gradient(135deg, rgba(59,130,246,0.08), rgba(37,99,235,0.02));
  border-color: rgba(59,130,246,0.2);
}
.stat-card.highlight .value { color: var(--blue-600); }
@media (prefers-color-scheme: dark) { .stat-card.highlight .value { color: var(--blue-400); } }

.field { margin-bottom: 14px; }
.field label {
  display: block; font-size: 10px; font-weight: 700;
  color: var(--text-muted); margin-bottom: 6px;
  text-transform: uppercase; letter-spacing: 0.06em;
}
.field input, .field textarea {
  width: 100%; padding: 11px 14px;
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: var(--radius-xs);
  font-size: 14px; color: var(--text);
  font-family: inherit; font-weight: 500; outline: none;
  -webkit-appearance: none;
  transition: var(--transition);
}
.field input:focus, .field textarea:focus {
  border-color: var(--blue-500);
  background: var(--bg-elev);
  box-shadow: 0 0 0 4px rgba(59,130,246,0.12);
}
.field textarea { resize: vertical; min-height: 70px; font-weight: 400; }

.tag-bar {
  display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; min-height: 30px;
}
.tag {
  background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(37,99,235,0.06));
  border: 1px solid rgba(59,130,246,0.22);
  border-radius: 999px; padding: 5px 6px 5px 13px;
  font-size: 12px; font-weight: 600;
  color: var(--blue-600);
  display: inline-flex; align-items: center; gap: 5px;
  animation: tagIn 0.25s var(--transition);
}
@media (prefers-color-scheme: dark) { .tag { color: var(--blue-300); } }
@keyframes tagIn { from { opacity: 0; transform: scale(0.85); } }
.tag .remove {
  cursor: pointer; width: 18px; height: 18px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  opacity: 0.6; font-size: 13px; line-height: 1;
}
.tag .remove:hover { opacity: 1; background: rgba(59,130,246,0.18); }

.tag-add {
  display: flex; gap: 6px; margin-top: 8px;
}
.tag-add input {
  flex: 1; padding: 10px 14px;
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: 999px; font-size: 13px;
  color: var(--text); font-family: inherit; outline: none;
  -webkit-appearance: none; font-weight: 500;
  transition: var(--transition);
}
.tag-add input:focus { border-color: var(--blue-500); background: var(--bg-elev); }
.tag-add button {
  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));
  color: white; border: none; border-radius: 999px;
  padding: 10px 18px; font-size: 13px;
  font-weight: 700; cursor: pointer;
  transition: var(--transition);
  box-shadow: 0 4px 14px rgba(59,130,246,0.3);
}
.tag-add button:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(59,130,246,0.4); }
.tag-add button:active { transform: translateY(0); }

.actions {
  display: flex; gap: 8px; margin-top: 24px;
}
.actions button {
  flex: 1; padding: 13px 16px;
  border-radius: var(--radius-xs);
  font-size: 14px; font-weight: 700;
  border: none; cursor: pointer;
  font-family: inherit;
  transition: var(--transition);
  display: flex; align-items: center; justify-content: center; gap: 7px;
}
.actions button svg { width: 15px; height: 15px; }
.btn-save {
  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));
  color: white;
  box-shadow: 0 6px 18px rgba(59,130,246,0.35);
}
.btn-save:hover { transform: translateY(-1px); box-shadow: 0 8px 22px rgba(59,130,246,0.45); }
.btn-delete {
  background: rgba(239,68,68,0.1);
  border: 1px solid rgba(239,68,68,0.22) !important;
  color: var(--red);
}
.btn-delete:hover { background: rgba(239,68,68,0.18); }
.btn-download {
  background: var(--card);
  border: 1px solid var(--card-border);
  color: var(--text-dim);
}
.btn-download:hover { color: var(--text); border-color: var(--card-border-hover); }

/* Toast */
.toast {
  position: fixed; top: 24px; left: 50%; transform: translateX(-50%);
  background: var(--modal-bg);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid var(--card-border);
  color: var(--text);
  padding: 12px 18px;
  border-radius: 999px;
  font-size: 13px; font-weight: 600;
  z-index: 1100;
  box-shadow: var(--shadow-xl);
  display: flex; align-items: center; gap: 12px;
  animation: toastIn 0.3s var(--transition);
  max-width: calc(100vw - 32px);
}
.toast-action {
  background: linear-gradient(135deg, var(--blue-500), var(--blue-700));
  color: white; border: none; border-radius: 999px;
  padding: 6px 14px; font-size: 12px; font-weight: 700;
  cursor: pointer; font-family: inherit;
  box-shadow: 0 4px 10px rgba(59,130,246,0.3);
}
.toast.fade-out { animation: toastOut 0.3s ease forwards; }
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(-12px); } }
@keyframes toastOut { to { opacity: 0; transform: translateX(-50%) translateY(-12px); } }

/* 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>
</head>
<body>

<header>
  <div class="header-inner">
    <div class="header-brand">
      <div class="header-icon">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
          <polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
          <line x1="12" y1="22.08" x2="12" y2="12"></line>
        </svg>
      </div>
      <div>
        <h1>PrintVault</h1>
        <div class="sub">STL-Bibliothek</div>
      </div>
    </div>
    <div class="header-actions">
      <div class="stats-pills">
        <span class="stat-pill"><span class="dot dot-ok"></span> <span class="val" id="stat-ok">0</span> druckbar</span>
        <span class="stat-pill"><span class="val" id="stat-total">0</span> Modelle</span>
        <span class="stat-pill" title="Gesamtkosten aller Modelle">≈ <span class="val" id="stat-cost">0,00 €</span></span>
      </div>
      <button class="price-pill" id="price-pill" title="Filamentpreis bearbeiten">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M9 9h6M9 15h6M12 7v10"></path></svg>
        <span id="price-pill-text">14,71 €/Rolle</span>
        <span class="edit-hint">bearbeiten</span>
      </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>

<div class="content">
  <div class="page-title">Deine Modelle</div>
  <div class="page-subtitle">Suche, organisiere und drucke deine STL-Sammlung.</div>

  <div class="controls">
    <div class="search-wrap">
      <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">
    </div>
    <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>
  </div>

  <div class="tag-chips" id="tag-chips"></div>
  <div class="active-filter-bar" id="active-filters" style="display:none"></div>

  <div class="upload-zone">
    <label class="upload-box" id="dropZone">
      <div class="icon-circle">
        <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>
      </div>
      <div class="title">STL hochladen</div>
      <div class="hint">Drag & Drop oder klicken — auch mehrere Dateien</div>
      <input type="file" accept=".stl" id="fileInput" multiple onchange="onFilesPicked(this)">
    </label>
  </div>

  <div class="upload-progress" id="upload-progress"></div>

  <div class="grid" id="grid"></div>
</div>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';

let viewerState = null;

function disposeViewer() {
  if (!viewerState) return;
  cancelAnimationFrame(viewerState.raf);
  viewerState.controls.dispose();
  viewerState.renderer.dispose();
  viewerState.geometry?.dispose();
  viewerState.material?.dispose();
  if (viewerState.resizeObs) viewerState.resizeObs.disconnect();
  if (viewerState.renderer.domElement.parentNode) {
    viewerState.renderer.domElement.parentNode.removeChild(viewerState.renderer.domElement);
  }
  viewerState = null;
}

window.openSTLViewer = async function(container, url) {
  disposeViewer();
  const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  const scene = new THREE.Scene();

  const w = container.clientWidth || 400;
  const h = container.clientHeight || 400;
  const camera = new THREE.PerspectiveCamera(40, w / h, 0.1, 5000);

  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setSize(w, h);
  renderer.setClearColor(0x000000, 0);
  container.appendChild(renderer.domElement);

  // Lights
  const ambient = new THREE.AmbientLight(0xffffff, dark ? 0.35 : 0.55);
  scene.add(ambient);
  const key = new THREE.DirectionalLight(0xffffff, dark ? 1.1 : 0.9);
  key.position.set(3, 5, 4); scene.add(key);
  const fill = new THREE.DirectionalLight(0x93C5FD, dark ? 0.55 : 0.35);
  fill.position.set(-4, -2, 3); scene.add(fill);
  const rim = new THREE.DirectionalLight(0x60A5FA, dark ? 0.6 : 0.3);
  rim.position.set(0, -3, -4); scene.add(rim);

  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.dampingFactor = 0.1;
  controls.rotateSpeed = 0.9;
  controls.minDistance = 1;
  controls.maxDistance = 5000;
  controls.enablePan = false;

  let geometry = null, material = null, mesh = null;
  const initialCam = new THREE.Vector3();

  try {
    const loader = new STLLoader();
    const buf = await fetch(url).then(r => r.arrayBuffer());
    geometry = loader.parse(buf);
    geometry.computeVertexNormals();
    geometry.center();

    material = new THREE.MeshStandardMaterial({
      color: 0x3B82F6,
      metalness: 0.15,
      roughness: 0.55,
      flatShading: false,
    });
    mesh = new THREE.Mesh(geometry, material);

    // STL ist typischerweise Z-up, three.js Y-up → 90° kippen
    mesh.rotation.x = -Math.PI / 2;
    scene.add(mesh);

    // Fit camera
    const box = new THREE.Box3().setFromObject(mesh);
    const size = box.getSize(new THREE.Vector3());
    const maxDim = Math.max(size.x, size.y, size.z);
    const dist = maxDim / (2 * Math.tan(Math.PI * camera.fov / 360)) * 1.6;
    camera.position.set(dist * 0.7, dist * 0.55, dist * 0.9);
    camera.lookAt(0, 0, 0);
    controls.target.set(0, 0, 0);
    initialCam.copy(camera.position);
    controls.update();
  } catch (e) {
    console.error('STL load error', e);
  }

  function onResize() {
    const W = container.clientWidth, H = container.clientHeight;
    if (!W || !H) return;
    camera.aspect = W / H;
    camera.updateProjectionMatrix();
    renderer.setSize(W, H);
  }
  const resizeObs = new ResizeObserver(onResize);
  resizeObs.observe(container);

  let raf;
  function animate() {
    raf = requestAnimationFrame(animate);
    controls.update();
    renderer.render(scene, camera);
  }
  animate();

  const resetBtn = container.querySelector('.reset-cam');
  if (resetBtn) {
    resetBtn.onclick = e => {
      e.stopPropagation();
      camera.position.copy(initialCam);
      controls.target.set(0, 0, 0);
      controls.update();
    };
  }

  viewerState = { renderer, controls, scene, camera, raf, geometry, material, mesh, resizeObs };
};

window.closeSTLViewer = disposeViewer;
</script>

<script>
const API = '/api';
let allFiles = [];
let activeTags = new Set();
let pendingDeletes = {};
let pollTimer = null;
let searchDebounce = null;

// ─── Filament-Preis (lokal gespeichert) ─────────────────
const PRICE_KEY = 'printvault.filament_price_eur';
const ROLL_KEY = 'printvault.roll_weight_g';
function getPricePerRoll() { return parseFloat(localStorage.getItem(PRICE_KEY) ?? '14.71'); }
function getRollWeight() { return parseFloat(localStorage.getItem(ROLL_KEY) ?? '1000'); }
function pricePerGram() { return getPricePerRoll() / getRollWeight(); }
function fmtEUR(v) {
  if (v == null || isNaN(v)) return '—';
  return v.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2 });
}
function costOf(grams) {
  if (!grams) return 0;
  return grams * pricePerGram();
}
function updatePricePill() {
  const t = document.getElementById('price-pill-text');
  if (t) t.textContent = fmtEUR(getPricePerRoll()) + '/Rolle';
}
function openPriceDialog() {
  const dlg = document.createElement('div');
  dlg.className = 'settings-dialog';
  dlg.onclick = e => { if (e.target === dlg) dlg.remove(); };
  dlg.innerHTML = `
    <div class="settings-card" onclick="event.stopPropagation()">
      <h3>Filamentpreis</h3>
      <p class="desc">Preis pro Rolle und Rollengewicht. Wird zur Kostenberechnung pro Modell verwendet.</p>
      <div class="row">
        <div class="field"><label>Preis pro Rolle (€)</label>
          <input type="number" id="set-price" step="0.01" min="0" value="${getPricePerRoll().toFixed(2)}">
        </div>
        <div class="field"><label>Rollengewicht (g)</label>
          <input type="number" id="set-weight" step="50" min="50" value="${getRollWeight()}">
        </div>
      </div>
      <div class="preview" id="set-preview"></div>
      <div class="actions">
        <button class="btn-download" id="set-cancel">Abbrechen</button>
        <button class="btn-save" id="set-save">Speichern</button>
      </div>
    </div>`;
  document.body.appendChild(dlg);
  const pInput = dlg.querySelector('#set-price');
  const wInput = dlg.querySelector('#set-weight');
  const preview = dlg.querySelector('#set-preview');
  const refresh = () => {
    const p = parseFloat(pInput.value) || 0;
    const w = parseFloat(wInput.value) || 1;
    const perG = p / w;
    preview.innerHTML = `Pro Gramm: <b>${fmtEUR(perG)}</b> · 100 g Druck ≈ <b>${fmtEUR(perG * 100)}</b>`;
  };
  pInput.oninput = refresh; wInput.oninput = refresh; refresh();
  dlg.querySelector('#set-cancel').onclick = () => dlg.remove();
  dlg.querySelector('#set-save').onclick = () => {
    const p = parseFloat(pInput.value);
    const w = parseFloat(wInput.value);
    if (p > 0 && w > 0) {
      localStorage.setItem(PRICE_KEY, String(p));
      localStorage.setItem(ROLL_KEY, String(w));
      updatePricePill();
      applyFilters();
      updateTotalCost();
      toast(`Filamentpreis: ${fmtEUR(p)} / ${w} g`);
    }
    dlg.remove();
  };
}
function updateTotalCost() {
  const total = allFiles.reduce((s, f) => s + costOf(f.analysis?.filament_grams || 0), 0);
  const el = document.getElementById('stat-cost');
  if (el) el.textContent = fmtEUR(total);
}

function toast(msg, opts = {}) {
  document.getElementById('toast')?.remove();
  const t = document.createElement('div');
  t.id = 'toast'; t.className = 'toast';
  t.innerHTML = `<span>${escapeHTML(msg)}</span>`;
  if (opts.action) {
    const btn = document.createElement('button');
    btn.className = 'toast-action';
    btn.textContent = opts.action.label;
    btn.onclick = () => { opts.action.onClick(); t.remove(); };
    t.appendChild(btn);
  }
  document.body.appendChild(t);
  setTimeout(() => {
    t.classList.add('fade-out');
    setTimeout(() => t.remove(), 300);
  }, opts.duration || 2600);
  return t;
}

function escapeHTML(s) {
  const d = document.createElement('div');
  d.textContent = String(s ?? '');
  return d.innerHTML;
}

const dropZone = document.getElementById('dropZone');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
  e.preventDefault(); dropZone.classList.remove('drag-over');
  if (e.dataTransfer.files.length) uploadMany([...e.dataTransfer.files]);
});

function onFilesPicked(input) {
  if (input.files.length) uploadMany([...input.files]);
  input.value = '';
}

async function uploadMany(files) {
  const stls = files.filter(f => f.name.toLowerCase().endsWith('.stl'));
  const skipped = files.length - stls.length;
  if (skipped > 0) toast(`${skipped} nicht-STL Datei(en) übersprungen`);
  if (!stls.length) return;
  const queue = stls.slice();
  const PARALLEL = 3;
  const workers = Array.from({length: Math.min(PARALLEL, queue.length)}, () => (async () => {
    while (queue.length) await uploadOne(queue.shift());
  })());
  await Promise.all(workers);
  toast(`${stls.length} Datei(en) hochgeladen`);
  await loadFiles();
}

function uploadOne(file) {
  return new Promise(resolve => {
    const item = document.createElement('div');
    item.className = 'upload-item';
    item.innerHTML = `
      <span class="upload-name">${escapeHTML(file.name)}</span>
      <div class="upload-bar"><div class="upload-bar-fill"></div></div>
      <span class="status-icon">⏳</span>`;
    document.getElementById('upload-progress').appendChild(item);
    const fill = item.querySelector('.upload-bar-fill');
    const statusIcon = item.querySelector('.status-icon');
    const form = new FormData(); form.append('file', file);
    const xhr = new XMLHttpRequest();
    xhr.open('POST', API + '/upload');
    xhr.upload.onprogress = e => { if (e.lengthComputable) fill.style.width = ((e.loaded / e.total) * 100) + '%'; };
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) { item.classList.add('done'); statusIcon.textContent = '✓'; }
      else { item.classList.add('error'); statusIcon.textContent = '✕'; }
      setTimeout(() => item.remove(), 2500); resolve();
    };
    xhr.onerror = () => { item.classList.add('error'); statusIcon.textContent = '✕'; setTimeout(() => item.remove(), 2500); resolve(); };
    xhr.send(form);
  });
}

function onSearchInput() {
  clearTimeout(searchDebounce);
  searchDebounce = setTimeout(applyFilters, 180);
}

async function loadFiles({silent = false} = {}) {
  if (!silent && allFiles.length === 0) renderSkeletons(8);
  const res = await fetch(API + '/files?sort=date');
  allFiles = await res.json();
  fetch(API + '/stats').then(r => r.json()).then(s => {
    document.getElementById('stat-ok').textContent = s.watertight;
    document.getElementById('stat-total').textContent = s.total_files;
  });
  updateTotalCost();
  renderTagChips();
  applyFilters();
  schedulePollIfNeeded();
}

function renderSkeletons(n) {
  document.getElementById('grid').innerHTML = Array.from({length: n}, () => `
    <div class="skeleton-card">
      <div class="sk-thumb"></div>
      <div class="sk-info">
        <div class="sk-line"></div>
        <div class="sk-line short"></div>
      </div>
    </div>`).join('');
}

function applyFilters() {
  const search = document.getElementById('search').value.toLowerCase().trim();
  const sort = document.getElementById('sort').value;
  let list = allFiles.slice();
  if (search) {
    list = list.filter(f =>
      f.name.toLowerCase().includes(search) ||
      (f.note || '').toLowerCase().includes(search) ||
      (f.tags || []).some(t => t.toLowerCase().includes(search))
    );
  }
  if (activeTags.size) {
    list = list.filter(f => {
      const ft = new Set((f.tags || []).map(t => t.toLowerCase()));
      return [...activeTags].every(t => ft.has(t.toLowerCase()));
    });
  }
  if (sort === 'name') list.sort((a,b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
  else if (sort === 'size') list.sort((a,b) => b.size_kb - a.size_kb);
  else if (sort === 'volume') list.sort((a,b) => (b.analysis?.volume_cm3 || 0) - (a.analysis?.volume_cm3 || 0));
  else list.sort((a,b) => new Date(b.uploaded_at) - new Date(a.uploaded_at));
  renderActiveFilters();
  renderGrid(list);
}

function renderActiveFilters() {
  const bar = document.getElementById('active-filters');
  if (!activeTags.size) { bar.style.display = 'none'; return; }
  bar.style.display = 'flex';
  bar.innerHTML = [...activeTags].map(t =>
    `<span class="active-filter">#${escapeHTML(t)} <span class="x" data-tag="${escapeHTML(t)}">×</span></span>`
  ).join('') + `<button class="clear-all-filters" onclick="clearTagFilters()">Alle zurücksetzen</button>`;
  bar.querySelectorAll('.x').forEach(x => { x.onclick = () => toggleTagFilter(x.dataset.tag); });
}

function renderTagChips() {
  const counts = {};
  allFiles.forEach(f => (f.tags || []).forEach(t => { counts[t] = (counts[t] || 0) + 1; }));
  const sorted = Object.entries(counts).sort((a,b) => b[1] - a[1]).slice(0, 30);
  const wrap = document.getElementById('tag-chips');
  if (!sorted.length) { wrap.innerHTML = ''; return; }
  wrap.innerHTML = sorted.map(([t, c]) => {
    const active = activeTags.has(t) ? 'active' : '';
    return `<span class="tag-chip ${active}" data-tag="${escapeHTML(t)}">#${escapeHTML(t)} <span class="count">${c}</span></span>`;
  }).join('');
  wrap.querySelectorAll('.tag-chip').forEach(c => { c.onclick = () => toggleTagFilter(c.dataset.tag); });
}

function toggleTagFilter(tag) {
  if (activeTags.has(tag)) activeTags.delete(tag); else activeTags.add(tag);
  renderTagChips(); applyFilters();
}
function clearTagFilters() { activeTags.clear(); renderTagChips(); applyFilters(); }

function renderGrid(list) {
  const grid = document.getElementById('grid');
  if (!list.length) {
    grid.innerHTML = `<div class="empty-state">
      <div class="icon">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>
      </div>
      <h3>${allFiles.length ? 'Keine Treffer' : 'Noch keine Modelle'}</h3>
      <p>${allFiles.length ? 'Filter anpassen oder zurücksetzen — versuche es mit anderen Stichworten.' : 'Lade deine erste STL-Datei hoch, um loszulegen.'}</p>
    </div>`;
    return;
  }
  grid.innerHTML = list.map(cardHTML).join('');

  grid.querySelectorAll('.card').forEach(card => {
    const id = card.dataset.id;
    card.addEventListener('click', e => {
      if (e.target.closest('.quick-actions')) return;
      openDetail(id);
    });
    card.querySelector('[data-act="download"]').onclick = e => { e.stopPropagation(); window.open(API + '/download/' + id, '_blank'); };
    card.querySelector('[data-act="delete"]').onclick = e => { e.stopPropagation(); softDelete(id); };
  });
}

function cardHTML(f) {
  const a = f.analysis || {};
  const pending = f.analysis_status === 'pending';
  let badge;
  if (pending) badge = '<span class="badge badge-pending">…</span>';
  else if (a.watertight) badge = '<span class="badge badge-ok">✓ druckbar</span>';
  else badge = '<span class="badge badge-warn">⚠ check</span>';
  const cost = a.filament_grams ? `<span class="price-chip">${fmtEUR(costOf(a.filament_grams))}</span>` : '';

  return `
    <div class="card" data-id="${f.id}">
      <div class="quick-actions">
        <button class="quick-btn" data-act="download" title="Download" aria-label="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>
        </button>
        <button class="quick-btn danger" data-act="delete" title="Löschen" aria-label="Löschen">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6M14 11v6"></path></svg>
        </button>
      </div>
      <div class="thumb">
        ${f.has_thumb ? `<img src="${API}/thumbs/${f.id}.png?v=${f.analysis_status}" loading="lazy" alt="">` : '<span class="placeholder">🔷</span>'}
        ${pending ? '<div class="pending-overlay"><div class="mini-spinner"></div>Analyse</div>' : ''}
      </div>
      <div class="info">
        <div class="name">${escapeHTML(f.name)}</div>
        <div class="meta">
          <span>${a.filament_grams ? a.filament_grams + ' g' : '—'}</span>
          ${cost}
        </div>
        <div class="meta" style="margin-top:5px">
          ${badge}
        </div>
      </div>
    </div>`;
}

function schedulePollIfNeeded() {
  if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
  if (allFiles.some(f => f.analysis_status === 'pending')) {
    pollTimer = setTimeout(() => loadFiles({silent: true}), 2000);
  }
}

function openDetail(id) {
  const f = allFiles.find(x => x.id === id);
  if (!f) return;
  const overlay = document.createElement('div');
  overlay.className = 'modal-overlay';
  overlay.onclick = e => {
    if (e.target === overlay) { window.closeSTLViewer?.(); overlay.remove(); }
  };
  overlay.innerHTML = `<div class="modal" id="modal" onclick="event.stopPropagation()"></div>`;
  document.body.appendChild(overlay);
  renderModal(f);
}

function renderModal(f) {
  const modal = document.getElementById('modal');
  if (!modal) return;
  const a = f.analysis || {};
  const pending = f.analysis_status === 'pending';
  const dims = a.width_mm ? `${a.width_mm} × ${a.depth_mm} × ${a.height_mm} mm` : (pending ? 'wird analysiert…' : '—');
  const printable = a.watertight ? '✓ druckbar' : (pending ? 'wird geprüft…' : '⚠ Mesh prüfen');

  modal.innerHTML = `
    <div class="modal-handle"></div>
    <button class="close-btn" onclick="window.closeSTLViewer?.(); this.closest('.modal-overlay').remove()" aria-label="Schließen">✕</button>
    <h2>${escapeHTML(f.name)}</h2>
    <div class="modal-sub">${escapeHTML(printable)} · ${f.size_kb} KB</div>

    <div class="viewer" id="stl-viewer">
      <button class="reset-cam" title="Ansicht zurücksetzen" aria-label="Reset">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>
      </button>
      <div class="viewer-hint">⤺ ziehen · ⊕ scrollen</div>
    </div>

    <div class="stats-grid">
      <div class="stat-card full"><div class="label">Maße</div><div class="value">${dims}</div></div>
      <div class="stat-card highlight full" style="background:linear-gradient(135deg,rgba(245,158,11,0.12),rgba(245,158,11,0.03));border-color:rgba(245,158,11,0.25)">
        <div class="label">Druckkosten</div>
        <div class="value" style="color:var(--orange);font-size:22px">${a.filament_grams ? fmtEUR(costOf(a.filament_grams)) : '—'}</div>
        <div style="font-size:11px;color:var(--text-muted);margin-top:3px;font-weight:500">
          ${a.filament_grams ? `${a.filament_grams} g · ${fmtEUR(pricePerGram())}/g · Rolle ${fmtEUR(getPricePerRoll())}/${getRollWeight()} g` : 'Daten noch nicht verfügbar'}
        </div>
      </div>
      <div class="stat-card highlight full"><div class="label">Filament (PLA)</div><div class="value">${a.filament_grams ? a.filament_grams + ' g' : '—'}</div></div>
    </div>

    <div class="field"><label>Name</label><input type="text" id="edit-name" value="${escapeHTML(f.name)}"></div>
    <div class="field">
      <label>Tags</label>
      <div class="tag-bar" id="tag-bar"></div>
      <div class="tag-add">
        <input type="text" id="tag-input" placeholder="Tag hinzufügen…">
        <button id="tag-add-btn">Hinzufügen</button>
      </div>
    </div>
    <div class="field"><label>Notiz</label><textarea id="edit-note" placeholder="Notizen, Druckeinstellungen…">${escapeHTML(f.note || '')}</textarea></div>

    <div class="actions">
      <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>
    </div>`;

  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('') || '<span style="font-size:12px;color:var(--text-muted)">Noch keine Tags</span>';
  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];
    try { await fetch(API + '/files/' + id, {method: 'DELETE'}); }
    catch { loadFiles({silent: true}); }
  }, 5000);
  pendingDeletes[id] = { file: f, idx, timeoutId };
  toast(`„${f.name}" gelöscht`, {
    duration: 5000,
    action: { label: 'Rückgängig', onClick: () => undoDelete(id) },
  });
}

function undoDelete(id) {
  const p = pendingDeletes[id];
  if (!p) return;
  clearTimeout(p.timeoutId);
  allFiles.splice(Math.min(p.idx, allFiles.length), 0, p.file);
  delete pendingDeletes[id];
  renderTagChips(); applyFilters();
  toast('Wiederhergestellt');
}

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}); });
</script>
</body>
</html>