← back to Muralsource Internal
public/index.html
450 lines
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mural Source · Catalog · dw_unified</title>
<style>
:root{--cols:6;--fs:1;--bg:#0c0d12;--panel:#15171f;--card:#12141b;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
--new:#f59e0b;--pub:#34d399;--unpub:#94a3b8;--arch:#f87171;--ship:#60a5fa}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.2px}
.pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px}
.muted{color:var(--mut)}
select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
.ctl{display:flex;align-items:center;gap:7px}
input[type=range]{accent-color:var(--acc)}
.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;text-decoration:none}
.btn:hover{border-color:var(--acc)}
/* ── left panel: filters + field tables, ALL collapsed on load ── */
#layout{display:flex;align-items:flex-start}
aside{position:sticky;width:230px;min-width:230px;max-height:calc(100vh - 52px);overflow:auto;
padding:10px 10px 28px;border-right:1px solid var(--line);background:#0e0f15}
aside input[type=text]{width:100%;margin-bottom:7px;font-size:12.5px;padding:5px 8px}
aside details{border-bottom:1px solid var(--line)}
aside summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;padding:8px 4px;
font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.6px;user-select:none}
aside summary::-webkit-details-marker{display:none}
aside summary::before{content:'▸';font-size:9px;color:var(--mut);transition:transform .12s}
aside details[open] summary::before{transform:rotate(90deg)}
aside summary:hover{color:var(--ink)}
aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-spacing:0;font-weight:600;
max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fbody{padding:2px 0 8px}
.frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;
cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none}
.frow:hover{background:var(--panel);color:var(--ink)}
.frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
.frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
.frow .dot{width:10px;height:10px;border-radius:50%;border:1px solid #0008;display:inline-block;flex:none}
.frow input[type=checkbox]{accent-color:var(--acc);margin:0 2px 0 0}
.fmore{padding:3px 8px;font-size:11px;color:var(--mut);font-style:italic}
#clearf{display:none;margin-top:10px;width:100%}
main{flex:1;min-width:0}
/* mobile: hamburger + full-screen filter drawer (sidebar no longer stacks above the grid) */
#burger{display:none;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;font-weight:600;white-space:nowrap}
#fclose{display:none}
@media(max-width:880px){
#layout{display:block}
header{padding:8px 10px;gap:8px}
#burger{display:inline-flex;align-items:center;gap:6px}
.ctl:has(#density){display:none} /* grid is fixed 2-up on phones — slider is noise */
/* top:0!important beats the inline style.top the desktop sticky-layout JS sets */
aside{display:none;position:fixed;inset:0;top:0!important;z-index:40;width:auto;min-width:0;max-height:none!important;
overflow:auto;border-right:0;padding:12px 12px 40px;background:var(--bg)}
aside.open-mobile{display:block}
#fclose{display:block;position:sticky;top:0;z-index:2;width:100%;background:var(--acc);color:#062a24;border:none;border-radius:8px;padding:11px;font-size:14px;font-weight:700;cursor:pointer;margin-bottom:10px}
.grid:not(.list){grid-template-columns:repeat(2,1fr)}
}
/* ── grid — tight, 1pt stroke edges; text scales with density via --fs ── */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px;padding:10px 12px 60px}
.card{background:var(--card);border:1px solid var(--line);border-radius:2px;overflow:hidden;display:flex;flex-direction:column;position:relative;cursor:pointer}
.card:hover{border-color:#414a5e}
.imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
.imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center;display:block}
.imgwrap img.roomimg{position:absolute;inset:0;opacity:0;transition:opacity .18s ease}
.imgwrap:hover img.roomimg{opacity:1}
.roombadge{position:absolute;top:6px;right:6px;z-index:2;background:#000a;color:#cbd5e1;font-size:9px;font-weight:700;border-radius:4px;padding:1px 6px;letter-spacing:.3px}
.imgwrap.noimg{background:repeating-linear-gradient(45deg,#0a0b0f,#0a0b0f 8px,#101218 8px,#101218 16px)}
.imgwrap.noimg::after{content:'🚫 no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5b6373;font-size:11px;font-weight:600;letter-spacing:.3px}
.card .b{padding:calc(6px*var(--fs)) calc(8px*var(--fs)) calc(7px*var(--fs));display:flex;flex-direction:column;gap:calc(3px*var(--fs))}
.vend{font-size:calc(10px*var(--fs));color:var(--mut);font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ttl{font-weight:600;font-size:calc(12.5px*var(--fs));line-height:1.3;letter-spacing:-.1px}
.sku{font-family:ui-monospace,Menlo,monospace;font-size:calc(11px*var(--fs));color:var(--mut)}
/* field visibility toggles (grid view only — list view columns stay intact) */
body.f-off-vend .grid:not(.list) .vend{display:none}
body.f-off-ttl .grid:not(.list) .ttl{display:none}
body.f-off-chips .grid:not(.list) .chips{display:none}
body.imgonly .grid:not(.list) .card .b{display:none}
/* ── chips (minimal) ── */
.chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
.chip{font-size:calc(10px*var(--fs));font-weight:600;border-radius:999px;padding:1px calc(8px*var(--fs));letter-spacing:.2px;white-space:nowrap;border:1px solid transparent}
.chip.cl{border-color:transparent;color:var(--mut);background:transparent;display:inline-flex;align-items:center;gap:5px;padding-left:0}
.chip.cl .dot{width:calc(9px*var(--fs));height:calc(9px*var(--fs));border-radius:50%;border:1px solid #0008;display:inline-block}
.chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
.chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
/* ── collapsed detail drawer (direct child of .card so list-mode span works) ── */
.info{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);padding:8px 8px 9px;cursor:default}
.card.info-open .info{display:flex}
.kv{display:grid;grid-template-columns:88px 1fr;gap:4px 10px;font-size:11.5px;align-items:baseline}
.kv .k{color:var(--mut)}
.kv .v{text-align:left;word-break:break-word;font-weight:500}
.lnk{color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px}
.lnk:hover{background:var(--panel)}
.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
#toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(20px);max-width:min(560px,92vw);background:#1b1e24;color:var(--ink,#e8e6e1);border:1px solid var(--line,#2a2d33);border-radius:8px;padding:10px 16px;font-size:13px;box-shadow:0 8px 30px #0008;opacity:0;pointer-events:none;transition:.25s;z-index:60}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.ok{border-color:#2a6a4a}#toast.err{border-color:#7a2a2a}
.act{font-size:11px;font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer}
.act:hover{border-color:var(--acc)}
#more{display:block;margin:8px auto 60px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
#empty{padding:60px;text-align:center;color:var(--mut)}
/* ── List view ── aligned CSS-grid rows (.b uses display:contents so its
children become columns of the card grid). Fixed type sizes (1 column —
density/font scaling is a grid-view concern). */
:root{--listcols:42px 105px minmax(130px,1.4fr) minmax(90px,1fr) 92px minmax(105px,1fr) 112px minmax(90px,1fr) 64px 64px 84px 92px}
.grid.list{grid-template-columns:1fr;gap:4px;padding:4px 12px 60px}
.grid.list .card{
display:grid;align-items:center;border-radius:2px;
grid-template-columns:var(--listcols);
column-gap:12px;min-height:52px;padding:0 12px 0 8px;
}
.grid.list .card>.b{display:contents}
.grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:3px;overflow:hidden;margin:5px 0}
.grid.list .imgwrap.noimg::after{font-size:8px}
.grid.list .roombadge{display:none}
.grid.list .vend{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.grid.list .ttl{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
.grid.list .chips{flex-wrap:nowrap;gap:6px;overflow:hidden}
.grid.list .chip{font-size:10px;padding:1px 8px}
.grid.list .chips .chip.toggle{display:none}
.grid.list .lc{display:block;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.grid.list .lsku,.grid.list .ldw{display:block;font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.grid.list .info{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
.lsku,.ldw,.lc{display:none}
/* sortable column header — same template as rows, sticky under the main header */
#listhead{display:none;position:sticky;z-index:8;background:#0e0f15;border-bottom:1px solid var(--line);
grid-template-columns:var(--listcols);column-gap:12px;padding:6px 24px 6px 20px;align-items:center}
body.listmode #listhead{display:grid}
#listhead .lh{font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.4px;
cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:none}
#listhead .lh:hover{color:var(--ink)}
#listhead .lh.on{color:var(--acc)}
/* ── corner refresh countdown pill ── */
.muralsource-refresh-pill{position:fixed;right:14px;bottom:14px;z-index:25;display:flex;align-items:center;gap:7px;
background:#11131bF2;border:1px solid var(--line);border-radius:999px;padding:6px 13px;font-size:11.5px;
color:var(--mut);backdrop-filter:blur(8px);cursor:default;user-select:none}
.muralsource-refresh-pill .rp-dot{width:8px;height:8px;border-radius:50%;background:var(--pub)}
.muralsource-refresh-pill.rp-live .rp-dot{background:var(--new);animation:rppulse 1s ease-in-out infinite}
@keyframes rppulse{0%,100%{opacity:1}50%{opacity:.25}}
</style></head>
<body>
<header>
<h1>Mural Source · Catalog</h1>
<button id="burger" type="button" aria-label="Open filters">☰ Filters</button>
<span class="pill" id="total">…</span>
<span class="muted" id="meta"></span>
<div class="ctl"><label class="muted">Sort</label>
<select id="sort">
<option value="newest">Newest</option>
<option value="color">Color</option>
<option value="style">Style</option>
<option value="pattern">Pattern A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="title">Title A→Z</option>
<option value="book">Book</option>
</select></div>
<div class="ctl"><label class="muted">Density</label>
<input type="range" id="density" min="3" max="20" step="1"></div>
<button class="btn" id="view">▤ List view</button>
<a class="btn" href="/curate" title="bulk-select candidates for the Phillipe Romano private label">✓ Select for PR</a>
</header>
<div id="layout">
<aside aria-label="filters">
<button id="fclose" type="button">✕ Done — show products</button>
<input type="text" id="q" placeholder="Search pattern / sku / color…">
<input type="text" id="pattern" list="patternlist" placeholder="Pattern type-ahead…" title="type-ahead over every pattern in the catalog">
<datalist id="patternlist"></datalist>
<details id="fieldsbox"><summary>Card fields</summary>
<div class="fbody">
<label class="frow"><span class="fl"><input type="checkbox" data-fv="vend" checked>Book · series</span></label>
<label class="frow"><span class="fl"><input type="checkbox" data-fv="ttl" checked>Title / color</span></label>
<label class="frow"><span class="fl"><input type="checkbox" data-fv="chips" checked>Chips · details</span></label>
</div>
</details>
<div id="facetbox"><!-- one collapsed <details> table per data field --></div>
<button class="btn" id="clearf">✕ Clear filters</button>
</aside>
<main>
<div id="listhead" aria-label="sortable columns"></div>
<div class="grid" id="grid"></div>
<button id="more" style="display:none">Load more</button>
<div id="empty" style="display:none">No products match.</div>
</main>
</div>
<script>
const $=s=>document.querySelector(s);
const grid=$('#grid');
let ALL=[], FACETS=null, VIEW=[], shown=0;
const PAGE=120;
const BUCKET_HEX={white:'#f4f4f2',grey:'#9aa0aa',black:'#1c1e24',pink:'#f3a8c0',red:'#d0453e',orange:'#e08a3c',
brown:'#8a6242',gold:'#c9a75a',green:'#5f8f5c',blue:'#4f7fb5',purple:'#8a6fb5'};
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
// ── every data field is a left-panel table: collapsed on load, click value = filter ──
const FIELDS=[
['book','Books'],['style','Styles'],['color_bucket','Colors'],['series','Patterns'],['color','Colorways'],
['material','Material'],['width','Width'],['length','Length'],['repeat','Repeat'],['match','Match'],
];
const FIL={}; // field -> active value ('' = off)
FIELDS.forEach(([k])=>FIL[k]='');
let FCOUNTS={}; // field -> [[value,count]…] computed once from ALL
const FCAP=40; // rows shown per table (top-by-count)
// persisted prefs (CLAUDE.md: sort + density persist in localStorage)
$('#sort').value=localStorage.getItem('muralsource.sort')||'newest';
// ── density also drives type scale: fewer/larger cards → bigger type, 20/row → smallest ──
function setDensity(v){
v=Number(v);
const fs=Math.max(.5,Math.min(1.2, v<=6 ? 1+(6-v)*.06 : 1-(v-6)*.042));
document.documentElement.style.setProperty('--cols',v);
document.documentElement.style.setProperty('--fs',fs.toFixed(3));
}
const dens=localStorage.getItem('muralsource.density')||'6';
$('#density').value=dens; setDensity(dens);
$('#density').oninput=()=>{setDensity($('#density').value);localStorage.setItem('muralsource.density',$('#density').value);};
// ── mobile: hamburger opens the filter sidebar as a full-screen drawer ──
const asideEl=document.querySelector('aside');
$('#burger').onclick=()=>{asideEl.classList.add('open-mobile');document.body.style.overflow='hidden';};
$('#fclose').onclick=()=>{asideEl.classList.remove('open-mobile');document.body.style.overflow='';};
window.matchMedia('(max-width:880px)').addEventListener('change',e=>{
if(!e.matches){asideEl.classList.remove('open-mobile');document.body.style.overflow='';}
});
// ── card-field visibility toggles (image-only when all off) ──
const FVIS=JSON.parse(localStorage.getItem('muralsource.fieldvis')||'{"vend":true,"ttl":true,"chips":true}');
function applyFieldVis(){
for(const k of ['vend','ttl','chips'])document.body.classList.toggle('f-off-'+k,!FVIS[k]);
document.body.classList.toggle('imgonly',!FVIS.vend&&!FVIS.ttl&&!FVIS.chips);
document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>cb.checked=!!FVIS[cb.dataset.fv]);
localStorage.setItem('muralsource.fieldvis',JSON.stringify(FVIS));
}
document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
});
applyFieldVis();
// NOTE: no <a> wrapper around the card — nesting anchors splits the DOM (adoption
// agency) and litters the grid with empty <a> cells. Navigation is delegated click.
function card(p){
const title=esc(p.display_name||p.series||p.sku||'(untitled)')+(p.color&&p.color!==p.display_name?(', '+esc(p.color)):'');
const hasRoom=p.room&&p.room!==p.swatch;
const img=p.swatch
?`<div class="imgwrap"><img loading="lazy" src="${esc(p.swatch)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')">`+
(hasRoom?`<img loading="lazy" class="roomimg" src="${esc(p.room)}" alt=""><span class="roombadge">ROOM</span>`:'')+`</div>`
:`<div class="imgwrap noimg"></div>`;
const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
const dot=BUCKET_HEX[p.color_bucket]||'#666';
return `<div class="card" data-href="/product/${encodeURIComponent(p.handle)}" data-handle="${esc(p.handle)}" data-sku="${esc(p.dw_sku||'')}" data-mfr="${esc(p.sku||'')}" data-title="${esc(p.display_name||p.series||p.dw_sku||'')}">
${img}<div class="b">
<span class="vend">${esc(p.book||'Mural Source')}${p.series&&p.series!==p.display_name?(' · '+esc(p.series)):''}</span>
<div class="ttl">${title}</div>
<span class="lc" title="Color">${esc(p.color||'')}</span>
<div class="chips">
${p.color_bucket?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${esc(p.color_bucket)}</span>`:''}
<span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
</div>
<span class="lsku" title="Vendor SKU">${esc(p.sku||'')}</span>
<span class="ldw" title="DW SKU">${esc(p.dw_sku||'')}</span>
<span class="lc" title="Material">${esc(p.material||'')}</span>
<span class="lc" title="Width">${esc(p.width||'')}</span>
<span class="lc" title="Length">${esc(p.length||'')}</span>
<span class="lc" title="Repeat">${esc(p.repeat||'')}</span>
<span class="lc" title="Match">${esc(p.match||'')}</span>
</div>
<div class="info" onclick="event.stopPropagation()">
${kv('DW SKU',p.dw_sku)}${kv('Vendor SKU',p.sku)}${kv('Series',p.series)}${kv('Book',p.book)}
${kv('Material',p.material)}${kv('Width',p.width)}${kv('Repeat',p.repeat)}${kv('Match',p.match)}
<div class="acts">
<button class="act reqact" data-act="memo" title="Log a memo-sample request">Memo</button>
<button class="act reqact" data-act="stock" title="Email the vendor a stock check">Stock</button>
<button class="act reqact" data-act="price" title="Email the vendor for current price">Price</button>
${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy vendor SKU">📋 SKU</button>`:''}
${p.dw_sku?`<button class="act" data-copy="${esc(p.dw_sku)}" onclick="copyTxt(this)" title="copy DW SKU">📋 DW</button>`:''}
<a class="lnk" href="/product/${encodeURIComponent(p.handle)}">open ↗</a>
</div>
</div>
</div>`;
}
// delegated card navigation — anything not interactive opens the PDP
grid.addEventListener('click',e=>{
if(e.target.closest('.chip.toggle,.info,a,button'))return;
const c=e.target.closest('.card[data-href]');
if(c)location.href=c.dataset.href;
});
function copyTxt(btn){
const t=btn.dataset.copy||'';
navigator.clipboard.writeText(t).then(()=>{
const o=btn.textContent; btn.textContent='✓ copied';
setTimeout(()=>{btn.textContent=o;},1200);
});
}
window.copyTxt=copyTxt;
// Purchasing actions on each chip: Memo / Stock / Price → /api/request
grid.addEventListener('click',async e=>{
const btn=e.target.closest('.reqact'); if(!btn)return;
e.stopPropagation();
const card=btn.closest('.card'), type=btn.dataset.act, old=btn.textContent;
btn.disabled=true; btn.textContent='…';
try{
const r=await fetch(location.origin+'/api/request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type,dw_sku:card.dataset.sku,mfr_sku:card.dataset.mfr,title:card.dataset.title})}).then(r=>r.json());
toast(r.ok?(r.message||('Logged '+(r.req_no||''))):(r.error||'request failed'),!!r.ok);
}catch(err){ toast('request failed: '+err.message,false); }
btn.disabled=false; btn.textContent=old;
});
let toastT=null;
function toast(msg,ok){
let t=document.getElementById('toast');
if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); }
t.textContent=msg; t.className='show '+(ok?'ok':'err');
clearTimeout(toastT); toastT=setTimeout(()=>t.className='',6000);
}
function applyFilters(){
const q=$('#q').value.trim().toLowerCase(), pat=$('#pattern').value.trim().toLowerCase();
VIEW=ALL.filter(p=>{
for(const [k] of FIELDS){ if(FIL[k]&&String(p[k]??'')!==FIL[k])return false; }
if(pat&&!String(p.series||p.display_name||'').toLowerCase().includes(pat))return false;
if(q&&![p.display_name,p.series,p.sku,p.dw_sku,p.color,p.book].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
return true;
});
const s=$('#sort').value, cmp={
newest:null,
color:(a,b)=>(a.hue??999)-(b.hue??999)||(a.val??0)-(b.val??0),
style:(a,b)=>{const av=a.style||'',bv=b.style||'';return av.localeCompare(bv)||String(a.series||'').localeCompare(String(b.series||''));},
pattern:(a,b)=>String(a.series||a.display_name||'').localeCompare(String(b.series||b.display_name||'')),
sku:(a,b)=>String(a.sku||a.handle).localeCompare(String(b.sku||b.handle)),
title:(a,b)=>String(a.display_name||'').localeCompare(String(b.display_name||'')),
book:(a,b)=>String(a.book||'').localeCompare(String(b.book||''))||String(a.series||'').localeCompare(String(b.series||'')),
}[s];
if(COLSORT.key){
const k=COLSORT.key, dir=COLSORT.dir;
VIEW=[...VIEW].sort((a,b)=>{
const av=a[k], bv=b[k];
if(av==null||av==='')return 1; if(bv==null||bv==='')return -1; // empties last either way
return dir*String(av).localeCompare(String(bv),undefined,{numeric:true});
});
}else if(cmp)VIEW=[...VIEW].sort(cmp);
shown=0; grid.innerHTML=''; renderMore();
$('#total').textContent=VIEW.length.toLocaleString()+' designs';
$('#empty').style.display=VIEW.length?'none':'block';
renderSide();
}
function renderMore(){
const slice=VIEW.slice(shown,shown+PAGE);
grid.insertAdjacentHTML('beforeend',slice.map(card).join(''));
shown+=slice.length;
$('#meta').textContent=`${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
$('#more').style.display=shown<VIEW.length?'block':'none';
}
function buildFacetCache(){
FCOUNTS={};
for(const [k] of FIELDS){
const m={};
for(const p of ALL){const v=p[k];if(v==null||v==='')continue;m[v]=(m[v]||0)+1;}
FCOUNTS[k]=Object.entries(m).sort((a,b)=>b[1]-a[1]);
}
// Pattern type-ahead over EVERY distinct pattern
const series=(FCOUNTS.series||[]).map(([v])=>v).sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
$('#patternlist').innerHTML=series.map(s=>`<option value="${esc(s)}">`).join('');
}
// left panel — one collapsed <details> table per field; open state survives re-render
function renderSide(){
const openNow=new Set([...document.querySelectorAll('#facetbox details[open]')].map(d=>d.dataset.f));
$('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
// hide fields that carry no useful filter for this line (0 values, or a single
// value shared by every product — e.g. muralsource Books/Repeat/Match)
if((FCOUNTS[k]||[]).length<2) return '';
const rows=(FCOUNTS[k]||[]).slice(0,FCAP).map(([v,n])=>{
const dot=k==='color_bucket'?`<span class="dot" style="background:${BUCKET_HEX[v]||'#666'}"></span>`:'';
return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
<span class="fl">${dot}${esc(v)}</span><span class="n">${n.toLocaleString()}</span></div>`;
}).join('');
const more=(FCOUNTS[k]||[]).length>FCAP?`<div class="fmore">+ ${(FCOUNTS[k].length-FCAP).toLocaleString()} more — use search</div>`:'';
const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(FIL[k])}</span>`:'';
return `<details data-f="${k}"${openNow.has(k)?' open':''}><summary>${label}${cur}</summary><div class="fbody">${rows}${more}</div></details>`;
}).join('');
$('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value||$('#pattern').value)?'block':'none';
}
function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters();}
window.pickF=pickF;
$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';applyFilters();};
$('#sort').onchange=()=>{localStorage.setItem('muralsource.sort',$('#sort').value);COLSORT.key=null;renderListHead();applyFilters();};
let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(applyFilters,300);};
let tp; $('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(applyFilters,300);};
$('#more').onclick=renderMore;
// ── sortable column header (list view) ──
const LIST_COLS=[[null,''],['book','Book'],['display_name','Pattern'],['color','Color'],['color_bucket','Bucket'],
['sku','SKU'],['dw_sku','DW SKU'],['material','Material'],['width','Width'],['length','Length'],['repeat','Repeat'],['match','Match']];
const COLSORT={key:null,dir:1};
function renderListHead(){
$('#listhead').innerHTML=LIST_COLS.map(([k,lbl])=>k
?`<span class="lh${COLSORT.key===k?' on':''}" onclick="colSort('${k}')">${lbl}${COLSORT.key===k?(COLSORT.dir>0?' ▲':' ▼'):''}</span>`
:'<span></span>').join('');
syncSticky();
}
function colSort(k){
if(COLSORT.key===k)COLSORT.dir=-COLSORT.dir; else{COLSORT.key=k;COLSORT.dir=1;}
renderListHead(); applyFilters();
}
window.colSort=colSort;
function syncSticky(){
const h=document.querySelector('header').offsetHeight;
$('#listhead').style.top=h+'px';
document.querySelector('aside').style.top=h+'px';
document.querySelector('aside').style.maxHeight=`calc(100vh - ${h}px)`;
}
addEventListener('resize',syncSticky);
function applyView(){const list=localStorage.getItem('muralsource.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listmode',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();}
$('#view').onclick=()=>{localStorage.setItem('muralsource.view',localStorage.getItem('muralsource.view')==='list'?'grid':'list');applyView();};
applyView();
addEventListener('scroll',()=>{if(shown<VIEW.length&&innerHeight+scrollY>=document.body.offsetHeight-600)renderMore();});
async function loadAll(){
const [pd,fc]=await Promise.all([
fetch(location.origin+'/api/products').then(r=>r.json()),
fetch(location.origin+'/api/facets').then(r=>r.json())]);
ALL=pd.products; FACETS=fc;
buildFacetCache(); applyFilters();
}
loadAll();
/* ── Corner snapshot-age pill ───────────────────────────────────────────────
HONEST: this internal line has NO auto-refresh cron. data/products.json is a
STATIC snapshot rebuilt only when someone runs `npm run build`
(node scripts/build-products-json.js) from dw_unified.muralsource_catalog.
The pill shows the snapshot's age; it polls /api/meta and hot-swaps ONLY if
the file is actually rebuilt (advanced mtime). No fake countdown. */
(function(){
var last=null;
var pill=document.createElement('div');
pill.className='muralsource-refresh-pill';
pill.title='Static snapshot of the Mural Source catalog (dw_unified.muralsource_catalog). Rebuilt manually — no auto-refresh cron on this internal line.';
pill.innerHTML='<span class="rp-dot"></span><span class="rp-txt">snapshot…</span>';
document.body.appendChild(pill);
var txt=pill.querySelector('.rp-txt');
function age(iso){
var s=Math.max(0,(Date.now()-new Date(iso).getTime())/1000);
if(s<90) return 'just now';
if(s<5400) return Math.round(s/60)+'m ago';
if(s<172800) return Math.round(s/3600)+'h ago';
return Math.round(s/86400)+'d ago';
}
async function sync(){
try{
var m=await fetch(location.origin+'/api/meta').then(r=>r.json());
if(last && m.lastRefresh!==last) loadAll(); // manual rebuild landed → hot-swap
last=m.lastRefresh;
txt.textContent='📷 Snapshot · '+age(m.lastRefresh);
}catch(e){}
}
sync(); setInterval(sync,30000);
})();
</script>
</body></html>