diff --git a/migrations/007_delivery_targets.sql b/migrations/007_delivery_targets.sql new file mode 100644 index 0000000..1f1ef86 --- /dev/null +++ b/migrations/007_delivery_targets.sql @@ -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; diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index af68200..ea39a02 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -10,20 +10,34 @@ export default function AdminApp() { const [toast, setToast] = useState(null); const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState(''); const [storage, setStorage] = useState(null); + const [targets, setTargets] = useState([]); + const [nt, setNt] = useState({ 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() { +
+
Weitere Ausgabeziele (FTP/SFTP)
+
+
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.
+
+ {targets.map((t) => ( +
+
{t.name} {t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}
+
+ + +
+
+ ))} + {targets.length === 0 &&
Noch keine zusätzlichen Ziele.
} +
+
+
setNt({ ...nt, name: e.target.value })} />
+
+
+
+
+
setNt({ ...nt, host: e.target.value })} />
+
setNt({ ...nt, port: e.target.value })} />
+
+
+
setNt({ ...nt, username: e.target.value })} />
+
setNt({ ...nt, password: e.target.value })} />
+
+
setNt({ ...nt, base_path: e.target.value })} />
+ +
+
+
diff --git a/src/components/StudioApp.tsx b/src/components/StudioApp.tsx index 62d48a8..4ad667d 100644 --- a/src/components/StudioApp.tsx +++ b/src/components/StudioApp.tsx @@ -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([]); const [models, setModels] = useState([]); const [modelKey, setModelKey] = useState(''); 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= 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[] }) { + {delivery !== 'library' && ( +
+ {targets.length > 0 && ( + + )} + setGallery(e.target.value)} + placeholder="Galerie/Ordner (leer = Standard)" /> +
+ )}