← back to Trending Dw
server+frontend: realImage support (signal default sort, meta coverage, Shopify staging guard) + UX overhaul (error/retry states, skeletons, provenance pill, OURS inset, date+time chip, alt text, 36-card first paint, detail modal w/ THEIRS-OURS)
b3b4e49557efa53009c9bc070b686c5f3bd3819c · 2026-07-15 10:59:12 -0700 · Steve Abrams
Files touched
M public/index.htmlM server.js
Diff
commit b3b4e49557efa53009c9bc070b686c5f3bd3819c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 15 10:59:12 2026 -0700
server+frontend: realImage support (signal default sort, meta coverage, Shopify staging guard) + UX overhaul (error/retry states, skeletons, provenance pill, OURS inset, date+time chip, alt text, 36-card first paint, detail modal w/ THEIRS-OURS)
---
public/index.html | 127 +++++++++++++++++++++++++++++++++++++++++++-----------
server.js | 12 ++++--
2 files changed, 111 insertions(+), 28 deletions(-)
diff --git a/public/index.html b/public/index.html
index 3f9855f..bb8e4af 100644
--- a/public/index.html
+++ b/public/index.html
@@ -62,6 +62,20 @@
.swatch .pending .sub{ font-size:9px; opacity:.8; letter-spacing:.02em; }
.spotted{ font-size:10px; color:var(--muted); display:flex; align-items:center; gap:4px; font-variant-numeric:tabular-nums; }
.cov.gap{ background:var(--gap); color:#fff; } .cov.covered{ background:rgba(255,255,255,.82); color:var(--accent); }
+ /* real-product hero: fades in on load; shimmer overlay while fetching */
+ .swatch img.hero{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; transition:opacity .25s; }
+ .swatch img.hero.ld{ opacity:1; }
+ .swatch .shimmer{ position:absolute; inset:0; background:linear-gradient(100deg,rgba(255,255,255,0) 30%,rgba(255,255,255,.35) 50%,rgba(255,255,255,0) 70%); background-size:200% 100%; animation:shim 1.1s linear infinite; pointer-events:none; }
+ @keyframes shim{ 0%{ background-position:180% 0; } 100%{ background-position:-80% 0; } }
+ .swatch .prov{ position:absolute; bottom:8px; right:9px; background:rgba(0,0,0,.62); color:#fff; font-size:9.5px; letter-spacing:.06em; font-weight:700; padding:3px 8px; border-radius:20px; }
+ /* our-own coverage design, inset over the competitor's real product image */
+ .swatch .oursthumb{ position:absolute; bottom:8px; left:9px; width:58px; text-align:center; }
+ .swatch .oursthumb img{ width:58px; height:58px; object-fit:cover; border-radius:8px; border:2px solid rgba(255,255,255,.95); box-shadow:0 2px 8px rgba(0,0,0,.35); display:block; }
+ .swatch .oursthumb .lb{ font-size:8px; font-weight:800; letter-spacing:.12em; color:#fff; background:rgba(0,0,0,.55); border-radius:6px; padding:1px 0; margin-top:3px; }
+ .errbar{ background:#fdf2f1; border:1px solid #ecc8c4; color:#8f2b23; font-size:13px; padding:12px 14px; border-radius:10px; display:flex; align-items:center; gap:12px; margin:10px 0; }
+ .errbar button{ border:1px solid #b6392f; background:#b6392f; color:#fff; border-radius:8px; padding:6px 12px; cursor:pointer; font-size:12px; }
+ .empty{ color:var(--muted); font-size:13px; padding:34px 10px; text-align:center; grid-column:1/-1; }
+ .empty a{ color:var(--gap); cursor:pointer; text-decoration:underline; }
.body{ padding:11px 12px 13px; display:flex; flex-direction:column; gap:6px; flex:1; }
.title{ font-size:14px; font-weight:700; line-height:1.25; }
.company{ font-size:12px; } .company b{ color:var(--accent); }
@@ -108,8 +122,8 @@
<button class="queuebtn" id="queueBtn" onclick="openQueue()">🛍 Shopify queue <b id="qn">0</b></button>
<div><label>Sort</label>
<select id="sort">
- <option value="have">Imaged first</option>
<option value="signal">Bestseller signal</option>
+ <option value="have">Imaged first</option>
<option value="gap">Gaps first</option>
<option value="newest">Newest</option>
<option value="titleAZ">Title A–Z</option>
@@ -120,6 +134,7 @@
<div class="dens"><label>Density</label><input type="range" id="dens" min="2" max="6" step="1" value="4"/></div>
</div>
<div class="chipsbar" id="chipsbar"></div>
+ <div id="errbar"></div>
<div class="grid" id="grid"></div>
<div id="lanes"></div>
<div id="sentinel"></div>
@@ -128,10 +143,15 @@
</div>
<script>
const store = k => { try{ return JSON.parse(localStorage.getItem(k)); }catch(e){ return null; } };
+// one-time migration: 'have' (Imaged first) was the default before real product images
+// existed; now nearly everything is imaged, so 'signal' is the meaningful default.
+const savedSort = (()=>{ let s=localStorage.getItem('tdw_sort');
+ if(s==='have'&&!localStorage.getItem('tdw_sort_mig')){ s='signal'; localStorage.setItem('tdw_sort','signal'); localStorage.setItem('tdw_sort_mig','1'); }
+ return s||'signal'; })();
const state = {
filters: store('tdw_filters2') || {}, // {dim: [values]}
- q:'', sort: localStorage.getItem('tdw_sort')||'have', view: localStorage.getItem('tdw_view')||'grid',
- page:1, size:12, loading:false, done:false
+ q:'', sort: savedSort, view: localStorage.getItem('tdw_view')||'grid',
+ page:1, size:36, loading:false, done:false
};
const DIMS = [['ourCoverage','Coverage'],['marketplace','Marketplace'],['company','Company'],['style','Style'],['color','Color'],['priceBand','Price']];
const has = (dim,val) => (state.filters[dim]||[]).includes(val);
@@ -139,7 +159,9 @@ function toggle(dim,val){ const a = state.filters[dim]||[]; state.filters[dim] =
function save(){ localStorage.setItem('tdw_filters2', JSON.stringify(state.filters)); localStorage.setItem('tdw_sort', state.sort); localStorage.setItem('tdw_view', state.view); }
async function loadFacets(){
- const f = await (await fetch('/api/facets')).json();
+ let f;
+ try{ const r = await fetch('/api/facets'); if(!r.ok) throw new Error('HTTP '+r.status); f = await r.json(); }
+ catch(e){ showError('Couldn’t load facets ('+e.message+').'); return; }
const el = document.getElementById('facets'); el.innerHTML='';
for (const [key,label] of DIMS){
const rows=(f[key]||[]); if(!rows.length) continue;
@@ -175,45 +197,87 @@ async function loadMeta(){
el.className = 'fresh fresh-'+tier;
const age = d==null ? '—' : d===0 ? 'today' : d===1 ? '1 day ago' : d+' days ago';
const asOf = m.asOf ? fmtSpotted(m.asOf) : '—';
- el.innerHTML = '<span class="dot"></span><span class="lbl">as of <b>'+esc(asOf)+'</b> · <span class="age">'+esc(age)+'</span> · <b>'+(m.count||0)+'</b> signals'+(m.imageGap?' · <b>'+m.imageGap+'</b> img-gap':'')+'</span>';
+ el.innerHTML = '<span class="dot"></span><span class="lbl">as of <b>'+esc(asOf)+'</b> · <span class="age">'+esc(age)+'</span> · <b>'+(m.count||0)+'</b> signals'+(m.withRealImage?' · <b>'+m.withRealImage+'</b> real imgs':'')+(m.realImageGap?' · <b>'+m.realImageGap+'</b> img-gap':'')+'</span>';
el.title = 'Data as of '+asOf+' ('+age+'). A true trend refresh is metered & gated — this board reflects the last approved research pull.';
}catch(e){}
}
function shade(h,p){ const n=parseInt(h.slice(1),16); let r=(n>>16)+p,g=((n>>8)&255)+p,b=(n&255)+p; r=Math.max(0,Math.min(255,r));g=Math.max(0,Math.min(255,g));b=Math.max(0,Math.min(255,b)); return '#'+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1); }
+function fmtWhen(iso){ try{ return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return iso; } }
function card(it){
const c=document.createElement('div'); c.className='card';
const grad='linear-gradient(135deg,'+it.dominantHex+' 0%,'+shade(it.dominantHex,-18)+' 100%)';
- const pending='<div class="pending"><div class="ico">🎨</div><div class="txt">Our-lane image pending</div><div class="sub">'+(it.ourCoverage==='gap'?'GAP — awaiting generation':'match in progress')+'</div></div>';
- c.innerHTML='<div class="swatch" style="position:relative;overflow:hidden;background:'+grad+'">'+(it.image?'<img src="'+esc(it.image)+'" alt="" loading="lazy" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover">':pending)
+ const pending='<div class="pending"><div class="ico">🎨</div><div class="txt">Product image pending</div><div class="sub">'+(it.ourCoverage==='gap'?'GAP — real image unresolved':'match in progress')+'</div></div>';
+ // hero = the REAL marketplace product image when we have one; else our own coverage design
+ const hero = it.realImage || it.image;
+ const prov = it.realImage ? '<div class="prov" title="Real product image — '+esc(it.realImageSource||it.marketplace)+(it.realImageFetchedAt?' · fetched '+esc(fmtWhen(it.realImageFetchedAt)):'')+'">▣ '+esc(it.marketplace)+'</div>' : '';
+ const oursInset = (it.realImage && it.image && it.isOurOriginal)
+ ? '<div class="oursthumb" title="Our design'+(it.imageDesign?': '+esc(it.imageDesign):'')+'"><img src="'+esc(it.image)+'" alt="Our design'+(it.imageDesign?': '+esc(it.imageDesign):'')+'" loading="lazy"><div class="lb">OURS</div></div>' : '';
+ const oursPill = (!it.realImage && it.isOurOriginal && it.imageDesign)
+ ? '<div class="ours" title="Our own design: '+esc(it.imageDesign)+'">◆ our lane: '+esc(it.imageDesign)+'</div>' : '';
+ c.innerHTML='<div class="swatch" style="position:relative;overflow:hidden;background:'+grad+'">'
+ +(hero?'<img class="hero" src="'+esc(hero)+'" alt="'+esc(it.title)+' — '+esc(it.marketplace)+'" loading="lazy"><div class="shimmer"></div>':pending)
+(it.isNew?'<div class="ribbon">NEW</div>':'')
+'<div class="cov '+it.ourCoverage+'">'+(it.ourCoverage==='gap'?'🎯 GAP':'✓ HAVE')+'</div>'
- +(it.signalRank?'<div class="rank">▲ '+it.signalRank+'</div>':'')
- +(it.isOurOriginal&&it.imageDesign?'<div class="ours" title="Our own design: '+esc(it.imageDesign)+'">◆ our lane: '+esc(it.imageDesign)+'</div>':'')+'</div>'
+ +(it.signalRank?'<div class="rank"'+(prov?' style="bottom:34px"':'')+'>▲ '+it.signalRank+'</div>':'')
+ +prov+oursInset+oursPill+'</div>'
+'<div class="body"><div class="title">'+esc(it.title)+'</div>'
+'<div class="company">by <b>'+esc(it.company)+'</b></div>'
+'<div class="chips"><span class="chip mk">'+esc(it.marketplace)+'</span><span class="chip">'+esc(it.style)+'</span><span class="chip">'+esc(it.color)+'</span><span class="chip">'+esc(it.priceBand)+'</span></div>'
- +(it.spottedAt?'<div class="spotted" title="Spotted '+esc(it.spottedAt)+'">🕓 spotted '+esc(fmtSpotted(it.spottedAt))+'</div>':'')
+ +(it.spottedAt?'<div class="spotted" title="Spotted '+esc(it.spottedAt)+(it.realImageFetchedAt?' · real image fetched '+esc(it.realImageFetchedAt):'')+'">🕓 spotted '+esc(fmtSpotted(it.spottedAt))+(it.realImageFetchedAt?' · 📷 '+esc(fmtWhen(it.realImageFetchedAt)):'')+'</div>':'')
+'<div class="signal"><span class="dot">●</span> '+esc(it.signal)+'</div>'
+'<div class="tier">DW tier → '+esc(it.dwTier)+(it.url?' · <a class="src" href="'+esc(it.url)+'" target="_blank" rel="noopener noreferrer">source ↗</a>':'')+'</div>'
+(it.isOurOriginal?'<button class="shopbtn" data-id="'+esc(it.id)+'">+ Put on Shopify</button>':'')
+'</div>';
- c.style.cursor="pointer"; c.title="Match against our catalog"; c.addEventListener("click",function(e){ if(!e.target.closest("a")&&!e.target.closest(".shopbtn")) openMatchup(it); });
+ c.style.cursor="pointer"; c.title="Open detail + match against our catalog"; c.addEventListener("click",function(e){ if(!e.target.closest("a")&&!e.target.closest(".shopbtn")) openMatchup(it); });
var sb=c.querySelector(".shopbtn"); if(sb){ sb.addEventListener("click",function(e){ e.stopPropagation(); putOnShopify(sb); }); }
- // image-404 fallback attached in JS (closure over `pending`) — no inline-onerror string
- // escaping, so the double-quoted class= attrs inside `pending` can't truncate the handler.
- var im=c.querySelector(".swatch img");
- if(im){ im.addEventListener("error",function(){ var sw=this.closest(".swatch"); if(sw&&!sw.querySelector(".pending")) sw.insertAdjacentHTML("beforeend",pending); this.remove(); }); }
+ // hero load/error handling attached in JS (closure over `it` + `pending`) — shimmer clears on
+ // load; a failed REAL image falls back to our design; a failed our-design falls to the tile.
+ var im=c.querySelector(".swatch img.hero");
+ if(im){
+ var clear=function(sw){ var sh=sw&&sw.querySelector(".shimmer"); if(sh) sh.remove(); };
+ im.addEventListener("load",function(){ this.classList.add("ld"); clear(this.closest(".swatch")); });
+ im.addEventListener("error",function(){
+ var sw=this.closest(".swatch"); clear(sw);
+ if(it.realImage && this.getAttribute("src")===it.realImage && it.image){
+ var pv=sw.querySelector(".prov"); if(pv) pv.remove(); // no longer showing the real product
+ var ot=sw.querySelector(".oursthumb"); if(ot) ot.remove(); // hero IS ours now — no duplicate inset
+ this.setAttribute("src", it.image); return;
+ }
+ if(sw&&!sw.querySelector(".pending")) sw.insertAdjacentHTML("beforeend",pending);
+ this.remove();
+ });
+ if(im.complete&&im.naturalWidth>0){ im.classList.add("ld"); clear(c.querySelector(".swatch")); }
+ }
return c;
}
+// a failed fetch used to leave state.loading=true forever — the board sat on "Loading…"
+// with no way out. Every load path now surfaces an error bar with a Retry.
+function showError(msg){ document.getElementById('errbar').innerHTML='<div class="errbar"><span>⚠ '+esc(msg)+'</span><button onclick="retryAll()">Retry</button></div>'; }
+function clearError(){ document.getElementById('errbar').innerHTML=''; }
+function retryAll(){ clearError(); loadFacets(); loadMeta(); refresh(); }
+function clearAllFilters(){ state.filters={}; state.q=''; document.getElementById('search').value=''; save(); loadFacets(); chips(); refresh(); }
async function fetchPage(){
if(state.loading||state.done||state.view!=='grid') return;
state.loading=true; document.getElementById('loading').style.display='block';
- const d=await (await fetch('/api/items?'+qs({page:state.page,size:state.size}))).json();
- document.getElementById('cnt').textContent=d.total;
- document.getElementById('gapcnt').textContent=d.gapCount?('🎯 '+d.gapCount+' gaps'):'';
- d.items.forEach(it=>document.getElementById('grid').appendChild(card(it)));
- if(state.page*state.size>=d.total) state.done=true;
- state.page++; state.loading=false; document.getElementById('loading').style.display='none';
+ try{
+ const r=await fetch('/api/items?'+qs({page:state.page,size:state.size}));
+ if(!r.ok) throw new Error('HTTP '+r.status);
+ const d=await r.json();
+ clearError();
+ document.getElementById('cnt').textContent=d.total;
+ document.getElementById('gapcnt').textContent=d.gapCount?('🎯 '+d.gapCount+' gaps'):'';
+ d.items.forEach(it=>document.getElementById('grid').appendChild(card(it)));
+ if(state.page===1&&d.total===0){
+ document.getElementById('grid').innerHTML='<div class="empty">No items match these filters — <a onclick="clearAllFilters()">clear all filters</a></div>';
+ state.done=true;
+ }
+ if(state.page*state.size>=d.total) state.done=true;
+ state.page++;
+ }catch(e){
+ showError('Couldn’t load items ('+e.message+'). The server may be restarting.');
+ }finally{
+ state.loading=false; document.getElementById('loading').style.display='none';
+ }
// If the loaded cards didn't fill the viewport, the IntersectionObserver won't
// re-fire (it only fires on an intersection *transition*) and the board stalls on
// page 1. Keep loading while the sentinel is still within (viewport + 400px).
@@ -300,10 +364,23 @@ function openMatchup(it){
document.body.appendChild(m);
}
var q=(it.style+' '+(it.color==='Multi'?'':it.color)).trim();
- document.getElementById('tmbox').innerHTML='<div style="padding:20px 24px;border-bottom:1px solid #eee;display:flex;gap:16px;align-items:center">'
- +(it.image?'<img src="'+it.image+'" style="width:84px;height:84px;object-fit:cover;border-radius:10px">':'')
- +'<div><div style="font-size:18px;font-weight:600">'+esc(it.title)+'</div>'
- +'<div style="color:#777;font-size:13px;margin-top:4px">'+esc(it.marketplace)+' · '+esc(it.style)+' · '+esc(it.color)+(it.url?' · <a href="'+esc(it.url)+'" target="_blank" rel="noopener noreferrer">source ↗</a>':'')+'</div></div>'
+ var hero=it.realImage||it.image;
+ var heroImg=hero?'<img src="'+esc(hero)+'" alt="'+esc(it.title)+'" style="width:340px;max-width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:12px;display:block">':'';
+ var theirs=hero?'<div style="flex:0 0 auto">'+(it.realImageUrl?'<a href="'+esc(it.realImageUrl)+'" target="_blank" rel="noopener noreferrer" title="Open the product page">'+heroImg+'</a>':heroImg)
+ +(it.realImage?'<div style="font-size:10px;font-weight:800;letter-spacing:.1em;color:#555;margin-top:5px">THEIRS — '+esc(it.realImageSource||it.marketplace)+(it.realImageFetchedAt?' · <span title="'+esc(it.realImageFetchedAt)+'">🕓 '+esc(fmtWhen(it.realImageFetchedAt))+'</span>':'')+'</div>':'')+'</div>':'';
+ var ours=(it.realImage&&it.image&&it.isOurOriginal)
+ ?'<div style="flex:0 0 auto;text-align:center"><img src="'+esc(it.image)+'" alt="Our design" style="width:150px;aspect-ratio:1;object-fit:cover;border-radius:10px;border:1px solid #eee;display:block">'
+ +'<div style="font-size:10px;font-weight:800;letter-spacing:.1em;color:#1f7a4d;margin-top:5px;max-width:150px">OURS'+(it.imageDesign?' — '+esc(it.imageDesign):'')+'</div></div>':'';
+ document.getElementById('tmbox').innerHTML='<div style="padding:20px 24px;border-bottom:1px solid #eee;display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap">'
+ +theirs+ours
+ +'<div style="flex:1;min-width:230px"><div style="font-size:19px;font-weight:700;line-height:1.25">'+esc(it.title)+'</div>'
+ +'<div style="color:#555;font-size:13px;margin-top:5px">by <b>'+esc(it.company)+'</b> · '+esc(it.marketplace)+'</div>'
+ +'<div style="color:#777;font-size:12.5px;margin-top:6px">'+esc(it.style)+' · '+esc(it.color)+' · '+esc(it.priceBand)+(it.signalRank?' · ▲ '+it.signalRank:'')+'</div>'
+ +(it.signal?'<div style="font-size:12.5px;color:#2f5d3a;margin-top:7px">● '+esc(it.signal)+'</div>':'')
+ +'<div style="font-size:12px;margin-top:9px">'+(it.url?'<a href="'+esc(it.url)+'" target="_blank" rel="noopener noreferrer">trend source ↗</a>':'')
+ +(it.realImageUrl&&it.realImageUrl!==it.url?(it.url?' · ':'')+'<a href="'+esc(it.realImageUrl)+'" target="_blank" rel="noopener noreferrer">product page ↗</a>':'')+'</div>'
+ +(it.ourCoverage==='gap'&&it.genBrief?'<div style="font-size:12px;background:#fdf2f1;border:1px dashed #ecc8c4;color:#8f2b23;padding:9px 11px;border-radius:8px;margin-top:11px"><b>🎯 GAP brief:</b> '+esc(it.genBrief)+'</div>':'')
+ +'<div style="font-size:11px;color:#999;margin-top:10px">DW tier → '+esc(it.dwTier||'')+'</div></div>'
+'<button onclick="document.getElementById(\'tmodal\').style.display=\'none\'" style="margin-left:auto;border:0;background:#f2efe9;border-radius:8px;padding:8px 12px;cursor:pointer;font-size:15px">✕</button></div>'
+'<div style="padding:16px 24px"><div style="font-weight:600;margin-bottom:10px">🎯 Matched in our catalog <span id="tmc" style="color:#888;font-weight:400;font-size:13px"></span></div><div id="tmgrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px">Loading…</div></div>';
document.getElementById('tmodal').style.display='flex';
diff --git a/server.js b/server.js
index 16e8121..e328fbb 100644
--- a/server.js
+++ b/server.js
@@ -55,8 +55,11 @@ function loadMeta(){
}
}
const withImage = items.filter(i=>i.image).length;
+ const withRealImage = items.filter(i=>i.realImage).length;
return { generatedAt, asOf, staleDays, count: items.length, imagesAttachedAt: d.imagesAttachedAt || null,
- withImage, imageGap: items.length - withImage, source: d.source || null };
+ withImage, imageGap: items.length - withImage,
+ withRealImage, realImageGap: items.length - withRealImage, realImagesAttachedAt: d.realImagesAttachedAt || null,
+ source: d.source || null };
} catch(e){ return { generatedAt:null, asOf:null, staleDays:null, count:0 }; }
}
@@ -106,8 +109,11 @@ function nextSku(q){
return DW_SERIES + '-' + String(max+1).padStart(4,'0');
}
// stage a product for one trending item's OUR-OWN design (idempotent per design)
+// INVARIANT: only `image` (our own design) is ever staged. `realImage` is a competitor's
+// marketplace image (internal intel only) and must never become a DW product image.
function stageForItem(it, nowIso){
if (!it || !it.image || !it.isOurOriginal) return { error: 'no our-own design to list (GAP lanes need generation first)' };
+ if (String(it.image).startsWith('/assets/real/')) return { error: 'refusing to stage: image is a competitor marketplace photo (internal intel only)' };
const q = loadQueue();
const designKey = it.imageDesign || it.image;
const existing = q.find(p => p.designKey === designKey);
@@ -179,9 +185,9 @@ const server = http.createServer((req,res)=>{
if (u.pathname === '/api/items'){
let items = applyFilters(loadItems(), u.searchParams);
- const sort = u.searchParams.get('sort') || 'have';
+ const sort = u.searchParams.get('sort') || 'signal';
const cmp = {
- have: (a,b)=> ((b.image?1:0)-(a.image?1:0)) || (b.signalRank||0)-(a.signalRank||0),
+ have: (a,b)=> (((b.realImage||b.image)?1:0)-((a.realImage||a.image)?1:0)) || (b.signalRank||0)-(a.signalRank||0),
signal: (a,b)=> (b.signalRank||0)-(a.signalRank||0),
gap: (a,b)=> (a.ourCoverage===b.ourCoverage?0:(a.ourCoverage==='gap'?-1:1)) || (b.signalRank||0)-(a.signalRank||0),
titleAZ: (a,b)=> (a.title||'').localeCompare(b.title||''),
← c44ad99 real-image pipeline scaffold: plan/download/qc/attach script
·
back to Trending Dw
·
auto-save: 2026-07-15T11:06:03 (3 files) — data/real-images/ 8ddd3d1 →