← back to Paul Conrad Cartoons
generator/build-contact-sheet.mjs
98 lines
#!/usr/bin/env node
// build-contact-sheet.mjs — writes generator/out/index.html, a LOCAL-ONLY contact
// sheet for the Shadow Man sample batch. The manifest is embedded inline so the
// page works straight from file:// (no server). Sort <select> + density slider,
// both persisted in localStorage; every card shows caption + created date/time.
// Usage: node generator/build-contact-sheet.mjs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const OUT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'out');
const man = JSON.parse(fs.readFileSync(path.join(OUT, 'manifest.json'), 'utf8'));
const data = man.items.map(({ id, created_at, theme, era, caption, seed, gen_seconds, file }) => ({ id, created_at, theme, era, caption, seed, gen_seconds, file }));
const esc = s => JSON.stringify(s).replace(/</g, '\\u003c');
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex">
<title>Shadow Man — sample contact sheet (local)</title>
<style>
:root { --min: 300px; }
* { box-sizing: border-box; }
body { margin: 0; font: 15px/1.4 Georgia, serif; background: #f4f1ea; color: #111; }
header { padding: 18px 24px 8px; border-bottom: 3px solid #111; background: #fff; position: sticky; top: 0; z-index: 2; }
h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: .5px; }
.sub { color: #555; font-size: 13px; }
.controls { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; margin-top: 10px; font-family: system-ui, sans-serif; font-size: 13px; }
.controls label { display: flex; gap: 8px; align-items: center; }
select, input[type=range] { font: inherit; }
main { padding: 20px 24px 40px; display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(var(--min), 1fr)); }
.card { background: #fff; border: 2px solid #111; box-shadow: 4px 4px 0 #111; display: flex; flex-direction: column; }
.card img { width: 100%; display: block; border-bottom: 1px solid #ddd; background: #eee; aspect-ratio: 1176 / 1000; object-fit: contain; }
.body { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 6px; }
.cap { font-style: italic; font-weight: bold; font-size: 16px; }
.meta { font-family: system-ui, sans-serif; font-size: 12px; color: #444; display: flex; gap: 6px; flex-wrap: wrap; }
.chip { border: 1px solid #999; border-radius: 10px; padding: 1px 8px; }
.when { font-family: system-ui, sans-serif; font-size: 12px; color: #222; }
</style></head>
<body>
<header>
<h1>Shadow Man — sample batch</h1>
<div class="sub">${data.length} original AI editorial cartoons · local SDXL · $0 · signed “Shadow Man” · local-only preview, not published</div>
<div class="controls">
<label for="sort">Sort
<select id="sort">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="theme">Theme A→Z</option>
<option value="era">Era</option>
<option value="caption">Caption A→Z</option>
<option value="gen">Render time ↓</option>
</select>
</label>
<label for="density">Density
<input id="density" type="range" min="180" max="620" step="20" value="300" aria-label="Card minimum width in pixels">
<span id="dval">300px</span>
</label>
</div>
</header>
<main id="grid" aria-live="polite"></main>
<script>
const DATA = ${esc(data)};
const grid = document.getElementById('grid'), sortSel = document.getElementById('sort'), dens = document.getElementById('density'), dval = document.getElementById('dval');
const fmtDate = iso => new Date(iso).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
const SORTS = {
newest: (a, b) => b.created_at.localeCompare(a.created_at),
oldest: (a, b) => a.created_at.localeCompare(b.created_at),
theme: (a, b) => a.theme.localeCompare(b.theme) || a.id.localeCompare(b.id),
era: (a, b) => a.era.localeCompare(b.era) || a.id.localeCompare(b.id),
caption: (a, b) => a.caption.replace(/^\\W+/, '').localeCompare(b.caption.replace(/^\\W+/, '')),
gen: (a, b) => b.gen_seconds - a.gen_seconds,
};
function render() {
grid.replaceChildren(...[...DATA].sort(SORTS[sortSel.value] || SORTS.newest).map(d => {
const c = el('article', 'card');
const img = el('img'); img.src = d.file; img.alt = 'Shadow Man editorial cartoon: ' + d.caption; img.loading = 'lazy';
const b = el('div', 'body');
b.append(el('div', 'cap', d.caption));
const m = el('div', 'meta'); m.append(el('span', 'chip', d.theme), el('span', 'chip', d.era), el('span', 'chip', d.gen_seconds + 's'));
const w = el('div', 'when', '\\u{1F553} ' + fmtDate(d.created_at)); w.title = d.created_at;
b.append(m, w); c.append(img, b); return c;
}));
}
function setDensity(v) { document.documentElement.style.setProperty('--min', v + 'px'); dval.textContent = v + 'px'; }
sortSel.value = localStorage.getItem('shadowman.sort') || 'newest';
dens.value = localStorage.getItem('shadowman.density') || '300';
setDensity(dens.value);
sortSel.addEventListener('change', () => { localStorage.setItem('shadowman.sort', sortSel.value); render(); });
dens.addEventListener('input', () => { localStorage.setItem('shadowman.density', dens.value); setDensity(dens.value); });
render();
</script>
</body></html>
`;
fs.writeFileSync(path.join(OUT, 'index.html'), html);
console.log(`wrote ${path.join(OUT, 'index.html')} (${data.length} cards)`);