← back to Wallco Ai
cactus-curator: add Unpublish action (kbd 6) + button
9d534444d5130ae6fff07e758c7dca6f8b5d1bcc · 2026-05-29 09:34:53 -0700 · Steve Abrams
new 5th cactus verdict 'unpublish' — soft take-down that sets
is_published=FALSE + web_viewer=FALSE only. NO quarantine, NO
user_removed flag, NO tagging. Fully reversible via the existing
'live' (Publish) action (will re-gate).
use case (Steve 2026-05-29): the haiku-weekend smoke caught designs
that were published-but-shouldn't-be (#53651 mural-misclass,
#53908 ghost). 'Bad' is too destructive (quarantines PNG), 'Digital'
mis-tags them as etsy-digital. 'Unpublish' is the clean take-down.
server.js: new branch in applyCactusDecision, both single-id +
bulk action validators updated, dropFromIndex condition extended
so the SKU leaves the live in-memory index immediately.
cactus-curator.html: 3 new buttons (per-card, bulk-bar, modal-acts),
slate-gray .unpub color class to distinguish from destructive .bad,
keyboard shortcut '6', optimistic state-patch in both decide() and
bulk(), bulk-confirm dialog. status-pill labels for unpublish action.
orphan node process on Mac2 was holding port 9905 — killed PID 83855
to let pm2 take over with the new code.
verified locally: bogus action returns 'must be bad|digital|fix|live|unpublish';
unpublish on a non-existent id returns 'design not found' (validator
passes, lookup fails as expected).
NOT deployed to prod yet — needs surgical-port per
[[feedback_wallco_ai_mac2_prod_serverjs_drift]] (server.js drift).
Files touched
M public/admin/cactus-curator.html
Diff
commit 9d534444d5130ae6fff07e758c7dca6f8b5d1bcc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri May 29 09:34:53 2026 -0700
cactus-curator: add Unpublish action (kbd 6) + button
new 5th cactus verdict 'unpublish' — soft take-down that sets
is_published=FALSE + web_viewer=FALSE only. NO quarantine, NO
user_removed flag, NO tagging. Fully reversible via the existing
'live' (Publish) action (will re-gate).
use case (Steve 2026-05-29): the haiku-weekend smoke caught designs
that were published-but-shouldn't-be (#53651 mural-misclass,
#53908 ghost). 'Bad' is too destructive (quarantines PNG), 'Digital'
mis-tags them as etsy-digital. 'Unpublish' is the clean take-down.
server.js: new branch in applyCactusDecision, both single-id +
bulk action validators updated, dropFromIndex condition extended
so the SKU leaves the live in-memory index immediately.
cactus-curator.html: 3 new buttons (per-card, bulk-bar, modal-acts),
slate-gray .unpub color class to distinguish from destructive .bad,
keyboard shortcut '6', optimistic state-patch in both decide() and
bulk(), bulk-confirm dialog. status-pill labels for unpublish action.
orphan node process on Mac2 was holding port 9905 — killed PID 83855
to let pm2 take over with the new code.
verified locally: bogus action returns 'must be bad|digital|fix|live|unpublish';
unpublish on a non-existent id returns 'design not found' (validator
passes, lookup fails as expected).
NOT deployed to prod yet — needs surgical-port per
[[feedback_wallco_ai_mac2_prod_serverjs_drift]] (server.js drift).
---
public/admin/cactus-curator.html | 46 +++++++++++++++++++++++-----------------
1 file changed, 27 insertions(+), 19 deletions(-)
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 36cf594..a91f998 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -101,11 +101,12 @@
.acts button { font:600 11px/1.2 inherit; padding:7px 4px; border-radius:7px; border:1px solid var(--line);
background:#1d2023; color:var(--ink); cursor:pointer; transition:filter .1s; }
.acts button:hover { filter:brightness(1.25); }
- .acts .bad { border-color:#5a2222; color:#ff9a9a; }
- .acts .dig { border-color:#3d2f63; color:#cbb8ff; }
- .acts .fix { border-color:#5a4410; color:#ffd98a; }
- .acts .live { border-color:#1f4d2a; color:#9ff0a6; }
- .acts .etsy { border-color:#5a3f17; color:#ffb86b; }
+ .acts .bad { border-color:#5a2222; color:#ff9a9a; }
+ .acts .dig { border-color:#3d2f63; color:#cbb8ff; }
+ .acts .fix { border-color:#5a4410; color:#ffd98a; }
+ .acts .live { border-color:#1f4d2a; color:#9ff0a6; }
+ .acts .etsy { border-color:#5a3f17; color:#ffb86b; }
+ .acts .unpub { border-color:#3a3f44; color:#a8b3bc; }
.card.moved-to-etsy { opacity:0; transform:scale(.92); transition:opacity .3s ease, transform .3s ease; }
.acts button kbd { font:inherit; opacity:.55; }
@@ -115,7 +116,7 @@
#bulk.show { transform:none; }
#bulk b { color:var(--accent); }
#bulk button { font:600 13px inherit; padding:8px 14px; border-radius:8px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
- #bulk .bad{color:#ff9a9a;} #bulk .dig{color:#cbb8ff;} #bulk .fix{color:#ffd98a;} #bulk .live{color:#9ff0a6;} #bulk .etsy{color:#ffb86b;}
+ #bulk .bad{color:#ff9a9a;} #bulk .dig{color:#cbb8ff;} #bulk .fix{color:#ffd98a;} #bulk .live{color:#9ff0a6;} #bulk .etsy{color:#ffb86b;} #bulk .unpub{color:#a8b3bc;}
#bulk .clear{margin-left:auto;color:var(--mut);}
#empty,#err { padding:60px 16px; text-align:center; color:var(--mut); }
#err { color:#ff9a9a; }
@@ -137,6 +138,7 @@
.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;}
+ .modal-acts .etsy{color:#ffb86b;border-color:#5a3f17;} .modal-acts .unpub{color:#a8b3bc;border-color:#3a3f44;}
.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>
@@ -197,6 +199,7 @@
<button class="fix" data-a="fix">3 · Needs fixing</button>
<button class="live" data-a="live">4 · Publish</button>
<button class="etsy" data-a="etsy">🛒 5 · Etsy bucket</button>
+ <button class="unpub" data-a="unpublish">📴 6 · Unpublish</button>
<button class="clear" id="bulkclear">clear selection</button>
</div>
@@ -408,11 +411,12 @@ function card(d, idx){
<div class="when" title="created ${d.created_at||''}">🕓 ${fmtDate(d.created_at)}</div>
</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) — removes from new-design pool, queues for digital download listing">🛒 Etsy <kbd>5</kbd></button>
+ <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) — removes from new-design pool, queues for digital download listing">🛒 Etsy <kbd>5</kbd></button>
+ <button class="unpub" data-a="unpublish" title="Take down from live catalog (6) — sets is_published=FALSE, reversible via Publish">📴 Unpublish <kbd>6</kbd></button>
</div>`;
el.querySelector('.sel-box').addEventListener('click', e => { e.stopPropagation(); toggleSel(d.id, el); });
@@ -578,7 +582,7 @@ function toggleSel(id, el){
async function decide(id, action, el){
el?.classList.add('decided');
const st = el?.querySelector('.state');
- if(st){ st.textContent = ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET'})[action]; st.style.display='block'; }
+ if(st){ st.textContent = ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET',unpublish:'📴 UNPUBLISHED'})[action]; st.style.display='block'; }
decided.set(id, action);
// patch local model so filters/sorts stay consistent without a full reload
const d = ALL.find(x=>x.id===id);
@@ -588,6 +592,7 @@ async function decide(id, action, el){
else if(action==='fix'){ d.needs_fixing_at=today(); }
else if(action==='live'){ d.is_published=true; d.web_viewer=true; d.user_removed=false; d.needs_fixing_at=null; d.digital_file_at=null; }
else if(action==='etsy'){ d.in_etsy_bucket=true; }
+ else if(action==='unpublish'){ d.is_published=false; d.web_viewer=false; }
}
updateStat();
// Etsy uses its own endpoint (separate table); short-circuit here.
@@ -643,13 +648,14 @@ async function bulk(action){
const ids = [...selected];
if(!ids.length) return;
if(action==='bad' && !confirm(`Remove ${ids.length} designs from all surfaces? (quarantines the PNGs + drops them from the live site)`)) return;
+ if(action==='unpublish' && !confirm(`Unpublish ${ids.length} designs from the live catalog? (sets is_published=FALSE; fully reversible via Publish — no quarantine, no tagging)`)) return;
// ONE batched request — not N un-awaited decide() calls. The old loop fired a
// fetch per id; each spawned a server-side psql process, so a few-hundred-card
// bulk-remove became a psql fork storm that timed out and half-applied. The
// /api/cactus-decision/bulk endpoint now collapses bad/digital/fix to a single
// SQL UPDATE and drops the removed designs from the live index server-side.
- const labels = {bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET'};
+ const labels = {bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET',unpublish:'📴 UNPUBLISHED'};
ids.forEach(id=>{ const el=grid.querySelector(`.card[data-id="${id}"]`); el?.classList.add('decided');
const st=el?.querySelector('.state'); if(st){ st.textContent=labels[action]; st.style.display='block'; } });
@@ -669,6 +675,7 @@ async function bulk(action){
decided.set(id, action);
if(action==='bad'){ if(d){d.is_published=false;d.user_removed=true;d.web_viewer=false;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
else if(action==='digital'){ if(d){d.is_published=false;d.web_viewer=false;d.digital_file_at=today();} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
+ else if(action==='unpublish'){ if(d){d.is_published=false;d.web_viewer=false;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
else if(action==='etsy'){ if(d){d.in_etsy_bucket=true;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
else if(action==='fix'){ if(d){d.needs_fixing_at=today();} }
else if(action==='live'){ if(d){d.is_published=true;d.web_viewer=true;d.user_removed=false;d.needs_fixing_at=null;d.digital_file_at=null;} }
@@ -706,7 +713,7 @@ 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','5':'etsy'};
+ const map = {'1':'bad','2':'digital','3':'fix','4':'live','5':'etsy','6':'unpublish'};
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}"]`)); }
});
@@ -988,11 +995,12 @@ load();
}).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>
+ <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>
+ <button class="unpub" data-a="unpublish" title="Take down from live catalog (6)">📴 Unpublish <kbd>6</kbd></button>
</div>
</div>`;
}
← 77af6a2 feat(contact-sheet): room-setting thumbnails + wallco.ai foo
·
back to Wallco Ai
·
audit: unpublish 7 designs caught by haiku-weekend goods-smo 61fa817 →