[object Object]

← 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

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 →