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