← back to Gracie Internal
Upgrade rail to all.dw feature-parity: active-chip row + collapsible facet sections (Type/Book/Color-families/Style/Material/Price-quote-only/Tags/Image), color-family bucketing + style/tag explosion computed identically in pg + jsonl modes, type-ahead + cap/expand, swatch dots, card-field toggles
68969165b35e004ed45464476c2e800960118bb0 · 2026-07-09 16:08:50 -0700 · Steve
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/index.htmlM server.js
Diff
commit 68969165b35e004ed45464476c2e800960118bb0
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 9 16:08:50 2026 -0700
Upgrade rail to all.dw feature-parity: active-chip row + collapsible facet sections (Type/Book/Color-families/Style/Material/Price-quote-only/Tags/Image), color-family bucketing + style/tag explosion computed identically in pg + jsonl modes, type-ahead + cap/expand, swatch dots, card-field toggles
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/index.html | 349 +++++++++++++++++++++++++++++++++++++++---------------
server.js | 109 +++++++++++++----
2 files changed, 343 insertions(+), 115 deletions(-)
diff --git a/public/index.html b/public/index.html
index a3eaef1..0a96f7b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,53 +5,80 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gracie — Internal Line Viewer</title>
<style>
- :root { --cardmin: 230px; --stroke:#e4e0d8; --ink:#1c1a17; --muted:#8a857c; --teal:#0f766e; --bg:#faf8f4; }
+ :root { --cardmin: 230px; --stroke:#e4e0d8; --ink:#1c1a17; --mut:#8a857c; --accent:#0f766e; --bg:#faf8f4; --card:#fbf9f5; }
* { box-sizing: border-box; }
body { margin:0; font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif; color:var(--ink); background:var(--bg); }
header { display:flex; align-items:center; gap:16px; padding:14px 20px; border-bottom:1px solid var(--stroke); background:#fff; position:sticky; top:0; z-index:20; }
header .brand { font-family: Georgia, "Times New Roman", serif; font-size:20px; letter-spacing:.02em; }
- header .brand small { color:var(--muted); font-size:12px; letter-spacing:.14em; text-transform:uppercase; display:block; }
- header .count { color:var(--muted); font-size:13px; }
+ header .brand small { color:var(--mut); font-size:12px; letter-spacing:.14em; text-transform:uppercase; display:block; }
+ header .count { color:var(--mut); font-size:13px; }
.controls { margin-left:auto; display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
- .controls label { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:6px; }
+ .controls label { font-size:12px; color:var(--mut); display:flex; align-items:center; gap:6px; }
select, input[type=search] { font:inherit; font-size:13px; padding:6px 8px; border:1px solid var(--stroke); border-radius:6px; background:#fff; }
- .wrap { display:grid; grid-template-columns: 280px 1fr; gap:0; align-items:start; }
- aside { border-right:1px solid var(--stroke); padding:14px; position:sticky; top:57px; max-height:calc(100vh - 57px); overflow:auto; background:#fff; }
- aside input[type=search] { width:100%; margin-bottom:12px; }
- .tab { border:1px solid var(--stroke); border-radius:8px; margin-bottom:8px; overflow:hidden; }
- .tab > summary { cursor:pointer; padding:9px 11px; font-size:12.5px; font-weight:600; letter-spacing:.02em; display:flex; justify-content:space-between; align-items:center; list-style:none; }
- .tab > summary::-webkit-details-marker { display:none; }
- .tab > summary .n { color:var(--muted); font-weight:400; }
- .tab .vals { max-height:230px; overflow:auto; padding:4px 6px 8px; }
- .val { display:flex; justify-content:space-between; gap:8px; padding:4px 6px; font-size:12.5px; border-radius:5px; cursor:pointer; }
- .val:hover { background:#f2efe9; }
- .val.on { background:var(--teal); color:#fff; }
- .val .badge { background:#eee; color:#555; border-radius:10px; padding:0 7px; font-size:11px; }
- .val.on .badge { background:rgba(255,255,255,.25); color:#fff; }
- .active-filters { display:flex; flex-wrap:wrap; gap:6px; padding:0 20px 10px; }
- .chip { font-size:11.5px; background:#efe9df; border:1px solid var(--stroke); border-radius:14px; padding:3px 10px; cursor:pointer; }
- .chip:hover { background:#e3dccf; }
- main { padding:16px 20px 60px; }
+
+ /* ── layout: Amazon-style left rail + content (ported from all.designerwallcoverings) ── */
+ .wrap { display:flex; align-items:flex-start; }
+ aside#rail { width:280px; flex:0 0 280px; position:sticky; top:57px; max-height:calc(100vh - 57px);
+ overflow-y:auto; padding:12px 12px 60px; border-right:1px solid var(--stroke); background:#fff; }
+ aside#rail input[type=search] { width:100%; margin-bottom:10px; }
+
+ /* active-filter chip row above the sections */
+ .active { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:8px; }
+ .afilter { background:#eef4f3; border:1px solid var(--accent); color:var(--ink); border-radius:20px; padding:3px 9px; font-size:11px; cursor:pointer; }
+ .afilter b { color:var(--accent); font-weight:600; }
+ .clearall { background:none; border:1px solid var(--stroke); color:var(--mut); border-radius:20px; padding:3px 11px; font-size:11px; cursor:pointer; }
+
+ /* Astek-style collapsed accordion sections per field */
+ details.sec { border:1px solid var(--stroke); border-radius:7px; margin-bottom:7px; background:var(--card); }
+ 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; }
+ details.sec > summary::-webkit-details-marker { display:none; }
+ details.sec > summary::before { content:'▸'; color:var(--accent); font-size:10px; }
+ details.sec[open] > summary::before { content:'▾'; }
+ details.sec > summary .n { margin-left:auto; background:var(--accent); color:#fff; border-radius:20px;
+ padding:0 7px; font-size:10px; font-weight:700; }
+ .fbody { padding:2px 8px 9px; }
+ .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:#f2efe9; }
+ .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(--accent); border-color:var(--accent); color:#fff; font-weight:700; }
+ .fitem.on { color:var(--accent); }
+ .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; }
+ .fitem .dot { width:12px; height:12px; flex:0 0 12px; border-radius:3px; border:1px solid rgba(0,0,0,.18); }
+ .more { color:var(--accent); 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(--stroke); border-radius:6px !important; margin:2px 0 5px; background:#fff; }
+ /* Card-fields toggles reuse .fitem chrome */
+ #fieldsRow .fitem .box { border-radius:3px; }
+
+ main { flex:1; min-width:0; padding:16px 20px 60px; }
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(var(--cardmin), 1fr)); gap:14px; }
.card { border:1px solid var(--stroke); border-radius:10px; overflow:hidden; background:#fff; display:flex; flex-direction:column; }
.card .imgwrap { aspect-ratio: 3/4; background:#f0ece4; overflow:hidden; }
.card img { width:100%; height:100%; object-fit:cover; display:block; }
.card .body { padding:9px 10px 11px; display:flex; flex-direction:column; gap:4px; }
.card .ptn { font-size:13.5px; font-weight:600; line-height:1.2; }
- .card .meta { font-size:11.5px; color:var(--muted); }
+ .card .meta { font-size:11.5px; color:var(--mut); }
.card .tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:3px; }
.card .tag { font-size:10px; background:#f2efe9; border-radius:8px; padding:1px 7px; color:#5a554c; }
- .card .when { font-size:10.5px; color:var(--muted); display:flex; align-items:center; gap:4px; margin-top:2px; }
+ .card .when { font-size:10.5px; color:var(--mut); display:flex; align-items:center; gap:4px; margin-top:2px; }
.card .flags { display:flex; gap:5px; flex-wrap:wrap; }
.flag { font-size:9.5px; text-transform:uppercase; letter-spacing:.04em; border-radius:6px; padding:1px 6px; }
.flag.gate { background:#fde2e2; color:#a12; }
- .flag.enr { background:#e2f0ec; color:var(--teal); }
+ .flag.enr { background:#e2f0ec; color:var(--accent); }
.swatch { width:11px; height:11px; border-radius:3px; border:1px solid rgba(0,0,0,.15); display:inline-block; vertical-align:-1px; margin-right:4px; }
- /* image-only mode */
+ /* card-field visibility toggles (hidden classes set on body) */
+ body.hide-meta .card .meta { display:none; }
+ body.hide-flags .card .flags { display:none; }
+ body.hide-tags .card .tags { display:none; }
+ body.hide-when .card .when { display:none; }
+ body.hide-ptn .card .ptn { display:none; }
+ /* image-only mode = collapse the entire card body → pure texture wall */
body.imgonly .card .body { display:none; }
body.imgonly .grid { gap:6px; }
- /* density → type scale */
.grid { font-size: calc(11px + (var(--cardmin) - 160px) * 0.012); }
+ @media (max-width:760px){ aside#rail { position:static; width:100%; flex-basis:auto; max-height:none; border-right:none; border-bottom:1px solid var(--stroke); } .wrap { flex-direction:column; } }
</style>
</head>
<body>
@@ -62,7 +89,7 @@
<label>Sort
<select id="sort">
<option value="newest">Newest</option>
- <option value="collection">Collection</option>
+ <option value="collection">Book / Series</option>
<option value="pattern">Pattern A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="color">Color</option>
@@ -74,11 +101,19 @@
<label><input type="checkbox" id="imgonly" /> Image only</label>
</div>
</header>
-<div class="active-filters" id="activeFilters"></div>
<div class="wrap">
- <aside>
+ <aside id="rail">
<input type="search" id="search" placeholder="Search pattern / SKU / tag…" autocomplete="off" />
- <div id="facets"></div>
+ <div class="active" id="activeRow"></div>
+ <details class="sec" data-k="fields"><summary>Card fields<span class="n" id="nFields"></span></summary><div class="fbody" id="fieldsRow"></div></details>
+ <details class="sec" data-k="type"><summary>Type<span class="n" id="nType"></span></summary><div class="fbody" id="typeRow"></div></details>
+ <details class="sec" data-k="series"><summary>Book / Series<span class="n" id="nSeries"></span></summary><div class="fbody" id="seriesRow"></div></details>
+ <details class="sec" data-k="color"><summary>Color<span class="n" id="nColor"></span></summary><div class="fbody" id="colorRow"></div></details>
+ <details class="sec" data-k="style"><summary>Style<span class="n" id="nStyle"></span></summary><div class="fbody" id="styleRow"></div></details>
+ <details class="sec" data-k="material"><summary>Material<span class="n" id="nMaterial"></span></summary><div class="fbody" id="matRow"></div></details>
+ <details class="sec" data-k="price"><summary>Price<span class="n" id="nPrice"></span></summary><div class="fbody" id="priceRow"></div></details>
+ <details class="sec" data-k="tag"><summary>Tags<span class="n" id="nTag"></span></summary><div class="fbody" id="tagRow"></div></details>
+ <details class="sec" data-k="image"><summary>Image<span class="n" id="nImage"></span></summary><div class="fbody" id="imgRow"></div></details>
</aside>
<main>
<div class="grid" id="grid"></div>
@@ -86,87 +121,206 @@
</div>
<script>
const ORIGIN = location.origin;
-let ALL = [], FACETS = {}, FILTERS = {}, SEARCH = '';
+let ALL = [], FACETS = {}, SEARCH = '';
const grid = document.getElementById('grid');
const fmtDate = (iso) => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch { return iso; } };
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
+
+/* ── filter state — one Set per facet dimension, persisted to localStorage ── */
+const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
+const state = {
+ types: loadSet('gr_types'), series: loadSet('gr_series'), colors: loadSet('gr_colors'),
+ styles: loadSet('gr_styles'), materials: loadSet('gr_materials'), tags: loadSet('gr_tags'),
+ prices: loadSet('gr_prices'), images: loadSet('gr_images'),
+};
+const SETS = { types:'gr_types', series:'gr_series', colors:'gr_colors', styles:'gr_styles', materials:'gr_materials', tags:'gr_tags', prices:'gr_prices', images:'gr_images' };
+function persistFacets() { for (const [k, key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
+
+/* per-row UI state: expanded? + type-ahead needle (big rows get a find box) */
+const rowUI = {
+ typeRow: { expand:false, find:'' }, seriesRow: { expand:false, find:'' },
+ colorRow: { expand:false, find:'' }, styleRow: { expand:false, find:'' },
+ matRow: { expand:false, find:'' }, tagRow: { expand:false, find:'', typeahead:true },
+ priceRow: { expand:false, find:'' }, imgRow: { expand:false, find:'' },
+};
+
+/* card-field visibility toggles (image-only spectrum) */
+const CARD_FIELDS = [
+ { key:'ptn', label:'Pattern name', cls:'hide-ptn' },
+ { key:'meta', label:'SKU · book', cls:'hide-meta' },
+ { key:'flags', label:'Flags', cls:'hide-flags' },
+ { key:'tags', label:'Tags', cls:'hide-tags' },
+ { key:'when', label:'Created date', cls:'hide-when' },
+];
+function renderCardFields() {
+ const row = document.getElementById('fieldsRow'); row.innerHTML = '';
+ for (const f of CARD_FIELDS) {
+ const on = localStorage.getItem('gr_cf_' + f.key) !== '0'; // default shown
+ document.body.classList.toggle(f.cls, !on);
+ const el = document.createElement('div');
+ el.className = 'fitem' + (on ? ' on' : '');
+ el.innerHTML = `<span class="box">${on ? '✓' : ''}</span><span class="lbl">${f.label}</span>`;
+ el.onclick = () => { const nv = localStorage.getItem('gr_cf_' + f.key) === '0'; localStorage.setItem('gr_cf_' + f.key, nv ? '1' : '0'); renderCardFields(); };
+ row.appendChild(el);
+ }
+ document.getElementById('nFields').textContent = CARD_FIELDS.filter(f => localStorage.getItem('gr_cf_' + f.key) === '0').length || '';
+ document.getElementById('nFields').style.display = CARD_FIELDS.some(f => localStorage.getItem('gr_cf_' + f.key) === '0') ? '' : 'none';
+}
async function boot() {
const [p, f] = await Promise.all([
- fetch(ORIGIN+'/api/products').then(r=>r.json()),
- fetch(ORIGIN+'/api/facets').then(r=>r.json()),
+ fetch(ORIGIN + '/api/products').then(r => r.json()),
+ fetch(ORIGIN + '/api/facets').then(r => r.json()),
]);
- ALL = p.products; FACETS = f.facets;
- renderFacets(); render();
+ ALL = p.products; FACETS = f;
+ renderCardFields(); renderFacets(); render();
}
+/* does product p match value val on facet dimension key? */
function facetMatch(p, key, val) {
- switch(key) {
- case 'Collection': return p.collection === val;
- case 'Type': return p.product_type === val;
- case 'Material': return p.material === val;
- case 'Style': return (p.styles||[]).includes(val);
- case 'Tag': return (p.tags||[]).includes(val);
- case 'Settlement': return (p.settlement_flag||'ungated') === val;
- case 'Enriched': return (p.enriched?'yes':'no') === val;
+ switch (key) {
+ case 'types': return p.product_type === val;
+ case 'series': return p.collection === val;
+ case 'colors': return productFamilies(p).includes(val);
+ case 'styles': return (p.styles || []).includes(val);
+ case 'materials': return (p.material || 'Unspecified') === val;
+ case 'tags': return (p.tags || []).includes(val);
+ case 'prices': return val === 'Quote-only'; // Gracie is quote-only across the board
+ case 'images': return (p.image_url ? 'Has image' : 'No image') === val;
default: return true;
}
}
-function renderFacets() {
- const host = document.getElementById('facets');
- host.innerHTML = '';
- for (const key of Object.keys(FACETS)) {
- const d = document.createElement('details'); d.className='tab';
- const sum = document.createElement('summary');
- sum.innerHTML = `<span>${key}</span><span class="n">${FACETS[key].length}</span>`;
- d.appendChild(sum);
- const vals = document.createElement('div'); vals.className='vals';
- for (const {value,count} of FACETS[key]) {
- const row = document.createElement('div'); row.className='val';
- if ((FILTERS[key]||new Set()).has(value)) row.classList.add('on');
- row.innerHTML = `<span>${value}</span><span class="badge">${count}</span>`;
- row.onclick = () => toggleFilter(key, value);
- vals.appendChild(row);
- }
- d.appendChild(vals);
- host.appendChild(d);
+/* client-side color-family bucketing — MUST match the server's familyOf() exactly */
+function hexToRgb(hex){ const m=String(hex||'').trim().replace(/^#/,''); if(!/^[0-9a-fA-F]{6}$/.test(m))return null; return [parseInt(m.slice(0,2),16),parseInt(m.slice(2,4),16),parseInt(m.slice(4,6),16)]; }
+function rgbToHsl(r,g,b){ r/=255;g/=255;b/=255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b); let h=0,s=0; const l=(mx+mn)/2; if(mx!==mn){const d=mx-mn;s=l>0.5?d/(2-mx-mn):d/(mx+mn);switch(mx){case r:h=(g-b)/d+(g<b?6:0);break;case g:h=(b-r)/d+2;break;case b:h=(r-g)/d+4;break;}h/=6;} return [h*360,s*100,l*100]; }
+function familyOf(hex){
+ const rgb=hexToRgb(hex); if(!rgb) return null;
+ const [h,s,l]=rgbToHsl(...rgb);
+ if(l<=10) return 'Black';
+ if(l>=94 && s<=8) return 'White';
+ if(s<=10){ if(l>=86) return 'White'; return 'Gray'; }
+ if(s<=25 && l>=84) return 'Cream';
+ if((h<52||h>=345) && l<=32 && !(h>300&&h<345)) return 'Brown';
+ if(h>=18 && h<52){ if(l<=42)return 'Brown'; if(s<=38&&l>=82)return 'Cream'; if(s<=55&&l>=60)return 'Beige'; if(s>=45&&l>=42&&l<=72)return 'Gold'; if(l<=60)return 'Brown'; return 'Beige'; }
+ if(h>=52 && h<66) return 'Yellow';
+ if((h<15||h>=340) && (l>=72||(s<=60&&l>=60))) return 'Pink';
+ if(h<15||h>=345) return 'Red';
+ if(h<18) return 'Orange';
+ if(h<160) return 'Green';
+ if(h<200) return 'Teal';
+ if(h<255) return 'Blue';
+ if(h<290) return 'Purple';
+ if(h<340) return 'Pink';
+ return 'Red';
+}
+function productFamilies(p){
+ const fams=new Set();
+ for(const c of (p.colors||[])){ const f=familyOf(c&&c.hex); if(f)fams.add(f); }
+ if(!fams.size && p.color_hex){ const f=familyOf(p.color_hex); if(f)fams.add(f); }
+ return [...fams];
+}
+
+/* ── the ported facet list: checkbox rows + counts, type-ahead on big rows, cap/expand ── */
+function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); persistFacets(); renderFacets(); render(); }
+
+function facetList(rowId, counts, set, opts = {}) {
+ const row = document.getElementById(rowId); row.innerHTML = '';
+ const ui = rowUI[rowId];
+ 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(); const f2 = document.querySelector(`#${rowId} .ffind`); if (f2) { f2.focus(); f2.setSelectionRange(f2.value.length, f2.value.length); } };
+ row.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:var(--mut);padding:2px'; s.textContent = 'none'; row.appendChild(s); return; }
+ shown.forEach(([val, ct]) => {
+ const el = document.createElement('div');
+ el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+ const dot = opts.swatch && opts.swatch[val] ? `<span class="dot" style="background:${esc(opts.swatch[val])}"></span>` : '';
+ el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span>${dot}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${ct.toLocaleString()}</span>`;
+ el.onclick = () => toggleSet(set, val);
+ row.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(); };
+ row.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(); };
+ row.appendChild(el);
}
}
-function toggleFilter(key, value) {
- FILTERS[key] = FILTERS[key] || new Set();
- FILTERS[key].has(value) ? FILTERS[key].delete(value) : FILTERS[key].add(value);
- if (!FILTERS[key].size) delete FILTERS[key];
- renderFacets(); render();
+function renderFacets() {
+ facetList('typeRow', FACETS.type, state.types, { cap: 10 });
+ facetList('seriesRow', FACETS.series, state.series, { cap: 12 });
+ facetList('colorRow', FACETS.color, state.colors, { order: FACETS.family_order, swatch: FACETS.color_swatch, cap: 12 });
+ facetList('styleRow', FACETS.style, state.styles, { cap: 10 });
+ facetList('matRow', FACETS.material, state.materials, { cap: 10 });
+ facetList('priceRow', FACETS.price_band, state.prices, { order: FACETS.price_order, cap: 4 });
+ facetList('tagRow', FACETS.tag, state.tags, { cap: 12 });
+ facetList('imgRow', FACETS.image_state, state.images, { cap: 4 });
+ renderActive();
}
+const SECN = { Type:'nType', Book:'nSeries', Color:'nColor', Style:'nStyle', Material:'nMaterial', Price:'nPrice', Tag:'nTag', Image:'nImage' };
function renderActive() {
- const host = document.getElementById('activeFilters'); host.innerHTML='';
- for (const key of Object.keys(FILTERS)) for (const v of FILTERS[key]) {
- const c = document.createElement('span'); c.className='chip'; c.textContent = `${key}: ${v} ✕`;
- c.onclick = () => toggleFilter(key, v); host.appendChild(c);
+ const row = document.getElementById('activeRow'); row.innerHTML = '';
+ const dims = [['Type', state.types], ['Book', state.series], ['Color', state.colors], ['Style', state.styles],
+ ['Material', state.materials], ['Price', state.prices], ['Tag', state.tags], ['Image', state.images]];
+ for (const [label, set] of dims) { // selection-count badge on each collapsed section header
+ const b = document.getElementById(SECN[label]);
+ if (b) { b.textContent = set.size || ''; b.style.display = set.size ? '' : 'none'; }
+ }
+ let n = 0;
+ for (const [label, set] of dims) {
+ set.forEach((v) => {
+ n++;
+ const el = document.createElement('span'); el.className = 'afilter';
+ el.innerHTML = `<b>${label}:</b> ${esc(v)} ✕`;
+ el.onclick = () => { set.delete(v); persistFacets(); renderFacets(); render(); };
+ row.appendChild(el);
+ });
+ }
+ if (n) {
+ const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
+ c.onclick = () => { dims.forEach(([, s]) => s.clear()); persistFacets(); renderFacets(); render(); };
+ row.appendChild(c);
}
}
function filtered() {
- let rows = ALL.filter(p => {
- for (const key of Object.keys(FILTERS)) {
- const any = [...FILTERS[key]].some(v => facetMatch(p, key, v));
- if (!any) return false;
+ let rows = ALL.filter((p) => {
+ for (const key of Object.keys(SETS)) {
+ const set = state[key];
+ if (set.size && ![...set].some((v) => facetMatch(p, key, v))) return false;
}
if (SEARCH) {
- const hay = (p.pattern_name+' '+p.mfr_sku+' '+p.collection+' '+(p.tags||[]).join(' ')+' '+(p.styles||[]).join(' ')).toLowerCase();
+ const hay = (p.pattern_name + ' ' + p.mfr_sku + ' ' + p.collection + ' ' + (p.tags || []).join(' ') + ' ' + (p.styles || []).join(' ')).toLowerCase();
if (!hay.includes(SEARCH)) return false;
}
return true;
});
const s = document.getElementById('sort').value;
+ const fam = (p) => { const f = productFamilies(p)[0]; const i = f ? (FACETS.family_order || []).indexOf(f) : -1; return i < 0 ? 99 : i; };
const cmp = {
- newest: (a,b)=> (b.created_at||'').localeCompare(a.created_at||''),
- collection: (a,b)=> (a.collection||'').localeCompare(b.collection||'') || (a.pattern_name||'').localeCompare(b.pattern_name||''),
- pattern: (a,b)=> (a.pattern_name||'').localeCompare(b.pattern_name||''),
- sku: (a,b)=> (a.mfr_sku||'').localeCompare(b.mfr_sku||''),
- color: (a,b)=> (a.color_hex||'zzz').localeCompare(b.color_hex||'zzz'),
+ newest: (a, b) => (b.created_at || '').localeCompare(a.created_at || ''),
+ collection: (a, b) => (a.collection || '').localeCompare(b.collection || '') || (a.pattern_name || '').localeCompare(b.pattern_name || ''),
+ pattern: (a, b) => (a.pattern_name || '').localeCompare(b.pattern_name || ''),
+ sku: (a, b) => (a.mfr_sku || '').localeCompare(b.mfr_sku || ''),
+ color: (a, b) => fam(a) - fam(b) || (a.mfr_sku || '').localeCompare(b.mfr_sku || ''),
}[s];
return rows.sort(cmp);
}
@@ -178,36 +332,41 @@ function render() {
grid.innerHTML = '';
const frag = document.createDocumentFragment();
for (const p of rows) {
- const card = document.createElement('div'); card.className='card';
- const img = p.image_url ? `<img loading="lazy" src="${p.image_url}?w=500&q=70" alt="${p.pattern_name}">` : '';
+ const card = document.createElement('div'); card.className = 'card';
+ const img = p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}?w=500&q=70" alt="${esc(p.pattern_name)}">` : '';
const flags = [];
- if (p.settlement_flag && p.settlement_flag!=='OK') flags.push(`<span class="flag gate">gate: ${p.settlement_flag}</span>`);
+ if (p.settlement_flag && p.settlement_flag !== 'OK') flags.push(`<span class="flag gate">gate: ${esc(p.settlement_flag)}</span>`);
if (p.enriched) flags.push(`<span class="flag enr">enriched</span>`);
- const sw = p.color_hex ? `<span class="swatch" style="background:${p.color_hex}"></span>` : '';
- const tags = (p.tags||[]).slice(0,5).map(t=>`<span class="tag">${t}</span>`).join('');
+ const sw = p.color_hex ? `<span class="swatch" style="background:${esc(p.color_hex)}"></span>` : '';
+ const tags = (p.tags || []).slice(0, 5).map((t) => `<span class="tag">${esc(t)}</span>`).join('');
card.innerHTML = `
<div class="imgwrap">${img}</div>
<div class="body">
- <div class="ptn">${p.pattern_name}</div>
- <div class="meta">${sw}${p.mfr_sku} · ${p.collection}</div>
+ <div class="ptn">${esc(p.pattern_name)}</div>
+ <div class="meta">${sw}${esc(p.mfr_sku)} · ${esc(p.collection)}</div>
<div class="flags">${flags.join('')}</div>
<div class="tags">${tags}</div>
- <div class="when" title="${p.created_at}">🕓 ${fmtDate(p.created_at)}</div>
+ <div class="when" title="${esc(p.created_at)}">🕓 ${fmtDate(p.created_at)}</div>
</div>`;
frag.appendChild(card);
}
grid.appendChild(frag);
}
-// controls
-document.getElementById('sort').onchange = (e)=> { localStorage.gracieSort = e.target.value; render(); };
-document.getElementById('density').oninput = (e)=> { document.documentElement.style.setProperty('--cardmin', e.target.value+'px'); localStorage.gracieDensity = e.target.value; };
-document.getElementById('imgonly').onchange = (e)=> { document.body.classList.toggle('imgonly', e.target.checked); localStorage.gracieImgOnly = e.target.checked?'1':''; };
-document.getElementById('search').oninput = (e)=> { SEARCH = e.target.value.trim().toLowerCase(); render(); };
-// restore prefs
-if (localStorage.gracieDensity) { document.getElementById('density').value = localStorage.gracieDensity; document.documentElement.style.setProperty('--cardmin', localStorage.gracieDensity+'px'); }
+/* ── top-bar controls (search + sort + density + image-only, all persisted) ── */
+document.getElementById('sort').onchange = (e) => { localStorage.gracieSort = e.target.value; render(); };
+document.getElementById('density').oninput = (e) => { document.documentElement.style.setProperty('--cardmin', e.target.value + 'px'); localStorage.gracieDensity = e.target.value; };
+document.getElementById('imgonly').onchange = (e) => { document.body.classList.toggle('imgonly', e.target.checked); localStorage.gracieImgOnly = e.target.checked ? '1' : ''; };
+document.getElementById('search').oninput = (e) => { SEARCH = e.target.value.trim().toLowerCase(); render(); };
+/* restore prefs */
+if (localStorage.gracieDensity) { document.getElementById('density').value = localStorage.gracieDensity; document.documentElement.style.setProperty('--cardmin', localStorage.gracieDensity + 'px'); }
if (localStorage.gracieImgOnly) { document.getElementById('imgonly').checked = true; document.body.classList.add('imgonly'); }
if (localStorage.gracieSort) { document.getElementById('sort').value = localStorage.gracieSort; }
+/* accordion sections: persist open/closed per section (collapsed by default, Astek-style) */
+document.querySelectorAll('details.sec').forEach((d) => {
+ d.open = localStorage.getItem('gr_sec_' + d.dataset.k) === '1';
+ d.ontoggle = () => localStorage.setItem('gr_sec_' + d.dataset.k, d.open ? '1' : '0');
+});
boot();
</script>
</body>
diff --git a/server.js b/server.js
index 504ce45..9ea6cd8 100644
--- a/server.js
+++ b/server.js
@@ -93,32 +93,101 @@ async function load() {
console.log(`[gracie-internal] loaded ${SNAP.length} products`);
}
-// Build field-tab facets (value + count) for the left panel.
+// ── color-family bucketing (all.dw rail parity) ──────────────────────────────────
+// Gracie is single-vendor and has no free-text color TAGS like all.dw — instead each
+// product carries a real HEX palette (color_hex / dominant_color_hex + ai_colors[{hex,name}]).
+// We bucket every hex into an interior-designer hue FAMILY (hex → HSL → family), so the
+// Color facet shows family rows with a representative swatch + count. Bucketing is derived
+// on SNAP, so it is identical in both data modes (pg + jsonl) by construction.
+const FAMILY_ORDER = ['Red', 'Orange', 'Brown', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Beige', 'Cream', 'Black', 'Gray', 'White', 'Metallic', 'Multi'];
+function hexToRgb(hex) {
+ const m = String(hex || '').trim().replace(/^#/, '');
+ if (!/^[0-9a-fA-F]{6}$/.test(m)) return null;
+ return [parseInt(m.slice(0, 2), 16), parseInt(m.slice(2, 4), 16), parseInt(m.slice(4, 6), 16)];
+}
+function rgbToHsl(r, g, b) {
+ r /= 255; g /= 255; b /= 255;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b); let h = 0, s = 0; const l = (mx + mn) / 2;
+ if (mx !== mn) {
+ const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
+ switch (mx) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; }
+ h /= 6;
+ }
+ return [h * 360, s * 100, l * 100];
+}
+// hex → interior-designer color family; null when unparseable.
+function familyOf(hex) {
+ const rgb = hexToRgb(hex); if (!rgb) return null;
+ const [h, s, l] = rgbToHsl(...rgb);
+ if (l <= 10) return 'Black';
+ if (l >= 94 && s <= 8) return 'White';
+ if (s <= 10) { if (l >= 86) return 'White'; return 'Gray'; }
+ if (s <= 25 && l >= 84) return 'Cream'; // parchment/ivory grounds
+ if ((h < 52 || h >= 345) && l <= 32 && !(h > 300 && h < 345)) return 'Brown'; // espresso/chocolate
+ if (h >= 18 && h < 52) { // warm band → Brown/Beige/Gold/Cream
+ if (l <= 42) return 'Brown';
+ if (s <= 38 && l >= 82) return 'Cream';
+ if (s <= 55 && l >= 60) return 'Beige';
+ if (s >= 45 && l >= 42 && l <= 72) return 'Gold';
+ if (l <= 60) return 'Brown';
+ return 'Beige';
+ }
+ if (h >= 52 && h < 66) return 'Yellow';
+ if ((h < 15 || h >= 340) && (l >= 72 || (s <= 60 && l >= 60))) return 'Pink';
+ if (h < 15 || h >= 345) return 'Red';
+ if (h < 18) return 'Orange';
+ if (h < 160) return 'Green';
+ if (h < 200) return 'Teal';
+ if (h < 255) return 'Blue';
+ if (h < 290) return 'Purple';
+ if (h < 340) return 'Pink';
+ return 'Red';
+}
+// The set of color families present on a product (deduped), from its ai_colors palette,
+// falling back to color_hex when the palette is empty.
+function familiesOf(p) {
+ const fams = new Set();
+ for (const c of (p.colors || [])) { const f = familyOf(c && c.hex); if (f) fams.add(f); }
+ if (!fams.size && p.color_hex) { const f = familyOf(p.color_hex); if (f) fams.add(f); }
+ return [...fams];
+}
+
+// ── facet aggregation (all.dw rail shape) ────────────────────────────────────────
+// Returns {counts:{value:count}} per dimension + swatch map for color + orderings.
+// Computed on SNAP, so pg and jsonl modes yield identical facets. Style + Tag are
+// EXPLODED from their JSONB arrays; Color is family-bucketed; Price is quote-only.
function facets() {
- const f = {};
- const bump = (key, val) => {
- if (val == null || val === '') return;
- f[key] = f[key] || {};
- f[key][val] = (f[key][val] || 0) + 1;
- };
+ const type = {}, series = {}, color = {}, style = {}, material = {}, tag = {}, price_band = {}, image_state = {};
+ const colorSwatch = {};
+ const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
for (const p of SNAP) {
- bump('Collection', p.collection);
- bump('Type', p.product_type);
- bump('Material', p.material);
- (p.styles || []).forEach((s) => bump('Style', s));
- (p.tags || []).forEach((t) => bump('Tag', t));
- bump('Settlement', p.settlement_flag || 'ungated');
- bump('Enriched', p.enriched ? 'yes' : 'no');
+ bump(type, p.product_type);
+ bump(series, p.collection);
+ bump(material, p.material || 'Unspecified');
+ (p.styles || []).forEach((s) => bump(style, s)); // explode ai_styles JSONB
+ (p.tags || []).forEach((t) => bump(tag, t)); // explode ai_tags JSONB
+ for (const fam of familiesOf(p)) {
+ bump(color, fam);
+ // a representative swatch per family — prefer a hex that truly buckets to this family
+ const hit = (p.colors || []).find((c) => c && familyOf(c.hex) === fam) || (familyOf(p.color_hex) === fam ? { hex: p.color_hex } : null);
+ if (hit && (!colorSwatch[fam] || familyOf(colorSwatch[fam]) !== fam)) colorSwatch[fam] = hit.hex;
+ }
+ // Gracie is QUOTE-ONLY — a single price band reflects that (parity with all.dw's price facet shape).
+ bump(price_band, 'Quote-only');
+ bump(image_state, p.image_url ? 'Has image' : 'No image');
}
- const out = {};
- for (const k of Object.keys(f)) {
- out[k] = Object.entries(f[k]).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
- }
- return out;
+ return {
+ type, series, color, style, material, tag, price_band, image_state,
+ color_swatch: colorSwatch,
+ family_order: FAMILY_ORDER,
+ price_order: ['Quote-only'],
+ total: SNAP.length,
+ refreshed: LAST_REFRESH,
+ };
}
app.get('/api/products', (_req, res) => res.json({ count: SNAP.length, products: SNAP }));
-app.get('/api/facets', (_req, res) => res.json({ facets: facets(), total: SNAP.length, refreshed: LAST_REFRESH }));
+app.get('/api/facets', (_req, res) => res.json(facets()));
app.use(express.static(path.join(__dirname, 'public')));
← 0b12522 jsonl-backed data layer: self-contained bundle for Kamatera
·
back to Gracie Internal
·
Add Activation Calendar tab (go-live date+time per staged SK f55e279 →