feat: Bibliothek + Admin

- Bibliothek: grid, before/after slider, rename, download, folder assign, delete, multi-select
- Admin: stats+costs, OpenRouter key (masked/encrypted), budget, defaults, Picdrop config+test,
  models (list/add from OpenRouter/set default/delete), users (add/set password)
- api: items/:id PATCH+DELETE, folders CRUD, admin/{settings,stats,models,test-picdrop,users}
- lib/picdrop.ts (SFTP/FTPS test+upload, temp->rename)
This commit is contained in:
2026-07-23 15:22:12 +00:00
parent dce7515c9b
commit 25d0a5cd97
13 changed files with 621 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
import React, { useState, useEffect, useCallback } from 'react';
export default function AdminApp() {
const [s, setS] = useState<any>(null);
const [stats, setStats] = useState<any>(null);
const [models, setModels] = useState<any[]>([]);
const [avail, setAvail] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [toast, setToast] = useState<string | null>(null);
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState('');
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
const load = useCallback(async () => {
const [a, b, c, d] = await Promise.all([
fetch('/api/admin/settings').then((r) => r.json()),
fetch('/api/admin/stats').then((r) => r.json()),
fetch('/api/admin/models?available=1').then((r) => r.json()),
fetch('/api/admin/users').then((r) => r.json()),
]);
setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []); setUsers(d.users || []);
}, []);
useEffect(() => { load(); }, [load]);
const field = (k: string, v: any) => setS((p: any) => ({ ...p, [k]: v }));
const saveSettings = async () => {
const body: any = {
picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port,
picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path,
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
};
if (orKey.trim()) body.openrouter_key = orKey.trim();
if (pdPw.trim()) body.picdrop_password = pdPw.trim();
await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
setOrKey(''); setPdPw(''); notify('Gespeichert.'); load();
};
const testPicdrop = async () => {
notify('Teste …');
const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json());
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
};
const addModel = async (m: any) => {
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) });
load();
};
const modelAction = async (id: string, action: string) => {
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, action }) });
load();
};
const setPw = async (id: string) => {
const pw = prompt('Neues Passwort?'); if (!pw) return;
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, password: pw }) });
notify('Passwort gesetzt.');
};
const addUser = async () => {
const username = prompt('Benutzername?'); if (!username) return;
const password = prompt('Passwort?'); if (!password) return;
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
load();
};
if (!s) return <p className="fein">Lädt </p>;
const stored = new Set(models.map((m) => m.model_id));
return (
<div className="admin">
{stats && (
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Nutzung & Kosten</span></div>
<div className="zahlen">
<div><b>{stats.total}</b><span>Bilder gesamt</span></div>
<div><b>{stats.error_rate}%</b><span>Fehlerquote</span></div>
<div><b>${stats.cost_30?.toFixed(2)}</b><span>Kosten 30 Tage</span></div>
<div><b>${stats.cost_all?.toFixed(2)}</b><span>Kosten gesamt</span></div>
</div>
<div className="fein">Heute ${stats.cost_today?.toFixed(2)} · 7 Tage ${stats.cost_7?.toFixed(2)}
{stats.last_activity ? ` · zuletzt ${new Date(stats.last_activity).toLocaleString('de-DE')}` : ''}</div>
</section>
)}
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Zugang & Grenzen</span></div>
<div className="steuer">
<div className="feld"><label>OpenRouter-Key {s.openrouter_key_set && <em>({s.openrouter_key_masked})</em>}</label>
<input className="input" placeholder={s.openrouter_key_set ? 'Neuen Key eingeben zum Wechseln' : 'sk-or-…'} value={orKey} onChange={(e) => setOrKey(e.target.value)} />
<div className="fein">Verschlüsselt gespeichert, hat Vorrang vor der Umgebungsvariable.</div>
</div>
<div className="feld"><label>Monatliches Budget ($)</label>
<input className="input" type="number" step="0.5" value={s.monthly_budget ?? ''} onChange={(e) => field('monthly_budget', e.target.value)} />
<div className="fein">Bei Erreichen hält die Warteschlange an.</div>
</div>
<div className="zwei">
<div className="feld"><label>Standard-dpi</label><input className="input" type="number" value={s.default_dpi ?? 300} onChange={(e) => field('default_dpi', e.target.value)} /></div>
<div className="feld"><label>Parallelität</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div>
</div>
<div className="feld"><label>Cricut-Bogenmaß (cm)</label><input className="input" value={s.cricut_sheet_cm ?? ''} onChange={(e) => field('cricut_sheet_cm', e.target.value)} /></div>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Picdrop</span>
<button className="mini" onClick={testPicdrop}>Verbindung testen</button></div>
<div className="steuer">
<div className="zwei">
<div className="feld"><label>Host</label><input className="input" value={s.picdrop_host ?? ''} onChange={(e) => field('picdrop_host', e.target.value)} /></div>
<div className="feld"><label>Port</label><input className="input" type="number" value={s.picdrop_port ?? 22} onChange={(e) => field('picdrop_port', e.target.value)} /></div>
</div>
<div className="zwei">
<div className="feld"><label>Protokoll</label>
<select className="input" value={s.picdrop_protocol ?? 'sftp'} onChange={(e) => field('picdrop_protocol', e.target.value)}>
<option value="sftp">SFTP (22)</option><option value="ftps">FTPS (21)</option></select></div>
<div className="feld"><label>Benutzer</label><input className="input" value={s.picdrop_user ?? ''} onChange={(e) => field('picdrop_user', e.target.value)} /></div>
</div>
<div className="feld"><label>Passwort {s.picdrop_password_set && <em>(gesetzt)</em>}</label>
<input className="input" type="password" placeholder={s.picdrop_password_set ? '••••••' : ''} value={pdPw} onChange={(e) => setPdPw(e.target.value)} /></div>
<div className="feld"><label>Basisordner</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div>
</div>
</section>
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Modelle</span></div>
<div className="liste">
{models.map((m) => (
<div key={m.id} className="zeile">
<div><b>{m.label}</b>{m.is_default && <span className="pille">Standard</span>}{m.supports_alpha && <span className="pille alpha">transparent</span>}<div className="fein">{m.model_id}</div></div>
<div className="reihe">
{!m.is_default && <button className="mini" onClick={() => modelAction(m.id, 'default')}>Standard</button>}
<button className="mini" onClick={() => modelAction(m.id, 'delete')}></button>
</div>
</div>
))}
{models.length === 0 && <div className="fein">Noch keine Modelle. Aus verfügbaren hinzufügen:</div>}
<div className="avail">
{avail.filter((m) => !stored.has(m.model_id)).slice(0, 12).map((m) => (
<button key={m.model_id} className="mini" onClick={() => addModel(m)}>+ {m.name}{m.supports_alpha ? ' ◇' : ''}</button>
))}
</div>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Nutzer</span><button className="mini" onClick={addUser}>Neuer Nutzer</button></div>
<div className="liste">
{users.map((u) => (
<div key={u.id} className="zeile">
<div><b>{u.display_name || u.username}</b> <span className="fein">{u.username} · {u.role}</span></div>
<button className="mini" onClick={() => setPw(u.id)}>Passwort setzen</button>
</div>
))}
</div>
</section>
{toast && <div className="toast">{toast}</div>}
<AdminStyles />
</div>
);
}
function AdminStyles() {
return <style>{`
.admin{display:flex;flex-direction:column;gap:16px;max-width:640px;}
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
.kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
.steuer{padding:16px;display:flex;flex-direction:column;gap:14px;}
.zwei{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);margin-bottom:6px;}
.feld em{text-transform:none;letter-spacing:0;color:var(--accent);font-style:normal;}
.input{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);}
.fein{font-size:11.5px;color:var(--soft);line-height:1.5;}
.knopf{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
.zahlen{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:16px;}
.zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;}
.zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);}
.karte>.fein{padding:0 16px 14px;}
.liste{padding:8px 16px 16px;display:flex;flex-direction:column;}
.zeile{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--line);}
.zeile b{font-size:14px;}
.pille{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;}
.pille.alpha{background:var(--paper);color:var(--soft);}
.avail{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;}
.reihe{display:flex;gap:6px;}
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);}
.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>;
}
+124
View File
@@ -0,0 +1,124 @@
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; }
export default function LibraryApp() {
const [items, setItems] = useState<Item[]>([]);
const [folders, setFolders] = useState<any[]>([]);
const [sel, setSel] = useState<Set<string>>(new Set());
const [compare, setCompare] = useState<Item | null>(null);
const [toast, setToast] = useState<string | null>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
const load = useCallback(async () => {
const [i, f] = await Promise.all([
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
]);
setItems(i.items || []); setFolders(f.folders || []);
}, []);
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 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 sel) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setSel(new Set()); load(); notify('Gelöscht.'); };
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 }) });
load();
};
return (
<div>
<div className="kopfzeile">
<span className="mono-label">Bibliothek{items.length ? ` · ${items.length}` : ''}</span>
<div className="reihe">
{sel.size > 0 && <>
<span className="fein">{sel.size} gewählt</span>
{[...sel].map((id) => <a key={id} className="hidden" />)}
<button className="mini" onClick={delSelected}>Löschen</button>
</>}
<button className="mini" onClick={newFolder}>Neuer Ordner</button>
</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>
) : (
<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`} 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' : ''}</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>
))}
</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 Original, rechts Ergebnis</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:14px;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);}
.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;}
.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(190px,1fr));gap:14px;}
.kachel{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:9px;}
.kachel.gewaehlt{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
.kachel-bild{position:relative;aspect-ratio:1;background:var(--paper);border-radius:2px;overflow:hidden;cursor:pointer;}
.kachel-bild img{width:100%;height:100%;object-fit:cover;display:block;}
.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);}
.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-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;}
.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>;
}