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
+21
View File
@@ -0,0 +1,21 @@
-- Mehrere Auslieferungsziele (FTP/SFTP-Quellen), z. B. eine zweite Picdrop-Galerie
-- oder ein anderer FTP-Server. Rezepte/Presets können ein Ziel fest verdrahten.
CREATE TABLE IF NOT EXISTS delivery_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
protocol text CHECK (protocol IN ('ftps','sftp')),
host text,
port int,
username text,
password_enc text,
base_path text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Preset → festes Ziel (NULL = Standard-Picdrop aus den Einstellungen).
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS delivery_target_id uuid
REFERENCES delivery_targets(id) ON DELETE SET NULL;
-- Ordner → festes Ziel (optional).
ALTER TABLE folders ADD COLUMN IF NOT EXISTS delivery_target_id uuid
REFERENCES delivery_targets(id) ON DELETE SET NULL;
+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;}
+15 -3
View File
@@ -1,6 +1,6 @@
import { one, query } from './db';
import { getObject } from './storage';
import { loadConfig, uploadBuffer } from './picdrop';
import { loadConfig, loadTargetConfig, uploadBuffer, type PicdropCfg } from './picdrop';
// Standard-Galerie wie initial gebrieft.
const DEFAULT_GALLERY = process.env.PICDROP_DEFAULT_GALLERY || 'POSTER LEA';
@@ -17,12 +17,24 @@ async function galleryFor(item: { folder_id: string | null; job_id: string }): P
return s?.picdrop_default_gallery || DEFAULT_GALLERY;
}
/** Ziel-Zugang: 1) Ordner-Ziel 2) Rezept-Ziel 3) Standard-Picdrop (Einstellungen). */
async function targetFor(item: { folder_id: string | null; job_id: string }): Promise<PicdropCfg | null> {
if (item.folder_id) {
const f = await one<{ delivery_target_id: string | null }>('SELECT delivery_target_id FROM folders WHERE id=$1', [item.folder_id]).catch(() => null);
if (f?.delivery_target_id) { const c = await loadTargetConfig(f.delivery_target_id); if (c) return c; }
}
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
const tid = j?.recipe_snapshot?.delivery_target_id;
if (tid) { const c = await loadTargetConfig(tid); if (c) return c; }
return loadConfig(); // Standard-Picdrop
}
/** Liefert ein fertiges Item an die passende Picdrop-Galerie aus. */
export async function deliverItem(itemId: string): Promise<{ ok: boolean; message: string }> {
const it = await one<any>('SELECT id, result_path, filename, folder_id, job_id FROM items WHERE id=$1', [itemId]);
if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' };
const cfg = await loadConfig();
if (!cfg) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Picdrop nicht konfiguriert.' }; }
const cfg = await targetFor(it);
if (!cfg) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Kein Auslieferungsziel konfiguriert.' }; }
await query(`UPDATE items SET delivery_status='pending' WHERE id=$1`, [itemId]);
try {
+14
View File
@@ -22,6 +22,20 @@ export async function loadConfig(): Promise<PicdropCfg | null> {
};
}
/** Zusätzliches Auslieferungsziel aus delivery_targets. */
export async function loadTargetConfig(id: string): Promise<PicdropCfg | null> {
const s = await one<any>(`SELECT protocol, host, port, username, password_enc, base_path
FROM delivery_targets WHERE id=$1`, [id]);
if (!s?.host || !s?.username || !s?.password_enc) return null;
let password = '';
try { password = decrypt(s.password_enc); } catch { return null; }
return {
host: s.host, protocol: (s.protocol || 'sftp'),
port: s.port || (s.protocol === 'ftps' ? 21 : 22),
user: s.username, password, basePath: s.base_path || '/',
};
}
/** Verbindung testen: verbinden + Basisordner listen. */
export async function testConnection(cfg: PicdropCfg): Promise<{ ok: boolean; message: string }> {
try {
+7 -1
View File
@@ -48,8 +48,14 @@ export async function seed(): Promise<void> {
await R('Nur bereinigen', ['clean'], 'keep', 'landscape', { is_default: true });
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
await R('The Frame', ['clean', 'format'], 'theframe', 'landscape');
await R('The Frame', ['clean', 'format'], 'theframe', 'landscape',
{ delivery: 'both', picdrop_gallery: 'TheFrame-Backgrounds' });
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape',
{ contour_mm: 3 });
}
// The-Frame-Rezept auf die eigene Galerie verdrahten (auch für bestehende Installationen).
await query(
`UPDATE recipes SET picdrop_gallery='TheFrame-Backgrounds', delivery='both'
WHERE output_format='theframe' AND (picdrop_gallery IS NULL OR picdrop_gallery='')`);
}
+55
View File
@@ -0,0 +1,55 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { encrypt } from '../../../lib/crypto';
import { loadTargetConfig, 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 GET: APIRoute = async () => {
const rows = await query<any>(`SELECT id, name, protocol, host, port, username, base_path,
(password_enc IS NOT NULL) AS password_set FROM delivery_targets ORDER BY name`);
return json({ targets: rows });
};
// POST: anlegen | { action:'test', id } testen
export const POST: APIRoute = async ({ request }) => {
const b = await request.json();
if (b.action === 'test') {
const cfg = await loadTargetConfig(b.id);
if (!cfg) return json({ ok: false, message: 'Ziel unvollständig konfiguriert.' });
return json(await testConnection(cfg));
}
if (!b.name?.trim()) return json({ error: 'Name fehlt.' }, 400);
const row = await one<any>(
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
[b.name.trim(), b.protocol || 'sftp', b.host || null, b.port || null, b.username || null,
b.password ? encrypt(String(b.password)) : null, b.base_path || null]);
return json({ id: row!.id });
};
// PATCH: bearbeiten
export const PATCH: APIRoute = async ({ request }) => {
const b = await request.json();
if (!b.id) return json({ error: 'Keine ID.' }, 400);
const sets: string[] = []; const args: any[] = [];
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
for (const c of ['name', 'protocol', 'host', 'port', 'username', 'base_path']) {
if (c in b) set(c, b[c] === '' ? null : b[c]);
}
if (b.password) set('password_enc', encrypt(String(b.password)));
if (!sets.length) return json({ ok: true });
args.push(b.id);
await query(`UPDATE delivery_targets SET ${sets.join(',')} WHERE id=$${args.length}`, args);
return json({ ok: true });
};
export const DELETE: APIRoute = async ({ request, url }) => {
let id = url.searchParams.get('id');
if (!id) { try { id = (await request.json())?.id; } catch { /* egal */ } }
if (!id) return json({ error: 'Keine ID.' }, 400);
await query('DELETE FROM delivery_targets WHERE id=$1', [id]);
return json({ ok: true });
};
+13
View File
@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro';
import { 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' } });
// Namen der Zusatzziele fürs Studio-Dropdown (kein Zugang).
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const rows = await query('SELECT id, name FROM delivery_targets ORDER BY name');
return json({ targets: rows });
};
+1
View File
@@ -49,6 +49,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
prompt_text: promptText || null,
delivery: snapshot.delivery || 'library',
picdrop_gallery: snapshot.picdrop_gallery ?? null,
delivery_target_id: snapshot.delivery_target_id ?? null,
};
const total = (mode === 'each') ? sources.length : 1;