Five things off the open list in the wiki. CSP with real hashes for every inline script (Astro's experimental.csp), so no 'unsafe-inline' for scripts. style-src is deliberately reopened in middleware: React sets styles as attributes and attributes cannot be hashed -- measured, the hash-only policy blocks them and the print preview loses its layout. Plus nosniff, referrer-policy, X-Frame-Options and a permissions-policy. Verified across all pages: zero violations. mem_limit 3g in compose. sharp can spike; without a limit an outlier takes the whole container down instead of just the process, and the healthcheck can't help. AI results now carry IPTC DigitalSourceType (trainedAlgorithmicMedia, or compositeWithTrainedAlgorithmicMedia for edited photos) plus the model, embedded as XMP -- written by hand into PNG iTXt and JPEG APP1 rather than pulling in a sharp upgrade with a native postinstall. exiftool reads it back from both formats; pixels, dimensions and dpi are provably untouched. Crop maths lived twice, in the browser preview and the server renderer, with nothing holding them together. Now one dependency-free module, and 60000 randomised cases assert both entry points agree. That test immediately found a real bug: the zoom limits (0.02 / 8x) also clamped the *initial* crop, so an extreme format silently broke the aspect ratio -- "Rand lassen" would have cropped after all. Limits are now widened to whatever the format needs. Also: the colour flag sat exactly on the selection circle in the library and covered it. Moved to the bottom left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
349 lines
23 KiB
TypeScript
349 lines
23 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
|
|
interface Item { id: string; filename: string; output_px: string; has_alpha: boolean;
|
|
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; nas_status: string;
|
|
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; prompt_used: string | null;
|
|
color_tag: string | null; created_at: string; }
|
|
|
|
const TAGS = [
|
|
{ id: 'red', label: 'Rot', color: '#E5484D' },
|
|
{ id: 'orange', label: 'Orange', color: '#E8830C' },
|
|
{ id: 'green', label: 'Grün', color: '#46A758' },
|
|
{ id: 'final', label: 'Final', color: '#3A3733' },
|
|
];
|
|
const tagColor = (t: string | null) => TAGS.find((x) => x.id === t)?.color || null;
|
|
|
|
export default function LibraryApp() {
|
|
const [items, setItems] = useState<Item[]>([]);
|
|
const [folders, setFolders] = useState<any[]>([]);
|
|
const [models, setModels] = useState<any[]>([]);
|
|
const [folderFilter, setFolderFilter] = useState<string | null>(null);
|
|
const [pick, setPick] = useState<Set<string>>(new Set());
|
|
const [variantSel, setVariantSel] = useState<Record<string, string>>({});
|
|
const [compare, setCompare] = useState<Item | null>(null);
|
|
const [promptOf, setPromptOf] = useState<Item | null>(null);
|
|
const [saveView, setSaveView] = useState<Item | null>(null);
|
|
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
|
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
|
const [toast, setToast] = useState<string | null>(null);
|
|
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
|
|
|
|
// Modellname: Admin-Label, sonst lesbarer Rest der Modell-ID.
|
|
const modelName = (id: string | null) => {
|
|
if (!id) return null;
|
|
const m = models.find((x) => x.model_id === id);
|
|
if (m) return m.label;
|
|
return id.split('/').pop()!.replace(/-/g, ' ');
|
|
};
|
|
|
|
const load = useCallback(async () => {
|
|
const [i, f, m] = await Promise.all([
|
|
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
|
|
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
|
|
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })),
|
|
]);
|
|
setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
|
|
}, []);
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const rename = async (id: string, filename: string) =>
|
|
fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
|
|
const setTag = async (id: string, color_tag: string | null) => {
|
|
setItems((x) => x.map((y) => y.id === id ? { ...y, color_tag } : y));
|
|
setTagMenu(null);
|
|
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color_tag }) });
|
|
};
|
|
const setFolder = async (id: string, folder_id: string | null) => {
|
|
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
|
load();
|
|
};
|
|
const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); };
|
|
const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify('Gelöscht.'); };
|
|
const deliverSelected = async () => {
|
|
notify('Sende an Picdrop …');
|
|
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json());
|
|
setPick(new Set()); load(); notify(`${r.delivered}/${r.total} nach Picdrop ausgeliefert.`);
|
|
};
|
|
const toggle = (id: string) => setPick((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
|
const moveSelected = async (folder_id: string | null) => {
|
|
for (const id of pick) await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
|
|
setPick(new Set()); load(); notify(folder_id ? 'In Ordner verschoben.' : 'Aus Ordner entfernt.');
|
|
};
|
|
const zipSelected = async () => {
|
|
notify('Packe ZIP …');
|
|
const res = await fetch('/api/items/zip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) });
|
|
if (!res.ok) { notify('ZIP fehlgeschlagen.'); return; }
|
|
const blob = await res.blob();
|
|
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
|
a.download = `klarbild_${new Date().toISOString().slice(0, 10)}.zip`; a.click();
|
|
URL.revokeObjectURL(a.href); notify('ZIP geladen.');
|
|
};
|
|
const alternative = async (id: string) => {
|
|
notify('Erzeuge Alternative …');
|
|
const r = await fetch(`/api/items/${id}/alternative`, { method: 'POST' }).then((x) => x.json());
|
|
if (r.ok) notify('Alternative wird erzeugt — erscheint gleich hier.'); else notify(r.error || 'Fehler.');
|
|
setTimeout(load, 2500);
|
|
};
|
|
// Ergebnis als neue Vorlage ins Studio übernehmen und weiterbearbeiten.
|
|
const reuse = (id: string) => { window.location.href = `/?reuse=${id}`; };
|
|
|
|
const newFolder = async () => {
|
|
const name = prompt('Name des Ordners?'); if (!name) return;
|
|
const gallery = prompt('Picdrop-Galerie für diesen Ordner? (leer = Standard)') || '';
|
|
await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, picdrop_gallery: gallery.trim() || null }) });
|
|
load();
|
|
};
|
|
const delFolder = async (id: string) => {
|
|
if (!confirm('Ordner löschen? Die Bilder bleiben erhalten.')) return;
|
|
await fetch(`/api/folders?id=${id}`, { method: 'DELETE' });
|
|
if (folderFilter === id) setFolderFilter(null);
|
|
load();
|
|
};
|
|
const renameFolder = async (o: any) => {
|
|
const name = prompt('Ordner umbenennen:', o.name); if (name == null) return;
|
|
const gallery = prompt('Picdrop-Galerie (leer = Standard):', o.picdrop_gallery || '');
|
|
await fetch('/api/folders', { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: o.id, name: name.trim() || o.name, picdrop_gallery: (gallery || '').trim() || null }) });
|
|
load();
|
|
};
|
|
|
|
// Nach Ordner + Markierung filtern, dann Alternativen unter ihrem Ursprung gruppieren.
|
|
const filtered = items
|
|
.filter((v) => !folderFilter || v.folder_id === folderFilter)
|
|
.filter((v) => !tagFilter || v.color_tag === tagFilter);
|
|
const groupMap = new Map<string, Item[]>();
|
|
for (const it of filtered) {
|
|
const root = it.variant_of || it.id;
|
|
if (!groupMap.has(root)) groupMap.set(root, []);
|
|
groupMap.get(root)!.push(it);
|
|
}
|
|
const groups = [...groupMap.values()].map((g) => g.slice().sort((a, b) => +new Date(a.created_at) - +new Date(b.created_at)));
|
|
groups.sort((a, b) => +new Date(b[b.length - 1].created_at) - +new Date(a[a.length - 1].created_at));
|
|
const shown = (g: Item[]) => g.find((x) => x.id === variantSel[g[0].id]) || g[g.length - 1];
|
|
|
|
return (
|
|
<div>
|
|
<div className="kopfzeile">
|
|
<span className="mono-label">Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}</span>
|
|
<div className="reihe">
|
|
{pick.size > 0 && <>
|
|
<span className="fein">{pick.size} gewählt</span>
|
|
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
|
|
<button className="mini" onClick={zipSelected}>ZIP laden</button>
|
|
<select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}>
|
|
<option value="" disabled>In Ordner …</option>
|
|
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
|
<option value="__none">Kein Ordner</option>
|
|
</select>
|
|
<button className="mini" onClick={delSelected}>Löschen</button>
|
|
</>}
|
|
<button className="mini" onClick={newFolder}>Neuer Ordner</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Ordner-Filter */}
|
|
<div className="ordnerleiste">
|
|
<button className={`chip ${!folderFilter ? 'an' : ''}`} onClick={() => setFolderFilter(null)}>Alle · {items.length}</button>
|
|
{folders.map((o) => (
|
|
<span key={o.id} className={`chip-wrap ${folderFilter === o.id ? 'an' : ''}`}>
|
|
<button className="chip" onClick={() => setFolderFilter(folderFilter === o.id ? null : o.id)}>
|
|
{o.name} · {o.count}{o.picdrop_gallery ? ' →' : ''}
|
|
</button>
|
|
{folderFilter === o.id && <>
|
|
<button className="chip-x" title="Ordner umbenennen" onClick={() => renameFolder(o)}>✎</button>
|
|
<button className="chip-x del" title="Ordner löschen" onClick={() => delFolder(o.id)}>✕</button>
|
|
</>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="fein hinweis">Ordner gruppieren die Bibliothek und können je Ordner eine eigene Picdrop-Galerie bekommen (Pfeil = eigene Galerie hinterlegt).</div>
|
|
|
|
{/* Farbmarkierungen filtern */}
|
|
<div className="ordnerleiste tagbar">
|
|
<button className={`chip ${!tagFilter ? 'an' : ''}`} onClick={() => setTagFilter(null)}>Alle Markierungen</button>
|
|
{TAGS.map((t) => (
|
|
<button key={t.id} className={`chip flagchip ${tagFilter === t.id ? 'an' : ''}`} onClick={() => setTagFilter(tagFilter === t.id ? null : t.id)}>
|
|
<span className="flag" style={{ color: t.color }}>⚑</span> {t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<div className="leer"><span className="regmark" /><h3>{folderFilter ? 'Ordner ist leer' : 'Noch nichts erstellt'}</h3>
|
|
<p>{folderFilter ? 'Weise Bildern über das Ordner-Menü diesen Ordner zu.' : 'Lege im Studio los — die Ergebnisse sammeln sich hier.'}</p></div>
|
|
) : (
|
|
<div className="galerie">
|
|
{groups.map((g) => {
|
|
const v = shown(g);
|
|
const multi = g.length > 1;
|
|
return (
|
|
<article key={g[0].id} className={`kachel ${pick.has(v.id) ? 'gewaehlt' : ''}`}>
|
|
<div className="kachel-bild" onClick={() => toggle(v.id)}>
|
|
<img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" />
|
|
<span className="haken">{pick.has(v.id) ? '✓' : ''}</span>
|
|
{(v.mode === 'compose' || v.mode === 'generate') &&
|
|
<span className="modetag">{v.mode === 'compose' ? 'kombiniert' : 'erzeugt'}</span>}
|
|
{v.color_tag && <span className="flagbadge" style={{ color: tagColor(v.color_tag)! }}>⚑</span>}
|
|
{multi && <span className="fassung">Fassung {g.indexOf(v) + 1}/{g.length}</span>}
|
|
</div>
|
|
|
|
{multi && (
|
|
<div className="varianten">
|
|
{g.map((x, idx) => (
|
|
<button key={x.id} className={`vthumb ${x.id === v.id ? 'an' : ''}`}
|
|
onClick={() => setVariantSel((s) => ({ ...s, [g[0].id]: x.id }))} title={`Fassung ${idx + 1}`}>
|
|
<img src={`/api/items/${x.id}/file?thumb=1`} alt="" loading="lazy" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<input className="name" defaultValue={v.filename || ''} key={v.id} 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' : ''}{modelName(v.model_used) ? ` · ${modelName(v.model_used)}` : ''}
|
|
{v.delivery_status === 'delivered' && <span className="dbadge ok">Picdrop ✓</span>}
|
|
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop …</span>}
|
|
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop ✕</span>}
|
|
{v.nas_status === 'mirrored' && <span className="dbadge ok">NAS ✓</span>}
|
|
{v.nas_status === 'failed' && <span className="dbadge err">NAS ✕</span>}
|
|
</div>
|
|
<div className="reihe knapp">
|
|
<div className="tagwrap">
|
|
<button className="mini flagbtn" style={v.color_tag ? { color: tagColor(v.color_tag)!, borderColor: tagColor(v.color_tag)! } : {}}
|
|
onClick={() => setTagMenu(tagMenu === v.id ? null : v.id)} title="Markierung">⚑</button>
|
|
{tagMenu === v.id && (
|
|
<div className="tagmenu" onMouseLeave={() => setTagMenu(null)}>
|
|
<button onClick={() => setTag(v.id, null)}><span className="flag none">⚑</span> Keine</button>
|
|
{TAGS.map((t) => (
|
|
<button key={t.id} onClick={() => setTag(v.id, t.id)}><span className="flag" style={{ color: t.color }}>⚑</span> {t.label}</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{v.prompt_used && <button className="mini" onClick={() => setPromptOf(v)}>Prompt</button>}
|
|
{v.mode !== 'generate' && <button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>}
|
|
<button className="mini stark2" onClick={() => setSaveView(v)} title="Groß öffnen, dann sichern (iPhone: In Fotos sichern)">Laden</button>
|
|
<button className="mini" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiter</button>
|
|
<button className="mini" onClick={() => alternative(v.id)} title="Weitere Fassung erzeugen">+ Alt.</button>
|
|
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
|
|
<option value="">Kein Ordner</option>
|
|
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
|
</select>
|
|
<button className="mini" onClick={() => del(v.id)}>✕</button>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{compare && (
|
|
<div className="lupe" onClick={() => setCompare(null)}>
|
|
<div className="vergleich" onClick={(e) => e.stopPropagation()}>
|
|
<img className="unten" src={`/api/items/${compare.id}/file?src=1`} alt="vorher" />
|
|
<img className="oben" src={`/api/items/${compare.id}/file`} alt="nachher" id="ov" />
|
|
<input type="range" min={0} max={100} defaultValue={50}
|
|
onChange={(e) => { const o = document.getElementById('ov'); if (o) o.style.clipPath = `inset(0 0 0 ${e.target.value}%)`; }} />
|
|
</div>
|
|
<div className="lupe-fuss">{compare.filename} · {compare.output_px}px — Regler: links {compare.mode === 'compose' ? 'Vorlage' : 'Original'}, rechts Ergebnis</div>
|
|
</div>
|
|
)}
|
|
{saveView && (
|
|
<div className="lupe" onClick={() => setSaveView(null)}>
|
|
<div className="savewrap" onClick={(e) => e.stopPropagation()}>
|
|
<img className="saveimg blur" src={`/api/items/${saveView.id}/file?thumb=1`} alt="" />
|
|
<img className="saveimg full" src={`/api/items/${saveView.id}/file?preview=1`} alt={saveView.filename || ''} />
|
|
</div>
|
|
<div className="save-fuss" onClick={(e) => e.stopPropagation()}>
|
|
<span>📱 iPhone/iPad: lange auf das Bild tippen → <b>„In Fotos sichern"</b>.</span>
|
|
<a className="mini stark" href={`/api/items/${saveView.id}/file?download=1`}>Original als Datei (.png)</a>
|
|
<button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{promptOf && (
|
|
<div className="lupe" onClick={() => setPromptOf(null)}>
|
|
<div className="promptbox" onClick={(e) => e.stopPropagation()}>
|
|
<div className="pb-kopf"><b>{promptOf.filename}</b>
|
|
<button className="mini" onClick={() => { navigator.clipboard?.writeText(promptOf.prompt_used || ''); notify('Prompt kopiert.'); }}>Kopieren</button></div>
|
|
<pre className="pb-text">{promptOf.prompt_used}</pre>
|
|
<div className="fein">{promptOf.model_used ? `Modell: ${modelName(promptOf.model_used)} · ` : ''}{promptOf.output_px}px</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{toast && <div className="toast">{toast}</div>}
|
|
<LibStyles />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LibStyles() {
|
|
return <style>{`
|
|
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
|
|
.reihe{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}.reihe.knapp{margin-top:8px;}
|
|
.fein{font-size:11.5px;color:var(--soft);}
|
|
.hinweis{margin:0 0 14px;line-height:1.5;}
|
|
.mini{background:var(--card);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;}
|
|
.ordnerleiste{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-bottom:8px;}
|
|
.chip-wrap{display:inline-flex;align-items:center;border-radius:20px;}
|
|
.chip{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:6px 13px;cursor:pointer;font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;color:var(--soft);white-space:nowrap;}
|
|
.chip.an,.chip-wrap.an .chip{border-color:var(--accent);background:var(--accent-bg);color:var(--accent);}
|
|
.chip-x{border:none;background:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0 4px;}
|
|
.chip-x.del{color:var(--err);padding:0 8px 0 2px;}
|
|
.leer{padding:60px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px;}
|
|
.leer h3{margin:0;font-size:17px;}.leer p{margin:0;color:var(--soft);max-width:38ch;}
|
|
.galerie{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;align-items:start;}
|
|
@media(min-width:768px){.galerie{grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;}}
|
|
.kachel{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:8px;}
|
|
.kachel.gewaehlt{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
|
.kachel-bild{position:relative;border-radius:3px;overflow:hidden;cursor:pointer;
|
|
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/16px 16px;}
|
|
.kachel-bild img{width:100%;height:auto;display:block;transition:transform .12s;}
|
|
.kachel-bild:active img{transform:scale(.98);}
|
|
.haken{position:absolute;top:6px;left:6px;width:21px;height:21px;border-radius:50%;border:1.5px solid #fff;background:rgba(22,21,15,.35);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;}
|
|
.kachel.gewaehlt .haken{background:var(--accent);}
|
|
.modetag{position:absolute;top:6px;right:6px;background:rgba(59,91,219,.92);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:20px;}
|
|
/* Unten links — oben links sitzt der Auswahlkreis (.haken), oben rechts die
|
|
Modus-Marke, unten rechts die Fassungsnummer. Vorher lag die Flagge exakt
|
|
auf dem Auswahlkreis und verdeckte ihn. */
|
|
.flagbadge{position:absolute;bottom:5px;left:6px;font-size:19px;line-height:1;
|
|
text-shadow:0 1px 3px rgba(0,0,0,.55);pointer-events:none;}
|
|
.tagbar{margin:0 0 12px;}
|
|
.flagchip .flag{font-size:13px;vertical-align:-1px;}
|
|
.flag.none{color:var(--soft);}
|
|
.tagwrap{position:relative;display:inline-block;}
|
|
.flagbtn{font-size:14px;line-height:1;padding:5px 9px;}
|
|
.tagmenu{position:absolute;bottom:calc(100% + 4px);left:0;z-index:20;background:var(--card);border:1px solid var(--line);border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:5px;display:flex;flex-direction:column;min-width:130px;}
|
|
.tagmenu button{display:flex;align-items:center;gap:8px;background:none;border:none;text-align:left;padding:7px 9px;cursor:pointer;font-family:inherit;font-size:13px;border-radius:4px;color:var(--ink);}
|
|
.tagmenu button:hover{background:var(--paper);}
|
|
.tagmenu .flag{font-size:15px;}
|
|
.savewrap{position:relative;max-width:94vw;max-height:78vh;display:flex;}
|
|
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
|
|
.saveimg.blur{filter:blur(8px);position:absolute;inset:0;width:100%;height:100%;object-fit:contain;}
|
|
.saveimg.full{position:relative;z-index:1;}
|
|
.save-fuss{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;max-width:90vw;}
|
|
.fassung{position:absolute;bottom:6px;right:6px;background:rgba(22,21,15,.7);color:#fff;font-family:var(--font-mono);font-size:9px;padding:2px 7px;border-radius:20px;}
|
|
.varianten{display:flex;gap:5px;margin-top:6px;overflow-x:auto;}
|
|
.vthumb{flex:0 0 auto;width:40px;height:40px;border:1px solid var(--line);border-radius:3px;overflow:hidden;padding:0;background:none;cursor:pointer;}
|
|
.vthumb.an{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
|
.vthumb img{width:100%;height:100%;object-fit:cover;display:block;}
|
|
.name{margin-top:8px;width:100%;font-size:12.5px;padding:6px 8px;font-family:var(--font-mono);border:1px solid var(--line);border-radius:3px;background:#fff;}
|
|
.meta{font-size:11px;color:var(--soft);margin-top:5px;}
|
|
.mini.stark{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;}
|
|
.mini.stark2{border-color:var(--accent);color:var(--accent);font-weight:600;}
|
|
.dbadge{display:inline-block;margin-left:6px;font-family:var(--font-mono);font-size:9px;letter-spacing:.08em;text-transform:uppercase;background:var(--paper);color:var(--soft);padding:1px 6px;border-radius:20px;}
|
|
.dbadge.ok{background:var(--accent-bg);color:var(--accent);}
|
|
.dbadge.err{color:var(--err);}
|
|
.mini-select{border:1px solid var(--line);border-radius:3px;padding:5px 7px;font-family:inherit;font-size:12px;background:#fff;}
|
|
.lupe{position:fixed;inset:0;background:rgba(22,21,15,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:26px;z-index:50;}
|
|
.vergleich{position:relative;max-width:90vw;max-height:76vh;}
|
|
.vergleich img{max-width:90vw;max-height:70vh;display:block;border-radius:3px;}
|
|
.vergleich .oben{position:absolute;inset:0;clip-path:inset(0 0 0 50%);}
|
|
.vergleich input[type=range]{position:absolute;left:0;right:0;bottom:-34px;width:100%;}
|
|
.lupe-fuss{color:#EAEAE3;font-family:var(--font-mono);font-size:12px;margin-top:30px;text-align:center;}
|
|
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
|
|
.pb-kopf{display:flex;justify-content:space-between;align-items:center;gap:10px;}
|
|
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
|
|
.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>;
|
|
}
|