← back to Wallco Ai
feat(designs-grid): Contact sheet button on admin bulk-select toolbar
9b7f1ebeed3561ccccdca88a187592b6d46506b1 · 2026-05-29 08:43:22 -0700 · Steve Abrams
Select N cards (checkbox / rubber-band drag) then '\U0001F4C7 Contact sheet' builds
a client-side canvas montage (cover-fit grid + header + per-card DIG/SKU label)
and downloads a PNG. Zero server load, no new endpoint. Images sourced from the
stable /designs/img/by-id/:id route — parsing card background-image was
unreliable (lazy-loaded / var(--card-bg) backgrounds rendered every cell
'image unavailable').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit 9b7f1ebeed3561ccccdca88a187592b6d46506b1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri May 29 08:43:22 2026 -0700
feat(designs-grid): Contact sheet button on admin bulk-select toolbar
Select N cards (checkbox / rubber-band drag) then '\U0001F4C7 Contact sheet' builds
a client-side canvas montage (cover-fit grid + header + per-card DIG/SKU label)
and downloads a PNG. Zero server load, no new endpoint. Images sourced from the
stable /designs/img/by-id/:id route — parsing card background-image was
unreliable (lazy-loaded / var(--card-bg) backgrounds rendered every cell
'image unavailable').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
server.js | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 104 insertions(+)
diff --git a/server.js b/server.js
index 7ac84c0..0626f9e 100644
--- a/server.js
+++ b/server.js
@@ -6697,6 +6697,7 @@ ${(req.query.source === 'all') ? `
<button type="button" data-bulk="publish" style="background:#5a8a5a;color:#fff;border:0;padding:6px 14px;border-radius:999px;cursor:pointer;font-weight:600">Publish</button>
<button type="button" data-bulk="unpublish" style="background:#8a7a5a;color:#fff;border:0;padding:6px 14px;border-radius:999px;cursor:pointer;font-weight:600">Unpublish</button>
<button type="button" data-bulk="delete" style="background:#b04030;color:#fff;border:0;padding:6px 14px;border-radius:999px;cursor:pointer;font-weight:600">Delete</button>
+ <button type="button" data-bulk="contactsheet" style="background:#3a6ea5;color:#fff;border:0;padding:6px 14px;border-radius:999px;cursor:pointer;font-weight:600" title="Build a downloadable contact sheet PNG of the selected designs">📇 Contact sheet</button>
<button type="button" data-bulk="cancel" style="background:transparent;color:#fff;border:1px solid rgba(255,255,255,.3);padding:6px 12px;border-radius:999px;cursor:pointer">Cancel</button>
</div>
` : ''}
@@ -6790,6 +6791,108 @@ ${(req.query.source === 'all') ? `
grid.querySelectorAll('.design-card.bulk-selected').forEach(function(c){ c.classList.remove('bulk-selected'); var box = c.querySelector('.bulk-cb'); if (box) box.innerHTML = ''; });
selected.clear(); if (toolbar) toolbar.style.display = 'none';
}
+ // ── Contact sheet — client-side canvas montage of the selected cards ──
+ // Reuses the existing bulk-select selected Set. Pulls each card's design
+ // thumbnail (same-origin, so the canvas never taints) + title/SKU label,
+ // tiles them into a grid, stamps a header, and downloads a PNG. Zero server
+ // load, no new endpoint. Steve 2026-05-29.
+ function cardInfo(id){
+ var c = grid.querySelector('[data-design-id="'+id+'"], [data-id="'+id+'"]');
+ if (!c) return null;
+ // Always source from the stable /designs/img/by-id/:id route rather than
+ // parsing the card's CSS background-image — backgrounds can be empty
+ // (lazy-loaded / off-screen) or set via var(--card-bg), which made the
+ // parsed URL unreliable and rendered every cell "image unavailable".
+ var src = '/designs/img/by-id/' + id;
+ var t = c.querySelector('.card-title');
+ var s = c.querySelector('.card-sku');
+ var dig = c.getAttribute('data-dig');
+ return { id:id, src:src,
+ title:(t&&t.textContent||'').trim() || (dig||''),
+ sku:(s&&s.textContent||'').trim() };
+ }
+ function loadImg(src){
+ return new Promise(function(resolve){
+ var im = new Image();
+ // No crossOrigin — design images are always same-origin as the page,
+ // so the canvas won't taint. Setting crossOrigin='anonymous' forced
+ // CORS mode and the static handler (no ACAO header) failed the load.
+ im.onload = function(){ resolve(im); };
+ im.onerror = function(){ resolve(null); };
+ im.src = src;
+ });
+ }
+ function buildContactSheet(ids, btn){
+ if (!ids.length) return;
+ var infos = ids.map(cardInfo).filter(Boolean);
+ if (!infos.length) { alert('Could not read the selected cards.'); return; }
+ var origLabel = btn ? btn.textContent : '';
+ if (btn) { btn.disabled = true; btn.style.opacity = '.6'; btn.textContent = 'Building… 0/' + infos.length; }
+ var n = infos.length;
+ var cols = Math.min(6, Math.max(1, Math.ceil(Math.sqrt(n))));
+ var rows = Math.ceil(n / cols);
+ var CELL = 320, LBL = 38, PAD = 12, HEAD = 64;
+ var W = cols * CELL + (cols + 1) * PAD;
+ var H = HEAD + rows * (CELL + LBL) + (rows + 1) * PAD;
+ var cv = document.createElement('canvas');
+ cv.width = W; cv.height = H;
+ var ctx = cv.getContext('2d');
+ ctx.fillStyle = '#18181c'; ctx.fillRect(0, 0, W, H);
+ // header
+ ctx.fillStyle = '#f2e9d3';
+ ctx.font = '600 26px Georgia, serif';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('wallco.ai — contact sheet', PAD + 4, HEAD / 2 - 2);
+ ctx.fillStyle = '#9a9488';
+ ctx.font = '14px system-ui, sans-serif';
+ var stamp = new Date().toLocaleString();
+ var meta = n + ' design' + (n === 1 ? '' : 's') + ' · ' + stamp;
+ ctx.textAlign = 'right';
+ ctx.fillText(meta, W - PAD - 4, HEAD / 2 - 2);
+ ctx.textAlign = 'left';
+ var loaded = 0;
+ Promise.all(infos.map(function(inf){ return loadImg(inf.src); })).then(function(imgs){
+ for (var i = 0; i < infos.length; i++) {
+ var r = Math.floor(i / cols), c = i % cols;
+ var x = PAD + c * (CELL + PAD);
+ var y = HEAD + PAD + r * (CELL + LBL + PAD);
+ ctx.fillStyle = '#222'; ctx.fillRect(x, y, CELL, CELL);
+ var im = imgs[i];
+ if (im) {
+ // cover-fit into CELL×CELL
+ var ar = im.width / im.height, cr = 1;
+ var sw, sh, sx, sy;
+ if (ar > cr) { sh = im.height; sw = sh * cr; sx = (im.width - sw) / 2; sy = 0; }
+ else { sw = im.width; sh = sw / cr; sx = 0; sy = (im.height - sh) / 2; }
+ try { ctx.drawImage(im, sx, sy, sw, sh, x, y, CELL, CELL); }
+ catch (e) { ctx.fillStyle = '#3a2a2a'; ctx.fillRect(x, y, CELL, CELL); }
+ } else {
+ ctx.fillStyle = '#b04030'; ctx.font = '13px system-ui';
+ ctx.fillText('image unavailable', x + 12, y + CELL / 2);
+ }
+ // label
+ ctx.fillStyle = '#0e0e10'; ctx.fillRect(x, y + CELL, CELL, LBL);
+ ctx.fillStyle = '#e8e2d4'; ctx.font = '600 13px system-ui, sans-serif';
+ var line = (infos[i].title || ('#' + infos[i].id));
+ if (infos[i].sku) line += ' · ' + infos[i].sku;
+ // clip overlong labels
+ var maxw = CELL - 16;
+ while (ctx.measureText(line).width > maxw && line.length > 4) line = line.slice(0, -2);
+ if (line !== (infos[i].title + (infos[i].sku ? ' · ' + infos[i].sku : '')) && infos[i].title) line = line.replace(/\s*$/, '…');
+ ctx.fillText(line, x + 8, y + CELL + LBL / 2);
+ }
+ cv.toBlob(function(blob){
+ if (btn) { btn.disabled = false; btn.style.opacity = '1'; btn.textContent = origLabel; }
+ if (!blob) { alert('Contact sheet render failed.'); return; }
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement('a');
+ var ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ a.href = url; a.download = 'wallco-contact-sheet-' + ts + '.png';
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(function(){ URL.revokeObjectURL(url); }, 4000);
+ }, 'image/png');
+ });
+ }
injectCheckboxes();
new MutationObserver(injectCheckboxes).observe(grid, { childList: true });
@@ -6825,6 +6928,7 @@ ${(req.query.source === 'all') ? `
var btn = e.target.closest('[data-bulk]'); if (!btn) return;
var action = btn.getAttribute('data-bulk');
if (action === 'cancel') { clearSelection(); return; }
+ if (action === 'contactsheet') { buildContactSheet(Array.from(selected), btn); return; }
var ids = Array.from(selected); if (!ids.length) return;
var labels = { tag:'Tag', publish:'Publish', unpublish:'Unpublish', 'delete':'Delete' };
if (!confirm(labels[action] + ' ' + ids.length + ' design(s)?')) return;
← 875105c haiku weekend test: yolo-queue runner (max-plan path) + SQL
·
back to Wallco Ai
·
Add sweep-orphan-rooms.py: remove unreferenced room PNGs (dr 6ef7cef →