← back to All Designerwallcoverings
amazon-style left filter rail + grid/list toggle + sortable columns (every field, asc/desc w/ null-aware desc) + density to 20 + URL param overrides
bb41e316e4586728fa435f86095f819dcd4b70ac · 2026-07-02 17:02:18 -0700 · Steve Abrams
Files touched
M public/index.htmlM server.js
Diff
commit bb41e316e4586728fa435f86095f819dcd4b70ac
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 2 17:02:18 2026 -0700
amazon-style left filter rail + grid/list toggle + sortable columns (every field, asc/desc w/ null-aware desc) + density to 20 + URL param overrides
---
public/index.html | 360 +++++++++++++++++++++++++++++++++++-------------------
server.js | 25 +++-
2 files changed, 258 insertions(+), 127 deletions(-)
diff --git a/public/index.html b/public/index.html
index c3a2523..5b37d37 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,39 +5,52 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>All Products — Designer Wallcoverings</title>
<style>
- :root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; }
+ :root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; --rail:260px; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
- header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; max-height:78vh; overflow:auto; }
- h1 { margin:0 0 10px; font-size:18px; font-weight:600; letter-spacing:.02em; }
- h1 small { color:var(--mut); font-weight:400; font-size:13px; margin-left:8px; }
- h1 a { color:var(--mut); font-size:12px; font-weight:400; margin-left:14px; text-decoration:none; border-bottom:1px dotted var(--mut); }
- .controls { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
+
+ /* ── top bar ── */
+ header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px);
+ border-bottom:1px solid var(--line); padding:12px 20px; display:flex; flex-wrap:wrap; gap:10px 16px; align-items:center; }
+ h1 { margin:0; font-size:17px; font-weight:600; letter-spacing:.02em; white-space:nowrap; }
+ h1 small { color:var(--mut); font-weight:400; font-size:12px; margin-left:8px; }
+ h1 a { color:var(--mut); font-size:11px; font-weight:400; margin-left:10px; text-decoration:none; border-bottom:1px dotted var(--mut); }
select, input[type=search], input[type=text] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
- input[type=search] { min-width:260px; }
- .dens { display:flex; align-items:center; gap:8px; color:var(--mut); }
- details.facets { margin-top:12px; }
- details.facets > summary { cursor:pointer; color:var(--mut); font-size:12px; letter-spacing:.04em; text-transform:uppercase; list-style:none; user-select:none; padding:2px 0; }
- details.facets > summary::-webkit-details-marker { display:none; }
- details.facets > summary::before { content:'▸ '; color:var(--accent); }
- details.facets[open] > summary::before { content:'▾ '; }
- summary b { color:var(--accent); }
- .facetbody { display:flex; flex-direction:column; gap:9px; margin-top:9px; }
- .frow { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
- .flabel { color:var(--mut); font-size:11px; text-transform:uppercase; letter-spacing:.06em; min-width:62px; }
- .chip { background:var(--card); border:1px solid var(--line); border-radius:20px; padding:4px 10px; cursor:pointer; font-size:11.5px; color:var(--mut); }
- .chip.on { background:var(--accent); color:#1a1407; border-color:var(--accent); font-weight:600; }
- .chip .ct { opacity:.65; margin-left:4px; font-size:10px; }
- .chip.zero { opacity:.3; }
- .chip.allchip { font-weight:600; border-style:dashed; }
- .chip.allchip.on { border-style:solid; }
- .chip.more { border-style:dotted; color:var(--accent); }
- .ffind { padding:3px 9px !important; font-size:11px !important; border-radius:20px !important; min-width:110px; }
- .active { display:flex; flex-wrap:wrap; gap:6px; align-items:center; padding-top:2px; }
+ input[type=search] { flex:1 1 200px; min-width:180px; max-width:420px; }
+ .bar { display:flex; align-items:center; gap:8px; color:var(--mut); font-size:12px; white-space:nowrap; }
+ .dirbtn { background:var(--card); color:var(--accent); border:1px solid var(--line); border-radius:8px; padding:7px 10px; cursor:pointer; font-size:13px; }
+ .viewseg { display:flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
+ .viewseg button { background:var(--card); color:var(--mut); border:none; padding:7px 13px; font-size:12px; cursor:pointer; }
+ .viewseg button.on { background:var(--accent); color:#1a1407; font-weight:600; }
+ #densWrap input { accent-color:var(--accent); width:110px; }
+ #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:59px; max-height:calc(100vh - 59px);
+ overflow-y:auto; padding:14px 14px 60px; border-right:1px solid var(--line); }
+ aside h4 { margin:16px 0 7px; font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--mut);
+ border-top:1px solid var(--line); padding-top:12px; }
+ aside h4:first-of-type { border-top:none; margin-top:2px; padding-top:0; }
+ .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:var(--card); }
+ .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:#1a1407; font-weight:700; }
+ .fitem.on { color:var(--accent); }
+ .fitem .lbl { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ .fitem .ct { color:var(--mut); font-size:10.5px; }
+ .fitem.zero { opacity:.35; }
+ .more { color:var(--accent); font-size:11.5px; cursor:pointer; padding:3px 2px; display:inline-block; }
+ .ffind { width:100%; padding:4px 8px !important; font-size:11.5px !important; border-radius:6px !important; margin:2px 0 5px; }
+ .active { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:4px; }
.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; }
- .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:18px 20px 60px; }
+
+ main { flex:1; min-width:0; }
+
+ /* ── grid view ── */
+ .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:10px; padding:14px 16px 30px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
.thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
.thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
@@ -49,90 +62,177 @@
.ttl { color:var(--txt); font-size:11.5px; margin-top:3px; max-height:2.8em; overflow:hidden; }
.vt { color:var(--mut); font-size:10.5px; margin-top:3px; }
.fac { margin-top:4px; font-size:10.5px; color:var(--accent); }
- .priceline { margin-top:4px; }
- .price { color:#e8e8ea; font-size:12.5px; font-weight:600; }
+ .price { color:#e8e8ea; font-size:12.5px; font-weight:600; margin-top:4px; display:block; }
+ /* dense grids: shed card text progressively so 20-across stays a clean texture wall */
+ .grid.d1 .ttl, .grid.d1 .fac { display:none; }
+ .grid.d2 .meta { display:none; }
+
+ /* ── list view ── */
+ .listwrap { padding:0 16px 30px; overflow-x:auto; }
+ table { width:100%; border-collapse:collapse; font-size:12.5px; }
+ thead th { position:sticky; top:59px; background:#131317; z-index:5; text-align:left; padding:9px 10px; font-size:11px;
+ letter-spacing:.05em; text-transform:uppercase; color:var(--mut); border-bottom:1px solid var(--line);
+ cursor:pointer; user-select:none; white-space:nowrap; }
+ thead th:hover { color:var(--accent); }
+ thead th.sorted { color:var(--accent); }
+ tbody td { padding:7px 10px; border-bottom:1px solid #1d1d22; vertical-align:middle; }
+ tbody tr:hover { background:#141419; }
+ tbody tr.linked { cursor:pointer; }
+ td.timg { width:52px; } td.timg div { width:44px; height:44px; border-radius:6px; background:#000 center/cover no-repeat; }
+ td .sku { font-size:12px; }
+ td.num { text-align:right; font-variant-numeric:tabular-nums; }
+ .tfac { color:var(--accent); font-size:11px; }
+
#sentinel { height:1px; }
#status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }
+
+ @media (max-width: 900px) {
+ #railToggle { display:inline-block; }
+ aside { position:fixed; left:0; top:59px; 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>All Products <small id="sub">Designer Wallcoverings — the whole catalog, one place</small><a href="/vendors.html">vendor directory →</a></h1>
- <div class="controls">
- <input type="search" id="q" placeholder="Search anything — red, damask, DWKR, Thibaut…">
- <label class="dens">Sort
- <select id="sort">
- <option value="newest">Newest</option>
- <option value="oldest">Oldest</option>
- <option value="updated">Recently updated</option>
- <option value="title">Title A→Z</option>
- <option value="vendor">Vendor A→Z</option>
- <option value="type">Type A→Z</option>
- <option value="sku">SKU A→Z</option>
- <option value="pattern">Pattern A→Z</option>
- <option value="color">Color (family)</option>
- <option value="style">Style A→Z</option>
- <option value="material">Material A→Z</option>
- <option value="price_asc">Price ↑</option>
- <option value="price_desc">Price ↓</option>
- <option value="status">Status</option>
- </select>
- </label>
- <label class="dens">Density
- <input type="range" id="dens" min="3" max="10" value="6">
- </label>
- </div>
+ <h1>All Products <small id="sub">loading…</small><a href="/vendors.html">vendor directory →</a></h1>
+ <button class="dirbtn" id="railToggle">☰ Filters</button>
+ <input type="search" id="q" placeholder="Search anything — red, damask, DWKR, Thibaut…">
+ <span class="bar">Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ <option value="updated">Recently updated</option>
+ <option value="title">Title</option>
+ <option value="vendor">Vendor</option>
+ <option value="type">Type</option>
+ <option value="sku">SKU</option>
+ <option value="pattern">Pattern</option>
+ <option value="color">Color (family)</option>
+ <option value="style">Style</option>
+ <option value="material">Material</option>
+ <option value="price">Price</option>
+ <option value="status">Status</option>
+ </select>
+ <button class="dirbtn" id="dir" title="Flip sort direction">▲</button>
+ </span>
+ <span class="viewseg"><button id="vGrid">Grid</button><button id="vList">List</button></span>
+ <span class="bar" id="densWrap">Density <input type="range" id="dens" min="2" max="20" value="6"> <span id="densN"></span></span>
+</header>
- <details class="facets" id="facets">
- <summary>Filters <b id="facetCount"></b></summary>
- <div class="facetbody">
- <div class="frow"><span class="flabel">Vendor</span><div class="frow" id="vendorRow"></div></div>
- <div class="frow"><span class="flabel">Type</span><div class="frow" id="typeRow"></div></div>
- <div class="frow"><span class="flabel">Color</span><div class="frow" id="colorRow"></div></div>
- <div class="frow"><span class="flabel">Style</span><div class="frow" id="styleRow"></div></div>
- <div class="frow"><span class="flabel">Material</span><div class="frow" id="matRow"></div></div>
- <div class="frow"><span class="flabel">Price</span><div class="frow" id="priceRow"></div></div>
- <div class="frow"><span class="flabel">Status</span><div class="frow" id="statusRow"></div></div>
- <div class="frow"><span class="flabel">Image</span><div class="frow" id="imgRow"></div></div>
- <div class="active" id="activeRow"></div>
+<div class="wrap">
+ <aside id="rail">
+ <div class="active" id="activeRow"></div>
+ <h4>Vendor</h4><div id="vendorRow"></div>
+ <h4>Type</h4><div id="typeRow"></div>
+ <h4>Color</h4><div id="colorRow"></div>
+ <h4>Style</h4><div id="styleRow"></div>
+ <h4>Material</h4><div id="matRow"></div>
+ <h4>Price</h4><div id="priceRow"></div>
+ <h4>Status</h4><div id="statusRow"></div>
+ <h4>Image</h4><div id="imgRow"></div>
+ </aside>
+
+ <main>
+ <div class="grid" id="grid"></div>
+ <div class="listwrap" id="listwrap" style="display:none">
+ <table>
+ <thead><tr id="cols"></tr></thead>
+ <tbody id="tbody"></tbody>
+ </table>
</div>
- </details>
-</header>
-<div class="grid" id="grid"></div>
-<div id="sentinel"></div>
-<div id="status">loading…</div>
+ <div id="sentinel"></div>
+ <div id="status">loading…</div>
+ </main>
+</div>
<script>
-const grid = document.getElementById('grid');
-const statusEl = document.getElementById('status');
-const subEl = document.getElementById('sub');
+const grid = document.getElementById('grid'), tbody = document.getElementById('tbody');
+const listwrap = document.getElementById('listwrap');
+const statusEl = document.getElementById('status'), subEl = document.getElementById('sub');
const LIMIT = 120;
const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
+const urlq = new URLSearchParams(location.search);
let state = {
- sort: localStorage.getItem('all_sort') || 'newest',
- q: '',
+ sort: urlq.get('sort') || localStorage.getItem('all_sort') || 'newest',
+ dir: urlq.get('dir') || localStorage.getItem('all_dir') || 'asc',
+ view: urlq.get('view') || localStorage.getItem('all_view') || 'grid',
+ q: urlq.get('q') || '',
vendors: loadSet('all_vendors'), types: loadSet('all_types'), statuses: loadSet('all_statuses'),
colors: loadSet('all_colors'), styles: loadSet('all_styles'), materials: loadSet('all_materials'),
prices: loadSet('all_prices'), images: loadSet('all_images'),
offset: 0, total: 0, loading: false, done: false,
};
// per-row UI state: expanded? + type-ahead needle (vendor/type rows have hundreds of values)
-const rowUI = { vendorRow: { expand: false, find: '' }, typeRow: { expand: false, find: '' } };
+const rowUI = {
+ vendorRow: { expand: false, find: '', typeahead: true }, typeRow: { expand: false, find: '', typeahead: true },
+ colorRow: { expand: false, find: '' }, styleRow: { expand: false, find: '' }, matRow: { expand: false, find: '' },
+ priceRow: { expand: false, find: '' }, statusRow: { expand: false, find: '' }, imgRow: { expand: false, find: '' },
+};
-const dens = document.getElementById('dens');
+/* ── top-bar wiring ── */
+const dens = document.getElementById('dens'), densN = document.getElementById('densN');
dens.value = localStorage.getItem('all_cols') || 6;
-document.documentElement.style.setProperty('--cols', dens.value);
-dens.oninput = () => { document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('all_cols', dens.value); };
+const setCols = () => { document.documentElement.style.setProperty('--cols', dens.value); densN.textContent = dens.value;
+ grid.classList.toggle('d1', dens.value >= 9); grid.classList.toggle('d2', dens.value >= 13); };
+setCols();
+dens.oninput = () => { setCols(); localStorage.setItem('all_cols', dens.value); };
-const facetsEl = document.getElementById('facets');
-facetsEl.open = localStorage.getItem('all_facets_open') !== '0';
-facetsEl.ontoggle = () => localStorage.setItem('all_facets_open', facetsEl.open ? '1' : '0');
+const sortSel = document.getElementById('sort'), dirBtn = document.getElementById('dir');
+sortSel.value = ['price_asc','price_desc'].includes(state.sort) ? 'price' : state.sort;
+state.sort = sortSel.value;
+const drawDir = () => { dirBtn.textContent = state.dir === 'desc' ? '▼' : '▲'; };
+drawDir();
+sortSel.onchange = () => { state.sort = sortSel.value; persistSort(); reset(); drawCols(); };
+dirBtn.onclick = () => { state.dir = state.dir === 'desc' ? 'asc' : 'desc'; persistSort(); drawDir(); reset(); drawCols(); };
+function persistSort() { localStorage.setItem('all_sort', state.sort); localStorage.setItem('all_dir', state.dir); }
-document.getElementById('sort').value = state.sort;
-document.getElementById('sort').onchange = (e) => { state.sort = e.target.value; localStorage.setItem('all_sort', state.sort); reset(); };
-let qt; document.getElementById('q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); applyFilterChange(); }, 250); };
+const vG = document.getElementById('vGrid'), vL = document.getElementById('vList');
+function drawView() {
+ vG.classList.toggle('on', state.view === 'grid'); vL.classList.toggle('on', state.view === 'list');
+ grid.style.display = state.view === 'grid' ? '' : 'none';
+ listwrap.style.display = state.view === 'list' ? '' : 'none';
+ document.getElementById('densWrap').style.opacity = state.view === 'grid' ? 1 : .35;
+}
+vG.onclick = () => { state.view = 'grid'; localStorage.setItem('all_view', 'grid'); drawView(); reset(); };
+vL.onclick = () => { state.view = 'list'; localStorage.setItem('all_view', 'list'); drawView(); reset(); };
+drawView();
+
+const rail = document.getElementById('rail');
+document.getElementById('railToggle').onclick = () => rail.classList.toggle('open');
+
+const qEl = document.getElementById('q'); qEl.value = state.q;
+let qt; qEl.oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); applyFilterChange(); }, 250); };
+
+/* ── list-view sortable columns: every column sorts, click toggles asc/desc ── */
+const COLS = [
+ { label: '', key: null }, // thumb
+ { label: 'SKU', key: 'sku' }, { label: 'Title', key: 'title' }, { label: 'Vendor', key: 'vendor' },
+ { label: 'Type', key: 'type' }, { label: 'Pattern', key: 'pattern' }, { label: 'Color', key: 'color' },
+ { label: 'Style', key: 'style' }, { label: 'Material', key: 'material' },
+ { label: 'Price', key: 'price', num: true }, { label: 'Status', key: 'status' },
+];
+function drawCols() {
+ const tr = document.getElementById('cols'); tr.innerHTML = '';
+ COLS.forEach((c) => {
+ const th = document.createElement('th');
+ if (c.key) {
+ const on = state.sort === c.key;
+ th.className = on ? 'sorted' : '';
+ th.textContent = c.label + (on ? (state.dir === 'desc' ? ' ▼' : ' ▲') : '');
+ th.onclick = () => {
+ if (state.sort === c.key) state.dir = state.dir === 'desc' ? 'asc' : 'desc';
+ else { state.sort = c.key; state.dir = 'asc'; }
+ sortSel.value = c.key; persistSort(); drawDir(); drawCols(); reset();
+ };
+ } else th.textContent = c.label;
+ tr.appendChild(th);
+ });
+}
+drawCols();
+/* ── filters (shared with facets API) ── */
const SETS = {
vendors: 'all_vendors', types: 'all_types', statuses: 'all_statuses', colors: 'all_colors',
styles: 'all_styles', materials: 'all_materials', prices: 'all_prices', images: 'all_images',
@@ -140,7 +240,7 @@ const SETS = {
function persistFacets() { for (const [k, key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
function facetParams(extra = {}) {
return new URLSearchParams({
- sort: state.sort, q: state.q,
+ sort: state.sort, dir: state.dir, q: state.q,
vendors: [...state.vendors].join(','), types: [...state.types].join(','),
statuses: [...state.statuses].join(','), colors: [...state.colors].join(','),
styles: [...state.styles].join(','), materials: [...state.materials].join(','),
@@ -148,26 +248,16 @@ function facetParams(extra = {}) {
});
}
-function reset() { grid.innerHTML = ''; state.offset = 0; state.done = false; statusEl.textContent = 'loading…'; load(); }
+function reset() { grid.innerHTML = ''; tbody.innerHTML = ''; state.offset = 0; state.done = false; statusEl.textContent = 'loading…'; load(); }
function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
-function allChip(set, label = 'All') {
- const el = document.createElement('span');
- el.className = 'chip allchip' + (set.size === 0 ? ' on' : '');
- el.textContent = label;
- el.title = set.size ? 'Clear this category' : 'Showing all';
- el.onclick = () => { if (set.size) { set.clear(); applyFilterChange(); } };
- return el;
-}
-
-// count-chip row. Big rows (vendor/type) get a type-ahead + top-N cap with "+N more" expander;
-// selected values ALWAYS render so an active filter can't hide off-screen.
-function chipRow(rowId, counts, set, opts = {}) {
+/* Amazon-style vertical facet list: checkbox rows + counts, type-ahead on big rows,
+ See-more expander, selected values always visible. */
+function facetList(rowId, counts, set, opts = {}) {
const row = document.getElementById(rowId); row.innerHTML = '';
const ui = rowUI[rowId];
- row.appendChild(allChip(set, opts.allLabel));
- if (ui) {
+ 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); } };
@@ -177,27 +267,26 @@ function chipRow(rowId, counts, set, opts = {}) {
entries = opts.order
? entries.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]))
: entries.sort((a, b) => b[1] - a[1]);
- if (ui && ui.find) entries = entries.filter(([v]) => v.toLowerCase().includes(ui.find.toLowerCase()));
- const CAP = 30;
+ if (ui.find) entries = entries.filter(([v]) => v.toLowerCase().includes(ui.find.toLowerCase()));
+ const CAP = opts.cap || 8;
let shown = entries, hidden = 0;
- if (ui && !ui.expand && entries.length > CAP) { shown = entries.slice(0, CAP); hidden = entries.length - CAP; }
- // active selections must always be visible
+ 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('span'); s.style.cssText = 'font-size:10px;color:#6a6a72'; s.textContent = 'none'; row.appendChild(s); return; }
+ 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]) => {
- const el = document.createElement('span');
- el.className = 'chip' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
- el.innerHTML = `${val}<span class="ct">${ct.toLocaleString()}</span>`;
+ const el = document.createElement('div');
+ el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+ el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span><span class="lbl" title="${val.replace(/"/g, '"')}">${val}</span><span class="ct">${ct.toLocaleString()}</span>`;
el.onclick = () => toggleSet(set, val);
row.appendChild(el);
});
if (hidden > 0) {
- const el = document.createElement('span'); el.className = 'chip more'; el.textContent = `+${hidden.toLocaleString()} more`;
+ 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 && ui.expand && entries.length > CAP) {
- const el = document.createElement('span'); el.className = 'chip more'; el.textContent = 'show less';
+ } 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);
}
@@ -206,14 +295,14 @@ function chipRow(rowId, counts, set, opts = {}) {
let LAST_FACETS = {};
function renderFacets(f) {
LAST_FACETS = f;
- chipRow('vendorRow', f.vendor, state.vendors);
- chipRow('typeRow', f.type, state.types);
- chipRow('colorRow', f.color, state.colors, { order: f.family_order });
- chipRow('styleRow', f.style, state.styles);
- chipRow('matRow', f.material, state.materials);
- chipRow('priceRow', f.price_band, state.prices, { order: f.price_order });
- chipRow('statusRow', f.status, state.statuses, { allLabel: 'Active (default)' });
- chipRow('imgRow', f.image_state, state.images);
+ facetList('vendorRow', f.vendor, state.vendors, { cap: 10 });
+ facetList('typeRow', f.type, state.types, { cap: 10 });
+ facetList('colorRow', f.color, state.colors, { order: f.family_order, cap: 12 });
+ facetList('styleRow', f.style, state.styles, { cap: 10 });
+ facetList('matRow', f.material, state.materials, { cap: 10 });
+ facetList('priceRow', f.price_band, state.prices, { order: f.price_order, cap: 8 });
+ facetList('statusRow', f.status, state.statuses, { cap: 6 });
+ facetList('imgRow', f.image_state, state.images, { cap: 4 });
renderActive();
}
@@ -231,7 +320,6 @@ function renderActive() {
row.appendChild(el);
});
}
- document.getElementById('facetCount').textContent = n ? `· ${n} active` : '';
if (n) {
const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
c.onclick = () => { dims.forEach(([, s]) => s.clear()); applyFilterChange(); };
@@ -246,6 +334,7 @@ async function refreshFacets() {
} catch (e) { /* facets degrade silently — grid still works */ }
}
+/* ── renderers ── */
function card(r) {
const el = document.createElement('div'); el.className = 'card';
const stBadge = (r.status && r.status !== 'ACTIVE') ? `<span class="badge st-${r.status}">${r.status}</span>` : '';
@@ -254,12 +343,31 @@ function card(r) {
: `<div class="thumb empty">${stBadge}no image</div>`;
const vt = [r.vendor, r.type].filter(Boolean).join(' · ');
const fac = [...(r.colors || []), ...(r.styles || []), ...(r.materials || [])].slice(0, 4).join(' · ');
- const price = r.price ? `<div class="priceline"><span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span></div>` : '';
+ const price = r.price ? `<span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>` : '';
el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '"')}">${r.title || ''}</div>${vt ? `<div class="vt">${vt}</div>` : ''}${fac ? `<div class="fac">${fac}</div>` : ''}${price}</div>`;
if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
return el;
}
+function listRow(r) {
+ const tr = document.createElement('tr');
+ const cell = (html, cls) => { const td = document.createElement('td'); if (cls) td.className = cls; td.innerHTML = html; return td; };
+ tr.appendChild((() => { const td = document.createElement('td'); td.className = 'timg';
+ td.innerHTML = r.image ? `<div style="background-image:url('${r.image}')"></div>` : '<div></div>'; return td; })());
+ tr.appendChild(cell(`<span class="sku">${r.sku || '—'}</span>`));
+ tr.appendChild(cell((r.title || '').replace(/</g, '<')));
+ tr.appendChild(cell(r.vendor || ''));
+ tr.appendChild(cell(r.type || ''));
+ tr.appendChild(cell(r.pattern || ''));
+ tr.appendChild(cell(`<span class="tfac">${(r.colors || []).join(', ')}</span>`));
+ tr.appendChild(cell(`<span class="tfac">${(r.styles || []).join(', ')}</span>`));
+ tr.appendChild(cell(`<span class="tfac">${(r.materials || []).join(', ')}</span>`));
+ tr.appendChild(cell(r.price ? '$' + r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '', 'num'));
+ tr.appendChild(cell(r.status || ''));
+ if (r.url) { tr.className = 'linked'; tr.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
+ return tr;
+}
+
async function load() {
if (state.loading || state.done) return;
state.loading = true;
@@ -268,11 +376,11 @@ async function load() {
const data = await res.json();
state.total = data.total;
const frag = document.createDocumentFragment();
- data.rows.forEach((r) => frag.appendChild(card(r)));
- grid.appendChild(frag);
+ data.rows.forEach((r) => frag.appendChild(state.view === 'list' ? listRow(r) : card(r)));
+ (state.view === 'list' ? tbody : grid).appendChild(frag);
state.offset += data.rows.length;
state.done = state.offset >= data.total || data.rows.length === 0;
- subEl.textContent = `${data.catalog.toLocaleString()} products in catalog · ${data.total.toLocaleString()} matching · showing ${state.offset.toLocaleString()}`;
+ subEl.textContent = `${data.catalog.toLocaleString()} products · ${data.total.toLocaleString()} matching · showing ${state.offset.toLocaleString()}`;
statusEl.textContent = data.total === 0 ? '— no matching products —'
: (state.done ? `— end · ${state.total.toLocaleString()} matching —` : 'scroll for more…');
} catch (e) {
diff --git a/server.js b/server.js
index 9563791..8eda2af 100644
--- a/server.js
+++ b/server.js
@@ -169,6 +169,15 @@ const sorters = {
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.sku).localeCompare(str(b.sku)); },
style: (a, b) => str(a.styles[0]).localeCompare(str(b.styles[0])) || str(a.sku).localeCompare(str(b.sku)),
material: (a, b) => str(a.materials[0]).localeCompare(str(b.materials[0])) || str(a.sku).localeCompare(str(b.sku)),
+ price: (a, b) => (a.price ?? 1e12) - (b.price ?? 1e12), // generic; pair with dir=desc for high→low
+};
+
+// per-sort "row actually has this value" — used to keep empties last on dir=desc
+const HAS_VALUE = {
+ sku: (r) => r.sku != null && r.sku !== '', title: (r) => !!r.title, vendor: (r) => !!r.vendor,
+ type: (r) => !!r.type, pattern: (r) => !!r.pattern, status: (r) => !!r.status,
+ price: (r) => r.price != null, price_asc: (r) => r.price != null, price_desc: (r) => r.price != null,
+ color: (r) => r.colors.length > 0, style: (r) => r.styles.length > 0, material: (r) => r.materials.length > 0,
};
const tally = (rows, key) => { const m = {}; for (const r of rows) { const v = r[key]; if (v) m[v] = (m[v] || 0) + 1; } return m; };
@@ -190,7 +199,21 @@ const server = http.createServer((req, res) => {
const offset = parseInt(u.searchParams.get('offset') || '0', 10) || 0;
const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10) || 120, 500);
let rows = applyFilters(ROWS, f);
- if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+ if (sorters[sort]) {
+ rows = [...rows].sort(sorters[sort]);
+ // dir=desc flips ANY sort key so every list-view column sorts both ways.
+ // Reversing would surface the null-sentinel rows first, so after the flip
+ // we stable-partition rows lacking the sort value back to the end.
+ if (u.searchParams.get('dir') === 'desc') {
+ rows.reverse();
+ const has = HAS_VALUE[sort];
+ if (has) {
+ const withV = [], without = [];
+ for (const r of rows) (has(r) ? withV : without).push(r);
+ rows = withV.concat(without);
+ }
+ }
+ }
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
total: rows.length, catalog: ROWS.length, loaded_at: LOADED_AT, offset, limit,
← 86857a5 portable snapshot SQL: probe optional mirror-only columns (m
·
back to All Designerwallcoverings
·
Astek-viewer alignment: accordion filter sections + CARD FIE 96f3c15 →