[object Object]

← back to Wallco Ai

cactus-curator by-base: full selection + action parity with index — sel-box, .acts (Bad/Digital/Fix/Live/Etsy), state label, decided class. Delegated click handler on #bbstack survives renderStack innerHTML rebuilds. applySelection/toggleSel resolve cards across #grid AND #bbstack. Marquee drag-select binds to both containers + enumerates cards in either to extend rubber-band across the stack

ade52372008b03d383063000ee33f02554c3e517 · 2026-05-28 07:13:46 -0700 · Steve Abrams

Files touched

Diff

commit ade52372008b03d383063000ee33f02554c3e517
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 07:13:46 2026 -0700

    cactus-curator by-base: full selection + action parity with index — sel-box, .acts (Bad/Digital/Fix/Live/Etsy), state label, decided class. Delegated click handler on #bbstack survives renderStack innerHTML rebuilds. applySelection/toggleSel resolve cards across #grid AND #bbstack. Marquee drag-select binds to both containers + enumerates cards in either to extend rubber-band across the stack
---
 public/admin/cactus-curator.html | 90 ++++++++++++++++++++++++++++++++++++----
 public/admin/seam-debug.html     |  9 +++-
 2 files changed, 89 insertions(+), 10 deletions(-)

diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 2377d0a..6bf2807 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -488,6 +488,10 @@ function updateStat(){
 }
 
 function toggleSel(id, el){
+  // Resolve the card element across BOTH the main grid AND the by-base stack
+  // (#bbstack). When toggled from a delegated handler `el` is passed; on
+  // keyboard 'x' from any hovered card we look it up here.
+  el = el || document.querySelector(`#grid .card[data-id="${id}"], #bbstack .card[data-id="${id}"]`);
   if(selected.has(id)){ selected.delete(id); el?.classList.remove('sel'); }
   else { selected.add(id); el?.classList.add('sel'); }
   $('bulkn').textContent = selected.size;
@@ -663,22 +667,42 @@ $('modal').addEventListener('click', e => { if(e.target.id==='modal') closeDetai
 
 // reconcile the live selection Set + .sel classes + bulk bar to a target set
 function applySelection(next){
-  for(const id of [...selected]) if(!next.has(id)){ selected.delete(id); grid.querySelector(`.card[data-id="${id}"]`)?.classList.remove('sel'); }
-  for(const id of next) if(!selected.has(id)){ selected.add(id); grid.querySelector(`.card[data-id="${id}"]`)?.classList.add('sel'); }
+  // Find the card in EITHER the flat grid (#grid) or the by-base stack
+  // (#bbstack) so marquee-select works in both layouts.
+  const findCard = id => document.querySelector(`#grid .card[data-id="${id}"], #bbstack .card[data-id="${id}"]`);
+  for(const id of [...selected]) if(!next.has(id)){ selected.delete(id); findCard(id)?.classList.remove('sel'); }
+  for(const id of next) if(!selected.has(id)){ selected.add(id); findCard(id)?.classList.add('sel'); }
   $('bulkn').textContent = selected.size;
   $('bulk').classList.toggle('show', selected.size>0);
 }
 
 // ── marquee rubber-band select: drag = select, shift+drag = add ────────────
+// Works across BOTH the flat grid (#grid) and the by-base family stack
+// (#bbstack). Mousedown can start on either; mousemove enumerates every
+// .card in either container and adds any that intersect the marquee rect.
 (function(){
   const mq = $('marquee'), THRESH = 5;
   let dragging=false, moved=false, sx=0, sy=0, baseSel=null;
-  grid.addEventListener('mousedown', e => {
+  function startDrag(e){
     if(e.button!==0) return;
-    if(e.target.closest('.acts') || e.target.classList.contains('sel-box')) return; // not on buttons/checkbox
+    // Don't start a drag on action buttons, the sel-box, or the colorway
+    // thumb strip (.bbstrip — those <a>s are navigation, not selection).
+    if(e.target.closest('.acts') || e.target.classList.contains('sel-box') || e.target.closest('.bbstrip')) return;
     dragging=true; moved=false; sx=e.clientX; sy=e.clientY;
     baseSel = e.shiftKey ? new Set(selected) : new Set();
-  });
+  }
+  grid.addEventListener('mousedown', startDrag);
+  // Attach to the stack TOO — it's created later by ensureStack(), so we use
+  // a MutationObserver to bind once it appears (and only once per session).
+  function bindStack(){
+    const stack = document.getElementById('bbstack');
+    if (!stack || stack.__mqBound) return;
+    stack.addEventListener('mousedown', startDrag);
+    stack.__mqBound = true;
+  }
+  bindStack();
+  new MutationObserver(bindStack).observe(document.body, { childList:true, subtree:true });
+
   window.addEventListener('mousemove', e => {
     if(!dragging) return;
     if(!moved && Math.abs(e.clientX-sx)<THRESH && Math.abs(e.clientY-sy)<THRESH) return;
@@ -690,7 +714,10 @@ function applySelection(next){
     const l=Math.min(sx,e.clientX), t=Math.min(sy,e.clientY), r=Math.max(sx,e.clientX), b=Math.max(sy,e.clientY);
     mq.style.left=l+'px'; mq.style.top=t+'px'; mq.style.width=(r-l)+'px'; mq.style.height=(b-t)+'px';
     const next = new Set(baseSel);
-    for(const cardEl of grid.children){
+    // Enumerate cards in both containers — whichever is visible contributes.
+    const cards = [...grid.querySelectorAll('.card[data-id]'),
+                   ...document.querySelectorAll('#bbstack .card[data-id]')];
+    for(const cardEl of cards){
       const cr = cardEl.getBoundingClientRect();
       if(!(cr.right<l || cr.left>r || cr.bottom<t || cr.top>b)) next.add(+cardEl.dataset.id);
     }
@@ -798,15 +825,22 @@ load();
     // colorway pills inline in the meta row + a thumb strip where .acts goes.
     const cwLabel = (fam.category || 'base');
     const dot = fam.dominant_hex || '#333';
-    return `<div class="card bbfam" data-base="${fam.id}" data-id="${fam.id}">
+    // Reflect any in-session decision on the base id so a re-render after a
+    // bulk action shows the right state.
+    const act = (typeof decided !== 'undefined' && decided.get) ? decided.get(fam.id) : null;
+    const selClass = (typeof selected !== 'undefined' && selected.has && selected.has(fam.id)) ? ' sel' : '';
+    const stateLabel = act ? ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET'})[act] : '';
+    return `<div class="card bbfam${act?' decided':''}${selClass}" data-base="${fam.id}" data-id="${fam.id}">
       <div class="thumb">
+        <div class="sel-box" title="select (x)"></div>
         <div class="badges">
           <span class="b ${fam.is_published?'pub':'unpub'}">${fam.is_published?'PUB':'unpub'}</span>
           <span class="b reason" title="${all.length} colorways in family">${all.length}cw</span>
         </div>
-        <a href="/admin/seam-debug/${fam.id}" target="_blank" style="display:block;width:100%;height:100%">
+        <a class="bb-link" href="/admin/seam-debug/${fam.id}" target="_blank" style="display:block;width:100%;height:100%">
           <img loading="lazy" src="/designs/img/by-id/${fam.id}" alt="base ${fam.id}">
         </a>
+        <div class="state" style="${stateLabel?'display:block':''}">${stateLabel}</div>
       </div>
       <div class="meta">
         <div class="cw" title="${fam.category||''}">base <span class="sub">#${fam.id}</span></div>
@@ -825,9 +859,48 @@ load();
           </a>`;
         }).join('')}
       </div>
+      <div class="acts">
+        <button class="bad"  data-a="bad"     title="Bad pattern — remove from all (1)">✕ Bad <kbd>1</kbd></button>
+        <button class="dig"  data-a="digital" title="Unpublish, sell as digital file (2)">⬇ Digital <kbd>2</kbd></button>
+        <button class="fix"  data-a="fix"     title="Keep but needs fixing (3)">⚠ Fix <kbd>3</kbd></button>
+        <button class="live" data-a="live"    title="Publish — go live in the web viewer (4)">✓ Publish <kbd>4</kbd></button>
+        <button class="etsy" data-a="etsy"    title="Send to Etsy bucket (5)">🛒 Etsy <kbd>5</kbd></button>
+      </div>
     </div>`;
   }
 
+  // Delegated handlers on the stack — survives every renderStack() innerHTML
+  // rebuild. sel-box → toggleSel; .acts button → decide; shift-click thumb →
+  // toggleSel (mirrors the index page's shift-click-to-select).
+  function bindStackHandlers() {
+    if (stackEl.__handlersBound) return;
+    stackEl.addEventListener('click', e => {
+      const card = e.target.closest('.card.bbfam');
+      if (!card) return;
+      const id = +card.dataset.id;
+      // sel-box → selection toggle
+      if (e.target.classList.contains('sel-box')) {
+        e.preventDefault(); e.stopPropagation();
+        if (typeof toggleSel === 'function') toggleSel(id, card);
+        return;
+      }
+      // action button → decide()
+      const actBtn = e.target.closest('.acts button[data-a]');
+      if (actBtn) {
+        e.preventDefault(); e.stopPropagation();
+        if (typeof decide === 'function') decide(id, actBtn.dataset.a, card);
+        return;
+      }
+      // shift+click on the main thumb img → toggle select (NOT open the link)
+      if (e.shiftKey && e.target.closest('.bb-link')) {
+        e.preventDefault(); e.stopPropagation();
+        if (typeof toggleSel === 'function') toggleSel(id, card);
+        return;
+      }
+    }, true);    // capture so we beat the <a>'s default navigation when needed
+    stackEl.__handlersBound = true;
+  }
+
   function renderStack() {
     if (!stackEl) ensureStack();
     // When "Published only" is on, drop families that have zero published rows
@@ -842,6 +915,7 @@ load();
       return;
     }
     stackEl.innerHTML = visible.map(familyCardHTML).join('');
+    bindStackHandlers();
   }
 
   function showStack() {
diff --git a/public/admin/seam-debug.html b/public/admin/seam-debug.html
index c672911..36180f5 100644
--- a/public/admin/seam-debug.html
+++ b/public/admin/seam-debug.html
@@ -95,8 +95,13 @@ $('t_seam').addEventListener('change',redraw);
 addEventListener('keydown',e=>{ if(e.key==='Enter'&&document.activeElement===$('idin')) load(); });
 
 let CUR=null;
-let SEL=new Set();   // selected box indices for healing
-function updHeal(){ const n=SEL.size; const b=$('healbtn'); b.disabled=n===0; b.textContent=`⚕ Heal selected (${n})`; }
+let SEL=new Set();   // selected AUTO-DETECTED box indices for healing
+let MANUAL=[];       // user-drawn boxes {x,y,w,h} in fractions (0..1) — always healed
+function updHeal(){
+  const n = SEL.size + MANUAL.length;
+  const b = $('healbtn'); b.disabled = n === 0;
+  b.textContent = `⚕ Heal selected (${n})${MANUAL.length ? ' · ' + MANUAL.length + ' manual' : ''}`;
+}
 
 function lensRow(name, obj){
   const score = obj.score!=null?obj.score:obj;

← 78f5533 cactus-curator by-base: 'Published only' filter (default ON)  ·  back to Wallco Ai  ·  cactus-curator: click-any-image → full-screen heal modal (dr 09b88ed →