← back to Fromental Internal
Upgrade viewer rail to all.dw feature-parity: active-filter chips, collapsible facet sections with counts, color-family swatches, style/tag explosion, price bands, drill-down /api/facets (both jsonl+pg modes)
f0fa00c42a879aa89730c3b880bcfe57b1f77df9 · 2026-07-09 16:08:01 -0700 · Steve Abrams
Files touched
M public/index.htmlM server.js
Diff
commit f0fa00c42a879aa89730c3b880bcfe57b1f77df9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 9 16:08:01 2026 -0700
Upgrade viewer rail to all.dw feature-parity: active-filter chips, collapsible facet sections with counts, color-family swatches, style/tag explosion, price bands, drill-down /api/facets (both jsonl+pg modes)
---
public/index.html | 240 +++++++++++++++++++++++++++++++++++-----------
server.js | 280 +++++++++++++++++++++++++++++++++++++++++-------------
2 files changed, 398 insertions(+), 122 deletions(-)
diff --git a/public/index.html b/public/index.html
index 527bb38..8ff5041 100644
--- a/public/index.html
+++ b/public/index.html
@@ -22,22 +22,39 @@
input[type=range]{width:120px;accent-color:var(--acc)}
.refresh{font-size:11px;color:var(--mut);border:1px solid var(--line);border-radius:20px;padding:3px 10px}
- .wrap{display:grid;grid-template-columns:260px 1fr;gap:0;align-items:start}
- aside{position:sticky;top:57px;max-height:calc(100vh - 57px);overflow:auto;border-right:1px solid var(--line);padding:14px;background:#fff}
- aside .search{width:100%;margin-bottom:10px}
- .clearbtn{width:100%;text-align:left;font-size:12px;color:var(--acc);background:none;border:none;cursor:pointer;padding:6px 2px;margin-bottom:6px}
- details.facet{border-bottom:1px solid var(--line)}
- details.facet>summary{cursor:pointer;list-style:none;padding:8px 2px;font-size:12px;font-weight:600;letter-spacing:.03em;text-transform:uppercase;color:#4a453f;display:flex;justify-content:space-between;align-items:center}
- details.facet>summary::-webkit-details-marker{display:none}
- details.facet>summary .badge{background:var(--acc);color:#fff;border-radius:10px;font-size:10px;padding:1px 7px;font-weight:600;text-transform:none;letter-spacing:0}
- details.facet[data-active="1"]>summary{color:var(--acc)}
- .facet .rows{padding:2px 0 8px}
- .facet .row{display:flex;justify-content:space-between;gap:8px;font-size:12.5px;padding:3px 6px;cursor:pointer;border-radius:3px;color:#3a352f}
- .facet .row:hover{background:#f2efe9}
- .facet .row.sel{background:var(--acc);color:#fff}
- .facet .row .n{color:var(--mut);font-variant-numeric:tabular-nums}
- .facet .row.sel .n{color:#dff3f0}
- .cardfields{padding:8px 2px 2px}
+ .wrap{display:grid;grid-template-columns:266px 1fr;gap:0;align-items:start}
+ aside{position:sticky;top:57px;max-height:calc(100vh - 57px);overflow:auto;border-right:1px solid var(--line);padding:12px 10px 60px;background:#fff}
+ aside .search{width:100%;margin-bottom:8px}
+
+ /* ── active-filter chip row (ported from all.dw #activeRow) ── */
+ .active{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
+ .afilter{background:#f2efe9;border:1px solid var(--acc);color:#3a352f;border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
+ .afilter b{color:var(--acc);font-weight:600}
+ .afilter .sw{width:9px;height:9px;border-radius:50%;border:1px solid rgba(0,0,0,.15)}
+ .clearall{background:none;border:1px solid var(--line);color:var(--mut);border-radius:20px;padding:3px 11px;font-size:11px;cursor:pointer}
+
+ /* ── Astek-style collapsed accordion sections per field (ported from all.dw details.sec) ── */
+ details.sec{border:1px solid var(--line);border-radius:7px;margin-bottom:7px;background:#fff}
+ 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(--acc);font-size:10px}
+ details.sec[open]>summary::before{content:'▾'}
+ details.sec>summary .n{margin-left:auto;background:var(--acc);color:#fff;border-radius:20px;padding:0 7px;font-size:10px;font-weight:700}
+ details.sec>summary .n:empty{display:none}
+ .fbody{padding:2px 8px 9px}
+ .fitem{display:flex;align-items:center;gap:7px;padding:2.5px 2px;cursor:pointer;font-size:12.5px;color:#3a352f;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;color:#fff}
+ .fitem.on .box{background:var(--acc);border-color:var(--acc)}
+ .fitem.on{color:var(--acc)}
+ .fitem .sw{width:12px;height:12px;flex:0 0 12px;border-radius:3px;border:1px solid rgba(0,0,0,.15)}
+ .fitem .lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ .fitem .ct{color:var(--mut);font-size:10.5px;font-variant-numeric:tabular-nums}
+ .fitem.zero{opacity:.4}
+ .more{color:var(--acc);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-radius:6px !important;margin:2px 0 5px;border:1px solid var(--line)}
+ .fnone{font-size:10.5px;color:var(--mut);padding:3px 2px}
+ .cardfields{padding:6px 8px 4px}
.cardfields .cf{display:flex;align-items:center;gap:7px;font-size:12.5px;padding:3px 4px;color:#3a352f}
main{padding:14px}
@@ -102,13 +119,13 @@
</header>
<div class="wrap">
- <aside>
+ <aside id="rail">
<input class="search" type="search" id="search" placeholder="Search pattern, color, SKU…" list="patterns" />
<datalist id="patterns"></datalist>
- <button class="clearbtn" id="clear">✕ Clear filters</button>
- <div id="facets"></div>
- <details class="facet" open>
- <summary>Card fields</summary>
+ <div class="active" id="activeRow"></div>
+
+ <details class="sec" data-k="fields">
+ <summary>Card fields<span class="n"></span></summary>
<div class="cardfields" id="cardfields">
<label class="cf"><input type="checkbox" data-f="ttl" checked> Title</label>
<label class="cf"><input type="checkbox" data-f="col" checked> Color</label>
@@ -116,6 +133,14 @@
<label class="cf"><input type="checkbox" data-f="when" checked> Created date</label>
</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="collection"><summary>Book / Series<span class="n" id="nCollection"></span></summary><div class="fbody" id="collectionRow"></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>
@@ -127,23 +152,48 @@
<script>
const ORIGIN = location.origin;
-const state = { all: [], facets: {}, filters: {}, q: '', sort: 'newest' };
-const FACET_LABELS = { type:'Type', collection:'Collection', color:'Color', material:'Material', width:'Width', style:'Style', tag:'Tag', pricing:'Pricing' };
+const esc = (s)=> String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+
+// ── filter state: one Set per facet field (multi-select), persisted to localStorage ──
+const loadSet = (k)=>{ try{ return new Set(JSON.parse(localStorage.getItem(k)||'[]')); }catch{ return new Set(); } };
+const state = {
+ all: [], facets: {}, sort: localStorage.getItem('fr_sort')||'newest', q: '',
+ types: loadSet('fr_types'), collections: loadSet('fr_collections'), colors: loadSet('fr_colors'),
+ styles: loadSet('fr_styles'), materials: loadSet('fr_materials'), prices: loadSet('fr_prices'),
+ tags: loadSet('fr_tags'), images: loadSet('fr_images'),
+};
+// field → {stateKey, rowId, summaryId, label, apiKey, array?, swatch?, orderKey?}
+const FACETS = [
+ { k:'types', row:'typeRow', n:'nType', label:'Type', api:'type', arr:false },
+ { k:'collections', row:'collectionRow', n:'nCollection', label:'Book', api:'collection', arr:false },
+ { k:'colors', row:'colorRow', n:'nColor', label:'Color', api:'color', arr:true, swatch:true, orderKey:'family_order' },
+ { k:'styles', row:'styleRow', n:'nStyle', label:'Style', api:'style', arr:true },
+ { k:'materials', row:'matRow', n:'nMaterial', label:'Material', api:'material', arr:false },
+ { k:'prices', row:'priceRow', n:'nPrice', label:'Price', api:'price_band', arr:false, orderKey:'price_order' },
+ { k:'tags', row:'tagRow', n:'nTag', label:'Tag', api:'tag', arr:true },
+ { k:'images', row:'imgRow', n:'nImage', label:'Image', api:'image_state', arr:false, orderKey:'image_order' },
+];
+// per-row UI state: expanded? + type-ahead needle
+const rowUI = {};
+FACETS.forEach(f=> rowUI[f.row] = { expand:false, find:'', typeahead:(f.api==='tag'||f.api==='color'||f.api==='style') });
+
+function persistFacets(){ FACETS.forEach(f=> localStorage.setItem('fr_'+f.k, JSON.stringify([...state[f.k]]))); }
function lum(hex){ if(!hex||hex[0]!=='#'||hex.length!==7) return null; const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16); return .2126*r+.7152*g+.0722*b; }
function hue(hex){ if(!hex||hex[0]!=='#'||hex.length!==7) return 999; let 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; if(d===0)return 999; 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; return h; }
function fmtDate(d){ if(!d) return ''; try{ return new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch{ return ''; } }
+// ── client-side filter (mirrors server applyFilters for the grid render) ──
function matches(p){
if(state.q){ const q=state.q.toLowerCase(); const hay=[p.pattern,p.color,p.sku,p.type,p.collection].filter(Boolean).join(' ').toLowerCase(); if(!hay.includes(q)) return false; }
- for(const [field,val] of Object.entries(state.filters)){
- if(val==null) continue;
- if(field==='pricing'){ const isQuote=p.quote; if((val==='Quote-only')!==isQuote) return false; continue; }
- if(field==='style'){ if(!(p.styles||[]).includes(val)) return false; continue; }
- if(field==='tag'){ if(!(p.aiTags||[]).includes(val)) return false; continue; }
- const map={type:'type',collection:'collection',color:'color',material:'material',width:'width'};
- if(p[map[field]]!==val) return false;
- }
+ if(state.types.size && !state.types.has(p.type)) return false;
+ if(state.collections.size && !state.collections.has(p.collection)) return false;
+ if(state.colors.size && !(p.families||[]).some(x=>state.colors.has(x))) return false;
+ if(state.styles.size && !(p.styles||[]).some(x=>state.styles.has(x))) return false;
+ if(state.materials.size && !state.materials.has(p.material)) return false;
+ if(state.prices.size && !state.prices.has(p.priceBand)) return false;
+ if(state.tags.size && !(p.aiTags||[]).some(x=>state.tags.has(x))) return false;
+ if(state.images.size && !state.images.has(p.imageState)) return false;
return true;
}
function sortRows(rows){
@@ -171,7 +221,7 @@ function render(){
const chips=[];
if(p.type) chips.push(`<span class="chip">${esc(p.type)}</span>`);
chips.push(p.quote?`<span class="chip q">Quote</span>`:(p.price!=null?`<span class="chip p">$${p.price.toLocaleString()}</span>`:''));
- const dot=p.hex?`<span class="dot" style="background:${p.hex}"></span>`:'';
+ const dot=p.hex?`<span class="dot" style="background:${esc(p.hex)}"></span>`:'';
return `<div class="card" data-sku="${esc(p.sku)}">
<div class="imgwrap">${p.image?`<img loading="lazy" src="${esc(p.image)}" alt="${esc(p.pattern||'')}">`:''}</div>
<div class="b">
@@ -184,30 +234,101 @@ function render(){
}).join('');
applyCardFields();
}
-function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
-// facets
-function renderFacets(){
- const host=document.getElementById('facets');
- host.innerHTML=Object.entries(state.facets).map(([field,rows])=>{
- if(!rows||!rows.length) return '';
- const active=state.filters[field];
- const rowsHtml=rows.map(([v,n])=>`<div class="row ${active===v?'sel':''}" data-field="${field}" data-val="${esc(v)}"><span>${esc(v)}</span><span class="n">${n}</span></div>`).join('');
- return `<details class="facet" data-active="${active?1:0}">
- <summary>${FACET_LABELS[field]||field} ${active?`<span class="badge">${esc(active)}</span>`:''}</summary>
- <div class="rows">${rowsHtml}</div>
- </details>`;
- }).join('');
+// ── Amazon-style vertical facet list (ported from all.dw facetList): checkbox rows + counts,
+// type-ahead on big rows, See-more expander, selected values always visible, optional swatch. ──
+function facetList(f, counts){
+ const row=document.getElementById(f.row); if(!row) return;
+ row.innerHTML='';
+ const set=state[f.k], ui=rowUI[f.row];
+ 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; facetList(f, LAST_FACETS[f.api]); const f2=row.querySelector('.ffind'); if(f2){ f2.focus(); f2.setSelectionRange(f2.value.length,f2.value.length); } };
+ row.appendChild(find);
+ }
+ let entries=Object.entries(counts||{});
+ const order = f.orderKey ? (state.facets[f.orderKey]||[]) : null;
+ entries = order
+ ? entries.sort((a,b)=> (order.indexOf(a[0])<0?999:order.indexOf(a[0])) - (order.indexOf(b[0])<0?999: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 = f.api==='color'||f.api==='tag' ? 12 : (f.api==='style'?10: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.className='fnone'; s.textContent='no data'; row.appendChild(s); return; }
+ const swatchMap = f.swatch ? (state.facets.family_swatch||{}) : null;
+ shown.forEach(([val,ct])=>{
+ const el=document.createElement('div');
+ el.className='fitem'+(set.has(val)?' on':'')+(ct?'':' zero');
+ const sw = swatchMap ? `<span class="sw" style="background:${esc(swatchMap[val]||'#ccc')}"></span>` : '';
+ el.innerHTML=`<span class="box">${set.has(val)?'✓':''}</span>${sw}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${(ct||0).toLocaleString()}</span>`;
+ el.onclick=()=> toggleSet(f.k, 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; facetList(f, LAST_FACETS[f.api]); }; 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; facetList(f, LAST_FACETS[f.api]); }; row.appendChild(el);
+ }
+}
+
+let LAST_FACETS={};
+function renderFacets(f){
+ LAST_FACETS=f; state.facets=f;
+ FACETS.forEach(def=> facetList(def, f[def.api]));
+ renderActive();
+}
+
+// ── active-filter chip row + per-section count badges (ported from all.dw renderActive) ──
+function renderActive(){
+ const row=document.getElementById('activeRow'); row.innerHTML='';
+ const swatchMap=state.facets.family_swatch||{};
+ FACETS.forEach(f=>{ const b=document.getElementById(f.n); if(b){ const n=state[f.k].size; b.textContent=n||''; } });
+ let n=0;
+ FACETS.forEach(f=>{
+ state[f.k].forEach(v=>{
+ n++;
+ const el=document.createElement('span'); el.className='afilter';
+ const sw = f.swatch ? `<span class="sw" style="background:${esc(swatchMap[v]||'#ccc')}"></span>` : '';
+ el.innerHTML=`${sw}<b>${f.label}:</b> ${esc(v)} ✕`;
+ el.onclick=()=>{ state[f.k].delete(v); applyFilterChange(); };
+ row.appendChild(el);
+ });
+ });
+ if(n){
+ const c=document.createElement('button'); c.className='clearall'; c.textContent='Clear all';
+ c.onclick=()=>{ FACETS.forEach(f=> state[f.k].clear()); applyFilterChange(); };
+ row.appendChild(c);
+ }
+}
+
+function applyFilterChange(){ persistFacets(); refreshFacets(); render(); }
+function toggleSet(key, val){ const set=state[key]; set.has(val)?set.delete(val):set.add(val); applyFilterChange(); }
+
+function facetParams(){
+ return new URLSearchParams({
+ q: state.q,
+ types:[...state.types].join(','), collections:[...state.collections].join(','),
+ colors:[...state.colors].join(','), styles:[...state.styles].join(','),
+ materials:[...state.materials].join(','), prices:[...state.prices].join(','),
+ tags:[...state.tags].join(','), images:[...state.images].join(','),
+ });
+}
+async function refreshFacets(){
+ try{ const f=await fetch(ORIGIN+'/api/facets?'+facetParams()).then(r=>r.json()); renderFacets(f); }
+ catch(e){ /* facets degrade silently — grid still works */ }
}
// events
document.addEventListener('click',(e)=>{
- const row=e.target.closest('.facet .row');
- if(row){ const f=row.dataset.field,v=row.dataset.val; state.filters[f]=(state.filters[f]===v)?null:v; renderFacets(); render(); return; }
const card=e.target.closest('.card'); if(card){ openDrawer(card.dataset.sku); }
});
-document.getElementById('clear').addEventListener('click',()=>{ state.filters={}; state.q=''; document.getElementById('search').value=''; renderFacets(); render(); });
-document.getElementById('search').addEventListener('input',(e)=>{ state.q=e.target.value.trim(); render(); });
+document.getElementById('search').addEventListener('input',(e)=>{ state.q=e.target.value.trim(); render(); refreshFacets(); });
document.getElementById('sort').addEventListener('change',(e)=>{ state.sort=e.target.value; localStorage.setItem('fr_sort',state.sort); render(); });
document.getElementById('density').addEventListener('input',(e)=>setDensity(+e.target.value));
function setDensity(cols){
@@ -216,6 +337,14 @@ function setDensity(cols){
document.documentElement.style.setProperty('--fs',fs);
localStorage.setItem('fr_density',cols); document.getElementById('density').value=cols;
}
+
+// accordion sections: persist open/closed per section (collapsed by default)
+document.querySelectorAll('details.sec').forEach((d)=>{
+ d.open = localStorage.getItem('fr_sec_'+d.dataset.k)==='1';
+ d.ontoggle = ()=> localStorage.setItem('fr_sec_'+d.dataset.k, d.open?'1':'0');
+});
+
+// card fields
document.querySelectorAll('#cardfields input').forEach(cb=>cb.addEventListener('change',()=>{ saveCardFields(); applyCardFields(); }));
function saveCardFields(){ const o={}; document.querySelectorAll('#cardfields input').forEach(cb=>o[cb.dataset.f]=cb.checked); localStorage.setItem('fr_cf',JSON.stringify(o)); }
function applyCardFields(){
@@ -238,8 +367,10 @@ async function openDrawer(sku){
const rowsSpec=[
['SKU',p.sku],['Color',p.color],['Type',p.type],['Collection',p.collection],
['Material',p.material],['Width',p.width],['Vendor',p.vendor],
+ ['Color families',(p.families||[]).join(', ')],
['Price', p.quote?'Quote-only (made to order)':(p.price!=null?'$'+p.price.toLocaleString():'—')],
['Styles',(p.styles||[]).join(', ')],
+ ['Tags',(p.aiTags||[]).slice(0,10).join(', ')],
].filter(r=>r[1]).map(r=>`<div class="row2"><span class="k">${r[0]}</span><span>${esc(r[1])}</span></div>`).join('');
panel.innerHTML=`
<div style="position:relative"><span class="close" onclick="document.getElementById('drawer').classList.remove('open')">×</span>
@@ -261,23 +392,22 @@ document.getElementById('drawer').addEventListener('click',(e)=>{ if(e.target.id
// meta refresh pill
async function tickMeta(){
try{ const m=await fetch(ORIGIN+'/api/meta').then(r=>r.json());
- document.getElementById('refresh').textContent=`↻ ${m.count} · ${m.enriched} enriched`;
+ document.getElementById('refresh').textContent=`↻ ${m.count} · ${m.enriched} enriched · ${m.source}`;
}catch{}
}
async function boot(){
- // restore prefs
setDensity(+(localStorage.getItem('fr_density')||6));
- state.sort=localStorage.getItem('fr_sort')||'newest'; document.getElementById('sort').value=state.sort;
+ document.getElementById('sort').value=state.sort;
const cf=localStorage.getItem('fr_cf'); if(cf){ try{ const o=JSON.parse(cf); document.querySelectorAll('#cardfields input').forEach(cb=>{ if(o[cb.dataset.f]===false) cb.checked=false; }); }catch{} }
const [prod,fac]=await Promise.all([
fetch(ORIGIN+'/api/products').then(r=>r.json()),
- fetch(ORIGIN+'/api/facets').then(r=>r.json()),
+ fetch(ORIGIN+'/api/facets?'+facetParams()).then(r=>r.json()),
]);
- state.all=prod.products; state.facets=fac;
+ state.all=prod.products;
document.getElementById('patterns').innerHTML=[...new Set(state.all.map(p=>p.pattern).filter(Boolean))].slice(0,400).map(v=>`<option value="${esc(v)}">`).join('');
- renderFacets(); render(); tickMeta(); setInterval(tickMeta,60000);
+ renderFacets(fac); render(); tickMeta(); setInterval(tickMeta,60000);
}
boot();
</script>
diff --git a/server.js b/server.js
index 85a01e2..dc2f3f0 100644
--- a/server.js
+++ b/server.js
@@ -1,12 +1,16 @@
-/* Fromental — INTERNAL line viewer (basic-auth, read-only over dw_unified.fromental_catalog).
- Built to Steve's internal-line-viewer standing spec (astek-landing :9944 is the reference):
- left panel with search + per-field collapsible tab tables (values+counts, click-to-filter),
- card-field visibility toggles down to image-only, type scale tied to the density slider,
- created-date chips, location.origin fetches, sort + density persisted in localStorage.
-
- Reads fromental_catalog LIVE (refreshes an in-memory snapshot every REFRESH_INTERVAL_SEC).
- Fromental is CUSTOMER-FACING (not private-label) but this viewer is an internal curation
- surface — quote-only, sample-order only, no Shopify links. */
+/* Fromental — INTERNAL line viewer (basic-auth, read-only).
+ Built to Steve's internal-line-viewer standing spec (astek-landing :9944) AND upgraded to
+ full feature-parity with all.designerwallcoverings.com's Amazon-style left rail:
+ - active-filter chip row (#activeRow) with removable pills + Clear all
+ - collapsible <details class="sec"> facet sections, each with a live count badge (.n)
+ - Amazon-style checkbox rows + counts + type-ahead (.ffind) + cap/expand "show more/less"
+ - server-computed /api/facets that recomputes each facet's counts from the CURRENT filtered
+ set (drill-down), the SAME way in BOTH data modes.
+
+ DATA PATHS (a HARD rail): jsonl mode NEVER touches a DB.
+ - LOCAL : live Postgres over dw_unified.fromental_catalog (in-memory snapshot, periodic reload)
+ - KAMATERA: self-contained data/fromental.jsonl loaded into memory (static; no DB, no reload)
+ The JSONL rows are the exact pg row shape, so the mapping + facet logic below is mode-agnostic. */
const express = require('express');
const path = require('path');
const fs = require('fs');
@@ -24,15 +28,96 @@ const USE_JSONL = process.env.DATA_SOURCE === 'jsonl' || (process.env.DATA_SOURC
const app = express();
app.use(require('compression')());
-let SNAP = { products: [], facets: {}, count: 0 };
+let SNAP = { products: [], count: 0 };
let LAST_REFRESH = new Date().toISOString();
+// ── color families (hue buckets) ─────────────────────────────────────────────
+// A single hex → a coarse hue/lightness family. Ordered light→warm→cool→dark so the
+// Color facet reads like a paint deck. Each family carries a representative swatch hex
+// so the rail can render a color dot next to the family name.
+const FAMILY_ORDER = ['White', 'Cream', 'Beige', 'Grey', 'Silver', 'Warm', 'Red', 'Orange', 'Yellow', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Brown', 'Black', 'Neutral'];
+const FAMILY_SWATCH = {
+ White: '#f5f3ee', Cream: '#efe7d0', Beige: '#d8c7a8', Grey: '#9a9a9a', Silver: '#c4c7cc',
+ Warm: '#c8a26a', Red: '#c0392b', Orange: '#e07b39', Yellow: '#e4c14a', Green: '#4c8b52',
+ Teal: '#2f9c94', Blue: '#3f6fb0', Purple: '#7a5aa0', Pink: '#d98aa8', Brown: '#6b4a30',
+ Black: '#1c1c1c', Neutral: '#b9b2a6',
+};
+function hexToRgb(hex) {
+ if (!hex || hex[0] !== '#' || (hex.length !== 7 && hex.length !== 4)) return null;
+ let h = hex;
+ if (h.length === 4) h = '#' + h[1] + h[1] + h[2] + h[2] + h[3] + h[3];
+ const r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16);
+ if ([r, g, b].some(Number.isNaN)) return null;
+ return [r, g, b];
+}
+function rgbToHsl(r, g, b) {
+ r /= 255; g /= 255; b /= 255;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+ let h = 0; const l = (mx + mn) / 2; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
+ if (d !== 0) {
+ 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;
+ }
+ return [h, s, l];
+}
+// Map a hex to one of FAMILY_ORDER. Lightness/saturation first (achromatics), then hue.
+function hexToFamily(hex) {
+ const rgb = hexToRgb(hex);
+ if (!rgb) return null;
+ const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
+ if (l >= 0.92) return 'White';
+ if (l <= 0.09) return 'Black';
+ if (s < 0.12) { // near-grey axis
+ if (l >= 0.75) return 'Silver';
+ if (l >= 0.55) return 'Grey';
+ return 'Grey';
+ }
+ if (s < 0.28 && l >= 0.62) { // muted, light → soft neutrals
+ if (h >= 30 && h <= 70) return 'Cream';
+ return 'Beige';
+ }
+ // low-sat mid/dark warm → Brown / Warm
+ if (s < 0.45 && l < 0.5 && (h < 45 || h >= 330)) return 'Brown';
+ if (h < 15 || h >= 345) return 'Red';
+ if (h < 40) return (l < 0.45 ? 'Brown' : (s < 0.5 ? 'Warm' : 'Orange'));
+ if (h < 50) return 'Orange';
+ if (h < 70) return 'Yellow';
+ if (h < 160) return 'Green';
+ if (h < 195) return 'Teal';
+ if (h < 255) return 'Blue';
+ if (h < 300) return 'Purple';
+ if (h < 345) return 'Pink';
+ return 'Neutral';
+}
+
+// ── price bands (tuned to the Fromental range: $20 – $13,745, median ~$1,015) ──
+const PRICE_BANDS = [
+ { key: 'Quote-only', test: null }, // special: matched by p.quote, handled below
+ { key: '≤$100', test: (p) => p <= 100 },
+ { key: '$100–500', test: (p) => p > 100 && p <= 500 },
+ { key: '$500–1k', test: (p) => p > 500 && p <= 1000 },
+ { key: '$1k–2.5k', test: (p) => p > 1000 && p <= 2500 },
+ { key: '$2.5k–5k', test: (p) => p > 2500 && p <= 5000 },
+ { key: '$5k+', test: (p) => p > 5000 },
+];
+const PRICE_ORDER = PRICE_BANDS.map((b) => b.key);
+function priceBand(p) {
+ if (p.quote || p.price == null) return 'Quote-only';
+ const b = PRICE_BANDS.find((x) => x.test && x.test(p.price));
+ return b ? b.key : 'Quote-only';
+}
+
+const IMAGE_ORDER = ['Has image', 'No image'];
+
function titleCase(s) {
- return String(s || '').replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
+ return String(s || '').replace(/\w[\w'-]*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
}
+// Normalize an ai_styles / ai_tags value so "Chinoiserie" and "chinoiserie" collapse to one.
+const normLabel = (s) => titleCase(String(s || '').trim());
// Raw-row acquisition — jsonl bundle (self-contained) or live Postgres.
-// The JSONL rows are the exact pg row shape, so downstream mapping is unchanged.
async function fetchRows() {
if (USE_JSONL) {
return fs.readFileSync(JSONL, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
@@ -55,63 +140,123 @@ async function fetchRows() {
async function load() {
const rows = await fetchRows();
- const products = rows.map((r) => ({
- id: r.id,
- sku: r.mfr_sku,
- handle: r.handle,
- pattern: r.pattern_name,
- color: r.color_name,
- collection: r.collection,
- type: r.product_type,
- material: r.material,
- composition: r.composition,
- width: r.width,
- length: r.roll_length,
- image: r.image_url,
- gallery: r.gallery_images || [],
- productUrl: r.product_url,
- description: r.description || r.ai_description || null,
- tags: r.tags || [],
- vendor: (r.vendor || 'Fromental').replace(/\s*Design$/i, ''),
- price: r.price != null ? Number(r.price) : null,
- quote: !!r.quote_only && r.price == null,
- hex: r.dominant_color_hex || r.color_hex || null,
- styles: r.ai_styles || [],
- aiTags: r.ai_tags || [],
- enriched: !!r.ai_accepted_at,
- createdAt: r.vendor_created_at || r.crawled_at,
- }));
-
- // Facets — one entry per filterable field: [value, count], sorted by count desc.
- const facet = (keyFn) => {
- const m = new Map();
- for (const p of products) {
- const vals = keyFn(p);
- for (const v of (Array.isArray(vals) ? vals : [vals])) {
- if (v == null || v === '') continue;
- m.set(v, (m.get(v) || 0) + 1);
- }
+ const products = rows.map((r) => {
+ const hex = r.dominant_color_hex || r.color_hex || null;
+ // color families: primary hex family + every ai_colors hex family (so a pattern with a
+ // green ground and blue motif shows under both Green and Blue).
+ const famSet = new Set();
+ const pf = hexToFamily(hex); if (pf) famSet.add(pf);
+ for (const c of (Array.isArray(r.ai_colors) ? r.ai_colors : [])) {
+ const f = hexToFamily(c && c.hex); if (f) famSet.add(f);
}
- return [...m.entries()].sort((a, b) => b[1] - a[1]);
- };
- const facets = {
- type: facet((p) => p.type),
- collection: facet((p) => p.collection),
- color: facet((p) => p.color),
- material: facet((p) => p.material),
- width: facet((p) => p.width),
- style: facet((p) => p.styles),
- tag: facet((p) => p.aiTags),
- pricing: facet((p) => (p.quote ? 'Quote-only' : 'Priced')),
- };
+ const price = r.price != null && r.price !== '' ? Number(r.price) : null;
+ const quote = (!!r.quote_only && price == null) || price == null;
+ const styles = [...new Set((Array.isArray(r.ai_styles) ? r.ai_styles : []).map(normLabel).filter(Boolean))];
+ const aiTags = [...new Set((Array.isArray(r.ai_tags) ? r.ai_tags : []).map(normLabel).filter(Boolean))];
+ const material = r.material || r.composition || null; // fall back composition → material
+ return {
+ id: r.id,
+ sku: r.mfr_sku,
+ handle: r.handle,
+ pattern: r.pattern_name,
+ color: r.color_name,
+ collection: r.collection,
+ type: r.product_type,
+ material,
+ composition: r.composition,
+ width: r.width,
+ length: r.roll_length,
+ image: r.image_url,
+ gallery: r.gallery_images || [],
+ productUrl: r.product_url,
+ description: r.description || r.ai_description || null,
+ tags: r.tags || [],
+ vendor: (r.vendor || 'Fromental').replace(/\s*Design$/i, ''),
+ price,
+ quote,
+ hex,
+ families: [...famSet],
+ styles,
+ aiTags,
+ imageState: r.image_url ? 'Has image' : 'No image',
+ enriched: !!r.ai_accepted_at,
+ createdAt: r.vendor_created_at || r.crawled_at,
+ };
+ });
+ // precompute priceBand once per product
+ for (const p of products) p.priceBand = priceBand(p);
- SNAP = { products, facets, count: products.length };
+ SNAP = { products, count: products.length };
LAST_REFRESH = new Date().toISOString();
- console.log(`[fromental] loaded ${products.length} products (${products.filter((p) => p.enriched).length} enriched)`);
+ console.log(`[fromental] loaded ${products.length} products (${products.filter((p) => p.enriched).length} enriched, source=${USE_JSONL ? 'jsonl' : 'pg'})`);
+}
+
+// ── filtered-set + facet aggregation (mode-agnostic; runs over the in-memory SNAP) ──
+// Each facet field is a multi-select OR within the field, AND across fields — exactly like
+// all.dw. When tallying a given field, that field's own filter is skipped (drill-down counts).
+function parseSet(v) { return new Set(String(v || '').split(',').map((s) => s.trim()).filter(Boolean)); }
+
+function applyFilters(products, f, skip) {
+ return products.filter((p) => {
+ if (f.q) {
+ const hay = [p.pattern, p.color, p.sku, p.type, p.collection].filter(Boolean).join(' ').toLowerCase();
+ if (!hay.includes(f.q)) return false;
+ }
+ if (skip !== 'type' && f.types.size && !f.types.has(p.type)) return false;
+ if (skip !== 'collection' && f.collections.size && !f.collections.has(p.collection)) return false;
+ if (skip !== 'color' && f.colors.size && !p.families.some((x) => f.colors.has(x))) return false;
+ if (skip !== 'style' && f.styles.size && !p.styles.some((x) => f.styles.has(x))) return false;
+ if (skip !== 'material' && f.materials.size && !f.materials.has(p.material)) return false;
+ if (skip !== 'price' && f.prices.size && !f.prices.has(p.priceBand)) return false;
+ if (skip !== 'tag' && f.tags.size && !p.aiTags.some((x) => f.tags.has(x))) return false;
+ if (skip !== 'image' && f.images.size && !f.images.has(p.imageState)) return false;
+ return true;
+ });
+}
+// tally: {value: count} over a set of products for a single-valued or array-valued key.
+function tally(products, keyFn) {
+ const m = new Map();
+ for (const p of products) {
+ const vals = keyFn(p);
+ for (const v of (Array.isArray(vals) ? vals : [vals])) {
+ if (v == null || v === '') continue;
+ m.set(v, (m.get(v) || 0) + 1);
+ }
+ }
+ // return a plain object (client sorts by count / order)
+ const out = {};
+ for (const [k, n] of [...m.entries()].sort((a, b) => b[1] - a[1])) out[k] = n;
+ return out;
+}
+
+function computeFacets(query) {
+ const f = {
+ q: String(query.q || '').trim().toLowerCase(),
+ types: parseSet(query.types), collections: parseSet(query.collections),
+ colors: parseSet(query.colors), styles: parseSet(query.styles),
+ materials: parseSet(query.materials), prices: parseSet(query.prices),
+ tags: parseSet(query.tags), images: parseSet(query.images),
+ };
+ const P = SNAP.products;
+ return {
+ type: tally(applyFilters(P, f, 'type'), (p) => p.type),
+ collection: tally(applyFilters(P, f, 'collection'), (p) => p.collection),
+ color: tally(applyFilters(P, f, 'color'), (p) => p.families),
+ style: tally(applyFilters(P, f, 'style'), (p) => p.styles),
+ material: tally(applyFilters(P, f, 'material'), (p) => p.material),
+ price_band: tally(applyFilters(P, f, 'price'), (p) => p.priceBand),
+ tag: tally(applyFilters(P, f, 'tag'), (p) => p.aiTags),
+ image_state: tally(applyFilters(P, f, 'image'), (p) => p.imageState),
+ family_order: FAMILY_ORDER,
+ family_swatch: FAMILY_SWATCH,
+ price_order: PRICE_ORDER,
+ image_order: IMAGE_ORDER,
+ matched: applyFilters(P, f).length,
+ total: P.length,
+ };
}
-// health probe BEFORE auth (deploy smoke + uptime monitors) — bare liveness only,
-// no catalog size/detail leaked to the unauthenticated probe (count stays behind auth via /api/meta)
+// health probe BEFORE auth (deploy smoke + uptime monitors) — bare liveness only.
app.get('/healthz', (_req, res) => res.json({ ok: true }));
app.use((req, res, next) => {
@@ -129,14 +274,15 @@ app.use(express.json({ limit: '2mb' }));
app.get('/api/meta', (_req, res) => res.json({
lastRefresh: LAST_REFRESH, intervalSec: REFRESH_INTERVAL_SEC,
count: SNAP.count, enriched: SNAP.products.filter((p) => p.enriched).length,
- now: new Date().toISOString(),
+ source: USE_JSONL ? 'jsonl' : 'pg', now: new Date().toISOString(),
}));
// light list — drop heavy fields the grid never renders
app.get('/api/products', (_req, res) => res.json({
count: SNAP.count,
- products: SNAP.products.map(({ gallery, description, ...p }) => p),
+ products: SNAP.products.map(({ gallery, description, composition, ...p }) => p),
}));
-app.get('/api/facets', (_req, res) => res.json(SNAP.facets));
+// facets recomputed from the current filtered set (query-param driven, like all.dw)
+app.get('/api/facets', (req, res) => res.json(computeFacets(req.query)));
app.get('/api/product/:sku', (req, res) => {
const p = SNAP.products.find((x) => x.sku === req.params.sku);
if (!p) return res.status(404).json({ error: 'not found' });
← d3c9132 jsonl-backed data layer: self-contained bundle for Kamatera
·
back to Fromental Internal
·
Populate collection + material facets from Fromental collect ee94c52 →