[object Object]

← back to Wallco Ai

cactus curator: click any card to view full details modal

83da7a004450e73e5a7701822d806d126d20add9 · 2026-05-27 09:03:38 -0700 · Steve Abrams

- GET /api/admin/cactus/:id returns the full row + rank sidecar (scores, dates,
  palette, prompt, motifs, tags, generator/seed, settlement, etc.)
- plain click on a card opens a detail modal (big image + every populated field
  + the 4 verdict buttons); Esc / click-outside / × closes it.
- preserved: shift+click toggles selection, marquee drag selects (post-drag
  click suppressed so it doesn't pop the modal), action buttons unaffected.
Verified headless: click opens (13 fields, img, 4 btns), Esc closes, drag selects without opening.

Files touched

Diff

commit 83da7a004450e73e5a7701822d806d126d20add9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 27 09:03:38 2026 -0700

    cactus curator: click any card to view full details modal
    
    - GET /api/admin/cactus/:id returns the full row + rank sidecar (scores, dates,
      palette, prompt, motifs, tags, generator/seed, settlement, etc.)
    - plain click on a card opens a detail modal (big image + every populated field
      + the 4 verdict buttons); Esc / click-outside / × closes it.
    - preserved: shift+click toggles selection, marquee drag selects (post-drag
      click suppressed so it doesn't pop the modal), action buttons unaffected.
    Verified headless: click opens (13 fields, img, 4 btns), Esc closes, drag selects without opening.
---
 public/admin/cactus-curator.html | 101 ++++++++++++++++++++++++++++++++++++++-
 server.js                        |  31 ++++++++++++
 2 files changed, 130 insertions(+), 2 deletions(-)

diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 1ca5c1f..ccea431 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -69,6 +69,24 @@
   #bulk .clear{margin-left:auto;color:var(--mut);}
   #empty,#err { padding:60px 16px; text-align:center; color:var(--mut); }
   #err { color:#ff9a9a; }
+
+  /* detail modal */
+  #modal { position:fixed; inset:0; z-index:60; background:rgba(0,0,0,.82); display:none; align-items:center; justify-content:center; padding:24px; }
+  #modal.show { display:flex; }
+  .modal-box { position:relative; background:var(--panel); border:1px solid var(--line); border-radius:12px; max-width:1120px; width:100%; max-height:90vh; display:flex; overflow:hidden; }
+  .modal-img { flex:0 0 52%; background:#000; display:flex; align-items:center; justify-content:center; }
+  .modal-img img { max-width:100%; max-height:90vh; object-fit:contain; display:block; }
+  .modal-side { flex:1; padding:18px 22px; overflow:auto; }
+  .modal-side h2 { margin:0 0 2px; font-size:18px; }
+  .modal-close { position:absolute; top:8px; right:12px; font-size:28px; line-height:1; color:#fff; cursor:pointer; background:none; border:none; z-index:2; }
+  .kv { display:grid; grid-template-columns:128px 1fr; gap:3px 12px; font-size:12.5px; margin-top:12px; }
+  .kv dt { color:var(--mut); } .kv dd { margin:0; color:var(--ink); word-break:break-word; }
+  .pal { display:flex; gap:4px; flex-wrap:wrap; margin-top:8px; }
+  .pal i { width:22px; height:22px; border-radius:4px; border:1px solid rgba(255,255,255,.2); display:block; }
+  .modal-acts { display:flex; gap:6px; margin-top:16px; flex-wrap:wrap; }
+  .modal-acts button { font:600 12px inherit; padding:8px 13px; border-radius:7px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
+  .modal-acts .bad{color:#ff9a9a;border-color:#5a2222;} .modal-acts .dig{color:#cbb8ff;border-color:#3d2f63;}
+  .modal-acts .fix{color:#ffd98a;border-color:#5a4410;} .modal-acts .live{color:#9ff0a6;border-color:#1f4d2a;}
   .legend { font-size:11px; color:var(--mut); }
   .legend code { background:var(--panel); padding:1px 5px; border-radius:4px; border:1px solid var(--line); color:var(--ink); }
 </style>
@@ -111,6 +129,7 @@
 <div id="empty" style="display:none">No designs match this filter.</div>
 <div class="grid" id="grid"></div>
 <div id="marquee"></div>
+<div id="modal"><div class="modal-box" id="modalBox"></div></div>
 
 <div id="bulk">
   <span><b id="bulkn">0</b> selected</span>
@@ -223,10 +242,12 @@ function card(d){
     </div>`;
 
   el.querySelector('.sel-box').addEventListener('click', e => { e.stopPropagation(); toggleSel(d.id, el); });
-  // shift+click anywhere on the image adds/removes this one card
+  // click image → full details · shift+click → add/remove from selection
   el.querySelector('.thumb').addEventListener('click', e => {
     if (e.target.classList.contains('sel-box')) return;
-    if (e.shiftKey) { e.preventDefault(); toggleSel(d.id, el); }
+    if (suppressClick) return;                      // a marquee drag just ended
+    if (e.shiftKey) { e.preventDefault(); toggleSel(d.id, el); return; }
+    openDetail(d.id);
   });
   el.querySelectorAll('.acts button').forEach(b =>
     b.addEventListener('click', () => decide(d.id, b.dataset.a, el)));
@@ -236,6 +257,7 @@ function card(d){
 }
 
 let hovered = null;
+let suppressClick = false;   // true briefly after a marquee drag so the click doesn't open detail
 
 function render(){
   const f = $('filter').value;
@@ -299,7 +321,9 @@ $('bulk').querySelectorAll('button[data-a]').forEach(b => b.addEventListener('cl
 $('bulkclear').addEventListener('click', ()=>{ selected.forEach(id=>grid.querySelector(`.card[data-id="${id}"]`)?.classList.remove('sel')); selected.clear(); $('bulk').classList.remove('show'); });
 
 document.addEventListener('keydown', e => {
+  if(e.key==='Escape'){ closeDetail(); return; }
   if(e.target.tagName==='SELECT'||e.target.tagName==='INPUT') return;
+  if($('modal').classList.contains('show')) return;   // don't fire grid keys behind an open modal
   const map = {'1':'bad','2':'digital','3':'fix','4':'live'};
   if(hovered && map[e.key]){ const el = grid.querySelector(`.card[data-id="${hovered}"]`); decide(hovered, map[e.key], el); }
   else if(hovered && (e.key==='x'||e.key==='X')){ toggleSel(hovered, grid.querySelector(`.card[data-id="${hovered}"]`)); }
@@ -313,6 +337,78 @@ function fmtDate(iso){
 }
 function flash(msg){ const e=$('err'); e.textContent=msg; e.style.display='block'; setTimeout(()=>e.style.display='none', 4000); }
 
+// ── detail modal: click a card to see every field ─────────────────────────
+function paletteSwatches(pal){
+  let arr = Array.isArray(pal) ? pal : (pal && Array.isArray(pal.colors) ? pal.colors : []);
+  const hexes = arr.map(c => typeof c==='string' ? c : (c && (c.hex||c.color||c.value))).filter(Boolean);
+  return hexes.length ? '<div class="pal">'+hexes.map(h=>`<i style="background:${h}" title="${h}"></i>`).join('')+'</div>' : '';
+}
+function esc(s){ return String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
+function detailHTML(d){
+  const row = (k,v) => (v==null||v==='') ? '' : `<dt>${k}</dt><dd>${v}</dd>`;
+  const list = v => Array.isArray(v) ? v.map(esc).join(', ') : (v==null?'':esc(v));
+  const seam = d.seam_score!=null
+    ? `${Math.round(d.seam_score)} (${d.seam_verdict||'—'}) · edge ${d.seam_edge_raw??'—'} / mid ${d.seam_mid_raw??'—'} · h-mid ${d.seam_hmid_raw??'—'} v-mid ${d.seam_vmid_raw??'—'}`
+    : '—';
+  const status = d.user_removed ? '✕ removed' : d.web_viewer ? '✓ published (web viewer)'
+    : d.is_published ? 'published' : d.needs_fixing_at ? '⚠ needs fixing' : d.digital_file_at ? '⬇ digital file' : 'unpublished';
+  return `
+    <button class="modal-close" onclick="closeDetail()" title="close (Esc)">×</button>
+    <div class="modal-img"><img src="/designs/img/by-id/${d.id}" alt="cactus ${d.id}"></div>
+    <div class="modal-side">
+      <h2>${esc(d.ai_title || ('Cactus #'+d.id))}</h2>
+      <div class="sub">${esc(d.category||'')} · #${d.id} · ${status}</div>
+      ${paletteSwatches(d.palette)}
+      <dl class="kv">
+        ${row('Composite rank', d.rank_score!=null?Math.round(d.rank_score):'—')}
+        ${row('Vision quality', d.vision_score!=null ? Math.round(d.vision_score)+(d.vision_model?` · ${esc(d.vision_model)}`:'') : 'not scored')}
+        ${row('Seam quality', seam)}
+        ${row('Created', d.created_at)}
+        ${row('Status', status)}
+        ${row('Published', d.is_published?'yes':'no')}
+        ${row('Digital file', d.digital_file_at)}
+        ${row('Needs fixing', d.needs_fixing_at)}
+        ${row('Kind', d.kind)}
+        ${row('Dominant hex', d.dominant_hex ? `<span class="dot" style="background:${d.dominant_hex}"></span> ${d.dominant_hex}` : '')}
+        ${row('Pattern', d.ai_pattern)}
+        ${row('Color name', d.ai_color_name)}
+        ${row('Dig #', d.dig_number)}
+        ${row('Product line', d.product_line)}
+        ${row('Generator', d.generator)}
+        ${row('Seed', d.seed)}
+        ${row('Size', (d.width_in&&d.height_in) ? `${d.width_in} × ${d.height_in} in${d.panels?` · ${d.panels} panels`:''}` : '')}
+        ${row('Settlement', d.settlement_verdict)}
+        ${row('Motifs', list(d.motifs))}
+        ${row('Tags', list(d.tags))}
+        ${row('Notes', list(d.notes))}
+        ${row('Prompt', list(d.prompt))}
+      </dl>
+      <div class="modal-acts">
+        <button class="bad"  data-a="bad">✕ Bad</button>
+        <button class="dig"  data-a="digital">⬇ Digital</button>
+        <button class="fix"  data-a="fix">⚠ Fix</button>
+        <button class="live" data-a="live">✓ Publish</button>
+      </div>
+    </div>`;
+}
+async function openDetail(id){
+  const m=$('modal'), box=$('modalBox');
+  box.innerHTML='<div class="modal-side">Loading…</div>';
+  m.classList.add('show');
+  try{
+    const r=await fetch(q(`/api/admin/cactus/${id}`));
+    const j=await r.json();
+    if(!j.ok) throw new Error(j.error||('HTTP '+r.status));
+    box.innerHTML=detailHTML(j.design);
+    box.querySelectorAll('.modal-acts button').forEach(b=>b.addEventListener('click',()=>{
+      decide(id, b.dataset.a, grid.querySelector(`.card[data-id="${id}"]`));
+      closeDetail();
+    }));
+  }catch(e){ box.innerHTML='<div class="modal-side">Error loading #'+id+': '+esc(e.message)+' <button class="modal-close" onclick="closeDetail()">×</button></div>'; }
+}
+function closeDetail(){ $('modal').classList.remove('show'); }
+$('modal').addEventListener('click', e => { if(e.target.id==='modal') closeDetail(); });
+
 // 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'); }
@@ -350,6 +446,7 @@ function applySelection(next){
   });
   window.addEventListener('mouseup', () => {
     if(!dragging) return;
+    if(moved){ suppressClick=true; setTimeout(()=>{ suppressClick=false; }, 60); }  // swallow the post-drag click
     dragging=false; moved=false; baseSel=null;
     document.body.classList.remove('dragging'); mq.style.display='none';
   });
diff --git a/server.js b/server.js
index 1256c9d..1043010 100644
--- a/server.js
+++ b/server.js
@@ -1164,6 +1164,37 @@ app.get('/api/admin/cactus/list', (req, res) => {
   }
 });
 
+// Full detail for one cactus design — every useful column + its rank sidecar.
+// Backs the click-a-card detail modal in the curator. (local_path omitted —
+// admin-gated, but no reason to surface a Mac2 fs path.)
+app.get('/api/admin/cactus/:id', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  try {
+    const raw = psqlQuery(
+      "SELECT row_to_json(t) FROM (" +
+        "SELECT d.id, d.category, d.kind, d.brand, d.generator, d.seed, d.steps, d.cfg_scale, " +
+               "d.is_published, COALESCE(d.user_removed,false) AS user_removed, COALESCE(d.web_viewer,false) AS web_viewer, " +
+               "to_char(d.created_at,'YYYY-MM-DD HH24:MI') AS created_at, " +
+               "to_char(d.digital_file_at,'YYYY-MM-DD HH24:MI') AS digital_file_at, " +
+               "to_char(d.needs_fixing_at,'YYYY-MM-DD HH24:MI') AS needs_fixing_at, " +
+               "d.dominant_hex, d.palette, d.motifs, d.tags, d.width_in, d.height_in, d.panels, " +
+               "d.ai_title, d.ai_pattern, d.ai_color_name, d.dig_number, d.settlement_verdict, " +
+               "d.product_line, d.notes, d.prompt, " +
+               "r.seam_score, r.seam_verdict, r.seam_edge_raw, r.seam_mid_raw, r.seam_hmid_raw, r.seam_vmid_raw, " +
+               "r.vision_score, r.vision_model, " +
+               "CASE WHEN r.vision_score IS NOT NULL AND r.seam_score IS NOT NULL THEN round(0.6*r.vision_score+0.4*r.seam_score,1) " +
+                    "WHEN r.vision_score IS NOT NULL THEN r.vision_score " +
+                    "WHEN r.seam_score IS NOT NULL THEN r.seam_score ELSE NULL END AS rank_score " +
+        "FROM all_designs d LEFT JOIN wallco_cactus_rank r ON r.design_id=d.id WHERE d.id=" + id +
+      ") t;"
+    );
+    if (!raw) return res.status(404).json({ error: 'design not found' });
+    res.json({ ok: true, design: JSON.parse(raw) });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 // Apply one cactus verdict. body { action: 'bad'|'digital'|'fix'|'live' }.
 function applyCactusDecision(id, action) {
   const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, local_path FROM all_designs WHERE id=${id}) t;`);

← 7e26aab edges-review: multi-select + bulk Reject/Delete bar (prod-ga  ·  back to Wallco Ai  ·  repeat-break audit: agent vision pass over 36 published desi d8d8b1b →