← 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
M public/index.htmlM server.js
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 →