[object Object]

← 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

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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> &nbsp;·&nbsp; <a href="/admin/affiliates">Affiliates</a> &nbsp;·&nbsp; <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 = '';
+const key = (net, adv) => `${net}${NUL}${adv || ''}`;
+
+// A live on/off form button. `disabledOn` (network is off) locks an advertiser's
+// row so you can't pretend a merchant is live while its whole network is dark.
+function toggleForm(network, advertiser, enabled, locked) {
+  const label = enabled ? 'Turn OFF' : 'Turn ON';
+  const btn = locked
+    ? `<button type="button" disabled title="Network is off — turn the network on first" style="opacity:.4;cursor:not-allowed">Turn ON</button>`
+    : `<button title="${enabled ? 'Hide this source from the storefront' : 'Show this source on the storefront'}">${label}</button>`;
+  return `<form method="post" action="/admin/affiliates/toggle" style="margin:0">
+    <input type="hidden" name="network" value="${esc(network)}">
+    <input type="hidden" name="advertiser" value="${esc(advertiser || '')}">${btn}</form>`;
+}
+
+router.get('/admin/affiliates', async (_req, res, next) => {
+  try {
+    const [nets, advs, clk] = await Promise.all([
+      // network-level rollup + its own on/off row (advertiser='')
+      db.query(`SELECT p.network,
+          count(*) AS products,
+          count(*) FILTER (WHERE p.in_stock) AS in_stock,
+          count(DISTINCT p.advertiser) AS advertisers,
+          max(p.created_at) AS latest,
+          COALESCE(s.enabled, TRUE) AS enabled
+        FROM products p
+        LEFT JOIN affiliate_settings s ON s.network = p.network AND s.advertiser = ''
+        GROUP BY p.network, s.enabled
+        ORDER BY products DESC`),
+      // advertiser-level rollup + own state (adv_enabled) and its network's state (net_enabled)
+      db.query(`SELECT p.network,
+          COALESCE(p.advertiser, '') AS advertiser,
+          count(*) AS products,
+          count(*) FILTER (WHERE p.in_stock) AS in_stock,
+          max(p.created_at) AS latest,
+          COALESCE(s.enabled, TRUE)  AS adv_enabled,
+          COALESCE(ns.enabled, TRUE) AS net_enabled
+        FROM products p
+        LEFT JOIN affiliate_settings s  ON s.network = p.network AND s.advertiser = COALESCE(p.advertiser, '')
+        LEFT JOIN affiliate_settings ns ON ns.network = p.network AND ns.advertiser = ''
+        GROUP BY p.network, COALESCE(p.advertiser, ''), s.enabled, ns.enabled
+        ORDER BY p.network, products DESC`),
+      // clicks per (network, advertiser), total + last 7 days
+      db.query(`SELECT network, COALESCE(advertiser, '') AS advertiser,
+          count(*) AS total,
+          count(*) FILTER (WHERE clicked_at > now() - interval '7 days') AS n7
+        FROM clicks GROUP BY network, COALESCE(advertiser, '')`),
+    ]);
+
+    // index clicks by advertiser key + roll up to network totals
+    const clkByAdv = new Map(), clkByNet = new Map();
+    for (const r of clk.rows) {
+      clkByAdv.set(key(r.network, r.advertiser), r);
+      const nn = clkByNet.get(r.network) || { total: 0, n7: 0 };
+      nn.total += Number(r.total); nn.n7 += Number(r.n7);
+      clkByNet.set(r.network, nn);
+    }
+
+    const onOff = (on) => `<span class="pill ${on ? 'on' : 'off'}">${on ? '● ON' : '○ OFF'}</span>`;
+
+    // ---- Networks table (coarse master switch per affiliate program) ----
+    const netRows = nets.rows.map((n) => {
+      const ck = clkByNet.get(n.network) || { total: 0, n7: 0 };
+      return `<tr>
+        <td><b class="net">${esc(n.network)}</b></td>
+        <td>${n.advertisers}</td>
+        <td>${n.in_stock} <span class="subtle">/ ${n.products}</span></td>
+        <td>${ck.n7} <span class="subtle">/ ${ck.total}</span></td>
+        <td>${when(n.latest)}</td>
+        <td>${onOff(n.enabled)}</td>
+        <td>${toggleForm(n.network, '', n.enabled, false)}</td>
+      </tr>`;
+    }).join('');
+
+    // ---- Advertisers table (the real "affiliates" list, grouped by network) ----
+    const advRows = advs.rows.map((a) => {
+      const ck = clkByAdv.get(key(a.network, a.advertiser)) || { total: 0, n7: 0 };
+      const live = a.adv_enabled && a.net_enabled;         // effective storefront visibility
+      const statusCell = a.net_enabled
+        ? onOff(a.adv_enabled)
+        : `${onOff(false)} <span class="subtle" title="Suppressed because the ${esc(a.network)} network is off">(network off)</span>`;
+      return `<tr class="${live ? '' : 'row-off'}">
+        <td>${esc(a.advertiser || '(unattributed)')}<div class="net">${esc(a.network)}</div></td>
+        <td>${a.in_stock} <span class="subtle">/ ${a.products}</span></td>
+        <td>${ck.n7} <span class="subtle">/ ${ck.total}</span></td>
+        <td>${when(a.latest)}</td>
+        <td>${statusCell}</td>
+        <td>${toggleForm(a.network, a.advertiser, a.adv_enabled, !a.net_enabled)}</td>
+      </tr>`;
+    }).join('');
+
+    const offCount = advs.rows.filter((a) => !(a.adv_enabled && a.net_enabled)).length;
+    const intro = `<p class="subtle" style="margin:0 0 6px">
+      An <b>affiliate</b> is a source of tracked links — a whole network or a single merchant on it.
+      Switch one <b>OFF</b> and its products immediately disappear from the storefront, room builder, and brand
+      filters (no re-import needed); switch it back <b>ON</b> to restore them. Nothing is deleted.
+      ${offCount ? `<b style="color:#b1483c"> ${offCount} affiliate${offCount === 1 ? '' : 's'} currently off.</b>` : ''}</p>`;
+
+    res.send(shell(`
+      <h1 style="font-size:1.15rem;margin:0 0 4px">Affiliates</h1>
+      ${intro}
+      <h2>Networks — master switch per program</h2>
+      <table>
+        <tr><th>Network</th><th>Merchants</th><th>In-stock / total</th><th>Clicks 7d / all</th><th>Newest item</th><th>Status</th><th>Switch</th></tr>
+        ${netRows || `<tr><td colspan="7" class="subtle">No products ingested yet.</td></tr>`}
+      </table>
+      <h2>Advertisers — every merchant (${advs.rows.length})</h2>
+      <table>
+        <tr><th>Affiliate (merchant)</th><th>In-stock / total</th><th>Clicks 7d / all</th><th>Newest item</th><th>Status</th><th>Switch</th></tr>
+        ${advRows || `<tr><td colspan="6" class="subtle">No advertisers found.</td></tr>`}
+      </table>
+    `));
+  } catch (e) { next(e); }
+});
+
+// Toggle one affiliate (network-wide when advertiser is blank, else that merchant).
+// Default state is ENABLED, so we only ever persist a row once it's been touched;
+// the flip is idempotent and reversible. We guard against junk keys by requiring
+// the (network[, advertiser]) to actually exist in the catalog.
+router.post('/admin/affiliates/toggle', async (req, res, next) => {
+  try {
+    const network = String(req.body.network || '').trim().slice(0, 40);
+    const advertiser = String(req.body.advertiser || '').trim().slice(0, 200);
+    if (!network) return res.status(400).send('Missing network.');
+    const exists = advertiser
+      ? await db.query('SELECT 1 FROM products WHERE network=$1 AND advertiser=$2 LIMIT 1', [network, advertiser])
+      : await db.query('SELECT 1 FROM products WHERE network=$1 LIMIT 1', [network]);
+    if (!exists.rowCount) return res.status(404).send('Unknown affiliate.');
+    await db.query(
+      `INSERT INTO affiliate_settings (network, advertiser, enabled, updated_at)
+       VALUES ($1, $2, FALSE, now())
+       ON CONFLICT (network, advertiser)
+       DO UPDATE SET enabled = NOT affiliate_settings.enabled, updated_at = now()`,
+      [network, advertiser]);
+    res.redirect('/admin/affiliates');
+  } catch (e) { next(e); }
+});
+
 module.exports = router;
diff --git a/server.js b/server.js
index 215b783..8711312 100644
--- a/server.js
+++ b/server.js
@@ -43,7 +43,7 @@ function gridControls(productCount) {
   <div class="grid-controls" role="toolbar" aria-label="Grid controls">
     <label>Sort
       <select id="sortSel" aria-label="Sort products">
-        <option value="newest">Newest</option>
+        <option value="newest">Date — Newest</option>
         <option value="color">Color</option>
         <option value="style">Style</option>
         <option value="brand">Brand A→Z</option>
@@ -52,8 +52,14 @@ function gridControls(productCount) {
         <option value="price-desc">Price ↓</option>
       </select>
     </label>
-    <label class="density">Density
-      <input id="densitySel" type="range" min="140" max="360" step="20" value="240" aria-label="Grid density">
+    <label class="mb-project-ctl">Mood board
+      <select id="mbProjectSel" aria-label="Active mood board project">
+        <option value="">Loading…</option>
+      </select>
+    </label>
+    <label class="density">Columns
+      <input id="densitySel" type="range" min="2" max="6" step="1" value="4" aria-label="Grid columns">
+      <span class="density-val" id="densityVal" aria-hidden="true">4</span>
     </label>
     ${countBadge}
   </div>`;

← f14bc91 refactor: replace all SELECT * with explicit column lists (l  ·  back to Interiordesignershowroom  ·  moodboard drawer: in-drawer project switcher + collapsible P 81400f7 →