← back to Zuber Internal
Rail parity with all.dw: cross-filtered facets, color families+swatches, active-chip row, needs-curation preserved
6db0b612f40358cec2f3f92ecb44b42fbc6113bc · 2026-07-09 16:08:13 -0700 · Steve
Files touched
M public/index.htmlM server.js
Diff
commit 6db0b612f40358cec2f3f92ecb44b42fbc6113bc
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 9 16:08:13 2026 -0700
Rail parity with all.dw: cross-filtered facets, color families+swatches, active-chip row, needs-curation preserved
---
public/index.html | 227 +++++++++++++++++++++++++++++++++++++++++++-----------
server.js | 188 ++++++++++++++++++++++++++++++++++----------
2 files changed, 327 insertions(+), 88 deletions(-)
diff --git a/public/index.html b/public/index.html
index 7f0746e..9afd948 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zuber — Designer Wallcoverings (internal)</title>
<style>
- :root { --cols: 5; --fs: 13px; --teal: #0f766e; --ink: #1a1a1a; --line: #e2e2e0; --bg: #fafaf8; }
+ :root { --cols: 5; --fs: 13px; --teal: #0f766e; --ink: #1a1a1a; --mut:#6a6a72; --line: #e2e2e0; --bg: #fafaf8; --card:#f6f6f4; }
* { box-sizing: border-box; }
body { margin: 0; font: var(--fs)/1.45 -apple-system, "Helvetica Neue", Arial, sans-serif; color: var(--ink); background: var(--bg); }
header { position: sticky; top: 0; z-index: 20; background: #fff; border-bottom: 1px solid var(--line); padding: 10px 16px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
@@ -15,18 +15,38 @@
header label { font-size: 11px; color: #666; display: inline-flex; align-items: center; gap: 6px; }
select, input[type=search] { font: inherit; padding: 5px 8px; border: 1px solid var(--line); border-radius: 4px; background: #fff; }
input[type=range] { vertical-align: middle; }
- .layout { display: grid; grid-template-columns: 260px 1fr; gap: 0; }
- aside { border-right: 1px solid var(--line); background: #fff; height: calc(100vh - 52px); overflow-y: auto; position: sticky; top: 52px; padding: 12px; }
- aside .search { width: 100%; margin-bottom: 12px; }
- .facet { border: 1px solid var(--line); border-radius: 5px; margin-bottom: 8px; overflow: hidden; }
- .facet > summary { cursor: pointer; padding: 7px 10px; font-weight: 600; font-size: 12px; list-style: none; display: flex; justify-content: space-between; align-items: center; background: #f6f6f4; }
- .facet > summary::-webkit-details-marker { display: none; }
- .facet .rows { max-height: 260px; overflow-y: auto; }
- .facet .row { display: flex; justify-content: space-between; align-items: center; padding: 4px 10px; cursor: pointer; font-size: 12px; }
- .facet .row:hover { background: #f0f0ee; }
- .facet .row.on { background: #e6f4f2; }
- .facet .row .badge { background: var(--teal); color: #fff; border-radius: 10px; padding: 0 7px; font-size: 10px; min-width: 20px; text-align: center; }
- .facet .row.on .badge { background: #0b544e; }
+ .layout { display: grid; grid-template-columns: 262px 1fr; gap: 0; }
+
+ /* ── left rail (all.dw parity) ── */
+ aside { border-right: 1px solid var(--line); background: #fff; height: calc(100vh - 52px); overflow-y: auto; position: sticky; top: 52px; padding: 10px 10px 60px; }
+ aside .search { width: 100%; margin-bottom: 10px; }
+
+ /* active-filter chip row */
+ .active { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
+ .afilter { background: #e6f4f2; border: 1px solid var(--teal); color: var(--ink); border-radius: 20px; padding: 3px 9px; font-size: 11px; cursor: pointer; }
+ .afilter b { color: var(--teal); font-weight: 600; }
+ .clearall { background: none; border: 1px solid var(--line); color: var(--mut); border-radius: 20px; padding: 3px 11px; font-size: 11px; cursor: pointer; }
+
+ /* collapsible facet sections */
+ details.sec { border: 1px solid var(--line); border-radius: 7px; margin-bottom: 7px; background: var(--card); overflow: hidden; }
+ details.sec > summary { list-style: none; cursor: pointer; user-select: none; padding: 8px 10px; font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--mut); display: flex; align-items: center; gap: 7px; font-weight: 600; }
+ details.sec > summary::-webkit-details-marker { display: none; }
+ details.sec > summary::before { content: '▸'; color: var(--teal); font-size: 10px; }
+ details.sec[open] > summary::before { content: '▾'; }
+ details.sec > summary .n { margin-left: auto; background: var(--teal); color: #fff; border-radius: 20px; padding: 0 7px; font-size: 10px; font-weight: 700; }
+ .fbody { padding: 2px 8px 9px; max-height: 300px; overflow-y: auto; }
+ .fitem { display: flex; align-items: center; gap: 7px; padding: 2.5px 2px; cursor: pointer; font-size: 12.5px; color: var(--ink); border-radius: 5px; }
+ .fitem:hover { background: #eaeae7; }
+ .fitem .box { width: 13px; height: 13px; flex: 0 0 13px; border: 1px solid var(--mut); border-radius: 3px; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; }
+ .fitem.on .box { background: var(--teal); border-color: var(--teal); color: #fff; font-weight: 700; }
+ .fitem.on { color: var(--teal); }
+ .fitem .swatch { width: 12px; height: 12px; flex: 0 0 12px; border-radius: 3px; border: 1px solid rgba(0,0,0,.18); }
+ .fitem .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .fitem .ct { color: var(--mut); font-size: 10.5px; }
+ .fitem.zero { opacity: .35; }
+ .more { color: var(--teal); font-size: 11.5px; cursor: pointer; padding: 3px 2px; display: inline-block; }
+ .ffind { width: 100%; padding: 4px 8px !important; font-size: 11.5px !important; border: 1px solid var(--line); border-radius: 6px !important; margin: 2px 0 5px; }
+
main { padding: 14px 16px 60px; }
.toolbar { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; }
.count { color: #888; font-size: 12px; }
@@ -34,7 +54,8 @@
.card { border: 1px solid var(--line); border-radius: 6px; background: #fff; overflow: hidden; display: flex; flex-direction: column; }
.card .img { aspect-ratio: 4/3; background: #eee center/cover no-repeat; }
.card .body { padding: 8px 9px; display: flex; flex-direction: column; gap: 3px; }
- .card .pat { font-weight: 600; font-size: calc(var(--fs) - 0px); line-height: 1.2; }
+ .card .pat { font-weight: 600; font-size: var(--fs); line-height: 1.2; }
+ .card .fr { color: #999; font-size: calc(var(--fs) - 2px); font-style: italic; }
.card .meta { color: #777; font-size: calc(var(--fs) - 2px); }
.card .when { color: #999; font-size: 10px; margin-top: 2px; }
.card a { color: var(--teal); text-decoration: none; font-size: 11px; }
@@ -65,6 +86,7 @@
<label><input type="checkbox" id="imgonly" /> Image-only</label>
<span class="fields">
<label><input type="checkbox" class="ff" data-f="pat" checked> Name</label>
+ <label><input type="checkbox" class="ff" data-f="fr" checked> FR</label>
<label><input type="checkbox" class="ff" data-f="meta" checked> Meta</label>
<label><input type="checkbox" class="ff" data-f="when" checked> Date</label>
</span>
@@ -72,6 +94,7 @@
<div class="layout">
<aside>
<input type="search" id="q" class="search" placeholder="Search pattern / SKU / collection…" />
+ <div class="active" id="activeRow"></div>
<div id="facets"></div>
</aside>
<main>
@@ -82,24 +105,44 @@
<script>
const $ = (s, r=document) => r.querySelector(s);
const API = location.origin;
-const state = { q: '', sort: 'newest', sel: {} };
-const FACET_DEFS = [['collection','Collection'],['product_type','Type'],['material','Material'],['style','Style'],['tag','Tag']];
+
+// facet dimensions: [stateKey, apiKey, label] (single-vendor → NO vendor facet)
+const FACET_DEFS = [
+ ['types', 'product_type', 'Type'],
+ ['collection', 'collection', 'Book / Series'],
+ ['colors', 'color', 'Color'],
+ ['styles', 'style', 'Style'],
+ ['materials', 'material', 'Material'],
+ ['prices', 'price', 'Price'],
+ ['tags', 'tag', 'Tag'],
+ ['images', 'image', 'Image'],
+];
+// active-chip labels + which apiKey emits family_order/family_swatch
+const CHIP_LABEL = { types: 'Type', collection: 'Book', colors: 'Color', styles: 'Style', materials: 'Material', prices: 'Price', tags: 'Tag', images: 'Image' };
+
+const state = { q: '', sort: 'newest' };
+for (const [k] of FACET_DEFS) state[k] = new Set();
function ls(k, d) { try { return JSON.parse(localStorage.getItem('zi_'+k)) ?? d; } catch { return d; } }
function save(k, v) { localStorage.setItem('zi_'+k, JSON.stringify(v)); }
-// restore prefs
+// restore facet selections from localStorage
+for (const [k] of FACET_DEFS) { const arr = ls('sel_'+k, []); if (Array.isArray(arr)) state[k] = new Set(arr); }
+function persistFacets() { for (const [k] of FACET_DEFS) save('sel_'+k, [...state[k]]); }
+
+// restore header prefs
$('#cols').value = ls('cols', 5); document.documentElement.style.setProperty('--cols', $('#cols').value);
$('#fs').value = ls('fs', 13); document.documentElement.style.setProperty('--fs', $('#fs').value + 'px');
$('#imgonly').checked = ls('imgonly', false); document.body.classList.toggle('imgonly', $('#imgonly').checked);
$('#sort').value = ls('sort', 'newest'); state.sort = $('#sort').value;
-['pat','meta','when'].forEach(f => { const on = ls('ff_'+f, true); document.querySelector('.ff[data-f="'+f+'"]').checked = on; applyField(f, on); });
+$('#q').value = ls('q', ''); state.q = $('#q').value;
+['pat','fr','meta','when'].forEach(f => { const on = ls('ff_'+f, true); document.querySelector('.ff[data-f="'+f+'"]').checked = on; });
$('#cols').oninput = e => { document.documentElement.style.setProperty('--cols', e.target.value); save('cols', +e.target.value); };
$('#fs').oninput = e => { document.documentElement.style.setProperty('--fs', e.target.value + 'px'); save('fs', +e.target.value); };
$('#imgonly').onchange = e => { document.body.classList.toggle('imgonly', e.target.checked); save('imgonly', e.target.checked); };
$('#sort').onchange = e => { state.sort = e.target.value; save('sort', e.target.value); load(); };
-$('#q').oninput = e => { state.q = e.target.value; clearTimeout(window._t); window._t = setTimeout(load, 200); };
+$('#q').oninput = e => { state.q = e.target.value; save('q', e.target.value); clearTimeout(window._t); window._t = setTimeout(applyFilterChange, 220); };
document.querySelectorAll('.ff').forEach(cb => cb.onchange = () => { applyField(cb.dataset.f, cb.checked); save('ff_'+cb.dataset.f, cb.checked); });
function applyField(f, on) { document.querySelectorAll('.card .'+f).forEach(el => el.style.display = on ? '' : 'none'); }
@@ -110,36 +153,126 @@ function fmtDate(iso) {
return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
}
-async function loadFacets() {
- const { facets } = await (await fetch(API + '/api/facets')).json();
- const host = $('#facets'); host.innerHTML = '';
- for (const [key, label] of FACET_DEFS) {
- const items = facets[key] || [];
- if (!items.length) continue;
- const det = document.createElement('details'); det.className = 'facet';
- det.innerHTML = `<summary>${label}<span>${items.length}</span></summary><div class="rows"></div>`;
- const rows = det.querySelector('.rows');
- for (const it of items) {
- const r = document.createElement('div'); r.className = 'row';
- r.innerHTML = `<span>${it.value}</span><span class="badge">${it.count}</span>`;
- r.onclick = () => {
- state.sel[key] = state.sel[key] || new Set();
- if (state.sel[key].has(it.value)) { state.sel[key].delete(it.value); r.classList.remove('on'); }
- else { state.sel[key].add(it.value); r.classList.add('on'); }
- load();
- };
- rows.appendChild(r);
- }
+// build the query params shared by /api/facets + /api/products
+function facetParams(extra = {}) {
+ const p = new URLSearchParams();
+ if (state.q) p.set('q', state.q);
+ p.set('sort', state.sort);
+ for (const [k, apiKey] of FACET_DEFS.map(([k,a]) => [k,a])) { const set = state[k]; if (set && set.size) p.set(apiKey, [...set].join('|')); }
+ for (const [k, v] of Object.entries(extra)) p.set(k, v);
+ return p;
+}
+// note: FACET_DEFS maps state key → api key; facetParams uses the api key as the query param
+function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
+function applyFilterChange() { persistFacets(); refreshFacets(); load(); }
+
+// per-section UI state: type-ahead text + expand
+const rowUI = {};
+for (const [k] of FACET_DEFS) rowUI[k] = { find: '', expand: false, typeahead: false };
+rowUI.styles.typeahead = true; rowUI.tags.typeahead = true; rowUI.collection.typeahead = false;
+
+let LAST_FACETS = {};
+
+// Amazon-style facet list: checkbox rows + counts, optional swatch, type-ahead, cap/expand.
+function facetList(host, stateKey, counts, opts = {}) {
+ host.innerHTML = '';
+ const ui = rowUI[stateKey];
+ const set = state[stateKey];
+ if (ui.typeahead) {
+ const find = document.createElement('input');
+ find.type = 'text'; find.className = 'ffind'; find.placeholder = 'find…'; find.value = ui.find;
+ find.oninput = () => { ui.find = find.value; renderFacets(LAST_FACETS); const f2 = host.querySelector('.ffind'); if (f2) { f2.focus(); f2.setSelectionRange(f2.value.length, f2.value.length); } };
+ host.appendChild(find);
+ }
+ let entries = Object.entries(counts || {});
+ entries = opts.order
+ ? entries.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]))
+ : entries.sort((a, b) => b[1] - a[1]);
+ if (ui.find) entries = entries.filter(([v]) => v.toLowerCase().includes(ui.find.toLowerCase()));
+ const CAP = opts.cap || 8;
+ let shown = entries, hidden = 0;
+ if (!ui.expand && entries.length > CAP) { shown = entries.slice(0, CAP); hidden = entries.length - CAP; }
+ const shownVals = new Set(shown.map(([v]) => v));
+ for (const v of set) if (!shownVals.has(v)) { const e = entries.find(([x]) => x === v); shown.push(e || [v, 0]); }
+ if (!shown.length) { const s = document.createElement('div'); s.style.cssText = 'font-size:10px;color:#6a6a72;padding:2px'; s.textContent = 'none'; host.appendChild(s); return; }
+ shown.forEach(([val, ct]) => {
+ const el = document.createElement('div');
+ el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+ let swatch = '';
+ if (opts.swatch && opts.swatch[val]) { const bg = opts.swatch[val]; swatch = `<span class="swatch" style="background:${bg}"></span>`; }
+ el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span>${swatch}<span class="lbl" title="${val.replace(/"/g, '"')}">${val}</span><span class="ct">${ct.toLocaleString()}</span>`;
+ el.onclick = () => toggleSet(set, val);
+ host.appendChild(el);
+ });
+ if (hidden > 0) {
+ const el = document.createElement('span'); el.className = 'more'; el.textContent = `See ${hidden.toLocaleString()} more ▾`;
+ el.onclick = () => { ui.expand = true; renderFacets(LAST_FACETS); };
+ host.appendChild(el);
+ } else if (ui.expand && entries.length > CAP) {
+ const el = document.createElement('span'); el.className = 'more'; el.textContent = 'See less ▴';
+ el.onclick = () => { ui.expand = false; renderFacets(LAST_FACETS); };
+ host.appendChild(el);
+ }
+}
+
+// Build/refresh the section shells once, fill bodies on every render.
+function ensureSections() {
+ if ($('#facets').dataset.built) return;
+ const host = $('#facets');
+ for (const [k, apiKey, label] of FACET_DEFS) {
+ const det = document.createElement('details'); det.className = 'sec'; det.dataset.k = k;
+ if (ls('open_'+k, k === 'collection' || k === 'colors')) det.open = true;
+ det.innerHTML = `<summary>${label}<span class="n" id="n_${k}"></span></summary><div class="fbody" id="body_${k}"></div>`;
+ det.addEventListener('toggle', () => save('open_'+k, det.open));
host.appendChild(det);
}
+ $('#facets').dataset.built = '1';
+}
+
+function renderFacets(f) {
+ LAST_FACETS = f;
+ ensureSections();
+ const capMap = { types: 6, collection: 12, colors: 17, styles: 12, materials: 10, prices: 4, tags: 14, images: 3 };
+ for (const [k, apiKey] of FACET_DEFS.map(([k,a]) => [k,a])) {
+ const body = $('#body_'+k); if (!body) continue;
+ const opts = { cap: capMap[k] || 8 };
+ if (k === 'colors') { opts.order = f.family_order; opts.swatch = f.family_swatch; }
+ facetList(body, k, f[apiKey] || {}, opts);
+ }
+ renderActive();
+}
+
+function renderActive() {
+ const row = $('#activeRow'); row.innerHTML = '';
+ let n = 0;
+ for (const [k] of FACET_DEFS) {
+ const set = state[k];
+ const badge = $('#n_'+k);
+ if (badge) { badge.textContent = set.size || ''; badge.style.display = set.size ? '' : 'none'; }
+ set.forEach((v) => {
+ n++;
+ const el = document.createElement('span'); el.className = 'afilter';
+ el.innerHTML = `<b>${CHIP_LABEL[k]}:</b> ${v} ✕`;
+ el.onclick = () => { set.delete(v); applyFilterChange(); };
+ row.appendChild(el);
+ });
+ }
+ if (n) {
+ const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
+ c.onclick = () => { for (const [k] of FACET_DEFS) state[k].clear(); applyFilterChange(); };
+ row.appendChild(c);
+ }
+}
+
+async function refreshFacets() {
+ try {
+ const f = await (await fetch(API + '/api/facets?' + facetParams())).json();
+ renderFacets(f);
+ } catch (e) { /* facets degrade silently — grid still works */ }
}
async function load() {
- const p = new URLSearchParams();
- if (state.q) p.set('q', state.q);
- p.set('sort', state.sort);
- for (const [k, set] of Object.entries(state.sel)) if (set.size) p.set(k, [...set].join('|'));
- const data = await (await fetch(API + '/api/products?' + p)).json();
+ const data = await (await fetch(API + '/api/products?' + facetParams())).json();
$('#count').textContent = `${data.total} of ${data.all} products`;
const grid = $('#grid'); grid.innerHTML = '';
for (const r of data.products) {
@@ -149,17 +282,19 @@ async function load() {
c.appendChild(img);
const body = document.createElement('div'); body.className = 'body';
const meta = [r.collection, r.width].filter(Boolean).join(' · ');
+ const frLine = (r.pattern_name_fr && r.pattern_name_fr !== r.pattern_name) ? `<div class="fr">${r.pattern_name_fr}</div>` : '';
body.innerHTML = `
<div class="pat">${r.pattern_name || r.mfr_sku}</div>
+ ${frLine}
<div class="meta">${r.mfr_sku}${meta ? ' — ' + meta : ''}</div>
<div class="when" title="${r.created_at || ''}">🕓 ${fmtDate(r.created_at)}</div>
${r.product_url ? `<a href="${r.product_url}" target="_blank" rel="noopener noreferrer">Source ↗</a>` : ''}`;
c.appendChild(body);
grid.appendChild(c);
}
- ['pat','meta','when'].forEach(f => applyField(f, document.querySelector('.ff[data-f="'+f+'"]').checked));
+ ['pat','fr','meta','when'].forEach(f => applyField(f, document.querySelector('.ff[data-f="'+f+'"]').checked));
}
-loadFacets().then(load);
+refreshFacets().then(load);
setInterval(load, 60000);
</script>
</body>
diff --git a/server.js b/server.js
index 2ae35f6..2eb265b 100644
--- a/server.js
+++ b/server.js
@@ -1,8 +1,15 @@
/* Zuber internal line viewer — Designer Wallcoverings (INTERNAL, basic-auth).
- Reads dw_unified.zuber_catalog LIVE (via psql), read-only browse + curate.
- Internal-line-viewer spec (astek-landing :9944 reference): left search + collapsible
- field-tab tables w/ counts, density slider, image-only toggle, created-date chips.
- NOT on Shopify. Quote-only, made-to-order heritage line. */
+ Reads dw_unified.zuber_catalog LIVE (via psql, local) OR a self-contained
+ data/zuber.jsonl bundle (Kamatera — NEVER touches a DB).
+ Left rail is ported to full all.designerwallcoverings.com parity:
+ - active-filter chip row (removable pills + clear-all, persisted)
+ - collapsible <details.sec> facet sections w/ per-value counts
+ - CROSS-FILTERED facets (each dim's counts skip its own selection)
+ - Color families (hue buckets, swatch dot + count, family-ordered)
+ - Style / Tag explosion (JSONB arrays), Type, Book/Series (collection),
+ Material (fallback composition), Price (Quote-only), Image (present/missing)
+ Single-vendor line → NO Vendor facet. Quote-only, made-to-order heritage line.
+ NOT on Shopify. Color sort + density + created-date chips preserved. */
const express = require('express');
const { execFile } = require('child_process');
const path = require('path');
@@ -22,7 +29,6 @@ const app = express();
app.use(require('compression')());
// unauthenticated health probe BEFORE the gate — keep it a bare liveness signal
-// (no catalog size/detail; the count is only exposed behind auth via /api/config)
app.get('/healthz', (_req, res) => res.json({ ok: true }));
app.use((req, res, next) => {
@@ -35,20 +41,86 @@ app.use((req, res, next) => {
return res.status(401).send('Authentication required.');
});
+// ── color families (hue buckets) ─────────────────────────────────────────────
+// Interior-designer wheel order; each family carries a representative swatch hex
+// for the rail dot. A product's color set is derived from its ai_colors[].name +
+// color_hex (name keyword first, hue fallback). IDENTICAL in psql + jsonl paths.
+const FAMILY_ORDER = ['Red', 'Orange', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Brown', 'Beige', 'Black', 'Gray', 'White', 'Silver', 'Metallic', 'Multi'];
+const FAMILY_SWATCH = {
+ Red: '#c0392b', Orange: '#e67e22', Yellow: '#f1c40f', Gold: '#c9a227', Green: '#27ae60',
+ Teal: '#17a2a2', Blue: '#2e6fd6', Purple: '#8e44ad', Pink: '#e28ac0', Brown: '#8b5a2b',
+ Beige: '#d8c9a8', Black: '#222222', Gray: '#8a8a8a', White: '#f2f2ee', Silver: '#c0c4c8',
+ Metallic: '#b8a06a', Multi: 'linear-gradient(90deg,#e74c3c,#f1c40f,#27ae60,#2e6fd6,#8e44ad)',
+};
+// Name-keyword → family. Longer/more-specific keys first via ordered array.
+const NAME_FAMILY = [
+ [/gold|golden|ambre|amber|aurore/i, 'Gold'],
+ [/silver/i, 'Silver'],
+ [/metallic|bronze|copper|brass/i, 'Metallic'],
+ [/terracotta|rust|burnt|orange/i, 'Orange'],
+ [/teal|turquoise|aqua|cyan/i, 'Teal'],
+ [/sky\s*blue|light\s*blue|navy|indigo|cobalt|blue/i, 'Blue'],
+ [/green|olive|sage|emerald|foliage/i, 'Green'],
+ [/purple|violet|lavender|mauve|plum/i, 'Purple'],
+ [/pink|rose|blush|magenta|fuchsia/i, 'Pink'],
+ [/red|crimson|scarlet|burgundy|maroon/i, 'Red'],
+ [/yellow|lemon|mustard|ochre|ocher/i, 'Yellow'],
+ [/brown|earth|sepia|chocolate|tan|umber|khaki/i, 'Brown'],
+ [/beige|cream|ivory|ecru|taupe|sand|oatmeal|linen|wheat/i, 'Beige'],
+ [/black|ebony|onyx|charcoal/i, 'Black'],
+ [/gray|grey|slate|ash/i, 'Gray'],
+ [/white|alabaster|snow/i, 'White'],
+];
+function hueOf(hex) {
+ if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+ const r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+ const l = (mx + mn) / 2;
+ if (d < 0.06) { // near-gray: split by lightness
+ if (l > 0.85) return 'White'; if (l < 0.18) return 'Black'; return 'Gray';
+ }
+ let h; if (mx === r) h = ((g - b) / d) % 6; else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
+ h *= 60; if (h < 0) h += 360;
+ if (h < 15 || h >= 345) return 'Red';
+ if (h < 40) return 'Orange';
+ if (h < 65) return l < 0.45 ? 'Brown' : 'Yellow';
+ if (h < 160) return 'Green';
+ if (h < 195) return 'Teal';
+ if (h < 255) return 'Blue';
+ if (h < 290) return 'Purple';
+ if (h < 345) return 'Pink';
+ return 'Multi';
+}
+function nameFamily(name) {
+ if (!name) return null;
+ for (const [re, fam] of NAME_FAMILY) if (re.test(name)) return fam;
+ return null;
+}
+// Derive the de-duped, family-ordered set of color families for one row.
+function colorFamilies(aiColors, colorHex) {
+ const fams = new Set();
+ for (const c of (aiColors || [])) {
+ const f = nameFamily(c && c.name) || hueOf(c && c.hex);
+ if (f) fams.add(f);
+ }
+ if (!fams.size && colorHex) { const f = hueOf(colorHex); if (f) fams.add(f); }
+ return FAMILY_ORDER.filter((f) => fams.has(f));
+}
+
// ---- data load (psql -> JSON) --------------------------------------------
let ROWS = [];
let LAST_REFRESH = new Date().toISOString();
const Q = `SELECT json_agg(row_to_json(t)) FROM (
SELECT mfr_sku, pattern_name, pattern_name_fr, color_name, collection, product_type,
- material, width, image_url, product_url, description,
+ material, composition, width, image_url, product_url, description,
coalesce(all_images,'') AS all_images,
ai_colors, ai_styles, ai_tags, color_hex,
to_char(coalesce(crawled_at, last_scraped, now()),'YYYY-MM-DD"T"HH24:MI:SS') AS created_at
FROM zuber_catalog ORDER BY id
) t;`;
-// Map raw catalog rows (from psql row_to_json OR the jsonl bundle — identical
-// shape) into the in-memory ROWS the API serves. Unchanged mapping across sources.
+// Map raw catalog rows (psql row_to_json OR jsonl bundle — identical shape) into
+// the in-memory ROWS the API serves. Color families + style/tag explosion baked here.
function mapRows(raw) {
ROWS = raw.map((r, i) => {
let imgs = [];
@@ -56,28 +128,34 @@ function mapRows(raw) {
if (!imgs.length && r.image_url) imgs = [r.image_url];
const parseArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' && v.trim() ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);
const aiColors = parseArr(r.ai_colors);
+ const aiStyles = parseArr(r.ai_styles);
+ const aiTags = parseArr(r.ai_tags);
+ const material = r.material || r.composition || null; // Material facet fallback → composition
+ const image = r.image_url || imgs[0] || null;
+ const colorHex = r.color_hex || (aiColors[0] && aiColors[0].hex) || null;
return {
idx: i,
sku: r.mfr_sku,
mfr_sku: r.mfr_sku,
pattern_name: r.pattern_name || r.mfr_sku,
pattern_name_fr: r.pattern_name_fr || null,
- // zuber_catalog.color_name is null for all rows; fall back to the enriched
- // ai_colors[0].name (the same pattern Gracie uses) so cards + Color sort have context.
color_name: r.color_name || (aiColors[0] && aiColors[0].name) || null,
collection: r.collection || null,
product_type: r.product_type || null,
- material: r.material || null,
+ material,
width: r.width || null,
- image: r.image_url || imgs[0] || null,
+ image,
images: imgs,
image_count: imgs.length,
+ image_state: image ? 'Has image' : 'No image',
product_url: r.product_url || null,
description: r.description || null,
ai_colors: aiColors,
- ai_styles: parseArr(r.ai_styles),
- ai_tags: parseArr(r.ai_tags),
- color_hex: r.color_hex || (aiColors[0] && aiColors[0].hex) || null,
+ ai_styles: aiStyles,
+ ai_tags: aiTags,
+ color_hex: colorHex,
+ colors: colorFamilies(aiColors, colorHex), // family-ordered color set for the facet + chips
+ price_band: 'Quote-only', // whole heritage line is quote-only, made-to-order
created_at: r.created_at || null,
};
});
@@ -105,26 +183,42 @@ load();
// Only poll psql against a live DB; the jsonl bundle is static.
if (!USE_JSONL) setInterval(load, REFRESH_SEC * 1000);
-// ---- facets (field-tab tables) -------------------------------------------
-function facets() {
- const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
- const collection = {}, product_type = {}, material = {}, style = {}, tag = {};
- for (const r of ROWS) {
- bump(collection, r.collection);
- bump(product_type, r.product_type);
- bump(material, r.material);
- for (const s of r.ai_styles) bump(style, s);
- for (const t of r.ai_tags) bump(tag, t);
- }
- const byCount = (o) => Object.entries(o).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
- const byAlpha = (o) => Object.entries(o).sort((a, b) => a[0].localeCompare(b[0])).map(([value, count]) => ({ value, count }));
- return { collection: byAlpha(collection), product_type: byCount(product_type), material: byCount(material), style: byCount(style), tag: byCount(tag) };
+// ---- filtering + cross-filtered facets -----------------------------------
+// Parse the multi-select filter state from the query string. Values are '|'-joined.
+function parseFilters(q) {
+ const sel = (k) => { const v = q[k]; return new Set(v ? v.toString().split('|').filter(Boolean) : []); };
+ return {
+ q: (q.q || '').toString().trim().toLowerCase(),
+ collection: sel('collection'), types: sel('product_type'), materials: sel('material'),
+ colors: sel('color'), styles: sel('style'), tags: sel('tag'), prices: sel('price'), images: sel('image'),
+ };
+}
+// Apply every active filter EXCEPT the one named in opts.skip — that's what makes the
+// facet counts cross-filtered (all.dw parity): a dimension's tally reflects the OTHER
+// selections but not its own, so its own options never collapse to the picked value.
+function applyFilters(rows, f, opts = {}) {
+ const hasAny = (arr, set) => set.size === 0 || arr.some((v) => set.has(v));
+ const inSet = (val, set) => set.size === 0 || set.has(val ?? '');
+ let out = rows;
+ if (opts.skip !== 'collection' && f.collection.size) out = out.filter((r) => inSet(r.collection, f.collection));
+ if (opts.skip !== 'type' && f.types.size) out = out.filter((r) => inSet(r.product_type, f.types));
+ if (opts.skip !== 'material' && f.materials.size) out = out.filter((r) => inSet(r.material, f.materials));
+ if (opts.skip !== 'color' && f.colors.size) out = out.filter((r) => hasAny(r.colors, f.colors));
+ if (opts.skip !== 'style' && f.styles.size) out = out.filter((r) => hasAny(r.ai_styles, f.styles));
+ if (opts.skip !== 'tag' && f.tags.size) out = out.filter((r) => hasAny(r.ai_tags, f.tags));
+ if (opts.skip !== 'price' && f.prices.size) out = out.filter((r) => inSet(r.price_band, f.prices));
+ if (opts.skip !== 'image' && f.images.size) out = out.filter((r) => inSet(r.image_state, f.images));
+ if (opts.skip !== 'q' && f.q) out = out.filter((r) => hay(r).includes(f.q));
+ return out;
}
+const hay = (r) => ((r.pattern_name || '') + ' ' + (r.pattern_name_fr || '') + ' ' + (r.mfr_sku || '') + ' ' + (r.collection || '') + ' ' + (r.description || '')).toLowerCase();
+
+// tally a scalar field into { value: count }; tallyArr for an array field (styles/tags/colors).
+function tally(rows, key) { const o = {}; for (const r of rows) { const v = r[key]; if (v != null && v !== '') o[v] = (o[v] || 0) + 1; } return o; }
+function tallyArr(rows, key) { const o = {}; for (const r of rows) for (const v of (r[key] || [])) { if (v != null && v !== '') o[v] = (o[v] || 0) + 1; } return o; }
// ---- api ------------------------------------------------------------------
const cmp = (a, b) => String(a ?? '').localeCompare(String(b ?? ''), undefined, { sensitivity: 'base' });
-// hue() maps a #rrggbb hex to a 0-360 color-wheel bucket (grays/unknown → 999 so they
-// sort last). Identical helper the sibling viewers (fromental/crezana) use for Color sort.
function hue(hex) {
if (!hex || hex[0] !== '#' || hex.length !== 7) return 999;
const r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
@@ -147,23 +241,33 @@ app.get('/api/config', (_req, res) => res.json({
title: 'Zuber — Designer Wallcoverings (internal)', total: ROWS.length,
lastRefresh: LAST_REFRESH, samples_only: true,
}));
-app.get('/api/facets', (_req, res) => res.json({ total: ROWS.length, facets: facets() }));
+
+// Cross-filtered facets — each dimension's counts skip its own selection.
+app.get('/api/facets', (req, res) => {
+ const f = parseFilters(req.query);
+ res.json({
+ total: applyFilters(ROWS, f).length,
+ all: ROWS.length,
+ product_type: tally(applyFilters(ROWS, f, { skip: 'type' }), 'product_type'),
+ collection: tally(applyFilters(ROWS, f, { skip: 'collection' }), 'collection'),
+ color: tallyArr(applyFilters(ROWS, f, { skip: 'color' }), 'colors'),
+ style: tallyArr(applyFilters(ROWS, f, { skip: 'style' }), 'ai_styles'),
+ material: tally(applyFilters(ROWS, f, { skip: 'material' }), 'material'),
+ price: tally(applyFilters(ROWS, f, { skip: 'price' }), 'price_band'),
+ tag: tallyArr(applyFilters(ROWS, f, { skip: 'tag' }), 'ai_tags'),
+ image_state: tally(applyFilters(ROWS, f, { skip: 'image' }), 'image_state'),
+ family_order: FAMILY_ORDER, family_swatch: FAMILY_SWATCH,
+ });
+});
app.get('/api/products', (req, res) => {
- const q = (req.query.q || '').toString().trim().toLowerCase();
+ const f = parseFilters(req.query);
const sort = (req.query.sort || 'newest').toString();
- const sel = (k) => { const v = req.query[k]; return v ? v.toString().split('|').filter(Boolean) : []; };
- const fColl = sel('collection'), fType = sel('product_type'), fMat = sel('material'), fStyle = sel('style'), fTag = sel('tag');
- const inAny = (arr, val) => arr.length === 0 || arr.includes(val ?? '');
- let rows = ROWS.filter((r) =>
- inAny(fColl, r.collection) && inAny(fType, r.product_type) && inAny(fMat, r.material) &&
- (fStyle.length === 0 || r.ai_styles.some((s) => fStyle.includes(s))) &&
- (fTag.length === 0 || r.ai_tags.some((t) => fTag.includes(t))));
- if (q) rows = rows.filter((r) => ((r.pattern_name || '') + ' ' + (r.pattern_name_fr || '') + ' ' + (r.mfr_sku || '') + ' ' + (r.collection || '') + ' ' + (r.description || '')).toLowerCase().includes(q));
+ let rows = applyFilters(ROWS, f);
if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
res.json({ total: rows.length, all: ROWS.length, products: rows });
});
app.use(express.static(path.join(__dirname, 'public')));
-app.listen(PORT, () => console.log(`Zuber internal viewer -> http://127.0.0.1:${PORT} (basic-auth ${AUTH_USER})`));
+app.listen(PORT, () => console.log(`Zuber internal viewer -> http://127.0.0.1:${PORT} (basic-auth ${AUTH_USER}, source=${USE_JSONL ? 'jsonl' : 'psql'})`));
← ada4b82 jsonl-backed data layer: self-contained bundle for Kamatera
·
back to Zuber Internal
·
Add Activation Calendar tab (go-live date+time per staged SK 8879146 →