feat: sheet templates, margin profiles and delivering the sheet

Seeds the four sheet setups Till actually prints (Kita set, small school set,
full passport sheet, two 10x7,5 on 10x15 paper). A multi-format template
applied to a single image clones it into every format, so one click yields the
whole set. Margin gets one-click borderless / 5 mm / 10 mm profiles, and the
finished PDF can be pushed to Picdrop, the NAS or any extra target.
This commit is contained in:
till committed 2026-08-18 06:40:31 +00:00
1 parent 21a0a68972
commit 4fb34cdab8
6 files changed
+125 -15

No files matched your search

+51 -11
View File
@@ -70,6 +70,9 @@ export default function PrintApp() {
const [ext, setExt] = useState<'jpg' | 'png'>('jpg');
const [center, setCenter] = useState(true);
const [footer, setFooter] = useState(true);
const [deliverTo, setDeliverTo] = useState<string>(''); // '' = nur herunterladen
const [gallery, setGallery] = useState('');
const [targets, setTargets] = useState<any[]>([]);
const [editing, setEditing] = useState<string | null>(null);
const [picker, setPicker] = useState(false);
@@ -149,7 +152,10 @@ export default function PrintApp() {
return () => window.removeEventListener('paste', onPaste);
}, [addFiles]);
useEffect(() => { loadPresets(); }, []);
useEffect(() => {
loadPresets();
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
}, []);
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
.then((j) => setPresets(j.presets || [])).catch(() => {});
@@ -183,6 +189,7 @@ export default function PrintApp() {
paper: paperId === 'custom' ? { size: paperCustom } : { id: paperId },
landscape: paperLandscape,
marginMm, gapMm: gapEff, bleedMm, center, dpi, ext, footer,
...(deliverTo ? { deliver: { target: deliverTo === 'picdrop' ? '' : deliverTo, gallery: gallery.trim() || undefined } } : {}),
marks: { mode: marks, lengthMm: markLen, offsetMm: markOff },
cells: cells.filter((c) => c.src && !c.error && sizeOfCell(c)).map((c) => {
const s = sizeOfCell(c)!;
@@ -200,7 +207,9 @@ export default function PrintApp() {
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.`);
const dmsgRaw = res.headers.get('X-Klarbild-Delivery-Msg');
const dmsg = dmsgRaw ? ` ${decodeURIComponent(dmsgRaw)}` : '';
notify(`PDF erzeugt · ${res.headers.get('X-Klarbild-Pages') || '?'} Bogen. Beim Drucken „Tatsächliche Größe / 100 %" wählen.${dmsg}`);
} catch { notify('Netzwerkfehler.'); } finally { setBusy(null); }
};
@@ -227,7 +236,7 @@ export default function PrintApp() {
if (!name?.trim()) return;
const config = {
paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm,
marks, markLen, markOff, dpi, ext, center, footer,
marks, markLen, markOff, dpi, ext, center, footer, deliverTo, gallery,
formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape, count: c.count, allowRotate: c.allowRotate })),
};
await fetch('/api/print/presets', {
@@ -243,16 +252,26 @@ export default function PrintApp() {
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).
setDeliverTo(c.deliverTo ?? ''); setGallery(c.gallery ?? '');
// Formate anwenden. Der typische Fall ist ein Bild und mehrere Größen
// („Kita-Satz": 1× 13×18, 2× 9×13, 8× Passbild) — dann wird das Bild geklont.
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;
}));
setCells((prev) => {
if (!prev.length) return prev;
const apply = (cell: Cell, f: any): Cell => {
const next = { ...cell, id: cell.id, 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;
};
if (prev.length === 1 && c.formats.length > 1)
return c.formats.map((f: any, i: number) => apply({ ...prev[0], id: i === 0 ? prev[0].id : uid() }, f));
return prev.map((cell, i) => apply(cell, c.formats[Math.min(i, c.formats.length - 1)]));
});
}
notify('Vorlage geladen.');
notify(Array.isArray(c.formats) && c.formats.length > 1
? 'Vorlage geladen — das Bild wurde für jedes Format übernommen.'
: 'Vorlage geladen.');
};
const deletePreset = async (id: string) => {
@@ -368,6 +387,11 @@ export default function PrintApp() {
<div className="feld">
<label>Ränder &amp; Abstände</label>
<div className="schalter zart">
<button className={marginMm === 0 ? 'an' : ''} onClick={() => setMarginMm(0)}>Randlos</button>
<button className={marginMm === 5 ? 'an' : ''} onClick={() => setMarginMm(5)}>Standard 5 mm</button>
<button className={marginMm === 10 ? 'an' : ''} onClick={() => setMarginMm(10)}>Sicher 10 mm</button>
</div>
<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}
@@ -412,6 +436,21 @@ export default function PrintApp() {
</label>
</div>
<div className="feld">
<label>Wohin?</label>
<select className="select" value={deliverTo} onChange={(e) => setDeliverTo(e.target.value)}>
<option value="">Nur herunterladen</option>
<option value="picdrop">Zusätzlich an Picdrop (Standard)</option>
<option value="nas">Zusätzlich aufs NAS</option>
{targets.map((t) => <option key={t.id} value={t.id}>Zusätzlich an „{t.name}"</option>)}
</select>
{deliverTo && (
<input className="input" style={{ marginTop: 7 }} value={gallery} onChange={(e) => setGallery(e.target.value)}
placeholder="Galerie/Ordner (leer = Standard)" />
)}
<div className="fein">Der fertige Bogen wird als PDF geladen — auf Wunsch zusätzlich ins gewohnte Ziel gelegt.</div>
</div>
<button className="knopf" disabled={!plan?.pages.length || busy === 'pdf'} onClick={makePdf}>
{busy === 'pdf' ? <><span className="spin" />PDF wird gebaut …</> : 'Druck-PDF erzeugen'}
</button>
@@ -722,6 +761,7 @@ function PrintStyles() {
.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:12px;padding:6px 4px;}
.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;}