← 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
M data/cactus-decisions.jsonlM public/admin/cactus-curator.htmlM scripts/push-etsy-bundle.js
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 →