← 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
M public/admin/cactus-curator.htmlM server.js
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 => ({'&':'&','<':'<','>':'>'}[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 →