[object Object]

← back to 4square Admin

feat: card-detail modal with full metadata

48decb791698d253cc2b8be7699cfa72f5f6c812 · 2026-05-14 09:33:23 -0700 · Steve

- new GET /api/product/:id?kind=… → full row from spoon_all_designs or shopify_products
- frontend: dblclick (any card) or shift-click (selectable) or click (readonly) opens modal
- 2-col layout: large image left, key/value rows right (status/sku/handle/vendor/price/tags/dimensions/prompt/etc.)
- dominant-hex swatch chip for wallco
- modal acts: 'Open on wallco.ai' / 'Open in Shopify Admin' deep links + inline publish/unpublish/delete buttons (wallco only)
- Esc closes, click-backdrop closes

Files touched

Diff

commit 48decb791698d253cc2b8be7699cfa72f5f6c812
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 14 09:33:23 2026 -0700

    feat: card-detail modal with full metadata
    
    - new GET /api/product/:id?kind=… → full row from spoon_all_designs or shopify_products
    - frontend: dblclick (any card) or shift-click (selectable) or click (readonly) opens modal
    - 2-col layout: large image left, key/value rows right (status/sku/handle/vendor/price/tags/dimensions/prompt/etc.)
    - dominant-hex swatch chip for wallco
    - modal acts: 'Open on wallco.ai' / 'Open in Shopify Admin' deep links + inline publish/unpublish/delete buttons (wallco only)
    - Esc closes, click-backdrop closes
---
 index.html | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 server.js  |  30 +++++++++++++++
 2 files changed, 149 insertions(+), 3 deletions(-)

diff --git a/index.html b/index.html
index 80ee06f..2294658 100644
--- a/index.html
+++ b/index.html
@@ -119,6 +119,29 @@
   .spinner { display:inline-block; width:14px; height:14px; border:2px solid #444; border-top-color:var(--gold); border-radius:50%; animation:spin .8s linear infinite; vertical-align:middle; margin-right:8px }
   @keyframes spin { to { transform:rotate(360deg) } }
 
+  /* ── modal ───────────────────────────────────────────────────────── */
+  .modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.65); backdrop-filter:blur(4px); z-index:60; display:none; align-items:center; justify-content:center; padding:40px }
+  .modal-bg.open { display:flex }
+  .modal { background:#13110f; border:1px solid var(--gold); border-radius:8px; max-width:1100px; width:100%; max-height:88vh; overflow:hidden; display:grid; grid-template-columns:minmax(280px, 1fr) 480px; box-shadow:0 24px 64px rgba(0,0,0,.6) }
+  .modal .img-side { background:#0a0908; display:flex; align-items:center; justify-content:center; overflow:hidden; min-height:300px }
+  .modal .img-side img { max-width:100%; max-height:88vh; object-fit:contain }
+  .modal .info-side { padding:24px 28px; overflow-y:auto; max-height:88vh }
+  .modal h2 { font:300 22px/1.2 'Cormorant Garamond','Playfair Display',serif; margin:0 0 4px; color:var(--gold) }
+  .modal .sub { color:#888; font:500 11px/1.4 'SF Mono',Menlo,monospace; letter-spacing:.04em; word-break:break-all }
+  .modal .row { display:flex; gap:8px; padding:9px 0; border-bottom:1px solid var(--line); align-items:baseline }
+  .modal .row .k { color:#888; font:500 10.5px/1.4 -apple-system; text-transform:uppercase; letter-spacing:.1em; min-width:110px; flex-shrink:0 }
+  .modal .row .v { color:var(--ink); word-break:break-word; flex:1 }
+  .modal .chips { display:flex; flex-wrap:wrap; gap:5px; margin-top:4px }
+  .modal .chip { padding:3px 9px; background:#1a1816; border:1px solid var(--line); border-radius:11px; font:500 10.5px/1.4 -apple-system; color:#bba }
+  .modal .swatch { display:inline-block; width:14px; height:14px; border-radius:3px; border:1px solid #444; vertical-align:middle; margin-right:6px }
+  .modal pre { margin:6px 0 0; padding:10px 12px; background:#0d0c0b; border:1px solid var(--line); border-radius:4px; font:500 11px/1.5 'SF Mono',Menlo,monospace; color:#bba; white-space:pre-wrap; max-height:160px; overflow-y:auto }
+  .modal .acts { display:flex; gap:8px; padding:14px 0 0; margin-top:14px; border-top:1px solid var(--line) }
+  .modal .acts a, .modal .acts button { font:inherit; padding:8px 16px; background:#1a1816; color:var(--ink); border:1px solid var(--line); border-radius:4px; cursor:pointer; font-size:12px; text-decoration:none }
+  .modal .acts a:hover, .modal .acts button:hover { border-color:var(--gold); color:var(--gold) }
+  .modal .close { position:absolute; top:18px; right:24px; color:#888; font-size:22px; cursor:pointer; background:transparent; border:0; padding:0; line-height:1 }
+  .modal .close:hover { color:var(--gold) }
+  @media (max-width:880px) { .modal { grid-template-columns:1fr; max-height:90vh } .modal .img-side { max-height:40vh } }
+
   /* ── toast ───────────────────────────────────────────────────────── */
   #toast { position:fixed; bottom:24px; right:24px; background:#1a1816; border:1px solid var(--gold); color:var(--ink); padding:14px 20px; border-radius:6px; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,.5); z-index:50; opacity:0; transform:translateY(10px); transition:.2s; pointer-events:none }
   #toast.show { opacity:1; transform:translateY(0); pointer-events:auto }
@@ -172,6 +195,14 @@
   </main>
 </div>
 
+<div class="modal-bg" id="modal-bg" onclick="if(event.target===this)closeModal()">
+  <div class="modal" id="modal" style="position:relative">
+    <button class="close" onclick="closeModal()" title="Close (Esc)">×</button>
+    <div class="img-side" id="m-img"></div>
+    <div class="info-side" id="m-info"></div>
+  </div>
+</div>
+
 <div id="toast"></div>
 
 <script>
@@ -381,17 +412,102 @@ function cardHtml(r, opts={}) {
 
 function attachCardHandlers() {
   document.querySelectorAll('.card').forEach(el => {
-    if (el.classList.contains('readonly')) return;
-    el.addEventListener('click', () => {
-      const id = parseInt(el.dataset.id, 10);
+    const id = parseInt(el.dataset.id, 10);
+    // Click → toggle (or open modal if shift-clicked or readonly card)
+    el.addEventListener('click', (e) => {
+      if (e.shiftKey || el.classList.contains('readonly')) {
+        openModal(id);
+        return;
+      }
       if (selected.has(id)) selected.delete(id); else selected.add(id);
       el.classList.toggle('selected');
       updateSelUI();
     });
+    // Double-click → always open modal (works on selectable cards too)
+    el.addEventListener('dblclick', (e) => { e.preventDefault(); openModal(id); });
   });
   updateSelUI();
 }
 
+async function openModal(id) {
+  const j = await fetch(`/api/product/${id}?kind=${CURRENT.kind}`).then(r => r.json());
+  if (!j.ok) { toast('Detail load failed: ' + j.error, 'err'); return; }
+  const r = j.row;
+  $('#m-img').innerHTML = `<img src="/img/${id}?kind=${CURRENT.kind}" alt="">`;
+  $('#m-info').innerHTML = j.kind === 'shopify' ? modalShopify(r) : modalWallco(r);
+  $('#modal-bg').classList.add('open');
+}
+function closeModal() { $('#modal-bg').classList.remove('open'); }
+
+function chips(arr) {
+  if (!arr) return '';
+  const list = Array.isArray(arr) ? arr : String(arr).split(',').map(s=>s.trim()).filter(Boolean);
+  if (!list.length) return '<span style="color:#666;font-style:italic">none</span>';
+  return `<div class="chips">${list.map(t => `<span class="chip">${esc(t)}</span>`).join('')}</div>`;
+}
+
+function modalWallco(r) {
+  const swatch = r.dominant_hex ? `<span class="swatch" style="background:${esc(r.dominant_hex)}"></span><code>${esc(r.dominant_hex)}</code>` : '<span style="color:#666;font-style:italic">none</span>';
+  return `
+    <h2>${esc(r.dig_number || `wallco #${r.id}`)}</h2>
+    <div class="sub">${esc(r.category||'')} · ${esc(r.brand||'')} · ${esc(r.generator||'')}</div>
+    <div class="row"><span class="k">Published</span><span class="v">${r.is_published ? '<span style="color:#7dd87d">PUBLISHED</span>' : '<span style="color:#d2b15c">DRAFT</span>'}</span></div>
+    <div class="row"><span class="k">Created</span><span class="v">${esc(r.created_at||'')}</span></div>
+    <div class="row"><span class="k">Dominant</span><span class="v">${swatch}</span></div>
+    <div class="row"><span class="k">Dimensions</span><span class="v">${r.width_in||'?'}″ × ${r.height_in||'?'}″ · ${r.panels||1} panel(s)</span></div>
+    <div class="row"><span class="k">Generator</span><span class="v">${esc(r.generator||'')} · seed=${esc(r.seed||'')}</span></div>
+    <div class="row"><span class="k">Tags</span><span class="v">${chips(r.tags)}</span></div>
+    <div class="row"><span class="k">Motifs</span><span class="v">${chips(r.motifs)}</span></div>
+    ${r.parent_design_id ? `<div class="row"><span class="k">Parent</span><span class="v">#${r.parent_design_id}</span></div>` : ''}
+    ${r.prompt ? `<div class="row"><span class="k">Prompt</span><span class="v"><pre>${esc(r.prompt)}</pre></span></div>` : ''}
+    ${r.negative_prompt ? `<div class="row"><span class="k">Negative</span><span class="v"><pre>${esc(r.negative_prompt)}</pre></span></div>` : ''}
+    ${r.notes ? `<div class="row"><span class="k">Notes</span><span class="v">${esc(r.notes)}</span></div>` : ''}
+    <div class="acts">
+      <a target="_blank" rel="noopener" href="https://wallco.ai/design/${r.id}">↗ Open on wallco.ai</a>
+      <button onclick="modalAction(${r.id}, '${r.is_published?'unpublish':'publish'}')">${r.is_published ? '↻ Unpublish' : '✓ Publish'}</button>
+      <button class="del" onclick="modalAction(${r.id}, 'delete')" style="color:#e08070; border-color:#4a2a26">🗑 Remove</button>
+    </div>`;
+}
+function modalShopify(r) {
+  const url = `https://designerwallcoverings.com/products/${esc(r.handle||'')}`;
+  return `
+    <h2>${esc(r.title || 'Untitled')}</h2>
+    <div class="sub">${esc(r.vendor||'')} · ${esc(r.status||'?')}</div>
+    <div class="row"><span class="k">Shopify ID</span><span class="v"><code>${esc(r.shopify_id||'')}</code></span></div>
+    <div class="row"><span class="k">DW SKU</span><span class="v"><code>${esc(r.dw_sku||'')}</code></span></div>
+    <div class="row"><span class="k">Mfr SKU</span><span class="v"><code>${esc(r.mfr_sku||r.sku||'')}</code></span></div>
+    <div class="row"><span class="k">Handle</span><span class="v"><code>${esc(r.handle||'')}</code></span></div>
+    <div class="row"><span class="k">Vendor</span><span class="v">${esc(r.vendor||'')} (prefix: ${esc(r.vendor_prefix||'?')})</span></div>
+    <div class="row"><span class="k">Type</span><span class="v">${esc(r.product_type||'')}</span></div>
+    <div class="row"><span class="k">Status</span><span class="v">${esc(r.status||'?')}</span></div>
+    <div class="row"><span class="k">Price</span><span class="v">${r.price?'$'+Number(r.price).toFixed(2):'?'} ${r.retail_price?`(retail $${Number(r.retail_price).toFixed(2)})`:''} ${r.cost_price?`(cost $${Number(r.cost_price).toFixed(2)})`:''}</span></div>
+    <div class="row"><span class="k">Pattern</span><span class="v">${esc(r.pattern_name||'')}</span></div>
+    <div class="row"><span class="k">Tags</span><span class="v">${chips(r.tags)}</span></div>
+    <div class="row"><span class="k">Full Monty</span><span class="v">${r.full_monty_at ? `✓ ${esc(r.full_monty_at)}` : '<span style="color:#666;font-style:italic">never</span>'}</span></div>
+    <div class="row"><span class="k">FM metafields</span><span class="v">${r.fm_metafields_verified ? '✓ verified' : (r.fm_metafields_missing ? `missing: ${esc(JSON.stringify(r.fm_metafields_missing))}` : '<span style="color:#666;font-style:italic">unset</span>')}</span></div>
+    <div class="row"><span class="k">Created (SP)</span><span class="v">${esc(r.created_at_shopify||'')}</span></div>
+    <div class="row"><span class="k">Synced</span><span class="v">${esc(r.synced_at||'')}</span></div>
+    <div class="acts">
+      <a target="_blank" rel="noopener" href="https://admin.shopify.com/store/${SHOPIFY_STORE_SLUG}/products/${esc(r.shopify_id||'')}">S Open in Shopify Admin</a>
+      <a target="_blank" rel="noopener" href="${url}">↗ Open on DW storefront</a>
+    </div>`;
+}
+
+async function modalAction(id, action) {
+  if (action === 'delete' && !confirm('Hard-delete this design? File → ~/.wallco-deleted/, DB row dropped.')) return;
+  const j = await fetch(`/api/bulk/${action}`, {
+    method:'POST', headers:{'content-type':'application/json'},
+    body: JSON.stringify({ ids:[id] })
+  }).then(r => r.json());
+  if (!j.ok) { toast('Error: ' + j.error, 'err'); return; }
+  toast(`${action}: ok`, 'ok');
+  closeModal();
+  await loadProducts();
+}
+
+// Esc closes modal
+window.addEventListener('keydown', e => { if (e.key === 'Escape' && $('#modal-bg').classList.contains('open')) closeModal(); });
+
 function selectMatrixRow(slug) {
   const ids = CURRENT.rows.filter(r => r.pattern === slug).map(r => r.id);
   const allIn = ids.length && ids.every(id => selected.has(id));
diff --git a/server.js b/server.js
index 209d08a..479a51f 100644
--- a/server.js
+++ b/server.js
@@ -169,6 +169,36 @@ app.get('/api/products', async (req, res) => {
   }
 });
 
+// ── /api/product/:id?kind=… — full single-row detail for modal ──────
+app.get('/api/product/:id', async (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  const kind = String(req.query.kind || 'wallco');
+  if (!Number.isFinite(id)) return res.status(400).json({ ok:false, error:'bad id' });
+  try {
+    if (kind === 'shopify') {
+      const { rows } = await pool.query(
+        `SELECT id, shopify_id, handle, title, vendor, product_type, sku, dw_sku, mfr_sku,
+                vendor_prefix, supplier_name, tags, status, image_url, price, retail_price, cost_price,
+                net_price, created_at_shopify, updated_at_shopify, synced_at,
+                full_monty_at, full_monty_rooms, full_monty_tags, fm_metafields_verified, fm_metafields_missing,
+                pattern_name, metafields
+           FROM shopify_products WHERE id=$1`, [id]);
+      if (!rows[0]) return res.status(404).json({ ok:false, error:'not found' });
+      return res.json({ ok:true, kind:'shopify', row: rows[0] });
+    }
+    const { rows } = await pool.query(
+      `SELECT id, dig_number, brand, kind, width_in, height_in, panels, generator,
+              prompt, negative_prompt, seed, steps, cfg_scale, image_url, local_path,
+              dominant_hex, palette, motifs, tags, category, is_published, notes,
+              created_at, parent_design_id, request_text, verticals, source_dw_sku, source_url
+         FROM spoon_all_designs WHERE id=$1`, [id]);
+    if (!rows[0]) return res.status(404).json({ ok:false, error:'not found' });
+    return res.json({ ok:true, kind:'wallco', row: rows[0] });
+  } catch (e) {
+    res.status(500).json({ ok:false, error: e.message });
+  }
+});
+
 // ── /img/:id?kind=wallco|shopify ──────────────────────────────────────
 app.get('/img/:id', async (req, res) => {
   const id = parseInt(req.params.id, 10);

← 42f74e8 feat: per-card deep-link hover actions  ·  back to 4square Admin  ·  feat: full keyboard navigation c8d2297 →