← back to New Import Viewer
new.: fill primary image from Shopify + full gallery (product_images) with hover-cycle
ff729201757ae3a693de631bbf5a1d5318925807 · 2026-07-08 12:38:50 -0700 · Steve
Files touched
M public/index.htmlM server.js
Diff
commit ff729201757ae3a693de631bbf5a1d5318925807
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 8 12:38:50 2026 -0700
new.: fill primary image from Shopify + full gallery (product_images) with hover-cycle
---
public/index.html | 10 +++++++++-
server.js | 12 ++++++++++--
2 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/public/index.html b/public/index.html
index c4ce7a2..949130e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -75,6 +75,7 @@ main{flex:1;min-width:0}
.card:hover{border-color:#414a5e}
.card.sel{outline:2px solid var(--acc);outline-offset:-2px}
.imgwrap{position:relative;width:100%;aspect-ratio:1/1;background:#0a0b0f}
+.imgcount{position:absolute;bottom:6px;right:6px;background:rgba(0,0,0,.62);color:#fff;font-size:10px;font-weight:600;padding:1px 6px;border-radius:20px;pointer-events:none}
.imgwrap img{width:100%;height:100%;object-fit:cover;object-position:center 12%;display:block}
.imgwrap.noimg{background:repeating-linear-gradient(45deg,#0a0b0f,#0a0b0f 8px,#101218 8px,#101218 16px)}
.imgwrap.noimg::after{content:'🚫 no image';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#5b6373;font-size:11px;font-weight:600;letter-spacing:.3px}
@@ -388,9 +389,16 @@ function actionsFor(st,hasImg){
}
// NOTE: cards are never wrapped in <a> (adoption-agency DOM split). This viewer
// has no PDP — the delegated grid click below toggles the ⓘ details drawer.
+// hover-cycle the full Shopify gallery on a card thumb (urls in data-imgs, proxied via /img)
+function galEnter(w){const raw=(w.dataset.imgs||'').split('|~|').filter(Boolean);const el=w.querySelector('img');if(!el||raw.length<2)return;let i=0;w._iv=setInterval(()=>{i=(i+1)%raw.length;el.src='/img?u='+encodeURIComponent(raw[i]);},700);}
+function galLeave(w){clearInterval(w._iv);const raw=(w.dataset.imgs||'').split('|~|').filter(Boolean);const el=w.querySelector('img');if(el&&raw[0])el.src='/img?u='+encodeURIComponent(raw[0]);}
function card(p){
const title=esc(p.pattern||p.sku||'(untitled)')+(p.color?(', '+esc(p.color)):'');
- const img=p.image?`<div class="imgwrap"><img loading="lazy" src="/img?u=${encodeURIComponent(p.image)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')"></div>`
+ // full Shopify gallery: hover to cycle every image (urls proxied through /img like the primary)
+ const gimgs=(p.images&&p.images.length>1)?p.images:null;
+ const galBadge=gimgs?`<span class="imgcount">⧉ ${gimgs.length}</span>`:'';
+ const galAttr=gimgs?` data-imgs="${esc(gimgs.join('|~|'))}" onmouseenter="galEnter(this)" onmouseleave="galLeave(this)"`:'';
+ const img=p.image?`<div class="imgwrap"${galAttr}><img loading="lazy" src="/img?u=${encodeURIComponent(p.image)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')">${galBadge}</div>`
:`<div class="imgwrap noimg"></div>`;
const checked=sel.has(p.id)?'checked':'';
const st=p.shopStatus||'NEW';
diff --git a/server.js b/server.js
index 5337fcc..ced5574 100644
--- a/server.js
+++ b/server.js
@@ -550,14 +550,21 @@ const server = http.createServer((req, res) => {
const limit = Math.min(parseInt(u.searchParams.get('limit'), 10) || 120, 500);
const offset = Math.max(parseInt(u.searchParams.get('offset'), 10) || 0, 0);
const where = buildWhere({ status, vendor: '', term, fil });
+ // image = vendor image, else the Shopify featured image, else the first gallery image
+ // (fills the "newly imported, no vendor photo yet, but Shopify has it" case). gallery =
+ // the full Shopify image set for this product (dw_unified.product_images), '|~|'-joined.
const rows = q(`SELECT vc.id, vc.vendor_code, vc.mfr_sku, vc.dw_sku, vc.pattern_name, vc.color_name, vc.collection,
- vc.product_type, vc.image_url, vc.product_url,
+ vc.product_type,
+ COALESCE(NULLIF(vc.image_url,''), sp.image_url,
+ (SELECT pi.image_url FROM product_images pi WHERE pi.product_id = vc.shopify_product_id::text ORDER BY pi.position LIMIT 1)) AS image,
+ vc.product_url,
to_char(vc.first_seen_at,'YYYY-MM-DD HH24:MI') AS first_seen,
to_char(vc.last_scraped_at,'YYYY-MM-DD HH24:MI') AS crawled,
vc.sync_status, ${SHOP_STATUS} AS shop_status,
vc.shopify_product_id, sp.handle,
to_char(vc.shopify_synced_at,'YYYY-MM-DD HH24:MI') AS shop_synced,
- vc.color_primary, vc.width, vc.match_type, vc.ai_styles->>0 AS style1
+ vc.color_primary, vc.width, vc.match_type, vc.ai_styles->>0 AS style1,
+ (SELECT string_agg(pi.image_url, '|~|' ORDER BY pi.position) FROM product_images pi WHERE pi.product_id = vc.shopify_product_id::text) AS gallery
FROM vendor_catalog vc ${SP_JOIN} ${where}
ORDER BY ${SORTS[sort]} LIMIT ${limit} OFFSET ${offset}`);
const cnt = +(q(`SELECT count(*) FROM vendor_catalog vc ${SP_JOIN} ${where}`)[0] || [0])[0];
@@ -567,6 +574,7 @@ const server = http.createServer((req, res) => {
firstSeen: r[10], crawled: r[11], syncStatus: r[12], shopStatus: r[13],
shopifyId: r[14] || null, handle: r[15] || null, shopSynced: r[16] || null,
colorGroup: r[17] || null, width: r[18] || null, match: r[19] || null, style: r[20] || null,
+ images: r[21] ? r[21].split('|~|').filter(Boolean) : [],
}));
return send(res, 200, JSON.stringify({ count: cnt, offset, limit, items }));
}
← 7582d03 chore: macstudio3 migration — reconcile from mac2 + repoint
·
back to New Import Viewer
·
chore: lint (node --check ✓), v1.0.1 (session close) 0250f13 →