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:
+51
-11
@@ -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 & 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;}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ async function galleryFor(item: { folder_id: string | null; job_id: string }): P
|
||||
return s?.picdrop_default_gallery || DEFAULT_GALLERY;
|
||||
}
|
||||
|
||||
async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
|
||||
export async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
|
||||
if (!key) return null;
|
||||
if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; }
|
||||
if (key === 'picdrop') return loadConfig();
|
||||
|
||||
@@ -54,6 +54,37 @@ export async function seed(): Promise<void> {
|
||||
{ contour_mm: 3 });
|
||||
}
|
||||
|
||||
// Druck-Vorlagen (Bogen ohne KI) — idempotent über den Namen, auch für bestehende Installationen.
|
||||
{
|
||||
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE username='till'`);
|
||||
const by = admin?.id ?? null;
|
||||
const P = async (name: string, config: Record<string, unknown>) => {
|
||||
const exists = await one(`SELECT id FROM print_presets WHERE name=$1`, [name]);
|
||||
if (exists) return;
|
||||
await query(`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3)`,
|
||||
[name, by, JSON.stringify(config)]);
|
||||
};
|
||||
const sheet = {
|
||||
paperId: 'A4', paperCustom: '', paperLandscape: false, marginMm: 5,
|
||||
autoGap: true, gapMm: 14, bleedMm: 0,
|
||||
marks: 'corner', markLen: 4, markOff: 3, dpi: 300, ext: 'jpg', center: true, footer: true,
|
||||
};
|
||||
const F = (sizeId: string, count: number, landscape = false) =>
|
||||
({ sizeId, customSize: '', landscape, count, allowRotate: true });
|
||||
|
||||
// Der klassische Kita-/Schulfoto-Satz aus einem Bild.
|
||||
await P('Kita-Satz (A4)', { ...sheet, formats: [F('S13x18', 1), F('S9x13', 2), F('P35x45', 8)] });
|
||||
// Nur die kleinen Abzüge, wenn der große schon gedruckt ist.
|
||||
await P('Schulsatz klein (A4)', { ...sheet, formats: [F('S9x13', 4), F('K30x40', 8)] });
|
||||
// Voller Passbildbogen — durchgehende Linien, weil alles gleich groß ist.
|
||||
await P('Passbildbogen 35×45 (A4)', { ...sheet, marks: 'grid', autoGap: false, gapMm: 4, formats: [F('P35x45', 20)] });
|
||||
// Zwei Bilder in Originalgröße auf ein Blatt 10×15-Fotopapier.
|
||||
await P('2 × 10×7,5 auf Fotopapier 10×15', {
|
||||
...sheet, paperId: 'F10x15', marginMm: 0, marks: 'grid', autoGap: false, gapMm: 0,
|
||||
formats: [{ sizeId: 'custom', customSize: '10x7,5', landscape: false, count: 2, allowRotate: true }],
|
||||
});
|
||||
}
|
||||
|
||||
// The-Frame-Rezept auf die eigene Galerie verdrahten (auch für bestehende Installationen).
|
||||
await query(
|
||||
`UPDATE recipes SET picdrop_gallery='TheFrame-Backgrounds', delivery='both'
|
||||
|
||||
@@ -85,8 +85,16 @@ import Base from '../layouts/Base.astro';
|
||||
schiefem Schnitt kein weißer Rand entsteht. Der Abstand zwischen zwei Bildern wächst automatisch mit.</li>
|
||||
<li><b>Einzeln herunterladen</b>: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.</li>
|
||||
<li><b>Auflösungswarnung</b>: reicht die Quelle nicht für die gewählte dpi, steht es rot unter dem Bild.</li>
|
||||
<li><b>Vorlagen</b>: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern —
|
||||
z. B. „Kita-Satz Felix" oder „8 × Passbild".</li>
|
||||
<li><b>Vorlagen</b>: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern.
|
||||
Mitgeliefert sind <b>Kita-Satz (A4)</b> (1 × 13 × 18, 2 × 9 × 13, 8 × Passbild),
|
||||
<b>Schulsatz klein (A4)</b>, <b>Passbildbogen 35 × 45 (A4)</b> und
|
||||
<b>2 × 10 × 7,5 auf Fotopapier 10 × 15</b>. Lädst du eine mehrformatige Vorlage bei nur
|
||||
<i>einem</i> Bild, wird das Bild automatisch für jedes Format übernommen — ein Klick,
|
||||
fertiger Satz.</li>
|
||||
<li><b>Randprofile</b>: <b>Randlos</b> (0 mm), <b>Standard</b> (5 mm) oder <b>Sicher</b> (10 mm)
|
||||
per Knopf, der genaue Wert bleibt frei eintragbar.</li>
|
||||
<li><b>Wohin?</b>: der fertige Bogen kann zusätzlich an Picdrop, aufs NAS oder an ein
|
||||
Zusatzziel gelegt werden — wie die Bilder aus dem Studio.</li>
|
||||
<li><b>Automatische Anordnung</b>: gleich große Bilder ergeben ein sauberes Raster; bei gemischten
|
||||
Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.</li>
|
||||
</ul>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type Ma
|
||||
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf } from '../../../lib/printrender';
|
||||
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
|
||||
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
|
||||
import { cfgForKey } from '../../../lib/delivery';
|
||||
import { uploadBuffer } from '../../../lib/picdrop';
|
||||
import { one } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -103,12 +106,34 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2),
|
||||
});
|
||||
|
||||
const filename = sheetFilename(body?.name || 'druckbogen', 'pdf');
|
||||
|
||||
// Optional: den fertigen Bogen zusätzlich ins gewohnte Ziel schieben
|
||||
// (Standard-Picdrop, NAS oder ein Zusatzziel) — wie bei den Bildern.
|
||||
let delivered = '0', deliveryMsg = '';
|
||||
if (body?.deliver) {
|
||||
const key = body.deliver.target === '' || body.deliver.target == null ? 'picdrop' : String(body.deliver.target);
|
||||
try {
|
||||
const cfg = await cfgForKey(key);
|
||||
if (!cfg) throw new Error('Ziel nicht konfiguriert.');
|
||||
const s = await one<{ picdrop_default_gallery: string | null }>('SELECT picdrop_default_gallery FROM settings WHERE id=1');
|
||||
const gallery = String(body.deliver.gallery || '').trim() || s?.picdrop_default_gallery || 'POSTER LEA';
|
||||
await uploadBuffer(cfg, gallery, filename, Buffer.from(bytes));
|
||||
delivered = '1';
|
||||
deliveryMsg = `Bogen nach „${gallery}" ausgeliefert.`;
|
||||
} catch (e: any) {
|
||||
deliveryMsg = `Auslieferung fehlgeschlagen: ${e?.message || e}`;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(Buffer.from(bytes), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${sheetFilename(body?.name || 'druckbogen', 'pdf')}"`,
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'X-Klarbild-Pages': String(pages.length),
|
||||
'X-Klarbild-Per-Sheet': String(plan.perSheet),
|
||||
'X-Klarbild-Delivered': delivered,
|
||||
...(deliveryMsg ? { 'X-Klarbild-Delivery-Msg': encodeURIComponent(deliveryMsg) } : {}),
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
|
||||
Reference in New Issue
Block a user