← back to Hollywood Wallcoverings
Server-side grid sort: honor ?sort param across all infinite-scroll pages
623f740848052c46cbd3217898f61bb8dbfb50ae · 2026-05-19 08:00:13 -0700 · Steve Abrams
Sort was client-side only — reordering only the ~48 cards already in the
DOM, so newly scrolled pages appended in newest order regardless. Now
filterAndPaginate sorts the full filtered list before pagination, the
select reloads with ?sort=, /api/page carries it forward, and saved sort
hydrates from localStorage (one-time redirect) before first render.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit 623f740848052c46cbd3217898f61bb8dbfb50ae
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 19 08:00:13 2026 -0700
Server-side grid sort: honor ?sort param across all infinite-scroll pages
Sort was client-side only — reordering only the ~48 cards already in the
DOM, so newly scrolled pages appended in newest order regardless. Now
filterAndPaginate sorts the full filtered list before pagination, the
select reloads with ?sort=, /api/page carries it forward, and saved sort
hydrates from localStorage (one-time redirect) before first render.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
server.js | 103 ++++++++++++++++++++++++++++++++++++++++++++------------------
1 file changed, 73 insertions(+), 30 deletions(-)
diff --git a/server.js b/server.js
index 8bcaa94..c047a49 100644
--- a/server.js
+++ b/server.js
@@ -72,10 +72,44 @@ function paginate(arr, page, perPage = 60) {
return { items: arr.slice((p-1)*perPage, p*perPage), page: p, pages, total };
}
+// DW STANDARD: server-side sort so infinite-scroll respects the chosen order
+// across ALL pages, not just the first batch of cards loaded in the DOM.
+function colorRank(p) {
+ const t = `${p.title||''} ${p.color||''}`.toLowerCase();
+ if (/(black|noir|onyx|ink)/.test(t)) return 1;
+ if (/(white|ivory|cream|paper|alabaster)/.test(t)) return 2;
+ if (/(gold|brass|champagne|bronze|amber|tan|caramel|sand)/.test(t)) return 3;
+ if (/(silver|grey|gray|platinum|pewter|smoke)/.test(t)) return 4;
+ if (/(red|crimson|burgundy|wine|garnet)/.test(t)) return 5;
+ if (/(green|emerald|forest|sage|olive|moss)/.test(t)) return 6;
+ if (/(blue|navy|cobalt|sapphire|indigo)/.test(t)) return 7;
+ if (/(purple|plum|violet|aubergine|amethyst)/.test(t)) return 8;
+ return 9;
+}
+function styleKey(p) {
+ const t = `${p.title||''}`.toLowerCase();
+ if (/(damask|toile|baroque|rococo|ornate)/.test(t)) return '1';
+ if (/(stripe|trellis|geometric)/.test(t)) return '2';
+ if (/(floral|botanical|vine|leaf)/.test(t)) return '3';
+ if (/(grass|natural|jute|hemp|raffia|linen|cork)/.test(t)) return '4';
+ if (/(metallic|foil)/.test(t)) return '5';
+ return '9';
+}
+function sortProducts(list, mode) {
+ const titleOf = p => String(p.title || '');
+ const skuOf = p => String(p.dw_sku || p.handle || '');
+ if (mode === 'sku') return [...list].sort((a,b) => skuOf(a).localeCompare(skuOf(b)));
+ if (mode === 'title') return [...list].sort((a,b) => titleOf(a).localeCompare(titleOf(b)));
+ if (mode === 'color') return [...list].sort((a,b) => colorRank(a) - colorRank(b) || titleOf(a).localeCompare(titleOf(b)));
+ if (mode === 'style') return [...list].sort((a,b) => styleKey(a).localeCompare(styleKey(b)) || titleOf(a).localeCompare(titleOf(b)));
+ return list; // 'newest' = natural order
+}
+
// JSON endpoint for infinite scroll
function filterAndPaginate(req, perPage = 48) {
const q = (req.query.q || '').toString().toLowerCase().trim();
const type = (req.query.type || '').toString();
+ const sort = (req.query.sort || 'newest').toString();
const page = parseInt(req.query.page) || 1;
let filtered = DATA;
if (type) filtered = filtered.filter(p => NORM_TYPE(p.product_type) === type);
@@ -86,9 +120,10 @@ function filterAndPaginate(req, perPage = 48) {
return tokens.every(t => hay.includes(t));
});
}
+ filtered = sortProducts(filtered, sort);
const pages = Math.max(1, Math.ceil(filtered.length / perPage));
const p = Math.min(Math.max(1, page), pages);
- return { items: filtered.slice((p-1)*perPage, p*perPage), page: p, pages, total: filtered.length };
+ return { items: filtered.slice((p-1)*perPage, p*perPage), page: p, pages, total: filtered.length, sort };
}
function shopifyUrl(handle) {
@@ -513,7 +548,7 @@ app.get('/api/page', (req, res) => {
app.get('/', (req, res) => {
const q = (req.query.q || '').toString().toLowerCase().trim();
const type = (req.query.type || '').toString();
- const { items, page: cur, pages, total } = filterAndPaginate(req, 48);
+ const { items, page: cur, pages, total, sort } = filterAndPaginate(req, 48);
const cards = items.map(cardHtml).join('');
@@ -526,20 +561,33 @@ app.get('/', (req, res) => {
<div class="controls-bar">
<label for="sort">Sort</label>
<select id="sort">
- <option value="newest">Newest</option>
- <option value="color">Color</option>
- <option value="style">Style</option>
- <option value="sku">SKU A→Z</option>
- <option value="title">Title A→Z</option>
+ <option value="newest"${sort==='newest'?' selected':''}>Newest</option>
+ <option value="color"${sort==='color'?' selected':''}>Color</option>
+ <option value="style"${sort==='style'?' selected':''}>Style</option>
+ <option value="sku"${sort==='sku'?' selected':''}>SKU A→Z</option>
+ <option value="title"${sort==='title'?' selected':''}>Title A→Z</option>
</select>
<label for="density" style="margin-left:14px">Density</label>
<input id="density" type="range" min="180" max="380" step="10" value="240">
</div>`}
${items.length === 0 ? '<div class="empty">No products match. Try a different search or category.</div>' : `<div class="grid" id="grid">${cards}</div>`}
- <div id="loadmore" data-q="${esc(q)}" data-type="${esc(type)}" data-page="${cur}" data-pages="${pages}" style="text-align:center;padding:48px 0;color:var(--muted);font-size:11px;letter-spacing:.18em;text-transform:uppercase">${cur < pages ? 'Loading more…' : '— end —'}</div>
+ <div id="loadmore" data-q="${esc(q)}" data-type="${esc(type)}" data-sort="${esc(sort)}" data-page="${cur}" data-pages="${pages}" style="text-align:center;padding:48px 0;color:var(--muted);font-size:11px;letter-spacing:.18em;text-transform:uppercase">${cur < pages ? 'Loading more…' : '— end —'}</div>
</main>
<script>
(function(){
+ const STORE='hw_grid';
+ const saved=(()=>{try{return JSON.parse(localStorage.getItem(STORE)||'{}')}catch(e){return {}}})();
+ const url=new URL(window.location.href);
+ const urlSort=url.searchParams.get('sort')||'newest';
+
+ // Hydrate saved sort BEFORE first grid render: if the URL has no explicit
+ // sort but a different one is saved, redirect once to apply it server-side.
+ if(saved.sort && saved.sort!=='newest' && !url.searchParams.get('sort')){
+ url.searchParams.set('sort',saved.sort);
+ window.location.replace(url.toString());
+ return;
+ }
+
const grid=document.getElementById('grid');
const lm=document.getElementById('loadmore');
if(!grid||!lm)return;
@@ -548,6 +596,7 @@ app.get('/', (req, res) => {
let loading=false;
const q=lm.dataset.q;
const type=lm.dataset.type;
+ const sort=lm.dataset.sort||'newest';
async function loadNext(){
if(loading||page>=pages)return;
loading=true;
@@ -555,6 +604,7 @@ app.get('/', (req, res) => {
const params=new URLSearchParams({page});
if(q)params.set('q',q);
if(type)params.set('type',type);
+ if(sort&&sort!=='newest')params.set('sort',sort);
try{
const r=await fetch('/api/page?'+params.toString());
const d=await r.json();
@@ -566,34 +616,27 @@ app.get('/', (req, res) => {
const io=new IntersectionObserver(es=>{es.forEach(e=>{if(e.isIntersecting)loadNext();});},{rootMargin:'400px'});
io.observe(lm);
- // Sort + density controls (client-side, persisted)
+ // Sort — server-side: persist + reload so ALL pages honor the order.
const sortSel=document.getElementById('sort');
- const dens=document.getElementById('density');
- const STORE='hw_grid';
- const saved=(()=>{try{return JSON.parse(localStorage.getItem(STORE)||'{}')}catch{return {}}})();
- function applyDensity(v){grid.style.gridTemplateColumns='repeat(auto-fill,minmax('+v+'px,1fr))';}
- function colorBucket(t){t=(t||'').toLowerCase();if(/(black|noir|onyx|ink)/.test(t))return 1;if(/(white|ivory|cream|paper|alabaster)/.test(t))return 2;if(/(gold|brass|champagne|bronze|amber|tan|caramel|sand)/.test(t))return 3;if(/(silver|grey|gray|platinum|pewter|smoke)/.test(t))return 4;if(/(red|crimson|burgundy|wine|garnet)/.test(t))return 5;if(/(green|emerald|forest|sage|olive|moss)/.test(t))return 6;if(/(blue|navy|cobalt|sapphire|indigo)/.test(t))return 7;if(/(purple|plum|violet|aubergine|amethyst)/.test(t))return 8;return 9;}
- function styleBucket(t){t=(t||'').toLowerCase();if(/(damask|toile|baroque|rococo|ornate)/.test(t))return 1;if(/(stripe|trellis|geometric)/.test(t))return 2;if(/(floral|botanical|vine|leaf)/.test(t))return 3;if(/(grass|natural|jute|hemp|raffia|linen|cork)/.test(t))return 4;if(/(metallic|foil|leaf)/.test(t))return 5;return 9;}
- function sortGrid(mode){
- const cards=Array.from(grid.querySelectorAll('.card'));
- const titleOf=c=>(c.querySelector('.t')?.textContent||'').trim();
- const skuOf=c=>(c.querySelector('.sub')?.textContent||'').trim();
- if(mode==='color')cards.sort((a,b)=>colorBucket(titleOf(a))-colorBucket(titleOf(b))||titleOf(a).localeCompare(titleOf(b)));
- else if(mode==='style')cards.sort((a,b)=>styleBucket(titleOf(a))-styleBucket(titleOf(b))||titleOf(a).localeCompare(titleOf(b)));
- else if(mode==='sku')cards.sort((a,b)=>skuOf(a).localeCompare(skuOf(b)));
- else if(mode==='title')cards.sort((a,b)=>titleOf(a).localeCompare(titleOf(b)));
- // newest = original order; clear+re-append in stored order
- cards.forEach(c=>grid.appendChild(c));
- }
if(sortSel){
- sortSel.value=saved.sort||'newest';
- sortGrid(sortSel.value);
- sortSel.addEventListener('change',()=>{sortGrid(sortSel.value);saved.sort=sortSel.value;localStorage.setItem(STORE,JSON.stringify(saved));});
+ sortSel.value=urlSort;
+ sortSel.addEventListener('change',()=>{
+ saved.sort=sortSel.value;
+ try{localStorage.setItem(STORE,JSON.stringify(saved));}catch(e){}
+ const u=new URL(window.location.href);
+ if(sortSel.value==='newest')u.searchParams.delete('sort');
+ else u.searchParams.set('sort',sortSel.value);
+ window.location.assign(u.toString());
+ });
}
+
+ // Density — pure client-side, persisted.
+ const dens=document.getElementById('density');
+ function applyDensity(v){grid.style.gridTemplateColumns='repeat(auto-fill,minmax('+v+'px,1fr))';}
if(dens){
dens.value=saved.density||240;
applyDensity(dens.value);
- dens.addEventListener('input',()=>{applyDensity(dens.value);saved.density=dens.value;localStorage.setItem(STORE,JSON.stringify(saved));});
+ dens.addEventListener('input',()=>{applyDensity(dens.value);saved.density=dens.value;try{localStorage.setItem(STORE,JSON.stringify(saved));}catch(e){}});
}
})();
</script>
← 75860b9 fix /contact: real phone+email (@hollywoodwallcoverings.com)
·
back to Hollywood Wallcoverings
·
Untrack backup snapshot, broaden .gitignore, add .bak/.pre- 2befa8b →