feat: multiple delivery targets (FTP/SFTP), per-preset wiring; The Frame -> TheFrame-Backgrounds gallery

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:04:27 +00:00
parent 3a098d708c
commit 36f3c8da22
9 changed files with 199 additions and 6 deletions
+52 -2
View File
@@ -10,20 +10,34 @@ export default function AdminApp() {
const [toast, setToast] = useState<string | null>(null);
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
const [storage, setStorage] = useState<any>(null);
const [targets, setTargets] = useState<any[]>([]);
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
const load = useCallback(async () => {
const [a, b, c, d, e, f] = await Promise.all([
const [a, b, c, d, e, f, g] = await Promise.all([
fetch('/api/admin/settings').then((r) => r.json()),
fetch('/api/admin/stats').then((r) => r.json()),
fetch('/api/admin/models?available=1').then((r) => r.json()),
fetch('/api/admin/users').then((r) => r.json()),
fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })),
fetch('/api/admin/storage').then((r) => r.json()).catch(() => ({ stats: null })),
fetch('/api/admin/delivery-targets').then((r) => r.json()).catch(() => ({ targets: [] })),
]);
setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []);
setUsers(d.users || []); setTgLinks(e.links || []); setStorage(f.stats || null);
setUsers(d.users || []); setTgLinks(e.links || []); setStorage(f.stats || null); setTargets(g.targets || []);
}, []);
const addTarget = async () => {
if (!nt.name?.trim()) { notify('Name fehlt.'); return; }
await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(nt) });
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 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());
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
};
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`;
@@ -222,6 +236,42 @@ export default function AdminApp() {
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Weitere Ausgabeziele (FTP/SFTP)</span></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>
<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 className="reihe">
<button className="mini" onClick={() => testTarget(t.id)}>Test</button>
<button className="mini" onClick={() => delTarget(t.id)}></button>
</div>
</div>
))}
{targets.length === 0 && <div className="fein">Noch keine zusätzlichen Ziele.</div>}
</div>
<div className="zwei">
<div className="feld"><label>Name</label><input className="input" placeholder="z. B. The Frame FTP" value={nt.name ?? ''} onChange={(e) => setNt({ ...nt, name: e.target.value })} /></div>
<div className="feld"><label>Protokoll</label>
<select className="input" value={nt.protocol} onChange={(e) => setNt({ ...nt, protocol: e.target.value })}>
<option value="sftp">SFTP</option><option value="ftps">FTPS</option></select></div>
</div>
<div className="zwei">
<div className="feld"><label>Host</label><input className="input" value={nt.host ?? ''} onChange={(e) => setNt({ ...nt, host: e.target.value })} /></div>
<div className="feld"><label>Port</label><input className="input" type="number" value={nt.port ?? ''} onChange={(e) => setNt({ ...nt, port: e.target.value })} /></div>
</div>
<div className="zwei">
<div className="feld"><label>Benutzer</label><input className="input" value={nt.username ?? ''} onChange={(e) => setNt({ ...nt, username: e.target.value })} /></div>
<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>
<button className="mini" onClick={addTarget}>Ziel hinzufügen</button>
</div>
</section>
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
<section className="karte">
+21
View File
@@ -53,6 +53,9 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
const [custom, setCustom] = useState('');
const [desc, setDesc] = useState('');
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
const [gallery, setGallery] = useState('');
const [targetId, setTargetId] = useState('');
const [targets, setTargets] = useState<any[]>([]);
const [models, setModels] = useState<any[]>([]);
const [modelKey, setModelKey] = useState<string>('');
const [dragging, setDragging] = useState(false);
@@ -113,6 +116,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
const def = ms.find((m: any) => m.is_default) || ms[0];
if (def) setModelKey(def.model_id);
}).catch(() => {});
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
}, []);
// Ergebnis aus der Bibliothek übernehmen: ?reuse=<itemId> lädt es als Vorlage.
@@ -151,6 +155,8 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
setContourMm(Number(r.contour_mm) || 3);
setCustom(r.custom_instruction || '');
setDelivery(r.delivery || 'library');
setGallery(r.picdrop_gallery || '');
setTargetId(r.delivery_target_id || '');
if (r.model_key) setModelKey(r.model_key);
}
@@ -176,6 +182,8 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
contour_mm: tasks.includes('contour') ? contourMm : null,
custom_instruction: mode === 'each' ? (custom || null) : null,
delivery, model_key: modelKey || null,
picdrop_gallery: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null,
delivery_target_id: (delivery !== 'library' && targetId) ? targetId : null,
};
const body: any = { recipe, mode, delivery };
if (mode !== 'each') body.prompt_text = desc.trim();
@@ -348,6 +356,18 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
</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>
)}
<input className="input" value={gallery} onChange={(e) => setGallery(e.target.value)}
placeholder="Galerie/Ordner (leer = Standard)" />
</div>
)}
</div>
<button className="knopf" disabled={!canRun} onClick={run}>
@@ -412,6 +432,7 @@ function StudioStyles() {
.schalter button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
.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;}
.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;}