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:
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user