Files
klarbild-oss/src/components/StudioApp.tsx
T
tillandClaude Opus 5 e2481139a1
CI / check (push) Has been cancelled
Klarbild 1.0.0 — first public release
A self-hosted print studio: images at exact physical sizes, sheet layout with
crop marks, tiling, 1:1 PDF. Everything else — image generation, delivery,
share links, mail, Telegram — is an optional module that is off by default.

This is the neutral public twin of a private tool. Compared to that original:

- optional modules behind KLARBILD_MODULES; the default install is print-only
- English interface with German as a full second language (1,038 strings);
  the English sentence is the translation key
- no built-in accounts — first boot creates one admin from ADMIN_USERNAME /
  ADMIN_PASSWORD, or generates a password and logs it once
- delivery and backup targets are generic SFTP/FTPS endpoints, off by default
- English routes, English identifiers, English comments throughout
- two compose files: print-only by default, everything in the full one
- AGPL-3.0, CI (build, tests, type check, secret scan), CONTRIBUTING, SECURITY

124 tests green, type check clean, verified end to end against a live instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
2026-09-15 14:52:41 +00:00

548 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<any[]>(recipes || []);
const [mode, setMode] = useState<Mode>('each');
const [pics, setPics] = useState<Pic[]>([]);
const [tasks, setTasks] = useState<string[]>(['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<any[]>([]);
const [presetSel, setPresetSel] = useState('');
const [me, setMe] = useState<any>({});
const [priv, setPriv] = useState(false);
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);
const fileRef = useRef<HTMLInputElement>(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=<itemId> 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 (
<div className="studio">
{/* Mode switch */}
<div className="mode-bar">
{MODES.map((m) => (
<button key={m.id} className={`mode-tab ${mode === m.id ? 'on' : ''}`} onClick={() => setMode(m.id)}>
<b>{t(m.name)}</b><span>{t(m.hint)}</span>
</button>
))}
</div>
<div className="grid">
{mode !== 'generate' && (
<section className="card">
<div className="cardHead">
<span className="mono-label">{mode === 'compose' ? t('Building blocks') : t('Sources')}{pics.length ? ` · ${pics.length}` : ''}</span>
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>{t('remove all')}</button>}
</div>
<div className="stage">
{pics.length === 0 ? (
<div className={`drop ${dragging ? 'drag' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
<span className="regmark" />
<p>{dropText[0]}<span className="kbd">⌘/Ctrl + V</span>{dropText[1]}</p>
<p className="hint">{uploadHint}</p>
</div>
) : (
<div className="mini-grid">
{pics.map((b) => (
<div key={b.id} className={`mini-image ${b.error ? 'err' : ''}`}>
{b.src && <img src={b.src} alt="" />}
{b.uploading && <span className="loading" />}
{b.error && <span className="badge">!</span>}
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
</div>
))}
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
onChange={(e) => e.target.files && upload(e.target.files)} />
</div>
</section>
)}
<section className="card">
<div className="controls">
<div className="field">
<label>{t('Presets')}</label>
<div className="preset-row">
<select className="select" value={presetSel}
onChange={(e) => { setPresetSel(e.target.value); applyRecipe(presets.find((r) => r.id === e.target.value)); }}>
<option value="">{t('Choose a preset …')}</option>
{presets.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
{presetSel && <button className="pbtn" title={t('Delete preset')} onClick={() => { deletePreset(presetSel); setPresetSel(''); }}>✕</button>}
<button className="pbtn" title={t('Save the current settings as a preset')} onClick={saveAsPreset}>+ {t('save')}</button>
</div>
</div>
{(mode === 'compose' || mode === 'generate') && (
<div className="field">
<label>{mode === 'compose' ? t('What should come out of it?') : t('Description of the new image')}</label>
<textarea className="area large" rows={4} value={desc} onChange={(e) => setDesc(e.target.value)}
placeholder={mode === 'compose'
? t('e.g. “Make the photo look like an oil painting.” or “…with our dog at the edge of the pool.”')
: t('e.g. “A minimalist poster with an olive branch on a sand-coloured ground.”')} />
<div className="hint">{mode === 'compose'
? t('One image = convert (e.g. change the style). Several images = the first is the lead scene, the others supply people and subjects.')
: t('The more precise the description, the better the result.')}</div>
</div>
)}
{mode === 'each' && (
<div className="field">
<label>{t('What should happen?')}</label>
<div className="modes">
{TASKS.map((m) => {
const on = tasks.includes(m.id);
const disabled = m.id === 'contour' && !hasCutout;
return (
<button key={m.id} disabled={disabled}
className={`mode ${on ? 'on' : ''} ${disabled ? 'off' : ''}`}
onClick={() => toggleTask(m.id)}>
<b>{t(m.name)}{on ? ' ✓' : ''}</b><span>{t(m.hint)}</span>
</button>
);
})}
</div>
</div>
)}
{showFormat && (
<div className="field">
<label>{mode === 'each' ? t('Output format') : t('Format (optional)')}</label>
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
{FORMATS.map((f) => <option key={f.id} value={f.id}>{t(f.label)}</option>)}
</select>
{isFreeFmt && (
<>
<input className="input" value={customFmt} onChange={(e) => setCustomFmt(e.target.value)}
placeholder={format === 'sticker' ? t('e.g. 5 (=5×5 cm) or 5x7') : t('e.g. 25x35, or 5 for 5×5 cm')} />
{freeFmtInvalid && <div className="hint warn">{t('Enter a size, for example “25x35” or “5”.')}</div>}
</>
)}
{theframeConflict && <div className="hint warn">{t('Cut out + The Frame makes no sense — pick one.')}</div>}
{effCm && (
<div className="switch">
<button className={portrait ? 'on' : ''} onClick={() => setPortrait(true)}>{t('Portrait')}</button>
<button className={!portrait ? 'on' : ''} onClick={() => setPortrait(false)}>{t('Landscape')}</button>
</div>
)}
{targetPx && <div className="hint">{t('Gives exactly {w} × {h} pixels at 300 dpi — ready to print.', { w: targetPx[0], h: targetPx[1] })}</div>}
{targetPx && mode !== 'generate' && (
<div className="switch subtle">
<button className={crop === 'crop' ? 'on' : ''} onClick={() => setCrop('crop')}>{t('Crop')}</button>
<button className={crop === 'extend' ? 'on' : ''} onClick={() => setCrop('extend')}>{t('Extend')}</button>
</div>
)}
{targetPx && mode !== 'generate' && crop === 'extend' && (
<div className="hint">{t('“Extend” produces more scene around the subject (outpainting) instead of cropping — made for The Frame.')}</div>
)}
</div>
)}
<div className="field">
<label>{t('File format')}</label>
<div className="switch">
<button className={outputExt === '' ? 'on' : ''} onClick={() => setOutputExt('')}>{t('Default')}</button>
<button className={outputExt === 'png' ? 'on' : ''} onClick={() => setOutputExt('png')}>PNG</button>
<button className={outputExt === 'jpg' ? 'on' : ''} onClick={() => setOutputExt('jpg')}>JPG</button>
</div>
<div className="hint">
{outputExt === 'jpg' ? t('JPG — small and easy on the delivery target. Cut-out subjects stay PNG all the same (transparency).')
: outputExt === 'png' ? t('PNG — lossless, supports transparency. Large files.')
: t('The default from the admin settings (set globally).')}
</div>
</div>
{mode === 'each' && tasks.includes('contour') && (
<div className="field">
<label>{t('Sticker border (mm)')}</label>
<input className="input" type="number" min={0} max={20} step={0.5}
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
</div>
)}
{models.length > 0 && (
<div className="field">
<label>{t('Quality')}</label>
<div className="switch wrap">
{models.map((m) => (
<button key={m.model_id} className={modelKey === m.model_id ? 'on' : ''}
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
))}
</div>
<div className="hint">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
</div>
)}
{mode === 'each' && (
<div className="field">
<label>{t('Own instruction (optional)')}</label>
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
placeholder={t('e.g. “Make the background lighter, leave the rest alone.”')} />
</div>
)}
<div className="field">
<label>{t('Where to?')}</label>
<div className="switch">
<button className={delivery === 'library' ? 'on' : ''} onClick={() => setDelivery('library')}>{t('Library')}</button>
<button className={delivery === 'remote' ? 'on' : ''} onClick={() => setDelivery('remote')}>{t('Delivery target')}</button>
<button className={delivery === 'both' ? 'on' : ''} onClick={() => setDelivery('both')}>{t('Both')}</button>
</div>
{delivery !== 'library' && (
<div className="target">
<select className="select" value={targetId} onChange={(e) => setTargetId(e.target.value)}>
<option value="">{t('Default delivery target')}</option>
<option value="mirror">{t('Backup mirror')}</option>
{targets.map((tg) => <option key={tg.id} value={tg.id}>{tg.name}</option>)}
</select>
<input className="input" value={gallery} onChange={(e) => setGallery(e.target.value)}
placeholder={t('Folder on the target (empty = default)')} />
</div>
)}
</div>
{(me.private_allowed || me.private_forced) && (
<label className="private">
<input type="checkbox" checked={priv} disabled={me.private_forced} onChange={(e) => setPriv(e.target.checked)} />
<span><b>{t('Private session')} {me.private_forced ? t('(enforced)') : ''}</b>
<em>{t('Generate only, remember nothing: no entry in the library, no delivery or backup, no stored prompt. The result can be fetched for a short while, then it is deleted.')}</em></span>
</label>
)}
<button className="button" disabled={!canRun} onClick={run}>
{busy ? <><span className="spin" />{t('Being created …')}</>
: mode === 'each' ? (ready.length === 0 ? t('Start')
: ready.length === 1 ? t('Start · {n} image', { n: ready.length })
: t('Start · {n} images', { n: ready.length }))
: t('Generate image')}
</button>
<div className="hint center">{t('It keeps running on the server — you can close the window.')}</div>
</div>
</section>
</div>
{toast && <div className="toast">{toast}</div>}
<StudioStyles />
</div>
);
}
function StudioStyles() {
return <style>{`
.studio{display:flex;flex-direction:column;gap:16px;}
.mode-bar{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
@media(max-width:560px){.mode-bar{grid-template-columns:1fr;}}
.mode-tab{text-align:left;background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);padding:11px 13px;cursor:pointer;font-family:inherit;}
.mode-tab b{display:block;font-size:14px;font-weight:600;}
.mode-tab span{display:block;font-size:11px;color:var(--soft);margin-top:2px;line-height:1.4;}
.mode-tab.on{border-color:var(--accent);background:var(--accent-bg);}
.grid{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
@media(max-width:820px){.grid{grid-template-columns:1fr;}}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
.cardHead{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;}
.stage{padding:22px;min-height:290px;display:flex;align-items:center;justify-content:center;}
.drop{width:100%;min-height:250px;border:1.5px dashed var(--line);border-radius:var(--radius-sm);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:26px;cursor:pointer;}
.drop.drag{border-color:var(--accent);background:var(--accent-bg);}
.drop p{margin:0;color:var(--soft);font-size:14px;line-height:1.55;}
.kbd{font-family:var(--font-mono);font-size:12px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 6px;}
.mini-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:8px;width:100%;}
.mini-image{position:relative;aspect-ratio:1;border:1px solid var(--line);border-radius:3px;overflow:hidden;
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/14px 14px;}
.mini-image.err{border-color:var(--err);}
.mini-image img{width:100%;height:100%;object-fit:contain;display:block;}
.mini-image>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;}
.mini-image .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;}
.loading{position:absolute;inset:0;background:var(--paper);opacity:.6;}
.mini-add{aspect-ratio:1;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
.controls{padding:16px;display:flex;flex-direction:column;gap:15px;}
.field label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
.input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
.input:focus,.select:focus,.area:focus{border-color:var(--accent);}
.area{font-size:13.5px;line-height:1.5;resize:vertical;}
.area.large{font-size:15px;line-height:1.55;}
.hint{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
.hint.center{text-align:center;}
.hint.warn{color:var(--err);}
.modes{display:flex;flex-direction:column;gap:6px;}
.mode{text-align:left;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;cursor:pointer;font-family:inherit;}
.mode b{display:block;font-size:14px;font-weight:600;}
.mode span{display:block;font-size:11.5px;color:var(--soft);margin-top:2px;}
.mode.on{border-color:var(--accent);background:var(--accent-bg);}
.mode.off{opacity:.5;cursor:not-allowed;}
.switch{display:flex;gap:6px;margin-top:8px;}
.switch 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);}
.switch button.on{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
.switch.subtle button{font-size:12.5px;}
.target{display:flex;flex-direction:column;gap:7px;margin-top:8px;}
.preset-row{display:flex;gap:6px;align-items:center;}
.preset-row .select{flex:1;}
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
.private{display:flex;gap:10px;align-items:flex-start;cursor:pointer;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:11px;}
.private input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
.private b{display:block;font-size:13.5px;}
.private em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:2px;line-height:1.45;}
.switch.wrap{flex-wrap:wrap;}
.switch.wrap button{flex:1 1 auto;min-width:110px;}
.button{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;}
.button: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;}
@keyframes sp{to{transform:rotate(360deg);}}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
`}</style>;
}