import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useT, type Dict, type Locale } from '../lib/useT'; /* Formats (01 §5) — cm in portrait convention; screen = fixed pixels */ const FORMATS: { id: string; label: string; cm?: [number, number]; screen?: [number, number] }[] = [ { id: 'keep', label: 'Keep original' }, { id: '9x13', label: '9 × 13 cm', cm: [9, 13] }, { id: '10x15', label: '10 × 15 cm', cm: [10, 15] }, { id: '13x18', label: '13 × 18 cm', cm: [13, 18] }, { id: '15x20', label: '15 × 20 cm', cm: [15, 20] }, { id: '20x30', label: '20 × 30 cm', cm: [20, 30] }, { id: '30x40', label: '30 × 40 cm', cm: [30, 40] }, { id: '30x45', label: '30 × 45 cm', cm: [30, 45] }, { id: '40x50', label: '40 × 50 cm', cm: [40, 50] }, { id: '40x60', label: '40 × 60 cm', cm: [40, 60] }, { id: '50x70', label: '50 × 70 cm', cm: [50, 70] }, { id: '60x90', label: '60 × 90 cm', cm: [60, 90] }, { id: 'A4', label: 'DIN A4', cm: [21, 29.7] }, { id: 'A3', label: 'DIN A3', cm: [29.7, 42] }, { id: 'A2', label: 'DIN A2', cm: [42, 59.4] }, { id: '20x20', label: '20 × 20 cm', cm: [20, 20] }, { id: '30x30', label: '30 × 30 cm', cm: [30, 30] }, { id: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] }, { id: 'portrait916', label: 'Portrait (9:16)', screen: [2160, 3840] }, { id: 'sticker', label: 'Sticker (custom size)', cm: [5, 5] }, { id: 'custom', label: 'Custom size (cm)' }, ]; /** Free-text size → format key + cm. "25x35" → 25×35 cm; "5" → 5×5 cm sticker. */ function parseCustomFmt(s: string): { key: string; cm: [number, number] | null } { const v = (s || '').trim().replace(',', '.'); const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/i.exec(v); if (wh) return { key: `${wh[1]}x${wh[2]}`, cm: [parseFloat(wh[1]), parseFloat(wh[2])] }; const n = /^(\d+(?:\.\d+)?)$/.exec(v); if (n) return { key: `sticker${n[1]}`, cm: [parseFloat(n[1]), parseFloat(n[1])] }; return { key: '', cm: null }; } const TASKS = [ { id: 'clean', name: 'Clean up', hint: 'Remove frame, wall and shop interface.' }, { id: 'cutout', name: 'Cut out', hint: 'Subject only, background transparent.' }, { id: 'format', name: 'Format', hint: 'Bring it to a given size.' }, { id: 'contour', name: 'Contour', hint: 'Add a white sticker border (only together with Cut out).' }, ]; type Mode = 'each' | 'compose' | 'generate'; const MODES: { id: Mode; name: string; hint: string }[] = [ { id: 'each', name: 'Clean up', hint: 'Clean up screenshots, cut them out, bring them to size.' }, { id: 'compose', name: 'Convert', hint: 'Convert one image (e.g. photo → oil painting) or combine several — with text.' }, { id: 'generate', name: 'Generate', hint: 'A completely new image from text alone.' }, ]; const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi); interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean } export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; locale?: Locale; dict?: Dict | null }) { const t = useT(dict); const [presets, setPresets] = useState(recipes || []); const [mode, setMode] = useState('each'); const [pics, setPics] = useState([]); const [tasks, setTasks] = useState(['clean', 'format']); const [format, setFormat] = useState('30x40'); const [portrait, setPortrait] = useState(true); const [crop, setCrop] = useState<'crop' | 'extend'>('crop'); const [contourMm, setContourMm] = useState(3); const [customFmt, setCustomFmt] = useState(''); // free-text size for "Custom size"/"Sticker" const [outputExt, setOutputExt] = useState<'' | 'png' | 'jpg'>(''); // '' = the global default setting const [custom, setCustom] = useState(''); const [desc, setDesc] = useState(''); const [delivery, setDelivery] = useState<'library' | 'remote' | 'both'>('library'); const [gallery, setGallery] = useState(''); const [targetId, setTargetId] = useState(''); const [targets, setTargets] = useState([]); const [presetSel, setPresetSel] = useState(''); const [me, setMe] = useState({}); const [priv, setPriv] = useState(false); const [models, setModels] = useState([]); const [modelKey, setModelKey] = useState(''); const [dragging, setDragging] = useState(false); const [busy, setBusy] = useState(false); const [toast, setToast] = useState(null); const fileRef = useRef(null); const fmt = FORMATS.find((f) => f.id === format); const hasCutout = tasks.includes('cutout'); const hasFormat = tasks.includes('format'); const theframeConflict = hasCutout && (format === 'theframe' || format === 'portrait916'); // In compose/generate the size is always available (without the "Format" task). const showFormat = mode === 'each' ? hasFormat : true; const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep'; const isFreeFmt = format === 'custom' || format === 'sticker'; const parsedFmt = isFreeFmt ? parseCustomFmt(customFmt) : { key: '', cm: null }; // "Sticker" without an entry still means 5×5 cm; "Custom size" needs an entry. const effCm: [number, number] | null = fmt?.screen ? null : parsedFmt.cm || (format === 'sticker' ? [5, 5] : (fmt?.cm || null)); // What is actually stored as output_format (e.g. "25x35", "sticker5"). const effFormat = isFreeFmt ? (parsedFmt.key || (format === 'sticker' ? 'sticker' : '')) : format; const targetPx: [number, number] | null = fmt?.screen ? (fmt.screen as [number, number]) : effCm ? (portrait ? [px(effCm[0]), px(effCm[1])] : [px(effCm[1]), px(effCm[0])]) : null; const notify = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2600); }; const upload = useCallback(async (files: FileList | File[]) => { const arr = Array.from(files).filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name)).slice(0, 100); for (const f of arr) { const id = crypto.randomUUID(); const reader = new FileReader(); reader.onload = () => setPics((p) => p.map((x) => x.id === id ? { ...x, src: reader.result as string } : x)); reader.readAsDataURL(f); setPics((p) => [...p, { id, src: '', name: f.name, uploading: true }]); const fd = new FormData(); fd.append('files', f); try { const res = await fetch('/api/uploads', { method: 'POST', body: fd }); const j = await res.json(); const info = j.files?.[0]; setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, source_path: info?.source_path, error: info?.error } : x)); } catch { setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: t('Upload failed') } : x)); } } }, []); useEffect(() => { const onPaste = (e: ClipboardEvent) => { if (mode === 'generate') return; const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/')); if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean)); }; window.addEventListener('paste', onPaste); return () => window.removeEventListener('paste', onPaste); }, [upload, mode]); 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(() => {}); fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {}); fetch('/api/me').then((r) => r.json()).then((j) => { setMe(j); if (j.private_forced) setPriv(true); }).catch(() => {}); loadPresets(); }, []); const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {}); const saveAsPreset = async () => { const name = prompt(t('Name of the preset?')); if (!name?.trim()) return; const order = ['clean', 'cutout', 'format', 'contour', 'deliver']; const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []); const body = { name: name.trim(), mode, tasks: tasksOut, output_format: wantsFormat ? effFormat : 'keep', orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300, contour_mm: tasks.includes('contour') ? contourMm : null, output_ext: outputExt || null, custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null, delivery, delivery_folder: gallery.trim() || null, delivery_target_id: targetId || null, }; await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); notify(t('Preset saved.')); loadPresets(); }; const deletePreset = async (id: string) => { if (!id || !confirm(t('Delete preset?'))) return; await fetch(`/api/recipes/${id}`, { method: 'DELETE' }); notify(t('Preset deleted.')); loadPresets(); }; // Take a result over from the library: ?reuse= loads it as a source. useEffect(() => { const params = new URLSearchParams(window.location.search); const reuseId = params.get('reuse'); if (!reuseId) return; (async () => { const id = crypto.randomUUID(); setPics((p) => [...p, { id, src: `/api/items/${reuseId}/file?thumb=1`, name: 'continue-editing.png', uploading: true }]); try { const j = await fetch(`/api/items/${reuseId}/reuse`, { method: 'POST' }).then((r) => r.json()); setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, source_path: j.source_path, name: j.filename || x.name, error: j.error } : x)); } catch { setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: t('Could not load image') } : x)); } history.replaceState(null, '', '/'); })(); }, []); function toggleTask(id: string) { setTasks((prev) => { let next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]; if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour'); return next; }); } function applyRecipe(r: any) { if (!r) return; if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode); setTasks(r.tasks || ['clean', 'format']); const of = r.output_format || 'keep'; // Get the custom size back out of the recipe (no known key = custom size). if (of !== 'keep' && !FORMATS.some((f) => f.id === of)) { setFormat('custom'); setCustomFmt(/^sticker[-_ ]?/i.test(of) ? of.replace(/^sticker[-_ ]?/i, '') : of); } else setFormat(of); setOutputExt(r.output_ext === 'jpg' || r.output_ext === 'png' ? r.output_ext : ''); setPortrait((r.orientation || 'portrait') !== 'landscape'); setCrop(r.crop_mode === 'extend' ? 'extend' : 'crop'); setContourMm(Number(r.contour_mm) || 3); setCustom(r.custom_instruction || ''); setDelivery(r.delivery || 'library'); setGallery(r.delivery_folder || ''); setTargetId(r.delivery_target_id || ''); if (r.model_key) setModelKey(r.model_key); } const ready = pics.filter((p) => p.source_path && !p.error); // "Custom size" needs a valid entry, otherwise the run cannot start. const freeFmtInvalid = wantsFormat && format === 'custom' && !parsedFmt.cm; const canRun = !busy && !freeFmtInvalid && ( mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout)) : mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0) : desc.trim().length > 0 ); async function run() { if (!canRun) return; setBusy(true); const order = ['clean', 'cutout', 'format', 'contour', 'deliver']; const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []); const recipe = { tasks: tasksOut, output_format: wantsFormat ? effFormat : 'keep', orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300, contour_mm: tasks.includes('contour') ? contourMm : null, output_ext: outputExt || null, custom_instruction: mode === 'each' ? (custom || null) : null, delivery, model_key: modelKey || null, delivery_folder: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null, delivery_target_id: (delivery !== 'library' && targetId) ? targetId : null, }; const body: any = { recipe, mode, delivery, private: priv }; if (mode !== 'each') body.prompt_text = desc.trim(); body.sources = mode === 'generate' ? [] : ready.map((p) => ({ source_path: p.source_path, filename: p.name })); try { const res = await fetch('/api/jobs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const j = await res.json(); if (j.jobId) location.href = `/queue?job=${j.jobId}`; else { notify(j.error || t('Could not create the job.')); setBusy(false); } } catch { notify(t('Network error.')); setBusy(false); } } const uploadHint = mode === 'compose' ? t('Convert one image or combine several (e.g. subject + a photo of the dog).') : t('Several at once, up to 100. PNG, JPG, WEBP, HEIC.'); // The key combination keeps its own styling, so the sentence carries a {keys} // placeholder and is split on it instead of being glued together from fragments. const dropText = t('Drag images here, paste with {keys} or tap to choose.').split('{keys}'); return (
{/* Mode switch */}
{MODES.map((m) => ( ))}
{mode !== 'generate' && (
{mode === 'compose' ? t('Building blocks') : t('Sources')}{pics.length ? ` · ${pics.length}` : ''} {pics.length > 0 && }
{pics.length === 0 ? (
fileRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>

{dropText[0]}⌘/Ctrl + V{dropText[1]}

{uploadHint}

) : (
{pics.map((b) => (
{b.src && } {b.uploading && } {b.error && !}
))}
)} e.target.files && upload(e.target.files)} />
)}
{presetSel && }
{(mode === 'compose' || mode === 'generate') && (