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
+2
View File
@@ -0,0 +1,2 @@
-- Konfigurierbare Standard-Picdrop-Galerie (statt fest verdrahtet).
ALTER TABLE settings ADD COLUMN IF NOT EXISTS picdrop_default_gallery text;
+6 -2
View File
@@ -37,7 +37,7 @@ export default function AdminApp() {
const saveSettings = async () => { const saveSettings = async () => {
const body: any = { const body: any = {
picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port, 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, 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, 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>
<div className="feld"><label>Passwort {s.picdrop_password_set && <em>(gesetzt)</em>}</label> <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> <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> </div>
</section> </section>
+7 -4
View File
@@ -1,22 +1,25 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
interface Item { id: string; filename: string; output_px: string; has_alpha: boolean; 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() { export default function LibraryApp() {
const [items, setItems] = useState<Item[]>([]); const [items, setItems] = useState<Item[]>([]);
const [folders, setFolders] = useState<any[]>([]); 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 [sel, setSel] = useState<Set<string>>(new Set());
const [compare, setCompare] = useState<Item | null>(null); const [compare, setCompare] = useState<Item | null>(null);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); }; const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
const load = useCallback(async () => { 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/items').then((r) => r.json()).catch(() => ({ items: [] })),
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })), 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]); useEffect(() => { load(); }, [load]);
@@ -67,7 +70,7 @@ export default function LibraryApp() {
<span className="haken">{sel.has(v.id) ? '✓' : ''}</span> <span className="haken">{sel.has(v.id) ? '✓' : ''}</span>
</div> </div>
<input className="name" defaultValue={v.filename || ''} onBlur={(e) => rename(v.id, e.target.value)} /> <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 === 'delivered' && <span className="dbadge ok">Picdrop </span>}
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop </span>} {v.delivery_status === 'pending' && <span className="dbadge">Picdrop </span>}
{v.delivery_status === 'failed' && <span className="dbadge err">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 [contourMm, setContourMm] = useState(3);
const [custom, setCustom] = useState(''); const [custom, setCustom] = useState('');
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library'); const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
const [models, setModels] = useState<any[]>([]);
const [modelKey, setModelKey] = useState<string>('');
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
@@ -92,6 +94,14 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
return () => window.removeEventListener('paste', onPaste); return () => window.removeEventListener('paste', onPaste);
}, [upload]); }, [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) { function toggleTask(id: string) {
setTasks((t) => { setTasks((t) => {
let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id]; 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); setContourMm(Number(r.contour_mm) || 3);
setCustom(r.custom_instruction || ''); setCustom(r.custom_instruction || '');
setDelivery(r.delivery || 'library'); setDelivery(r.delivery || 'library');
if (r.model_key) setModelKey(r.model_key);
} }
const ready = pics.filter((p) => p.source_path && !p.error); 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', orientation: portrait ? 'portrait' : 'landscape',
crop_mode: crop, dpi: 300, crop_mode: crop, dpi: 300,
contour_mm: tasks.includes('contour') ? contourMm : null, contour_mm: tasks.includes('contour') ? contourMm : null,
custom_instruction: custom || null, delivery, custom_instruction: custom || null, delivery, model_key: modelKey || null,
}; };
try { try {
const res = await fetch('/api/jobs', { const res = await fetch('/api/jobs', {
@@ -234,6 +245,19 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
</div> </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"> <div className="feld">
<label>Eigene Anweisung (optional)</label> <label>Eigene Anweisung (optional)</label>
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)} <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{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 button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
.schalter.zart button{font-size:12.5px;} .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{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;} .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;} .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;}
+4 -2
View File
@@ -6,13 +6,15 @@ import { loadConfig, uploadBuffer } from './picdrop';
const DEFAULT_GALLERY = process.env.PICDROP_DEFAULT_GALLERY || 'POSTER LEA'; const DEFAULT_GALLERY = process.env.PICDROP_DEFAULT_GALLERY || 'POSTER LEA';
async function galleryFor(item: { folder_id: string | null; job_id: string }): Promise<string> { async function galleryFor(item: { folder_id: string | null; job_id: string }): Promise<string> {
// 1) Ordner-Mapping 2) Rezept 3) konfigurierte Standard-Galerie 4) Fallback-Konstante
if (item.folder_id) { if (item.folder_id) {
const f = await one<{ picdrop_gallery: string | null }>('SELECT picdrop_gallery FROM folders WHERE id=$1', [item.folder_id]); const f = await one<{ picdrop_gallery: string | null }>('SELECT picdrop_gallery FROM folders WHERE id=$1', [item.folder_id]);
if (f?.picdrop_gallery) return f.picdrop_gallery; if (f?.picdrop_gallery) return f.picdrop_gallery;
} }
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]); const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
const g = j?.recipe_snapshot?.picdrop_gallery; if (j?.recipe_snapshot?.picdrop_gallery) return j.recipe_snapshot.picdrop_gallery;
return g || DEFAULT_GALLERY; const s = await one<{ picdrop_default_gallery: string | null }>('SELECT picdrop_default_gallery FROM settings WHERE id=1');
return s?.picdrop_default_gallery || DEFAULT_GALLERY;
} }
/** Liefert ein fertiges Item an die passende Picdrop-Galerie aus. */ /** Liefert ein fertiges Item an die passende Picdrop-Galerie aus. */
+20 -6
View File
@@ -44,11 +44,11 @@ export async function testConnection(cfg: PicdropCfg): Promise<{ ok: boolean; me
} }
} }
/** Lädt einen Puffer als Datei hoch (temp-Name → umbenennen, kein .filepart). */ /** Lädt einen Puffer direkt unter dem finalen Namen hoch.
* (Picdrop verträgt das Temp→Rename-Muster nicht — es blieben `.tmp-…`-Dateien liegen.) */
export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: string, buf: Buffer): Promise<void> { export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: string, buf: Buffer): Promise<void> {
const dir = posixpath.join(cfg.basePath || '/', gallery || ''); const dir = posixpath.join(cfg.basePath || '/', gallery || '');
const finalPath = posixpath.join(dir, filename); const finalPath = posixpath.join(dir, filename);
const tmpPath = posixpath.join(dir, `.tmp-${Date.now()}-${filename}`);
const { Readable } = await import('node:stream'); const { Readable } = await import('node:stream');
if (cfg.protocol === 'sftp') { if (cfg.protocol === 'sftp') {
@@ -57,8 +57,7 @@ export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: s
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 }); await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
try { try {
if (!(await c.exists(dir))) await c.mkdir(dir, true); if (!(await c.exists(dir))) await c.mkdir(dir, true);
await c.put(buf, tmpPath); await c.put(buf, finalPath);
await c.rename(tmpPath, finalPath);
} finally { await c.end(); } } finally { await c.end(); }
} else { } else {
const { Client } = await import('basic-ftp'); const { Client } = await import('basic-ftp');
@@ -66,8 +65,23 @@ export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: s
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true }); await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
try { try {
await c.ensureDir(dir); await c.ensureDir(dir);
await c.uploadFrom(Readable.from(buf), tmpPath); await c.uploadFrom(Readable.from(buf), finalPath);
await c.rename(tmpPath, finalPath);
} finally { c.close(); } } finally { c.close(); }
} }
} }
/** Löscht `.tmp-…`-Altlasten aus einer Galerie (einmalige Aufräumhilfe). */
export async function cleanupTmp(cfg: PicdropCfg, gallery: string): Promise<number> {
const dir = posixpath.join(cfg.basePath || '/', gallery || '');
let n = 0;
if (cfg.protocol === 'sftp') {
const SftpClient = (await import('ssh2-sftp-client')).default;
const c = new SftpClient();
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
try {
const list = await c.list(dir);
for (const e of list) if (e.name.startsWith('.tmp-')) { await c.delete(posixpath.join(dir, e.name)); n++; }
} finally { await c.end(); }
}
return n;
}
+24 -6
View File
@@ -82,13 +82,31 @@ export async function heicToPng(buffer: Buffer): Promise<Buffer> {
return Buffer.from(out); return Buffer.from(out);
} }
/** Deutscher Datei-Slug: klein, mit Bindestrichen, Format angehängt. */ /** Klein, mit Bindestrichen, ohne Umlaut-Probleme. */
export function slugFilename(base: string, formatLabel: string, ext = 'png'): string { export function slugify(s: string): string {
const slug = (base || 'bild') return (s || '')
.toLowerCase() .toLowerCase()
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c)) .replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
.normalize('NFKD').replace(/[̀-ͯ]/g, '') .normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'bild'; .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
const fmt = formatLabel.toLowerCase().replace(/[^a-z0-9]+/g, ''); }
return `${slug}${fmt ? '-' + fmt : ''}.${ext}`;
const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']);
/** Logischer Dateiname: JJJJ-MM-TT_<motiv>_<format>.<ext>
* z. B. 2026-07-23_lea_the-frame.png */
export function buildResultFilename(originalName: string | null, formatToken: string, ext = 'png'): string {
const raw = slugify((originalName || '').replace(/\.[^.]+$/, ''));
const motif = GENERIC.has(raw) ? 'klarbild' : raw;
const date = new Date().toISOString().slice(0, 10);
const fmt = slugify(formatToken) || 'original';
return `${date}_${motif}_${fmt}.${ext}`;
}
/** Formatschlüssel für den Dateinamen (aus dem output_format). */
export function formatToken(outputFormat: string | undefined): string {
if (!outputFormat || outputFormat === 'keep') return 'original';
if (outputFormat === 'theframe') return 'the-frame';
if (outputFormat === 'hochformat') return 'hochformat';
return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
} }
+4 -5
View File
@@ -3,7 +3,7 @@ import { getObject, putObject, resultKey } from './storage';
import { generateImage } from './openrouter'; import { generateImage } from './openrouter';
import { buildPrompt, type Task } from './prompts'; import { buildPrompt, type Task } from './prompts';
import { resolveDimensions, aspectRatio, type Orientation } from './format'; import { resolveDimensions, aspectRatio, type Orientation } from './format';
import { finalizeToFormat, stickerContour, slugFilename, type CropMode } from './pipeline'; import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline';
import sharp from 'sharp'; import sharp from 'sharp';
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image'; const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
@@ -46,8 +46,8 @@ export interface ProcessResult { ok: boolean; cost: number; error?: string; }
/** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */ /** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */
export async function processItem(itemId: string): Promise<ProcessResult> { export async function processItem(itemId: string): Promise<ProcessResult> {
const item = await one<{ id: string; job_id: string; source_path: string }>( const item = await one<{ id: string; job_id: string; source_path: string; filename: string | null }>(
'SELECT id, job_id, source_path FROM items WHERE id=$1', [itemId]); 'SELECT id, job_id, source_path, filename FROM items WHERE id=$1', [itemId]);
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' }; if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
const job = await one<{ recipe_snapshot: RecipeSnapshot }>( const job = await one<{ recipe_snapshot: RecipeSnapshot }>(
@@ -105,8 +105,7 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
const key = resultKey(item.id); const key = resultKey(item.id);
await putObject(key, outBuf, 'image/png'); await putObject(key, outBuf, 'image/png');
const label = target?.label || 'original'; const filename = buildResultFilename(item.filename, formatToken(r.output_format), 'png');
const filename = slugFilename(label.replace(/\s+/g, '-'), target?.label || '');
const outputPx = `${fin.width}x${fin.height}`; const outputPx = `${fin.width}x${fin.height}`;
const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none'; const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none';
+10
View File
@@ -5,6 +5,16 @@ import { hashPassword } from './auth';
export async function seed(): Promise<void> { export async function seed(): Promise<void> {
// Einstellungen (eine Zeile) // Einstellungen (eine Zeile)
await query(`INSERT INTO settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`); await query(`INSERT INTO settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`);
// Standard-Galerie vorbelegen (wie initial gebrieft), falls leer
await query(`UPDATE settings SET picdrop_default_gallery='POSTER LEA' WHERE id=1 AND picdrop_default_gallery IS NULL`);
// Modelle vorbelegen (freundliche Namen, keine rohen IDs in der UI)
const mCount = await one<{ n: string }>(`SELECT count(*)::text AS n FROM models`);
if (mCount && Number(mCount.n) === 0) {
await query(`INSERT INTO models (model_id, label, description, active, is_default, supports_alpha, sort) VALUES
('google/gemini-3.1-flash-image','Schnell','In wenigen Sekunden fertig. Für den Alltag.',true,true,true,0),
('google/gemini-3-pro-image','Beste Qualität','Dauert länger, trifft Schrift und Details genauer.',true,false,true,1)`);
}
// Nutzer // Nutzer
const till = await one(`SELECT id FROM users WHERE username='till'`); const till = await one(`SELECT id FROM users WHERE username='till'`);
+2 -1
View File
@@ -15,6 +15,7 @@ export const GET: APIRoute = async () => {
openrouter_key_masked: orMask, openrouter_key_set: !!s.openrouter_key_enc, openrouter_key_masked: orMask, openrouter_key_set: !!s.openrouter_key_enc,
picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port, 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,
picdrop_password_set: !!s.picdrop_password_enc, picdrop_password_set: !!s.picdrop_password_enc,
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency, 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, cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
@@ -29,7 +30,7 @@ export const PATCH: APIRoute = async ({ request }) => {
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key))); if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password))); if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password)));
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path', for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url']) { 'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url']) {
if (col in b) set(col, b[col] === '' ? null : b[col]); if (col in b) set(col, b[col] === '' ? null : b[col]);
} }
if (!sets.length) return json({ ok: true }); if (!sets.length) return json({ ok: true });
+1 -1
View File
@@ -24,7 +24,7 @@ export const GET: APIRoute = async ({ url, locals }) => {
const rows = await query( const rows = await query(
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.folder_id, `SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.folder_id,
i.delivery_status, i.created_at, u.display_name AS by_name, i.delivery_status, i.created_at, i.model_used, u.display_name AS by_name,
j.recipe_snapshot->'tasks' AS tasks j.recipe_snapshot->'tasks' AS tasks
FROM items i FROM items i
JOIN jobs j ON j.id = i.job_id JOIN jobs j ON j.id = i.job_id
+13
View File
@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro';
import { query } from '../../lib/db';
export const prerender = false;
// Aktive Modelle mit freundlichen Namen (für Studio-Auswahl + Ergebnis-Anzeige).
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const models = await query(
`SELECT model_id, label, description, is_default, supports_alpha FROM models
WHERE active ORDER BY sort, label`);
return new Response(JSON.stringify({ models }), { headers: { 'Content-Type': 'application/json' } });
};