feat: functional folders (filter/delete/gallery) + always show model + variant switcher
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
+119
-39
@@ -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<Item[]>([]);
|
||||
const [folders, setFolders] = useState<any[]>([]);
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const modelLabel = (id: string) => models.find((m) => m.model_id === id)?.label || null;
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
const [folderFilter, setFolderFilter] = useState<string | null>(null);
|
||||
const [pick, setPick] = useState<Set<string>>(new Set());
|
||||
const [variantSel, setVariantSel] = useState<Record<string, string>>({});
|
||||
const [compare, setCompare] = useState<Item | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(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<string, Item[]>();
|
||||
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 (
|
||||
<div>
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">Bibliothek{items.length ? ` · ${items.length}` : ''}</span>
|
||||
<span className="mono-label">Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}</span>
|
||||
<div className="reihe">
|
||||
{sel.size > 0 && <>
|
||||
<span className="fein">{sel.size} gewählt</span>
|
||||
{pick.size > 0 && <>
|
||||
<span className="fein">{pick.size} gewählt</span>
|
||||
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
|
||||
<button className="mini" onClick={delSelected}>Löschen</button>
|
||||
</>}
|
||||
@@ -58,34 +93,68 @@ export default function LibraryApp() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="leer"><span className="regmark" /><h3>Noch nichts erstellt</h3>
|
||||
<p>Lade im Studio ein paar Screenshots hoch — die Ergebnisse sammeln sich hier.</p></div>
|
||||
{/* Ordner-Filter */}
|
||||
<div className="ordnerleiste">
|
||||
<button className={`chip ${!folderFilter ? 'an' : ''}`} onClick={() => setFolderFilter(null)}>Alle · {items.length}</button>
|
||||
{folders.map((o) => (
|
||||
<span key={o.id} className={`chip-wrap ${folderFilter === o.id ? 'an' : ''}`}>
|
||||
<button className="chip" onClick={() => setFolderFilter(folderFilter === o.id ? null : o.id)}>
|
||||
{o.name} · {o.count}{o.picdrop_gallery ? ' →' : ''}
|
||||
</button>
|
||||
{folderFilter === o.id && <button className="chip-x" title="Ordner löschen" onClick={() => delFolder(o.id)}>✕</button>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="fein hinweis">Ordner gruppieren die Bibliothek und können je Ordner eine eigene Picdrop-Galerie bekommen (Pfeil = eigene Galerie hinterlegt).</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="leer"><span className="regmark" /><h3>{folderFilter ? 'Ordner ist leer' : 'Noch nichts erstellt'}</h3>
|
||||
<p>{folderFilter ? 'Weise Bildern über das Ordner-Menü diesen Ordner zu.' : 'Lege im Studio los — die Ergebnisse sammeln sich hier.'}</p></div>
|
||||
) : (
|
||||
<div className="galerie">
|
||||
{items.map((v) => (
|
||||
<article key={v.id} className={`kachel ${sel.has(v.id) ? 'gewaehlt' : ''}`}>
|
||||
<div className="kachel-bild" onClick={() => toggle(v.id)}>
|
||||
<img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||
<span className="haken">{sel.has(v.id) ? '✓' : ''}</span>
|
||||
</div>
|
||||
<input className="name" defaultValue={v.filename || ''} onBlur={(e) => rename(v.id, e.target.value)} />
|
||||
<div className="meta">{(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' && <span className="dbadge ok">Picdrop ✓</span>}
|
||||
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop …</span>}
|
||||
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop ✕</span>}
|
||||
</div>
|
||||
<div className="reihe knapp">
|
||||
<button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>
|
||||
<a className="mini" href={`/api/items/${v.id}/file?download=1`}>Laden</a>
|
||||
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
|
||||
<option value="">Kein Ordner</option>
|
||||
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
<button className="mini" onClick={() => del(v.id)}>✕</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{groups.map((g) => {
|
||||
const v = shown(g);
|
||||
const multi = g.length > 1;
|
||||
return (
|
||||
<article key={g[0].id} className={`kachel ${pick.has(v.id) ? 'gewaehlt' : ''}`}>
|
||||
<div className="kachel-bild" onClick={() => toggle(v.id)}>
|
||||
<img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||
<span className="haken">{pick.has(v.id) ? '✓' : ''}</span>
|
||||
{(v.mode === 'compose' || v.mode === 'generate') &&
|
||||
<span className="modetag">{v.mode === 'compose' ? 'kombiniert' : 'erzeugt'}</span>}
|
||||
{multi && <span className="fassung">Fassung {g.indexOf(v) + 1}/{g.length}</span>}
|
||||
</div>
|
||||
|
||||
{multi && (
|
||||
<div className="varianten">
|
||||
{g.map((x, idx) => (
|
||||
<button key={x.id} className={`vthumb ${x.id === v.id ? 'an' : ''}`}
|
||||
onClick={() => setVariantSel((s) => ({ ...s, [g[0].id]: x.id }))} title={`Fassung ${idx + 1}`}>
|
||||
<img src={`/api/items/${x.id}/file?thumb=1`} alt="" loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input className="name" defaultValue={v.filename || ''} key={v.id} onBlur={(e) => rename(v.id, e.target.value)} />
|
||||
<div className="meta">{(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' && <span className="dbadge ok">Picdrop ✓</span>}
|
||||
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop …</span>}
|
||||
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop ✕</span>}
|
||||
</div>
|
||||
<div className="reihe knapp">
|
||||
<button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>
|
||||
<a className="mini" href={`/api/items/${v.id}/file?download=1`}>Laden</a>
|
||||
<button className="mini" onClick={() => alternative(v.id)} title="Weitere Fassung erzeugen">+ Alternative</button>
|
||||
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
|
||||
<option value="">Kein Ordner</option>
|
||||
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
<button className="mini" onClick={() => del(v.id)}>✕</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -108,11 +177,16 @@ export default function LibraryApp() {
|
||||
|
||||
function LibStyles() {
|
||||
return <style>{`
|
||||
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap;}
|
||||
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
|
||||
.reihe{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}.reihe.knapp{margin-top:8px;}
|
||||
.fein{font-size:11.5px;color:var(--soft);}
|
||||
.hinweis{margin:0 0 14px;line-height:1.5;}
|
||||
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;}
|
||||
.hidden{display:none;}
|
||||
.ordnerleiste{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-bottom:8px;}
|
||||
.chip-wrap{display:inline-flex;align-items:center;border-radius:20px;}
|
||||
.chip{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:6px 13px;cursor:pointer;font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;color:var(--soft);white-space:nowrap;}
|
||||
.chip.an,.chip-wrap.an .chip{border-color:var(--accent);background:var(--accent-bg);color:var(--accent);}
|
||||
.chip-x{border:none;background:none;color:var(--err);cursor:pointer;font-size:12px;padding:0 8px 0 2px;}
|
||||
.leer{padding:60px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.leer h3{margin:0;font-size:17px;}.leer p{margin:0;color:var(--soft);max-width:38ch;}
|
||||
.galerie{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;align-items:start;}
|
||||
@@ -125,6 +199,12 @@ function LibStyles() {
|
||||
.kachel-bild:active img{transform:scale(.98);}
|
||||
.haken{position:absolute;top:6px;left:6px;width:21px;height:21px;border-radius:50%;border:1.5px solid #fff;background:rgba(22,21,15,.35);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;}
|
||||
.kachel.gewaehlt .haken{background:var(--accent);}
|
||||
.modetag{position:absolute;top:6px;right:6px;background:rgba(59,91,219,.92);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:20px;}
|
||||
.fassung{position:absolute;bottom:6px;right:6px;background:rgba(22,21,15,.7);color:#fff;font-family:var(--font-mono);font-size:9px;padding:2px 7px;border-radius:20px;}
|
||||
.varianten{display:flex;gap:5px;margin-top:6px;overflow-x:auto;}
|
||||
.vthumb{flex:0 0 auto;width:40px;height:40px;border:1px solid var(--line);border-radius:3px;overflow:hidden;padding:0;background:none;cursor:pointer;}
|
||||
.vthumb.an{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
||||
.vthumb img{width:100%;height:100%;object-fit:cover;display:block;}
|
||||
.name{margin-top:8px;width:100%;font-size:12.5px;padding:6px 8px;font-family:var(--font-mono);border:1px solid var(--line);border-radius:3px;background:#fff;}
|
||||
.meta{font-size:11px;color:var(--soft);margin-top:5px;}
|
||||
.mini.stark{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;}
|
||||
|
||||
Reference in New Issue
Block a user