← back to New Import Viewer
viewer: select-and-launch — per-product checkboxes, select-all, launch bar (draft/active) → /api/launch stages a batch (live Shopify push stays a separate gated step)
039ba4a3cd2c18857d554a53b7e8887e738d5d66 · 2026-06-09 16:38:00 -0700 · SteveStudio2
Files touched
A data/launch-queue.jsonlM public/index.htmlM server.js
Diff
commit 039ba4a3cd2c18857d554a53b7e8887e738d5d66
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue Jun 9 16:38:00 2026 -0700
viewer: select-and-launch — per-product checkboxes, select-all, launch bar (draft/active) → /api/launch stages a batch (live Shopify push stays a separate gated step)
---
data/launch-queue.jsonl | 1 +
public/index.html | 45 ++++++++++++++++++++++++++++++++++++++++++++-
server.js | 27 +++++++++++++++++++++++++++
3 files changed, 72 insertions(+), 1 deletion(-)
diff --git a/data/launch-queue.jsonl b/data/launch-queue.jsonl
new file mode 100644
index 0000000..3eb7e7d
--- /dev/null
+++ b/data/launch-queue.jsonl
@@ -0,0 +1 @@
+{"batchId":"launch-1-1879119","mode":"draft","count":1,"skus":1}
diff --git a/public/index.html b/public/index.html
index 8a1b079..dba39e2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -30,6 +30,18 @@ a.open{color:var(--acc);text-decoration:none;font-size:11px;border:1px solid var
a.open:hover{background:var(--panel)}
#more{display:block;margin:8px auto 40px;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)}
+.card{position:relative}
+.card.sel{outline:2px solid var(--acc);outline-offset:-2px}
+.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}
+.btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px}
+.btn:hover{border-color:var(--acc)}
+#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:14px;flex-wrap:wrap}
+#selbar.on{display:flex}
+#selbar .n{font-weight:700;color:var(--acc)}
+#launch{background:var(--acc);color:#04201a;font-weight:700;border:0;border-radius:8px;padding:8px 18px;cursor:pointer}
+#launch:disabled{opacity:.4;cursor:default}
</style></head>
<body>
<header>
@@ -51,13 +63,25 @@ a.open:hover{background:var(--panel)}
</select></div>
<div class="ctl"><label class="muted">Density</label>
<input type="range" id="density" min="3" max="10" step="1"></div>
+ <button class="btn" id="selall">☑ Select all shown</button>
</header>
<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>
+<div id="selbar">
+ <span class="n" id="seln">0 selected</span>
+ <button class="btn" id="clearsel">clear</button>
+ <span class="muted">launch as</span>
+ <select id="mode"><option value="draft">DRAFT (review first)</option><option value="active">ACTIVE (live on create)</option></select>
+ <button id="launch">🚀 Launch to Shopify ▸</button>
+ <span class="muted" id="launchmsg"></span>
+</div>
<script>
const $=s=>document.querySelector(s);
const grid=$('#grid'), state={offset:0,limit:120,count:0,loading:false};
+const sel=new Set();
+function syncSel(){const n=sel.size;$('#seln').textContent=n.toLocaleString()+' selected';$('#selbar').classList.toggle('on',n>0);$('#launch').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();}
// persisted prefs (CLAUDE.md: sort + density persist in localStorage)
$('#sort').value=localStorage.getItem('nip.sort')||'newest';
const dens=localStorage.getItem('nip.density')||'6'; $('#density').value=dens;
@@ -69,7 +93,10 @@ function card(p){
:`<div class="imgwrap noimg"></div>`;
const crawled=p.crawled?`🕓 ${esc(p.crawled)}`:'🕓 (no crawl date)';
const seen=p.firstSeen?` · first seen ${esc(p.firstSeen)}`:'';
- return `<div class="card">${img}<div class="b">
+ const checked=sel.has(p.id)?'checked':'';
+ return `<div class="card${checked?' sel':''}" data-id="${p.id}">
+ <label class="pick"><input type="checkbox" ${checked} onchange="togglePick(${p.id},this)"></label>
+ ${img}<div class="b">
<span class="vend">${esc(p.vendor)}${p.collection?(' · '+esc(p.collection)):''}</span>
<div class="ttl">${title}</div>
<div class="row"><span class="sub">${esc(p.type||'')}</span>
@@ -102,7 +129,23 @@ $('#sort').onchange=()=>{localStorage.setItem('nip.sort',$('#sort').value);load(
let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>load(true),300);};
$('#density').oninput=()=>{document.documentElement.style.setProperty('--cols',$('#density').value);localStorage.setItem('nip.density',$('#density').value);};
$('#more').onclick=()=>load(false);
+$('#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();};
+$('#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();};
+$('#launch').onclick=async()=>{
+ const ids=[...sel]; if(!ids.length)return;
+ const mode=$('#mode').value;
+ if(!confirm(`Launch ${ids.length} product(s) to Shopify as ${mode.toUpperCase()}?\n\nThis STAGES the batch into the launch queue. The live Shopify push is a separate gated step — nothing goes to the store yet.`))return;
+ $('#launch').disabled=true; $('#launchmsg').textContent='staging…';
+ try{
+ const r=await fetch('/api/launch',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids,mode})});
+ const d=await r.json(); if(d.error)throw new Error(d.error);
+ const vlist=Object.entries(d.byVendor||{}).map(([v,n])=>`${v}:${n}`).join(', ');
+ $('#launchmsg').textContent=`✅ staged ${d.staged} as ${d.mode} — ${vlist}`;
+ }catch(e){$('#launchmsg').textContent='✗ '+e.message;}
+ $('#launch').disabled=false;
+};
addEventListener('scroll',()=>{if(innerHeight+scrollY>=document.body.offsetHeight-600)load(false);});
+syncSel();
boot();
</script>
</body></html>
diff --git a/server.js b/server.js
index 7bdb7c8..9093463 100644
--- a/server.js
+++ b/server.js
@@ -89,6 +89,33 @@ const server = http.createServer((req, res) => {
}));
return send(res, 200, JSON.stringify({ count: cnt, offset, limit, items }));
}
+ if (u.pathname === '/api/launch' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => (body += c));
+ req.on('end', () => {
+ try {
+ const d = JSON.parse(body || '{}');
+ const ids = Array.isArray(d.ids) ? d.ids.filter(n => Number.isInteger(n)).slice(0, 5000) : [];
+ const mode = d.mode === 'active' ? 'active' : 'draft';
+ if (!ids.length) return send(res, 400, JSON.stringify({ error: 'no products selected' }));
+ // Resolve the selected rows so the manifest carries vendor + sku, not bare ids.
+ const rows = q(`SELECT id, vendor_code, mfr_sku, dw_sku, pattern_name
+ FROM vendor_catalog WHERE id IN (${ids.join(',')}) AND ${NEW_PRED}`);
+ const batchId = 'launch-' + rows.length + '-' + ids[0];
+ const entry = { batchId, mode, count: rows.length,
+ skus: rows.map(r => ({ id: +r[0], vendor: r[1], sku: r[2], dwSku: r[3], pattern: r[4] })) };
+ // STAGE only — append to a launch queue. The live Shopify push (Phase 8)
+ // is a separate gated step; this never writes to the store.
+ const qf = path.join(__dirname, 'data', 'launch-queue.jsonl');
+ fs.mkdirSync(path.dirname(qf), { recursive: true });
+ fs.appendFileSync(qf, JSON.stringify({ ...entry, skus: entry.skus.length }) + '\n');
+ const byVendor = {};
+ rows.forEach(r => (byVendor[r[1]] = (byVendor[r[1]] || 0) + 1));
+ return send(res, 200, JSON.stringify({ ok: true, staged: rows.length, mode, batchId, byVendor }));
+ } catch (e) { return send(res, 500, JSON.stringify({ error: e.message })); }
+ });
+ return;
+ }
return send(res, 404, JSON.stringify({ error: 'not found' }));
} catch (e) {
return send(res, 500, JSON.stringify({ error: e.message }));
← a368e81 viewer: clean 'no image' placeholder on broken vendor image
·
back to New Import Viewer
·
viewer: add List view toggle (compact horizontal rows, persi 186111c →