← 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)`);
});