feat: print page with crop editor and live sheet preview

New Druck tab: upload or pick from the library, choose a target size and copy
count per image, pan/zoom crop on a locked aspect ratio, and a live preview
that renders the same layout the PDF will contain. Sheet controls for paper,
margin, gap, bleed, crop marks, dpi and templates.
This commit is contained in:
2026-08-18 06:20:46 +00:00
parent e8b3fde652
commit cd9081823d
3 changed files with 786 additions and 0 deletions
+768
View File
@@ -0,0 +1,768 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { PAPERS, PHOTO_SIZES, parseSizeMm, labelMm, mmToPx, paperById, photoById } from '../lib/paper';
import {
layout, cutMarks, recommendedGap, dpiCheck,
type SheetSpec, type PlaceSpec, type MarkMode,
} from '../lib/printlayout';
/* --------------------------------------------------------------------------
Druck — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit
Schnittmarken auf einen Bogen setzen. Alles rechnet in Millimetern;
Pixel entstehen erst beim Rendern auf dem Server.
-------------------------------------------------------------------------- */
type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string };
interface CropRel { x: number; y: number; w: number; h: number }
interface Cell {
id: string;
name: string;
preview: string; // Data-URL (Upload) oder /api/items/…/file
src?: SrcRef;
uploading?: boolean;
error?: string;
natW: number; natH: number; // echte Quellpixel (für den dpi-Hinweis)
sizeId: string; // Schlüssel aus PHOTO_SIZES oder 'custom'
customSize: string;
landscape: boolean;
count: number;
allowRotate: boolean;
crop: CropRel;
}
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
/** Größter Ausschnitt mit dem Zielseitenverhältnis, mittig („Bild füllen"). */
function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel {
const imgA = natW / natH;
let w = 1, h = 1;
if (imgA > aspect) { h = 1; w = (aspect / imgA); } else { w = 1; h = (imgA / aspect); }
const cx = around ? around.x + around.w / 2 : 0.5;
const cy = around ? around.y + around.h / 2 : 0.5;
return clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h });
}
function clampCrop(c: CropRel): CropRel {
const w = Math.min(1, Math.max(0.02, c.w));
const h = Math.min(1, Math.max(0.02, c.h));
return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) };
}
const sizeOfCell = (c: Cell): { w: number; h: number } | null => {
const base = c.sizeId === 'custom' ? parseSizeMm(c.customSize) : photoById(c.sizeId);
if (!base) return null;
const s = { w: base.w, h: base.h };
return c.landscape ? { w: s.h, h: s.w } : s;
};
export default function PrintApp() {
const [cells, setCells] = useState<Cell[]>([]);
const [paperId, setPaperId] = useState('A4');
const [paperCustom, setPaperCustom] = useState('');
const [paperLandscape, setPaperLandscape] = useState(false);
const [marginMm, setMarginMm] = useState(5);
const [autoGap, setAutoGap] = useState(true);
const [gapMm, setGapMm] = useState(12);
const [bleedMm, setBleedMm] = useState(0);
const [marks, setMarks] = useState<MarkMode>('corner');
const [markLen, setMarkLen] = useState(4);
const [markOff, setMarkOff] = useState(3);
const [dpi, setDpi] = useState(300);
const [ext, setExt] = useState<'jpg' | 'png'>('jpg');
const [center, setCenter] = useState(true);
const [footer, setFooter] = useState(true);
const [editing, setEditing] = useState<string | null>(null);
const [picker, setPicker] = useState(false);
const [presets, setPresets] = useState<any[]>([]);
const [presetSel, setPresetSel] = useState('');
const [busy, setBusy] = useState<null | 'pdf' | 'single'>(null);
const [toast, setToast] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 3000); };
/* ---------------- Bogen ---------------- */
const paper = useMemo(() => {
const p = paperId === 'custom' ? parseSizeMm(paperCustom) : paperById(paperId);
if (!p) return null;
return paperLandscape ? { w: p.h, h: p.w } : { w: p.w, h: p.h };
}, [paperId, paperCustom, paperLandscape]);
const gapEff = autoGap ? Math.max(recommendedGap(marks, bleedMm, markLen, markOff), 2) : gapMm;
const sheet: SheetSpec | null = paper
? { wMm: paper.w, hMm: paper.h, marginMm, gapMm: gapEff, bleedMm, center }
: null;
const specs: PlaceSpec[] = useMemo(() => cells.flatMap((c) => {
const s = sizeOfCell(c);
if (!s || c.error || !c.src) return [];
return [{ id: c.id, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate }];
}), [cells]);
const plan = useMemo(() => (sheet && specs.length ? layout(specs, sheet) : null), [sheet, specs]);
const marksPerPage = useMemo(() => (plan && sheet)
? plan.pages.map((pg) => cutMarks(pg, sheet, { mode: marks, lengthMm: markLen, offsetMm: markOff, bleedMm }))
: [], [plan, sheet, marks, markLen, markOff, bleedMm]);
/* ---------------- Upload ---------------- */
const addFiles = useCallback(async (files: FileList | File[]) => {
const arr = Array.from(files)
.filter((f) => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name))
.slice(0, 30);
for (const f of arr) {
const id = uid();
const reader = new FileReader();
reader.onload = () => {
const url = reader.result as string;
const probe = new Image();
probe.onload = () => setCells((p) => p.map((x) => x.id === id
? { ...x, preview: url, natW: probe.naturalWidth, natH: probe.naturalHeight,
crop: coverCrop(probe.naturalWidth, probe.naturalHeight, aspectOf(x)) }
: x));
probe.src = url;
};
reader.readAsDataURL(f);
setCells((p) => [...p, newCell(id, f.name)]);
const fd = new FormData(); fd.append('files', f);
try {
const j = await fetch('/api/uploads', { method: 'POST', body: fd }).then((r) => r.json());
const info = j.files?.[0];
setCells((p) => p.map((x) => x.id === id ? {
...x, uploading: false, error: info?.error,
src: info?.source_path ? { kind: 'upload', path: info.source_path } : undefined,
natW: info?.width || x.natW, natH: info?.height || x.natH,
} : x));
} catch {
setCells((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) addFiles(imgs.map((i) => i.getAsFile()!).filter(Boolean));
};
window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste);
}, [addFiles]);
useEffect(() => { loadPresets(); }, []);
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
.then((j) => setPresets(j.presets || [])).catch(() => {});
const addFromLibrary = (it: any) => {
const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0);
const id = uid();
const c: Cell = {
...newCell(id, it.filename || 'Bild'),
uploading: false,
src: { kind: 'item', id: it.id },
preview: `/api/items/${it.id}/file?preview=1`,
natW: w || 2000, natH: h || 3000,
};
c.crop = coverCrop(c.natW, c.natH, aspectOf(c));
setCells((p) => [...p, c]);
};
const patch = (id: string, up: Partial<Cell>) => setCells((p) => p.map((c) => {
if (c.id !== id) return c;
const next = { ...c, ...up };
// Formatwechsel → Ausschnitt auf das neue Seitenverhältnis nachziehen.
if (up.sizeId !== undefined || up.customSize !== undefined || up.landscape !== undefined) {
const a = aspectOf(next);
if (a) next.crop = coverCrop(next.natW, next.natH, a, c.crop);
}
return next;
}));
/* ---------------- Ausgabe ---------------- */
const body = () => ({
paper: paperId === 'custom' ? { size: paperCustom } : { id: paperId },
landscape: paperLandscape,
marginMm, gapMm: gapEff, bleedMm, center, dpi, ext, footer,
marks: { mode: marks, lengthMm: markLen, offsetMm: markOff },
cells: cells.filter((c) => c.src && !c.error && sizeOfCell(c)).map((c) => {
const s = sizeOfCell(c)!;
return { id: c.id, src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate };
}),
});
const makePdf = async () => {
if (!plan?.pages.length) return;
setBusy('pdf');
try {
const res = await fetch('/api/print/sheet', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body()),
});
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; }
const blob = await res.blob();
download(blob, `klarbild-druckbogen-${new Date().toISOString().slice(0, 10)}.pdf`);
notify(`PDF erzeugt · ${res.headers.get('X-Klarbild-Pages') || '?'} Bogen. Beim Drucken „Tatsächliche Größe / 100 %" wählen.`);
} catch { notify('Netzwerkfehler.'); } finally { setBusy(null); }
};
const makeSingle = async (c: Cell) => {
const s = sizeOfCell(c); if (!s || !c.src) return;
setBusy('single');
try {
const res = await fetch('/api/print/single', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name }),
});
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; }
const blob = await res.blob();
download(blob, `${(c.name || 'bild').replace(/\.[^.]+$/, '')}_${labelMm(s.w, s.h).replace(/[^0-9]+/g, 'x')}.${ext}`);
const real = res.headers.get('X-Klarbild-Real-Dpi');
notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0'
? `Heruntergeladen — Achtung: die Quelle reicht nur für ca. ${real} dpi.`
: `Heruntergeladen · ${res.headers.get('X-Klarbild-Px')} px bei ${dpi} dpi.`);
} catch { notify('Netzwerkfehler.'); } finally { setBusy(null); }
};
const savePreset = async () => {
const name = prompt('Name der Vorlage? (z. B. „Kita-Satz Felix")');
if (!name?.trim()) return;
const config = {
paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm,
marks, markLen, markOff, dpi, ext, center, footer,
formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape, count: c.count, allowRotate: c.allowRotate })),
};
await fetch('/api/print/presets', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), config }),
});
notify('Vorlage gespeichert.'); loadPresets();
};
const applyPreset = (p: any) => {
const c = p?.config; if (!c) return;
setPaperId(c.paperId ?? 'A4'); setPaperCustom(c.paperCustom ?? ''); setPaperLandscape(!!c.paperLandscape);
setMarginMm(c.marginMm ?? 5); setAutoGap(c.autoGap !== false); setGapMm(c.gapMm ?? 12);
setBleedMm(c.bleedMm ?? 0); setMarks(c.marks ?? 'corner'); setMarkLen(c.markLen ?? 4); setMarkOff(c.markOff ?? 3);
setDpi(c.dpi ?? 300); setExt(c.ext === 'png' ? 'png' : 'jpg'); setCenter(c.center !== false); setFooter(c.footer !== false);
// Formate auf die vorhandenen Bilder anwenden (der Reihe nach).
if (Array.isArray(c.formats) && c.formats.length) {
setCells((prev) => prev.map((cell, i) => {
const f = c.formats[Math.min(i, c.formats.length - 1)];
const next = { ...cell, sizeId: f.sizeId, customSize: f.customSize || '', landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false };
const a = aspectOf(next); if (a) next.crop = coverCrop(next.natW, next.natH, a, cell.crop);
return next;
}));
}
notify('Vorlage geladen.');
};
const deletePreset = async (id: string) => {
if (!id || !confirm('Vorlage löschen?')) return;
await fetch(`/api/print/presets?id=${id}`, { method: 'DELETE' });
setPresetSel(''); loadPresets();
};
const editCell = cells.find((c) => c.id === editing) || null;
const ready = cells.filter((c) => c.src && !c.error && sizeOfCell(c));
const totalPieces = ready.reduce((n, c) => n + c.count, 0);
return (
<div className="druck">
{/* ---------- 1 · Bilder ---------- */}
<section className="karte">
<div className="kopfzeile">
<span className="mono-label">1 · Bilder</span>
<div className="kopf-tools">
<button className="link" onClick={() => setPicker(true)}>Aus Bibliothek</button>
{cells.length > 0 && <button className="link" onClick={() => setCells([])}>Alle entfernen</button>}
</div>
</div>
<div className="buehne">
{cells.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); addFiles(e.dataTransfer.files); }}>
<span className="regmark" />
<p>Bilder hierher ziehen, <span className="kbd">V</span> einfügen oder klicken.<br />
Kein KI-Schritt nur zuschneiden, skalieren, setzen.</p>
</div>
) : (
<div className="zellen">
{cells.map((c) => <CellCard key={c.id} cell={c} dpi={dpi}
onPatch={(u) => patch(c.id, u)}
onEdit={() => setEditing(c.id)}
onSingle={() => makeSingle(c)}
onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} />)}
<button className="zelle-add" onClick={() => fileRef.current?.click()}> Bild</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
onChange={(e) => e.target.files && addFiles(e.target.files)} />
</div>
</section>
<div className="raster">
{/* ---------- 2 · Bogen ---------- */}
<section className="karte">
<div className="kopfzeile"><span className="mono-label">2 · Bogen &amp; Schnitt</span>
<button className="link" onClick={savePreset}>Als Vorlage sichern</button>
</div>
<div className="steuer">
<div className="feld">
<label>Vorlagen</label>
<div className="reihe">
<select className="select" value={presetSel}
onChange={(e) => { setPresetSel(e.target.value); applyPreset(presets.find((p) => p.id === e.target.value)); }}>
<option value="">Vorlage wählen </option>
{presets.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{presetSel && <button className="pbtn" onClick={() => deletePreset(presetSel)}></button>}
</div>
</div>
<div className="feld">
<label>Papierformat</label>
<select className="select" value={paperId} onChange={(e) => setPaperId(e.target.value)}>
{groupBy(PAPERS, 'group').map(([g, list]) => (
<optgroup key={g} label={g}>
{list.map((p) => <option key={p.id} value={p.id}>{p.label} · {p.w} × {p.h} mm</option>)}
</optgroup>
))}
<option value="custom">Eigenes Papiermaß </option>
</select>
{paperId === 'custom' && (
<input className="input" value={paperCustom} onChange={(e) => setPaperCustom(e.target.value)}
placeholder="z. B. 32,9x48,3 (cm) oder 329x483mm" />
)}
<div className="schalter">
<button className={!paperLandscape ? 'an' : ''} onClick={() => setPaperLandscape(false)}>Hoch</button>
<button className={paperLandscape ? 'an' : ''} onClick={() => setPaperLandscape(true)}>Quer</button>
</div>
{paper && <div className="fein">Bogen {labelMm(paper.w, paper.h)} die PDF-Seite hat exakt dieses Maß.</div>}
</div>
<div className="feld">
<label>Schnitthilfen</label>
<div className="schalter">
<button className={marks === 'none' ? 'an' : ''} onClick={() => setMarks('none')}>Keine</button>
<button className={marks === 'corner' ? 'an' : ''} onClick={() => setMarks('corner')}>Eckmarken</button>
<button className={marks === 'grid' ? 'an' : ''} onClick={() => setMarks('grid')}>Durchgehend</button>
</div>
<div className="fein">
{marks === 'corner' ? 'Feine Marken an den vier Ecken jedes Bildes, außerhalb des Endformats — wie in Photoshop/InDesign.'
: marks === 'grid' ? 'Durchgehende Hilfslinien über den ganzen Bogen — für Schneidelineal und Schlagschere.'
: 'Keine Linien — Kanten selbst anlegen.'}
</div>
{marks === 'corner' && (
<div className="reihe zwei">
<label className="mini">Länge (mm)
<input className="input" type="number" min={1} max={20} step={0.5} value={markLen}
onChange={(e) => setMarkLen(Number(e.target.value))} /></label>
<label className="mini">Abstand (mm)
<input className="input" type="number" min={0} max={20} step={0.5} value={markOff}
onChange={(e) => setMarkOff(Number(e.target.value))} /></label>
</div>
)}
</div>
<div className="feld">
<label>Ränder &amp; Abstände</label>
<div className="reihe zwei">
<label className="mini">Rand zum Papier (mm)
<input className="input" type="number" min={0} max={40} step={0.5} value={marginMm}
onChange={(e) => setMarginMm(Number(e.target.value))} /></label>
<label className="mini">Beschnittzugabe (mm)
<input className="input" type="number" min={0} max={10} step={0.5} value={bleedMm}
onChange={(e) => setBleedMm(Number(e.target.value))} /></label>
</div>
<label className="check">
<input type="checkbox" checked={autoGap} onChange={(e) => setAutoGap(e.target.checked)} />
<span>Abstand automatisch ({gapEff.toFixed(1)} mm passend zu den Marken)</span>
</label>
{!autoGap && (
<input className="input" type="number" min={0} max={60} step={0.5} value={gapMm}
onChange={(e) => setGapMm(Number(e.target.value))} />
)}
<label className="check">
<input type="checkbox" checked={center} onChange={(e) => setCenter(e.target.checked)} />
<span>Auf dem Bogen zentrieren</span>
</label>
<div className="fein">Randlos druckbare Drucker: Rand 0 setzen. Sonst 35 mm sonst schneidet der Drucker Marken weg.</div>
</div>
<div className="feld">
<label>Qualität</label>
<div className="reihe zwei">
<label className="mini">Auflösung (dpi)
<select className="select" value={dpi} onChange={(e) => setDpi(Number(e.target.value))}>
<option value={150}>150 Entwurf</option>
<option value={300}>300 Fotodruck (Standard)</option>
<option value={600}>600 sehr fein</option>
</select></label>
<label className="mini">Dateiformat im PDF
<select className="select" value={ext} onChange={(e) => setExt(e.target.value as any)}>
<option value="jpg">JPG kleiner</option>
<option value="png">PNG verlustfrei</option>
</select></label>
</div>
<label className="check">
<input type="checkbox" checked={footer} onChange={(e) => setFooter(e.target.checked)} />
<span>Fußzeile mit Maßen &amp; Druckhinweis</span>
</label>
</div>
<button className="knopf" disabled={!plan?.pages.length || busy === 'pdf'} onClick={makePdf}>
{busy === 'pdf' ? <><span className="spin" />PDF wird gebaut </> : 'Druck-PDF erzeugen'}
</button>
<div className="fein mitte">Im Druckdialog <b>Tatsächliche Größe" / 100 %</b> wählen — nicht „an Seitengröße anpassen".</div>
</div>
</section>
{/* ---------- 3 · Vorschau ---------- */}
<section className="karte">
<div className="kopfzeile">
<span className="mono-label">3 · Vorschau</span>
{plan && <span className="fein nopad">{plan.pages.length} Bogen · {totalPieces} Bilder</span>}
</div>
<div className="vorschau">
{!sheet ? <p className="leer">Bitte ein gültiges Papiermaß angeben.</p>
: !plan || !plan.pages.length ? <p className="leer">Noch nichts zu zeigen Bilder hinzufügen und Formate wählen.</p>
: plan.pages.map((pg, i) => (
<div key={i} className="bogen-halter">
<div className="bogen" style={{ aspectRatio: `${sheet.wMm} / ${sheet.hMm}` }}>
{pg.placements.map((p, k) => {
const c = cells.find((x) => x.id === p.specId);
if (!c) return null;
return (
<div key={k} className="slot" style={{
left: `${(p.x / sheet.wMm) * 100}%`, top: `${(p.y / sheet.hMm) * 100}%`,
width: `${(p.w / sheet.wMm) * 100}%`, height: `${(p.h / sheet.hMm) * 100}%`,
}}>
<div className="rotor" style={p.rotated ? {
width: `${(p.h / p.w) * 100}%`, height: `${(p.w / p.h) * 100}%`,
transform: 'translate(-50%, -50%) rotate(90deg)',
} : undefined}>
<img src={c.preview} alt="" style={cropStyle(c.crop)} />
</div>
</div>
);
})}
<svg className="marken" viewBox={`0 0 ${sheet.wMm} ${sheet.hMm}`} preserveAspectRatio="none">
{(marksPerPage[i] || []).map((l, k) => (
<line key={k} x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2}
stroke="#16150F" strokeWidth={0.25} vectorEffect="non-scaling-stroke" />
))}
</svg>
</div>
<div className="bogen-fuss">Bogen {i + 1} · {pg.placements.length} Bilder</div>
</div>
))}
{plan?.unplaced?.length ? (
<p className="warnung">Nicht platzierbar: {plan.unplaced.map((u) => {
const c = cells.find((x) => x.id === u.specId);
return `${c?.name || u.specId} (${u.reason})`;
}).join(', ')}</p>
) : null}
</div>
</section>
</div>
{editCell && <CropModal cell={editCell} onClose={() => setEditing(null)}
onChange={(crop) => patch(editCell.id, { crop })} />}
{picker && <LibraryPicker onClose={() => setPicker(false)} onPick={(it) => { addFromLibrary(it); setPicker(false); }} />}
{toast && <div className="toast">{toast}</div>}
<PrintStyles />
</div>
);
}
/* ---------------- Bausteine ---------------- */
function newCell(id: string, name: string): Cell {
return {
id, name, preview: '', uploading: true, natW: 1000, natH: 1500,
sizeId: 'S10x15', customSize: '', landscape: false, count: 1, allowRotate: true,
crop: { x: 0, y: 0, w: 1, h: 1 },
};
}
function aspectOf(c: Cell): number {
const s = sizeOfCell(c);
return s ? s.w / s.h : 1;
}
function cropStyle(c: CropRel): React.CSSProperties {
return {
position: 'absolute',
width: `${100 / c.w}%`, height: `${100 / c.h}%`,
left: `${(-c.x / c.w) * 100}%`, top: `${(-c.y / c.h) * 100}%`,
objectFit: 'fill',
};
}
function groupBy<T extends { group: string }>(list: T[], _k: 'group'): [string, T[]][] {
const m = new Map<string, T[]>();
for (const x of list) { if (!m.has(x.group)) m.set(x.group, []); m.get(x.group)!.push(x); }
return [...m.entries()];
}
function download(blob: Blob, name: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
cell: Cell; dpi: number;
onPatch: (u: Partial<Cell>) => void; onEdit: () => void; onSingle: () => void; onRemove: () => void;
}) {
const s = sizeOfCell(cell);
const check = s ? dpiCheck(cell.crop.w * cell.natW, s.w, dpi) : null;
return (
<div className={`zelle ${cell.error ? 'err' : ''}`}>
<button className="zelle-x" onClick={onRemove} aria-label="Entfernen"></button>
<div className="zelle-bild" style={{ aspectRatio: s ? `${s.w} / ${s.h}` : '3 / 4' }}
onClick={() => !cell.uploading && onEdit()}>
{cell.preview
? <img src={cell.preview} alt="" style={cropStyle(cell.crop)} />
: <span className="lade" />}
{!cell.uploading && <span className="zelle-lupe">Zuschnitt</span>}
</div>
<div className="zelle-steuer">
<select className="select mini-select" value={cell.sizeId} onChange={(e) => onPatch({ sizeId: e.target.value })}>
{groupBy(PHOTO_SIZES, 'group').map(([g, list]) => (
<optgroup key={g} label={g}>
{list.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</optgroup>
))}
<option value="custom">Eigenes Maß </option>
</select>
{cell.sizeId === 'custom' && (
<input className="input mini-input" value={cell.customSize} onChange={(e) => onPatch({ customSize: e.target.value })}
placeholder="12x15 · 35x45mm · 4:3/15" />
)}
<div className="zelle-reihe">
<div className="schalter winzig">
<button className={!cell.landscape ? 'an' : ''} onClick={() => onPatch({ landscape: false })}>Hoch</button>
<button className={cell.landscape ? 'an' : ''} onClick={() => onPatch({ landscape: true })}>Quer</button>
</div>
<div className="anzahl">
<button onClick={() => onPatch({ count: Math.max(1, cell.count - 1) })}></button>
<input type="number" min={1} max={200} value={cell.count}
onChange={(e) => onPatch({ count: Math.min(200, Math.max(1, Number(e.target.value) || 1)) })} />
<button onClick={() => onPatch({ count: Math.min(200, cell.count + 1) })}>+</button>
</div>
</div>
<label className="check winzig">
<input type="checkbox" checked={cell.allowRotate} onChange={(e) => onPatch({ allowRotate: e.target.checked })} />
<span>darf gedreht platziert werden</span>
</label>
{cell.error ? <div className="fein warn">{cell.error}</div> : s && (
<div className={`fein ${check && !check.ok ? 'warn' : ''}`}>
{labelMm(s.w, s.h)} = {mmToPx(s.w, dpi)} × {mmToPx(s.h, dpi)} px
{check && (check.ok ? ' · Auflösung reicht' : ` · nur ca. ${check.dpi} dpi`)}
</div>
)}
<button className="pbtn breit" disabled={!cell.src} onClick={onSingle}>Einzeln herunterladen</button>
</div>
</div>
);
}
/** Zuschnitt: schieben und zoomen, Seitenverhältnis bleibt fest am Zielformat. */
function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: CropRel) => void; onClose: () => void }) {
const s = sizeOfCell(cell);
const aspect = s ? s.w / s.h : 1;
const base = coverCrop(cell.natW, cell.natH, aspect);
const [crop, setCrop] = useState<CropRel>(cell.crop);
const boxRef = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; y: number; crop: CropRel } | null>(null);
const zoom = Math.min(20, Math.max(1, base.w / crop.w));
const setZoom = (z: number) => {
const zz = Math.min(20, Math.max(1, z));
const cx = crop.x + crop.w / 2, cy = crop.y + crop.h / 2;
const w = base.w / zz, h = base.h / zz;
setCrop(clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h }));
};
const onDown = (e: React.PointerEvent) => {
(e.target as Element).setPointerCapture?.(e.pointerId);
drag.current = { x: e.clientX, y: e.clientY, crop };
};
const onMove = (e: React.PointerEvent) => {
if (!drag.current || !boxRef.current) return;
const r = boxRef.current.getBoundingClientRect();
const dx = (e.clientX - drag.current.x) / r.width;
const dy = (e.clientY - drag.current.y) / r.height;
const c = drag.current.crop;
setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h }));
};
const onUp = () => { drag.current = null; };
useEffect(() => {
const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', esc);
return () => window.removeEventListener('keydown', esc);
}, [onClose]);
const apply = () => { onChange(crop); onClose(); };
return (
<div className="modal" onClick={onClose}>
<div className="modal-inhalt" onClick={(e) => e.stopPropagation()}>
<div className="kopfzeile">
<span className="mono-label">Zuschnitt · {s ? labelMm(s.w, s.h) : ''}</span>
<button className="link" onClick={onClose}>Schließen</button>
</div>
<div className="crop-buehne">
<div ref={boxRef} className="crop-box" style={{ aspectRatio: `${aspect}` }}
onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
onWheel={(e) => { setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}>
<img src={cell.preview} alt="" draggable={false} style={cropStyle(crop)} />
<div className="crop-gitter" />
</div>
</div>
<div className="crop-steuer">
<label className="mini breit">Zoom
<input type="range" min={1} max={6} step={0.01} value={Math.min(6, zoom)}
onChange={(e) => setZoom(Number(e.target.value))} />
</label>
<div className="reihe">
<button className="pbtn" onClick={() => setCrop(base)}>Bild füllen</button>
<button className="pbtn" onClick={() => setCrop(clampCrop({ ...crop, x: (1 - crop.w) / 2, y: (1 - crop.h) / 2 }))}>Zentrieren</button>
<button className="knopf schmal" onClick={apply}>Übernehmen</button>
</div>
<div className="fein">Ziehen zum Verschieben, Mausrad oder Regler zum Zoomen. Das Seitenverhältnis bleibt exakt am Zielformat.</div>
</div>
</div>
</div>
);
}
function LibraryPicker({ onPick, onClose }: { onPick: (it: any) => void; onClose: () => void }) {
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/items').then((r) => r.json())
.then((j) => setItems(j.items || [])).catch(() => {}).finally(() => setLoading(false));
}, []);
return (
<div className="modal" onClick={onClose}>
<div className="modal-inhalt breit" onClick={(e) => e.stopPropagation()}>
<div className="kopfzeile">
<span className="mono-label">Aus der Bibliothek wählen</span>
<button className="link" onClick={onClose}>Schließen</button>
</div>
<div className="picker">
{loading ? <p className="leer">Wird geladen </p>
: !items.length ? <p className="leer">Noch keine Bilder in der Bibliothek.</p>
: items.map((it) => (
<button key={it.id} className="pick" onClick={() => onPick(it)} title={it.filename}>
<img src={`/api/items/${it.id}/file?thumb=1`} alt="" loading="lazy" />
<span>{it.filename}</span>
</button>
))}
</div>
</div>
</div>
);
}
function PrintStyles() {
return <style>{`
.druck{display:flex;flex-direction:column;gap:16px;}
.raster{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px;align-items:start;}
@media(max-width:900px){.raster{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;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line);}
.kopf-tools{display:flex;gap:12px;}
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;padding:0;}
.buehne{padding:16px;}
.drop{width:100%;min-height:180px;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;}
.zellen{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px;}
.zelle{position:relative;border:1px solid var(--line);border-radius:var(--radius-sm);background:#fff;padding:9px;display:flex;flex-direction:column;gap:8px;}
.zelle.err{border-color:var(--err);}
.zelle-x{position:absolute;top:5px;right:5px;z-index:3;width:22px;height:22px;border:none;border-radius:50%;background:rgba(22,21,15,.72);color:#fff;cursor:pointer;font-size:13px;line-height:1;}
.zelle-bild{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:3px;cursor:zoom-in;
background:repeating-conic-gradient(oklch(90% 0.006 95) 0% 25%, oklch(96% 0.004 95) 0% 50%) 50%/14px 14px;}
.zelle-bild img{display:block;}
.zelle-lupe{position:absolute;left:0;right:0;bottom:0;background:rgba(22,21,15,.62);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;text-align:center;padding:3px 0;}
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
.zelle-steuer{display:flex;flex-direction:column;gap:6px;}
.zelle-reihe{display:flex;gap:6px;align-items:center;}
.zelle-add{border:1.5px dashed var(--line);background:none;border-radius:var(--radius-sm);cursor:pointer;font-size:14px;color:var(--soft);min-height:120px;font-family:inherit;}
.anzahl{display:flex;align-items:center;border:1px solid var(--line);border-radius:3px;overflow:hidden;background:#fff;}
.anzahl button{width:28px;height:30px;border:none;background:none;cursor:pointer;font-size:15px;color:var(--ink);}
.anzahl input{width:44px;height:30px;border:none;text-align:center;font-family:var(--font-mono);font-size:13px;outline:none;-moz-appearance:textfield;}
.anzahl input::-webkit-outer-spin-button,.anzahl input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0;}
.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{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;}
.mini-select,.mini-input{padding:6px 8px;font-size:12.5px;}
.input:focus,.select:focus{border-color:var(--accent);}
.reihe{display:flex;gap:6px;align-items:center;margin-top:6px;}
.reihe .select{flex:1;}
.reihe.zwei{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.mini{display:block;font-family:var(--font-mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--soft);}
.mini .input,.mini .select{margin-top:4px;}
.mini.breit{width:100%;}
.mini input[type=range]{width:100%;accent-color:var(--accent);margin-top:6px;}
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
.fein.nopad{margin:0;}
.fein.mitte{text-align:center;}
.fein.warn{color:var(--err);}
.check{display:flex;gap:8px;align-items:flex-start;margin-top:8px;font-size:12.5px;color:var(--ink);text-transform:none;letter-spacing:0;font-family:inherit;}
.check input{width:15px;height:15px;accent-color:var(--accent);margin-top:2px;flex:0 0 auto;}
.check.winzig{font-size:11px;color:var(--soft);}
.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.winzig{margin:0;flex:1;}
.schalter.winzig button{padding:5px 4px;font-size:11.5px;}
.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;}
.pbtn.breit{width:100%;}
.pbtn:disabled{opacity:.5;cursor:not-allowed;}
.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.schmal{width:auto;padding:9px 16px;font-size:13.5px;}
.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);}}
.vorschau{padding:16px;display:flex;flex-direction:column;gap:16px;}
.leer{color:var(--soft);font-size:13px;margin:0;text-align:center;padding:26px 0;}
.bogen-halter{display:flex;flex-direction:column;gap:6px;}
.bogen{position:relative;width:100%;background:#fff;border:1px solid var(--line);box-shadow:var(--shadow);overflow:hidden;}
.slot{position:absolute;overflow:hidden;}
.rotor{position:absolute;left:50%;top:50%;width:100%;height:100%;transform:translate(-50%,-50%);overflow:hidden;}
.rotor img{position:absolute;}
.marken{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;}
.bogen-fuss{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);}
.warnung{color:var(--err);font-size:12px;margin:0;}
.modal{position:fixed;inset:0;background:rgba(22,21,15,.55);display:flex;align-items:center;justify-content:center;z-index:70;padding:16px;}
.modal-inhalt{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);width:min(560px,100%);max-height:90vh;overflow:auto;}
.modal-inhalt.breit{width:min(880px,100%);}
.crop-buehne{padding:16px;display:flex;justify-content:center;background:var(--paper);}
.crop-box{position:relative;width:100%;max-width:420px;max-height:56vh;overflow:hidden;background:#fff;border:1px solid var(--line);cursor:grab;touch-action:none;user-select:none;}
.crop-box:active{cursor:grabbing;}
.crop-box img{position:absolute;pointer-events:none;}
.crop-gitter{position:absolute;inset:0;pointer-events:none;
background:linear-gradient(to right,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px)),
linear-gradient(to bottom,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px));
box-shadow:inset 0 0 0 1px rgba(27,59,224,.35);}
.crop-steuer{padding:14px 16px;display:flex;flex-direction:column;gap:8px;}
.picker{padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;}
.pick{border:1px solid var(--line);background:#fff;border-radius:3px;padding:0;cursor:pointer;overflow:hidden;display:flex;flex-direction:column;font-family:inherit;}
.pick img{width:100%;aspect-ratio:1;object-fit:cover;display:block;}
.pick span{font-size:10px;color:var(--soft);padding:4px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.pick:hover{border-color:var(--accent);}
.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:80;max-width:min(92vw,520px);text-align:center;line-height:1.45;}
`}</style>;
}
+2
View File
@@ -14,6 +14,7 @@ const active = (href: string) => href === '/' ? path === '/' : path.startsWith(h
const nav = [
{ href: '/', label: 'Studio', icon: 'studio' },
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
{ href: '/druck', label: 'Druck', icon: 'print' },
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
{ href: '/anleitung', label: 'Hilfe', icon: 'help' },
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
@@ -25,6 +26,7 @@ const ICONS: Record<string, string> = {
library: `<svg viewBox="0 0 24 24" ${S}><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></svg>`,
queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`,
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`,
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
};
---
+16
View File
@@ -0,0 +1,16 @@
---
import Base from '../layouts/Base.astro';
import PrintApp from '../components/PrintApp.tsx';
---
<Base title="Druck · Klarbild">
<h1 class="seiten-titel">Druck</h1>
<p class="seiten-lead">
Bilder ohne KI auf exakte Maße bringen und mehrere davon in 100 %-Größe mit
Schnittmarken auf einen Bogen setzen — Passbildsatz, Kita-Bilder, Sticker.
</p>
<PrintApp client:load />
<style>
.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
.seiten-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
</style>
</Base>