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:
@@ -8,20 +8,24 @@ export default function AdminApp() {
|
|||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const [tgLinks, setTgLinks] = useState<any[]>([]);
|
const [tgLinks, setTgLinks] = useState<any[]>([]);
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
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 notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
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/settings').then((r) => r.json()),
|
||||||
fetch('/api/admin/stats').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/models?available=1').then((r) => r.json()),
|
||||||
fetch('/api/admin/users').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/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 || []);
|
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 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());
|
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,
|
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,
|
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,
|
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 (orKey.trim()) body.openrouter_key = orKey.trim();
|
||||||
if (pdPw.trim()) body.picdrop_password = pdPw.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) });
|
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 () => {
|
const testPicdrop = async () => {
|
||||||
notify('Teste …');
|
notify('Teste …');
|
||||||
const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json());
|
const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json());
|
||||||
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
|
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) => {
|
const addModel = async (m: any) => {
|
||||||
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
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 }) });
|
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>
|
</div>
|
||||||
</section>
|
</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 & SNMP → SFTP). Zugangsdaten werden verschlüsselt gespeichert.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
|
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
|
||||||
|
|
||||||
<section className="karte">
|
<section className="karte">
|
||||||
@@ -213,6 +274,11 @@ function AdminStyles() {
|
|||||||
.zeile b{font-size:14px;}
|
.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{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);}
|
.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;}
|
.avail{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;}
|
||||||
.reihe{display:flex;gap:6px;}
|
.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);}
|
.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);}
|
||||||
|
|||||||
Reference in New Issue
Block a user