← back to Japan Enrich
viewer: host-based scoping — china.=Greenland-only (empty state for now), japan.=Sangetsu+Lilycolor unchanged
df5e3a0f005b4f46852efca5b2de984c74c8e763 · 2026-06-30 14:41:13 -0700 · Steve Abrams
server.js: per-request hostScope(req) reads Host header; china.* → source:'greenland' pool only, japan/local → lily+sangetsu (greenland excluded). Added /api/scope endpoint; /api/skus + /api/facets pre-filter ROWS by host pool and clamp f.source to in-scope sources so neither host can leak into the other. index.html fetches /api/scope first to set title/H1/subtitle + build source tabs (china: All+Greenland; japan: All+Lilycolor+Sangetsu) and shows a 'Greenland — catalog coming soon' empty state.
Files touched
M viewer-local/public/index.htmlM viewer-local/server.js
Diff
commit df5e3a0f005b4f46852efca5b2de984c74c8e763
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jun 30 14:41:13 2026 -0700
viewer: host-based scoping — china.=Greenland-only (empty state for now), japan.=Sangetsu+Lilycolor unchanged
server.js: per-request hostScope(req) reads Host header; china.* → source:'greenland' pool only, japan/local → lily+sangetsu (greenland excluded). Added /api/scope endpoint; /api/skus + /api/facets pre-filter ROWS by host pool and clamp f.source to in-scope sources so neither host can leak into the other. index.html fetches /api/scope first to set title/H1/subtitle + build source tabs (china: All+Greenland; japan: All+Lilycolor+Sangetsu) and shows a 'Greenland — catalog coming soon' empty state.
---
viewer-local/public/index.html | 53 ++++++++++++++++++++++++++++++--------
viewer-local/server.js | 58 ++++++++++++++++++++++++++++++++----------
2 files changed, 88 insertions(+), 23 deletions(-)
diff --git a/viewer-local/public/index.html b/viewer-local/public/index.html
index a6fabb8..c2f2a33 100644
--- a/viewer-local/public/index.html
+++ b/viewer-local/public/index.html
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Sangetsu + Lilycolor — Staged SKUs (offline preview)</title>
+<title>Staged SKUs (offline preview)</title>
<style>
:root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; }
* { box-sizing: border-box; }
@@ -77,12 +77,10 @@
</head>
<body>
<header>
- <h1>Sangetsu + Lilycolor <small id="sub">staged offline · read-only preview</small></h1>
+ <h1 id="h1">Sangetsu + Lilycolor <small id="sub">staged offline · read-only preview</small></h1>
<div class="controls">
<div class="seg" id="srcSeg">
<button data-src="all" class="on">All</button>
- <button data-src="lilycolor">Lilycolor</button>
- <button data-src="sangetsu">Sangetsu</button>
</div>
<input type="search" id="q" placeholder="Search SKU or pattern…">
<label class="dens">Sort
@@ -135,6 +133,9 @@ const grid = document.getElementById('grid');
const statusEl = document.getElementById('status');
const subEl = document.getElementById('sub');
const LIMIT = 120;
+// host scope (set by loadScope() before first load): {id,label,sub,tabs[]}
+let SCOPE = { id: 'japan', label: 'Sangetsu + Lilycolor', sub: 'staged offline · read-only preview',
+ tabs: [{ src: 'lilycolor', label: 'Lilycolor' }, { src: 'sangetsu', label: 'Sangetsu' }] };
// representative dot color per family (UI only — true hex comes from each card's palette)
const FAMILY_DOT = {
@@ -193,11 +194,34 @@ const facetsEl = document.getElementById('facets');
facetsEl.open = localStorage.getItem('dw_facets_open') !== '0';
facetsEl.ontoggle = () => localStorage.setItem('dw_facets_open', facetsEl.open ? '1' : '0');
-// restore sort/source/enriched UI
+// restore sort/enriched UI (source tabs are built by loadScope() once the host scope is known)
document.getElementById('sort').value = state.sort;
-document.querySelectorAll('#srcSeg button').forEach(b => b.classList.toggle('on', b.dataset.src === state.source));
document.querySelectorAll('#enrSeg button').forEach(b => b.classList.toggle('on', b.dataset.enr === state.enriched));
+// Build the source-tab bar for the current host scope and clamp a stale persisted source.
+// china shows only [All, Greenland]; japan shows [All, Lilycolor, Sangetsu].
+function buildSrcTabs() {
+ const seg = document.getElementById('srcSeg');
+ const valid = new Set(['all', ...SCOPE.tabs.map(t => t.src)]);
+ if (!valid.has(state.source)) { state.source = 'all'; localStorage.setItem('dw_src', 'all'); }
+ seg.innerHTML = '';
+ const mk = (src, label) => { const b = document.createElement('button'); b.dataset.src = src; b.textContent = label; if (src === state.source) b.classList.add('on'); return b; };
+ seg.appendChild(mk('all', 'All'));
+ SCOPE.tabs.forEach(t => seg.appendChild(mk(t.src, t.label)));
+}
+
+async function loadScope() {
+ try {
+ const res = await fetch('/api/scope');
+ if (res.ok) SCOPE = await res.json();
+ } catch (e) { /* keep default japan scope */ }
+ document.title = SCOPE.label + ' — Staged SKUs (offline preview)';
+ const h1 = document.getElementById('h1');
+ if (h1) h1.firstChild.textContent = SCOPE.label + ' ';
+ subEl.textContent = SCOPE.sub;
+ buildSrcTabs();
+}
+
function persistFacets() {
localStorage.setItem('dw_colors', JSON.stringify([...state.colors]));
localStorage.setItem('dw_styles', JSON.stringify([...state.styles]));
@@ -395,8 +419,16 @@ async function load() {
state.offset += data.rows.length;
state.done = state.offset >= data.total || data.rows.length === 0;
const fxNote = FX.rate ? ` · ¥→$ ${FX.rate.toFixed(5)} ${FX.src === 'live' ? 'live' : 'approx'} (${FX.asOf})` : ' · ¥→$ …';
- subEl.textContent = `staged offline · ${data.counts.all.toLocaleString()} SKUs (Lily ${data.counts.lilycolor.toLocaleString()} / Sangetsu ${data.counts.sangetsu.toLocaleString()}) · showing ${state.offset.toLocaleString()}/${state.total.toLocaleString()}${fxNote}`;
- statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} matching SKUs —` : 'scroll for more…';
+ // per-source breakdown built from the scope's tabs (Lily/Sangetsu for japan; Greenland for china)
+ const parts = (SCOPE.tabs || []).map(t => `${t.label} ${(data.counts[t.src] || 0).toLocaleString()}`).join(' / ');
+ const brk = parts ? ` (${parts})` : '';
+ subEl.textContent = `staged offline · ${data.counts.all.toLocaleString()} SKUs${brk} · showing ${state.offset.toLocaleString()}/${state.total.toLocaleString()}${fxNote}`;
+ if (data.total === 0) {
+ // graceful empty state — e.g. china before any Greenland data lands
+ statusEl.textContent = SCOPE.id === 'china' ? 'Greenland — catalog coming soon' : '— no matching SKUs —';
+ } else {
+ statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} matching SKUs —` : 'scroll for more…';
+ }
} catch (e) {
statusEl.textContent = 'Load error — check server';
} finally {
@@ -406,8 +438,9 @@ async function load() {
// infinite scroll
new IntersectionObserver((es) => { if (es[0].isIntersecting) load(); }, { rootMargin: '600px' }).observe(document.getElementById('sentinel'));
-refreshFacets();
-loadFX().finally(load); // pull the live JPY→USD rate first so the first cards render in $ (load runs regardless)
+// resolve the host scope FIRST (sets title/H1/subtitle + builds the source tabs), then
+// fan out facets + grid. Clamp the persisted source before any /api call goes out.
+loadScope().then(() => { refreshFacets(); loadFX().finally(load); });
</script>
</body>
</html>
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 8e8002c..7081486 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -244,19 +244,50 @@ function authed(req) {
return u === AUTH_USER && p === AUTH_PASS;
}
+// ── per-host catalog scope ──────────────────────────────────────────────────────
+// china.designerwallcoverings.com → Greenland-vendor catalog ONLY (source:'greenland').
+// japan. (or anything else / localhost) → the Japanese catalog, with any greenland row
+// EXCLUDED. The scope pre-filters ROWS BEFORE any user filter and overrides f.source so
+// the two hosts can never leak into each other.
+function hostScope(req) {
+ const host = String(req.headers.host || '').toLowerCase().split(':')[0];
+ if (host.startsWith('china.')) {
+ return { id: 'china', label: 'China — Greenland', sub: 'Greenland vendor · staged offline · read-only preview',
+ sources: ['greenland'], tabs: [{ src: 'greenland', label: 'Greenland' }] };
+ }
+ // japan + local dev + anything else: the existing Japanese catalog, greenland excluded
+ return { id: 'japan', label: 'Sangetsu + Lilycolor', sub: 'staged offline · read-only preview',
+ sources: ['lilycolor', 'sangetsu'], tabs: [{ src: 'lilycolor', label: 'Lilycolor' }, { src: 'sangetsu', label: 'Sangetsu' }] };
+}
+// rows visible for this host (the pool every count + filter runs against)
+const scopedRows = (scope) => ROWS.filter((r) => scope.sources.includes(r.source));
+
const server = http.createServer((req, res) => {
if (REQUIRE_AUTH && !authed(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Japan/China — admin preview"' });
return res.end('Authentication required');
}
const u = new URL(req.url, `http://localhost:${PORT}`);
+ const scope = hostScope(req);
+ const POOL = scopedRows(scope);
+ // tell the client which host scope it's in so it can set the H1/subtitle + hide the
+ // irrelevant source tabs (china shows only Greenland; japan shows Lilycolor+Sangetsu).
+ if (u.pathname === '/api/scope') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ id: scope.id, label: scope.label, sub: scope.sub, tabs: scope.tabs }));
+ return;
+ }
if (u.pathname === '/api/skus') {
const f = parseFilters(u);
+ // host scope overrides the user-supplied source so a stale localStorage tab can't
+ // request lilycolor on china (or greenland on japan); clamp to in-scope sources only.
+ if (!scope.sources.includes(f.source)) f.source = 'all';
const sort = u.searchParams.get('sort') || 'newest';
const offset = parseInt(u.searchParams.get('offset') || '0', 10);
const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10), 500);
- let rows = applyFilters(ROWS, f);
- const counts = { all: ROWS.length, lilycolor: ROWS.filter((r) => r.source === 'lilycolor').length, sangetsu: ROWS.filter((r) => r.source === 'sangetsu').length };
+ let rows = applyFilters(POOL, f);
+ const counts = { all: POOL.length };
+ for (const s of scope.sources) counts[s] = POOL.filter((r) => r.source === s).length;
if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
const page = rows.slice(offset, offset + limit);
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -266,24 +297,25 @@ const server = http.createServer((req, res) => {
// facet counts — each dimension counted against the OTHER active filters (drill-down)
if (u.pathname === '/api/facets') {
const f = parseFilters(u);
+ if (!scope.sources.includes(f.source)) f.source = 'all';
const tally = (rows, key) => { const m = {}; for (const r of rows) { const v = r[key]; if (v) m[v] = (m[v] || 0) + 1; } return m; };
const tallyArr = (rows, key) => { const m = {}; for (const r of rows) { for (const v of (r[key] || [])) m[v] = (m[v] || 0) + 1; } return m; };
- const enrRows = applyFilters(ROWS, f, { skip: 'enriched' });
+ const enrRows = applyFilters(POOL, f, { skip: 'enriched' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
- color_family: tally(applyFilters(ROWS, f, { skip: 'color' }), 'color_family'),
- style: tally(applyFilters(ROWS, f, { skip: 'style' }), 'style'),
- material: tally(applyFilters(ROWS, f, { skip: 'material' }), 'material'),
- collection: tally(applyFilters(ROWS, f, { skip: 'collection' }), 'collection'),
- width: tally(applyFilters(ROWS, f, { skip: 'width' }), 'width'),
- fire_class: tally(applyFilters(ROWS, f, { skip: 'fire' }), 'fire_class'),
- functions: tallyArr(applyFilters(ROWS, f, { skip: 'funcs' }), 'functions'),
- price_band: tally(applyFilters(ROWS, f, { skip: 'price' }), 'price_band'),
- image_state: tally(applyFilters(ROWS, f, { skip: 'image' }), 'image_state'),
+ color_family: tally(applyFilters(POOL, f, { skip: 'color' }), 'color_family'),
+ style: tally(applyFilters(POOL, f, { skip: 'style' }), 'style'),
+ material: tally(applyFilters(POOL, f, { skip: 'material' }), 'material'),
+ collection: tally(applyFilters(POOL, f, { skip: 'collection' }), 'collection'),
+ width: tally(applyFilters(POOL, f, { skip: 'width' }), 'width'),
+ fire_class: tally(applyFilters(POOL, f, { skip: 'fire' }), 'fire_class'),
+ functions: tallyArr(applyFilters(POOL, f, { skip: 'funcs' }), 'functions'),
+ price_band: tally(applyFilters(POOL, f, { skip: 'price' }), 'price_band'),
+ image_state: tally(applyFilters(POOL, f, { skip: 'image' }), 'image_state'),
price_order: PRICE_ORDER,
enriched: enrRows.filter((r) => r.enriched).length,
unenriched: enrRows.filter((r) => !r.enriched).length,
- total: applyFilters(ROWS, f).length,
+ total: applyFilters(POOL, f).length,
family_order: FAMILY_ORDER,
}));
return;
← 1826109 viewer: env-gated admin Basic Auth (REQUIRE_AUTH) for deploy
·
back to Japan Enrich
·
greenland: scrape vendor API → 880 patterns/3117 colorways, cb79303 →