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:
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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' } });
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user