← back to Zuber Internal
Add color context+Color sort, tag facet (needs-curation), strip healthz count
2d17e2abc77d582d53e14cdaa942f4b109bacd52 · 2026-07-09 13:44:34 -0700 · Steve Abrams
FIX1: color_name falls back to ai_colors[0].name (column null for all rows);
color_hex falls back to ai_colors[0].hex; server-side Color sort by hue(color_hex)
+ Color option in sort select (parity with sibling viewers).
FIX2: unauthenticated /healthz returns {ok:true} only, no product count leak.
FIX3: ai_tags now built into a Tag facet (needs-curation/broken-source-image
visible+filterable, count 2) + tag filter in /api/products.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/index.htmlM server.js
Diff
commit 2d17e2abc77d582d53e14cdaa942f4b109bacd52
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 9 13:44:34 2026 -0700
Add color context+Color sort, tag facet (needs-curation), strip healthz count
FIX1: color_name falls back to ai_colors[0].name (column null for all rows);
color_hex falls back to ai_colors[0].hex; server-side Color sort by hue(color_hex)
+ Color option in sort select (parity with sibling viewers).
FIX2: unauthenticated /healthz returns {ok:true} only, no product count leak.
FIX3: ai_tags now built into a Tag facet (needs-curation/broken-source-image
visible+filterable, count 2) + tag filter in /api/products.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/index.html | 3 ++-
server.js | 37 ++++++++++++++++++++++++++++---------
2 files changed, 30 insertions(+), 10 deletions(-)
diff --git a/public/index.html b/public/index.html
index 3a8a44b..7f0746e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -54,6 +54,7 @@
<label>Sort
<select id="sort">
<option value="newest">Newest</option>
+ <option value="color">Color</option>
<option value="pattern">Pattern A→Z</option>
<option value="collection">Collection</option>
<option value="sku">SKU A→Z</option>
@@ -82,7 +83,7 @@
const $ = (s, r=document) => r.querySelector(s);
const API = location.origin;
const state = { q: '', sort: 'newest', sel: {} };
-const FACET_DEFS = [['collection','Collection'],['product_type','Type'],['material','Material'],['style','Style']];
+const FACET_DEFS = [['collection','Collection'],['product_type','Type'],['material','Material'],['style','Style'],['tag','Tag']];
function ls(k, d) { try { return JSON.parse(localStorage.getItem('zi_'+k)) ?? d; } catch { return d; } }
function save(k, v) { localStorage.setItem('zi_'+k, JSON.stringify(v)); }
diff --git a/server.js b/server.js
index 646ccc4..3c7457a 100644
--- a/server.js
+++ b/server.js
@@ -15,8 +15,9 @@ const REFRESH_SEC = Number(process.env.REFRESH_INTERVAL_SEC || 300);
const app = express();
app.use(require('compression')());
-// unauthenticated health probe BEFORE the gate
-app.get('/healthz', (_req, res) => res.json({ ok: true, products: ROWS.length, lastRefresh: LAST_REFRESH }));
+// unauthenticated health probe BEFORE the gate — keep it a bare liveness signal
+// (no catalog size/detail; the count is only exposed behind auth via /api/config)
+app.get('/healthz', (_req, res) => res.json({ ok: true }));
app.use((req, res, next) => {
const [scheme, b64] = (req.headers.authorization || '').split(' ');
@@ -50,13 +51,16 @@ function load() {
if (r.all_images) { try { imgs = JSON.parse(r.all_images); } catch { imgs = []; } }
if (!imgs.length && r.image_url) imgs = [r.image_url];
const parseArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' && v.trim() ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);
+ const aiColors = parseArr(r.ai_colors);
return {
idx: i,
sku: r.mfr_sku,
mfr_sku: r.mfr_sku,
pattern_name: r.pattern_name || r.mfr_sku,
pattern_name_fr: r.pattern_name_fr || null,
- color_name: r.color_name || null,
+ // zuber_catalog.color_name is null for all rows; fall back to the enriched
+ // ai_colors[0].name (the same pattern Gracie uses) so cards + Color sort have context.
+ color_name: r.color_name || (aiColors[0] && aiColors[0].name) || null,
collection: r.collection || null,
product_type: r.product_type || null,
material: r.material || null,
@@ -66,10 +70,10 @@ function load() {
image_count: imgs.length,
product_url: r.product_url || null,
description: r.description || null,
- ai_colors: parseArr(r.ai_colors),
+ ai_colors: aiColors,
ai_styles: parseArr(r.ai_styles),
ai_tags: parseArr(r.ai_tags),
- color_hex: r.color_hex || null,
+ color_hex: r.color_hex || (aiColors[0] && aiColors[0].hex) || null,
created_at: r.created_at || null,
};
});
@@ -84,25 +88,39 @@ setInterval(load, REFRESH_SEC * 1000);
// ---- facets (field-tab tables) -------------------------------------------
function facets() {
const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
- const collection = {}, product_type = {}, material = {}, style = {};
+ const collection = {}, product_type = {}, material = {}, style = {}, tag = {};
for (const r of ROWS) {
bump(collection, r.collection);
bump(product_type, r.product_type);
bump(material, r.material);
for (const s of r.ai_styles) bump(style, s);
+ for (const t of r.ai_tags) bump(tag, t);
}
const byCount = (o) => Object.entries(o).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
const byAlpha = (o) => Object.entries(o).sort((a, b) => a[0].localeCompare(b[0])).map(([value, count]) => ({ value, count }));
- return { collection: byAlpha(collection), product_type: byCount(product_type), material: byCount(material), style: byCount(style) };
+ return { collection: byAlpha(collection), product_type: byCount(product_type), material: byCount(material), style: byCount(style), tag: byCount(tag) };
}
// ---- api ------------------------------------------------------------------
const cmp = (a, b) => String(a ?? '').localeCompare(String(b ?? ''), undefined, { sensitivity: 'base' });
+// hue() maps a #rrggbb hex to a 0-360 color-wheel bucket (grays/unknown → 999 so they
+// sort last). Identical helper the sibling viewers (fromental/crezana) use for Color sort.
+function hue(hex) {
+ if (!hex || hex[0] !== '#' || hex.length !== 7) return 999;
+ const r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+ if (d === 0) return 999;
+ let h;
+ if (mx === r) h = ((g - b) / d) % 6; else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
+ h *= 60; if (h < 0) h += 360;
+ return h;
+}
const sorters = {
newest: null,
pattern: (a, b) => cmp(a.pattern_name, b.pattern_name),
collection: (a, b) => cmp(a.collection, b.collection) || cmp(a.pattern_name, b.pattern_name),
sku: (a, b) => cmp(a.mfr_sku, b.mfr_sku),
+ color: (a, b) => (hue(a.color_hex) - hue(b.color_hex)) || cmp(a.color_name, b.color_name) || cmp(a.pattern_name, b.pattern_name),
};
app.get('/api/config', (_req, res) => res.json({
@@ -115,11 +133,12 @@ app.get('/api/products', (req, res) => {
const q = (req.query.q || '').toString().trim().toLowerCase();
const sort = (req.query.sort || 'newest').toString();
const sel = (k) => { const v = req.query[k]; return v ? v.toString().split('|').filter(Boolean) : []; };
- const fColl = sel('collection'), fType = sel('product_type'), fMat = sel('material'), fStyle = sel('style');
+ const fColl = sel('collection'), fType = sel('product_type'), fMat = sel('material'), fStyle = sel('style'), fTag = sel('tag');
const inAny = (arr, val) => arr.length === 0 || arr.includes(val ?? '');
let rows = ROWS.filter((r) =>
inAny(fColl, r.collection) && inAny(fType, r.product_type) && inAny(fMat, r.material) &&
- (fStyle.length === 0 || r.ai_styles.some((s) => fStyle.includes(s))));
+ (fStyle.length === 0 || r.ai_styles.some((s) => fStyle.includes(s))) &&
+ (fTag.length === 0 || r.ai_tags.some((t) => fTag.includes(t))));
if (q) rows = rows.filter((r) => ((r.pattern_name || '') + ' ' + (r.pattern_name_fr || '') + ' ' + (r.mfr_sku || '') + ' ' + (r.collection || '') + ' ' + (r.description || '')).toLowerCase().includes(q));
if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
res.json({ total: rows.length, all: ROWS.length, products: rows });
← afe7565 Fix zuber viewer 401 crash: plain-ASCII WWW-Authenticate rea
·
back to Zuber Internal
·
jsonl-backed data layer: self-contained bundle for Kamatera ada4b82 →