[object Object]

← back to Dw Photo Capture

Multi-photo add: ➕ Multiple in the Photos gallery batch-appends several shots at once (keeps featured + existing images); /api/photos endpoint, never-upscale each, 12/batch cap

4e6ec3f341c7c69cf1fe07da2a862f1cd16abd6c · 2026-06-25 07:15:10 -0700 · Steve

Files touched

Diff

commit 4e6ec3f341c7c69cf1fe07da2a862f1cd16abd6c
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 25 07:15:10 2026 -0700

    Multi-photo add: ➕ Multiple in the Photos gallery batch-appends several shots at once (keeps featured + existing images); /api/photos endpoint, never-upscale each, 12/batch cap
---
 data/build.json     |   5 +++--
 public/icon-180.png | Bin 0 -> 4386 bytes
 public/icon-512.png | Bin 0 -> 12871 bytes
 public/index.html   |  18 +++++++++++++++-
 server.js           |  59 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 test-allshopify.js  |   8 +++++++
 6 files changed, 87 insertions(+), 3 deletions(-)

diff --git a/data/build.json b/data/build.json
index 7b98532..f5579ee 100644
--- a/data/build.json
+++ b/data/build.json
@@ -1,5 +1,5 @@
 {
-  "next": 21,
+  "next": 22,
   "map": {
     "578af86f": 2,
     "bc95fdb0": 3,
@@ -19,6 +19,7 @@
     "48c9e659": 17,
     "779f6d1f": 18,
     "598b1ee5": 19,
-    "c3efeca8": 20
+    "c3efeca8": 20,
+    "859c4943": 21
   }
 }
\ No newline at end of file
diff --git a/public/icon-180.png b/public/icon-180.png
new file mode 100644
index 0000000..1148fc6
Binary files /dev/null and b/public/icon-180.png differ
diff --git a/public/icon-512.png b/public/icon-512.png
new file mode 100644
index 0000000..aa6134a
Binary files /dev/null and b/public/icon-512.png differ
diff --git a/public/index.html b/public/index.html
index 7f9c0a7..9785150 100644
--- a/public/index.html
+++ b/public/index.html
@@ -72,6 +72,7 @@
   .gal-title{flex:1;min-width:0;line-height:1.2} .gal-title b{display:block;font:600 15px/1.2 "SF Pro Display",sans-serif} .gal-title span{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--gold)}
   .gal-add{position:relative;overflow:hidden;background:var(--gold);color:#1b1407;border-radius:10px;padding:9px 14px;font-weight:700;font-size:14px;cursor:pointer}
   .gal-add input{position:absolute;inset:0;opacity:0;font-size:120px;cursor:pointer}
+  .gal-add-multi{background:#16140f;border:1px solid var(--gold);color:var(--gold)}
   .gal-grid{flex:1;overflow-y:auto;padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;align-content:start}
   .gtile{position:relative;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:#0c0b09;aspect-ratio:1}
   .gtile img{width:100%;height:100%;object-fit:cover;display:block}
@@ -148,6 +149,7 @@
   <div class="gal-head">
     <button class="gal-x" id="galClose">‹ Back</button>
     <div class="gal-title"><b id="galName"></b><span id="galSku"></span></div>
+    <label class="gal-add gal-add-multi">➕ Multiple<input type="file" accept="image/*" multiple id="galAddMulti"></label>
     <label class="gal-add">📷 Add<input type="file" accept="image/*" capture="environment" id="galAdd"></label>
   </div>
   <div class="gal-grid" id="galGrid"></div>
@@ -372,7 +374,7 @@ async function renderGallery(){
   try{
     const r=await fetch('/api/images?pid='+GAL_X.product_id); const d=await r.json();
     const imgs=d.images||[];
-    if(!imgs.length){ g.innerHTML='<div class="gal-empty">No photos yet — tap 📷 Add to take one.</div>'; return; }
+    if(!imgs.length){ g.innerHTML='<div class="gal-empty">No photos yet — tap 📷 Add for one, or ➕ Multiple to add several.</div>'; return; }
     g.innerHTML='';
     imgs.forEach((im,i)=>{
       const feat=im.position===1;
@@ -391,6 +393,20 @@ async function imgAction(act,image_id){
     const d=await r.json(); if(d.ok){ toast(act==='delete'?'Removed':'Featured ✓'); renderGallery(); } else toast('Failed'); }catch(e){ toast('Failed'); }
 }
 $('#galClose').addEventListener('click',closeGallery);
+// Batch add: take several photos (in your camera roll), pick them all here, save at once.
+$('#galAddMulti').addEventListener('change',async e=>{
+  const files=[...e.target.files]; e.target.value=''; if(!files.length||!GAL_X)return;
+  if(files.length>12){ toast('Max 12 at a time — adding first 12'); files.length=12; }
+  toast('Adding '+files.length+' photo'+(files.length>1?'s':'')+'…');
+  try{
+    const dataUrls=[]; for(const f of files){ dataUrls.push(await downscale(f)); } // never-upscale each
+    const r=await fetch('/api/photos',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({dw_sku:GAL_X.dw_sku,product_id:GAL_X.product_id,dataUrls,meta:cardMeta(GAL_X)})});
+    const d=await r.json();
+    if(d.ok){ toast('✓ Added '+d.added+' photo'+(d.added>1?'s':'')+((d.errors&&d.errors.length)?(' · '+d.errors.length+' failed'):'')+(d.live?' · 🟢 LIVE':'')); renderGallery(); }
+    else toast('Add failed'+(d.err?': '+d.err:''));
+  }catch(err){ toast('Upload failed — check connection'); }
+});
 $('#galAdd').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f||!GAL_X)return;
   // add a photo to this SKU's gallery (keep existing), via the editor
   const gx={...GAL_X, keep_images:true, _replaceAll:false, _fromGallery:true};
diff --git a/server.js b/server.js
index ce8ecfd..9d3f790 100644
--- a/server.js
+++ b/server.js
@@ -113,6 +113,16 @@ async function shopifyAttachImage(productId, dwSku, base64, keepOthers = false)
   return { ok: true, image_id: newId, kept_others: keepOthers };
 }
 
+// Append an ADDITIONAL image — no sibling delete, no position:1, so the existing
+// featured image is untouched. Used by the multi-photo batch add.
+async function shopifyAppendImage(productId, dwSku, base64, idx) {
+  const safe = (dwSku || ('SKU' + productId)).replace(/[^A-Za-z0-9._-]/g, '');
+  const r = await shopifyReq('POST', `/products/${productId}/images.json`,
+    { image: { attachment: base64, filename: `${safe}-${idx}.jpg` } });
+  if (!(r.status >= 200 && r.status < 300)) return { ok: false, err: `HTTP ${r.status}: ${(r.raw || '').slice(0, 120)}` };
+  return { ok: true, image_id: r.body && r.body.image && r.body.image.id };
+}
+
 // After a photo lands, make the SKU live IF it passes the activation gate
 // (now has image + a real price + width + a description). Else stays draft.
 async function shopifyActivateIfReady(productId) {
@@ -257,6 +267,21 @@ const server = http.createServer((req, res) => {
     return send(res, 404, { err: 'not found' });
   }
 
+  // App icons + PWA manifest — make "Add to Home Screen" a real app (clean icon, fullscreen).
+  if (u.pathname === '/icon-180.png' || u.pathname === '/icon-512.png' || u.pathname === '/apple-touch-icon.png') {
+    const f = path.join(ROOT, 'public', u.pathname === '/apple-touch-icon.png' ? 'icon-180.png' : path.basename(u.pathname));
+    if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
+    return send(res, 404, { err: 'not found' });
+  }
+  if (u.pathname === '/manifest.webmanifest') {
+    res.writeHead(200, { 'Content-Type': 'application/manifest+json' });
+    return res.end(JSON.stringify({
+      name: 'Mobile DW SKU Photos', short_name: 'DW SKU Photos', start_url: '/?app=1', display: 'standalone',
+      background_color: '#0f0e0c', theme_color: '#0f0e0c',
+      icons: [{ src: '/icon-180.png', sizes: '180x180', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }]
+    }));
+  }
+
   // Photo gallery: list every image on a product (keep/remove/feature from the UI).
   if (u.pathname === '/api/images' && req.method === 'GET') {
     const pid = u.searchParams.get('pid');
@@ -348,6 +373,40 @@ const server = http.createServer((req, res) => {
     return;
   }
 
+  // Batch add MULTIPLE photos to an existing product (append, keep featured + siblings).
+  // Body: { dw_sku, product_id, dataUrls:[...], meta }
+  if (u.pathname === '/api/photos' && req.method === 'POST') {
+    let body = '';
+    req.on('data', c => { body += c; if (body.length > 60 * 1024 * 1024) req.destroy(); });
+    req.on('end', async () => {
+      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
+      const { dw_sku, product_id, dataUrls, meta } = p;
+      if (!dw_sku || !product_id || !Array.isArray(dataUrls) || !dataUrls.length)
+        return send(res, 400, { err: 'dw_sku + product_id + dataUrls[] required' });
+      const urls = dataUrls.slice(0, 12); // cap a single batch
+      let added = 0; const errors = []; let lastPhoto = null;
+      for (let i = 0; i < urls.length; i++) {
+        const b64 = String(urls[i]).replace(/^data:image\/\w+;base64,/, '');
+        const safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
+        const fname = `${safe}-${Date.now()}-${i}.jpg`;
+        try { fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64')); lastPhoto = `/photos/${fname}`; } catch (e) {}
+        const r = await shopifyAppendImage(product_id, dw_sku, b64, `${Date.now()}-${i}`);
+        if (r.ok) added++; else errors.push(r.err);
+      }
+      const entry = progress[dw_sku] || {};
+      entry.done = true; entry.skipped = false; entry.photo = lastPhoto || entry.photo;
+      entry.ts = new Date().toISOString(); entry.shopify_pushed = added > 0; entry.product_id = product_id;
+      const a = added > 0 ? await shopifyActivateIfReady(product_id) : {};
+      entry.live = !!a.live; entry.live_reason = a.reason || null;
+      progress[dw_sku] = entry; saveProgress();
+      recents[dw_sku] = cardRecord(dw_sku, meta, { product_id, image: entry.photo,
+        status: entry.live ? 'ACTIVE' : (meta && meta.status) || '', ts: entry.ts });
+      saveRecents();
+      return send(res, 200, { ok: added > 0, added, total: urls.length, errors, live: entry.live, live_reason: entry.live_reason });
+    });
+    return;
+  }
+
   if (u.pathname === '/api/skip' && req.method === 'POST') {
     let body = ''; req.on('data', c => body += c);
     req.on('end', () => {
diff --git a/test-allshopify.js b/test-allshopify.js
index 00695e7..cb930aa 100644
--- a/test-allshopify.js
+++ b/test-allshopify.js
@@ -85,6 +85,14 @@ const ok  = (c, m) => { (c ? pass++ : fail++); console.log(`  ${c ? '✓' : '✗
     const r = await srv('POST', '/api/photo', { dw_sku: TAG, product_id: pid, dataUrl: DATAURL, keep_images: false });
     ok(r.body && r.body.ok, `/api/photo (replace) returned ok`);
     ok(await imgCount(pid) === 1, `image count 3 → 1 (replace-all wiped siblings)`);
+
+    console.log(`\n── Test 4: MULTI add (batch /api/photos) appends, keeps featured ──`);
+    const feat0 = ((await shopify('GET', `/products/${pid}/images.json`)).body.images.find(i => i.position === 1) || {}).id;
+    const m = await srv('POST', '/api/photos', { dw_sku: TAG, product_id: pid, dataUrls: [DATAURL, DATAURL, DATAURL], meta: { title: 'ZZ TEST', product_id: pid } });
+    ok(m.body && m.body.ok && m.body.added === 3, `/api/photos added 3 of 3 (got ${m.body && m.body.added})`);
+    ok(await imgCount(pid) === 4, `image count 1 → 4 (3 appended, original kept)`);
+    const feat1 = ((await shopify('GET', `/products/${pid}/images.json`)).body.images.find(i => i.position === 1) || {}).id;
+    ok(feat0 && feat0 === feat1, `featured image UNCHANGED by batch add (append, not re-feature)`);
   } finally {
     console.log(`\n── Teardown: delete the throwaway product + local residue ──`);
     const del = await shopify('DELETE', `/products/${pid}.json`);

← a4af440 All GRS- sold PER YARD: flipped 133 products (variant Roll->  ·  back to Dw Photo Capture  ·  Installable PWA: app icon (gold DW/camera) + web manifest + 989aa83 →