← back to Designer Wallcoverings
onboarding/sangetsu-lilycolor/viewer/server.js
126 lines
#!/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-unified-staging.jsonl'); // trade pricebook + specs + price + image manifest
const SANG = path.join(DIR, 'sangetsu-staging.jsonl');
const PUBLIC = path.join(__dirname, 'public');
const HENRY_IMG = '/Volumes/Henry/dw-lily-images'; // selectively-fetched pattern swatches
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: unified trade row — one SKU, full specs + price + image manifest
for (const r of readJsonl(LILY)) {
rows.push({
source: 'lilycolor',
sku: r.mfr_sku,
prefix: r.mfr_prefix || null,
title: r.title_ja || r.mfr_sku,
catalog: r.source_catalog || null, // WILL / LIGHT / V-wall / MATERIALS / Import Selection
width: r.width || null,
composition: (r.functions && r.functions.length) ? r.functions.join(', ') : null,
fire: (r.fire_ratings && r.fire_ratings[0]) || null,
price_yen: r.list_price_yen || null, // Japan LIST price (¥), not confirmed net cost
repeat: (r.repeat_tate_cm && r.repeat_yoko_cm) ? `${r.repeat_tate_cm}×${r.repeat_yoko_cm}cm` : null,
// prefer the locally-fetched clean swatch on Henry; else shop-CDN swatch-first preview
image: (fs.existsSync(path.join(HENRY_IMG, `${r.mfr_sku}.jpg`)) ? `/img/lily/${r.mfr_sku}` : (r.preview_images && r.preview_images[0])) || null,
image_count: r.image_count || 0,
image_kinds: r.image_kinds || [],
url: r.shop_handle ? `https://shop.lilycolor.co.jp/products/${r.shop_handle}` : null,
is_sample: false,
});
}
// 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);
let lastImgCount = -1; // re-resolve primary images as swatches land on Henry
setInterval(() => {
try { const n = fs.existsSync(HENRY_IMG) ? fs.readdirSync(HENRY_IMG).length : 0; if (n !== lastImgCount) { lastImgCount = n; reload(); } } catch {}
}, 6000);
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),
price: (a, b) => (a.price_yen || 1e9) - (b.price_yen || 1e9),
images: (a, b) => (b.image_count || 0) - (a.image_count || 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;
}
// locally-fetched Lily pattern swatches from Henry
if (u.pathname.startsWith('/img/lily/')) {
const sku = decodeURIComponent(u.pathname.slice('/img/lily/'.length)).replace(/[^A-Za-z0-9-]/g, '');
const f = path.join(HENRY_IMG, `${sku}.jpg`);
if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
res.writeHead(404); return res.end('no image');
}
// 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)`);
});