feat: model selection+visibility, better filenames, picdrop direct upload + configurable gallery

- studio: quality/model selector (friendly names from active models); library shows model used
- seed 2 models (Schnell/Beste Qualitaet); GET /api/models for studio+library
- filename: YYYY-MM-DD_motif_format.png (was redundant slug, no date)
- picdrop: direct upload to final name (fix .tmp- leftover from failed rename on picdrop); cleanupTmp helper
- configurable default gallery (migration 003, settings + admin field); base_path=SFTP root, gallery=subfolder
This commit is contained in:
2026-07-23 18:54:37 +00:00
parent b047f55687
commit 6d58c6c8fd
12 changed files with 120 additions and 28 deletions
+6 -2
View File
@@ -37,7 +37,7 @@ export default function AdminApp() {
const saveSettings = async () => {
const body: any = {
picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port,
picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path,
picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path, picdrop_default_gallery: s.picdrop_default_gallery,
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,
};
@@ -126,7 +126,11 @@ export default function AdminApp() {
</div>
<div className="feld"><label>Passwort {s.picdrop_password_set && <em>(gesetzt)</em>}</label>
<input className="input" type="password" placeholder={s.picdrop_password_set ? '••••••' : ''} value={pdPw} onChange={(e) => setPdPw(e.target.value)} /></div>
<div className="feld"><label>Basisordner</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div>
<div className="zwei">
<div className="feld"><label>Basisordner (SFTP-Wurzel)</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div>
<div className="feld"><label>Standard-Galerie</label><input className="input" placeholder="POSTER LEA" value={s.picdrop_default_gallery ?? ''} onChange={(e) => field('picdrop_default_gallery', e.target.value)} /></div>
</div>
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> z. B. <code>/ + POSTER LEA</code>. Pro Ordner lässt sich später eine eigene Galerie mappen.</div>
</div>
</section>
+7 -4
View File
@@ -1,22 +1,25 @@
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; }
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; model_used: string; }
export default function LibraryApp() {
const [items, setItems] = useState<Item[]>([]);
const [folders, setFolders] = useState<any[]>([]);
const [models, setModels] = useState<any[]>([]);
const modelLabel = (id: string) => models.find((m) => m.model_id === id)?.label || null;
const [sel, setSel] = useState<Set<string>>(new Set());
const [compare, setCompare] = useState<Item | null>(null);
const [toast, setToast] = useState<string | null>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
const load = useCallback(async () => {
const [i, f] = await Promise.all([
const [i, f, m] = await Promise.all([
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })),
]);
setItems(i.items || []); setFolders(f.folders || []);
setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
}, []);
useEffect(() => { load(); }, [load]);
@@ -67,7 +70,7 @@ export default function LibraryApp() {
<span className="haken">{sel.has(v.id) ? '✓' : ''}</span>
</div>
<input className="name" defaultValue={v.filename || ''} onBlur={(e) => rename(v.id, e.target.value)} />
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelLabel(v.model_used) ? ` · ${modelLabel(v.model_used)}` : ''}
{v.delivery_status === 'delivered' && <span className="dbadge ok">Picdrop </span>}
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop </span>}
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop </span>}
+27 -1
View File
@@ -44,6 +44,8 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
const [contourMm, setContourMm] = useState(3);
const [custom, setCustom] = useState('');
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
const [models, setModels] = useState<any[]>([]);
const [modelKey, setModelKey] = useState<string>('');
const [dragging, setDragging] = useState(false);
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<string | null>(null);
@@ -92,6 +94,14 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
return () => window.removeEventListener('paste', onPaste);
}, [upload]);
useEffect(() => {
fetch('/api/models').then((r) => r.json()).then((j) => {
const ms = j.models || []; setModels(ms);
const def = ms.find((m: any) => m.is_default) || ms[0];
if (def) setModelKey(def.model_id);
}).catch(() => {});
}, []);
function toggleTask(id: string) {
setTasks((t) => {
let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id];
@@ -110,6 +120,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
setContourMm(Number(r.contour_mm) || 3);
setCustom(r.custom_instruction || '');
setDelivery(r.delivery || 'library');
if (r.model_key) setModelKey(r.model_key);
}
const ready = pics.filter((p) => p.source_path && !p.error);
@@ -125,7 +136,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
orientation: portrait ? 'portrait' : 'landscape',
crop_mode: crop, dpi: 300,
contour_mm: tasks.includes('contour') ? contourMm : null,
custom_instruction: custom || null, delivery,
custom_instruction: custom || null, delivery, model_key: modelKey || null,
};
try {
const res = await fetch('/api/jobs', {
@@ -234,6 +245,19 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
</div>
)}
{models.length > 0 && (
<div className="feld">
<label>Qualität</label>
<div className="schalter wrap">
{models.map((m) => (
<button key={m.model_id} className={modelKey === m.model_id ? 'an' : ''}
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
))}
</div>
<div className="fein">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
</div>
)}
<div className="feld">
<label>Eigene Anweisung (optional)</label>
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
@@ -300,6 +324,8 @@ 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;}
.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;}
.knopf:disabled{background:#C9C8BF;cursor:not-allowed;}
.spin{width:13px;height:13px;display:inline-block;border:2px solid #fff;border-top-color:transparent;border-radius:50%;vertical-align:-2px;margin-right:9px;animation:sp 1s linear infinite;}