← back to Crezana Internal
Upgrade rail to all.dw feature-parity: active-chip row + collapsible facet sections (Type/Book/Color-family/Style/Material/Price/Tags/Image), type-ahead, cap/expand, color-family bucketing + style/tag explosion identical in pg + jsonl modes; sort incl Color(family) + dir; card-fields image-only toggle; density/text/date chips preserved
1ebbbb69d395e4c77e9ad98430b3579d467263ff · 2026-07-09 16:08:28 -0700 · Steve
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/index.htmlM server.js
Diff
commit 1ebbbb69d395e4c77e9ad98430b3579d467263ff
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 9 16:08:28 2026 -0700
Upgrade rail to all.dw feature-parity: active-chip row + collapsible facet sections (Type/Book/Color-family/Style/Material/Price/Tags/Image), type-ahead, cap/expand, color-family bucketing + style/tag explosion identical in pg + jsonl modes; sort incl Color(family) + dir; card-fields image-only toggle; density/text/date chips preserved
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/index.html | 339 ++++++++++++++++++++++++++++++++++++++++--------------
server.js | 171 +++++++++++++++++++++++----
2 files changed, 403 insertions(+), 107 deletions(-)
diff --git a/public/index.html b/public/index.html
index 684e1cd..ac82e67 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2,64 +2,116 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Crezana Design — Internal Line Viewer</title>
<style>
- :root{--bg:#0e0f11;--panel:#16181c;--stroke:#2a2d33;--txt:#e8e6e1;--mut:#8b8f96;--teal:#3fb8af;--scale:1;}
+ :root{--bg:#0e0f11;--card:#16181c;--panel:#16181c;--line:#2a2d33;--stroke:#2a2d33;--txt:#e8e6e1;--mut:#8b8f96;
+ --accent:#3fb8af;--teal:#3fb8af;--rail:290px;--cols:6;--fs:1;--scale:1;}
*{box-sizing:border-box}
body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--txt)}
- header{padding:14px 18px;border-bottom:1px solid var(--stroke);display:flex;align-items:center;gap:14px;position:sticky;top:0;background:var(--bg);z-index:5}
- header h1{font-size:16px;margin:0;letter-spacing:.14em;font-weight:600;text-transform:uppercase}
+ a{color:var(--accent)}
+ header{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;flex-wrap:wrap;
+ position:sticky;top:0;background:var(--bg);z-index:10}
+ header h1{font-size:16px;margin:0;letter-spacing:.14em;font-weight:600;text-transform:uppercase;display:flex;align-items:center;gap:8px}
header .meta{color:var(--mut);font-size:12px}
- .quote{margin-left:auto;color:var(--teal);border:1px solid var(--teal);padding:3px 10px;border-radius:12px;font-size:11px;letter-spacing:.1em}
- .layout{display:flex;align-items:flex-start}
- aside{width:290px;flex:0 0 290px;border-right:1px solid var(--stroke);height:calc(100vh - 51px);overflow:auto;position:sticky;top:51px;padding:12px}
- aside input[type=search]{width:100%;padding:8px 10px;background:var(--panel);border:1px solid var(--stroke);color:var(--txt);border-radius:8px;margin-bottom:10px}
- .controls{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
- .controls label{font-size:11px;color:var(--mut);display:flex;align-items:center;gap:8px;justify-content:space-between}
- .controls input[type=range]{flex:1}
- details{border:1px solid var(--stroke);border-radius:8px;margin-bottom:8px;background:var(--panel)}
- details>summary{cursor:pointer;padding:8px 10px;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);list-style:none}
- details>summary::-webkit-details-marker{display:none}
- details[open]>summary{color:var(--txt);border-bottom:1px solid var(--stroke)}
- .facet{display:flex;justify-content:space-between;padding:5px 10px;cursor:pointer;font-size:13px}
- .facet:hover{background:#1d2026}
- .facet.active{color:var(--teal)}
- .badge{background:#1d2026;color:var(--teal);border-radius:10px;padding:0 7px;font-size:11px;min-width:22px;text-align:center}
- main{flex:1;padding:14px}
- .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card,190px),1fr));gap:12px}
- .card{border:1px solid var(--stroke);border-radius:8px;overflow:hidden;background:var(--panel)}
+ header input[type=search]{flex:1;min-width:200px;padding:8px 10px;background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:8px}
+ .bar{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--mut);letter-spacing:.04em}
+ .bar select{background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:7px;padding:5px 7px;font-size:12px}
+ .bar input[type=range]{width:120px}
+ .quote{color:var(--teal);border:1px solid var(--teal);padding:3px 10px;border-radius:12px;font-size:11px;letter-spacing:.1em}
+ .dirbtn{background:var(--panel);border:1px solid var(--line);color:var(--accent);border-radius:7px;padding:5px 9px;cursor:pointer;font-family:inherit}
+ .dirbtn:hover{border-color:var(--accent)}
+ #railToggle{display:none}
+
+ /* ── layout: Amazon-style left rail + content ── */
+ .wrap{display:flex;align-items:flex-start}
+ aside{width:var(--rail);flex:0 0 var(--rail);position:sticky;top:57px;max-height:calc(100vh - 57px);
+ overflow-y:auto;padding:10px 10px 60px;border-right:1px solid var(--line)}
+
+ /* active-filter chip row */
+ .active{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
+ .afilter{background:#23211a;border:1px solid var(--accent);color:var(--txt);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(--line);color:var(--mut);border-radius:20px;padding:3px 11px;font-size:11px;cursor:pointer}
+ .clearall:hover{border-color:var(--accent);color:var(--txt)}
+
+ /* collapsible facet sections */
+ details.sec{border:1px solid var(--line);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:#0e0f11;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(--txt);border-radius:5px}
+ .fitem:hover{background:#1d2026}
+ .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:#0e0f11;font-weight:700}
+ .fitem.on{color:var(--accent)}
+ .fitem .swatch{width:12px;height:12px;flex:0 0 12px;border-radius:50%;border:1px solid #0006}
+ .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(--accent);font-size:11.5px;cursor:pointer;padding:3px 2px;display:inline-block}
+ .ffind{width:100%;padding:4px 8px;font-size:11.5px;border-radius:6px;margin:2px 0 5px;background:var(--panel);border:1px solid var(--line);color:var(--txt)}
+ /* Card-fields toggle chips (image-only wall = all off) */
+ .fieldchip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);border:1px solid var(--line);
+ border-radius:6px;padding:3px 8px;margin:2px 3px 2px 0;cursor:pointer;background:var(--panel)}
+ .fieldchip.on{color:var(--accent);border-color:var(--accent)}
+
+ main{flex:1;min-width:0;padding:14px}
+ .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px}
+ .card{border:1px solid var(--line);border-radius:6px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}
.card .imgwrap{aspect-ratio:1;background:#000;overflow:hidden}
.card img{width:100%;height:100%;object-fit:cover;display:block}
- .card .body{padding:8px}
- .card .nm{font-size:calc(13px*var(--scale));font-weight:600;line-height:1.25;margin-bottom:3px}
- .card .sub{font-size:calc(11px*var(--scale));color:var(--mut)}
- .card .when{font-size:calc(10px*var(--scale));color:var(--mut);margin-top:5px}
+ .card .body{padding:calc(8px*var(--fs))}
+ .card .nm{font-size:calc(13px*var(--fs)*var(--scale));font-weight:600;line-height:1.25;margin-bottom:3px;color:var(--txt)}
+ .card .sub{font-size:calc(11px*var(--fs)*var(--scale));color:var(--mut)}
+ .card .when{font-size:calc(10px*var(--fs)*var(--scale));color:var(--mut);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid #0006;vertical-align:middle;margin-right:5px}
.card .tags{margin-top:5px;display:flex;flex-wrap:wrap;gap:3px}
- .card .tag{font-size:calc(9px*var(--scale));background:#1d2026;color:var(--mut);padding:1px 6px;border-radius:8px}
+ .card .tag{font-size:calc(9px*var(--fs)*var(--scale));background:#1d2026;color:var(--mut);padding:1px 6px;border-radius:8px}
body.imgonly .card .body{display:none}
.empty{color:var(--mut);padding:40px;text-align:center}
+
+ @media(max-width:900px){
+ #railToggle{display:inline-block}
+ aside{position:fixed;left:0;top:57px;bottom:0;background:var(--bg);z-index:20;transform:translateX(-100%);transition:transform .2s}
+ aside.open{transform:none;box-shadow:6px 0 30px rgba(0,0,0,.5)}
+ }
</style></head><body>
<header>
- <h1>Crezana Design</h1>
+ <h1>Crezana Design <span class="quote">QUOTE-ONLY · FABRIC</span></h1>
+ <button class="dirbtn" id="railToggle">☰ Filters</button>
<span class="meta" id="meta">loading…</span>
- <span class="quote">QUOTE-ONLY · FABRIC</span>
+ <input type="search" id="q" placeholder="Search pattern / sku / collection / description…">
+ <span class="bar">Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="title">Title A→Z</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="collection">Book / Series</option>
+ <option value="color">Color (family)</option>
+ <option value="style">Style</option>
+ </select>
+ <button class="dirbtn" id="dir" title="Flip sort direction">▲</button>
+ </span>
+ <span class="bar">Density <input type="range" id="dens" min="2" max="14" value="6"> <span id="densN"></span></span>
+ <span class="bar">Text <input type="range" id="tscale" min="80" max="150" value="100"></span>
+ <label class="bar"><input type="checkbox" id="imgonly"> Image-only wall</label>
</header>
-<div class="layout">
- <aside>
- <input type="search" id="q" placeholder="Search pattern / sku / collection…">
- <div class="controls">
- <label>Sort
- <select id="sort">
- <option value="newest">Newest</option>
- <option value="title">Title A→Z</option>
- <option value="color">Color</option>
- <option value="style">Style</option>
- </select>
- </label>
- <label>Density <input type="range" id="density" min="120" max="340" value="190"></label>
- <label>Text size <input type="range" id="tscale" min="80" max="150" value="100"></label>
- <label><input type="checkbox" id="imgonly"> Image-only wall</label>
- </div>
- <div id="facets"></div>
+
+<div class="wrap">
+ <aside id="rail">
+ <input type="search" id="qRail" placeholder="Search…" style="width:100%;padding:7px 9px;background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:8px;margin-bottom:8px;display:none">
+ <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="collection" open><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="tags"><summary>Tags<span class="n" id="nTags"></span></summary><div class="fbody" id="tagsRow"></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>
@@ -68,77 +120,190 @@
</div>
<script>
const API = location.origin;
-let ALL=[], activeFacets={};
-let SORT = localStorage.getItem('crz_sort') || 'newest';
-const fmtDate = d => d ? new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
-function hue(hex){ if(!hex) return 999; const m=/^#?([0-9a-f]{6})$/i.exec(hex); if(!m) return 999; const n=parseInt(m[1],16), r=(n>>16&255)/255, g=(n>>8&255)/255, b=(n&255)/255; const mx=Math.max(r,g,b), mn=Math.min(r,g,b), d=mx-mn; if(!d) return -1; 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; return (h*60+360)%360; }
+let ALL = [], LAST_FACETS = {};
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
+const fmtDate = (d) => d ? new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
+
+/* ── persisted multi-select facet state (Sets, mirrored to localStorage) ── */
+const SETS = { types:'crz_types', collections:'crz_collections', colors:'crz_colors', styles:'crz_styles', materials:'crz_materials', prices:'crz_prices', tags:'crz_tags', images:'crz_images' };
+const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k)||'[]')); } catch { return new Set(); } };
+const state = {
+ q: '', sort: localStorage.getItem('crz_sort')||'newest', dir: localStorage.getItem('crz_dir')||'asc',
+ types:loadSet(SETS.types), collections:loadSet(SETS.collections), colors:loadSet(SETS.colors),
+ styles:loadSet(SETS.styles), materials:loadSet(SETS.materials), prices:loadSet(SETS.prices),
+ tags:loadSet(SETS.tags), images:loadSet(SETS.images),
+};
+function persistFacets(){ state.types=state.types; for(const [k,key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
+// per-row UI state: expanded? + type-ahead needle
+const rowUI = {
+ collectionRow:{expand:false,find:''}, colorRow:{expand:false,find:''}, styleRow:{expand:false,find:'',typeahead:true},
+ matRow:{expand:false,find:''}, priceRow:{expand:false,find:''}, tagsRow:{expand:false,find:'',typeahead:true},
+ typeRow:{expand:false,find:''}, imgRow:{expand:false,find:''},
+};
+
+/* ── card-field visibility toggles (all off = image-only texture wall) ── */
+const FIELDS = [['nm','Pattern'],['sub','Collection · SKU'],['dot','Color dot'],['tags','Tags'],['when','Date']];
+let fieldOn = (()=>{ try{ return JSON.parse(localStorage.getItem('crz_fields')); }catch{} return null; })() || {nm:1,sub:1,dot:1,tags:1,when:1};
+function renderFieldChips(){
+ const box=document.getElementById('fieldsRow'); box.innerHTML='';
+ for(const [k,label] of FIELDS){
+ const el=document.createElement('span'); el.className='fieldchip'+(fieldOn[k]?' on':'');
+ el.innerHTML=`<span class="box" style="width:11px;height:11px;border:1px solid var(--mut);border-radius:3px;display:inline-flex">${fieldOn[k]?'✓':''}</span>${label}`;
+ el.onclick=()=>{ fieldOn[k]=fieldOn[k]?0:1; localStorage.setItem('crz_fields',JSON.stringify(fieldOn)); renderFieldChips(); render(); };
+ box.appendChild(el);
+ }
+ document.getElementById('nFields').textContent = Object.values(fieldOn).filter(Boolean).length;
+}
+
+/* ── sort ── */
+function hue(hex){ if(!hex) return 999; const m=/^#?([0-9a-f]{6})$/i.exec(hex); if(!m) return 999; const n=parseInt(m[1],16),r=(n>>16&255)/255,g=(n>>8&255)/255,b=(n&255)/255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn; if(!d) return -1; 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; return (h*60+360)%360; }
+const FAMILY_ORDER = ['Red','Orange','Brown','Yellow','Gold','Green','Teal','Blue','Purple','Pink','Beige','Cream','Black','Gray','White','Multi'];
function sortRows(rows){
+ const str=(x)=>String(x||'');
const by={
newest:(a,b)=> new Date(b.crawled_at||0)-new Date(a.crawled_at||0),
- title:(a,b)=> (a.pattern_name||a.mfr_sku||'').localeCompare(b.pattern_name||b.mfr_sku||''),
- color:(a,b)=> hue(a.dominant_color_hex||a.color_hex)-hue(b.dominant_color_hex||b.color_hex),
- style:(a,b)=> ((a.ai_styles&&a.ai_styles[0])||'zz').localeCompare((b.ai_styles&&b.ai_styles[0])||'zz'),
+ title:(a,b)=> str(a.pattern_name||a.mfr_sku).localeCompare(str(b.pattern_name||b.mfr_sku)),
+ sku:(a,b)=> str(a.mfr_sku).localeCompare(str(b.mfr_sku)),
+ collection:(a,b)=> str(a.collection).localeCompare(str(b.collection)),
+ color:(a,b)=>{ const i=(x)=>{ const c=(x.colors||[])[0]; const k=c?FAMILY_ORDER.indexOf(c):-1; return k<0?99:k; }; return i(a)-i(b) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)); },
+ style:(a,b)=> str((a.styles||[])[0]||'zz').localeCompare(str((b.styles||[])[0]||'zz')),
};
- return rows.slice().sort(by[SORT]||by.newest);
+ const out = rows.slice().sort(by[state.sort]||by.newest);
+ return state.dir==='desc' ? out.reverse() : out;
}
+/* ── data load ── */
async function load(){
const [p,f] = await Promise.all([
fetch(API+'/api/products').then(r=>r.json()),
- fetch(API+'/api/facets').then(r=>r.json())
+ fetch(API+'/api/facets').then(r=>r.json()),
]);
ALL = p.products;
document.getElementById('meta').textContent = p.count+' images · '+new Set(ALL.map(x=>x.collection)).size+' collections';
+ LAST_FACETS = f;
+ renderFieldChips();
renderFacets(f);
render();
}
+
+/* ── facet list rows: checkbox + count + type-ahead + cap/expand ── (ported from all.dw) */
+function toggleSet(set,val){ set.has(val)?set.delete(val):set.add(val); applyFilterChange(); }
+function applyFilterChange(){ persistFacets(); renderFacets(LAST_FACETS); render(); }
+function facetList(rowId, items, set, opts={}){
+ const row=document.getElementById(rowId); row.innerHTML='';
+ const ui=rowUI[rowId]||{expand:false,find:''};
+ 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=document.querySelector(`#${rowId} .ffind`); if(f2){ f2.focus(); f2.setSelectionRange(f2.value.length,f2.value.length); } };
+ row.appendChild(find);
+ }
+ let entries=(items||[]).map(it=>[it.v,it.n,it.dot]);
+ if(opts.order) entries.sort((a,b)=>opts.order.indexOf(a[0])-opts.order.indexOf(b[0]));
+ if(ui.find) entries=entries.filter(([v])=>String(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'; row.appendChild(s); return; }
+ shown.forEach(([val,ct,dot])=>{
+ const el=document.createElement('div'); el.className='fitem'+(set.has(val)?' on':'')+(ct?'':' zero');
+ const sw = dot ? `<span class="swatch" style="background:${dot}"></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(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(LAST_FACETS); }; 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(LAST_FACETS); }; row.appendChild(el); }
+}
+
function renderFacets(f){
- const box=document.getElementById('facets'); box.innerHTML='';
- const groups=[['collection','Collection',f.collection],['styles','Style',f.styles],['tags','Tag',f.tags],['product_type','Type',f.product_type]];
- for(const [key,label,items] of groups){
- if(!items||!items.length) continue;
- const d=document.createElement('details'); if(key==='collection') d.open=true;
- d.innerHTML=`<summary>${label} (${items.length})</summary>`;
- for(const it of items){
- const row=document.createElement('div'); row.className='facet';
- row.innerHTML=`<span>${it.v}</span><span class="badge">${it.n}</span>`;
- row.onclick=()=>{ activeFacets[key]=activeFacets[key]===it.v?null:it.v; renderFacets(f); render(); };
- if(activeFacets[key]===it.v) row.classList.add('active');
- d.appendChild(row);
- }
- box.appendChild(d);
+ LAST_FACETS=f;
+ facetList('typeRow', f.product_type, state.types, {cap:6});
+ facetList('collectionRow', f.collection, state.collections, {cap:12});
+ facetList('colorRow', f.color, state.colors, {order:f.family_order||FAMILY_ORDER, cap:16});
+ facetList('styleRow', f.style, state.styles, {cap:12});
+ facetList('matRow', f.material, state.materials, {cap:12});
+ facetList('priceRow', f.price_band, state.prices, {cap:4});
+ facetList('tagsRow', f.tags, state.tags, {cap:14});
+ facetList('imgRow', f.image_state, state.images, {cap:4});
+ renderActive();
+}
+
+/* ── active-filter chip row + per-section count badges ── */
+const DIMS = [['Type','types','nType'],['Book','collections','nCollection'],['Color','colors','nColor'],['Style','styles','nStyle'],['Material','materials','nMaterial'],['Price','prices','nPrice'],['Tag','tags','nTags'],['Image','images','nImage']];
+function renderActive(){
+ const row=document.getElementById('activeRow'); row.innerHTML='';
+ for(const [,k,badgeId] of DIMS){ const b=document.getElementById(badgeId); if(b){ b.textContent=state[k].size||''; b.style.display=state[k].size?'':'none'; } }
+ let n=0;
+ for(const [label,k] of DIMS){
+ state[k].forEach((v)=>{
+ n++;
+ const el=document.createElement('span'); el.className='afilter';
+ el.innerHTML=`<b>${label}:</b> ${esc(v)} ✕`;
+ el.onclick=()=>{ state[k].delete(v); applyFilterChange(); };
+ row.appendChild(el);
+ });
}
+ if(n){ const c=document.createElement('button'); c.className='clearall'; c.textContent='Clear all'; c.onclick=()=>{ DIMS.forEach(([,k])=>state[k].clear()); applyFilterChange(); }; row.appendChild(c); }
}
+
+/* ── filter predicate (client-side, all rows in RAM) ── */
function matches(p){
- const q=document.getElementById('q').value.trim().toLowerCase();
+ const q=state.q;
if(q){ const hay=[p.pattern_name,p.mfr_sku,p.collection,p.description].filter(Boolean).join(' ').toLowerCase(); if(!hay.includes(q)) return false; }
- if(activeFacets.collection && p.collection!==activeFacets.collection) return false;
- if(activeFacets.product_type && p.product_type!==activeFacets.product_type) return false;
- if(activeFacets.styles && !(p.ai_styles||[]).includes(activeFacets.styles)) return false;
- if(activeFacets.tags && !(p.ai_tags||[]).includes(activeFacets.tags)) return false;
+ if(state.types.size && !state.types.has(p.product_type)) return false;
+ if(state.collections.size && !state.collections.has(p.collection)) return false;
+ if(state.colors.size && !(p.colors||[]).some(c=>state.colors.has(c))) return false;
+ if(state.styles.size && !(p.styles||[]).some(s=>state.styles.has(s))) return false;
+ if(state.materials.size && !(p.materials||[]).some(m=>state.materials.has(m))) return false;
+ if(state.prices.size && !state.prices.has(p.price_band)) return false;
+ if(state.tags.size && !(p.tags||[]).some(t=>state.tags.has(t))) return false;
+ if(state.images.size && !state.images.has(p.image_state)) return false;
return true;
}
+
function render(){
const grid=document.getElementById('grid'); const rows=sortRows(ALL.filter(matches));
document.getElementById('empty').style.display=rows.length?'none':'block';
grid.innerHTML = rows.map(p=>{
const hex=p.dominant_color_hex||p.color_hex;
- const tags=(p.ai_styles||[]).concat(p.ai_tags||[]).slice(0,4);
+ const tags=(p.styles||[]).concat(p.tags||[]).slice(0,4);
+ const body = fieldOn.nm||fieldOn.sub||fieldOn.tags||fieldOn.when ? `<div class="body">
+ ${fieldOn.nm?`<div class="nm">${esc(p.pattern_name||p.mfr_sku)}</div>`:''}
+ ${fieldOn.sub?`<div class="sub">${fieldOn.dot&&hex?`<span class="dot" style="background:${esc(hex)}"></span>`:''}${esc(p.collection||'')} · ${esc(p.mfr_sku)}</div>`:''}
+ ${fieldOn.tags&&tags.length?`<div class="tags">${tags.map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>`:''}
+ ${fieldOn.when?`<div class="when" title="${esc(p.crawled_at||'')}">🕓 ${fmtDate(p.crawled_at)}</div>`:''}
+ </div>`:'';
return `<div class="card">
- <div class="imgwrap"><img loading="lazy" src="${p.image_url}" alt="${p.pattern_name||''}"></div>
- <div class="body">
- <div class="nm">${p.pattern_name||p.mfr_sku}</div>
- <div class="sub">${hex?`<span class="dot" style="background:${hex}"></span>`:''}${p.collection||''} · ${p.mfr_sku}</div>
- ${tags.length?`<div class="tags">${tags.map(t=>`<span class="tag">${t}</span>`).join('')}</div>`:''}
- <div class="when" title="${p.crawled_at||''}">🕓 ${fmtDate(p.crawled_at)}</div>
- </div></div>`;
+ <div class="imgwrap"><img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.pattern_name||'')}"></div>
+ ${body}</div>`;
}).join('');
}
-document.getElementById('q').oninput=render;
-const sortEl=document.getElementById('sort'); sortEl.value=SORT;
-sortEl.onchange=e=>{ SORT=e.target.value; localStorage.setItem('crz_sort',SORT); render(); };
-document.getElementById('density').oninput=e=>document.documentElement.style.setProperty('--card',e.target.value+'px');
-document.getElementById('tscale').oninput=e=>document.documentElement.style.setProperty('--scale',(e.target.value/100));
-document.getElementById('imgonly').onchange=e=>document.body.classList.toggle('imgonly',e.target.checked);
+
+/* ── top-bar wiring ── */
+const qEl=document.getElementById('q');
+qEl.oninput=()=>{ state.q=qEl.value.trim().toLowerCase(); render(); };
+const sortEl=document.getElementById('sort'); sortEl.value=state.sort;
+sortEl.onchange=e=>{ state.sort=e.target.value; localStorage.setItem('crz_sort',state.sort); render(); };
+const dirBtn=document.getElementById('dir');
+const drawDir=()=>{ dirBtn.textContent = state.dir==='desc'?'▼':'▲'; };
+drawDir();
+dirBtn.onclick=()=>{ state.dir=state.dir==='desc'?'asc':'desc'; localStorage.setItem('crz_dir',state.dir); drawDir(); render(); };
+const dens=document.getElementById('dens'), densN=document.getElementById('densN');
+dens.value=localStorage.getItem('crz_cols')||6;
+const setCols=()=>{ document.documentElement.style.setProperty('--cols',dens.value); densN.textContent=dens.value; document.documentElement.style.setProperty('--fs', Math.max(.62,Math.min(1.15, 6/dens.value)).toFixed(2)); };
+setCols();
+dens.oninput=()=>{ setCols(); localStorage.setItem('crz_cols',dens.value); };
+const tscale=document.getElementById('tscale');
+tscale.value=localStorage.getItem('crz_tscale')||100;
+document.documentElement.style.setProperty('--scale',(tscale.value/100));
+tscale.oninput=e=>{ document.documentElement.style.setProperty('--scale',(e.target.value/100)); localStorage.setItem('crz_tscale',e.target.value); };
+const imgonlyEl=document.getElementById('imgonly');
+imgonlyEl.checked = localStorage.getItem('crz_imgonly')==='1';
+document.body.classList.toggle('imgonly',imgonlyEl.checked);
+imgonlyEl.onchange=e=>{ document.body.classList.toggle('imgonly',e.target.checked); localStorage.setItem('crz_imgonly',e.target.checked?'1':'0'); };
+document.getElementById('railToggle').onclick=()=>document.getElementById('rail').classList.toggle('open');
+
load();
</script></body></html>
diff --git a/server.js b/server.js
index c401757..3777dbf 100644
--- a/server.js
+++ b/server.js
@@ -1,11 +1,15 @@
-// Crezana internal line viewer — basic-auth, reads crezana_catalog. Port 10072.
+// Crezana internal line viewer — basic-auth, reads crezana_catalog. Port 10072 (local) / 10106 (Kamatera).
// Internal curation surface per internal-line-viewer spec. QUOTE-ONLY, product_type Fabric.
+// Rail upgraded to full feature-parity with all.designerwallcoverings.com's rail
+// (active-filter chip row + collapsible facet sections w/ counts, type-ahead, cap/expand,
+// color-FAMILY bucketing, style/tag explosion). Single-vendor line → NO Vendor facet.
//
// AUTO-DETECT data source: a self-contained jsonl bundle (no DB — for Kamatera) or
-// live Postgres (local). In BOTH modes the catalog is loaded ONCE into memory and
-// every endpoint is served from that array — the client does its own sort/filter,
-// so /api/products returns raw rows and /api/facets returns {v,n} lists identical
-// to the previous SQL output. jsonl mode when DATA_SOURCE=jsonl OR the bundle exists.
+// live Postgres (local). In BOTH modes the catalog is loaded ONCE into memory, each row is
+// ENRICHED once (color families from hex/ai_colors, style/tag/material explosion, image_state),
+// and every endpoint is served from that array. Color-family bucketing + explosion are
+// IDENTICAL in both modes (they run on the in-memory rows, never in SQL). jsonl mode when
+// DATA_SOURCE=jsonl OR the bundle exists — and in jsonl mode the pg pool is NEVER required.
const express = require('express');
const basicAuth = require('basic-auth');
const path = require('path');
@@ -19,6 +23,7 @@ const app = express();
// ---- Basic auth (admin / DW2024!) ----
app.use((req, res, next) => {
+ if (req.path === '/api/health') return next();
const cred = basicAuth(req);
if (!cred || cred.name !== 'admin' || cred.pass !== 'DW2024!') {
res.set('WWW-Authenticate', 'Basic realm="Crezana Internal"');
@@ -29,11 +34,133 @@ app.use((req, res, next) => {
app.use(express.static(__dirname + '/public'));
+// ─── shared enrichment (identical in jsonl + pg modes — runs on in-memory rows) ──────────
+// Color FAMILIES: map every hex in color_hex/dominant_color_hex/ai_colors into a hue bucket,
+// family-ordered. Also fold in ai_colors *names* that are already family words.
+const FAMILY_ORDER = ['Red', 'Orange', 'Brown', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Beige', 'Cream', 'Black', 'Gray', 'White', 'Multi'];
+// swatch color for each family dot (front-end reads facet.dot)
+const FAMILY_HEX = {
+ Red: '#c0392b', Orange: '#e67e22', Brown: '#8b5a2b', Yellow: '#f1c40f', Gold: '#c9a227',
+ Green: '#27ae60', Teal: '#1abc9c', Blue: '#2980b9', Purple: '#8e44ad', Pink: '#e84393',
+ Beige: '#d2b48c', Cream: '#f5f0e1', Black: '#1a1a1a', Gray: '#8b8f96', White: '#f5f5f5', Multi: 'linear-gradient(90deg,#c0392b,#27ae60,#2980b9)',
+};
+// map a family word (from ai_colors names) → canonical family bucket
+const NAME_TO_FAMILY = {
+ red: 'Red', crimson: 'Red', maroon: 'Red', burgundy: 'Red', rust: 'Red', coral: 'Red', terracotta: 'Orange',
+ orange: 'Orange', peach: 'Orange', apricot: 'Orange', amber: 'Orange',
+ brown: 'Brown', tan: 'Brown', taupe: 'Brown', chocolate: 'Brown', khaki: 'Brown', 'light brown': 'Brown', 'dark brown': 'Brown', mocha: 'Brown', umber: 'Brown', sienna: 'Brown', chestnut: 'Brown',
+ yellow: 'Yellow', mustard: 'Yellow', lemon: 'Yellow',
+ gold: 'Gold', golden: 'Gold', brass: 'Gold', bronze: 'Gold',
+ green: 'Green', olive: 'Green', sage: 'Green', mint: 'Green', emerald: 'Green', forest: 'Green', lime: 'Green', 'light green': 'Green', 'dark green': 'Green',
+ teal: 'Teal', turquoise: 'Teal', aqua: 'Teal', cyan: 'Teal',
+ blue: 'Blue', navy: 'Blue', 'light blue': 'Blue', 'dark blue': 'Blue', 'sky blue': 'Blue', indigo: 'Blue', cobalt: 'Blue', denim: 'Blue',
+ purple: 'Purple', violet: 'Purple', lavender: 'Purple', plum: 'Purple', mauve: 'Purple', lilac: 'Purple',
+ pink: 'Pink', rose: 'Pink', blush: 'Pink', magenta: 'Pink', fuchsia: 'Pink',
+ beige: 'Beige', sand: 'Beige', ecru: 'Beige', 'neutral beige': 'Beige', 'light beige': 'Beige', 'warm brown': 'Brown', neutral: 'Beige', natural: 'Beige', wheat: 'Beige',
+ cream: 'Cream', ivory: 'Cream', 'off-white': 'Cream', 'off white': 'Cream', vanilla: 'Cream', linen: 'Cream',
+ black: 'Black', charcoal: 'Black', ebony: 'Black', onyx: 'Black',
+ gray: 'Gray', grey: 'Gray', silver: 'Gray', 'light gray': 'Gray', 'dark gray': 'Gray', 'light grey': 'Gray', 'dark grey': 'Gray', slate: 'Gray', graphite: 'Gray', pewter: 'Gray',
+ white: 'White', 'pure white': 'White', snow: 'White',
+};
+function hexFamily(hex) {
+ if (!hex) return null;
+ const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
+ if (!m) return null;
+ const n = parseInt(m[1], 16);
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+ const light = (mx + mn) / 2 / 255; // 0..1
+ // near-neutral (low saturation) → white / cream / beige / gray / black by lightness
+ if (d < 26) {
+ if (light > 0.90) return 'White';
+ if (light > 0.80) return 'Cream';
+ if (light > 0.16) return 'Gray';
+ return 'Black';
+ }
+ // hue in degrees
+ 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 = (h * 60 + 360) % 360;
+ // low-light warm hues read as brown; very light warm read as beige/cream
+ if (h < 15 || h >= 345) return 'Red';
+ if (h < 45) { // orange band → brown when dark, orange when bright
+ if (light < 0.42) return 'Brown';
+ if (light > 0.82 && d < 90) return 'Beige';
+ return 'Orange';
+ }
+ if (h < 65) { if (light < 0.42) return 'Brown'; return light > 0.78 ? 'Gold' : 'Yellow'; }
+ if (h < 80) return 'Gold';
+ 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 rowColorFamilies(r) {
+ const fams = new Set();
+ // from ai_colors: prefer the name if it's a known family word, else bucket its hex
+ let ac = r.ai_colors;
+ if (typeof ac === 'string') { try { ac = JSON.parse(ac); } catch { ac = []; } }
+ if (Array.isArray(ac)) {
+ for (const c of ac) {
+ const nm = (c && c.name ? String(c.name) : '').toLowerCase().trim();
+ const byName = NAME_TO_FAMILY[nm];
+ if (byName) { fams.add(byName); continue; }
+ const byHex = hexFamily(c && c.hex);
+ if (byHex) fams.add(byHex);
+ }
+ }
+ // fold in the primary hex(es) too
+ for (const h of [r.dominant_color_hex, r.color_hex]) { const f = hexFamily(h); if (f) fams.add(f); }
+ return [...fams].sort((a, b) => FAMILY_ORDER.indexOf(a) - FAMILY_ORDER.indexOf(b));
+}
+// Material facet — Crezana data carries NO material/composition column, so derive fabric-content
+// terms out of ai_tags + ai_styles (Woven, Linen, Cotton, Silk, Velvet, Embroidered, …).
+const MATERIAL_TERMS = {
+ woven: 'Woven', linen: 'Linen', cotton: 'Cotton', silk: 'Silk', velvet: 'Velvet', wool: 'Wool',
+ embroidered: 'Embroidered', embroidery: 'Embroidered', jute: 'Jute', burlap: 'Burlap', leather: 'Leather',
+ suede: 'Suede', chenille: 'Chenille', jacquard: 'Jacquard', boucle: 'Bouclé', 'bouclé': 'Bouclé',
+ grasscloth: 'Grasscloth', sisal: 'Sisal', canvas: 'Canvas', tweed: 'Tweed', damask: 'Damask',
+ metallic: 'Metallic', beaded: 'Beaded', 'faux leather': 'Faux Leather',
+};
+function rowMaterials(r) {
+ const out = new Set();
+ const feed = [].concat(explode(r.ai_tags), explode(r.ai_styles));
+ for (const t of feed) { const m = MATERIAL_TERMS[String(t).toLowerCase().trim()]; if (m) out.add(m); }
+ return [...out];
+}
+function explode(v) {
+ if (typeof v === 'string') { try { v = JSON.parse(v); } catch { return []; } }
+ if (!Array.isArray(v)) return [];
+ // title-case-fold so "elegant"/"Elegant" collapse to one value
+ return v.filter((x) => x != null && x !== '').map((x) => String(x));
+}
+const titleCase = (s) => String(s).replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
+function foldList(v) {
+ const seen = new Map();
+ for (const raw of explode(v)) { const k = titleCase(raw); if (!seen.has(k)) seen.set(k, k); }
+ return [...seen.values()];
+}
+function enrich(r) {
+ r.colors = rowColorFamilies(r);
+ r.styles = foldList(r.ai_styles);
+ r.tags = foldList(r.ai_tags);
+ r.materials = rowMaterials(r);
+ r.image_state = (r.image_url && String(r.image_url).trim()) ? 'Has image' : 'No image';
+ // QUOTE-ONLY line → single price band
+ r.price_band = 'Quote-only';
+ return r;
+}
+
// ---- in-memory catalog (jsonl bundle OR live Postgres) --------------------
let ROWS = [];
async function load() {
if (USE_JSONL) {
+ // HARD RAIL: jsonl mode NEVER touches a DB — no pool is even required here.
ROWS = fs.readFileSync(JSONL, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
} else {
const { Pool } = require('pg');
@@ -47,36 +174,40 @@ async function load() {
await pool.end();
ROWS = rows;
}
+ ROWS.forEach(enrich);
console.log(`[crezana] loaded ${ROWS.length} products (source=${USE_JSONL ? 'jsonl' : 'pg'})`);
}
-// Facet counts for the collapsible field tabs — {v,n} lists, count desc.
-// Reproduces the previous SQL facets exactly (scalar col facet + jsonb-array facet).
+// Facet counts for the collapsible field sections — {v,n[,dot]} lists, count desc.
+// Single-vendor line → NO vendor facet. Color is family-bucketed & family-ordered w/ swatch.
function facets() {
const scalar = (col) => {
const m = new Map();
for (const r of ROWS) { const v = r[col]; if (v == null || v === '') continue; m.set(v, (m.get(v) || 0) + 1); }
return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([v, n]) => ({ v, n }));
};
- const jsonArr = (col) => {
+ const listCol = (col, opts = {}) => { // col already holds an array on each enriched row
const m = new Map();
- for (const r of ROWS) {
- let arr = r[col];
- if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch { arr = []; } }
- if (!Array.isArray(arr)) continue;
- for (const val of arr) { if (val == null || val === '') continue; m.set(val, (m.get(val) || 0) + 1); }
- }
- return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([v, n]) => ({ v, n }));
+ for (const r of ROWS) { for (const val of (r[col] || [])) { if (val == null || val === '') continue; m.set(val, (m.get(val) || 0) + 1); } }
+ let e = [...m.entries()];
+ if (opts.order) e.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]));
+ else e.sort((a, b) => b[1] - a[1]);
+ return e.map(([v, n]) => ({ v, n, ...(opts.dot ? { dot: FAMILY_HEX[v] || '' } : {}) }));
};
return {
- collection: scalar('collection'),
product_type: scalar('product_type'),
- styles: jsonArr('ai_styles'),
- tags: jsonArr('ai_tags'),
+ collection: scalar('collection'),
+ color: listCol('colors', { order: FAMILY_ORDER, dot: true }),
+ style: listCol('styles'),
+ material: listCol('materials'),
+ price_band: [{ v: 'Quote-only', n: ROWS.length }],
+ tags: listCol('tags'),
+ image_state: scalar('image_state'),
+ family_order: FAMILY_ORDER,
};
}
-// Full products list (raw rows — the client sorts/filters/renders).
+// Full products list (raw enriched rows — the client sorts/filters/renders).
app.get('/api/products', (req, res) => {
res.json({ count: ROWS.length, products: ROWS });
});
@@ -86,7 +217,7 @@ app.get('/api/facets', (req, res) => {
catch (e) { res.status(500).json({ error: e.message }); }
});
-app.get('/api/health', (req, res) => res.json({ ok: true, service: 'crezana-internal' }));
+app.get('/api/health', (req, res) => res.json({ ok: true, service: 'crezana-internal', count: ROWS.length, source: USE_JSONL ? 'jsonl' : 'pg' }));
load().then(() => {
app.listen(PORT, () => console.log(`crezana-internal on :${PORT} (source=${USE_JSONL ? 'jsonl' : 'pg'})`));
← d05fb36 jsonl-backed data layer: self-contained bundle for Kamatera
·
back to Crezana Internal
·
Show DWCZ- DW series SKU instead of raw CREZ- mfr hash on ca ff668cb →