feat: single-image transform, NAS/FTP in delivery, backup-all any target, metadata sidecar, prompt view, preset manager, API token + MCP server

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 21:29:43 +00:00
parent 5cfafb456c
commit 295f0ce91e
19 changed files with 479 additions and 69 deletions
+37 -3
View File
@@ -33,6 +33,21 @@ export default function AdminApp() {
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());
@@ -59,6 +74,7 @@ export default function AdminApp() {
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,
};
@@ -201,6 +217,8 @@ export default function AdminApp() {
<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>
@@ -237,15 +255,18 @@ export default function AdminApp() {
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Weitere Ausgabeziele (FTP/SFTP)</span></div>
<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 (Rezept) oder Ordner kann fest auf ein Ziel zeigen; sonst gilt das Standard-Picdrop oben.</div>
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> <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
<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>
@@ -268,10 +289,23 @@ export default function AdminApp() {
<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 &lt;Token&gt;</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">
+16 -1
View File
@@ -2,7 +2,7 @@ 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; nas_status: string;
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; created_at: string; }
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; prompt_used: string | null; created_at: string; }
export default function LibraryApp() {
const [items, setItems] = useState<Item[]>([]);
@@ -12,6 +12,7 @@ export default function LibraryApp() {
const [pick, setPick] = useState<Set<string>>(new Set());
const [variantSel, setVariantSel] = useState<Record<string, string>>({});
const [compare, setCompare] = useState<Item | null>(null);
const [promptOf, setPromptOf] = useState<Item | null>(null);
const [toast, setToast] = useState<string | null>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
@@ -176,6 +177,7 @@ export default function LibraryApp() {
{v.nas_status === 'failed' && <span className="dbadge err">NAS </span>}
</div>
<div className="reihe knapp">
{v.prompt_used && <button className="mini" onClick={() => setPromptOf(v)}>Prompt</button>}
{v.mode !== 'generate' && <button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>}
<a className="mini" href={`/api/items/${v.id}/file?download=1`}>Laden</a>
<button className="mini stark2" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiterbearbeiten</button>
@@ -203,6 +205,16 @@ export default function LibraryApp() {
<div className="lupe-fuss">{compare.filename} · {compare.output_px}px Regler: links {compare.mode === 'compose' ? 'Vorlage' : 'Original'}, rechts Ergebnis</div>
</div>
)}
{promptOf && (
<div className="lupe" onClick={() => setPromptOf(null)}>
<div className="promptbox" onClick={(e) => e.stopPropagation()}>
<div className="pb-kopf"><b>{promptOf.filename}</b>
<button className="mini" onClick={() => { navigator.clipboard?.writeText(promptOf.prompt_used || ''); notify('Prompt kopiert.'); }}>Kopieren</button></div>
<pre className="pb-text">{promptOf.prompt_used}</pre>
<div className="fein">{promptOf.model_used ? `Modell: ${modelName(promptOf.model_used)} · ` : ''}{promptOf.output_px}px</div>
</div>
</div>
)}
{toast && <div className="toast">{toast}</div>}
<LibStyles />
</div>
@@ -254,6 +266,9 @@ function LibStyles() {
.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;}
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
.pb-kopf{display:flex;justify-content:space-between;align-items:center;gap:10px;}
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
.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>;
}
+48 -20
View File
@@ -33,8 +33,8 @@ const TASKS = [
type Mode = 'each' | 'compose' | 'generate';
const MODES: { id: Mode; name: string; hint: string }[] = [
{ id: 'each', name: 'Bearbeiten', hint: 'Screenshots bereinigen, freistellen, aufs Format bringen.' },
{ id: 'compose', name: 'Kombinieren', hint: 'Mehrere Bilder + Beschreibung zu einem neuen Bild.' },
{ id: 'each', name: 'Bereinigen', hint: 'Screenshots bereinigen, freistellen, aufs Format bringen.' },
{ id: 'compose', name: 'Umwandeln', hint: 'Ein Bild umwandeln (z. B. Foto → Ölgemälde) oder mehrere kombinieren — mit Text.' },
{ id: 'generate', name: 'Neu erzeugen', hint: 'Ein komplett neues Bild allein aus Text.' },
];
@@ -43,6 +43,7 @@ const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi);
interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean }
export default function StudioApp({ recipes }: { recipes: any[] }) {
const [presets, setPresets] = useState<any[]>(recipes || []);
const [mode, setMode] = useState<Mode>('each');
const [pics, setPics] = useState<Pic[]>([]);
const [tasks, setTasks] = useState<string[]>(['clean', 'format']);
@@ -56,6 +57,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
const [gallery, setGallery] = useState('');
const [targetId, setTargetId] = useState('');
const [targets, setTargets] = useState<any[]>([]);
const [presetSel, setPresetSel] = useState('');
const [models, setModels] = useState<any[]>([]);
const [modelKey, setModelKey] = useState<string>('');
const [dragging, setDragging] = useState(false);
@@ -117,7 +119,27 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
if (def) setModelKey(def.model_id);
}).catch(() => {});
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
loadPresets();
}, []);
const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {});
const saveAsPreset = async () => {
const name = prompt('Name des Presets?'); if (!name?.trim()) return;
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []);
const body = {
name: name.trim(), mode, tasks: tasksOut, output_format: wantsFormat ? format : 'keep',
orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300,
contour_mm: tasks.includes('contour') ? contourMm : null,
custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null,
delivery, picdrop_gallery: gallery.trim() || null, delivery_target_id: targetId || null,
};
await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
notify('Preset gespeichert.'); loadPresets();
};
const deletePreset = async (id: string) => {
if (!id || !confirm('Preset löschen?')) return;
await fetch(`/api/recipes/${id}`, { method: 'DELETE' }); notify('Preset gelöscht.'); loadPresets();
};
// Ergebnis aus der Bibliothek übernehmen: ?reuse=<itemId> lädt es als Vorlage.
useEffect(() => {
@@ -148,6 +170,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
function applyRecipe(r: any) {
if (!r) return;
if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode);
setTasks(r.tasks || ['clean', 'format']);
setFormat(r.output_format || 'keep');
setPortrait((r.orientation || 'portrait') !== 'landscape');
@@ -163,7 +186,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
const ready = pics.filter((p) => p.source_path && !p.error);
const canRun = !busy && (
mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout))
: mode === 'compose' ? (ready.length >= 2 && desc.trim().length > 0)
: mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0)
: desc.trim().length > 0
);
@@ -199,7 +222,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
}
const uploadHint = mode === 'compose'
? 'Mindestens 2 Bilder — z. B. das Motiv und ein Foto von Frieda.'
? 'Ein Bild umwandeln oder mehrere kombinieren (z. B. Motiv + Foto von Frieda).'
: 'Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.';
return (
@@ -252,25 +275,28 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
<section className="karte">
<div className="steuer">
{mode === 'each' && recipes?.length > 0 && (
<div className="feld">
<label>Rezept laden</label>
<select className="select" onChange={(e) => applyRecipe(recipes.find((r) => r.id === e.target.value))} defaultValue="">
<option value="" disabled>Voreinstellung wählen </option>
{recipes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
<div className="feld">
<label>Presets</label>
<div className="reihe-presets">
<select className="select" value={presetSel}
onChange={(e) => { setPresetSel(e.target.value); applyRecipe(presets.find((r) => r.id === e.target.value)); }}>
<option value="">Preset wählen </option>
{presets.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
{presetSel && <button className="pbtn" title="Preset löschen" onClick={() => { deletePreset(presetSel); setPresetSel(''); }}></button>}
<button className="pbtn" title="Aktuelle Einstellungen als Preset speichern" onClick={saveAsPreset}> speichern</button>
</div>
)}
</div>
{(mode === 'compose' || mode === 'generate') && (
<div className="feld">
<label>{mode === 'compose' ? 'Was soll entstehen?' : 'Beschreibung des neuen Bildes'}</label>
<textarea className="area gross" rows={4} value={desc} onChange={(e) => setDesc(e.target.value)}
placeholder={mode === 'compose'
? 'z. B. „Das Poolbild, aber mit unserer Hündin Frieda am Beckenrand.“'
? 'z. B. „Lass das Foto wie ein Ölgemälde aussehen.“ oder „…​mit unserer Hündin Frieda am Beckenrand.“'
: 'z. B. „Ein minimalistisches Poster mit einem Olivenzweig auf sandfarbenem Grund.“'} />
<div className="fein">{mode === 'compose'
? 'Das erste Bild ist die Leitszene, weitere liefern Personen/Motive.'
? 'Ein Bild = umwandeln (z. B. Stil ändern). Mehrere Bilder = erstes ist Leitszene, weitere liefern Personen/Motive.'
: 'Je genauer die Beschreibung, desto besser das Ergebnis.'}</div>
</div>
)}
@@ -358,12 +384,11 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
</div>
{delivery !== 'library' && (
<div className="ziel">
{targets.length > 0 && (
<select className="select" value={targetId} onChange={(e) => setTargetId(e.target.value)}>
<option value="">Standard-Picdrop</option>
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
)}
<select className="select" value={targetId} onChange={(e) => setTargetId(e.target.value)}>
<option value="">Standard-Picdrop</option>
<option value="nas">NAS</option>
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<input className="input" value={gallery} onChange={(e) => setGallery(e.target.value)}
placeholder="Galerie/Ordner (leer = Standard)" />
</div>
@@ -373,7 +398,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
<button className="knopf" disabled={!canRun} onClick={run}>
{busy ? <><span className="spin" />Wird angelegt …</>
: mode === 'each' ? `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`
: mode === 'compose' ? 'Bild kombinieren' : 'Bild erzeugen'}
: 'Bild erzeugen'}
</button>
<div className="fein mitte">Läuft serverseitig weiter — du kannst das Fenster schließen.</div>
</div>
@@ -433,6 +458,9 @@ function StudioStyles() {
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
.schalter.zart button{font-size:12.5px;}
.ziel{display:flex;flex-direction:column;gap:7px;margin-top:8px;}
.reihe-presets{display:flex;gap:6px;align-items:center;}
.reihe-presets .select{flex:1;}
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
.schalter.wrap{flex-wrap:wrap;}
.schalter.wrap button{flex:1 1 auto;min-width:110px;}
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}