← back to Sister Parish Onboarding

server.js

269 lines

#!/usr/bin/env node
/**
 * Sister Parish onboarding viewer — local-only browse UI for the 73 prints
 * sitting in dw_unified.sisterparish_catalog with PDP-crawled specs.
 *
 * Standing rule compliance: sort <select> + density slider, both
 * localStorage-persisted (per ~/.claude/CLAUDE.md).
 */
require('dotenv').config();
const express = require('express');
const path = require('path');
const { execSync } = require('child_process');

const app = express();
const PORT = process.env.PORT || 9790;

function psqlJson(sql) {
  return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
}

// Block any .bak / .bak.* / .pre-* / .pre.* snapshot paths from ever serving via static.
// Fleet-wide hygiene: snapshot files must never be reachable from the web root.
app.use((req, _res, next) => {
  if (/\.(bak|pre)(\.|$|-)/i.test(req.path)) return next({ status: 404 });
  next();
});

// Proxy PD-corpus images from wallco-ai's data dir so the SP viewer can render them
const WALLCO_IMG_DIR = path.join(process.env.HOME, 'Projects', 'wallco-ai', 'data', 'images');
app.use('/pd-img', express.static(WALLCO_IMG_DIR, { maxAge: '7d' }));

app.get('/api/products', (_req, res) => {
  try {
    const raw = psqlJson(`SELECT json_agg(t) FROM (SELECT sp_product_id, handle, title, primary_image, tags, variants->0->>'vendor_retail' AS vendor_retail, variants->0->>'dw_retail' AS dw_retail, specs, dominant_hex, colors, shopify_product_id, shopify_handle, source_url FROM sisterparish_catalog ORDER BY title) t;`);
    res.type('application/json').send(raw || '[]');
  } catch (e) {
    res.status(500).type('application/json').send(JSON.stringify({ error: e.message }));
  }
});

// PD inspirations for one SP product — 3 nearest in Lab color space
app.get('/api/sp-pd-matches/:sp_id', (req, res) => {
  try {
    const id = parseInt(req.params.sp_id, 10);
    if (!id) return res.status(400).json({ error: 'bad_id' });
    const raw = psqlJson(`SELECT COALESCE(json_agg(t),'[]'::json) FROM (
      SELECT pd_id, pd_source, pd_title, pd_hex, pd_source_url, pd_local_path, rank, lab_distance
      FROM sp_pd_color_matches WHERE sp_product_id=${id} ORDER BY rank) t;`);
    res.type('application/json').send(raw || '[]');
  } catch (e) {
    res.status(500).type('application/json').send(JSON.stringify({ error: e.message }));
  }
});

app.get('/', (_req, res) => {
  res.type('html').send(`<!doctype html>
<meta charset="utf-8">
<title>Sister Parish — DW onboarding viewer</title>
<style>
  :root { --cols: 5; }
  *,*::before,*::after { box-sizing: border-box; }
  body { margin:0; font-family: -apple-system, system-ui, sans-serif; color:#1a1a1a; background:#faf8f5; }
  header { padding: 24px 32px; border-bottom: 1px solid #e6e0d8; background:#fff; display:flex; align-items:baseline; gap:16px; flex-wrap:wrap; }
  header h1 { margin:0; font-weight:300; font-size: 22px; letter-spacing:.5px; }
  header .tag { font-size:12px; color:#888; }
  .controls { margin-left:auto; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
  .controls label { font-size: 11px; color:#666; text-transform:uppercase; letter-spacing:.08em; display:flex; gap:6px; align-items:center; }
  .controls select, .controls input[type=range] { font: inherit; padding:4px 8px; border:1px solid #d8d2ca; background:#fff; border-radius:2px; }
  .controls input[type=range] { padding:0; width:140px; }
  main { padding: 24px 32px; }
  .grid { display:grid; gap:24px; grid-template-columns: repeat(var(--cols), 1fr); }
  .card { background:#fff; border:1px solid #ece6dd; border-radius:2px; overflow:hidden; }
  .card .img { aspect-ratio: 1; background:#f3eee5 no-repeat center/cover; }
  .card .meta { padding: 12px 14px 16px; }
  .card .title { font-size:13px; font-weight:500; margin:0 0 6px; }
  .card .price { font-size:12px; color:#444; }
  .card .price .ca { color:#999; text-decoration:line-through; margin-left:6px; font-size:11px; }
  .card .specs { font-size:11px; color:#888; margin-top:8px; line-height:1.4; }
  .card a { color:inherit; text-decoration:none; }
  .badge { display:inline-block; padding:1px 6px; font-size:10px; background:#e6f0e9; color:#2d6440; border-radius:2px; margin-left:6px; }
  .badge.draft { background:#fdf2dd; color:#7a5a13; }
  .dot { display:inline-block; width:10px; height:10px; border-radius:50%; border:1px solid #00000020; vertical-align:middle; margin-right:5px; }
  .pd-btn { margin-top:8px; font-size:10px; background:transparent; border:1px solid #d8d2ca; color:#666; padding:4px 8px; border-radius:2px; cursor:pointer; letter-spacing:.04em; text-transform:uppercase; }
  .pd-btn:hover { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
  #pd-modal { position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; }
  #pd-modal.is-open { display:flex; }
  #pd-modal .pd-card { background:#fff; border-radius:6px; max-width:880px; width:92vw; max-height:85vh; overflow-y:auto; padding:28px 32px; }
  #pd-modal h2 { margin:0 0 4px; font-weight:300; font-size:18px; }
  #pd-modal .pd-sub { font-size:11px; color:#888; margin-bottom:18px; letter-spacing:.08em; text-transform:uppercase; }
  #pd-modal .pd-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:16px; }
  #pd-modal .pd-item { border:1px solid #e6e0d8; border-radius:4px; overflow:hidden; }
  #pd-modal .pd-item img { width:100%; aspect-ratio:1; object-fit:cover; background:#f5f0e8; }
  #pd-modal .pd-meta { padding:10px 12px; font-size:11px; }
  #pd-modal .pd-meta .pd-title { font-weight:500; margin-bottom:3px; color:#1a1a1a; }
  #pd-modal .pd-meta .pd-src { color:#888; text-transform:uppercase; letter-spacing:.06em; font-size:9px; }
  #pd-modal .pd-meta .pd-dist { color:#666; margin-top:4px; }
  #pd-modal .pd-close { background:transparent; border:0; color:#888; font-size:24px; cursor:pointer; float:right; line-height:1; padding:0; }
  #pd-modal .pd-empty { padding:32px; text-align:center; color:#888; }
  .empty { padding:48px; text-align:center; color:#999; }
</style>
<header>
  <h1>Sister Parish — DW onboarding</h1>
  <span class="tag" id="count"></span>
  <div class="controls">
    <label>Sort
      <select id="sort">
        <option value="title">Title A→Z</option>
        <option value="price-asc">Price ↑</option>
        <option value="price-desc">Price ↓</option>
        <option value="width">Width</option>
        <option value="repeat-h">Repeat H</option>
        <option value="color-wheel">Color Wheel</option>
        <option value="light-dark">Light → Dark</option>
        <option value="dark-light">Dark → Light</option>
      </select>
    </label>
    <label>Density
      <input type="range" id="density" min="3" max="8" step="1" value="5">
    </label>
  </div>
</header>
<main><div class="grid" id="grid"></div></main>
<div id="pd-modal" role="dialog" aria-hidden="true">
  <div class="pd-card">
    <button class="pd-close" aria-label="Close">×</button>
    <h2 id="pd-title">PD inspirations</h2>
    <div class="pd-sub">Closest public-domain patterns by CIE Lab color · Met / Wikimedia / Smithsonian</div>
    <div class="pd-grid" id="pd-grid"></div>
  </div>
</div>
<script>
const sortSel = document.getElementById('sort');
const dens = document.getElementById('density');
sortSel.value = localStorage.getItem('sp.sort') || 'title';
dens.value = localStorage.getItem('sp.density') || 5;
document.documentElement.style.setProperty('--cols', dens.value);

let products = [];
const grid = document.getElementById('grid');
const cnt = document.getElementById('count');

function priceOf(p) { return parseFloat(p.dw_retail || 0); }
function inchesOf(s) { const m = String(s||'').match(/([0-9.]+)/); return m ? parseFloat(m[1]) : 0; }

function hexToHsl(hex) {
  if (!hex || hex.length < 4) return [0,0,0];
  const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
  if (!m) return [0,0,0];
  const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/255;
  const max = Math.max(r,g,b), min = Math.min(r,g,b);
  const l = (max + min) / 2;
  let h = 0, s = 0;
  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
    else if (max === g) h = (b - r) / d + 2;
    else h = (r - g) / d + 4;
    h *= 60;
  }
  return [h, s, l];
}

function render() {
  const sort = sortSel.value;
  const sorted = [...products].sort((a,b) => {
    if (sort === 'price-asc')  return priceOf(a) - priceOf(b);
    if (sort === 'price-desc') return priceOf(b) - priceOf(a);
    if (sort === 'width')      return inchesOf(a.specs?.width) - inchesOf(b.specs?.width);
    if (sort === 'repeat-h')   return inchesOf(a.specs?.horizontal_repeat) - inchesOf(b.specs?.horizontal_repeat);
    if (sort === 'color-wheel') {
      const ha = hexToHsl(a.dominant_hex), hb = hexToHsl(b.dominant_hex);
      // Group by hue, then by saturation (chromatic), then by lightness within hue band
      // Achromatic (low saturation) goes to the start
      const isaA = ha[1] < 0.08, isaB = hb[1] < 0.08;
      if (isaA !== isaB) return isaA ? -1 : 1;
      if (isaA) return ha[2] - hb[2]; // both grayscale → by lightness
      const bucketA = Math.floor(ha[0] / 30), bucketB = Math.floor(hb[0] / 30);
      if (bucketA !== bucketB) return bucketA - bucketB;
      return ha[2] - hb[2];
    }
    if (sort === 'light-dark') return hexToHsl(b.dominant_hex)[2] - hexToHsl(a.dominant_hex)[2];
    if (sort === 'dark-light') return hexToHsl(a.dominant_hex)[2] - hexToHsl(b.dominant_hex)[2];
    return (a.title||'').localeCompare(b.title||'');
  });
  cnt.textContent = sorted.length + ' prints · vendor/0.85 → DW';
  grid.innerHTML = sorted.map(p => {
    const sf = p.shopify_product_id;
    const badge = sf
      ? '<span class="badge">SF #' + sf + '</span>'
      : '<span class="badge draft">unpushed</span>';
    const sp = p.specs || {};
    const specBits = [
      sp.width ? sp.width + ' wide' : null,
      sp.sold_by,
      sp.horizontal_repeat ? 'H rpt ' + sp.horizontal_repeat : null,
      sp.country_of_origin
    ].filter(Boolean).join(' · ');
    const dot = p.dominant_hex
      ? \`<span class="dot" title="\${p.dominant_hex}" style="background:\${p.dominant_hex}"></span>\`
      : '';
    return \`
      <div class="card">
        <a href="\${p.source_url}" target="_blank" rel="noopener noreferrer">
          <div class="img" style="background-image:url('\${p.primary_image || ''}')"></div>
        </a>
        <div class="meta">
          <div class="title">\${dot}\${p.title}\${badge}</div>
          <div class="price">$\${(+p.dw_retail).toFixed(2)} <span class="ca">$\${(+p.vendor_retail).toFixed(2)}</span></div>
          <div class="specs">\${specBits}</div>
          <button class="pd-btn" data-sp-id="\${p.sp_product_id}" data-title="\${(p.title||'').replace(/"/g, '&quot;')}">PD inspirations →</button>
        </div>
      </div>\`;
  }).join('') || '<div class="empty">No products yet — run scripts/load_pg.sh</div>';
  // Wire PD-inspiration buttons
  grid.querySelectorAll('.pd-btn').forEach(b => b.addEventListener('click', () => showPdModal(b.dataset.spId, b.dataset.title)));
}

sortSel.addEventListener('change', () => { localStorage.setItem('sp.sort', sortSel.value); render(); });
dens.addEventListener('input', () => {
  document.documentElement.style.setProperty('--cols', dens.value);
  localStorage.setItem('sp.density', dens.value);
});

fetch('/api/products').then(r=>r.json()).then(d => { products = d || []; render(); });

// PD inspirations modal
const pdModal = document.getElementById('pd-modal');
const pdGrid = document.getElementById('pd-grid');
const pdTitleEl = document.getElementById('pd-title');
pdModal.querySelector('.pd-close').addEventListener('click', () => pdModal.classList.remove('is-open'));
pdModal.addEventListener('click', e => { if (e.target === pdModal) pdModal.classList.remove('is-open'); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') pdModal.classList.remove('is-open'); });

async function showPdModal(spId, title) {
  pdTitleEl.textContent = title + ' — PD inspirations';
  pdGrid.innerHTML = '<div class="pd-empty">Loading…</div>';
  pdModal.classList.add('is-open');
  try {
    const r = await fetch('/api/sp-pd-matches/' + spId);
    const matches = await r.json();
    if (!matches.length) { pdGrid.innerHTML = '<div class="pd-empty">No matches found.</div>'; return; }
    pdGrid.innerHTML = matches.map(m => {
      // Build proxied PD image URL: /pd-img/<source>/<basename>
      const fname = (m.pd_local_path || '').split('/').pop();
      const imgPath = fname ? '/pd-img/' + m.pd_source + '/' + fname : '';
      return \`
        <div class="pd-item">
          <a href="\${m.pd_source_url}" target="_blank" rel="noopener noreferrer">
            <img src="\${imgPath}" alt="\${m.pd_title}">
          </a>
          <div class="pd-meta">
            <div class="pd-title">\${(m.pd_title || '').slice(0,60)}</div>
            <div class="pd-src">\${m.pd_source} · \${m.pd_hex}</div>
            <div class="pd-dist">Lab dist \${parseFloat(m.lab_distance).toFixed(1)}</div>
          </div>
        </div>\`;
    }).join('');
  } catch (e) {
    pdGrid.innerHTML = '<div class="pd-empty">Error: ' + e.message + '</div>';
  }
}
</script>`);
});

app.listen(PORT, '127.0.0.1', () => {
  console.log(`Sister Parish viewer → http://127.0.0.1:${PORT}`);
});