[object Object]

← back to Sister Parish Onboarding

tick 7: SP viewer × wallco PD bridge — 'PD inspirations →' button on every SP card opens modal with 3 nearest Lab-color PD matches; image proxy /pd-img/<source>/<file> reads from wallco-ai/data/images

2570b3026d25b478c547aadb177e70dae1fe85fc · 2026-05-11 17:47:39 -0700 · Steve

Files touched

Diff

commit 2570b3026d25b478c547aadb177e70dae1fe85fc
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon May 11 17:47:39 2026 -0700

    tick 7: SP viewer × wallco PD bridge — 'PD inspirations →' button on every SP card opens modal with 3 nearest Lab-color PD matches; image proxy /pd-img/<source>/<file> reads from wallco-ai/data/images
---
 server.js | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 83 insertions(+)

diff --git a/server.js b/server.js
index 7c30c28..06af2a8 100644
--- a/server.js
+++ b/server.js
@@ -8,6 +8,7 @@
  */
 require('dotenv').config();
 const express = require('express');
+const path = require('path');
 const { execSync } = require('child_process');
 
 const app = express();
@@ -17,6 +18,10 @@ function psqlJson(sql) {
   return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
 }
 
+// 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;`);
@@ -26,6 +31,20 @@ app.get('/api/products', (_req, res) => {
   }
 });
 
+// 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">
@@ -54,6 +73,22 @@ app.get('/', (_req, res) => {
   .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>
@@ -78,6 +113,14 @@ app.get('/', (_req, res) => {
   </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');
@@ -158,9 +201,12 @@ function render() {
           <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(); });
@@ -170,6 +216,43 @@ dens.addEventListener('input', () => {
 });
 
 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">
+            <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>`);
 });
 

← 134a484 SP color v2: center-crop 80% before palette extraction — nea  ·  back to Sister Parish Onboarding  ·  Shopify: bump to API 2026-01 52bd57d →