Files
klarbild/src/components/LibraryApp.tsx
T

345 lines
23 KiB
TypeScript

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; nas_status: string;
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; prompt_used: string | null;
color_tag: string | null; created_at: string; }
const TAGS = [
{ id: 'red', label: 'Rot', color: '#E5484D' },
{ id: 'orange', label: 'Orange', color: '#E8830C' },
{ id: 'green', label: 'Grün', color: '#46A758' },
{ id: 'final', label: 'Final', color: '#3A3733' },
];
const tagColor = (t: string | null) => TAGS.find((x) => x.id === t)?.color || null;
export default function LibraryApp() {
const [items, setItems] = useState<Item[]>([]);
const [folders, setFolders] = useState<any[]>([]);
const [models, setModels] = useState<any[]>([]);
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 [promptOf, setPromptOf] = useState<Item | null>(null);
const [saveView, setSaveView] = useState<Item | null>(null);
const [tagMenu, setTagMenu] = useState<string | null>(null);
const [tagFilter, setTagFilter] = useState<string | 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: [] })),
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })),
]);
setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
}, []);
useEffect(() => { load(); }, [load]);
const rename = async (id: string, filename: string) =>
fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
const setTag = async (id: string, color_tag: string | null) => {
setItems((x) => x.map((y) => y.id === id ? { ...y, color_tag } : y));
setTagMenu(null);
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color_tag }) });
};
const setFolder = async (id: string, folder_id: string | null) => {
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
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 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: [...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 moveSelected = async (folder_id: string | null) => {
for (const id of pick) await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
setPick(new Set()); load(); notify(folder_id ? 'In Ordner verschoben.' : 'Aus Ordner entfernt.');
};
const zipSelected = async () => {
notify('Packe ZIP …');
const res = await fetch('/api/items/zip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) });
if (!res.ok) { notify('ZIP fehlgeschlagen.'); return; }
const blob = await res.blob();
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `klarbild_${new Date().toISOString().slice(0, 10)}.zip`; a.click();
URL.revokeObjectURL(a.href); notify('ZIP geladen.');
};
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);
};
// Ergebnis als neue Vorlage ins Studio übernehmen und weiterbearbeiten.
const reuse = (id: string) => { window.location.href = `/?reuse=${id}`; };
const newFolder = async () => {
const name = prompt('Name des Ordners?'); if (!name) return;
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();
};
const renameFolder = async (o: any) => {
const name = prompt('Ordner umbenennen:', o.name); if (name == null) return;
const gallery = prompt('Picdrop-Galerie (leer = Standard):', o.picdrop_gallery || '');
await fetch('/api/folders', { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: o.id, name: name.trim() || o.name, picdrop_gallery: (gallery || '').trim() || null }) });
load();
};
// Nach Ordner + Markierung filtern, dann Alternativen unter ihrem Ursprung gruppieren.
const filtered = items
.filter((v) => !folderFilter || v.folder_id === folderFilter)
.filter((v) => !tagFilter || v.color_tag === tagFilter);
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{filtered.length ? ` · ${filtered.length}` : ''}</span>
<div className="reihe">
{pick.size > 0 && <>
<span className="fein">{pick.size} gewählt</span>
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
<button className="mini" onClick={zipSelected}>ZIP laden</button>
<select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}>
<option value="" disabled>In Ordner </option>
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
<option value="__none">Kein Ordner</option>
</select>
<button className="mini" onClick={delSelected}>Löschen</button>
</>}
<button className="mini" onClick={newFolder}>Neuer Ordner</button>
</div>
</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 umbenennen" onClick={() => renameFolder(o)}></button>
<button className="chip-x del" 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>
{/* Farbmarkierungen filtern */}
<div className="ordnerleiste tagbar">
<button className={`chip ${!tagFilter ? 'an' : ''}`} onClick={() => setTagFilter(null)}>Alle Markierungen</button>
{TAGS.map((t) => (
<button key={t.id} className={`chip flagchip ${tagFilter === t.id ? 'an' : ''}`} onClick={() => setTagFilter(tagFilter === t.id ? null : t.id)}>
<span className="flag" style={{ color: t.color }}></span> {t.label}
</button>
))}
</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">
{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>}
{v.color_tag && <span className="flagbadge" style={{ color: tagColor(v.color_tag)! }}></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>}
{v.nas_status === 'mirrored' && <span className="dbadge ok">NAS </span>}
{v.nas_status === 'failed' && <span className="dbadge err">NAS </span>}
</div>
<div className="reihe knapp">
<div className="tagwrap">
<button className="mini flagbtn" style={v.color_tag ? { color: tagColor(v.color_tag)!, borderColor: tagColor(v.color_tag)! } : {}}
onClick={() => setTagMenu(tagMenu === v.id ? null : v.id)} title="Markierung"></button>
{tagMenu === v.id && (
<div className="tagmenu" onMouseLeave={() => setTagMenu(null)}>
<button onClick={() => setTag(v.id, null)}><span className="flag none"></span> Keine</button>
{TAGS.map((t) => (
<button key={t.id} onClick={() => setTag(v.id, t.id)}><span className="flag" style={{ color: t.color }}></span> {t.label}</button>
))}
</div>
)}
</div>
{v.prompt_used && <button className="mini" onClick={() => setPromptOf(v)}>Prompt</button>}
{v.mode !== 'generate' && <button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>}
<button className="mini stark2" onClick={() => setSaveView(v)} title="Groß öffnen, dann sichern (iPhone: In Fotos sichern)">Laden</button>
<button className="mini" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiter</button>
<button className="mini" onClick={() => alternative(v.id)} title="Weitere Fassung erzeugen">+ Alt.</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>
)}
{compare && (
<div className="lupe" onClick={() => setCompare(null)}>
<div className="vergleich" onClick={(e) => e.stopPropagation()}>
<img className="unten" src={`/api/items/${compare.id}/file?src=1`} alt="vorher" />
<img className="oben" src={`/api/items/${compare.id}/file`} alt="nachher" id="ov" />
<input type="range" min={0} max={100} defaultValue={50}
onChange={(e) => { const o = document.getElementById('ov'); if (o) o.style.clipPath = `inset(0 0 0 ${e.target.value}%)`; }} />
</div>
<div className="lupe-fuss">{compare.filename} · {compare.output_px}px Regler: links {compare.mode === 'compose' ? 'Vorlage' : 'Original'}, rechts Ergebnis</div>
</div>
)}
{saveView && (
<div className="lupe" onClick={() => setSaveView(null)}>
<div className="savewrap" onClick={(e) => e.stopPropagation()}>
<img className="saveimg blur" src={`/api/items/${saveView.id}/file?thumb=1`} alt="" />
<img className="saveimg full" src={`/api/items/${saveView.id}/file?preview=1`} alt={saveView.filename || ''} />
</div>
<div className="save-fuss" onClick={(e) => e.stopPropagation()}>
<span>📱 iPhone/iPad: lange auf das Bild tippen <b>In Fotos sichern"</b>.</span>
<a className="mini stark" href={`/api/items/${saveView.id}/file?download=1`}>Original als Datei (.png)</a>
<button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
</div>
</div>
)}
{promptOf && (
<div className="lupe" onClick={() => setPromptOf(null)}>
<div className="promptbox" onClick={(e) => e.stopPropagation()}>
<div className="pb-kopf"><b>{promptOf.filename}</b>
<button className="mini" onClick={() => { navigator.clipboard?.writeText(promptOf.prompt_used || ''); notify('Prompt kopiert.'); }}>Kopieren</button></div>
<pre className="pb-text">{promptOf.prompt_used}</pre>
<div className="fein">{promptOf.model_used ? `Modell: ${modelName(promptOf.model_used)} · ` : ''}{promptOf.output_px}px</div>
</div>
</div>
)}
{toast && <div className="toast">{toast}</div>}
<LibStyles />
</div>
);
}
function LibStyles() {
return <style>{`
.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;}
.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(--accent);cursor:pointer;font-size:12px;padding:0 4px;}
.chip-x.del{color:var(--err);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;}
@media(min-width:768px){.galerie{grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;}}
.kachel{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:8px;}
.kachel.gewaehlt{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
.kachel-bild{position:relative;border-radius:3px;overflow:hidden;cursor:pointer;
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/16px 16px;}
.kachel-bild img{width:100%;height:auto;display:block;transition:transform .12s;}
.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;}
.flagbadge{position:absolute;top:5px;left:6px;font-size:19px;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.35);}
.tagbar{margin:0 0 12px;}
.flagchip .flag{font-size:13px;vertical-align:-1px;}
.flag.none{color:var(--soft);}
.tagwrap{position:relative;display:inline-block;}
.flagbtn{font-size:14px;line-height:1;padding:5px 9px;}
.tagmenu{position:absolute;bottom:calc(100% + 4px);left:0;z-index:20;background:var(--card);border:1px solid var(--line);border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:5px;display:flex;flex-direction:column;min-width:130px;}
.tagmenu button{display:flex;align-items:center;gap:8px;background:none;border:none;text-align:left;padding:7px 9px;cursor:pointer;font-family:inherit;font-size:13px;border-radius:4px;color:var(--ink);}
.tagmenu button:hover{background:var(--paper);}
.tagmenu .flag{font-size:15px;}
.savewrap{position:relative;max-width:94vw;max-height:78vh;display:flex;}
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
.saveimg.blur{filter:blur(8px);position:absolute;inset:0;width:100%;height:100%;object-fit:contain;}
.saveimg.full{position:relative;z-index:1;}
.save-fuss{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;max-width:90vw;}
.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;}
.mini.stark2{border-color:var(--accent);color:var(--accent);font-weight:600;}
.dbadge{display:inline-block;margin-left:6px;font-family:var(--font-mono);font-size:9px;letter-spacing:.08em;text-transform:uppercase;background:var(--paper);color:var(--soft);padding:1px 6px;border-radius:20px;}
.dbadge.ok{background:var(--accent-bg);color:var(--accent);}
.dbadge.err{color:var(--err);}
.mini-select{border:1px solid var(--line);border-radius:3px;padding:5px 7px;font-family:inherit;font-size:12px;background:#fff;}
.lupe{position:fixed;inset:0;background:rgba(22,21,15,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:26px;z-index:50;}
.vergleich{position:relative;max-width:90vw;max-height:76vh;}
.vergleich img{max-width:90vw;max-height:70vh;display:block;border-radius:3px;}
.vergleich .oben{position:absolute;inset:0;clip-path:inset(0 0 0 50%);}
.vergleich input[type=range]{position:absolute;left:0;right:0;bottom:-34px;width:100%;}
.lupe-fuss{color:#EAEAE3;font-family:var(--font-mono);font-size:12px;margin-top:30px;text-align:center;}
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
.pb-kopf{display:flex;justify-content:space-between;align-items:center;gap:10px;}
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
`}</style>;
}