← back to Philipperomano
add sort select + density slider to product grid (DW standing rule), with localStorage persistence
597bd720982ef633bad8ab3eb93c00a7d0a62168 · 2026-05-18 20:30:23 -0700 · Steve Abrams
Files touched
Diff
commit 597bd720982ef633bad8ab3eb93c00a7d0a62168
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 18 20:30:23 2026 -0700
add sort select + density slider to product grid (DW standing rule), with localStorage persistence
---
server.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 79 insertions(+), 3 deletions(-)
diff --git a/server.js b/server.js
index 129ba0f..0cea4d1 100644
--- a/server.js
+++ b/server.js
@@ -8,7 +8,15 @@ const path = require('path');
const fs = require('fs');
const PORT = process.env.PORT || 9831;
-const DATA = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
+const DATA_FILE = path.join(__dirname, 'data', 'products.json');
+let DATA;
+try {
+ DATA = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
+ if (!Array.isArray(DATA)) throw new Error('products.json is not a JSON array');
+} catch (err) {
+ console.error(`FATAL: could not load product catalog from ${DATA_FILE}\n ${err.message}`);
+ process.exit(1);
+}
const DW_SHOPIFY = 'https://designerwallcoverings.com';
const app = express();
@@ -48,6 +56,17 @@ function paginate(arr, page, perPage = 60) {
return { items: arr.slice((p-1)*perPage, p*perPage), page: p, pages, total };
}
+// Server-side product sort. Only modes the snapshot data can actually
+// support — products.json carries no tag/color data and price fields are
+// near-empty, so Color/Style/Price sorts are intentionally omitted here.
+function sortProducts(list, mode) {
+ const cmpStr = k => (a, b) => String(a[k] || '').localeCompare(String(b[k] || ''));
+ if (mode === 'sku') return [...list].sort((a,b) => String(a.dw_sku || a.handle || '').localeCompare(String(b.dw_sku || b.handle || '')));
+ if (mode === 'title') return [...list].sort(cmpStr('title'));
+ if (mode === 'type') return [...list].sort((a,b) => NORM_TYPE(a.product_type).localeCompare(NORM_TYPE(b.product_type)) || cmpStr('title')(a,b));
+ return list; // 'newest' / default — snapshot's natural order
+}
+
function shopifyUrl(handle) {
return handle ? `${DW_SHOPIFY}/products/${handle}` : `${DW_SHOPIFY}`;
}
@@ -89,7 +108,12 @@ const layout = (title, body, { q='', type='' } = {}) => `<!doctype html>
form.search button{border:1px solid var(--accent);background:var(--accent);color:#fff;padding:8px 16px;font:inherit;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:500;cursor:pointer}
main{max-width:1400px;margin:0 auto;padding:32px}
.meta-line{font-size:12px;color:var(--muted);margin-bottom:18px;letter-spacing:.06em;text-transform:uppercase}
- .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:24px}
+ .controls{display:flex;gap:24px;align-items:center;flex-wrap:wrap;margin-bottom:24px}
+ .controls label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:500;display:flex;gap:8px;align-items:center}
+ .controls select{border:1px solid var(--rule);background:#fff;padding:6px 10px;font:inherit;font-size:13px;color:var(--fg);outline:none;cursor:pointer}
+ .controls select:focus{border-color:var(--accent)}
+ .controls input[type=range]{cursor:pointer;accent-color:var(--accent)}
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cardmin,220px),1fr));gap:24px}
.card{background:var(--card);border:1px solid var(--rule);transition:box-shadow .15s, transform .12s}
.card:hover{box-shadow:0 8px 24px rgba(0,0,0,.06);transform:translateY(-2px)}
.card a.thumb{display:block;aspect-ratio:1/1;background:#f0ece4;overflow:hidden}
@@ -151,7 +175,8 @@ ${body}
app.get('/', (req, res) => {
const q = (req.query.q || '').toString().toLowerCase().trim();
const type = (req.query.type || '').toString();
- const page = parseInt(req.query.page) || 1;
+ const sort = (req.query.sort || '').toString();
+ const page = parseInt(req.query.page, 10) || 1;
let filtered = DATA;
if (type) filtered = filtered.filter(p => NORM_TYPE(p.product_type) === type);
@@ -162,6 +187,7 @@ app.get('/', (req, res) => {
return tokens.every(t => hay.includes(t));
});
}
+ filtered = sortProducts(filtered, sort);
const { items, page: cur, pages, total } = paginate(filtered, page);
@@ -183,11 +209,25 @@ app.get('/', (req, res) => {
</article>
`).join('');
+ const SORTS = [
+ ['', 'Newest'], ['sku', 'SKU A→Z'], ['title', 'Title A→Z'], ['type', 'Type'],
+ ];
+ const sortOptions = SORTS.map(([v, lbl]) =>
+ `<option value="${v}"${sort === v ? ' selected' : ''}>${lbl}</option>`).join('');
+
const body = `
<main>
<div class="meta-line">
${q || type ? `Showing ${total.toLocaleString()} ${type ? `<strong style="color:var(--fg)">${esc(type)}</strong>` : ''} ${q ? `matching "<strong style="color:var(--fg)">${esc(q)}</strong>"` : ''}` : `${total.toLocaleString()} products available`}
</div>
+ <div class="controls">
+ <label>Sort
+ <select id="sortSelect">${sortOptions}</select>
+ </label>
+ <label>Density
+ <input type="range" id="densityRange" min="160" max="340" step="20" value="220">
+ </label>
+ </div>
${items.length === 0 ? '<div class="empty">No products match. Try a different search or category.</div>' : `<div class="grid">${cards}</div>`}
${pages > 1 ? `<nav class="pager">
${cur > 1 ? `<a href="?${new URLSearchParams({...req.query, page: cur-1}).toString()}">‹ Prev</a>` : ''}
@@ -202,6 +242,42 @@ app.get('/', (req, res) => {
${cur < pages ? `<a href="?${new URLSearchParams({...req.query, page: cur+1}).toString()}">Next ›</a>` : ''}
</nav>` : ''}
</main>
+ <script>
+ (function () {
+ var sortSel = document.getElementById('sortSelect');
+ var dens = document.getElementById('densityRange');
+ // Sort: server-side. Persist choice, then navigate with ?sort=.
+ if (sortSel) {
+ var savedSort = localStorage.getItem('pr.sort');
+ var params = new URLSearchParams(location.search);
+ if (savedSort !== null && !params.has('sort') && savedSort !== '') {
+ params.set('sort', savedSort);
+ location.replace(location.pathname + '?' + params.toString());
+ return;
+ }
+ sortSel.addEventListener('change', function () {
+ localStorage.setItem('pr.sort', sortSel.value);
+ var p = new URLSearchParams(location.search);
+ if (sortSel.value) p.set('sort', sortSel.value); else p.delete('sort');
+ p.delete('page');
+ location.search = p.toString();
+ });
+ }
+ // Density: client-side CSS var, persisted.
+ if (dens) {
+ var savedDens = localStorage.getItem('pr.density');
+ if (savedDens) { dens.value = savedDens; }
+ var apply = function () {
+ document.documentElement.style.setProperty('--cardmin', dens.value + 'px');
+ };
+ apply();
+ dens.addEventListener('input', function () {
+ localStorage.setItem('pr.density', dens.value);
+ apply();
+ });
+ }
+ })();
+ </script>
`;
res.set('Content-Type','text/html').send(layout(`${type || 'All Products'}${q ? ` · ${q}` : ''}`, body, { q, type }));
});
← 4cd23c3 copy: replace 'private label' with 'exclusive' (brand-voice
·
back to Philipperomano
·
contacts: import DW_PHONE/PR_EMAIL constants from kamatera ( 03b56e8 →