feat: admin storage + NAS settings card

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-23 19:19:47 +00:00
parent 3e2e09a38d
commit a69e2452e1
+70 -4
View File
@@ -8,20 +8,24 @@ export default function AdminApp() {
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 [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
const [storage, setStorage] = useState<any>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
const load = useCallback(async () => {
const [a, b, c, d, e] = await Promise.all([
const [a, b, c, d, e, f] = 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 })),
]);
setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []);
setUsers(d.users || []); setTgLinks(e.links || []);
setUsers(d.users || []); setTgLinks(e.links || []); setStorage(f.stats || null);
}, []);
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());
@@ -40,17 +44,33 @@ export default function AdminApp() {
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,
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(''); notify('Gespeichert.'); load();
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 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 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 }) });
@@ -134,6 +154,47 @@ export default function AdminApp() {
</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>
<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>
<button className="mini" onClick={testNas}>Verbindung testen</button></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="/homes/klarbild oder /volume1/…" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
<div className="fein">Bei Synology am einfachsten SFTP aktivieren (Systemsteuerung Terminal &amp; SNMP SFTP). Zugangsdaten werden verschlüsselt gespeichert.</div>
</div>
</section>
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
<section className="karte">
@@ -213,6 +274,11 @@ function AdminStyles() {
.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);}