[object Object]

← back to New Engine

Add search + vendor filter, sort, since-date filter, and density slider

6df1adf145bc4bd88bf8086de546bed321b7abb1 · 2026-07-28 14:48:17 -0700 · Steve Abrams

- server: fetchItems() with safe filters (sq-escaped q/vendor, whitelisted sort/since) + /api/facets vendor list
- front-end: persisted controls (localStorage), density switches list<->multi-col image grid
- injection-guarded, verified across all filters

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 6df1adf145bc4bd88bf8086de546bed321b7abb1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 14:48:17 2026 -0700

    Add search + vendor filter, sort, since-date filter, and density slider
    
    - server: fetchItems() with safe filters (sq-escaped q/vendor, whitelisted sort/since) + /api/facets vendor list
    - front-end: persisted controls (localStorage), density switches list<->multi-col image grid
    - injection-guarded, verified across all filters
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/index.html | 186 ++++++++++++++++++++++++++++++++++++++++++------------
 server.js         |  79 +++++++++++++++++++----
 2 files changed, 215 insertions(+), 50 deletions(-)

diff --git a/public/index.html b/public/index.html
index 3854a3b..a797f00 100644
--- a/public/index.html
+++ b/public/index.html
@@ -7,14 +7,14 @@
 <link rel="preconnect" href="https://fonts.googleapis.com">
 <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&family=Share+Tech+Mono&display=swap" rel="stylesheet">
 <style>
-:root{--gold:#c8a96a;--gold-lit:#e6c98f;--ink:#e8e6e0;--muted:#8f8c84;--green:#5fbf6a}
+:root{--gold:#c8a96a;--gold-lit:#e6c98f;--ink:#e8e6e0;--muted:#8f8c84;--green:#5fbf6a;--cols:1}
 *{box-sizing:border-box;margin:0;padding:0}
 body{font-family:'Jost',sans-serif;color:var(--ink);
   background:radial-gradient(120% 80% at 50% -10%,#22201d,#131211 45%,#070605);min-height:100vh;padding:22px 14px 60px;-webkit-font-smoothing:antialiased}
-.wrap{max-width:1080px;margin:0 auto}
+.wrap{max-width:1180px;margin:0 auto}
 header{display:flex;align-items:center;justify-content:space-between;gap:14px;
   background:linear-gradient(180deg,#1b1a17,#0d0c0b);border:1px solid #2b2925;border-radius:16px;
-  padding:18px 24px;box-shadow:inset 0 2px 0 rgba(255,255,255,.04),0 20px 50px rgba(0,0,0,.7);position:sticky;top:10px;z-index:5}
+  padding:18px 24px;box-shadow:inset 0 2px 0 rgba(255,255,255,.04),0 20px 50px rgba(0,0,0,.7);position:sticky;top:10px;z-index:6}
 .brand{display:flex;align-items:center;gap:12px}
 .brand .h1{font-family:'Cormorant Garamond',serif;font-size:26px;font-weight:600;letter-spacing:.5px;
   background:linear-gradient(180deg,#f4efe4 0%,#c8a96a 55%,#8a6f3e);-webkit-background-clip:text;background-clip:text;color:var(--gold-lit)}
@@ -25,8 +25,24 @@ header{display:flex;align-items:center;justify-content:space-between;gap:14px;
 .stats{text-align:right;font-family:'Share Tech Mono',monospace}
 .stats .big{font-size:26px;color:var(--gold-lit);text-shadow:0 0 12px rgba(200,169,106,.4);line-height:1}
 .stats .lbl{font-size:9px;letter-spacing:3px;color:var(--muted)}
-.subline{text-align:center;font-size:10px;letter-spacing:4px;color:#7d7a72;margin:14px 0 16px}
-.list{display:flex;flex-direction:column;gap:9px}
+/* controls */
+.controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:14px 0 8px;
+  background:linear-gradient(180deg,#191815,#100f0e);border:1px solid #2b2925;border-radius:14px;padding:12px 16px;position:sticky;top:96px;z-index:5}
+.controls input[type=search],.controls select{font-family:'Jost',sans-serif;font-size:13px;color:var(--ink);
+  background:#0e0d0c;border:1px solid #37352f;border-radius:9px;padding:8px 11px;outline:none}
+.controls input[type=search]{flex:1 1 220px;min-width:160px}
+.controls input[type=search]:focus,.controls select:focus{border-color:var(--gold)}
+.controls select{cursor:pointer}
+.fld{display:flex;align-items:center;gap:6px}
+.fld label{font-size:9px;letter-spacing:2px;color:var(--muted);text-transform:uppercase}
+.dens{display:flex;align-items:center;gap:8px}
+.dens input[type=range]{width:96px;accent-color:var(--gold);cursor:pointer}
+.btn{font-family:'Share Tech Mono',monospace;font-size:10px;letter-spacing:1px;color:var(--muted);
+  background:#0e0d0c;border:1px solid #37352f;border-radius:9px;padding:8px 11px;cursor:pointer}
+.btn:hover{color:var(--gold-lit);border-color:var(--gold)}
+.subline{text-align:center;font-size:10px;letter-spacing:4px;color:#7d7a72;margin:6px 0 16px}
+/* list / grid */
+.list{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:9px}
 .row{display:grid;grid-template-columns:30px 56px 1fr 200px;gap:13px;align-items:center;
   background:linear-gradient(180deg,#191815,#100f0e);border:1px solid #2b2925;border-radius:12px;
   padding:11px 14px;box-shadow:0 5px 14px rgba(0,0,0,.45);transition:box-shadow .15s}
@@ -48,9 +64,22 @@ header{display:flex;align-items:center;justify-content:space-between;gap:14px;
 .pal i span{font-family:'Share Tech Mono',monospace;font-size:7px;color:rgba(255,255,255,.9);text-shadow:0 1px 2px #000;opacity:0;padding-bottom:1px}
 .pal:hover i span{opacity:1}
 .gidl{font-family:'Share Tech Mono',monospace;font-size:9px;color:#5f5c54;letter-spacing:1px}
-.hint{text-align:center;color:#5f5c54;font-size:11px;margin-top:20px;letter-spacing:2px}
 a.open{font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--gold-lit);text-decoration:none;letter-spacing:1px}
 a.open:hover{text-shadow:0 0 8px rgba(200,169,106,.6)}
+/* compact card (density >= 2) */
+.card{background:linear-gradient(180deg,#191815,#100f0e);border:1px solid #2b2925;border-radius:12px;overflow:hidden;
+  box-shadow:0 5px 14px rgba(0,0,0,.45);transition:box-shadow .15s}
+.card:hover{box-shadow:0 8px 20px rgba(0,0,0,.7),0 0 0 1px rgba(200,169,106,.3)}
+.card.fresh{animation:land .8s ease}
+.card a.ph{display:block;aspect-ratio:1/1;background:#201f1c}
+.card img{width:100%;height:100%;object-fit:cover;display:block}
+.card .cbody{padding:8px 10px 10px}
+.card .ct{font-family:'Cormorant Garamond',serif;font-size:14px;line-height:1.12;max-height:2.3em;overflow:hidden}
+.card .ct a{color:var(--ink);text-decoration:none}
+.card .ct a:hover{color:var(--gold-lit)}
+.card .cmeta{font-size:9px;letter-spacing:.5px;color:var(--muted);text-transform:uppercase;margin:3px 0 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.card .pal{height:16px;border-radius:5px}
+.hint{text-align:center;color:#5f5c54;font-size:11px;margin-top:20px;letter-spacing:2px}
 </style>
 </head>
 <body>
@@ -62,50 +91,129 @@ a.open:hover{text-shadow:0 0 8px rgba(200,169,106,.6)}
         <div class="sub-h">Designer Wallcoverings</div>
         <div class="live"><span class="dot"></span><span id="clock">— live catalog feed —</span></div></div>
     </div>
-    <div class="stats"><div class="big" id="total">0</div><div class="lbl">ACTIVE SKUS</div></div>
+    <div class="stats"><div class="big" id="total">0</div><div class="lbl" id="totlbl">MATCHES</div></div>
   </header>
-  <div class="subline">N E W E S T &nbsp; P R O D U C T S &nbsp; · &nbsp; N E W E S T &nbsp; F I R S T &nbsp; · &nbsp; A U T O - R E F R E S H</div>
+
+  <div class="controls">
+    <input type="search" id="q" placeholder="Search title, vendor, or SKU…" autocomplete="off">
+    <div class="fld"><label>Vendor</label>
+      <select id="vendor"><option value="">All</option></select></div>
+    <div class="fld"><label>Sort</label>
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="color">Color</option>
+        <option value="vendor">Vendor A→Z</option>
+        <option value="sku">SKU A→Z</option>
+        <option value="title">Title A→Z</option>
+      </select></div>
+    <div class="fld"><label>Since</label>
+      <select id="since">
+        <option value="all">All time</option>
+        <option value="today">Today</option>
+        <option value="7d">7 days</option>
+        <option value="30d">30 days</option>
+      </select></div>
+    <div class="dens"><label style="font-size:9px;letter-spacing:2px;color:var(--muted)">DENSITY</label>
+      <input type="range" id="dens" min="1" max="6" step="1" value="1"></div>
+    <button class="btn" id="reset">RESET</button>
+  </div>
+
+  <div class="subline" id="subline">N E W E S T &nbsp; F I R S T &nbsp; · &nbsp; L I V E &nbsp; · &nbsp; A U T O - R E F R E S H</div>
   <div class="list" id="list"></div>
   <div class="hint" id="hint">connecting…</div>
 </div>
 <script>
-let seen=new Set(), first=true;
+const $ = id => document.getElementById(id);
+const LS = 'newengine.v1';
+const state = Object.assign({ q:'', vendor:'', sort:'newest', since:'all', cols:1 },
+  JSON.parse(localStorage.getItem(LS) || '{}'));
+let seen = new Set(), first = true;
+function save(){ localStorage.setItem(LS, JSON.stringify(state)); }
 function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
-// route product images through the same-origin proxy so a dead CDN asset degrades to a
-// transparent placeholder instead of a browser 404
 function proxied(url){ return url ? '/img?u='+encodeURIComponent(url) : ''; }
+function palHTML(p, big){ const a=p.palette||[];
+  return a.length ? a.map(c=>`<i style="flex:${c.pct||1};background:${esc(c.hex)}">${big?`<span>${esc(c.hex)}</span>`:''}</i>`).join('') : '<i style="flex:1;background:#201f1c"></i>'; }
+function href(p){ return p.handle ? `https://designer-laboratory-sandbox.myshopify.com/products/${encodeURIComponent(p.handle)}` : null; }
+
+// hydrate controls from persisted state
+$('q').value=state.q; $('vendor').value=state.vendor; $('sort').value=state.sort;
+$('since').value=state.since; $('dens').value=state.cols;
+document.documentElement.style.setProperty('--cols', state.cols);
+
+function qs(){ const p=new URLSearchParams({limit:'60', sort:state.sort, since:state.since});
+  if(state.q) p.set('q',state.q); if(state.vendor) p.set('vendor',state.vendor); return p.toString(); }
+
+function renderRow(p,i,isNew){
+  const h=href(p), title=h?`<a href="${h}" target="_blank" rel="noopener noreferrer">${esc(p.title)}</a>`:esc(p.title);
+  const img=proxied(p.image), thumb=img?`<img class="thumb" src="${img}" loading="lazy" alt="">`:`<div class="thumb"></div>`;
+  const chips=(p.tags||[]).map(t=>`<span class="chip">${esc(t)}</span>`).join('');
+  const el=document.createElement('div'); el.className='row'+(isNew?' fresh':'');
+  el.innerHTML=`<div class="idx ${i<3?'top':''}">${i+1}</div>
+    ${h?`<a href="${h}" target="_blank" rel="noopener noreferrer">${thumb}</a>`:thumb}
+    <div class="main"><div class="t">${title}</div>
+      <div class="meta">${esc(p.vendor)||'—'} · ${esc(p.sku)||'no sku'} · ${(p.palette||[]).length} colors</div>
+      <div class="chips">${chips}</div></div>
+    <div class="right"><div class="pal">${palHTML(p,true)}</div>
+      <div class="gidl">#${esc(p.gid)}</div>
+      ${h?`<a class="open" href="${h}" target="_blank" rel="noopener noreferrer">OPEN PAGE ▸</a>`:''}</div>`;
+  return el;
+}
+function renderCard(p,isNew){
+  const h=href(p), img=proxied(p.image);
+  const ph=img?`<img src="${img}" loading="lazy" alt="">`:'';
+  const el=document.createElement('div'); el.className='card'+(isNew?' fresh':'');
+  el.innerHTML=`${h?`<a class="ph" href="${h}" target="_blank" rel="noopener noreferrer">${ph}</a>`:`<div class="ph">${ph}</div>`}
+    <div class="cbody">
+      <div class="ct">${h?`<a href="${h}" target="_blank" rel="noopener noreferrer">${esc(p.title)}</a>`:esc(p.title)}</div>
+      <div class="cmeta">${esc(p.vendor)||'—'} · ${esc(p.sku)||''}</div>
+      <div class="pal">${palHTML(p,false)}</div>
+    </div>`;
+  return el;
+}
+function paint(records){
+  const list=$('list'); list.innerHTML='';
+  const compact = state.cols>1;
+  records.forEach((p,i)=>{
+    const isNew=!seen.has(p.gid)&&!first;
+    list.appendChild(compact?renderCard(p,isNew):renderRow(p,i,isNew));
+  });
+  seen=new Set(records.map(p=>p.gid)); first=false;
+}
+let lastRecords=[];
 async function tick(){
   try{
-    const r=await fetch('/api/new-items?limit=60',{cache:'no-store'});
+    const r=await fetch('/api/new-items?'+qs(),{cache:'no-store'});
     const d=await r.json();
-    document.getElementById('total').textContent=(d.total||0).toLocaleString();
-    document.getElementById('clock').textContent='updated '+new Date().toLocaleTimeString();
-    document.getElementById('hint').textContent=d.records.length?'▲ newest at top · live feed':'waiting for items to land…';
-    const list=document.getElementById('list'); list.innerHTML='';
-    d.records.forEach((p,i)=>{
-      const isNew=!seen.has(p.gid)&&!first;
-      const pal=(p.palette&&p.palette.length)?p.palette.map(c=>`<i style="flex:${c.pct||1};background:${esc(c.hex)}"><span>${esc(c.hex)}</span></i>`).join(''):'<i style="flex:1;background:#201f1c"></i>';
-      const href=p.handle?`https://designer-laboratory-sandbox.myshopify.com/products/${encodeURIComponent(p.handle)}`:null;
-      const title=href?`<a href="${href}" target="_blank" rel="noopener noreferrer">${esc(p.title)}</a>`:esc(p.title);
-      const chips=(p.tags||[]).map(t=>`<span class="chip">${esc(t)}</span>`).join('');
-      const img=proxied(p.image);
-      const thumb=img?`<img class="thumb" src="${img}" loading="lazy" alt="">`:`<div class="thumb"></div>`;
-      const row=document.createElement('div');
-      row.className='row'+(isNew?' fresh':'');
-      row.innerHTML=`<div class="idx ${i<3?'top':''}">${i+1}</div>
-        ${href?`<a href="${href}" target="_blank" rel="noopener noreferrer">${thumb}</a>`:thumb}
-        <div class="main"><div class="t">${title}</div>
-          <div class="meta">${esc(p.vendor)||'—'} · ${esc(p.sku)||'no sku'} · ${(p.palette||[]).length} colors</div>
-          <div class="chips">${chips}</div></div>
-        <div class="right"><div class="pal">${pal}</div>
-          <div class="gidl">#${esc(p.gid)}</div>
-          ${href?`<a class="open" href="${href}" target="_blank" rel="noopener noreferrer">OPEN PAGE ▸</a>`:''}</div>`;
-      list.appendChild(row);
-    });
-    seen=new Set(d.records.map(p=>p.gid)); first=false;
-  }catch(e){document.getElementById('hint').textContent='feed error: '+e.message;}
+    lastRecords=d.records||[];
+    $('total').textContent=(d.total||0).toLocaleString();
+    $('totlbl').textContent=(state.q||state.vendor||state.since!=='all')?'MATCHES':'ACTIVE SKUS';
+    $('clock').textContent='updated '+new Date().toLocaleTimeString();
+    $('hint').textContent=lastRecords.length?`▲ ${state.sort==='newest'?'newest at top':'sorted by '+state.sort} · ${lastRecords.length} shown`:'no matches — loosen the filters';
+    paint(lastRecords);
+  }catch(e){ $('hint').textContent='feed error: '+e.message; }
 }
-tick(); setInterval(tick,5000);
+// density is a client-only relayout — no refetch
+function applyDensity(){ document.documentElement.style.setProperty('--cols', state.cols); first=true; paint(lastRecords); }
+
+// wire controls
+let deb; $('q').addEventListener('input',e=>{ state.q=e.target.value.trim(); save();
+  clearTimeout(deb); deb=setTimeout(()=>{ first=true; tick(); },350); });
+['vendor','sort','since'].forEach(id=>$(id).addEventListener('change',e=>{
+  state[id]=e.target.value; save(); first=true; tick(); }));
+$('dens').addEventListener('input',e=>{ state.cols=+e.target.value; save(); applyDensity(); });
+$('reset').addEventListener('click',()=>{
+  Object.assign(state,{q:'',vendor:'',sort:'newest',since:'all'});
+  $('q').value=''; $('vendor').value=''; $('sort').value='newest'; $('since').value='all';
+  save(); first=true; tick(); });
+
+// load vendor facets once
+fetch('/api/facets',{cache:'no-store'}).then(r=>r.json()).then(d=>{
+  const sel=$('vendor'); (d.vendors||[]).forEach(v=>{
+    const o=document.createElement('option'); o.value=v.vendor; o.textContent=`${v.vendor} (${v.n})`; sel.appendChild(o); });
+  sel.value=state.vendor; // restore selection now that options exist
+}).catch(()=>{});
+
+tick(); setInterval(tick,6000);
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index 7b33dcd..776cc98 100644
--- a/server.js
+++ b/server.js
@@ -12,14 +12,45 @@ const PGDB = process.env.PGDATABASE || 'dw_unified';
 const PGHOST = process.env.PGHOST || '/tmp';
 const MIME = { '.html': 'text/html', '.json': 'application/json', '.css': 'text/css', '.js': 'text/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
 
-// Read-only live pull of the newest ACTIVE, imaged products. `limit` is clamped to an
-// integer before interpolation (the only dynamic value in the SQL) so there's no
-// injection surface. Returns { records:[…newest-first…], total } where total is the
-// TRUE count of eligible products so the header never lies about the catalog size.
+// SQL string-literal escape: double single-quotes. PG runs with standard_conforming_strings
+// on by default, so backslashes are literal and doubling quotes fully neutralizes injection.
+const sq = s => "'" + String(s).replace(/'/g, "''") + "'";
+
+// Read-only live pull of the newest ACTIVE, imaged products, with optional filters.
+// Only these dynamic values ever touch the SQL, and each is neutralized:
+//   • limit          → clamped integer
+//   • q / vendor     → sq()-escaped string literals
+//   • sort / since   → whitelisted (raw user value is NEVER interpolated)
+// Returns { records:[…], total } where total is the count matching the SAME filters.
 // color_enrichment is LEFT-joined — a product without a palette still shows.
-function fetchNewest(limit, cb) {
-  const n = Math.max(1, Math.min(parseInt(limit, 10) || 60, 200));
-  const where = `p.status = 'ACTIVE' and p.created_at_shopify is not null and p.image_url is not null`;
+function fetchItems(params, cb) {
+  const n = Math.max(1, Math.min(parseInt(params.get('limit'), 10) || 60, 200));
+  const q = (params.get('q') || '').trim().slice(0, 80);
+  const vendor = (params.get('vendor') || '').trim().slice(0, 120);
+  const sortKey = params.get('sort') || 'newest';
+  const since = params.get('since') || 'all';
+
+  const conds = [`p.status = 'ACTIVE'`, `p.created_at_shopify is not null`, `p.image_url is not null`];
+  if (q) conds.push(`(p.title ilike ${sq('%' + q + '%')} or p.vendor ilike ${sq('%' + q + '%')}
+      or coalesce(p.dw_sku,p.mfr_sku,p.variant_sku,'') ilike ${sq('%' + q + '%')})`);
+  if (vendor) conds.push(`p.vendor = ${sq(vendor)}`);
+  const sinceMap = {
+    today: `p.created_at_shopify >= date_trunc('day', now())`,
+    '7d': `p.created_at_shopify >= now() - interval '7 days'`,
+    '30d': `p.created_at_shopify >= now() - interval '30 days'`,
+  };
+  if (sinceMap[since]) conds.push(sinceMap[since]);
+  const where = conds.join(' and ');
+
+  const orderMap = {
+    newest: 'p.created_at_shopify desc',
+    vendor: `p.vendor asc nulls last, p.created_at_shopify desc`,
+    sku: `coalesce(nullif(p.dw_sku,''),nullif(p.mfr_sku,''),nullif(p.variant_sku,'')) asc nulls last`,
+    title: 'lower(p.title) asc nulls last',
+    color: `coalesce(nullif(e.color_family,''),nullif(e.dominant_color,''),'zzzz') asc, p.created_at_shopify desc`,
+  };
+  const orderBy = orderMap[sortKey] || orderMap.newest;
+
   const sql = `select json_build_object(
     'records', (select coalesce(json_agg(row_to_json(t)),'[]') from (
         select regexp_replace(p.shopify_id,'.*/','') as gid, p.title, p.vendor, p.handle, p.image_url as image,
@@ -34,7 +65,7 @@ function fetchNewest(limit, cb) {
         from shopify_products p
         left join shopify_color_enrichment e on e.shopify_id = p.shopify_id
         where ${where}
-        order by p.created_at_shopify desc
+        order by ${orderBy}
         limit ${n}) t),
     'total', (select count(*) from shopify_products p where ${where})
   )`;
@@ -46,6 +77,22 @@ function fetchNewest(limit, cb) {
   });
 }
 
+// Distinct vendors (with counts) among the eligible set — powers the vendor filter dropdown.
+function fetchFacets(cb) {
+  const sql = `select json_build_object(
+    'vendors', (select coalesce(json_agg(row_to_json(t)),'[]') from (
+        select vendor, count(*) as n
+        from shopify_products
+        where status='ACTIVE' and image_url is not null and vendor is not null and vendor <> ''
+        group by vendor order by count(*) desc, vendor asc limit 250) t))`;
+  execFile('psql', ['-h', PGHOST, '-d', PGDB, '-tAc', sql], { maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
+    if (err) return cb(err);
+    const out = (stdout || '').trim();
+    if (!out) return cb(new Error('empty'));
+    cb(null, out);
+  });
+}
+
 // HTTP Basic Auth gate. Credentials come from BASIC_AUTH ("user:pass"), default admin:DW2024!.
 // /healthz is intentionally left OPEN so the deploy smoke-test + uptime probes work unauthed.
 const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
@@ -67,10 +114,20 @@ http.createServer((req, res) => {
     return res.end('auth required');
   }
 
-  // Live newest feed. On any DB failure, fall back to the frozen snapshot (wrapped to
-  // match the {records,total} contract) so the front end always gets valid data.
+  // Vendor facets for the filter dropdown.
+  if (p === '/api/facets') {
+    fetchFacets((err, json) => {
+      if (!err && json) { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' }); return res.end(json); }
+      res.writeHead(200, { 'Content-Type': 'application/json' });
+      res.end('{"vendors":[]}');
+    });
+    return;
+  }
+
+  // Live newest feed (with filters). On any DB failure, fall back to the frozen snapshot
+  // (wrapped to match the {records,total} contract) so the front end always gets valid data.
   if (p === '/api/new-items') {
-    fetchNewest(u.searchParams.get('limit'), (err, json) => {
+    fetchItems(u.searchParams, (err, json) => {
       if (!err && json) { res.writeHead(200, { 'Content-Type': 'application/json', 'X-Data-Source': 'live', 'Cache-Control': 'no-store' }); return res.end(json); }
       fs.readFile(path.join(ROOT, 'data.json'), (e, buf) => {
         if (e) { res.writeHead(502); return res.end('no live db and no snapshot'); }

← da6a927 Save prod nginx vhost (IP-specific listen 45.61.58.125:80/44  ·  back to New Engine  ·  deploy.conf: HEALTH_URL -> public https domain (surfaces on 40f95bb →