From 78737936fb7e0b7db04524ac00df9d546da91323 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Thu, 23 Jul 2026 19:39:05 +0000 Subject: [PATCH] feat: functional folders (filter/delete/gallery) + always show model + variant switcher Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6 --- src/components/LibraryApp.tsx | 158 +++++++++++++++++++++++++-------- src/pages/api/folders/index.ts | 10 +++ 2 files changed, 129 insertions(+), 39 deletions(-) diff --git a/src/components/LibraryApp.tsx b/src/components/LibraryApp.tsx index 6412511..1046715 100644 --- a/src/components/LibraryApp.tsx +++ b/src/components/LibraryApp.tsx @@ -1,18 +1,28 @@ import React, { useState, useEffect, useCallback } from 'react'; interface Item { id: string; filename: string; output_px: string; has_alpha: boolean; - folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; model_used: string; } + folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; + model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; created_at: string; } export default function LibraryApp() { const [items, setItems] = useState([]); const [folders, setFolders] = useState([]); const [models, setModels] = useState([]); - const modelLabel = (id: string) => models.find((m) => m.model_id === id)?.label || null; - const [sel, setSel] = useState>(new Set()); + const [folderFilter, setFolderFilter] = useState(null); + const [pick, setPick] = useState>(new Set()); + const [variantSel, setVariantSel] = useState>({}); const [compare, setCompare] = useState(null); const [toast, setToast] = useState(null); const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); }; + // Modellname: Admin-Label, sonst lesbarer Rest der Modell-ID. + const modelName = (id: string | null) => { + if (!id) return null; + const m = models.find((x) => x.model_id === id); + if (m) return m.label; + return id.split('/').pop()!.replace(/-/g, ' '); + }; + const load = useCallback(async () => { const [i, f, m] = await Promise.all([ fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })), @@ -30,27 +40,52 @@ export default function LibraryApp() { load(); }; const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); }; - const delSelected = async () => { for (const id of sel) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setSel(new Set()); load(); notify('Gelöscht.'); }; + const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify('Gelöscht.'); }; const deliverSelected = async () => { notify('Sende an Picdrop …'); - const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...sel] }) }).then((x) => x.json()); - setSel(new Set()); load(); notify(`${r.delivered}/${r.total} nach Picdrop ausgeliefert.`); + const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json()); + setPick(new Set()); load(); notify(`${r.delivered}/${r.total} nach Picdrop ausgeliefert.`); + }; + const toggle = (id: string) => setPick((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); + const alternative = async (id: string) => { + notify('Erzeuge Alternative …'); + const r = await fetch(`/api/items/${id}/alternative`, { method: 'POST' }).then((x) => x.json()); + if (r.ok) notify('Alternative wird erzeugt — erscheint gleich hier.'); else notify(r.error || 'Fehler.'); + setTimeout(load, 2500); }; - const toggle = (id: string) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const newFolder = async () => { const name = prompt('Name des Ordners?'); if (!name) return; - await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); + const gallery = prompt('Picdrop-Galerie für diesen Ordner? (leer = Standard)') || ''; + await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, picdrop_gallery: gallery.trim() || null }) }); load(); }; + const delFolder = async (id: string) => { + if (!confirm('Ordner löschen? Die Bilder bleiben erhalten.')) return; + await fetch(`/api/folders?id=${id}`, { method: 'DELETE' }); + if (folderFilter === id) setFolderFilter(null); + load(); + }; + + // Nach Ordner filtern, dann Alternativen unter ihrem Ursprung gruppieren. + const filtered = folderFilter ? items.filter((v) => v.folder_id === folderFilter) : items; + const groupMap = new Map(); + for (const it of filtered) { + const root = it.variant_of || it.id; + if (!groupMap.has(root)) groupMap.set(root, []); + groupMap.get(root)!.push(it); + } + const groups = [...groupMap.values()].map((g) => g.slice().sort((a, b) => +new Date(a.created_at) - +new Date(b.created_at))); + groups.sort((a, b) => +new Date(b[b.length - 1].created_at) - +new Date(a[a.length - 1].created_at)); + const shown = (g: Item[]) => g.find((x) => x.id === variantSel[g[0].id]) || g[g.length - 1]; return (
- Bibliothek{items.length ? ` · ${items.length}` : ''} + Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}
- {sel.size > 0 && <> - {sel.size} gewählt + {pick.size > 0 && <> + {pick.size} gewählt } @@ -58,34 +93,68 @@ export default function LibraryApp() {
- {items.length === 0 ? ( -

Noch nichts erstellt

-

Lade im Studio ein paar Screenshots hoch — die Ergebnisse sammeln sich hier.

+ {/* Ordner-Filter */} +
+ + {folders.map((o) => ( + + + {folderFilter === o.id && } + + ))} +
+
Ordner gruppieren die Bibliothek und können je Ordner eine eigene Picdrop-Galerie bekommen (Pfeil = eigene Galerie hinterlegt).
+ + {filtered.length === 0 ? ( +

{folderFilter ? 'Ordner ist leer' : 'Noch nichts erstellt'}

+

{folderFilter ? 'Weise Bildern über das Ordner-Menü diesen Ordner zu.' : 'Lege im Studio los — die Ergebnisse sammeln sich hier.'}

) : (
- {items.map((v) => ( -
-
toggle(v.id)}> - - {sel.has(v.id) ? '✓' : ''} -
- rename(v.id, e.target.value)} /> -
{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelLabel(v.model_used) ? ` · ${modelLabel(v.model_used)}` : ''} - {v.delivery_status === 'delivered' && Picdrop ✓} - {v.delivery_status === 'pending' && Picdrop …} - {v.delivery_status === 'failed' && Picdrop ✕} -
-
- - Laden - - -
-
- ))} + {groups.map((g) => { + const v = shown(g); + const multi = g.length > 1; + return ( +
+
toggle(v.id)}> + + {pick.has(v.id) ? '✓' : ''} + {(v.mode === 'compose' || v.mode === 'generate') && + {v.mode === 'compose' ? 'kombiniert' : 'erzeugt'}} + {multi && Fassung {g.indexOf(v) + 1}/{g.length}} +
+ + {multi && ( +
+ {g.map((x, idx) => ( + + ))} +
+ )} + + rename(v.id, e.target.value)} /> +
{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelName(v.model_used) ? ` · ${modelName(v.model_used)}` : ''} + {v.delivery_status === 'delivered' && Picdrop ✓} + {v.delivery_status === 'pending' && Picdrop …} + {v.delivery_status === 'failed' && Picdrop ✕} +
+
+ + Laden + + + +
+
+ ); + })}
)} @@ -108,11 +177,16 @@ export default function LibraryApp() { function LibStyles() { return