← 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
M data/build.jsonA public/icon-180.pngA public/icon-512.pngM public/index.htmlM server.jsM test-allshopify.js
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 →