[object Object]

← back to Designer Wallcoverings

Add offline web viewer for staged Sangetsu + Lilycolor SKUs (sort+density+infinite-scroll, single-writer restage)

f8cd79bc077fcc69e50170fbcca4bd025f38c196 · 2026-06-30 04:18:34 -0700 · Steve

Files touched

Diff

commit f8cd79bc077fcc69e50170fbcca4bd025f38c196
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 30 04:18:34 2026 -0700

    Add offline web viewer for staged Sangetsu + Lilycolor SKUs (sort+density+infinite-scroll, single-writer restage)
---
 onboarding/sangetsu-lilycolor/restage-sangetsu.sh  |  11 ++
 .../sangetsu-lilycolor/viewer/public/index.html    | 128 +++++++++++++++++++++
 onboarding/sangetsu-lilycolor/viewer/server.js     | 105 +++++++++++++++++
 3 files changed, 244 insertions(+)

diff --git a/onboarding/sangetsu-lilycolor/restage-sangetsu.sh b/onboarding/sangetsu-lilycolor/restage-sangetsu.sh
new file mode 100644
index 00000000..8299338e
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/restage-sangetsu.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# Clean single-writer restage of all 623 Sangetsu patterns. Kills any concurrent
+# stage process first so the JSONL can't be corrupted by interleaved writes.
+set -u
+cd "$(dirname "$0")"
+pkill -f "sangetsu-sitemap-scraper.cjs stage" 2>/dev/null
+sleep 1
+: > staging/sangetsu-staging.jsonl          # truncate to a clean empty file
+echo "[restage] starting clean single-writer stage of 623 patterns $(date)"
+node sangetsu-sitemap-scraper.cjs stage 623
+echo "[restage] DONE $(date) — staged $(wc -l < staging/sangetsu-staging.jsonl) patterns"
diff --git a/onboarding/sangetsu-lilycolor/viewer/public/index.html b/onboarding/sangetsu-lilycolor/viewer/public/index.html
new file mode 100644
index 00000000..bd89949a
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/viewer/public/index.html
@@ -0,0 +1,128 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Sangetsu + Lilycolor — 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; }
+  body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
+  header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; }
+  h1 { margin:0 0 10px; font-size:18px; font-weight:600; letter-spacing:.02em; }
+  h1 small { color:var(--mut); font-weight:400; font-size:13px; margin-left:8px; }
+  .controls { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
+  .seg { display:flex; gap:2px; background:var(--card); border:1px solid var(--line); border-radius:8px; padding:2px; }
+  .seg button { background:none; border:none; color:var(--mut); padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px; }
+  .seg button.on { background:var(--accent); color:#1a1407; font-weight:600; }
+  select, input[type=search] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
+  input[type=search] { min-width:220px; }
+  .dens { display:flex; align-items:center; gap:8px; color:var(--mut); }
+  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:18px 20px 60px; }
+  .card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
+  .thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
+  .thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
+  .badge { position:absolute; top:6px; left:6px; font-size:10px; font-weight:700; letter-spacing:.04em; padding:2px 7px; border-radius:20px; text-transform:uppercase; }
+  .badge.lilycolor { background:#7c5cff; color:#fff; }
+  .badge.sangetsu { background:#1f9e6b; color:#fff; }
+  .badge.sample { position:absolute; top:6px; right:6px; left:auto; background:#444; color:#ddd; }
+  .meta { padding:8px 10px; }
+  .sku { font-weight:700; font-size:13px; color:var(--accent); word-break:break-all; }
+  .ttl { color:var(--mut); font-size:11.5px; margin-top:3px; max-height:2.8em; overflow:hidden; }
+  .spec { margin-top:5px; font-size:11px; color:#777; display:flex; gap:8px; flex-wrap:wrap; }
+  #sentinel { height:1px; }
+  #status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }
+</style>
+</head>
+<body>
+<header>
+  <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
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="sku">SKU A→Z</option>
+        <option value="title">Title A→Z</option>
+        <option value="width">Width ↑</option>
+      </select>
+    </label>
+    <label class="dens">Density
+      <input type="range" id="dens" min="3" max="10" value="6">
+    </label>
+  </div>
+</header>
+<div class="grid" id="grid"></div>
+<div id="sentinel"></div>
+<div id="status">loading…</div>
+
+<script>
+const grid = document.getElementById('grid');
+const statusEl = document.getElementById('status');
+const subEl = document.getElementById('sub');
+const LIMIT = 120;
+let state = {
+  source: localStorage.getItem('dw_src') || 'all',
+  sort: localStorage.getItem('dw_sort') || 'newest',
+  q: '',
+  offset: 0, total: 0, loading: false, done: false,
+};
+
+// density persists
+const dens = document.getElementById('dens');
+dens.value = localStorage.getItem('dw_cols') || 6;
+document.documentElement.style.setProperty('--cols', dens.value);
+dens.oninput = () => { document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('dw_cols', dens.value); };
+
+// restore sort/source UI
+document.getElementById('sort').value = state.sort;
+document.querySelectorAll('#srcSeg button').forEach(b => b.classList.toggle('on', b.dataset.src === state.source));
+
+function reset() { grid.innerHTML=''; state.offset=0; state.done=false; statusEl.textContent='loading…'; load(); }
+
+document.getElementById('srcSeg').onclick = (e) => {
+  const b = e.target.closest('button'); if (!b) return;
+  document.querySelectorAll('#srcSeg button').forEach(x => x.classList.remove('on')); b.classList.add('on');
+  state.source = b.dataset.src; localStorage.setItem('dw_src', state.source); reset();
+};
+document.getElementById('sort').onchange = (e) => { state.sort = e.target.value; localStorage.setItem('dw_sort', state.sort); reset(); };
+let qt; document.getElementById('q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); reset(); }, 250); };
+
+function card(r) {
+  const el = document.createElement('div'); el.className='card';
+  const thumb = r.image
+    ? `<div class="thumb" style="background-image:url('${r.image}')"><span class="badge ${r.source}">${r.source==='lilycolor'?'Lily':'Sangetsu'}</span>${r.is_sample?'<span class="badge sample">sample</span>':''}</div>`
+    : `<div class="thumb empty"><span class="badge ${r.source}">${r.source==='lilycolor'?'Lily':'Sangetsu'}</span>no image</div>`;
+  const specs = [r.width, r.composition, r.fire].filter(Boolean).map(s=>`<span>${s}</span>`).join('');
+  el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku||'—'}</div><div class="ttl" title="${(r.title||'').replace(/"/g,'&quot;')}">${r.title||''}</div><div class="spec">${specs}</div></div>`;
+  if (r.url) { el.style.cursor='pointer'; el.onclick=()=>window.open(r.url,'_blank'); }
+  return el;
+}
+
+async function load() {
+  if (state.loading || state.done) return;
+  state.loading = true;
+  const p = new URLSearchParams({ source:state.source, sort:state.sort, q:state.q, offset:state.offset, limit:LIMIT });
+  const res = await fetch('/api/skus?'+p); const data = await res.json();
+  state.total = data.total;
+  const frag = document.createDocumentFragment();
+  data.rows.forEach(r => frag.appendChild(card(r)));
+  grid.appendChild(frag);
+  state.offset += data.rows.length;
+  state.done = state.offset >= data.total;
+  state.loading = false;
+  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()}`;
+  statusEl.textContent = state.done ? `— end · ${state.total.toLocaleString()} SKUs —` : 'scroll for more…';
+}
+
+// infinite scroll
+new IntersectionObserver((es)=>{ if (es[0].isIntersecting) load(); }, { rootMargin:'600px' }).observe(document.getElementById('sentinel'));
+load();
+</script>
+</body>
+</html>
diff --git a/onboarding/sangetsu-lilycolor/viewer/server.js b/onboarding/sangetsu-lilycolor/viewer/server.js
new file mode 100644
index 00000000..aaf2e178
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/viewer/server.js
@@ -0,0 +1,105 @@
+#!/usr/bin/env node
+/**
+ * Staging viewer for the Sangetsu + Lilycolor onboarding catalogs (OFFLINE / read-only).
+ * Reads the two staging JSONL files, flattens them to a uniform SKU row, and serves a
+ * paginated /api/skus endpoint behind a sort + density + infinite-scroll grid.
+ *
+ * HARD: read-only. No Shopify, no dw_unified, no publish. Pure local preview of staged data.
+ *   node viewer/server.js [port]   (default 9931)
+ */
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = parseInt(process.argv[2] || '9931', 10);
+const DIR = path.join(__dirname, '..', 'staging');
+const LILY = path.join(DIR, 'lilycolor-staging.jsonl');
+const SANG = path.join(DIR, 'sangetsu-staging.jsonl');
+const PUBLIC = path.join(__dirname, 'public');
+
+const readJsonl = (f) => (fs.existsSync(f)
+  ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
+  : []);
+
+// Flatten both vendors to one uniform SKU row: {source, sku, title, width, image, url, tags[]}
+function buildRows() {
+  const rows = [];
+  // Lilycolor: one product = one row (its mfr_sku is the colorway)
+  for (const r of readJsonl(LILY)) {
+    rows.push({
+      source: 'lilycolor',
+      sku: r.mfr_sku || r.handle,
+      prefix: r.mfr_prefix || null,
+      title: r.title_ja || r.handle,
+      width: r.width || null,
+      composition: r.composition || null,
+      fire: r.fire_grade || null,
+      image: (r.images && r.images[0]) || null,
+      url: r.handle ? `https://shop.lilycolor.co.jp/products/${r.handle}` : null,
+      is_sample: !!(r.product_type && /サンプル/.test(r.product_type)) || /_{3}sample/.test(r.handle || ''),
+    });
+  }
+  // Sangetsu: one PATTERN explodes to N colorway SKUs, each with its own full-res image
+  for (const r of readJsonl(SANG)) {
+    const imgs = r.sku_images || {};
+    for (const sku of (r.colorway_skus || [])) {
+      rows.push({
+        source: 'sangetsu',
+        sku,
+        prefix: r.mfr_prefix || null,
+        title: r.pattern || sku,
+        width: (r.spec && r.spec.width) || null,
+        composition: (r.spec && r.spec.is_grasscloth) ? 'Grasscloth' : null,
+        fire: (r.spec && r.spec.type) || null,
+        image: imgs[sku] || null,
+        url: r.source_url || null,
+        is_sample: false,
+      });
+    }
+  }
+  return rows;
+}
+
+let ROWS = buildRows();
+const reload = () => { ROWS = buildRows(); };
+fs.watchFile(SANG, { interval: 4000 }, reload);   // pick up Sangetsu staging as it grows
+fs.watchFile(LILY, { interval: 8000 }, reload);
+
+const sorters = {
+  newest: null, // server natural order
+  sku: (a, b) => String(a.sku).localeCompare(String(b.sku)),
+  title: (a, b) => String(a.title).localeCompare(String(b.title)),
+  width: (a, b) => (parseFloat(a.width) || 0) - (parseFloat(b.width) || 0),
+};
+
+const server = http.createServer((req, res) => {
+  const u = new URL(req.url, `http://localhost:${PORT}`);
+  if (u.pathname === '/api/skus') {
+    const src = u.searchParams.get('source') || 'all';
+    const q = (u.searchParams.get('q') || '').toLowerCase();
+    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 = ROWS;
+    if (src !== 'all') rows = rows.filter((r) => r.source === src);
+    if (q) rows = rows.filter((r) => (r.sku + ' ' + r.title).toLowerCase().includes(q));
+    const counts = { all: ROWS.length, lilycolor: ROWS.filter((r) => r.source === 'lilycolor').length, sangetsu: ROWS.filter((r) => r.source === 'sangetsu').length };
+    if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+    const page = rows.slice(offset, offset + limit);
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ total: rows.length, counts, offset, limit, rows: page }));
+    return;
+  }
+  // static
+  let p = u.pathname === '/' ? '/index.html' : u.pathname;
+  const file = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
+  if (!file.startsWith(PUBLIC) || !fs.existsSync(file)) { res.writeHead(404); res.end('not found'); return; }
+  const ext = path.extname(file);
+  const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }[ext] || 'text/plain';
+  res.writeHead(200, { 'Content-Type': mime });
+  res.end(fs.readFileSync(file));
+});
+
+server.listen(PORT, () => {
+  console.log(`Staging viewer → http://localhost:${PORT}  (lilycolor ${ROWS.filter((r) => r.source === 'lilycolor').length} / sangetsu ${ROWS.filter((r) => r.source === 'sangetsu').length} SKUs)`);
+});

← a2399f8e stage free-samples banner on dedicated dev theme [Dev] free-  ·  back to Designer Wallcoverings  ·  free-samples phase 2: verified live-main theme role, staged d477dbc9 →