← back to Interiordesignershowroom
shop: collapse facet rail on load, 4-across grid + columns slider, mood-board project select
590fca725553862b833254d23aa3208d67a5cc2b · 2026-08-03 08:28:14 -0700 · Steve
- facet rail -> native <details>, all collapsed on load (auto-opens only the group holding an active filter)
- grid controls: columns slider (2-6, default 4) driving #grid via --gcols; grid starts 4-across even pre-JS
- sort: date/color/style/brand/title/price all present (Newest relabelled 'Date - Newest')
- mood board -> named PROJECTS: #mbProjectSel switcher, create/switch, per-project item lists; legacy single board migrated to 'My Board'
Files touched
M db/schema.sqlM lib/catalog.jsM lib/rooms.jsM public/css/site.cssM public/js/grid.jsM public/js/moodboard.jsM routes/admin.jsM server.js
Diff
commit 590fca725553862b833254d23aa3208d67a5cc2b
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Aug 3 08:28:14 2026 -0700
shop: collapse facet rail on load, 4-across grid + columns slider, mood-board project select
- facet rail -> native <details>, all collapsed on load (auto-opens only the group holding an active filter)
- grid controls: columns slider (2-6, default 4) driving #grid via --gcols; grid starts 4-across even pre-JS
- sort: date/color/style/brand/title/price all present (Newest relabelled 'Date - Newest')
- mood board -> named PROJECTS: #mbProjectSel switcher, create/switch, per-project item lists; legacy single board migrated to 'My Board'
---
db/schema.sql | 15 +++++
lib/catalog.js | 20 ++++++-
lib/rooms.js | 12 +++-
public/css/site.css | 61 ++++++++++++++++++-
public/js/grid.js | 20 ++++---
public/js/moodboard.js | 79 +++++++++++++++++++++++--
routes/admin.js | 158 ++++++++++++++++++++++++++++++++++++++++++++++++-
server.js | 12 +++-
8 files changed, 356 insertions(+), 21 deletions(-)
diff --git a/db/schema.sql b/db/schema.sql
index 6193708..a0e4737 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -61,3 +61,18 @@ CREATE TABLE IF NOT EXISTS clicks (
CREATE INDEX IF NOT EXISTS idx_clicks_product ON clicks (product_id);
CREATE INDEX IF NOT EXISTS idx_clicks_time ON clicks (clicked_at DESC);
+
+-- Affiliate on/off control. An "affiliate" here is a source of tracked links —
+-- either a whole NETWORK (advertiser = '') or a specific ADVERTISER/merchant on
+-- that network. A product is visible on the storefront only if NEITHER its network
+-- nor its (network, advertiser) has been switched OFF here. ABSENCE = ENABLED, so a
+-- newly-ingested advertiser is live by default and we only persist explicit OFFs.
+CREATE TABLE IF NOT EXISTS affiliate_settings (
+ network TEXT NOT NULL,
+ advertiser TEXT NOT NULL DEFAULT '', -- '' = the whole network
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ PRIMARY KEY (network, advertiser)
+);
+-- Only the disabled rows ever need scanning during a storefront query.
+CREATE INDEX IF NOT EXISTS idx_affiliate_settings_off ON affiliate_settings (network, advertiser) WHERE enabled = FALSE;
diff --git a/lib/catalog.js b/lib/catalog.js
index e5a9a8d..0b51e6c 100644
--- a/lib/catalog.js
+++ b/lib/catalog.js
@@ -15,7 +15,7 @@ const PRICE_BUCKETS = [
// Build a parameterized WHERE from active filters, optionally EXCLUDING one
// dimension (so a facet's own counts reflect the rest of the query, not itself).
function buildWhere(f, exclude) {
- const where = ['in_stock'];
+ const where = ['in_stock', AFFILIATE_ENABLED];
const params = [];
for (const d of DIMENSIONS) {
if (f[d] && d !== exclude) { params.push(f[d]); where.push(`${d} = $${params.length}`); }
@@ -25,6 +25,17 @@ function buildWhere(f, exclude) {
return { sql: where.join(' AND '), params };
}
+// Storefront visibility gate: hide any product whose network — or whose specific
+// (network, advertiser) — has been switched OFF in the admin's affiliate_settings.
+// Correlates on the outer `products` row; carries no params (admin state, not user
+// input) so it slots into every buildWhere caller without renumbering placeholders.
+const AFFILIATE_ENABLED = `NOT EXISTS (
+ SELECT 1 FROM affiliate_settings a
+ WHERE a.enabled = FALSE
+ AND a.network = products.network
+ AND (a.advertiser = '' OR a.advertiser = COALESCE(products.advertiser, ''))
+ )`;
+
// Serialize filters back to a query string, applying a change (set/clear one key).
function toQuery(f, change = {}) {
const merged = { ...f, ...change };
@@ -46,20 +57,23 @@ async function facetCounts(f) {
}
function facetRail(f, counts, basePath) {
+ // Each dimension is a native <details> so the rail is collapsible with zero JS.
+ // ALL groups start collapsed on load (Steve's spec); a group is auto-opened only
+ // when it holds the currently-active filter, so an applied facet is never hidden.
const groups = DIMENSIONS.map((d) => {
const items = counts[d].map((row) => {
const active = f[d] === row.val;
const href = basePath + toQuery(f, { [d]: active ? '' : row.val });
return `<a class="facet${active ? ' active' : ''}" href="${esc(href)}">${esc(row.val)} <span class="fn">${row.n}</span></a>`;
}).join('');
- return items ? `<div class="facet-group"><h4>${LABELS[d]}</h4>${items}</div>` : '';
+ return items ? `<details class="facet-group"${f[d] ? ' open' : ''}><summary>${LABELS[d]}</summary><div class="facet-list">${items}</div></details>` : '';
}).join('');
const priceItems = PRICE_BUCKETS.map(([v, label]) => {
const active = f.max === v;
const href = basePath + toQuery(f, { max: active ? '' : v });
return `<a class="facet${active ? ' active' : ''}" href="${esc(href)}">${esc(label)}</a>`;
}).join('');
- return `<aside class="facets">${groups}<div class="facet-group"><h4>Price</h4>${priceItems}</div></aside>`;
+ return `<aside class="facets">${groups}<details class="facet-group"${f.max ? ' open' : ''}><summary>Price</summary><div class="facet-list">${priceItems}</div></details></aside>`;
}
function activeChips(f, basePath) {
diff --git a/lib/rooms.js b/lib/rooms.js
index 9b87620..095a25f 100644
--- a/lib/rooms.js
+++ b/lib/rooms.js
@@ -5,8 +5,17 @@ const COLS = require('./cols');
const slugify = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'room';
+// Same storefront affiliate on/off gate the faceted catalog uses (see lib/catalog.js).
+// Applied to the LIVE browse surfaces (Room Builder search + brand facet) so a
+// switched-off advertiser disappears everywhere a shopper can discover it. Saved
+// rooms + the paint palette are deliberately NOT gated — they're curated artifacts.
+const AFFILIATE_ENABLED = `NOT EXISTS (
+ SELECT 1 FROM affiliate_settings a
+ WHERE a.enabled = FALSE AND a.network = products.network
+ AND (a.advertiser = '' OR a.advertiser = COALESCE(products.advertiser, '')))`;
+
async function searchProducts({ q, room, style, color, colors, advertiser, cat, max, limit = 40, offset = 0 } = {}) {
- const where = ['in_stock', 'is_wall_paint = FALSE', 'image_url IS NOT NULL'];
+ const where = ['in_stock', 'is_wall_paint = FALSE', 'image_url IS NOT NULL', AFFILIATE_ENABLED];
const params = [];
if (room) { params.push(room); where.push(`room = $${params.length}`); }
if (style) { params.push(style); where.push(`style = $${params.length}`); }
@@ -80,6 +89,7 @@ async function listRooms({ limit = 60 } = {}) {
async function listBrands(limit = 40) {
const { rows } = await db.query(
`SELECT advertiser, count(*) n FROM products WHERE advertiser IS NOT NULL AND NOT is_wall_paint AND image_url IS NOT NULL
+ AND ${AFFILIATE_ENABLED}
GROUP BY advertiser HAVING count(*) >= 3 ORDER BY n DESC LIMIT $1`, [limit]);
return rows;
}
diff --git a/public/css/site.css b/public/css/site.css
index 955be54..81cdbf6 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -328,6 +328,27 @@ h2.ruled::after {
grid-template-columns: repeat(auto-fill, minmax(var(--cols), 1fr));
gap: 2px;
}
+/* The catalog grid (#grid) is driven by an explicit COLUMN COUNT (the toolbar
+ slider), starting at 4 across. Other .grid blocks (home, room detail) keep the
+ responsive min-width behavior above. --gcols is set by /js/grid.js. */
+#grid {
+ grid-template-columns: repeat(var(--gcols, 4), minmax(0, 1fr));
+}
+@media (max-width: 640px) {
+ #grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+/* Toolbar column-count readout next to the slider */
+.grid-controls .density-val {
+ min-width: 1.1em;
+ text-align: center;
+ color: var(--ink);
+ font-variant-numeric: tabular-nums;
+}
+/* Mood-board project switcher — cap width so long names don't stretch the toolbar */
+#mbProjectSel {
+ max-width: 200px;
+ text-overflow: ellipsis;
+}
/* Empty grid state */
.grid-empty {
grid-column: 1/-1;
@@ -572,8 +593,46 @@ h2.ruled::after {
top: 88px;
display: flex;
flex-direction: column;
- gap: 28px;
+ gap: 2px;
+}
+/* Collapsible facet groups — native <details>, all collapsed on load.
+ Summary mirrors the room-builder .rb-tab look for a consistent design system. */
+.facet-group {
+ border-bottom: 1px solid var(--line);
+}
+.facet-group > summary {
+ cursor: pointer;
+ list-style: none;
+ user-select: none;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 2px;
+ margin: 0;
+ font-family: var(--sans);
+ font-size: .65rem;
+ text-transform: uppercase;
+ letter-spacing: .12em;
+ color: var(--muted-lt);
+ transition: color var(--dur);
+}
+.facet-group > summary:hover { color: var(--ink); }
+.facet-group > summary::-webkit-details-marker { display: none; }
+.facet-group > summary::after {
+ content: '+';
+ font-size: 1rem;
+ font-weight: 300;
+ color: var(--muted-lt);
+}
+.facet-group[open] > summary { color: var(--ink); }
+.facet-group[open] > summary::after { content: '\2013'; }
+.facet-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ padding: 2px 0 12px;
}
+/* legacy label (kept harmless in case any static markup still uses it) */
.facet-group h4 {
margin: 0 0 10px;
font-family: var(--sans);
diff --git a/public/js/grid.js b/public/js/grid.js
index d1de656..9a2a05d 100644
--- a/public/js/grid.js
+++ b/public/js/grid.js
@@ -5,14 +5,20 @@
var grid = document.getElementById('grid');
var sortSel = document.getElementById('sortSel');
var densitySel = document.getElementById('densitySel');
+ var densityVal = document.getElementById('densityVal');
if (!grid) return;
- var LS_SORT = 'ids.sort', LS_DENSITY = 'ids.density';
+ var LS_SORT = 'ids.sort', LS_COLS = 'ids.cols';
+ var COL_MIN = 2, COL_MAX = 6, COL_DEFAULT = 4;
- function applyDensity(px) {
- document.documentElement.style.setProperty('--cols', px + 'px');
- if (densitySel) densitySel.value = px;
- localStorage.setItem(LS_DENSITY, px);
+ // The slider now sets an explicit COLUMN COUNT (2–6, default 4) on --gcols,
+ // which #grid consumes as `repeat(var(--gcols), 1fr)`. Grid starts at 4 across.
+ function applyCols(n) {
+ n = Math.min(COL_MAX, Math.max(COL_MIN, parseInt(n, 10) || COL_DEFAULT));
+ document.documentElement.style.setProperty('--gcols', n);
+ if (densitySel) densitySel.value = n;
+ if (densityVal) densityVal.textContent = n;
+ localStorage.setItem(LS_COLS, n);
}
function num(el, a) { return parseFloat(el.getAttribute(a)) || 0; }
@@ -36,8 +42,8 @@
}
if (sortSel) sortSel.addEventListener('change', function () { sortBy(sortSel.value); });
- if (densitySel) densitySel.addEventListener('input', function () { applyDensity(densitySel.value); });
+ if (densitySel) densitySel.addEventListener('input', function () { applyCols(densitySel.value); });
- applyDensity(localStorage.getItem(LS_DENSITY) || 240);
+ applyCols(localStorage.getItem(LS_COLS) || COL_DEFAULT);
sortBy(localStorage.getItem(LS_SORT) || 'newest');
})();
diff --git a/public/js/moodboard.js b/public/js/moodboard.js
index 99d17b7..d144791 100644
--- a/public/js/moodboard.js
+++ b/public/js/moodboard.js
@@ -7,14 +7,69 @@
// selected products into an Architectural-Digest-grade room) is done server-side by the
// EXISTING POST /api/render (Gemini 2.5 Flash Image, ~$0.039/render).
(function () {
- var KEY = 'ids_moodboard';
+ // Named mood-board PROJECTS: pick/create the active project from the toolbar
+ // <select id="mbProjectSel">. Storage model:
+ // ids_mb_projects → { "<name>": [items...] } ids_mb_active → active name
+ // The legacy single board (ids_moodboard) is migrated into "My Board" once.
+ var LEGACY_KEY = 'ids_moodboard';
+ var PKEY = 'ids_mb_projects';
+ var AKEY = 'ids_mb_active';
+ var DEFAULT_PROJECT = 'My Board';
+ var projectSel = null;
var COST = 0.039; // per render, mirrors scene.COST_PER_IMAGE — shown before + after
var SESSION_CAP = 8; // client-side spend guard: max renders per browser session (server also rate-limits per-IP)
function renderCount() { try { return Number(sessionStorage.getItem('ids_mb_renders') || 0); } catch (e) { return 0; } }
function bumpRenderCount() { try { sessionStorage.setItem('ids_mb_renders', String(renderCount() + 1)); } catch (e) {} }
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
- function load() { try { return JSON.parse(localStorage.getItem(KEY)) || []; } catch (e) { return []; } }
- function save(b) { localStorage.setItem(KEY, JSON.stringify(b)); sync(); }
+ // ---------- project store ----------
+ function readProjects() {
+ try {
+ var raw = JSON.parse(localStorage.getItem(PKEY));
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw;
+ } catch (e) {}
+ // first run: migrate the legacy single board (if any) into DEFAULT_PROJECT
+ var seed = {};
+ try { var old = JSON.parse(localStorage.getItem(LEGACY_KEY)); if (Array.isArray(old) && old.length) seed[DEFAULT_PROJECT] = old; } catch (e) {}
+ if (!seed[DEFAULT_PROJECT]) seed[DEFAULT_PROJECT] = [];
+ localStorage.setItem(PKEY, JSON.stringify(seed));
+ return seed;
+ }
+ function writeProjects(p) { localStorage.setItem(PKEY, JSON.stringify(p)); }
+ function getActive() {
+ var p = readProjects();
+ var a = localStorage.getItem(AKEY);
+ if (!a || !(a in p)) { a = Object.keys(p)[0] || DEFAULT_PROJECT; localStorage.setItem(AKEY, a); }
+ return a;
+ }
+ function setActive(name) {
+ var p = readProjects();
+ if (!(name in p)) { p[name] = []; writeProjects(p); }
+ localStorage.setItem(AKEY, name);
+ lastRender = null; // switching projects clears any shown render
+ sync();
+ if (!drawer.hidden) renderScroll();
+ }
+ function createProject(name) {
+ name = (name || '').trim().slice(0, 60);
+ if (!name) { populateProjectSel(); return; }
+ var p = readProjects();
+ if (!(name in p)) { p[name] = []; writeProjects(p); }
+ setActive(name);
+ }
+ function populateProjectSel() {
+ projectSel = document.getElementById('mbProjectSel');
+ if (!projectSel) return;
+ var p = readProjects();
+ var active = getActive();
+ var opts = Object.keys(p).map(function (n) {
+ return '<option value="' + esc(n) + '"' + (n === active ? ' selected' : '') + '>' + esc(n) + ' (' + p[n].length + ')</option>';
+ }).join('');
+ projectSel.innerHTML = opts + '<option value="__new__">+ New project…</option>';
+ }
+
+ // load()/save() always operate on the ACTIVE project's item list
+ function load() { var p = readProjects(); return p[getActive()] || []; }
+ function save(b) { var p = readProjects(); p[getActive()] = b; writeProjects(p); sync(); }
function has(id) { return load().some(function (x) { return String(x.id) === String(id); }); }
function addItem(it) { var b = load(); if (!b.some(function (x) { return String(x.id) === String(it.id); })) { if (!b.some(function (x) { return x.primary; })) it.primary = true; b.push(it); save(b); } }
function removeItem(id) {
@@ -95,7 +150,7 @@
var backdrop = document.createElement('div'); backdrop.className = 'mb-backdrop'; backdrop.hidden = true;
var drawer = document.createElement('div'); drawer.className = 'mbdrawer'; drawer.hidden = true;
drawer.setAttribute('role', 'dialog'); drawer.setAttribute('aria-label', 'Your mood board');
- drawer.innerHTML = '<div class="mbdrawer-head">Mood Board <button class="mbdrawer-x" type="button" aria-label="Close">✕</button></div>'
+ drawer.innerHTML = '<div class="mbdrawer-head"><span class="mbdrawer-title">Mood Board</span> <button class="mbdrawer-x" type="button" aria-label="Close">✕</button></div>'
+ '<div class="mb-scroll"></div>';
document.body.appendChild(fab); document.body.appendChild(backdrop); document.body.appendChild(drawer);
@@ -105,6 +160,8 @@
// ---------- drawer rendering: assemble view vs result view ----------
function renderScroll() {
var scroll = drawer.querySelector('.mb-scroll');
+ var head = drawer.querySelector('.mbdrawer-title');
+ if (head) head.textContent = getActive();
if (lastRender) return renderResult(scroll);
var b = load();
if (!b.length) { scroll.innerHTML = '<p class="mb-empty">Your mood board is empty.<br>While you shop, tap <strong>◇ Mood Board</strong> on any piece to collect it here, then turn the whole board into a room.</p>'; return; }
@@ -234,6 +291,7 @@
fab.querySelector('.mbfab-n').textContent = n;
fab.classList.toggle('has', n > 0);
document.querySelectorAll('.mb-add').forEach(syncBtn);
+ populateProjectSel();
if (!drawer.hidden) renderScroll();
}
@@ -251,6 +309,19 @@
var rm = e.target.closest('[data-rm]'); if (rm) return removeItem(rm.getAttribute('data-rm'));
});
drawer.addEventListener('change', function (e) { /* room select — read at create time */ });
+ // toolbar mood-board project switcher (select lives outside the drawer)
+ document.addEventListener('change', function (e) {
+ var sel = e.target.closest ? e.target.closest('#mbProjectSel') : null;
+ if (!sel) return;
+ if (sel.value === '__new__') {
+ var name = window.prompt('Name your new mood board project:', '');
+ if (name && name.trim()) createProject(name);
+ else populateProjectSel(); // user cancelled — restore the active selection
+ return;
+ }
+ setActive(sel.value);
+ });
+
// per-card add/remove (delegated so infinite-scroll cards work)
document.addEventListener('click', function (e) {
var b = e.target.closest('.mb-add'); if (!b) return;
diff --git a/routes/admin.js b/routes/admin.js
index 30f7d1d..77cbd0a 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -71,8 +71,14 @@ button{cursor:pointer;border:1px solid var(--line);background:#fff;border-radius
button:hover{border-color:var(--accent);color:var(--accent)}
img.thumb{width:40px;height:40px;object-fit:cover;border-radius:4px;vertical-align:middle}
.net{font-size:.7rem;text-transform:uppercase;color:var(--accent);letter-spacing:.04em}
+.subtle{color:#8a8178;font-size:.82em}
+tr.row-off td{background:#faf5f2;color:#9a8f86}
+tr.row-off td:first-child{box-shadow:inset 3px 0 0 #d9bcbc}
+.pill.on{color:#2f7a2f}.pill.off{color:#b1483c}
+header nav a{margin-left:2px}
</style></head><body>
-<header><h1>Interior Designer’s Showroom — Admin</h1><a href="/">← View site</a></header>
+<header><h1>Interior Designer’s Showroom — Admin</h1>
+<nav><a href="/admin">Dashboard</a> · <a href="/admin/affiliates">Affiliates</a> · <a href="/">View site ↗</a></nav></header>
<main>${body}</main></body></html>`;
router.get('/admin', async (_req, res, next) => {
@@ -83,7 +89,9 @@ router.get('/admin', async (_req, res, next) => {
(SELECT count(*) FROM products WHERE featured) AS featured,
(SELECT count(*) FROM guides WHERE published) AS guides,
(SELECT count(*) FROM clicks) AS clicks,
- (SELECT count(*) FROM clicks WHERE clicked_at > now() - interval '7 days') AS clicks7`),
+ (SELECT count(*) FROM clicks WHERE clicked_at > now() - interval '7 days') AS clicks7,
+ (SELECT count(DISTINCT advertiser) FROM products WHERE advertiser IS NOT NULL) AS affiliates,
+ (SELECT count(*) FROM affiliate_settings WHERE enabled = FALSE) AS affiliates_off`),
db.query(`SELECT p.id,p.title,p.advertiser,count(c.*) AS n
FROM clicks c JOIN products p ON p.id=c.product_id
GROUP BY p.id,p.title,p.advertiser ORDER BY n DESC LIMIT 10`),
@@ -98,6 +106,7 @@ router.get('/admin', async (_req, res, next) => {
<div class="stat"><b>${c.guides}</b><span>Published guides</span></div>
<div class="stat"><b>${c.clicks}</b><span>Total clicks</span></div>
<div class="stat"><b>${c.clicks7}</b><span>Clicks · 7 days</span></div>
+ <div class="stat"><b>${c.affiliates}</b><span>Affiliates${Number(c.affiliates_off) ? ` · <span style="color:#b1483c">${c.affiliates_off} off</span>` : ''}</span></div>
</div>`;
const topRows = topClicks.rows.length
@@ -123,6 +132,7 @@ router.get('/admin', async (_req, res, next) => {
res.send(shell(`
${stats}
+ <p style="margin:16px 0 0"><a href="/admin/affiliates" style="display:inline-block;background:#221e19;color:#e6c98a;padding:8px 14px;border-radius:6px;text-decoration:none;font-size:.85rem">⚙︎ Manage affiliates — turn sources on / off →</a></p>
<h2>Top-clicked products</h2>
<table><tr><th>Product</th><th>Advertiser</th><th>Clicks</th></tr>${topRows}</table>
<h2>Products — curate featured (${products.rows.length})</h2>
@@ -142,4 +152,148 @@ router.post('/admin/guides/:id/published', async (req, res, next) => {
catch (e) { next(e); }
});
+// --- Affiliates: see every link source & switch it on/off -----------------
+// An affiliate is either a whole NETWORK (cj/amazon/...) or a specific ADVERTISER
+// (merchant) on that network. OFF here => that source's products vanish from the
+// storefront, room builder, and brand facet (see the AFFILIATE_ENABLED gate in
+// lib/catalog.js + lib/rooms.js). A product is live only if BOTH its network and
+// its advertiser are on.
+const NUL = '