[object Object]

← back to New Import Viewer

Panel items B+D: No-image filter (12,156 rows) + structured empty state; threshold-colored catalog-health strip with click-to-filter quick chips

b8c32b48ad2db85d96175841028cba9252183fc9 · 2026-06-09 19:15:31 -0700 · SteveStudio2

Files touched

Diff

commit b8c32b48ad2db85d96175841028cba9252183fc9
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue Jun 9 19:15:31 2026 -0700

    Panel items B+D: No-image filter (12,156 rows) + structured empty state; threshold-colored catalog-health strip with click-to-filter quick chips
---
 public/index.html | 40 +++++++++++++++++++++++++++++++++++-----
 server.js         |  7 +++++--
 2 files changed, 40 insertions(+), 7 deletions(-)

diff --git a/public/index.html b/public/index.html
index 460c048..499a90b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -31,7 +31,18 @@ body.armed #livestrip{display:block}
 .card.sel{outline:2px solid var(--acc);outline-offset:-2px}
 .imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
 .imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center 12%;display:block}
-.imgwrap.noimg::after{content:'no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#3a4150;font-size:11px;letter-spacing:.5px}
+.imgwrap.noimg{background:repeating-linear-gradient(45deg,#0a0b0f,#0a0b0f 8px,#101218 8px,#101218 16px)}
+.imgwrap.noimg::after{content:'🚫 no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5b6373;font-size:11px;font-weight:600;letter-spacing:.3px}
+/* catalog-health strip — one colored count chip per state; click to filter */
+#health{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 16px;border-bottom:1px solid var(--line);background:#0e0f15}
+#health .hc{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:700;border-radius:999px;padding:3px 11px;cursor:pointer;border:1px solid var(--line);background:var(--panel);color:var(--mut)}
+#health .hc .n{font-variant-numeric:tabular-nums}
+#health .hc:hover{border-color:var(--acc)}
+#health .hc.active{outline:2px solid var(--acc);outline-offset:1px}
+#health .hc.zero{opacity:.4}
+#health .hc.warn{background:#2a1c05;border-color:var(--new);color:var(--new)}
+#health .hc.bad{background:#2a0606;border-color:var(--arch);color:#fca5a5}
+#health .hlabel{font-size:10.5px;color:var(--mut);text-transform:uppercase;letter-spacing:.5px;margin-right:2px}
 .card .b{padding:9px 10px;display:flex;flex-direction:column;gap:6px}
 .vend{font-size:10.5px;color:var(--mut);font-weight:600;letter-spacing:.2px}
 .ttl{font-weight:600;font-size:14px;line-height:1.25;letter-spacing:-.1px}
@@ -130,6 +141,7 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
       <option value="published">🟢 Published</option>
       <option value="unpublished">⚪ Unpublished</option>
       <option value="archived">🔴 Archived</option>
+      <option value="noimage">🚫 No image</option>
       <option value="all">▦ All</option>
     </select></div>
   <div class="ctl"><label class="muted">Vendor</label>
@@ -151,6 +163,8 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
   <button class="btn" id="selall">☑ Select all shown</button>
   <button class="armtoggle" id="armtoggle" style="display:none" onclick="toggleArm()">🔒 Live writes: OFF</button>
 </header>
+<!-- catalog-health strip: threshold-colored, clickable quick-filters (D) -->
+<div id="health" aria-label="catalog health"></div>
 <div class="grid" id="grid"></div>
 <button id="more" style="display:none">Load more</button>
 <div id="empty" style="display:none">No products match.</div>
@@ -292,14 +306,30 @@ async function boot(){
   const s=await (await fetch('/api/stats')).json();
   state.armable=!!s.armable; state.liveActions=s.liveActions||[]; state.store=s.store||''; state.armed=false; const c=s.counts||{};
   setHeaderCount(c);
-  [['new','🟠 New to import'],['onshopify','🔵 On Shopify (all)'],['published','🟢 Published'],['unpublished','⚪ Unpublished'],['archived','🔴 Archived'],['all','▦ All']].forEach(([k,lbl])=>{
+  [['new','🟠 New to import'],['onshopify','🔵 On Shopify (all)'],['published','🟢 Published'],['unpublished','⚪ Unpublished'],['archived','🔴 Archived'],['noimage','🚫 No image'],['all','▦ All']].forEach(([k,lbl])=>{
     const o=$('#status').querySelector(`option[value="${k}"]`); if(o&&c[k]!=null) o.textContent=`${lbl} (${c[k].toLocaleString()})`;
   });
   renderArm();
-  window.__counts=c;
+  window.__counts=c; renderHealth(c);
   await reloadVendors(); load(true);
 }
-$('#status').onchange=async()=>{localStorage.setItem('nip.status',$('#status').value);setHeaderCount(window.__counts);await reloadVendors();load(true);};
+// Threshold-colored catalog-health strip (D). Actionable-bad states (Unpublished,
+// Archived, No image) flag amber/red when non-zero; healthy/neutral stay plain;
+// zero counts dim. Each chip is a one-click quick-filter.
+function renderHealth(c){
+  const el=$('#health'); if(!el)return;
+  const cur=$('#status').value;
+  const defs=[['new','🟠 New','warn'],['published','🟢 Published','ok'],['unpublished','⚪ Unpublished','warn'],
+    ['archived','🔴 Archived','bad'],['noimage','🚫 No image','bad'],['onshopify','🔵 On Shopify','ok'],['all','▦ All','ok']];
+  el.innerHTML='<span class="hlabel">catalog health</span>'+defs.map(([k,lbl,sev])=>{
+    const n=(c||{})[k]||0; let cls='hc';
+    if(n===0) cls+=' zero'; else if(sev==='bad') cls+=' bad'; else if(sev==='warn') cls+=' warn';
+    if(k===cur) cls+=' active';
+    return `<span class="${cls}" onclick="pickHealth('${k}')">${lbl} <span class="n">${n.toLocaleString()}</span></span>`;
+  }).join('');
+}
+function pickHealth(k){const s=$('#status'); s.value=k; s.onchange();}
+$('#status').onchange=async()=>{localStorage.setItem('nip.status',$('#status').value);setHeaderCount(window.__counts);renderHealth(window.__counts);await reloadVendors();load(true);};
 $('#vendor').onchange=()=>load(true);
 $('#sort').onchange=()=>{localStorage.setItem('nip.sort',$('#sort').value);load(true);};
 let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>load(true),300);};
@@ -333,7 +363,7 @@ async function rowAction(id,spec,btn){
   }catch(e){if(msg)msg.textContent='✗ '+e.message;}
   btn.disabled=false;
 }
-window.rowAction=rowAction; window.togglePick=togglePick; window.togglePane=togglePane; window.toggleArm=toggleArm;
+window.rowAction=rowAction; window.togglePick=togglePick; window.togglePane=togglePane; window.toggleArm=toggleArm; window.pickHealth=pickHealth;
 $('#bulkgo').onclick=async()=>{
   const ids=[...sel]; if(!ids.length)return;
   const [action,mode]=$('#bulkact').value.split(':');
diff --git a/server.js b/server.js
index 8ec667c..ef072aa 100644
--- a/server.js
+++ b/server.js
@@ -96,6 +96,7 @@ const STATUS_FILTERS = {
   published:   "upper(sp.status)='ACTIVE'",
   unpublished: "upper(sp.status)='DRAFT'",
   archived:    "upper(sp.status)='ARCHIVED'",
+  noimage:     "(vc.image_url IS NULL OR vc.image_url='')",
 };
 
 function q(sql) {
@@ -162,7 +163,8 @@ const server = http.createServer((req, res) => {
           count(*) FILTER (WHERE upper(sp.status)='DRAFT'),
           count(*) FILTER (WHERE upper(sp.status)='ARCHIVED'),
           count(*),
-          to_char(max(vc.last_scraped_at),'YYYY-MM-DD HH24:MI')
+          to_char(max(vc.last_scraped_at),'YYYY-MM-DD HH24:MI'),
+          count(*) FILTER (WHERE vc.image_url IS NULL OR vc.image_url='')
         FROM vendor_catalog vc ${SP_JOIN}`)[0] || [];
       const armable = LIVE && !!SHOP_TOKEN && LIVE_ACTIONS.size > 0;
       return send(res, 200, JSON.stringify({
@@ -174,7 +176,8 @@ const server = http.createServer((req, res) => {
         liveActions: armable ? [...LIVE_ACTIONS] : [],
         store: SHOP_STORE,
         counts: { new: +r[0] || 0, onshopify: +r[1] || 0, published: +r[2] || 0,
-                  unpublished: +r[3] || 0, archived: +r[4] || 0, all: +r[5] || 0 },
+                  unpublished: +r[3] || 0, archived: +r[4] || 0, all: +r[5] || 0,
+                  noimage: +r[7] || 0 },
         newestCrawl: r[6] || null,
       }));
     }

← fe11c1f Panel item A: split details/actions into separate triggers —  ·  back to New Import Viewer  ·  Full action suite per chip + select 1→all: every card render 1833735 →