295f0ce91e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
412 lines
29 KiB
TypeScript
412 lines
29 KiB
TypeScript
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 [tgLinks, setTgLinks] = useState<any[]>([]);
|
|
const [toast, setToast] = useState<string | null>(null);
|
|
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
|
|
const [storage, setStorage] = useState<any>(null);
|
|
const [targets, setTargets] = useState<any[]>([]);
|
|
const [nt, setNt] = useState<any>({ 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 <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="zwei">
|
|
<div className="feld"><label>Basisordner (SFTP-Wurzel)</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div>
|
|
<div className="feld"><label>Standard-Galerie</label><input className="input" placeholder="POSTER LEA" value={s.picdrop_default_gallery ?? ''} onChange={(e) => field('picdrop_default_gallery', e.target.value)} /></div>
|
|
</div>
|
|
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> — z. B. <code>/ + POSTER LEA</code>. Pro Ordner lässt sich später eine eigene Galerie mappen.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="karte">
|
|
<div className="kopfzeile"><span className="mono-label">Speicher & Aufräumen</span>
|
|
{storage && <span className="fein">{fmtBytes(storage.bytes)} · {storage.results} Ergebnisse · {storage.sources} Quellen · {storage.thumbs} Vorschau</span>}</div>
|
|
<div className="steuer">
|
|
<label className="schalt"><input type="checkbox" checked={s.make_thumbnails !== false} onChange={(e) => field('make_thumbnails', e.target.checked)} />
|
|
<span><b>Vorschaubilder erzeugen</b><em>Kleine, sparsame Bilder für die Bibliotheksvorschau.</em></span></label>
|
|
<label className="schalt"><input type="checkbox" checked={!s.keep_sources} onChange={(e) => field('keep_sources', !e.target.checked)} />
|
|
<span><b>Quellbilder nach Bearbeitung löschen</b><em>Spart Platz — Originale werden nach dem Ergebnis entfernt.</em></span></label>
|
|
<label className="schalt"><input type="checkbox" checked={!!s.metadata_sidecar} onChange={(e) => field('metadata_sidecar', e.target.checked)} />
|
|
<span><b>Metadaten als .md mitschicken</b><em>Zu jedem Bild eine begleitende Textdatei (Prompt, Modell, Format …) bei Picdrop/NAS/FTP.</em></span></label>
|
|
<div className="feld"><label>Aufbewahrung (Tage)</label>
|
|
<input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
|
<div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
|
|
<div className="reihe">
|
|
<button className="mini" onClick={() => storageAction('purge_sources', 'Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.')}>Quellen jetzt löschen</button>
|
|
{s.retention_days ? <button className="mini" onClick={() => storageAction('retention', `Positionen älter als ${s.retention_days} Tage jetzt löschen?`)}>Retention jetzt anwenden</button> : null}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="karte">
|
|
<div className="kopfzeile"><span className="mono-label">Synology-NAS (zweite Sicherung)</span>
|
|
<div className="reihe"><button className="mini" onClick={netcheck}>Erreichbarkeit</button>
|
|
<button className="mini" onClick={testNas}>Verbindung testen</button></div></div>
|
|
<div className="steuer">
|
|
<label className="schalt"><input type="checkbox" checked={!!s.nas_enabled} onChange={(e) => field('nas_enabled', e.target.checked)} />
|
|
<span><b>Ergebnisse zusätzlich aufs NAS spiegeln</b><em>Jedes fertige Bild wird nach <code>klarbild/JJJJ-MM/</code> gesichert.</em></span></label>
|
|
<div className="zwei">
|
|
<div className="feld"><label>Host</label><input className="input" placeholder="z. B. nas.local oder DDNS" value={s.nas_host ?? ''} onChange={(e) => field('nas_host', e.target.value)} /></div>
|
|
<div className="feld"><label>Port</label><input className="input" type="number" value={s.nas_port ?? 22} onChange={(e) => field('nas_port', e.target.value)} /></div>
|
|
</div>
|
|
<div className="zwei">
|
|
<div className="feld"><label>Protokoll</label>
|
|
<select className="input" value={s.nas_protocol ?? 'sftp'} onChange={(e) => field('nas_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.nas_user ?? ''} onChange={(e) => field('nas_user', e.target.value)} /></div>
|
|
</div>
|
|
<div className="feld"><label>Passwort {s.nas_password_set && <em>(gesetzt)</em>}</label>
|
|
<input className="input" type="password" placeholder={s.nas_password_set ? '••••••' : ''} value={nasPw} onChange={(e) => setNasPw(e.target.value)} /></div>
|
|
<div className="feld"><label>Basisordner auf dem NAS</label><input className="input" placeholder="/Klarbild" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
|
|
<button className="mini" onClick={mirrorAll}>Alle vorhandenen Bilder aufs NAS sichern</button>
|
|
<div className="fein">Ergebnisse landen unter <code>Basisordner / JJJJ-MM /</code>. Zugangsdaten werden verschlüsselt gespeichert.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="karte">
|
|
<div className="kopfzeile"><span className="mono-label">Weitere Ausgabeziele (FTP/SFTP)</span>
|
|
<button className="mini" onClick={backupAllTargets}>Alle Bilder sichern</button></div>
|
|
<div className="steuer">
|
|
<div className="fein">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 <b>alle</b> Ergebnisse (wie das NAS).</div>
|
|
<div className="liste">
|
|
{targets.map((t) => (
|
|
<div key={t.id} className="zeile">
|
|
<div><b>{t.name}</b>{t.is_backup && <span className="pille">Backup</span>} <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
|
|
<div className="reihe">
|
|
<button className="mini" onClick={() => toggleBackup(t)}>{t.is_backup ? 'Backup aus' : 'Als Backup'}</button>
|
|
<button className="mini" onClick={() => testTarget(t.id)}>Test</button>
|
|
<button className="mini" onClick={() => delTarget(t.id)}>✕</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{targets.length === 0 && <div className="fein">Noch keine zusätzlichen Ziele.</div>}
|
|
</div>
|
|
<div className="zwei">
|
|
<div className="feld"><label>Name</label><input className="input" placeholder="z. B. The Frame FTP" value={nt.name ?? ''} onChange={(e) => setNt({ ...nt, name: e.target.value })} /></div>
|
|
<div className="feld"><label>Protokoll</label>
|
|
<select className="input" value={nt.protocol} onChange={(e) => setNt({ ...nt, protocol: e.target.value })}>
|
|
<option value="sftp">SFTP</option><option value="ftps">FTPS</option></select></div>
|
|
</div>
|
|
<div className="zwei">
|
|
<div className="feld"><label>Host</label><input className="input" value={nt.host ?? ''} onChange={(e) => setNt({ ...nt, host: e.target.value })} /></div>
|
|
<div className="feld"><label>Port</label><input className="input" type="number" value={nt.port ?? ''} onChange={(e) => setNt({ ...nt, port: e.target.value })} /></div>
|
|
</div>
|
|
<div className="zwei">
|
|
<div className="feld"><label>Benutzer</label><input className="input" value={nt.username ?? ''} onChange={(e) => setNt({ ...nt, username: e.target.value })} /></div>
|
|
<div className="feld"><label>Passwort</label><input className="input" type="password" value={nt.password ?? ''} onChange={(e) => setNt({ ...nt, password: e.target.value })} /></div>
|
|
</div>
|
|
<div className="feld"><label>Basisordner</label><input className="input" placeholder="/" value={nt.base_path ?? ''} onChange={(e) => setNt({ ...nt, base_path: e.target.value })} /></div>
|
|
<label className="schalt"><input type="checkbox" checked={!!nt.is_backup} onChange={(e) => setNt({ ...nt, is_backup: e.target.checked })} />
|
|
<span><b>Als Backup-Ziel</b><em>Erhält automatisch alle Ergebnisse.</em></span></label>
|
|
<button className="mini" onClick={addTarget}>Ziel hinzufügen</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="karte">
|
|
<div className="kopfzeile"><span className="mono-label">Automatisierung / MCP-Zugriff</span></div>
|
|
<div className="steuer">
|
|
<div className="fein">API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie"). Als <code>Authorization: Bearer <Token></code> an <code>/api/uploads</code> und <code>/api/jobs</code>.</div>
|
|
{s.api_token
|
|
? <div className="feld"><label>API-Token</label><input className="input" readOnly value={s.api_token} onFocus={(e) => e.target.select()} /></div>
|
|
: <div className="fein">Noch kein Token erzeugt.</div>}
|
|
<button className="mini" onClick={genToken}>{s.api_token ? 'Neuen Token erzeugen' : 'Token erzeugen'}</button>
|
|
</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>
|
|
<div className="reihe">
|
|
<button className="mini" onClick={() => pairCode(u.id)}>Telegram koppeln</button>
|
|
<button className="mini" onClick={() => setPw(u.id)}>Passwort setzen</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="karte">
|
|
<div className="kopfzeile"><span className="mono-label">Telegram-Bot</span></div>
|
|
<div className="liste">
|
|
<div className="fein">Ü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.</div>
|
|
{tgLinks.map((l) => (
|
|
<div key={l.chat_id} className="zeile">
|
|
<div><b>Chat {l.chat_id}</b> <span className="fein">{l.user_name || ''} · seit {new Date(l.linked_at).toLocaleDateString('de-DE')}</span></div>
|
|
<button className="mini" onClick={() => unlink(l.chat_id)}>Trennen</button>
|
|
</div>
|
|
))}
|
|
{tgLinks.length === 0 && <div className="fein">Noch keine gekoppelten Chats.</div>}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="karte gefahr">
|
|
<div className="kopfzeile"><span className="mono-label">Zurücksetzen</span></div>
|
|
<div className="steuer">
|
|
<div className="fein">Löscht <b>alle Bilder, Aufträge und Ordner</b> unwiderruflich, um sauber neu zu starten.
|
|
Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder aufs NAS sichern".</div>
|
|
<button className="knopf warn" onClick={resetAll}>Bibliothek & Aufträge zurücksetzen</button>
|
|
</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;}
|
|
.knopf.warn{background:var(--err);}
|
|
.karte.gefahr{border-color:color-mix(in oklab,var(--err) 40%,var(--line));}
|
|
.karte.gefahr .mono-label{color:var(--err);}
|
|
.steuer code{font-family:var(--font-mono);font-size:11px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
|
|
.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);}
|
|
.schalt{display:flex;gap:10px;align-items:flex-start;cursor:pointer;}
|
|
.schalt input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
|
|
.schalt b{display:block;font-size:14px;}
|
|
.schalt em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:1px;line-height:1.4;}
|
|
.schalt code{font-family:var(--font-mono);font-size:11px;}
|
|
.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>;
|
|
}
|