[object Object]

← back to Japan Enrich

japan viewer: import-queue drawer — list queued SKUs, per-row remove, clear all

9f2be431a2a42c7ed006186ea2e26b53602c8d8b · 2026-07-06 13:49:18 -0700 · Steve

Click the '⤓ N queued' badge to open a right-side drawer (thumb + sku +
distributor + title per row). Per-row ✕ removes; 'Clear all' empties the queue.
/api/import-queue now returns full items; added POST /api/import/clear. Panel
stays in sync with grid card chips (syncCardImp) and refreshes if open when you
queue from a card or bulk-import.

Files touched

Diff

commit 9f2be431a2a42c7ed006186ea2e26b53602c8d8b
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 6 13:49:18 2026 -0700

    japan viewer: import-queue drawer — list queued SKUs, per-row remove, clear all
    
    Click the '⤓ N queued' badge to open a right-side drawer (thumb + sku +
    distributor + title per row). Per-row ✕ removes; 'Clear all' empties the queue.
    /api/import-queue now returns full items; added POST /api/import/clear. Panel
    stays in sync with grid card chips (syncCardImp) and refreshes if open when you
    queue from a card or bulk-import.
---
 viewer-local/public/index.html | 82 ++++++++++++++++++++++++++++++++++++++++--
 viewer-local/server.js         |  6 +++-
 2 files changed, 85 insertions(+), 3 deletions(-)

diff --git a/viewer-local/public/index.html b/viewer-local/public/index.html
index 395a00d..9bf120e 100644
--- a/viewer-local/public/index.html
+++ b/viewer-local/public/index.html
@@ -94,7 +94,29 @@ body.imgonly .grid:not(.list) .card .b{display:none}
 .chip.imp{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
 .chip.imp:hover{border-color:var(--acc);color:var(--ink)}
 .chip.imp.on{border-color:var(--pub);color:var(--pub)}
-#qbadge{color:var(--pub);font-weight:600}
+#qbadge{color:var(--pub);font-weight:600;cursor:pointer}
+#qbadge:hover{text-decoration:underline}
+/* ── import-queue drawer (right side) ── */
+#qpanel{position:fixed;top:0;right:0;z-index:60;width:360px;max-width:92vw;height:100vh;background:#0e0f15;
+ border-left:1px solid var(--line);box-shadow:-8px 0 24px #0006;display:flex;flex-direction:column}
+#qpanel[hidden]{display:none}
+.qp-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--line)}
+.qp-head strong{font-size:14px;flex:1}
+.qp-head button{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 10px;cursor:pointer;font-size:12px}
+.qp-head #qp-clear:hover{border-color:#c0392b;color:#e57373}
+.qp-head #qp-close:hover{border-color:var(--acc)}
+#qp-list{flex:1;overflow:auto;padding:8px 10px 20px}
+.qi{display:grid;grid-template-columns:40px 1fr 22px;gap:9px;align-items:center;padding:6px 4px;border-bottom:1px solid var(--line)}
+.qi img{width:40px;height:40px;object-fit:cover;border-radius:4px;background:#0a0b0f;display:block}
+.qi .qm{min-width:0}
+.qi .qs{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--mut)}
+.qi .qt{font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.qi .qd{font-size:10px;color:var(--acc)}
+.qi .qd.direct{color:var(--mut)}
+.qi .qx{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:6px;cursor:pointer;width:22px;height:22px;line-height:1;padding:0}
+.qi .qx:hover{border-color:#c0392b;color:#e57373}
+.qp-empty{padding:40px 12px;text-align:center;color:var(--mut);font-size:12.5px}
+.qp-foot{padding:10px 14px;border-top:1px solid var(--line);font-size:11px;line-height:1.5}
 .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)}
 /* colorway sibling dots — the "other colors of this pattern" */
@@ -190,6 +212,15 @@ body.listmode #listhead{display:grid}
   <div id="empty" style="display:none">No SKUs match.</div>
 </main>
 </div>
+<div id="qpanel" hidden aria-label="import queue">
+  <div class="qp-head">
+    <strong>Import queue (<span id="qp-count">0</span>)</strong>
+    <button id="qp-clear" type="button">Clear all</button>
+    <button id="qp-close" type="button" aria-label="Close">✕</button>
+  </div>
+  <div id="qp-list"></div>
+  <div class="qp-foot muted">Queued SKUs stage into <code>dw_unified</code> via <code>db/promote-import-queue.js</code>. Promotion &amp; Shopify publish are gated.</div>
+</div>
 <script>
 const $=s=>document.querySelector(s);
 const grid=$('#grid');
@@ -312,7 +343,52 @@ grid.addEventListener('click',e=>{
   if(c&&c.dataset.href)window.open(c.dataset.href,'_blank','noopener,noreferrer');
 });
 // ── import queue: enqueue INTENT for onboarding (promotion to dw_unified/Shopify is gated) ──
-function updateQueueBadge(){const el=$('#qbadge');if(el)el.textContent=QUEUED.size?`⤓ ${QUEUED.size.toLocaleString()} queued`:'';}
+function updateQueueBadge(){const el=$('#qbadge');if(el)el.textContent=`⤓ ${QUEUED.size.toLocaleString()} queued`;}
+// keep a grid card's import chip in sync when the queue changes from the panel
+function syncCardImp(sku,queued){
+  const el=document.querySelector(`.chip.imp[data-sku="${(window.CSS&&CSS.escape)?CSS.escape(sku):sku}"]`);
+  if(el){el.classList.toggle('on',queued);el.textContent=queued?'✓ queued':'⤓ import';}
+}
+// ── import-queue drawer ──
+const qpanel=()=>$('#qpanel');
+async function openQueue(){
+  const d=await fetch(location.origin+'/api/import-queue').then(r=>r.json()).catch(()=>({items:[]}));
+  QUEUED=new Set(d.skus||[]); updateQueueBadge();
+  renderQueue(d.items||[]);
+  qpanel().hidden=false;
+}
+function renderQueue(items){
+  $('#qp-count').textContent=items.length.toLocaleString();
+  const list=$('#qp-list');
+  if(!items.length){list.innerHTML='<div class="qp-empty">Queue is empty.<br>Click <b>⤓ import</b> on any card to add SKUs.</div>';return;}
+  list.innerHTML=items.map(r=>{
+    const img=r.image?`<img loading="lazy" src="${esc(r.image)}" onerror="this.style.visibility='hidden'">`:'<img>';
+    const d=r.distributor?`<div class="qd${r.dist_direct?' direct':''}">${r.dist_direct?'':'🏬 '}${esc(r.distributor)}</div>`:'';
+    return `<div class="qi" data-sku="${esc(r.sku)}">${img}
+      <div class="qm"><div class="qt" title="${esc(r.title||'')}">${esc(r.title||r.sku)}</div>
+      <div class="qs">${esc(r.sku)}</div>${d}</div>
+      <button class="qx" title="Remove from queue" onclick="removeFromQueue('${esc(r.sku)}')">✕</button></div>`;
+  }).join('');
+}
+async function removeFromQueue(sku){
+  try{await fetch(location.origin+'/api/import/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sku})});}catch(e){}
+  QUEUED.delete(sku); updateQueueBadge(); syncCardImp(sku,false);
+  const row=$('#qp-list').querySelector(`.qi[data-sku="${(window.CSS&&CSS.escape)?CSS.escape(sku):sku}"]`);
+  if(row)row.remove();
+  $('#qp-count').textContent=QUEUED.size.toLocaleString();
+  if(!QUEUED.size)renderQueue([]);
+}
+window.removeFromQueue=removeFromQueue;
+async function clearQueue(){
+  if(!QUEUED.size)return;
+  if(!confirm(`Clear all ${QUEUED.size.toLocaleString()} queued SKUs? (nothing is imported — this just empties the queue)`))return;
+  try{await fetch(location.origin+'/api/import/clear',{method:'POST'});}catch(e){}
+  const was=[...QUEUED]; QUEUED=new Set(); updateQueueBadge();
+  was.forEach(s=>syncCardImp(s,false)); renderQueue([]);
+}
+$('#qbadge').onclick=openQueue;
+$('#qp-close').onclick=()=>{qpanel().hidden=true;};
+$('#qp-clear').onclick=clearQueue;
 async function toggleImport(sku,el){
   const on=QUEUED.has(sku);
   try{
@@ -321,6 +397,7 @@ async function toggleImport(sku,el){
     if(on)QUEUED.delete(sku); else QUEUED.add(sku);
     if(el){el.classList.toggle('on',!on);el.textContent=on?'⤓ import':'✓ queued';}
     updateQueueBadge();
+    if(!$('#qpanel').hidden)openQueue();
   }catch(e){}
 }
 window.toggleImport=toggleImport;
@@ -332,6 +409,7 @@ async function importFiltered(){
     await fetch(location.origin+'/api/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({skus})});
     const q=await fetch(location.origin+'/api/import-queue').then(r=>r.json());
     QUEUED=new Set(q.skus||[]); updateQueueBadge(); applyFilters();
+    if(!$('#qpanel').hidden)openQueue();
   }catch(e){}
 }
 $('#impfiltered').onclick=importFiltered;
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 09b404b..53173c9 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -428,7 +428,11 @@ const server = http.createServer(async (req, res) => {
   // ── import queue: enqueue INTENT for onboarding (reversible; no prod write) ──
   if (u.pathname === '/api/import-queue') {
     const q = readQueue();
-    return sendJson(req, res, { count: q.length, skus: q.map((r) => r.sku) });
+    return sendJson(req, res, { count: q.length, skus: q.map((r) => r.sku), items: q });
+  }
+  if (u.pathname === '/api/import/clear' && req.method === 'POST') {
+    writeQueue([]);
+    return sendJson(req, res, { count: 0, cleared: true });
   }
   if (u.pathname === '/api/import' && req.method === 'POST') {
     const body = await readBody(req);

← ab44253 japan viewer: '⤓ Import' button — enqueue SKUs for dw_unifie  ·  back to Japan Enrich  ·  japan: create per-vendor *_catalog tables (promote-queue tar af6d4f1 →