← back to New Import Viewer
public/index.html
681 lines
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Catalog · Shopify state · dw_unified</title>
<style>
:root{--cols:6;--fs:1;--bg:#0c0d12;--panel:#15171f;--card:#12141b;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
--new:#f59e0b;--pub:#34d399;--unpub:#94a3b8;--arch:#f87171;--del:#6b7280;--ship:#60a5fa}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12,#0c0d12e8);backdrop-filter:blur(8px);
border-bottom:1px solid var(--line);padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.2px}
.pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px}
.muted{color:var(--mut)}
select,input[type=text],input[type=number]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
input[type=number]{width:70px}
.ctl{display:flex;align-items:center;gap:7px}
input[type=range]{accent-color:var(--acc)}
.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;text-decoration:none}
.btn:hover{border-color:var(--acc)}
/* Live-writes arm toggle — defaults OFF every page load (per-session). */
.armtoggle{font-size:12px;font-weight:800;letter-spacing:.3px;border-radius:6px;padding:4px 11px;cursor:pointer;
border:1px solid var(--line);background:var(--panel);color:var(--mut);display:inline-flex;align-items:center;gap:6px}
.armtoggle:hover{border-color:var(--mut)}
body.armed .armtoggle{background:var(--arch);color:#2a0606;border-color:#fca5a5;box-shadow:0 0 0 2px #f8717155}
body.armed .armtoggle::before{content:'';width:8px;height:8px;border-radius:50%;background:#2a0606;animation:livepulse 1.6s ease-in-out infinite}
@keyframes livepulse{0%,100%{opacity:1}50%{opacity:.25}}
#livestrip{display:none;position:fixed;top:0;left:0;right:0;height:3px;background:var(--arch);z-index:30;box-shadow:0 0 8px #f87171}
body.armed #livestrip{display:block}
/* ── left panel: search + field tables, ALL collapsed on load ── */
#layout{display:flex;align-items:flex-start}
aside{position:sticky;width:236px;min-width:236px;max-height:calc(100vh - 52px);overflow:auto;
padding:10px 10px 28px;border-right:1px solid var(--line);background:#0e0f15}
aside input[type=text]{width:100%;margin-bottom:7px;font-size:12.5px;padding:5px 8px}
aside details{border-bottom:1px solid var(--line)}
aside summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;padding:8px 4px;
font-size:10.5px;font-weight:700;color:var(--mut);text-transform:uppercase;letter-spacing:.6px;user-select:none}
aside summary::-webkit-details-marker{display:none}
aside summary::before{content:'▸';font-size:9px;color:var(--mut);transition:transform .12s}
aside details[open] summary::before{transform:rotate(90deg)}
aside summary:hover{color:var(--ink)}
aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-spacing:0;font-weight:600;
max-width:104px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fbody{padding:2px 0 8px}
.frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;
cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none}
.frow:hover{background:var(--panel);color:var(--ink)}
.frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
.frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
.frow .imgpct{font-size:9.5px;color:var(--pub);font-weight:700;flex:none}
.frow .imgpct.warn{color:var(--new)}
.frow .sdot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
.frow input[type=checkbox]{accent-color:var(--acc);margin:0 2px 0 0}
.fmore{padding:3px 8px;font-size:11px;color:var(--mut);font-style:italic}
.fnote{padding:4px 8px;font-size:10.5px;color:var(--new)}
#clearf{display:none;margin-top:10px;width:100%}
main{flex:1;min-width:0}
/* mobile: hamburger + full-screen filter drawer */
#burger{display:none;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;font-weight:600;white-space:nowrap}
#fclose{display:none}
@media(max-width:880px){
#layout{display:block}
header{padding:8px 10px;gap:8px}
#burger{display:inline-flex;align-items:center;gap:6px}
.ctl:has(#density){display:none}
aside{display:none;position:fixed;inset:0;top:0!important;z-index:40;width:auto;min-width:0;max-height:none!important;
overflow:auto;border-right:0;padding:12px 12px 40px;background:var(--bg)}
aside.open-mobile{display:block}
#fclose{display:block;position:sticky;top:0;z-index:2;width:100%;background:var(--acc);color:#062a24;border:none;border-radius:8px;padding:11px;font-size:14px;font-weight:700;cursor:pointer;margin-bottom:10px}
.grid:not(.list){grid-template-columns:repeat(2,1fr)}
}
/* ── grid — tight, 1px stroke; text scales with density via --fs ── */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px;padding:10px 12px 60px}
.card{background:var(--card);border:1px solid var(--line);border-radius:2px;overflow:hidden;display:flex;flex-direction:column;position:relative;cursor:pointer}
.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}
.card .b{padding:calc(6px*var(--fs)) calc(8px*var(--fs)) calc(7px*var(--fs));display:flex;flex-direction:column;gap:calc(3px*var(--fs))}
.vend{font-size:calc(10px*var(--fs));color:var(--mut);font-weight:600;letter-spacing:.2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ttl{font-weight:600;font-size:calc(12.5px*var(--fs));line-height:1.3;letter-spacing:-.1px}
.sub{color:var(--mut);font-size:calc(11px*var(--fs))}
.sku{font-family:ui-monospace,Menlo,monospace;font-size:calc(11px*var(--fs));color:var(--mut)}
.pick{position:absolute;top:7px;left:7px;z-index:3;background:#000a;border-radius:6px;padding:3px 4px;display:flex;cursor:pointer}
.pick input{width:17px;height:17px;accent-color:var(--acc);cursor:pointer;margin:0}
/* field visibility toggles (grid view only — list view columns stay intact) */
body.f-off-vend .grid:not(.list) .vend{display:none}
body.f-off-ttl .grid:not(.list) .ttl{display:none}
body.f-off-when .grid:not(.list) .sub.when{display:none}
body.f-off-chips .grid:not(.list) .card>.b>.chips{display:none}
body.imgonly .grid:not(.list) .card .b{display:none}
/* ── status chip (always visible) + chip row ── */
.chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center}
.chip{font-size:calc(10.5px*var(--fs));font-weight:700;border-radius:999px;padding:2px calc(9px*var(--fs));letter-spacing:.3px;white-space:nowrap;border:1px solid transparent}
.chip.st-NEW{background:var(--new);color:#1a1205}
.chip.st-PUBLISHED{background:var(--pub);color:#04211a}
.chip.st-UNPUBLISHED{background:var(--unpub);color:#11151c}
.chip.st-ARCHIVED{background:var(--arch);color:#2a0606}
.chip.st-DELETED{background:var(--del);color:#0b0d11}
.chip.st-ON_SHOPIFY{background:var(--ship);color:#04122a}
.chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
.chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
/* ── collapsed drawers: ⓘ details (read-only) and ⚙ actions (write surface) ── */
.info,.actpane{display:none;flex-direction:column;gap:7px;border-top:1px dashed var(--line);padding:8px 8px 9px;cursor:default}
.card.info-open .info{display:flex}
.card.acts-open .actpane{display:flex}
.chip.act-toggle{border-color:#3a4150}
body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
.kv{display:grid;grid-template-columns:104px 1fr;gap:4px 10px;font-size:11.5px;align-items:baseline}
.kv .k{color:var(--mut)}
.kv .v{text-align:left;word-break:break-word;font-weight:500}
.lnk{color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px}
.lnk:hover{background:var(--panel)}
.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
.act{font-size:11px;font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer}
.act:hover{border-color:var(--acc)}
.act.pub{border-color:var(--pub);color:var(--pub)}
.act.unpub{border-color:var(--unpub);color:var(--unpub)}
.act.arch{border-color:var(--arch);color:var(--arch)}
.act.live{background:#7f1d1d;border-color:#fca5a5;color:#fff;font-weight:800;box-shadow:0 0 0 1px #f8717155}
.act.live:hover{background:#991b1b;border-color:#fff}
.acts.haslive{border-left:2px solid var(--arch);padding-left:8px;margin-left:-2px}
.act:disabled{opacity:.4;cursor:default}
.actmsg{font-size:11px;color:var(--mut);min-height:14px}
.act.simact.on{border-color:var(--acc);color:var(--acc)}
.simpane:empty{display:none}
.simpane{margin-top:6px}
.simhead{font-size:11px;font-weight:700;color:var(--acc);text-transform:uppercase;letter-spacing:.05em;display:flex;align-items:center;gap:8px;margin-bottom:7px}
.simhead .simx{cursor:pointer;color:var(--mut);font-weight:400}
.simhead .simload{color:var(--mut);font-weight:400;text-transform:none;letter-spacing:0}
.simnone{font-size:11.5px;color:var(--mut)}
.simgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px}
.simcard{background:var(--card);border:1px solid var(--line);border-radius:8px;overflow:hidden;display:flex;flex-direction:column}
.simcard .simimg{width:100%;aspect-ratio:1/1;background:#1a1c24 center/cover no-repeat}
.simcard .simb{padding:5px 6px 6px;display:flex;flex-direction:column;gap:1px}
.simcard .simsku{font-size:10px;font-weight:700;color:var(--ink)}
.simcard .simttl{font-size:10.5px;color:var(--ink);line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.simcard .simvt{font-size:9.5px;color:var(--mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.simcard .simprice{font-size:10.5px;font-weight:700;color:var(--acc);margin-top:1px}
#more{display:block;margin:8px auto 60px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 18px;cursor:pointer}
#empty{padding:60px;text-align:center;color:var(--mut)}
#selbar{position:fixed;left:0;right:0;bottom:0;z-index:20;background:#11131bF2;backdrop-filter:blur(10px);
border-top:1px solid var(--acc);padding:11px 16px;display:none;align-items:center;gap:12px;flex-wrap:wrap}
#selbar.on{display:flex}
#selbar .n{font-weight:700;color:var(--acc)}
#bulkgo{background:var(--acc);color:#04201a;font-weight:700;border:0;border-radius:8px;padding:8px 18px;cursor:pointer}
#bulkgo:disabled{opacity:.4;cursor:default}
/* ── List view ── aligned CSS-grid rows; .b uses display:contents. Fixed type
sizes (single column — density/font scaling is a grid-view concern). */
.grid.list{grid-template-columns:1fr;gap:4px;padding:12px 16px 60px}
.grid.list .card,body.listview #listhead{
grid-template-columns:26px 42px 100px minmax(120px,1.4fr) 80px 128px 105px 85px minmax(80px,0.9fr) 80px 70px 80px 92px 92px;
column-gap:10px;
}
.grid.list .card{
display:grid;align-items:center;border-radius:2px;
min-height:52px;padding:0 12px 0 8px;
}
#listhead{display:none;position:sticky;z-index:8;align-items:center;margin:12px 16px -8px;
padding:6px 12px 6px 8px;background:#0e0f15;border:1px solid var(--line);border-radius:8px;
font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:var(--mut)}
body.listview #listhead{display:grid}
#listhead .lh{cursor:pointer;user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#listhead .lh:hover{color:var(--ink)}
#listhead .lh.on{color:var(--acc)}
.grid.list .card>.b{display:contents}
.grid.list .imgwrap{width:42px;min-width:42px;height:42px;aspect-ratio:auto;border-radius:3px;overflow:hidden;margin:5px 0}
.grid.list .imgwrap img{object-position:center}
.grid.list .imgwrap.noimg::after{font-size:8px}
.grid.list .pick{position:static;background:none;padding:0;justify-content:center}
.grid.list .vend{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.grid.list .ttl{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}
.grid.list .chips{flex-wrap:nowrap;gap:6px;overflow:hidden}
.grid.list .chip{font-size:10px;padding:1px 8px}
.grid.list .chips .chip.toggle{font-size:9.5px;padding:2px 7px}
.grid.list .lcell{font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;justify-self:start;text-align:left}
.grid.list .lsku,.grid.list .ldw,.grid.list .lshopid{font-family:ui-monospace,Menlo,monospace}
.grid.list .info,.grid.list .actpane{grid-column:1 / -1;margin:2px 0 6px;padding:8px 4px 2px}
.lcell{display:none}
.grid.list .lcell{display:block}
.sub.when{font-size:calc(11px*var(--fs))}
.grid.list .sub.when{display:none}
</style></head>
<body>
<div id="livestrip" title="Live Shopify writes are armed"></div>
<header>
<h1>Catalog · Shopify state</h1>
<button id="burger" type="button" aria-label="Open filters">☰ Filters</button>
<span class="pill" id="total">…</span>
<span class="muted" id="meta"></span>
<div class="ctl"><label class="muted">Sort</label>
<select id="sort">
<option value="newest">Newest crawl</option>
<option value="oldest">Oldest crawl</option>
<option value="firstseen">First seen</option>
<option value="colorgroup">Color (group)</option>
<option value="color">Color name A→Z</option>
<option value="style">Style</option>
<option value="pattern">Pattern / Title A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="dwsku">DW SKU A→Z</option>
<option value="vendor">Vendor</option>
</select></div>
<div class="ctl"><label class="muted">Density</label>
<input type="range" id="density" min="3" max="20" step="1"></div>
<button class="btn" id="view">▤ List view</button>
<div class="ctl"><label class="muted">Select</label>
<input type="number" id="selcount" min="1" step="1" value="1" title="how many to select (first N in current sort)">
<button class="btn" id="selN" title="select the first N matching the current filter">☑ first N</button>
<button class="btn" id="selall" title="select every card currently loaded on the page">☑ shown</button>
<button class="btn" id="selmatch" title="select EVERY row matching the current filter — not just loaded cards">☑ ALL matching</button>
</div>
<button class="armtoggle" id="armtoggle" style="display:none" onclick="toggleArm()">🔒 Live writes: OFF</button>
</header>
<div id="layout">
<aside aria-label="filters">
<button id="fclose" type="button">✕ Done — show products</button>
<input type="text" id="q" placeholder="Search pattern / sku / color…">
<input type="text" id="pattern" list="patternlist" placeholder="Pattern type-ahead…" title="type-ahead over every pattern matching the current view (server-backed)">
<datalist id="patternlist"></datalist>
<details id="fieldsbox"><summary>Card fields</summary>
<div class="fbody">
<label class="frow"><span class="fl"><input type="checkbox" data-fv="vend" checked>Vendor</span></label>
<label class="frow"><span class="fl"><input type="checkbox" data-fv="ttl" checked>Pattern / color</span></label>
<label class="frow"><span class="fl"><input type="checkbox" data-fv="when" checked>🕓 Crawl date</span></label>
<label class="frow"><span class="fl"><input type="checkbox" data-fv="chips" checked>Chips · status</span></label>
</div>
</details>
<div id="statusbox"><!-- Shopify-state table (counts from /api/stats) --></div>
<div id="facetbox"><!-- one collapsed <details> table per data field --></div>
<button class="btn" id="clearf">✕ Clear filters</button>
</aside>
<main>
<div id="listhead" aria-label="list columns"></div>
<div class="grid" id="grid"></div>
<button id="more" style="display:none">Load more</button>
<div id="empty" style="display:none">No products match.</div>
</main>
</div>
<div id="selbar">
<span class="n" id="seln">0 selected</span>
<button class="btn" id="clearsel">clear</button>
<span class="muted">bulk action</span>
<select id="bulkact">
<option value="launch:draft">🚀 Stage launch → DRAFT</option>
<option value="launch:active">🚀 Stage launch → ACTIVE</option>
<option value="publish:">🟢 Stage publish</option>
<option value="unpublish:">⚪ Stage unpublish</option>
<option value="archive:">🔴 Stage archive</option>
</select>
<button id="bulkgo">Apply ▸</button>
<span class="muted" id="bulkmsg"></span>
</div>
<script>
const $=s=>document.querySelector(s);
const grid=$('#grid'), state={offset:0,limit:120,count:0,loading:false,live:false,liveActions:[],store:''};
const sel=new Set();
const STATUS_LABEL={NEW:'NEW',PUBLISHED:'PUBLISHED',UNPUBLISHED:'UNPUBLISHED',ARCHIVED:'ARCHIVED',DELETED:'DELETED',ON_SHOPIFY:'ON SHOPIFY'};
const ACTION_RESULT={publish:'PUBLISHED',unpublish:'UNPUBLISHED',archive:'ARCHIVED'};
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
// ── left-panel field tables: server-computed facets (291k rows — the grid is
// paginated, so counts come from /api/facets, filters re-query the server) ──
const FIELDS=[
['vendor','Vendors'],['style','Styles'],['color','Colors'],['colorway','Colorways'],
['collection','Collections'],['ptype','Type'],['width','Width'],['match','Match'],['sync','Sync status'],
];
const FIL={}; // field -> active value ('' = off)
FIELDS.forEach(([k])=>FIL[k]='');
let FACETS={}; // field -> [[value,count(,withImg)]…] from /api/facets
const FCAP=40; // rows shown per table (top-by-count)
// Shopify-state quick-filter table (replaces the old header select + health strip)
const STATUS_DEFS=[
['netnew','✨ NET NEW (not on Shopify)','#5eead4'],['new','🟠 New — flagged (incl. dupes)','#f59e0b'],
['onshopify','🔵 On Shopify (all)','#60a5fa'],['published','🟢 Published','#34d399'],
['unpublished','⚪ Unpublished','#94a3b8'],['archived','🔴 Archived','#f87171'],
['noimage','🚫 No image','#f87171'],['all','▦ All','#9aa0b0']];
let STATUS='netnew';
// Boot status: restore the saved filter EXCEPT the 🚫 No image diagnostic view
{const sv=localStorage.getItem('nip.status');
if(sv&&sv!=='noimage'&&STATUS_DEFS.some(([k])=>k===sv))STATUS=sv;
localStorage.setItem('nip.status',STATUS);}
// ── List-view sortable column headers (server-side sorts) ──
const SORTABLE=[
['',''],['',''],
['vendor','Vendor'],['pattern','Pattern'],['color','Color'],['status','Status'],
['sku','Vendor SKU'],['dwsku','DW SKU'],['collection','Collection'],['type','Type'],
['sync','Sync'],['shopid','Shopify ID'],['firstseen','First seen'],['crawl','Last crawl']];
const HDR_KEYS={crawl:{asc:'oldest',desc:'newest'},firstseen:{asc:'firstseen_asc',desc:'firstseen'},type:{asc:'type',desc:'type_desc'}};
function sortKeys(k){return HDR_KEYS[k]||{asc:k,desc:k+'_desc'};}
function renderListHead(){
const el=$('#listhead'); if(!el)return;
el.innerHTML=SORTABLE.map(([k,lbl])=>{
if(!k)return '<span></span>';
const {asc,desc}=sortKeys(k);
const dir=state.sort===asc?'asc':(state.sort===desc?'desc':'');
return `<span class="lh${dir?' on':''}" onclick="headSort('${k}')" title="sort by ${esc(lbl)} (click again to flip)">${esc(lbl)}${dir?(dir==='asc'?' ▲':' ▼'):''}</span>`;
}).join('');
}
function headSort(k){
const {asc,desc}=sortKeys(k);
state.sort=state.sort===asc?desc:asc;
localStorage.setItem('nip.sort',state.sort);
const so=$('#sort');
so.value=so.querySelector(`option[value="${state.sort}"]`)?state.sort:'';
renderListHead(); load(true);
}
function pinListHead(){const h=document.querySelector('header');const el=$('#listhead');if(h&&el)el.style.top=h.offsetHeight+'px';syncAside();}
function syncAside(){const h=document.querySelector('header').offsetHeight;const a=document.querySelector('aside');a.style.top=h+'px';a.style.maxHeight=`calc(100vh - ${h}px)`;}
addEventListener('resize',pinListHead);
// ── live-write arming (unchanged safety model) ──
const isLive=a=>state.armed&&state.liveActions.includes(a);
function toggleArm(){
if(!state.armable)return;
if(!state.armed){
if(!confirm(`Arm LIVE writes to the REAL store (${state.store}) for THIS session?\n\nWhile armed, single-card ${state.liveActions.join('/')} buttons write to the live storefront (still confirm each). Resets to OFF when you reload.`))return;
state.armed=true;
}else state.armed=false;
renderArm(); load(true);
}
function renderArm(){
const b=$('#armtoggle'); if(!b)return;
if(!state.armable){b.style.display='none';document.body.classList.remove('armed');return;}
b.style.display='inline-flex';
document.body.classList.toggle('armed',state.armed);
b.textContent=state.armed?`🔴 Live writes: ARMED (${state.liveActions.join('/')})`:'🔒 Live writes: OFF';
}
function fmtVendor(v){return String(v||'').replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase());}
function buildActs(id,st,hasImg){
const defs=actionsFor(st,hasImg);
const btns=defs.map(([a,label,cls,dis,why])=>{
const live=!dis&&isLive(a.split(':')[0]);
return `<button class="act ${cls}${live?' live':''}"${dis?` disabled title="${esc(why||'')}"`:''} onclick="rowAction(${id},'${a}',this)">${live?'⚡ ':''}${label}${live?' · LIVE':''}</button>`;
});
const hasLive=defs.some(([a,,,dis])=>!dis&&isLive(a.split(':')[0]));
return `<div class="acts${hasLive?' haslive':''}">${btns.join('')}</div>`;
}
function copyTxt(btn){
const t=btn.dataset.copy||'';
navigator.clipboard.writeText(t).then(()=>{
const o=btn.textContent; btn.textContent='✓ copied';
setTimeout(()=>{btn.textContent=o;},1200);
});
}
function updateCardStatus(id,st){
const c=grid.querySelector('.card[data-id="'+id+'"]'); if(!c)return;
c.dataset.st=st;
const chip=c.querySelector('.b>.chips>.chip:not(.toggle)');
if(chip){chip.className='chip st-'+st;chip.textContent=STATUS_LABEL[st]||st;}
const actsEl=c.querySelector('.acts');
if(actsEl)actsEl.outerHTML=buildActs(id,st,c.dataset.img==='1');
}
function syncSel(){const n=sel.size;$('#seln').textContent=n.toLocaleString()+' selected';$('#selbar').classList.toggle('on',n>0);$('#bulkgo').disabled=!n;}
function togglePick(id,cb){if(cb.checked)sel.add(id);else sel.delete(id);cb.closest('.card').classList.toggle('sel',cb.checked);syncSel();}
function togglePane(el,which){el.closest('.card').classList.toggle(which==='info'?'info-open':'acts-open');}
// sort persistence (server-side sort keys)
state.sort=localStorage.getItem('nip.sort')||'newest';
{const so=$('#sort');so.value=so.querySelector(`option[value="${state.sort}"]`)?state.sort:'';
if(!so.value&&!SORTABLE.some(([k])=>k&&(sortKeys(k).asc===state.sort||sortKeys(k).desc===state.sort))){state.sort='newest';so.value='newest';localStorage.setItem('nip.sort','newest');}}
// ── density also drives type scale: fewer/larger cards → bigger type ──
function setDensity(v){
v=Number(v);
const fs=Math.max(.5,Math.min(1.2, v<=6 ? 1+(6-v)*.06 : 1-(v-6)*.042));
document.documentElement.style.setProperty('--cols',v);
document.documentElement.style.setProperty('--fs',fs.toFixed(3));
}
const dens=localStorage.getItem('nip.density')||'6';
$('#density').value=dens; setDensity(dens);
$('#density').oninput=()=>{setDensity($('#density').value);localStorage.setItem('nip.density',$('#density').value);};
// ── mobile: hamburger opens the filter sidebar as a full-screen drawer ──
const asideEl=document.querySelector('aside');
$('#burger').onclick=()=>{asideEl.classList.add('open-mobile');document.body.style.overflow='hidden';};
$('#fclose').onclick=()=>{asideEl.classList.remove('open-mobile');document.body.style.overflow='';};
window.matchMedia('(max-width:880px)').addEventListener('change',e=>{
if(!e.matches){asideEl.classList.remove('open-mobile');document.body.style.overflow='';}
});
// ── card-field visibility toggles (image-only when all off) ──
const FVIS=JSON.parse(localStorage.getItem('nip.fieldvis')||'{"vend":true,"ttl":true,"when":true,"chips":true}');
function applyFieldVis(){
for(const k of ['vend','ttl','when','chips'])document.body.classList.toggle('f-off-'+k,!FVIS[k]);
document.body.classList.toggle('imgonly',!FVIS.vend&&!FVIS.ttl&&!FVIS.when&&!FVIS.chips);
document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>cb.checked=!!FVIS[cb.dataset.fv]);
localStorage.setItem('nip.fieldvis',JSON.stringify(FVIS));
}
document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
});
applyFieldVis();
const SHOP='https://admin.shopify.com/store/designer-laboratory-sandbox/products/';
function actionsFor(st,hasImg){
const linked=st!=='NEW';
const launchDis=linked||!hasImg;
const launchWhy=linked?'already linked to Shopify — launching again would create a duplicate'
:'no image — ALL PRODUCTS MUST HAVE IMAGES (fix the scrape first)';
return [
['launch:draft','🚀 Stage → Draft','',launchDis,launchWhy],
['launch:active','🚀 Stage → Active','pub',launchDis,launchWhy],
['publish:','🟢 Publish','pub',!linked||st==='PUBLISHED',!linked?'not on Shopify yet — stage a launch first':'already published'],
['unpublish:','⚪ Unpublish','unpub',!linked||st==='UNPUBLISHED',!linked?'not on Shopify yet — stage a launch first':'already unpublished'],
['archive:','🔴 Archive','arch',!linked||st==='ARCHIVED',!linked?'not on Shopify yet — stage a launch first':'already archived'],
];
}
// 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)):'');
// 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';
const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
const shopLink=p.handle?`<a class="lnk" href="${SHOP}${esc(p.handle)}" target="_blank" rel="noopener noreferrer">Shopify admin ↗</a>`
:(p.shopifyId?`<a class="lnk" href="${SHOP}${esc(p.shopifyId)}" target="_blank" rel="noopener noreferrer">Shopify admin ↗</a>`:'');
const vendLink=(p.url&&/^https?:\/\//i.test(p.url))?`<a class="lnk" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">vendor ↗</a>`:'';
const storeLink=p.handle?`<a class="lnk" href="https://www.designerwallcoverings.com/products/${esc(p.handle)}" target="_blank" rel="noopener noreferrer">storefront ↗</a>`:'';
const acts=buildActs(p.id,st,!!p.image);
const utils=`<div class="acts">
${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy vendor SKU">📋 SKU</button>`:''}
${p.dwSku?`<button class="act" data-copy="${esc(p.dwSku)}" onclick="copyTxt(this)" title="copy DW SKU">📋 DW</button>`:''}
${(p.dwSku||p.sku)?`<button class="act simact" onclick="loadSim(${p.id},'${esc(p.dwSku||p.sku)}',this)" title="Search the catalog for a similar pattern">≈ Similar</button>`:''}
${vendLink}${shopLink}${storeLink}
</div>`;
return `<div class="card${checked?' sel':''}" data-id="${p.id}" data-st="${st}" data-img="${p.image?1:0}">
<label class="pick"><input type="checkbox" ${checked} onchange="togglePick(${p.id},this)"></label>
${img}<div class="b">
<span class="vend">${esc(fmtVendor(p.vendor))}</span>
<div class="ttl">${title}</div>
<span class="sub when" title="last crawl: ${esc(p.crawled||'unknown')}${p.firstSeen?(' · first seen: '+esc(p.firstSeen)):''}">🕓 ${esc(p.crawled||p.firstSeen||'—')}</span>
<span class="lcell lcolor" title="Color">${esc(p.color||'—')}</span>
<div class="chips">
<span class="chip st-${st}">${STATUS_LABEL[st]||st}</span>
<span class="chip toggle" onclick="togglePane(this,'info')">ⓘ details</span>
<span class="chip toggle act-toggle" onclick="togglePane(this,'acts')">⚙ actions</span>
</div>
<span class="lcell lsku" title="Vendor SKU">${esc(p.sku||'—')}</span>
<span class="lcell ldw" title="DW SKU">${esc(p.dwSku||'—')}</span>
<span class="lcell lcoll" title="Collection: ${esc(p.collection||'')}">${esc(p.collection||'—')}</span>
<span class="lcell ltype" title="Type">${esc(p.type||'—')}</span>
<span class="lcell lsync" title="Sync status">${esc(p.syncStatus||'—')}</span>
<span class="lcell lshopid" title="Shopify product id">${esc(p.shopifyId||'—')}</span>
<span class="lcell lfirst" title="First seen: ${esc(p.firstSeen||'')}">${esc(p.firstSeen||'—')}</span>
<span class="lcell lcrawl" title="Last crawl: ${esc(p.crawled||'')}">🕓 ${esc(p.crawled||'—')}</span>
<div class="info">
${kv('Vendor SKU',p.sku)}${kv('DW SKU',p.dwSku)}${kv('Collection',p.collection)}
${kv('Type',p.type)}${kv('Style',p.style)}${kv('Color group',p.colorGroup)}
${kv('Width',p.width)}${kv('Match',p.match)}
${kv('Sync status',p.syncStatus)}${kv('Shopify ID',p.shopifyId)}
${kv('First seen',p.firstSeen)}${kv('Last crawl',p.crawled)}${kv('Shopify synced',p.shopSynced)}
<div class="chips">${vendLink}${shopLink}</div>
</div>
<div class="actpane">
${acts}
${utils}
<div class="actmsg" id="msg-${p.id}"></div>
<div class="simpane" id="sim-${p.id}"></div>
</div>
</div></div>`;
}
// delegated card interaction — clicking anywhere non-interactive on the card
// toggles the ⓘ details drawer (never <a>-wrap the card)
grid.addEventListener('click',e=>{
if(e.target.closest('.pick,.chip,.info,.actpane,a,button,input,label'))return;
const c=e.target.closest('.card');
if(c)c.classList.toggle('info-open');
});
// "≈ Similar" → proxied all.dw /api/similar → attribute-scored LIVE substitutes rendered
// in a panel inside this card's action drawer. Toggle: a second click closes it.
async function loadSim(id,sku,btn){
const box=document.getElementById('sim-'+id); if(!box)return;
if(box.dataset.open==='1'){ box.dataset.open=''; box.innerHTML=''; if(btn)btn.classList.remove('on'); return; }
box.dataset.open='1'; if(btn)btn.classList.add('on');
box.innerHTML='<div class="simhead">Similar live patterns — '+esc(sku)+' <span class="simx" title="close">✕</span><span class="simload">searching…</span></div>';
box.querySelector('.simx').onclick=()=>{ box.dataset.open=''; box.innerHTML=''; if(btn)btn.classList.remove('on'); };
try{
const d=await (await fetch('/api/similar?sku='+encodeURIComponent(sku)+'&limit=12')).json();
const rows=(d&&d.rows)||[];
const ld=box.querySelector('.simload'); if(ld)ld.remove();
if(!rows.length){ box.insertAdjacentHTML('beforeend','<div class="simnone">No similar live patterns found.</div>'); return; }
const g=document.createElement('div'); g.className='simgrid'; box.appendChild(g);
rows.forEach(r=>{
const price=r.price?('$'+Number(r.price).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})):'';
const img=r.image||(r.images&&r.images[0])||'';
const c=document.createElement('div'); c.className='simcard';
c.innerHTML='<div class="simimg"></div><div class="simb"><div class="simsku">'+esc(r.sku||'—')+'</div>'
+'<div class="simttl" title="'+esc(r.title||'')+'">'+esc(r.title||'')+'</div>'
+'<div class="simvt">'+esc([r.vendor,r.type].filter(Boolean).join(' · '))+'</div>'
+(price?'<div class="simprice">'+price+'</div>':'')+'</div>';
if(img)c.querySelector('.simimg').style.backgroundImage='url("/img?u='+encodeURIComponent(img)+'")';
if(r.url){ c.style.cursor='pointer'; c.onclick=()=>window.open(r.url,'_blank','noopener,noreferrer'); }
g.appendChild(c);
});
}catch(e){
box.innerHTML='<div class="simnone">Similarity search unavailable — try again.</div>';
box.dataset.open=''; if(btn)btn.classList.remove('on');
}
}
function filsParams(p){
for(const [k] of FIELDS)if(FIL[k])p.set(k,FIL[k]);
const pat=$('#pattern').value.trim(); if(pat)p.set('pattern',pat);
return p;
}
async function load(reset){
if(state.loading) return; state.loading=true;
if(reset){ state.offset=0; grid.innerHTML=''; }
const p=filsParams(new URLSearchParams({status:STATUS,sort:state.sort,q:$('#q').value,limit:state.limit,offset:state.offset}));
// location.origin prefix: pages opened via user:pass@ URLs break RELATIVE
// fetches in WebKit/Safari — absolute same-origin URLs keep creds working.
const d=await (await fetch(location.origin+'/api/new-products?'+p)).json();
state.count=d.count; state.offset+=d.items.length;
grid.insertAdjacentHTML('beforeend', d.items.map(card).join(''));
$('#total').textContent=d.count.toLocaleString()+' '+STATUS;
$('#meta').textContent=`${state.offset.toLocaleString()} of ${d.count.toLocaleString()} shown`;
$('#empty').style.display=d.count?'none':'block';
$('#more').style.display=state.offset<d.count?'block':'none';
state.loading=false;
}
async function loadFacets(){
try{FACETS=await (await fetch(location.origin+'/api/facets?status='+STATUS)).json();}
catch(e){FACETS={};}
renderSide();
}
// ── left panel — Shopify-state table + one collapsed <details> per field ──
function renderStatusBox(){
const c=window.__counts||{};
const openNow=$('#statusbox details')?$('#statusbox details').open:false;
const curLbl=(STATUS_DEFS.find(([k])=>k===STATUS)||[])[1]||STATUS;
const rows=STATUS_DEFS.map(([k,lbl,col])=>{
const n=c[k]!=null?c[k]:null;
return `<div class="frow${STATUS===k?' active':''}" onclick="pickStatus('${k}')">
<span class="fl"><span class="sdot" style="background:${col}"></span>${esc(lbl)}</span>
<span class="n">${n!=null?n.toLocaleString():''}</span></div>`;
}).join('');
const dupNote=c.newButOnShopify?`<div class="fnote" title="flagged 'new' but the mfr pattern number is ALREADY on Shopify — excluded from NET NEW">⚠ ${c.newButOnShopify.toLocaleString()} flagged-new already on Shopify (dupes)</div>`:'';
$('#statusbox').innerHTML=`<details${openNow?' open':''}><summary>Shopify state<span class="cur" title="${esc(curLbl)}">${esc(curLbl)}</span></summary>
<div class="fbody">${rows}${dupNote}</div></details>`;
}
function pickStatus(k){
STATUS=k; localStorage.setItem('nip.status',k);
renderStatusBox(); loadFacets(); load(true); refreshPatternList();
}
function renderSide(){
renderStatusBox();
const openNow=new Set([...document.querySelectorAll('#facetbox details[open]')].map(d=>d.dataset.f));
$('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
const all=FACETS[k]||[];
const rows=all.slice(0,FCAP).map(([v,n,wi])=>{
const pct=(k==='vendor'&&n)?Math.round((wi||0)/n*100):null;
const img=pct!=null?`<span class="imgpct${pct<100?' warn':''}" title="${wi||0} of ${n} rows have an image">${pct}%🖼</span>`:'';
return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
<span class="fl">${esc(k==='vendor'?fmtVendor(v):v)}</span>${img}<span class="n">${n.toLocaleString()}</span></div>`;
}).join('');
const more=all.length>FCAP?`<div class="fmore">+ ${(all.length-FCAP).toLocaleString()} more — use search</div>`:'';
const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(k==='vendor'?fmtVendor(FIL[k]):FIL[k])}</span>`:'';
return `<details data-f="${k}"${openNow.has(k)?' open':''}><summary>${label}${cur}</summary><div class="fbody">${rows}${more}</div></details>`;
}).join('');
$('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value||$('#pattern').value||STATUS!=='netnew')?'block':'none';
}
function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);renderSide();load(true);refreshPatternList();}
$('#clearf').onclick=()=>{
FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';
STATUS='netnew';localStorage.setItem('nip.status',STATUS);
renderStatusBox();loadFacets();load(true);
};
// server-backed pattern type-ahead (126k distinct patterns — datalist refreshes
// as you type, scoped to the current status + filters)
let tp;
async function refreshPatternList(){
const qv=$('#pattern').value.trim();
const p=filsParams(new URLSearchParams({status:STATUS}));
p.delete('pattern'); if(qv)p.set('q',qv);
try{
const d=await (await fetch(location.origin+'/api/patterns?'+p)).json();
$('#patternlist').innerHTML=(d.patterns||[]).map(s=>`<option value="${esc(s)}">`).join('');
}catch(e){}
}
$('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(()=>{refreshPatternList();renderSide();load(true);},300);};
$('#sort').onchange=()=>{state.sort=$('#sort').value||'newest';localStorage.setItem('nip.sort',state.sort);renderListHead();load(true);};
let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>{renderSide();load(true);},300);};
$('#more').onclick=()=>load(false);
function applyView(){const list=localStorage.getItem('nip.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listview',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();pinListHead();}
$('#view').onclick=()=>{localStorage.setItem('nip.view',localStorage.getItem('nip.view')==='list'?'grid':'list');applyView();};
applyView();
async function boot(){
const s=await (await fetch(location.origin+'/api/stats')).json();
state.armable=!!s.armable; state.liveActions=s.liveActions||[]; state.store=s.store||''; state.armed=false;
window.__counts=s.counts||{};
renderArm();
renderSide(); // status table + (empty) facet tables render immediately
pinListHead();
loadFacets(); // fills the field tables when the server responds
refreshPatternList();
load(true);
}
$('#selall').onclick=()=>{grid.querySelectorAll('.card').forEach(c=>{const id=+c.dataset.id;if(!id)return;sel.add(id);c.classList.add('sel');const cb=c.querySelector('.pick input');if(cb)cb.checked=true;});syncSel();};
async function fetchIds(limit){
const p=filsParams(new URLSearchParams({status:STATUS,sort:state.sort,q:$('#q').value}));
if(limit)p.set('limit',limit);
const d=await(await fetch(location.origin+'/api/ids?'+p)).json();
return d.ids||[];
}
function applySelIds(ids){
ids.forEach(id=>sel.add(id));
grid.querySelectorAll('.card').forEach(c=>{const id=+c.dataset.id;const on=sel.has(id);c.classList.toggle('sel',on);const cb=c.querySelector('.pick input');if(cb)cb.checked=on;});
syncSel();
}
$('#selN').onclick=async()=>{
const n=Math.max(1,parseInt($('#selcount').value,10)||1);
const b=$('#selN'); b.disabled=true;
try{applySelIds(await fetchIds(n));}finally{b.disabled=false;}
};
$('#selmatch').onclick=async()=>{
const b=$('#selmatch'); b.disabled=true;
try{
const ids=await fetchIds(0);
if(!ids.length||!confirm(`Select ALL ${ids.length.toLocaleString()} rows matching the current filter?\n\n(Selection includes rows beyond the loaded page; bulk actions always stage, never write live.)`))return;
applySelIds(ids);
}finally{b.disabled=false;}
};
$('#clearsel').onclick=()=>{sel.clear();grid.querySelectorAll('.card.sel').forEach(c=>{c.classList.remove('sel');const cb=c.querySelector('.pick input');if(cb)cb.checked=false;});syncSel();};
async function rowAction(id,spec,btn){
const [action,mode]=spec.split(':');
const live=isLive(action);
const warn=live
? `⚠️ LIVE ${action.toUpperCase()} on ${state.store}\n\nThis writes to the REAL store immediately (reversible). Proceed?`
: `${action.toUpperCase()} 1 product?\n\nStages to the launch queue — nothing reaches the store.`;
if(!confirm(warn))return;
btn.disabled=true; const msg=$('#msg-'+id); if(msg)msg.textContent=live?'writing to Shopify…':'staging…';
try{
const r=await fetch(location.origin+'/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[id],action,mode,arm:state.armed})});
const d=await r.json(); if(d.error)throw new Error(d.error);
if(d.live){
const ap=d.applied||{}; const res=(d.results||[])[0]||{};
if(res.ok){updateCardStatus(id,(res.status&&{active:'PUBLISHED',draft:'UNPUBLISHED',archived:'ARCHIVED'}[res.status])||ACTION_RESULT[action]||'UNPUBLISHED');
if(msg)msg.textContent=`✅ LIVE ${action} → ${res.status||'done'} on ${d.store}`;}
else if(res.skipped){if(msg)msg.textContent=`↷ skipped — ${res.skipped}`;}
else{if(msg)msg.textContent=`✗ live ${action} failed — ${res.error||'unknown'}`;}
}else{
if(msg)msg.textContent=`✅ staged ${action}${mode?(' ('+mode+')'):''} — batch ${d.batchId}`;
}
}catch(e){if(msg)msg.textContent='✗ '+e.message;}
btn.disabled=false;
}
window.rowAction=rowAction; window.togglePick=togglePick; window.togglePane=togglePane; window.toggleArm=toggleArm; window.headSort=headSort; window.pickF=pickF; window.pickStatus=pickStatus; window.copyTxt=copyTxt;
$('#bulkgo').onclick=async()=>{
const ids=[...sel]; if(!ids.length)return;
const [action,mode]=$('#bulkact').value.split(':');
if(!confirm(`${action.toUpperCase()} ${ids.length} product(s)${mode?(' as '+mode.toUpperCase()):''}?\n\nBulk actions ALWAYS stage to the launch queue — bulk never writes live to the store (single-card only).`))return;
$('#bulkgo').disabled=true; $('#bulkmsg').textContent='staging…';
let staged=0;
try{
let blockedNoImg=0; const byVendor={};
for(let i=0;i<ids.length;i+=5000){
const ch=ids.slice(i,i+5000);
$('#bulkmsg').textContent=`staging… ${Math.min(i+ch.length,ids.length).toLocaleString()} / ${ids.length.toLocaleString()}`;
const r=await fetch(location.origin+'/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:ch,action,mode,stageOnly:true,arm:false})});
const d=await r.json();
if(d.error){ if(d.noImageBlocked){blockedNoImg+=d.noImageBlocked;continue;} throw new Error(d.error); }
staged+=d.staged||0; blockedNoImg+=d.noImageBlocked||0;
Object.entries(d.byVendor||{}).forEach(([v,n])=>byVendor[v]=(byVendor[v]||0)+n);
}
const vents=Object.entries(byVendor);
const vlist=vents.slice(0,8).map(([v,n])=>`${v}:${n}`).join(', ')+(vents.length>8?` … +${vents.length-8} vendors`:'');
$('#bulkmsg').textContent=`✅ staged ${staged.toLocaleString()} ${action} — ${vlist}`+(blockedNoImg?` · 🚫 ${blockedNoImg.toLocaleString()} blocked (no image)`:'');
}catch(e){$('#bulkmsg').textContent=`✗ ${e.message}`+(staged?` (— ${staged.toLocaleString()} staged before error)`:'');}
$('#bulkgo').disabled=false;
};
addEventListener('scroll',()=>{if(innerHeight+scrollY>=document.body.offsetHeight-600)load(false);});
syncSel();
boot();
</script>
</body></html>