import React, { useState, useEffect, useCallback } from 'react'; export default function AdminApp() { const [s, setS] = useState(null); const [stats, setStats] = useState(null); const [models, setModels] = useState([]); const [avail, setAvail] = useState([]); const [users, setUsers] = useState([]); const [tgLinks, setTgLinks] = useState([]); const [toast, setToast] = useState(null); const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState(''); const [storage, setStorage] = useState(null); const [targets, setTargets] = useState([]); const [nt, setNt] = useState({ protocol: 'sftp' }); const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; const load = useCallback(async () => { const [a, b, c, d, e, f, g] = 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()), fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })), fetch('/api/admin/storage').then((r) => r.json()).catch(() => ({ stats: null })), fetch('/api/admin/delivery-targets').then((r) => r.json()).catch(() => ({ targets: [] })), ]); setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []); setUsers(d.users || []); setTgLinks(e.links || []); setStorage(f.stats || null); setTargets(g.targets || []); }, []); const addTarget = async () => { if (!nt.name?.trim()) { notify('Name fehlt.'); return; } await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(nt) }); setNt({ protocol: 'sftp' }); notify('Ziel angelegt.'); load(); }; const delTarget = async (id: string) => { if (!confirm('Ziel löschen?')) return; await fetch(`/api/admin/delivery-targets?id=${id}`, { method: 'DELETE' }); load(); }; const toggleBackup = async (t: any) => { await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, is_backup: !t.is_backup }) }); load(); }; const backupAllTargets = async () => { if (!confirm('Alle fertigen Bilder auf alle Backup-Ziele (NAS + markierte Ziele) sichern?')) return; notify('Sichere … (kann dauern)'); const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'backup_all' }) }).then((x) => x.json()); notify(r.items != null ? `${r.mirrored} Kopien auf Backup-Ziele${r.failed ? `, ${r.failed} Fehler` : ''}.` : (r.error || 'OK')); load(); }; const genToken = async () => { await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ generate_api_token: true }) }); notify('API-Token erzeugt.'); load(); }; const testTarget = async (id: string) => { notify('Teste Ziel …'); const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', id }) }).then((x) => x.json()); notify(r.message || (r.ok ? 'OK' : 'Fehler')); }; const fmtBytes = (n: number | null) => n == null ? '—' : n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.round(n / 1e3)} KB`; const pairCode = async (userId: string) => { const r = await fetch('/api/admin/telegram/pairing-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }).then((x) => x.json()); if (r.code) alert(`Kopplungscode: ${r.code}\n\n15 Minuten gültig. Im Klarbild-Telegram-Chat eingeben.`); }; const unlink = async (chat_id: number) => { await fetch('/api/admin/telegram/links', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id }) }); load(); }; 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, picdrop_default_gallery: s.picdrop_default_gallery, 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, keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days, metadata_sidecar: !!s.metadata_sidecar, nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port, nas_user: s.nas_user, nas_base_path: s.nas_base_path, }; if (orKey.trim()) body.openrouter_key = orKey.trim(); if (pdPw.trim()) body.picdrop_password = pdPw.trim(); if (nasPw.trim()) body.nas_password = nasPw.trim(); await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); setOrKey(''); setPdPw(''); setNasPw(''); 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 testNas = async () => { notify('Teste NAS …'); const r = await fetch('/api/admin/test-nas', { method: 'POST' }).then((x) => x.json()); notify(r.message || (r.ok ? 'OK' : 'Fehler')); }; const netcheck = async () => { if (!s.nas_host) { notify('Erst Host eintragen und speichern.'); return; } notify('Prüfe Erreichbarkeit …'); const r = await fetch('/api/admin/netcheck', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host: s.nas_host, port: s.nas_port || 22 }) }).then((x) => x.json()); const dnsMsg = r.resolve ? (r.resolve.ok ? `DNS ✓ (${r.resolve.address})` : `DNS ✕ (${r.resolve.error})`) : ''; const tcpMsg = r.tcp ? (r.tcp.ok ? `Port erreichbar in ${r.tcp.ms}ms ✓` : `nicht erreichbar: ${r.tcp.error}`) : (r.error || 'Fehler'); notify(`${dnsMsg}${dnsMsg ? ' · ' : ''}${tcpMsg}`); }; const storageAction = async (action: string, confirmMsg?: string) => { if (confirmMsg && !confirm(confirmMsg)) return; notify('Räume auf …'); const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }).then((x) => x.json()); notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : (r.error || 'OK')); load(); }; const mirrorAll = async () => { if (!s.nas_enabled) { notify('NAS erst aktivieren & speichern.'); return; } if (!confirm('Alle fertigen Bilder aufs NAS sichern?')) return; notify('Sichere aufs NAS … (kann dauern)'); const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'mirror_all' }) }).then((x) => x.json()); notify(r.total != null ? `${r.mirrored}/${r.total} aufs NAS gesichert${r.failed ? `, ${r.failed} fehlgeschlagen` : ''}.` : (r.error || 'Fehler')); load(); }; const resetAll = async () => { const t = prompt('ACHTUNG: Löscht ALLE Bilder, Aufträge und Ordner unwiderruflich (Nutzer, Einstellungen, Modelle & Rezepte bleiben).\n\nZum Bestätigen RESET eingeben:'); if (t !== 'RESET') { if (t != null) notify('Abgebrochen — nicht bestätigt.'); return; } notify('Setze zurück …'); const r = await fetch('/api/admin/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'RESET' }) }).then((x) => x.json()); notify(r.ok ? `Zurückgesetzt: ${r.deleted_items} Bilder entfernt.` : (r.error || 'Fehler')); load(); }; 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

Lädt …

; const stored = new Set(models.map((m) => m.model_id)); return (
{stats && (
Nutzung & Kosten
{stats.total}Bilder gesamt
{stats.error_rate}%Fehlerquote
${stats.cost_30?.toFixed(2)}Kosten 30 Tage
${stats.cost_all?.toFixed(2)}Kosten gesamt
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')}` : ''}
)}
Zugang & Grenzen
setOrKey(e.target.value)} />
Verschlüsselt gespeichert, hat Vorrang vor der Umgebungsvariable.
field('monthly_budget', e.target.value)} />
Bei Erreichen hält die Warteschlange an.
field('default_dpi', e.target.value)} />
field('concurrency', e.target.value)} />
field('cricut_sheet_cm', e.target.value)} />
Picdrop
field('picdrop_host', e.target.value)} />
field('picdrop_port', e.target.value)} />
field('picdrop_user', e.target.value)} />
setPdPw(e.target.value)} />
field('picdrop_base_path', e.target.value)} />
field('picdrop_default_gallery', e.target.value)} />
Bilder landen in Basisordner / Galerie — z. B. / + POSTER LEA. Pro Ordner lässt sich später eine eigene Galerie mappen.
Speicher & Aufräumen {storage && {fmtBytes(storage.bytes)} · {storage.results} Ergebnisse · {storage.sources} Quellen · {storage.thumbs} Vorschau}
field('retention_days', e.target.value)} />
Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.
{s.retention_days ? : null}
Synology-NAS (zweite Sicherung)
field('nas_host', e.target.value)} />
field('nas_port', e.target.value)} />
field('nas_user', e.target.value)} />
setNasPw(e.target.value)} />
field('nas_base_path', e.target.value)} />
Ergebnisse landen unter Basisordner / JJJJ-MM /. Zugangsdaten werden verschlüsselt gespeichert.
Weitere Ausgabeziele (FTP/SFTP)
Zusätzliche FTP/SFTP-Ziele — z. B. eine zweite Picdrop-Galerie oder ein anderer Server. Ein Preset/Ordner kann fest auf ein Ziel zeigen; im Studio unter „Wohin?" wählbar (auch NAS). „Backup"-Ziele erhalten automatisch alle Ergebnisse (wie das NAS).
{targets.map((t) => (
{t.name}{t.is_backup && Backup} {t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}
))} {targets.length === 0 &&
Noch keine zusätzlichen Ziele.
}
setNt({ ...nt, name: e.target.value })} />
setNt({ ...nt, host: e.target.value })} />
setNt({ ...nt, port: e.target.value })} />
setNt({ ...nt, username: e.target.value })} />
setNt({ ...nt, password: e.target.value })} />
setNt({ ...nt, base_path: e.target.value })} />
Automatisierung / MCP-Zugriff
API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie"). Als Authorization: Bearer <Token> an /api/uploads und /api/jobs.
{s.api_token ?
e.target.select()} />
:
Noch kein Token erzeugt.
}
Modelle
{models.map((m) => (
{m.label}{m.is_default && Standard}{m.supports_alpha && transparent}
{m.model_id}
{!m.is_default && }
))} {models.length === 0 &&
Noch keine Modelle. Aus verfügbaren hinzufügen:
}
{avail.filter((m) => !stored.has(m.model_id)).slice(0, 12).map((m) => ( ))}
Nutzer
{users.map((u) => (
{u.display_name || u.username} {u.username} · {u.role}
))}
Telegram-Bot
Über „Telegram koppeln" bei einem Nutzer einen Code erzeugen und im Chat mit dem Bot eingeben. Danach dort Bilder weiterleiten → Rezept wählen → Ergebnisse kommen zurück.
{tgLinks.map((l) => (
Chat {l.chat_id} {l.user_name || ''} · seit {new Date(l.linked_at).toLocaleDateString('de-DE')}
))} {tgLinks.length === 0 &&
Noch keine gekoppelten Chats.
}
Zurücksetzen
Löscht alle Bilder, Aufträge und Ordner unwiderruflich, um sauber neu zu starten. Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder aufs NAS sichern".
{toast &&
{toast}
}
); } function AdminStyles() { return ; }