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:
2026-07-23 15:22:12 +00:00
parent dce7515c9b
commit 25d0a5cd97
13 changed files with 621 additions and 0 deletions
+188
View File
@@ -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>;
}
+124
View File
@@ -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>;
}
+73
View File
@@ -0,0 +1,73 @@
// Picdrop-Auslieferung via SFTP (ssh2-sftp-client) oder FTPS (basic-ftp).
// Konfiguration kommt aus settings (verschlüsseltes Passwort).
import posixpath from 'node:path/posix';
import { one } from './db';
import { decrypt } from './crypto';
export interface PicdropCfg {
host: string; protocol: 'ftps' | 'sftp'; port: number;
user: string; password: string; basePath: string;
}
export async function loadConfig(): Promise<PicdropCfg | null> {
const s = await one<any>(`SELECT picdrop_host, picdrop_protocol, picdrop_port, picdrop_user,
picdrop_password_enc, picdrop_base_path FROM settings WHERE id=1`);
if (!s?.picdrop_host || !s?.picdrop_user || !s?.picdrop_password_enc) return null;
let password = '';
try { password = decrypt(s.picdrop_password_enc); } catch { return null; }
return {
host: s.picdrop_host, protocol: (s.picdrop_protocol || 'sftp'),
port: s.picdrop_port || (s.picdrop_protocol === 'ftps' ? 21 : 22),
user: s.picdrop_user, password, basePath: s.picdrop_base_path || '/',
};
}
/** Verbindung testen: verbinden + Basisordner listen. */
export async function testConnection(cfg: PicdropCfg): Promise<{ ok: boolean; message: string }> {
try {
if (cfg.protocol === 'sftp') {
const SftpClient = (await import('ssh2-sftp-client')).default;
const c = new SftpClient();
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
await c.list(cfg.basePath || '/');
await c.end();
} else {
const { Client } = await import('basic-ftp');
const c = new Client(15000);
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
await c.list(cfg.basePath || '/');
c.close();
}
return { ok: true, message: 'Verbindung erfolgreich.' };
} catch (e: any) {
return { ok: false, message: e?.message || 'Verbindung fehlgeschlagen.' };
}
}
/** Lädt einen Puffer als Datei hoch (temp-Name → umbenennen, kein .filepart). */
export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: string, buf: Buffer): Promise<void> {
const dir = posixpath.join(cfg.basePath || '/', gallery || '');
const finalPath = posixpath.join(dir, filename);
const tmpPath = posixpath.join(dir, `.tmp-${Date.now()}-${filename}`);
const { Readable } = await import('node:stream');
if (cfg.protocol === 'sftp') {
const SftpClient = (await import('ssh2-sftp-client')).default;
const c = new SftpClient();
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
try {
if (!(await c.exists(dir))) await c.mkdir(dir, true);
await c.put(buf, tmpPath);
await c.rename(tmpPath, finalPath);
} finally { await c.end(); }
} else {
const { Client } = await import('basic-ftp');
const c = new Client(20000);
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
try {
await c.ensureDir(dir);
await c.uploadFrom(Readable.from(buf), tmpPath);
await c.rename(tmpPath, finalPath);
} finally { c.close(); }
}
}
+10
View File
@@ -0,0 +1,10 @@
---
import Base from '../../layouts/Base.astro';
import AdminApp from '../../components/AdminApp.tsx';
if (Astro.locals.user?.role !== 'admin') return Astro.redirect('/');
---
<Base title="Admin · Klarbild">
<h1 class="seiten-titel">Admin</h1>
<AdminApp client:load />
<style>.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 16px;letter-spacing:-.02em;}</style>
</Base>
+45
View File
@@ -0,0 +1,45 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { listImageModels } from '../../../lib/openrouter';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
// GET: gespeicherte Modelle; ?available=1 zusätzlich die von OpenRouter verfügbaren.
export const GET: APIRoute = async ({ url }) => {
const models = await query('SELECT * FROM models ORDER BY sort, label');
const out: any = { models };
if (url.searchParams.get('available') === '1') {
try {
const avail = await listImageModels();
out.available = avail.map((m: any) => ({
model_id: m.id, name: m.name,
supports_alpha: (m.supported_parameters?.background?.values || []).includes('transparent')
|| !!m.supported_parameters?.background,
}));
} catch (e: any) { out.available_error = e?.friendly || 'Modellliste nicht abrufbar.'; }
}
return json(out);
};
// POST: Modell anlegen/aktualisieren (upsert by model_id) oder Aktion (toggle/default/delete).
export const POST: APIRoute = async ({ request }) => {
const b = await request.json();
if (b.action === 'delete' && b.id) { await query('DELETE FROM models WHERE id=$1', [b.id]); return json({ ok: true }); }
if (b.action === 'default' && b.id) {
await query('UPDATE models SET is_default=false'); await query('UPDATE models SET is_default=true WHERE id=$1', [b.id]);
return json({ ok: true });
}
if (!b.model_id || !b.label) return json({ error: 'model_id + label nötig.' }, 400);
const existing = await one<{ id: string }>('SELECT id FROM models WHERE model_id=$1', [b.model_id]);
if (existing) {
await query(`UPDATE models SET label=$2, description=$3, active=$4, supports_alpha=$5, sort=$6 WHERE id=$1`,
[existing.id, b.label, b.description || null, b.active ?? true, b.supports_alpha ?? false, b.sort ?? 0]);
return json({ id: existing.id });
}
const row = await one(`INSERT INTO models (model_id, label, description, active, supports_alpha, sort)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[b.model_id, b.label, b.description || null, b.active ?? true, b.supports_alpha ?? false, b.sort ?? 0]);
return json({ model: row });
};
+38
View File
@@ -0,0 +1,38 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => {
const s = await one<any>('SELECT * FROM settings WHERE id=1');
if (!s) return json({ settings: null });
let orMask = '';
if (s.openrouter_key_enc) { try { orMask = maskSecret(decrypt(s.openrouter_key_enc)); } catch { orMask = 'gesetzt'; } }
return json({ settings: {
openrouter_key_masked: orMask, openrouter_key_set: !!s.openrouter_key_enc,
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_password_set: !!s.picdrop_password_enc,
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,
} });
};
export const PATCH: APIRoute = async ({ request }) => {
const b = await request.json();
const sets: string[] = []; const args: any[] = [];
const set = (col: string, val: any) => { args.push(val); sets.push(`${col}=$${args.length}`); };
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password)));
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url']) {
if (col in b) set(col, b[col] === '' ? null : b[col]);
}
if (!sets.length) return json({ ok: true });
await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args);
return json({ ok: true });
};
+25
View File
@@ -0,0 +1,25 @@
import type { APIRoute } from 'astro';
import { query, one } from '../../../lib/db';
export const prerender = false;
export const GET: APIRoute = async () => {
const total = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='done'`);
const failed = await one<{ n: string }>(`SELECT count(*)::text n FROM items WHERE status='failed'`);
const cost = async (days: number | null) => (await one<{ c: string }>(
`SELECT COALESCE(sum(cost),0)::text c FROM items WHERE cost IS NOT NULL` +
(days ? ` AND created_at > now() - interval '${days} days'` : ''))
)?.c || '0';
const byModel = await query(`SELECT COALESCE(model_used,'—') m, count(*)::int n, COALESCE(sum(cost),0)::float c
FROM items WHERE status='done' GROUP BY 1 ORDER BY n DESC`);
const last = await one<{ t: string }>(`SELECT max(created_at)::text t FROM items`);
const totalN = Number(total?.n || 0), failedN = Number(failed?.n || 0);
return new Response(JSON.stringify({
total: totalN, failed: failedN,
error_rate: totalN + failedN ? Math.round((failedN / (totalN + failedN)) * 100) : 0,
cost_today: Number(await cost(1)), cost_7: Number(await cost(7)),
cost_30: Number(await cost(30)), cost_all: Number(await cost(null)),
by_model: byModel, last_activity: last?.t || null,
}), { headers: { 'Content-Type': 'application/json' } });
};
+12
View File
@@ -0,0 +1,12 @@
import type { APIRoute } from 'astro';
import { loadConfig, testConnection } from '../../../lib/picdrop';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async () => {
const cfg = await loadConfig();
if (!cfg) return json({ ok: false, message: 'Picdrop nicht vollständig konfiguriert.' });
return json(await testConnection(cfg));
};
+28
View File
@@ -0,0 +1,28 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { hashPassword } from '../../../lib/auth';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => {
const users = await query('SELECT id, username, role, display_name, created_at FROM users ORDER BY username');
return json({ users });
};
// POST: neuen Nutzer anlegen ODER Passwort setzen (bei vorhandenem username/id).
export const POST: APIRoute = async ({ request }) => {
const b = await request.json();
if (b.id && b.password) {
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]);
return json({ ok: true });
}
if (!b.username || !b.password) return json({ error: 'username + password nötig.' }, 400);
const exists = await one('SELECT id FROM users WHERE username=$1', [b.username.trim()]);
if (exists) return json({ error: 'Benutzername vergeben.' }, 400);
const row = await one(`INSERT INTO users (username, role, display_name, password_hash)
VALUES ($1,$2,$3,$4) RETURNING id, username, role, display_name`,
[b.username.trim(), b.role === 'admin' ? 'admin' : 'user', b.display_name || b.username.trim(), await hashPassword(b.password)]);
return json({ user: row });
};
+20
View File
@@ -0,0 +1,20 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const row = await one(`UPDATE folders SET name=COALESCE($2,name), picdrop_gallery=$3 WHERE id=$1 RETURNING *`,
[params.id, b.name?.trim() || null, b.picdrop_gallery ?? null]);
return json({ folder: row });
};
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
await query('DELETE FROM folders WHERE id=$1', [params.id]);
return json({ ok: true });
};
+22
View File
@@ -0,0 +1,22 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const rows = await query(`SELECT f.*, (SELECT count(*) FROM items WHERE folder_id=f.id)::int AS count
FROM folders f ORDER BY f.name`);
return json({ folders: rows });
};
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
if (!b.name?.trim()) return json({ error: 'Name fehlt.' }, 400);
const row = await one(`INSERT INTO folders (name, picdrop_gallery) VALUES ($1,$2) RETURNING *`,
[b.name.trim(), b.picdrop_gallery || null]);
return json({ folder: row });
};
+27
View File
@@ -0,0 +1,27 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { deleteObject } from '../../../lib/storage';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const sets: string[] = []; const args: any[] = [];
if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); }
if ('folder_id' in b) { args.push(b.folder_id || null); sets.push(`folder_id=$${args.length}`); }
if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400);
args.push(params.id);
const row = await one(`UPDATE items SET ${sets.join(',')} WHERE id=$${args.length} RETURNING id, filename, folder_id`, args);
return json({ item: row });
};
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const it = await one<{ result_path: string | null }>('SELECT result_path FROM items WHERE id=$1', [params.id]);
if (it?.result_path) await deleteObject(it.result_path).catch(() => {});
await query('DELETE FROM items WHERE id=$1', [params.id]);
return json({ ok: true });
};
+9
View File
@@ -0,0 +1,9 @@
---
import Base from '../layouts/Base.astro';
import LibraryApp from '../components/LibraryApp.tsx';
---
<Base title="Bibliothek · Klarbild">
<h1 class="seiten-titel">Bibliothek</h1>
<LibraryApp client:load />
<style>.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 16px;letter-spacing:-.02em;}</style>
</Base>