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