[object Object]

← 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 →