[object Object]

← back to Wallco Ai

cactus-curator by-base: 'Published only' filter (default ON) — hides families with no published rows + filters thumb strips to published-only; toggle button persists in localStorage

78f5533d0d1305e34ba3a213571c790a850e737e · 2026-05-28 07:03:44 -0700 · Steve Abrams

Files touched

Diff

commit 78f5533d0d1305e34ba3a213571c790a850e737e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 07:03:44 2026 -0700

    cactus-curator by-base: 'Published only' filter (default ON) — hides families with no published rows + filters thumb strips to published-only; toggle button persists in localStorage
---
 data/cactus-decisions.jsonl      |   3 +
 public/admin/cactus-curator.html |  47 ++++++++++++++--
 scripts/push-etsy-bundle.js      | 119 ++++++++++++++++++++++++++++++++++++---
 3 files changed, 156 insertions(+), 13 deletions(-)

diff --git a/data/cactus-decisions.jsonl b/data/cactus-decisions.jsonl
index e583fcc..22059f6 100644
--- a/data/cactus-decisions.jsonl
+++ b/data/cactus-decisions.jsonl
@@ -3759,3 +3759,6 @@
 {"ts":"2026-05-28T13:59:02.315Z","id":53856,"action":"live"}
 {"ts":"2026-05-28T13:59:21.843Z","id":53828,"action":"live"}
 {"ts":"2026-05-28T13:59:38.735Z","id":53836,"action":"live"}
+{"ts":"2026-05-28T14:03:04.303Z","id":39307,"action":"live"}
+{"ts":"2026-05-28T14:03:07.728Z","id":39288,"action":"live"}
+{"ts":"2026-05-28T14:03:17.818Z","id":40538,"action":"bad"}
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index c4aab3f..2377d0a 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -758,7 +758,9 @@ load();
 (() => {
   const URL_CAT = new URLSearchParams(location.search).get('category') || '';
   const LS_KEY = 'cactus_curator_bblayout';
-  let families = [], stackEl = null, loaded = false, btn = null;
+  const LS_PUB = 'cactus_curator_bblayout_pubonly';
+  let families = [], stackEl = null, loaded = false, btn = null, pubBtn = null;
+  let pubOnly = localStorage.getItem(LS_PUB) !== '0';   // default ON: only show families with published content
 
   function setBtnState(on) {
     if (!btn) return;
@@ -784,10 +786,13 @@ load();
   }
 
   function familyCardHTML(fam) {
-    const all = [
+    const allRaw = [
       { id: fam.id, category: fam.category, dominant_hex: fam.dominant_hex, is_published: fam.is_published },
       ...(fam.children || []),
     ];
+    // When "Published only" is on, hide unpublished thumbs from the strip too —
+    // the family card shows only what's actually live on the storefront.
+    const all = pubOnly ? allRaw.filter(c => c.is_published) : allRaw;
     // Re-use the index page's own .card / .thumb / .meta classes so this reads
     // visually identical to a regular catalog tile — base in the thumb slot,
     // colorway pills inline in the meta row + a thumb strip where .acts goes.
@@ -825,8 +830,18 @@ load();
 
   function renderStack() {
     if (!stackEl) ensureStack();
-    if (!families.length) { stackEl.innerHTML = '<div style="text-align:center;padding:60px;color:var(--mut,#9aa3ab)">no bases with colorway children in this category</div>'; return; }
-    stackEl.innerHTML = families.map(familyCardHTML).join('');
+    // When "Published only" is on, drop families that have zero published rows
+    // (no base published AND no published colorway) — they'd render as empty cards.
+    const visible = pubOnly
+      ? families.filter(f => f.is_published || (f.children || []).some(c => c.is_published))
+      : families;
+    if (!visible.length) {
+      stackEl.innerHTML = `<div style="grid-column:1 / -1;text-align:center;padding:60px;color:var(--mut,#9aa3ab)">
+        ${pubOnly ? 'no published families in this category — toggle "Published only" off to see all' : 'no bases with colorway children in this category'}
+      </div>`;
+      return;
+    }
+    stackEl.innerHTML = visible.map(familyCardHTML).join('');
   }
 
   function showStack() {
@@ -856,7 +871,23 @@ load();
     setBtnState(on);
   }
 
-  // inject the toggle button into the controls bar (re-injects if DOM rebuilds)
+  function setPubBtnState() {
+    if (!pubBtn) return;
+    pubBtn.textContent = pubOnly ? '✓ Published only' : '◯ Published only';
+    pubBtn.style.background = pubOnly ? '#1d3b1d' : '';
+    pubBtn.style.color = pubOnly ? '#bdf1bd' : '';
+    pubBtn.title = pubOnly
+      ? 'showing only families & thumbs that are live on the storefront — click to show all'
+      : 'showing all families (published + unpublished + held) — click to filter to published only';
+  }
+  function togglePubOnly() {
+    pubOnly = !pubOnly;
+    if (pubOnly) localStorage.removeItem(LS_PUB); else localStorage.setItem(LS_PUB, '0');
+    setPubBtnState();
+    if (loaded && stackEl && stackEl.style.display !== 'none') renderStack();
+  }
+
+  // inject the toggle buttons into the controls bar (re-injects if DOM rebuilds)
   new MutationObserver(() => {
     if (document.getElementById('bbtoggle')) return;
     const anchor = document.getElementById('autoall') || document.querySelector('.controls') || document.querySelector('header');
@@ -866,6 +897,12 @@ load();
     btn.onclick = () => setMode(localStorage.getItem(LS_KEY) !== '1');
     anchor.parentNode.insertBefore(btn, anchor.nextSibling);
     setBtnState(localStorage.getItem(LS_KEY) === '1');
+    // sibling "Published only" filter button (applies inside by-base mode)
+    pubBtn = document.createElement('button'); pubBtn.id = 'bbpubonly';
+    pubBtn.className = anchor.className || '';
+    pubBtn.onclick = togglePubOnly;
+    btn.parentNode.insertBefore(pubBtn, btn.nextSibling);
+    setPubBtnState();
     // restore mode on first paint if saved
     if (localStorage.getItem(LS_KEY) === '1') setMode(true);
   }).observe(document.body, { childList: true, subtree: true });
diff --git a/scripts/push-etsy-bundle.js b/scripts/push-etsy-bundle.js
index da6d7e4..27f473d 100644
--- a/scripts/push-etsy-bundle.js
+++ b/scripts/push-etsy-bundle.js
@@ -175,12 +175,19 @@ print(json.dumps({"cover": ${JSON.stringify(path.join(BUNDLE_DIR, 'cover_grid.pn
 // Thin v3 client; if MCP `mcp__etsy__*` schema retrieval is wired into your
 // agent runtime, mcp__etsy__search_etsy_api / get_endpoint can verify the
 // payload shape before deploy. The actual write is plain HTTPS.
-function etsyRequest(method, p, body, isMultipart = false) {
+//
+// 401 auto-refresh: Etsy access tokens expire in 1 hour. When a request
+// 401s mid-push, we exchange the refresh token for a fresh access token,
+// rewrite ~/Projects/secrets-manager/.env so the next process inherits it,
+// then retry the original request ONCE. If the retry also 401s, the
+// refresh token itself is dead and we surface that to Steve so he can re-run
+// scripts/etsy-oauth-helper.js for a full new auth.
+function etsyRequestRaw(method, p, body, isMultipart) {
   return new Promise((resolve, reject) => {
     const url = new URL('https://openapi.etsy.com/v3' + p);
     const headers = {
       'x-api-key': ETSY.clientId,
-      'Authorization': `Bearer ${ETSY.accessToken}`,
+      'Authorization': `Bearer ${ETSY.accessToken}`,     // reads current closure var — refresh updates it
       'Accept': 'application/json',
     };
     let payload;
@@ -189,7 +196,6 @@ function etsyRequest(method, p, body, isMultipart = false) {
       payload = Buffer.from(JSON.stringify(body));
       headers['Content-Length'] = payload.length;
     } else if (body && isMultipart) {
-      // body is already a Buffer + boundary already set on Content-Type
       payload = body.buffer;
       headers['Content-Type'] = body.contentType;
       headers['Content-Length'] = payload.length;
@@ -201,11 +207,14 @@ function etsyRequest(method, p, body, isMultipart = false) {
       res.on('data', c => data += c);
       res.on('end', () => {
         const ok = res.statusCode >= 200 && res.statusCode < 300;
-        try {
-          const j = data ? JSON.parse(data) : {};
-          if (!ok) return reject(new Error(`Etsy ${method} ${p} → ${res.statusCode}: ${j.error || data.slice(0, 400)}`));
-          resolve(j);
-        } catch { ok ? resolve({ raw: data }) : reject(new Error(`Etsy ${method} ${p} → ${res.statusCode}: ${data.slice(0, 400)}`)); }
+        let j; try { j = data ? JSON.parse(data) : {}; } catch { j = { raw: data }; }
+        if (!ok) {
+          const err = new Error(`Etsy ${method} ${p} → ${res.statusCode}: ${j.error || j.error_description || (data || '').slice(0, 400)}`);
+          err.status = res.statusCode;
+          err.body = j;
+          return reject(err);
+        }
+        resolve(j);
       });
     });
     req.on('error', reject);
@@ -214,6 +223,100 @@ function etsyRequest(method, p, body, isMultipart = false) {
   });
 }
 
+// Refresh the access token via the stored refresh token. Updates in-memory
+// ETSY.{accessToken,refreshToken} AND rewrites them in secrets-manager .env
+// so a re-launched process picks up the new credentials.
+async function refreshAccessToken() {
+  if (!ETSY.refreshToken) {
+    const err = new Error('access token expired and no ETSY_OAUTH_REFRESH_TOKEN — re-run scripts/etsy-oauth-helper.js');
+    err.fatal = true;
+    throw err;
+  }
+  const body = new URLSearchParams({
+    grant_type: 'refresh_token',
+    client_id: ETSY.clientId,
+    refresh_token: ETSY.refreshToken,
+  }).toString();
+  const tok = await new Promise((resolve, reject) => {
+    const req = https.request({
+      method: 'POST', hostname: 'api.etsy.com', path: '/v3/public/oauth/token',
+      headers: {
+        'Content-Type': 'application/x-www-form-urlencoded',
+        'Content-Length': body.length,
+        Accept: 'application/json',
+      },
+    }, res => {
+      let data = ''; res.on('data', c => data += c);
+      res.on('end', () => {
+        let j; try { j = data ? JSON.parse(data) : {}; } catch { j = { raw: data }; }
+        if (res.statusCode >= 400) {
+          const err = new Error(`refresh ${res.statusCode}: ${j.error || data.slice(0, 200)}`);
+          err.fatal = res.statusCode === 400 || res.statusCode === 401;     // refresh token itself dead
+          return reject(err);
+        }
+        resolve(j);
+      });
+    });
+    req.on('error', reject); req.write(body); req.end();
+  });
+  ETSY.accessToken = tok.access_token;
+  if (tok.refresh_token) ETSY.refreshToken = tok.refresh_token;
+  persistTokens();
+  return tok;
+}
+
+// Direct rewrite of ~/Projects/secrets-manager/.env (the canonical secrets
+// store this script reads from on launch). Idempotent: replaces existing
+// key=value lines, appends if absent. Doesn't trigger full secrets-manager
+// fan-out (project .env files / MCP block in ~/.claude.json) — that's a
+// followup if Steve wants the new token visible to other projects too;
+// for THIS process it's the in-memory ETSY object that the retry uses.
+function persistTokens() {
+  const p = path.join(require('os').homedir(), 'Projects', 'secrets-manager', '.env');
+  if (!fs.existsSync(p)) return;
+  let text = fs.readFileSync(p, 'utf8');
+  const setLine = (key, val) => {
+    const re = new RegExp(`^${key}\\s*=.*$`, 'm');
+    if (re.test(text)) text = text.replace(re, `${key}=${val}`);
+    else text += (text.endsWith('\n') ? '' : '\n') + `${key}=${val}\n`;
+  };
+  setLine('ETSY_OAUTH_ACCESS_TOKEN', ETSY.accessToken);
+  if (ETSY.refreshToken) setLine('ETSY_OAUTH_REFRESH_TOKEN', ETSY.refreshToken);
+  fs.writeFileSync(p, text);
+}
+
+// Wrapper: try once, on 401 refresh + retry exactly once. A second 401 means
+// the refresh token itself is dead — fatal, user must re-auth.
+async function etsyRequest(method, p, body, isMultipart = false) {
+  try {
+    return await etsyRequestRaw(method, p, body, isMultipart);
+  } catch (e) {
+    if (e.status !== 401) throw e;
+    console.log('  ↻ 401 — refreshing access token and retrying once');
+    try {
+      await refreshAccessToken();
+    } catch (refreshErr) {
+      const msg = refreshErr.fatal
+        ? `refresh token also dead — re-run: node scripts/etsy-oauth-helper.js`
+        : `refresh failed: ${refreshErr.message}`;
+      const err = new Error(msg);
+      err.fatal = true;
+      throw err;
+    }
+    // Retry — if THIS 401s too, the new token is somehow bad; surface it.
+    try {
+      return await etsyRequestRaw(method, p, body, isMultipart);
+    } catch (retryErr) {
+      if (retryErr.status === 401) {
+        const err = new Error('refreshed token also rejected — re-run: node scripts/etsy-oauth-helper.js');
+        err.fatal = true;
+        throw err;
+      }
+      throw retryErr;
+    }
+  }
+}
+
 // Minimal multipart/form-data builder for file uploads.
 function buildMultipart(fields) {
   const boundary = '----WallcoEtsy' + Math.random().toString(36).slice(2);

← 7cdc0df etsy oauth: scripts/etsy-oauth-helper.js — one-time interact  ·  back to Wallco Ai  ·  cactus-curator by-base: full selection + action parity with ade5237 →