← 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 →