feat: Studio UI (upload/recipe/submit island) + queue view + library/file API
- StudioApp.tsx: upload (file/DnD/paste, HEIC, to /api/uploads), combinable task toggles with validation (contour needs cutout, freistellen+theframe blocked), format+orientation+live px+crop, contour mm, custom instruction, delivery, recipe prefill - QueueApp.tsx + warteschlange.astro: live polling, progress+ETA, per-item status, retry/pause/resume/cancel, result thumbnails+download - api/items (library filters), api/items/:id/file (stream result/source from S3) - index.astro renders Studio island
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const STATUS_DE: Record<string, string> = {
|
||||
queued: 'wartet', running: 'läuft', done: 'fertig', failed: 'fehlgeschlagen',
|
||||
skipped: 'übersprungen', paused: 'angehalten', cancelled: 'abgebrochen',
|
||||
};
|
||||
|
||||
export default function QueueApp({ jobId }: { jobId: string | null }) {
|
||||
const [job, setJob] = useState<any>(null);
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (jobId) {
|
||||
const r = await fetch(`/api/jobs/${jobId}`).then((x) => x.json()).catch(() => null);
|
||||
if (r?.job) { setJob(r.job); setItems(r.items || []); }
|
||||
} else {
|
||||
const r = await fetch('/api/jobs').then((x) => x.json()).catch(() => null);
|
||||
setJobs(r?.jobs || []);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const active = !job || ['queued', 'running', 'paused'].includes(job?.status);
|
||||
const t = setInterval(load, active ? 2000 : 6000);
|
||||
return () => clearInterval(t);
|
||||
}, [load, job?.status]);
|
||||
|
||||
const act = async (path: string) => { await fetch(path, { method: 'POST' }); load(); };
|
||||
|
||||
if (!jobId) {
|
||||
return (
|
||||
<div className="q">
|
||||
<div className="kopfzeile"><span className="mono-label">Aufträge</span></div>
|
||||
{jobs.length === 0 ? <p className="fein">Noch keine Aufträge. Leg im Studio los.</p> : (
|
||||
<div className="joblist">
|
||||
{jobs.map((j) => (
|
||||
<a key={j.id} className="jobrow" href={`/warteschlange?job=${j.id}`}>
|
||||
<span className={`punkt ${j.status}`} />
|
||||
<b>{j.done_count}/{j.total}</b>
|
||||
<span className="fein">{STATUS_DE[j.status] || j.status} · {j.by_name || ''}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<QStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const done = items.filter((i) => i.status === 'done').length;
|
||||
const failed = items.filter((i) => i.status === 'failed').length;
|
||||
const open = items.filter((i) => ['queued', 'running'].includes(i.status)).length;
|
||||
const pct = items.length ? Math.round(((done + failed) / items.length) * 100) : 0;
|
||||
const etaSec = open * 20;
|
||||
|
||||
return (
|
||||
<div className="q">
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">Auftrag · {STATUS_DE[job?.status] || job?.status}</span>
|
||||
<div className="reihe">
|
||||
{job?.status === 'running' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/pause`)}>Anhalten</button>}
|
||||
{job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>Fortsetzen</button>}
|
||||
{failed > 0 && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/retry-failed`)}>Fehlgeschlagene erneut</button>}
|
||||
{['queued', 'running', 'paused'].includes(job?.status) && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/cancel`)}>Abbrechen</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fortschritt">
|
||||
<div className="bar"><i style={{ width: `${pct}%` }} /></div>
|
||||
<div className="fein">{done} fertig{failed ? `, ${failed} fehlgeschlagen` : ''} von {items.length}
|
||||
{open > 0 && ` · noch ca. ${etaSec >= 60 ? Math.ceil(etaSec / 60) + ' Min' : etaSec + ' Sek'}`}</div>
|
||||
</div>
|
||||
|
||||
<div className="items">
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className={`item ${it.status}`}>
|
||||
<div className="thumb">
|
||||
{it.status === 'done' && it.result_path
|
||||
? <img src={`/api/items/${it.id}/file`} alt="" loading="lazy" />
|
||||
: <span className={`punkt ${it.status}`} />}
|
||||
</div>
|
||||
<div className="itxt">
|
||||
<b>{it.filename || `Bild ${it.position + 1}`}</b>
|
||||
<span className="fein">{STATUS_DE[it.status]}{it.output_px ? ` · ${it.output_px}px` : ''}</span>
|
||||
{it.error_message && <span className="fein err">{it.error_message}</span>}
|
||||
</div>
|
||||
<div className="iact">
|
||||
{it.status === 'done' && <a className="mini" href={`/api/items/${it.id}/file?download=1`}>Laden</a>}
|
||||
{it.status === 'failed' && <button className="mini" onClick={() => act(`/api/items/${it.id}/retry`)}>Erneut</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<QStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QStyles() {
|
||||
return <style>{`
|
||||
.q{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||
.kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);gap:10px;flex-wrap:wrap;}
|
||||
.reihe{display:flex;gap:6px;flex-wrap:wrap;}
|
||||
.fortschritt{padding:16px;border-bottom:1px solid var(--line);}
|
||||
.bar{height:6px;background:var(--paper);border-radius:20px;overflow:hidden;}
|
||||
.bar i{display:block;height:100%;background:var(--accent);transition:width .4s;}
|
||||
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;display:block;}
|
||||
.fein.err{color:var(--err);}
|
||||
.items{display:flex;flex-direction:column;}
|
||||
.item{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);}
|
||||
.thumb{width:44px;height:44px;flex:none;background:var(--paper);border:1px solid var(--line);border-radius:3px;overflow:hidden;display:flex;align-items:center;justify-content:center;}
|
||||
.thumb img{width:100%;height:100%;object-fit:cover;}
|
||||
.itxt{flex:1;min-width:0;}
|
||||
.itxt b{font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.iact{flex:none;}
|
||||
.mini{background:#fff;border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;}
|
||||
.punkt{width:11px;height:11px;border-radius:50%;background:var(--mark);display:inline-block;}
|
||||
.punkt.running{background:var(--accent);}
|
||||
.punkt.done{background:var(--ok);}
|
||||
.punkt.failed{background:var(--err);}
|
||||
.punkt.queued{background:var(--mark);}
|
||||
.joblist{display:flex;flex-direction:column;}
|
||||
.jobrow{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--line);text-decoration:none;color:var(--ink);}
|
||||
p.fein{padding:16px;}
|
||||
`}</style>;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
/* Formate (01 §5) — cm in Hochformat-Konvention; screen = feste Pixel */
|
||||
const FORMATS: { id: string; label: string; cm?: [number, number]; screen?: [number, number] }[] = [
|
||||
{ id: 'keep', label: 'Original beibehalten' },
|
||||
{ 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: 'hochformat', label: 'Hochformat (9:16)', screen: [2160, 3840] },
|
||||
{ id: 'sticker', label: 'Sticker (freies Maß)', cm: [5, 5] },
|
||||
];
|
||||
|
||||
const TASKS = [
|
||||
{ id: 'clean', name: 'Bereinigen', hint: 'Rahmen, Wand und Shop-Oberfläche entfernen.' },
|
||||
{ id: 'cutout', name: 'Freistellen', hint: 'Nur das Motiv, Hintergrund transparent.' },
|
||||
{ id: 'format', name: 'Format', hint: 'Auf ein bestimmtes Maß bringen.' },
|
||||
{ id: 'contour', name: 'Kontur', hint: 'Weißen Stickerrand anlegen (nur mit Freistellen).' },
|
||||
];
|
||||
|
||||
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 }: { recipes: any[] }) {
|
||||
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 [custom, setCustom] = useState('');
|
||||
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
|
||||
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 === 'hochformat');
|
||||
|
||||
const zielPx: [number, number] | null = fmt?.screen
|
||||
? (fmt.screen as [number, number])
|
||||
: fmt?.cm
|
||||
? (portrait ? [px(fmt.cm[0]), px(fmt.cm[1])] : [px(fmt.cm[1]), px(fmt.cm[0])])
|
||||
: null;
|
||||
|
||||
const notify = (t: string) => { setToast(t); 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: 'Upload fehlgeschlagen' } : x));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
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]);
|
||||
|
||||
function toggleTask(id: string) {
|
||||
setTasks((t) => {
|
||||
let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id];
|
||||
if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour'); // Kontur braucht Freistellen
|
||||
if (id === 'contour' && next.includes('contour') && !next.includes('cutout')) next = next; // wird per disabled verhindert
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function applyRecipe(r: any) {
|
||||
if (!r) return;
|
||||
setTasks(r.tasks || ['clean', 'format']);
|
||||
setFormat(r.output_format || 'keep');
|
||||
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');
|
||||
}
|
||||
|
||||
const ready = pics.filter((p) => p.source_path && !p.error);
|
||||
const canRun = ready.length > 0 && !busy && tasks.length > 0 && !theframeConflict &&
|
||||
!(tasks.includes('contour') && !hasCutout);
|
||||
|
||||
async function run() {
|
||||
if (!canRun) return;
|
||||
setBusy(true);
|
||||
const recipe = {
|
||||
tasks: [...tasks].sort((a, b) => ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(a) - ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(b)),
|
||||
output_format: hasFormat ? format : 'keep',
|
||||
orientation: portrait ? 'portrait' : 'landscape',
|
||||
crop_mode: crop, dpi: 300,
|
||||
contour_mm: tasks.includes('contour') ? contourMm : null,
|
||||
custom_instruction: custom || null, delivery,
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/jobs', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipe, sources: ready.map((p) => ({ source_path: p.source_path, filename: p.name })) }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.jobId) location.href = `/warteschlange?job=${j.jobId}`;
|
||||
else { notify(j.error || 'Fehler beim Anlegen.'); setBusy(false); }
|
||||
} catch { notify('Netzwerkfehler.'); setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="studio">
|
||||
<section className="karte">
|
||||
<div className="kopfzeile">
|
||||
<span className="mono-label">Vorlagen{pics.length ? ` · ${pics.length}` : ''}</span>
|
||||
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>alle entfernen</button>}
|
||||
</div>
|
||||
<div className="buehne">
|
||||
{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>Bilder hierher ziehen,<br />mit <span className="kbd">⌘/Strg + V</span> einfügen<br />oder zum Auswählen tippen.</p>
|
||||
<p className="fein">Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mini-raster">
|
||||
{pics.map((b) => (
|
||||
<div key={b.id} className={`mini-bild ${b.error ? 'err' : ''}`}>
|
||||
{b.src && <img src={b.src} alt="" />}
|
||||
{b.uploading && <span className="lade" />}
|
||||
{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="karte">
|
||||
<div className="steuer">
|
||||
{recipes?.length > 0 && (
|
||||
<div className="feld">
|
||||
<label>Rezept laden</label>
|
||||
<select className="select" onChange={(e) => applyRecipe(recipes.find((r) => r.id === e.target.value))} defaultValue="">
|
||||
<option value="" disabled>Voreinstellung wählen …</option>
|
||||
{recipes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="feld">
|
||||
<label>Was soll passieren?</label>
|
||||
<div className="modi">
|
||||
{TASKS.map((m) => {
|
||||
const on = tasks.includes(m.id);
|
||||
const disabled = m.id === 'contour' && !hasCutout;
|
||||
return (
|
||||
<button key={m.id} disabled={disabled}
|
||||
className={`modus ${on ? 'an' : ''} ${disabled ? 'aus' : ''}`}
|
||||
onClick={() => toggleTask(m.id)}>
|
||||
<b>{m.name}{on ? ' ✓' : ''}</b><span>{m.hint}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasFormat && (
|
||||
<div className="feld">
|
||||
<label>Ausgabeformat</label>
|
||||
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
|
||||
{FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
|
||||
</select>
|
||||
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn — bitte eins wählen.</div>}
|
||||
{fmt?.cm && !fmt.screen && (
|
||||
<div className="schalter">
|
||||
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
|
||||
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
|
||||
</div>
|
||||
)}
|
||||
{zielPx && <div className="fein">Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi — druckfertig.</div>}
|
||||
{zielPx && (
|
||||
<div className="schalter zart">
|
||||
<button className={crop === 'crop' ? 'an' : ''} onClick={() => setCrop('crop')}>Zuschneiden</button>
|
||||
<button className={crop === 'extend' ? 'an' : ''} onClick={() => setCrop('extend')}>Ränder ergänzen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks.includes('contour') && (
|
||||
<div className="feld">
|
||||
<label>Stickerrand (mm)</label>
|
||||
<input className="input" type="number" min={0} max={20} step={0.5}
|
||||
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="feld">
|
||||
<label>Eigene Anweisung (optional)</label>
|
||||
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
|
||||
placeholder="z. B. „Mach den Hintergrund heller, sonst alles lassen.“" />
|
||||
</div>
|
||||
|
||||
<div className="feld">
|
||||
<label>Wohin?</label>
|
||||
<div className="schalter">
|
||||
<button className={delivery === 'library' ? 'an' : ''} onClick={() => setDelivery('library')}>Bibliothek</button>
|
||||
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
|
||||
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="knopf" disabled={!canRun} onClick={run}>
|
||||
{busy ? <><span className="spin" />Wird angelegt …</> : `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`}
|
||||
</button>
|
||||
<div className="fein mitte">Läuft serverseitig weiter — du kannst das Fenster schließen.</div>
|
||||
</div>
|
||||
</section>
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
<StudioStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudioStyles() {
|
||||
return <style>{`
|
||||
.studio{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
|
||||
@media(max-width:820px){.studio{grid-template-columns:1fr;}}
|
||||
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||
.kopfzeile{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;}
|
||||
.buehne{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-raster{display:grid;grid-template-columns:repeat(auto-fill,minmax(78px,1fr));gap:8px;width:100%;}
|
||||
.mini-bild{position:relative;aspect-ratio:3/4;background:var(--paper);border:1px solid var(--line);border-radius:3px;overflow:hidden;}
|
||||
.mini-bild.err{border-color:var(--err);}
|
||||
.mini-bild img{width:100%;height:100%;object-fit:cover;display:block;}
|
||||
.mini-bild>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-bild .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;}
|
||||
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
|
||||
.mini-add{aspect-ratio:3/4;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
|
||||
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
|
||||
.feld 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;}
|
||||
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
|
||||
.fein.mitte{text-align:center;}
|
||||
.fein.warn{color:var(--err);}
|
||||
.modi{display:flex;flex-direction:column;gap:6px;}
|
||||
.modus{text-align:left;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;cursor:pointer;font-family:inherit;}
|
||||
.modus b{display:block;font-size:14px;font-weight:600;}
|
||||
.modus span{display:block;font-size:11.5px;color:var(--soft);margin-top:2px;}
|
||||
.modus.an{border-color:var(--accent);background:var(--accent-bg);}
|
||||
.modus.aus{opacity:.5;cursor:not-allowed;}
|
||||
.schalter{display:flex;gap:6px;margin-top:8px;}
|
||||
.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;}
|
||||
.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;}
|
||||
@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>;
|
||||
}
|
||||
Reference in New Issue
Block a user