[object Object]

← 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

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 →