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