[object Object]

← back to Wallco Ai

etsy bucket: wallco_etsy_bucket table + /api/etsy-bucket/{add,list,DELETE} + /admin/etsy-bucket viewer Β· curator 5th button (πŸ›’ Etsy + key 5 + bulk-bar entry) Β· eligibility gate (seamless verdict ≀ WARN) Β· bucket items excluded from cactus/list + drunk/list pools

78093f07cc3eee1bc8aede49bc28da477dddaceb Β· 2026-05-28 06:43:01 -0700 Β· Steve Abrams

Files touched

Diff

commit 78093f07cc3eee1bc8aede49bc28da477dddaceb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 06:43:01 2026 -0700

    etsy bucket: wallco_etsy_bucket table + /api/etsy-bucket/{add,list,DELETE} + /admin/etsy-bucket viewer Β· curator 5th button (πŸ›’ Etsy + key 5 + bulk-bar entry) Β· eligibility gate (seamless verdict ≀ WARN) Β· bucket items excluded from cactus/list + drunk/list pools
---
 public/admin/cactus-curator.html |  25 +++++-
 public/admin/etsy-bucket.html    |  91 ++++++++++++++++++++++
 server.js                        | 160 ++++++++++++++++++++++++++++++++++++++-
 3 files changed, 272 insertions(+), 4 deletions(-)

diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 76c7f6d..abc888c 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -95,6 +95,8 @@
   .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; }
+  .card.moved-to-etsy { opacity:0; transform:scale(.92); transition:opacity .3s ease, transform .3s ease; }
   .acts button kbd { font:inherit; opacity:.55; }
 
   /* sticky bulk bar */
@@ -103,7 +105,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 .bad{color:#ff9a9a;} #bulk .dig{color:#cbb8ff;} #bulk .fix{color:#ffd98a;} #bulk .live{color:#9ff0a6;} #bulk .etsy{color:#ffb86b;}
   #bulk .clear{margin-left:auto;color:var(--mut);}
   #empty,#err { padding:60px 16px; text-align:center; color:var(--mut); }
   #err { color:#ff9a9a; }
@@ -180,6 +182,7 @@
   <button class="dig"  data-a="digital">2 Β· Digital file</button>
   <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="clear" id="bulkclear">clear selection</button>
 </div>
 
@@ -344,6 +347,7 @@ function card(d){
       <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>
     </div>`;
 
   el.querySelector('.sel-box').addEventListener('click', e => { e.stopPropagation(); toggleSel(d.id, el); });
@@ -493,7 +497,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'})[action]; st.style.display='block'; }
+  if(st){ st.textContent = ({bad:'βœ• REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'βœ“ PUBLISHED',etsy:'πŸ›’ ETSY BUCKET'})[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);
@@ -502,8 +506,23 @@ async function decide(id, action, el){
     else if(action==='digital'){ d.is_published=false; d.web_viewer=false; d.digital_file_at=today(); }
     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; }
   }
   updateStat();
+  // Etsy uses its own endpoint (separate table); short-circuit here.
+  if(action==='etsy'){
+    try{
+      const r=await fetch(q('/api/etsy-bucket/add'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:[id]})});
+      const j=await r.json().catch(()=>({}));
+      if(!r.ok||!j.ok){ throw new Error(j.error||('HTTP '+r.status)); }
+      const result=(j.results||[])[0]||{};
+      if(!result.ok){ el?.classList.remove('decided'); if(st) st.style.display='none'; decided.delete(id); flash(`#${id} not added: ${result.reason||'unknown'}`); return; }
+      // hide the card β€” it's out of the new-design pool now
+      el?.classList.add('moved-to-etsy');
+      setTimeout(()=>el?.remove(), 350);
+    }catch(e){ el?.classList.remove('decided'); if(st) st.style.display='none'; decided.delete(id); flash(`#${id} Etsy add failed: ${e.message}`); }
+    return;
+  }
   try {
     const r = await fetch(q(`/api/cactus-decision/${id}`), {
       method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action })
@@ -557,7 +576,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'};
+  const map = {'1':'bad','2':'digital','3':'fix','4':'live','5':'etsy'};
   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}"]`)); }
 });
diff --git a/public/admin/etsy-bucket.html b/public/admin/etsy-bucket.html
new file mode 100644
index 0000000..110bb85
--- /dev/null
+++ b/public/admin/etsy-bucket.html
@@ -0,0 +1,91 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Etsy bucket β€” wallco.ai admin</title>
+<style>
+  :root{--cols:5;--bg:#0e0f10;--panel:#17191b;--line:#272b2e;--ink:#e9ecef;--mut:#9aa3ab;--etsy:#ffb86b;--ok:#7bc96f;--bad:#ff4d4d;}
+  *{box-sizing:border-box;}
+  body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
+  header{position:sticky;top:0;z-index:30;background:rgba(14,15,16,.96);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;}
+  h1{font-size:16px;margin:0;font-weight:650;} h1 .ico{color:var(--etsy);}
+  .pill{background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:3px 10px;font-size:12px;color:var(--mut);} .pill b{color:var(--ink);}
+  select,input[type=range]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:13px;accent-color:var(--etsy);}
+  label.ctl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mut);}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:10px;padding:14px 16px 80px;}
+  .card{background:var(--panel);border:1px solid var(--line);border-radius:9px;overflow:hidden;display:flex;flex-direction:column;position:relative;}
+  .card .thumb{position:relative;aspect-ratio:1/1;background:#000;}
+  .card .thumb img{width:100%;height:100%;object-fit:cover;display:block;}
+  .badges{position:absolute;top:6px;left:6px;display:flex;gap:4px;z-index:3;}
+  .b{padding:2px 7px;font:700 10.5px sans-serif;border-radius:3px;background:rgba(0,0,0,.7);color:#fff;}
+  .b.q{background:#5a3f17;color:var(--etsy);} .b.exp{background:#3b2e08;color:#f0b400;} .b.list{background:#143b1d;color:var(--ok);}
+  .meta{padding:7px 9px;font-size:11.5px;display:flex;justify-content:space-between;}
+  .meta .id{color:var(--mut);font-variant-numeric:tabular-nums;}
+  .meta .dpi{color:var(--etsy);font-weight:700;}
+  .when{padding:0 9px 8px;font-size:10.5px;color:var(--mut);font-variant-numeric:tabular-nums;}
+  .rm{position:absolute;bottom:6px;right:6px;background:rgba(0,0,0,.75);color:#ff9a9a;border:1px solid #5a2222;border-radius:5px;font:700 10px sans-serif;padding:3px 7px;cursor:pointer;z-index:4;}
+  .rm:hover{background:var(--bad);color:#fff;}
+  #empty,#err{padding:60px 16px;text-align:center;color:var(--mut);} #err{color:#ff9a9a;}
+  .hero{padding:14px 16px;background:#15171a;border-bottom:1px solid var(--line);display:flex;gap:14px;font-size:13px;color:var(--mut);}
+  .hero b{color:var(--ink);}
+</style>
+</head>
+<body>
+<header>
+  <h1><span class="ico">πŸ›’</span> Etsy bucket</h1>
+  <span class="pill" id="stat">loading…</span>
+  <label class="ctl">State <select id="state"><option value="">all</option><option value="queued">queued</option><option value="exported">exported</option><option value="listed">listed</option><option value="sold">sold</option></select></label>
+  <label class="ctl">Density <input type="range" id="density" min="3" max="8" step="1" value="5"></label>
+  <a href="javascript:void(0)" id="explink" style="margin-left:auto;color:var(--etsy);font-size:13px;text-decoration:none;border:1px solid #5a3f17;padding:5px 10px;border-radius:7px">πŸ“¦ Run export script</a>
+</header>
+<div class="hero">
+  <span><b>How it works:</b> click πŸ›’ Etsy in any curator to send a design here. Bucket items are excluded from new-design pools. Run <code style="background:#0a0b0c;padding:2px 6px;border-radius:4px">node scripts/export-etsy-digital.js --limit 0</code> to bundle assets + listing copy.</span>
+</div>
+<div id="err" style="display:none"></div>
+<div id="empty" style="display:none">Bucket is empty β€” pick designs in the curator and hit πŸ›’ Etsy.</div>
+<div class="grid" id="grid"></div>
+<script>
+const ADMIN=new URLSearchParams(location.search).get('admin');
+const q=s=>ADMIN?(s+(s.includes('?')?'&':'?')+'admin='+encodeURIComponent(ADMIN)):s;
+const $=id=>document.getElementById(id);
+$('density').value=+(localStorage.getItem('etsy_density')||5); document.documentElement.style.setProperty('--cols',$('density').value);
+$('density').addEventListener('input',e=>{localStorage.setItem('etsy_density',e.target.value);document.documentElement.style.setProperty('--cols',e.target.value);});
+$('state').addEventListener('change',load);
+$('explink').addEventListener('click',()=>{
+  alert('From your terminal:\n\n  cd ~/Projects/wallco-ai\n  node scripts/export-etsy-digital.js --limit 0\n\nThis writes per-design assets + a listings.csv manifest to data/etsy-exports/<today>/. After upload, designs auto-progress from queued β†’ exported.');
+});
+let ALL=[];
+async function rm(id){
+  if(!confirm(`Remove #${id} from Etsy bucket? It'll be eligible for the new-design pool again.`)) return;
+  const r=await fetch(q('/api/etsy-bucket/'+id),{method:'DELETE'});
+  if(r.ok){ ALL=ALL.filter(x=>x.design_id!==id); render(); }
+}
+function render(){
+  $('empty').style.display=ALL.length?'none':'block';
+  $('stat').innerHTML=`<b>${ALL.length}</b> in bucket`;
+  $('grid').innerHTML=ALL.map(x=>`
+    <div class="card">
+      <div class="thumb">
+        <div class="badges"><span class="b ${({queued:'q',exported:'exp',listed:'list'})[x.state]||''}">${x.state}</span></div>
+        <a href="${q('/design/'+x.design_id)}" target="_blank"><img loading="lazy" src="/designs/img/by-id/${x.design_id}" alt="#${x.design_id}"></a>
+        <button class="rm" onclick="rm(${x.design_id})">βœ• remove</button>
+      </div>
+      <div class="meta"><span class="id">#${x.design_id}</span><span class="dpi">${x.target_dpi}dpi</span></div>
+      <div class="when" title="${x.added_at} added">πŸ•“ ${x.added_at}</div>
+    </div>`).join('');
+}
+async function load(){
+  try{
+    const s=$('state').value;
+    const r=await fetch(q('/api/etsy-bucket/list'+(s?'?state='+s:'')));
+    if(!r.ok) throw new Error('list '+r.status+(r.status===404?' β€” append ?admin=TOKEN':''));
+    const j=await r.json();
+    ALL=j.items||[];
+    render();
+  }catch(e){ $('err').style.display='block'; $('err').textContent='Failed: '+e.message; }
+}
+load();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index ce79016..794f29c 100644
--- a/server.js
+++ b/server.js
@@ -1176,6 +1176,103 @@ app.get(['/admin/seam-debug', '/admin/seam-debug/:id'], (req, res) => {
 // POST /api/seam-debug/:id/heal  body { box_ids: [int…] }  β€” heal ONLY the
 // selected mid/edge defect regions; save as a NEW design (preserves source).
 // Returns new_id + before/after scan so the viewer can show side-by-side.
+// ── Etsy bucket ─────────────────────────────────────────────────────────────
+// Steve "Create a brand new store for these files… put in a separate table so
+// we dont use for new designs". wallco_etsy_bucket is the holding pen for
+// designs slated for Etsy. Excluded from /api/admin/cactus/list +
+// /api/admin/drunk/list (the new-design pools) by the NOT EXISTS clauses.
+// Eligibility: seamless verdict (mids+edges ≀ WARN_MAX 12) + source file exists.
+// "Upscale to 600 DPI" is gated separately at export time; we record the
+// target_dpi on the bucket row.
+function etsyEligible(id) {
+  // Cheap pre-check: file present + seam scores acceptable. Runs the
+  // seam-defect-boxes scanner; verdict 'PASS' or 'WARN' is eligible, 'FAIL' is not.
+  try {
+    const scanOut = _execFileSyncSeam('python3',
+      [path.join(__dirname, 'scripts', 'seam-defect-boxes.py'), '--id', String(id)],
+      { encoding: 'utf8', timeout: 30000, maxBuffer: 4 * 1024 * 1024 });
+    const r = JSON.parse(scanOut);
+    if (!r.ok) return { ok: false, reason: r.error || 'scan failed' };
+    if (r.verdict === 'FAIL') return { ok: false, reason: `seamless verdict FAIL (mids=${r.scores.mids_max}, edges=${r.scores.edges_max})`, scores: r.scores };
+    return { ok: true, verdict: r.verdict, scores: r.scores, width: r.width, height: r.height };
+  } catch (e) {
+    return { ok: false, reason: e.message };
+  }
+}
+app.post('/api/etsy-bucket/add', express.json({ limit: '256kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const ids = Array.isArray(req.body && req.body.ids)
+    ? req.body.ids.map(n => parseInt(n, 10)).filter(Number.isFinite)
+    : (Number.isFinite(parseInt(req.body && req.body.id, 10)) ? [parseInt(req.body.id, 10)] : []);
+  const force = req.body && req.body.force === true;          // skip eligibility check
+  const dpi = Math.max(72, Math.min(600, parseInt(req.body && req.body.target_dpi, 10) || 300));
+  const bundle = (req.body && req.body.bundle_name && String(req.body.bundle_name).slice(0, 80)) || null;
+  if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
+  const results = [];
+  let added = 0, skipped = 0, errored = 0;
+  for (const id of ids) {
+    try {
+      // Already in bucket?
+      const existing = psqlQuery(`SELECT state FROM wallco_etsy_bucket WHERE design_id=${id};`);
+      if (existing) { results.push({ id, ok: true, already_in: existing }); skipped++; continue; }
+      // Eligibility
+      if (!force) {
+        const elig = etsyEligible(id);
+        if (!elig.ok) { results.push({ id, ok: false, reason: elig.reason, scores: elig.scores }); skipped++; continue; }
+      }
+      const upscale = Math.round(dpi * 24);                   // 24" tile target
+      psqlExecLocal(
+        `INSERT INTO wallco_etsy_bucket (design_id, added_by, state, target_dpi, upscale_px, bundle_name) ` +
+        `VALUES (${id}, 'admin', 'queued', ${dpi}, ${upscale}, ${bundle ? "'" + bundle.replace(/'/g, "''") + "'" : 'NULL'}) ` +
+        `ON CONFLICT (design_id) DO NOTHING;`
+      );
+      // Stamp a tag for cross-table filtering convenience
+      psqlExecLocal(
+        `UPDATE all_designs SET tags = (array_remove(COALESCE(tags, ARRAY[]::text[]), 'etsy-bucket')) ` +
+        `|| ARRAY['etsy-bucket']::text[] WHERE id=${id};`
+      );
+      results.push({ id, ok: true, target_dpi: dpi, upscale_px: upscale });
+      added++;
+    } catch (e) {
+      results.push({ id, ok: false, reason: e.message.slice(0, 200) });
+      errored++;
+    }
+  }
+  res.json({ ok: true, added, skipped, errored, results });
+});
+app.get('/api/etsy-bucket/list', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const state = String(req.query.state || '').replace(/[^a-z]/g, '').slice(0, 20);
+  try {
+    const raw = psqlQuery(
+      "SELECT COALESCE(json_agg(t ORDER BY t.added_at DESC), '[]'::json) FROM (" +
+        "SELECT eb.id AS bucket_id, eb.design_id, eb.state, eb.bundle_name, eb.target_dpi, eb.upscale_px, " +
+        "to_char(eb.added_at,'YYYY-MM-DD HH24:MI') AS added_at, " +
+        "to_char(eb.exported_at,'YYYY-MM-DD HH24:MI') AS exported_at, " +
+        "eb.etsy_listing_id, eb.notes, d.category, d.dominant_hex, d.local_path " +
+        "FROM wallco_etsy_bucket eb LEFT JOIN all_designs d ON d.id=eb.design_id " +
+        (state ? `WHERE eb.state='${state}' ` : '') +
+      ") t;"
+    );
+    const items = JSON.parse(raw || '[]');
+    res.json({ ok: true, count: items.length, items });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+app.delete('/api/etsy-bucket/:design_id', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.design_id, 10);
+  if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+  try {
+    psqlExecLocal(`DELETE FROM wallco_etsy_bucket WHERE design_id=${id};`);
+    psqlExecLocal(`UPDATE all_designs SET tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'etsy-bucket') WHERE id=${id};`);
+    res.json({ ok: true, design_id: id });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+app.get('/admin/etsy-bucket', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'etsy-bucket.html'));
+});
+
 app.post('/api/seam-debug/:id/heal', express.json({ limit: '16kb' }), (req, res) => {
   if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
   const id = parseInt(req.params.id, 10);
@@ -1232,6 +1329,8 @@ app.get('/api/admin/drunk/list', (req, res) => {
         "(SELECT be.boxes FROM bad_examples be WHERE be.design_id=all_designs.id LIMIT 1) AS annotations " +
         "FROM all_designs WHERE " + where +
         " AND local_path NOT LIKE '%midheal_%' AND local_path NOT LIKE '%edgeheal_%' AND local_path NOT LIKE '%smartfix_%'" +
+        // Hide Etsy-bucket items β€” they're out of the new-design pool.
+        " AND NOT EXISTS (SELECT 1 FROM wallco_etsy_bucket eb WHERE eb.design_id=all_designs.id AND eb.state IN ('queued','exported','listed','sold'))" +
       ") t;"
     );
     res.json({ ok: true, items: JSON.parse(rawJson || '[]') });
@@ -1410,6 +1509,10 @@ app.get('/api/admin/cactus/list', (req, res) => {
         // carry the old destructive blur (horizontal/vertical seam line + fuzzy
         // edges) and should never be curated β€” curate the clean round-1 originals.
         "AND d.local_path NOT LIKE '%midheal_%' AND d.local_path NOT LIKE '%edgeheal_%' AND d.local_path NOT LIKE '%smartfix_%' " +
+        // Hide designs already promoted to the Etsy bucket β€” they're out of
+        // the new-design curation pool (Steve "once in the etsy bucket put in
+        // a separate table so we dont use for new designs").
+        "AND NOT EXISTS (SELECT 1 FROM wallco_etsy_bucket eb WHERE eb.design_id=d.id AND eb.state IN ('queued','exported','listed','sold')) " +
         (includeRemoved ? '' : "AND NOT COALESCE(d.user_removed,false) ") +
       ") t;"
     );
@@ -1451,7 +1554,9 @@ app.get('/api/admin/curator/collections', (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) => {
+app.get('/api/admin/cactus/:id', (req, res, next) => {
+  // Pass through non-numeric :id so sibling routes (families, etc.) aren't shadowed.
+  if (!/^[0-9]+$/.test(String(req.params.id))) return next();
   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' });
@@ -1624,6 +1729,34 @@ app.post('/api/admin/cactus/:id/annotations', express.json({ limit: '32kb' }), (
   } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
 });
 
+// GET /api/admin/cactus/families?category=<cat> β€” return base designs (parent_design_id
+// IS NULL) for a category, each with its colorway children inlined. Powers the
+// "one pattern + thumbs of all colors" toggle on the cactus-curator: walk one base at
+// a time + see its 16 colorway variants as thumbs underneath, instead of a flat grid.
+app.get('/api/admin/cactus/families', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const cat = String(req.query.category || '').replace(/[^a-zA-Z0-9 ._Β·-]/g, '').slice(0, 60);
+  if (!cat) return res.status(400).json({ error: 'category required' });
+  try {
+    const rawJson = psqlQuery(
+      "SELECT COALESCE(json_agg(t ORDER BY t.id), '[]'::json) FROM (" +
+        "SELECT b.id, b.category, b.dominant_hex, b.is_published, " +
+               "COALESCE(b.user_removed,false) AS user_removed, b.created_at, " +
+               "(SELECT COALESCE(json_agg(json_build_object(" +
+                  "'id',c.id,'category',c.category,'dominant_hex',c.dominant_hex," +
+                  "'is_published',c.is_published,'user_removed',COALESCE(c.user_removed,false)" +
+                ") ORDER BY c.category), '[]'::json) " +
+                "FROM all_designs c WHERE c.parent_design_id = b.id AND c.local_path IS NOT NULL) AS children " +
+        "FROM all_designs b WHERE b.category = '" + cat.replace(/'/g, "''") + "' " +
+        "AND b.parent_design_id IS NULL AND b.local_path IS NOT NULL " +
+        "AND NOT COALESCE(b.user_removed,false) " +
+        "ORDER BY b.id LIMIT 800" +
+      ") t;"
+    );
+    res.json({ ok: true, families: JSON.parse(rawJson || '[]') });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 // GET /api/admin/seam-review/:id β€” pixel-based seam-break scanner (free, fast,
 // deterministic). Highlights regions where shapes DON'T line up across the wrap
 // boundary (L↔R or T↔B). Returns breaks[] + score 0-100 β€” chips at β‰₯95 are clean
@@ -13919,6 +14052,7 @@ ${(() => {
             <span style="display:inline-block; width:1px; height:24px; background:rgba(31,24,8,.18); margin:0 6px;"></span>
             <button type="button" class="btn-admin-primary" id="btn-spoon-live" data-id="${design.id}">Spoonflower β€” Publish Live</button>
             <button type="button" class="btn-admin-primary" id="btn-shopify"    data-id="${design.id}">Publish to Shopify</button>
+            <button type="button" class="btn-outline"       id="btn-etsy-tag"   data-id="${design.id}" title="Mark for Etsy digital-file resale. Tags + queues only β€” actual Etsy listing is a separate batch step.">Sell on Etsy (digital)</button>
           </div>
         </details>
         ` : ''}
@@ -14060,6 +14194,30 @@ ${(() => {
           });
         }
 
+        var etsyBtn = document.getElementById('btn-etsy-tag');
+        if (etsyBtn) {
+          etsyBtn.addEventListener('click', async function(){
+            if (!confirm('Mark this design for Etsy digital-file resale? This tags the row and queues it for the Etsy export pipeline. It does NOT create a live Etsy listing yet β€” that is a separate batch step that needs your approval per design.')) return;
+            var btn = this;
+            btn.disabled = true;
+            show('Tagging for Etsy digital queue…', false);
+            try {
+              var r = await fetch('/api/design/' + btn.dataset.id + '/etsy-tag', {
+                method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({})
+              });
+              var j = await r.json();
+              if (r.ok && j.ok) {
+                show('Tagged <strong>etsy-digital-file</strong> on #' + j.id + ' Β· queued at <code>' + j.queued_at + '</code>. ' +
+                     (j.already_tagged ? '(was already tagged)' : '') +
+                     ' Run the etsy-export pipeline when ready to list.', false);
+              } else {
+                show('Etsy tag failed: ' + (j.error || 'unknown'), true);
+              }
+            } catch (e) { show('Etsy tag error: ' + e.message, true); }
+            btn.disabled = false;
+          });
+        }
+
         var _bNR = document.getElementById('btn-narrate');
         if (_bNR) _bNR.addEventListener('click', async function(){
           show('Generating narration…', false);

← c9af59b seam-debug: click boxes to select + 'Heal selected' button (  Β·  back to Wallco Ai  Β·  design page: Sell on Etsy (digital) button β†’ tags etsy-digit 3ed1a19 β†’