← back to New Items Dashboard
feat: sort + density controls on wines & handbags grids
7d0b816a8cd55e92ba7f952b0e57df519e443834 · 2026-05-19 21:20:29 -0700 · Steve Abrams
Per Steve's standing rule, every product/item grid must have a working
sort <select> + density slider, both persisted to localStorage and
hydrated BEFORE the first fetch.
Frontend (public/index.html):
- New .grid-controls toolbar above each tab grid with sort select
(Newest / Vendor / Series / SKU / Title) + density range input
(180-480px card-min via CSS var --card-min).
- hydrateControls() reads localStorage and sets control values BEFORE
loadWines/loadHandbags fire, so the first paint already reflects the
saved user choice instead of flashing default→saved.
- Change handlers persist to namespaced keys (newItemsDash.<tab>.<ctl>)
then either re-fetch (sort) or just re-apply CSS var (density).
- Grids now render into dedicated <div id=wines-grid> / handbags-grid
containers so re-renders don't blow away the toolbars.
- applyDensity re-runs after every grid re-render to survive the
innerHTML wipe.
Server (server.js):
- New applySort(items, sort, type) helper. Honors ?sort=newest|vendor|
series|sku|title on both /api/new-wines and /api/new-handbags.
- 'vendor' falls back to brand / source when an item lacks vendor;
'series' falls back to vintage / model. Keeps shape consistent
across the two heterogeneous data sources.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M public/index.htmlM server.js
Diff
commit 7d0b816a8cd55e92ba7f952b0e57df519e443834
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 19 21:20:29 2026 -0700
feat: sort + density controls on wines & handbags grids
Per Steve's standing rule, every product/item grid must have a working
sort <select> + density slider, both persisted to localStorage and
hydrated BEFORE the first fetch.
Frontend (public/index.html):
- New .grid-controls toolbar above each tab grid with sort select
(Newest / Vendor / Series / SKU / Title) + density range input
(180-480px card-min via CSS var --card-min).
- hydrateControls() reads localStorage and sets control values BEFORE
loadWines/loadHandbags fire, so the first paint already reflects the
saved user choice instead of flashing default→saved.
- Change handlers persist to namespaced keys (newItemsDash.<tab>.<ctl>)
then either re-fetch (sort) or just re-apply CSS var (density).
- Grids now render into dedicated <div id=wines-grid> / handbags-grid
containers so re-renders don't blow away the toolbars.
- applyDensity re-runs after every grid re-render to survive the
innerHTML wipe.
Server (server.js):
- New applySort(items, sort, type) helper. Honors ?sort=newest|vendor|
series|sku|title on both /api/new-wines and /api/new-handbags.
- 'vendor' falls back to brand / source when an item lacks vendor;
'series' falls back to vintage / model. Keeps shape consistent
across the two heterogeneous data sources.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/index.html | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++----
server.js | 50 +++++++++++++++--
2 files changed, 201 insertions(+), 14 deletions(-)
diff --git a/public/index.html b/public/index.html
index 94abc60..e307249 100644
--- a/public/index.html
+++ b/public/index.html
@@ -121,11 +121,54 @@
.items-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 300px), 1fr));
gap: 20px;
margin-top: 20px;
}
+ .grid-controls {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ flex-wrap: wrap;
+ padding: 12px 14px;
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+ margin-bottom: 8px;
+ }
+
+ .grid-controls label {
+ font-size: 0.85em;
+ color: #374151;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .grid-controls select {
+ font-size: 0.9em;
+ padding: 6px 10px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ background: white;
+ color: #1f2937;
+ cursor: pointer;
+ }
+
+ .grid-controls input[type="range"] {
+ width: 160px;
+ cursor: pointer;
+ }
+
+ .grid-controls .density-val {
+ font-size: 0.8em;
+ color: #6b7280;
+ min-width: 48px;
+ font-variant-numeric: tabular-nums;
+ }
+
.item-card {
border: 2px solid #e5e7eb;
border-radius: 8px;
@@ -276,10 +319,40 @@
<div class="content">
<div id="wines" class="tab-content active">
- <div class="loading">Loading new wines...</div>
+ <div class="grid-controls">
+ <label>Sort:
+ <select id="wines-sort">
+ <option value="newest">Newest</option>
+ <option value="vendor">Vendor / Source</option>
+ <option value="series">Series / Vintage</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="title">Title A→Z</option>
+ </select>
+ </label>
+ <label>Density:
+ <input type="range" id="wines-density" min="180" max="480" step="20" value="300">
+ <span class="density-val" id="wines-density-val">300px</span>
+ </label>
+ </div>
+ <div id="wines-grid"><div class="loading">Loading new wines...</div></div>
</div>
<div id="handbags" class="tab-content">
- <div class="loading">Loading new handbags...</div>
+ <div class="grid-controls">
+ <label>Sort:
+ <select id="handbags-sort">
+ <option value="newest">Newest</option>
+ <option value="vendor">Brand</option>
+ <option value="series">Model</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="title">Title A→Z</option>
+ </select>
+ </label>
+ <label>Density:
+ <input type="range" id="handbags-density" min="180" max="480" step="20" value="300">
+ <span class="density-val" id="handbags-density-val">300px</span>
+ </label>
+ </div>
+ <div id="handbags-grid"><div class="loading">Loading new handbags...</div></div>
</div>
</div>
@@ -290,6 +363,42 @@
let winesData = [];
let handbagsData = [];
+ // ---- localStorage keys per Steve's standing rule: namespace by site+surface
+ const LS = {
+ winesSort: 'newItemsDash.wines.sort',
+ winesDensity: 'newItemsDash.wines.density',
+ handbagsSort: 'newItemsDash.handbags.sort',
+ handbagsDensity: 'newItemsDash.handbags.density',
+ };
+
+ // ---- Hydrate sort + density from localStorage BEFORE first fetch
+ function hydrateControls() {
+ const wSort = localStorage.getItem(LS.winesSort) || 'newest';
+ const wDen = parseInt(localStorage.getItem(LS.winesDensity) || '300', 10);
+ const hSort = localStorage.getItem(LS.handbagsSort) || 'newest';
+ const hDen = parseInt(localStorage.getItem(LS.handbagsDensity) || '300', 10);
+
+ const wSortEl = document.getElementById('wines-sort');
+ const wDenEl = document.getElementById('wines-density');
+ const hSortEl = document.getElementById('handbags-sort');
+ const hDenEl = document.getElementById('handbags-density');
+
+ if (wSortEl) wSortEl.value = wSort;
+ if (wDenEl) wDenEl.value = wDen;
+ if (hSortEl) hSortEl.value = hSort;
+ if (hDenEl) hDenEl.value = hDen;
+
+ applyDensity('wines', wDen);
+ applyDensity('handbags', hDen);
+ }
+
+ function applyDensity(which, px) {
+ const grid = document.getElementById(which + '-grid');
+ if (grid) grid.style.setProperty('--card-min', px + 'px');
+ const lbl = document.getElementById(which + '-density-val');
+ if (lbl) lbl.textContent = px + 'px';
+ }
+
function showTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
@@ -314,13 +423,14 @@
async function loadWines() {
try {
- const res = await fetch('/api/new-wines');
+ const sort = (document.getElementById('wines-sort') || {}).value || localStorage.getItem(LS.winesSort) || 'newest';
+ const res = await fetch('/api/new-wines?sort=' + encodeURIComponent(sort));
const data = await res.json();
winesData = data.wines || [];
document.getElementById('newWinesCount').textContent = winesData.length;
- const container = document.getElementById('wines');
+ const container = document.getElementById('wines-grid');
if (winesData.length === 0) {
container.innerHTML = `
@@ -350,13 +460,16 @@
</div>
`;
+ // Re-apply density now that the grid was re-rendered
+ applyDensity('wines', parseInt(localStorage.getItem(LS.winesDensity) || '300', 10));
+
if (data.timestamp) {
document.getElementById('lastUpdated').textContent =
`Last crawl: ${new Date(data.timestamp).toLocaleString()}`;
}
} catch (error) {
console.error('Error loading wines:', error);
- document.getElementById('wines').innerHTML = `
+ document.getElementById('wines-grid').innerHTML = `
<div class="empty-state">
<h3>Error loading wines</h3>
<p>Please try again later</p>
@@ -367,13 +480,14 @@
async function loadHandbags() {
try {
- const res = await fetch('/api/new-handbags');
+ const sort = (document.getElementById('handbags-sort') || {}).value || localStorage.getItem(LS.handbagsSort) || 'newest';
+ const res = await fetch('/api/new-handbags?sort=' + encodeURIComponent(sort));
const data = await res.json();
handbagsData = data.handbags || [];
document.getElementById('newHandbagsCount').textContent = handbagsData.length;
- const container = document.getElementById('handbags');
+ const container = document.getElementById('handbags-grid');
if (handbagsData.length === 0) {
container.innerHTML = `
@@ -406,9 +520,11 @@
`).join('')}
</div>
`;
+
+ applyDensity('handbags', parseInt(localStorage.getItem(LS.handbagsDensity) || '300', 10));
} catch (error) {
console.error('Error loading handbags:', error);
- document.getElementById('handbags').innerHTML = `
+ document.getElementById('handbags-grid').innerHTML = `
<div class="empty-state">
<h3>Error loading handbags</h3>
<p>Please try again later</p>
@@ -417,7 +533,36 @@
}
}
- // Load data on page load
+ // ---- Wire up sort + density change handlers
+ function wireControls() {
+ const wSort = document.getElementById('wines-sort');
+ const wDen = document.getElementById('wines-density');
+ const hSort = document.getElementById('handbags-sort');
+ const hDen = document.getElementById('handbags-density');
+
+ if (wSort) wSort.addEventListener('change', () => {
+ localStorage.setItem(LS.winesSort, wSort.value);
+ loadWines();
+ });
+ if (wDen) wDen.addEventListener('input', () => {
+ const px = parseInt(wDen.value, 10);
+ localStorage.setItem(LS.winesDensity, String(px));
+ applyDensity('wines', px);
+ });
+ if (hSort) hSort.addEventListener('change', () => {
+ localStorage.setItem(LS.handbagsSort, hSort.value);
+ loadHandbags();
+ });
+ if (hDen) hDen.addEventListener('input', () => {
+ const px = parseInt(hDen.value, 10);
+ localStorage.setItem(LS.handbagsDensity, String(px));
+ applyDensity('handbags', px);
+ });
+ }
+
+ // ---- Boot: hydrate FIRST, then fetch, then wire handlers
+ hydrateControls();
+ wireControls();
loadStats();
loadWines();
loadHandbags();
diff --git a/server.js b/server.js
index ec39ea3..6b5e94f 100644
--- a/server.js
+++ b/server.js
@@ -21,6 +21,44 @@ app.use((req, res, next) => {
// Serve static files
app.use(express.static('public'));
+// Shared sort helper — applies `sort` query param to any item list
+// in-memory. Frontend persists the user's choice and re-fetches with ?sort=
+function applySort(items, sort, type) {
+ if (!Array.isArray(items) || items.length === 0) return items;
+ const arr = items.slice();
+ const cmp = (a, b) => {
+ const av = (a == null) ? '' : a;
+ const bv = (b == null) ? '' : b;
+ if (typeof av === 'number' && typeof bv === 'number') return av - bv;
+ return String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
+ };
+ switch (sort) {
+ case 'title':
+ return arr.sort((a, b) => cmp(a.title || a.name || '', b.title || b.name || ''));
+ case 'source':
+ return arr.sort((a, b) => cmp(a.source || '', b.source || ''));
+ case 'vendor':
+ // wines have no vendor field; fall back to source. handbags use brand.
+ return arr.sort((a, b) => cmp(a.vendor || a.brand || a.source || '', b.vendor || b.brand || b.source || ''));
+ case 'series':
+ // wines: vintage; handbags: model
+ return arr.sort((a, b) => cmp(a.series || a.vintage || a.model || '', b.series || b.vintage || b.model || ''));
+ case 'sku':
+ return arr.sort((a, b) => cmp(a.sku || a.id || '', b.sku || a.id || ''));
+ case 'newest':
+ default: {
+ // newest: prefer crawled_at, then timestamp, otherwise leave server order
+ const hasTs = arr.some(x => x && (x.crawled_at || x.timestamp || x.listed_at));
+ if (!hasTs) return arr;
+ return arr.sort((a, b) => {
+ const at = new Date(a.crawled_at || a.timestamp || a.listed_at || 0).getTime();
+ const bt = new Date(b.crawled_at || b.timestamp || b.listed_at || 0).getTime();
+ return bt - at;
+ });
+ }
+ }
+}
+
// API: Get NEW wines
app.get('/api/new-wines', async (req, res) => {
try {
@@ -41,10 +79,12 @@ app.get('/api/new-wines', async (req, res) => {
const latestFile = path.join(dataDir, newWineFiles[0]);
const data = JSON.parse(await fs.readFile(latestFile, 'utf8'));
+ const wines = applySort(data.newWines || [], req.query.sort, 'wine');
+
res.json({
success: true,
- wines: data.newWines || [],
- count: data.newWinesCount || 0,
+ wines,
+ count: wines.length,
timestamp: data.timestamp,
summary: data.summary
});
@@ -74,10 +114,12 @@ app.get('/api/new-handbags', (req, res) => {
return res.json({ success: true, handbags: [], count: 0 });
}
+ const handbags = applySort(rows || [], req.query.sort, 'handbag');
+
res.json({
success: true,
- handbags: rows,
- count: rows.length
+ handbags,
+ count: handbags.length
});
});
});
← 793d2e2 harden: broaden .gitignore + 404-guard for snapshot files
·
back to New Items Dashboard
·
secure: basic-auth gate on all routes (server was public on b447df1 →