← back to Greenland China
viewer/server.js
180 lines
#!/usr/bin/env node
/**
* Staging viewer for the Greenland Wallcoverings onboarding catalog (OFFLINE / read-only).
* Reads ../staging/greenland.jsonl, flattens to uniform SKU rows, and serves a paginated
* /api/skus endpoint behind a sort + density + infinite-scroll grid. Mirrors the
* Sangetsu/Lilycolor viewer pattern.
*
* Images: Greenland's CDN (api.greenlandwallcoverings.com/api/downloadFile) is referenced
* directly; a /img proxy provides a same-origin fallback so the grid renders even if the
* remote CDN hot-link-blocks a browser.
*
* HARD: read-only. No Shopify, no dw_unified, no publish. Pure local preview of staged data.
* node viewer/server.js [port] (default 0 = OS-assigned free port)
*/
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const PORT = parseInt(process.argv[2] || '0', 10);
const DIR = path.join(__dirname, '..', 'staging');
const GREEN = path.join(DIR, 'greenland.jsonl');
const COLORS = path.join(DIR, 'greenland-colors.json');
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)
: []);
// dominant-hex cache (produced by scripts/extract-colors.py). Absent = color sorts fall back to newest.
const readColors = () => { try { return JSON.parse(fs.readFileSync(COLORS, 'utf8')); } catch { return {}; } };
let COLOR = readColors();
// #rrggbb -> {h,s,l} for hue/lightness sorting
function hsl(hex) {
if (!hex) return null;
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
if (!m) return null;
const r = parseInt(m[1], 16) / 255, g = parseInt(m[2], 16) / 255, b = parseInt(m[3], 16) / 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn, l = (mx + mn) / 2;
let h = 0, s = 0;
if (d) {
s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
if (mx === r) h = (g - b) / d + (g < b ? 6 : 0);
else if (mx === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return { h, s, l };
}
function buildRows() {
const rows = [];
for (const r of readJsonl(GREEN)) {
const specs = [r.width, r.repeat, r.fire_rating, r.backing, r.weight ? r.weight + ' g' : null].filter(Boolean);
const hex = COLOR[String(r.id)] || null;
rows.push({
source: 'greenland',
id: r.id,
sku: r.sku || String(r.id),
title: r.pattern || r.name || r.sku || String(r.id),
color: r.color || null, // real color name only; r.code is a style code, not a color
collection: r.collection || null,
category: r.category || null,
wall_classes: (r.wall_class && r.wall_class.length) ? r.wall_class.map((s) => String(s).trim()) : [],
wall_class: (r.wall_class && r.wall_class.length) ? r.wall_class.join(', ') : null,
wall_types: (r.wall_type && r.wall_type.length) ? r.wall_type.map((s) => String(s).trim()) : [],
wall_type: (r.wall_type && r.wall_type.length) ? r.wall_type.join(', ') : null,
use: r.use || null,
code: r.code || null,
width: r.width || null,
repeat: r.repeat || null,
fire: r.fire_rating || null,
backing: r.backing || null,
weight: r.weight || null,
composition: r.composition || null,
min_order: r.minimum_order || null,
specs,
image: r.cover_image || (r.images && r.images[0]) || null,
image_count: r.image_count || (r.images ? r.images.length : 0),
colorway_count: r.colorways ? r.colorways.length : 0,
hex,
hsl: hsl(hex),
url: r.url || null,
});
}
return rows;
}
let ROWS = buildRows();
fs.watchFile(GREEN, { interval: 5000 }, () => { COLOR = readColors(); ROWS = buildRows(); });
if (fs.existsSync(COLORS)) fs.watchFile(COLORS, { interval: 5000 }, () => { COLOR = readColors(); ROWS = buildRows(); });
// rows with no resolved color sort to the end of any color sort
const colorLast = (a, b) => (a.hsl ? 0 : 1) - (b.hsl ? 0 : 1);
const sorters = {
newest: null, // server natural order (list order from vendor)
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),
weight: (a, b) => (a.weight || 0) - (b.weight || 0),
images: (a, b) => (b.image_count || 0) - (a.image_count || 0),
lightdark: (a, b) => colorLast(a, b) || (b.hsl?.l ?? -1) - (a.hsl?.l ?? -1),
darklight: (a, b) => colorLast(a, b) || (a.hsl?.l ?? 2) - (b.hsl?.l ?? 2),
colorwheel: (a, b) => colorLast(a, b) || (a.hsl?.h ?? 999) - (b.hsl?.h ?? 999) || (b.hsl?.l ?? 0) - (a.hsl?.l ?? 0),
};
const categoriesOf = (rows) => {
const m = {};
for (const r of rows) { const c = r.category || 'Uncategorized'; m[c] = (m[c] || 0) + 1; }
return m;
};
const materialsOf = (rows) => {
const m = {};
for (const r of rows) for (const w of r.wall_classes) m[w] = (m[w] || 0) + 1;
return m;
};
const server = http.createServer((req, res) => {
const u = new URL(req.url, `http://localhost`);
// whole-catalog snapshot (read-only) — the front end filters/sorts/facets client-side
if (u.pathname === '/api/rows') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ total: ROWS.length, colors_ready: Object.keys(COLOR).length, rows: ROWS }));
return;
}
if (u.pathname === '/api/skus') {
const cat = u.searchParams.get('category') || 'all';
const material = u.searchParams.get('material') || 'all';
const withimg = u.searchParams.get('withimg') === '1';
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 (cat !== 'all') rows = rows.filter((r) => (r.category || 'Uncategorized') === cat);
if (material !== 'all') rows = rows.filter((r) => r.wall_classes.includes(material));
if (withimg) rows = rows.filter((r) => !!r.image);
if (q) rows = rows.filter((r) => (r.sku + ' ' + r.title + ' ' + (r.color || '') + ' ' + (r.wall_class || '')).toLowerCase().includes(q));
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, all: ROWS.length,
categories: categoriesOf(ROWS), materials: materialsOf(ROWS),
colors_ready: Object.keys(COLOR).length,
offset, limit, rows: page,
}));
return;
}
// same-origin image proxy fallback (CDN hot-link safety)
if (u.pathname === '/img') {
const target = u.searchParams.get('u');
if (!target || !/^https:\/\/api\.greenlandwallcoverings\.com\//.test(target)) { res.writeHead(400); return res.end('bad'); }
https.get(target, { headers: { 'User-Agent': 'Mozilla/5.0', Referer: 'http://m.greenlandwallcoverings.com/' } }, (pr) => {
res.writeHead(pr.statusCode || 200, { 'Content-Type': pr.headers['content-type'] || 'image/jpeg', 'Cache-Control': 'max-age=86400' });
pr.pipe(res);
}).on('error', () => { res.writeHead(502); res.end('img err'); });
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, () => {
const addr = server.address();
console.log(`Greenland staging viewer → http://localhost:${addr.port} (${ROWS.length} SKUs)`);
});