[object Object]

← back to Dw Photo Capture

Create 22 net-new GRS drafts (full sheet info, photo->live) + per-SKU photo gallery: πŸ–Ό Photos shows every image with β˜… Feature / βœ• Remove / πŸ“· Add (keep existing). /api/images,/api/image/delete,/api/image/feature

087b386d1fe246ba800c902d7adcceb876b1ada7 Β· 2026-06-24 18:53:04 -0700 Β· steve@designerwallcoverings.com

Files touched

Diff

commit 087b386d1fe246ba800c902d7adcceb876b1ada7
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Wed Jun 24 18:53:04 2026 -0700

    Create 22 net-new GRS drafts (full sheet info, photo->live) + per-SKU photo gallery: πŸ–Ό Photos shows every image with β˜… Feature / βœ• Remove / πŸ“· Add (keep existing). /api/images,/api/image/delete,/api/image/feature
---
 create_22_new.py  |  56 +++++++++++++++
 data/build.json   |   5 +-
 data/queue.json   | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html |  73 ++++++++++++++++++++
 server.js         |  26 +++++++
 5 files changed, 356 insertions(+), 2 deletions(-)

diff --git a/create_22_new.py b/create_22_new.py
new file mode 100644
index 0000000..3a42139
--- /dev/null
+++ b/create_22_new.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Bulk-create the net-new GRS sheet items as DRAFT Shopify products with full info
+   (no image yet β€” they go live once photographed). DRY default; CONFIRM=1 writes."""
+import os,sys,json,re,time,urllib.request
+SHOP="designer-laboratory-sandbox.myshopify.com";API="2024-10";DRY=os.environ.get("CONFIRM","0")!="1"
+TOKEN=""
+for l in open(os.path.expanduser("~/Projects/secrets-manager/.env")):
+    if l.startswith("SHOPIFY_ADMIN_TOKEN="): TOKEN=l.strip().split("=",1)[1];break
+H={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json"}
+def api(m,p,pl=None):
+    d=json.dumps(pl).encode() if pl is not None else None
+    r=urllib.request.Request(f"https://{SHOP}/admin/api/{API}"+p,data=d,headers=H,method=m)
+    with urllib.request.urlopen(r,timeout=60) as x: b=x.read().decode();return x.status,(json.loads(b) if b.strip() else {})
+SHEET={x["grs"].upper():x for x in json.load(open(os.path.expanduser("~/Projects/dw-photo-capture/data/sheet_grs.json")))}
+# the net-new list straight from the app
+import urllib.request as u, base64
+req=u.Request("http://127.0.0.1:9890/api/new",headers={"Authorization":"Basic "+base64.b64encode(b"admin:DW2024!").decode()})
+NEW=json.loads(u.urlopen(req,timeout=20).read())["items"]
+def title(s):
+    name=(s.get("name") or "").strip(); color=(s.get("color") or "").strip()
+    nl,cl=name.lower(),color.lower()
+    col=(" "+color) if (color and nl!=cl and cl not in nl and nl not in cl) else ""
+    if name: return f"{name}{col} Grasscloth Wallcovering | Fentucci"
+    if s.get("material"): return f"Fentucci {s['material']} Grasscloth Wallcovering"+((" β€” "+color) if color else "")
+    return "Fentucci Grasscloth Wallcovering"+((" β€” "+color) if color else "")
+def body(s):
+    lead=s.get("name") or (("Fentucci "+s["material"]+" grasscloth") if s.get("material") else "This Fentucci grasscloth")
+    de=(s.get("desc") or "").strip()
+    if de and de.lower().startswith("is "): return f"{lead} {de}"
+    return de or f"{lead} is a natural woven grasscloth wallcovering with handcrafted natural-fiber texture."
+def mf(ns,k,v): return {"namespace":ns,"key":k,"value":str(v),"type":"single_line_text_field"} if v else None
+print(f"{len(NEW)} net-new to create as DRAFT (full info, no image)")
+created=0
+for it in NEW:
+    s=SHEET.get(it["dw_sku"].upper())
+    if not s: print(f"   skip {it['dw_sku']} (no sheet row)"); continue
+    grs=s["grs"]; price=s.get("price"); col=(s.get("color") or "").strip()
+    tags=["Grasscloth","Natural Wallcovering","Fentucci","TWIL Naturals","display_variant","Needs-Image"]+([f"color:{col}"] if col else [])+([] if price else ["Needs-Cost"])
+    roll={"option1":"Roll","sku":grs,"inventory_management":"shopify","inventory_quantity":2026}
+    if price: roll["price"]=str(price)
+    payload={"product":{"title":title(s),"body_html":body(s),"vendor":"Fentucci","product_type":"Wallcovering",
+      "status":"draft","published_scope":"global","tags":", ".join(tags),"options":[{"name":"Size"}],
+      "variants":[roll,{"option1":"Sample","sku":grs+"-Sample","price":"4.25","inventory_management":"shopify","inventory_quantity":2026}],
+      "metafields":[x for x in [mf("global","width",s.get("width") or "36 Inches"),mf("global","length",s.get("length") or "8 Yards"),
+        mf("global","unit_of_measure","Priced Per Single Roll"),mf("global","Content","Natural Grasscloth"),
+        mf("global","Brand","Fentucci"),mf("global","Collection","TWIL Naturals"),mf("global","Color-Way",col),mf("custom","color",col),
+        mf("custom","manufacturer_sku",s.get("mfr")),mf("dwc","manufacturer_sku",s.get("mfr")),
+        mf("custom","pattern_name",s.get("name")),mf("dwc","pattern_name",s.get("name"))] if x]}}
+    if DRY: print(f"   [dry] {grs:12} {('$'+str(price)) if price else 'no-price':9} {title(s)[:46]}"); continue
+    try:
+        st,r=api("POST","/products.json",payload); created+=1
+        print(f"   created {grs} -> {r.get('product',{}).get('id')} (draft)"); time.sleep(0.5)
+    except Exception as e:
+        bd=getattr(e,'read',lambda:b'')() if hasattr(e,'read') else b''; print(f"   ERR {grs}: {e} {bd[:140]}")
+if DRY: print(f"\n[DRY] would create {len(NEW)} drafts. CONFIRM=1 to create.")
+else: print(f"\nDONE β€” created {created} draft products (Needs-Image; photograph to go live).")
diff --git a/data/build.json b/data/build.json
index d30ab2b..9a222da 100644
--- a/data/build.json
+++ b/data/build.json
@@ -1,5 +1,5 @@
 {
-  "next": 16,
+  "next": 17,
   "map": {
     "578af86f": 2,
     "bc95fdb0": 3,
@@ -14,6 +14,7 @@
     "5ef2ebe2": 12,
     "788487db": 13,
     "3a1be30f": 14,
-    "d0f0df55": 15
+    "d0f0df55": 15,
+    "9905b54e": 16
   }
 }
\ No newline at end of file
diff --git a/data/queue.json b/data/queue.json
index 7ca6d22..434d1ab 100644
--- a/data/queue.json
+++ b/data/queue.json
@@ -26,6 +26,15 @@
     "price": "32.85",
     "done": false
   },
+  {
+    "product_id": "7867561902131",
+    "title": "Assisi Straw Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27490",
+    "mfr": "EG501",
+    "status": "DRAFT",
+    "price": "50.40",
+    "done": false
+  },
   {
     "product_id": "7822226030643",
     "title": "Belluno Sage Green Grasscloth Wallcovering | Fentucci",
@@ -215,6 +224,24 @@
     "price": "36.30",
     "done": false
   },
+  {
+    "product_id": "7867561771059",
+    "title": "Creamy Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-30600",
+    "mfr": "NAT4018",
+    "status": "DRAFT",
+    "price": "32.85",
+    "done": false
+  },
+  {
+    "product_id": "7867562065971",
+    "title": "Cremona Parchment Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27560",
+    "mfr": "75085W-01",
+    "status": "DRAFT",
+    "price": "47.55",
+    "done": false
+  },
   {
     "product_id": "7822227341363",
     "title": "Cuneo Grasscloth Wallcovering | Fentucci",
@@ -269,6 +296,33 @@
     "price": "38.40",
     "done": false
   },
+  {
+    "product_id": "7867561443379",
+    "title": "Fentucci Grasscloth Wallcovering",
+    "dw_sku": "GRS-26870",
+    "mfr": "wl332t",
+    "status": "DRAFT",
+    "price": "38.85",
+    "done": false
+  },
+  {
+    "product_id": "7867561508915",
+    "title": "Fentucci Grasscloth Wallcovering",
+    "dw_sku": "GRS-27100",
+    "mfr": "md5056t",
+    "status": "DRAFT",
+    "price": "32.85",
+    "done": false
+  },
+  {
+    "product_id": "7867561574451",
+    "title": "Fentucci Grasscloth Wallcovering",
+    "dw_sku": "GRS-27110",
+    "mfr": "mc1717",
+    "status": "DRAFT",
+    "price": "37.65",
+    "done": false
+  },
   {
     "product_id": "7822227603507",
     "title": "Ferrara Black Wash Grasscloth Wallcovering | Fentucci",
@@ -512,6 +566,15 @@
     "price": "32.85",
     "done": false
   },
+  {
+    "product_id": "7867562098739",
+    "title": "Modena Gold Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27570",
+    "mfr": "C3529",
+    "status": "DRAFT",
+    "price": "36.45",
+    "done": false
+  },
   {
     "product_id": "7822228815923",
     "title": "Murano Grasscloth Wallcovering | Fentucci",
@@ -521,6 +584,33 @@
     "price": "45.30",
     "done": false
   },
+  {
+    "product_id": "7867562164275",
+    "title": "Napoli Caramel Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27600",
+    "mfr": "MG2020",
+    "status": "DRAFT",
+    "price": "53.85",
+    "done": false
+  },
+  {
+    "product_id": "7867561607219",
+    "title": "Natural Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27180",
+    "mfr": "sh1521T",
+    "status": "DRAFT",
+    "price": "38.55",
+    "done": false
+  },
+  {
+    "product_id": "7867562131507",
+    "title": "Oliva Khaki Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27580",
+    "mfr": "BA227",
+    "status": "DRAFT",
+    "price": "37.35",
+    "done": false
+  },
   {
     "product_id": "7822229995571",
     "title": "Oristano Grasscloth Wallcovering | Fentucci",
@@ -539,6 +629,15 @@
     "price": "36.00",
     "done": false
   },
+  {
+    "product_id": "7867561967667",
+    "title": "Palermo Wheat Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27530",
+    "mfr": "BA454",
+    "status": "DRAFT",
+    "price": "35.25",
+    "done": false
+  },
   {
     "product_id": "7822230192179",
     "title": "Parma Grasscloth Wallcovering | Fentucci",
@@ -719,6 +818,15 @@
     "price": "54.15",
     "done": false
   },
+  {
+    "product_id": "7867562000435",
+    "title": "Salerno Sand Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27540",
+    "mfr": "wS4723T",
+    "status": "DRAFT",
+    "price": "38.85",
+    "done": false
+  },
   {
     "product_id": "7822231273523",
     "title": "Sanremo Grasscloth Wallcovering | Fentucci",
@@ -818,6 +926,24 @@
     "price": "32.85",
     "done": false
   },
+  {
+    "product_id": "7867561934899",
+    "title": "Sorrento Natural Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27520",
+    "mfr": "sh1518T",
+    "status": "DRAFT",
+    "price": "38.55",
+    "done": false
+  },
+  {
+    "product_id": "7867562033203",
+    "title": "Spoleto Bone Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27550",
+    "mfr": "C4539T",
+    "status": "DRAFT",
+    "price": "35.70",
+    "done": false
+  },
   {
     "product_id": "7822231666739",
     "title": "Stresa Grasscloth Wallcovering | Fentucci",
@@ -935,6 +1061,15 @@
     "price": "33.90",
     "done": false
   },
+  {
+    "product_id": "7867561869363",
+    "title": "Verona Butter Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27480",
+    "mfr": "SH1519T",
+    "status": "DRAFT",
+    "price": "38.55",
+    "done": false
+  },
   {
     "product_id": "7822226063411",
     "title": "Vigevano Beige Blue On Lightr Ochre Grasscloth Wallcovering | Fentucci",
@@ -944,6 +1079,24 @@
     "price": "36.00",
     "done": false
   },
+  {
+    "product_id": "7867561738291",
+    "title": "Classic Denim Blue Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-30580",
+    "mfr": "zg15326",
+    "status": "DRAFT",
+    "price": "0.00",
+    "done": false
+  },
+  {
+    "product_id": "7867561836595",
+    "title": "Cream, Black, Gold Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-30740",
+    "mfr": "sg1069",
+    "status": "DRAFT",
+    "price": "0.00",
+    "done": false
+  },
   {
     "product_id": "7822232453171",
     "title": "Fentucci Abaca Grasscloth Wallcovering",
@@ -989,6 +1142,33 @@
     "price": "4.25",
     "done": false
   },
+  {
+    "product_id": "7867561476147",
+    "title": "Fentucci Grasscloth Wallcovering",
+    "dw_sku": "GRS-26990",
+    "mfr": "tsl7553",
+    "status": "DRAFT",
+    "price": "0.00",
+    "done": false
+  },
+  {
+    "product_id": "7867561672755",
+    "title": "Fentucci Grasscloth Wallcovering",
+    "dw_sku": "GRS-30500",
+    "mfr": "m62012",
+    "status": "DRAFT",
+    "price": "0.00",
+    "done": false
+  },
+  {
+    "product_id": "7867561705523",
+    "title": "Fentucci Grasscloth Wallcovering",
+    "dw_sku": "GRS-30520",
+    "mfr": "mnr2122",
+    "status": "DRAFT",
+    "price": "0.00",
+    "done": false
+  },
   {
     "product_id": "7822233010227",
     "title": "Fentucci Herringbone Grasscloth Wallcovering",
@@ -1151,6 +1331,24 @@
     "price": null,
     "done": false
   },
+  {
+    "product_id": "7867561803827",
+    "title": "Olivia Green Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-30610",
+    "mfr": "SG5059T",
+    "status": "DRAFT",
+    "price": "0.00",
+    "done": false
+  },
+  {
+    "product_id": "7867561639987",
+    "title": "Teal Green Blue Grasscloth Wallcovering | Fentucci",
+    "dw_sku": "GRS-27290",
+    "mfr": "mnr1213",
+    "status": "DRAFT",
+    "price": "0.00",
+    "done": false
+  },
   {
     "product_id": "7822232715315",
     "title": "Trieste Grasscloth Wallcovering | Fentucci",
diff --git a/public/index.html b/public/index.html
index 0c61c6b..6a7cc7a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -54,10 +54,28 @@
   .shoot input{position:absolute;inset:0;opacity:0;font-size:200px;cursor:pointer}
   .skip{background:#16140f;border:1px solid var(--line);color:var(--muted);border-radius:12px;padding:13px 14px;font-size:14px;cursor:pointer}
   .enh{background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:12px;padding:13px 14px;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap}
+  .gal{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:13px 12px;font-size:14px;font-weight:600;cursor:pointer;white-space:nowrap}
   .card.done .shoot{background:#16140f;color:var(--gold);border:1px solid var(--gold)}
   .empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px 10px}
   .toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#16140f;border:1px solid var(--gold);color:var(--ink);padding:11px 18px;border-radius:12px;font-size:14px;z-index:50;opacity:0;transition:opacity .2s;pointer-events:none}
   .toast.show{opacity:1}
+  /* photo gallery */
+  .gallery{position:fixed;inset:0;z-index:90;background:rgba(10,9,7,.98);display:flex;flex-direction:column}
+  .gallery[hidden]{display:none}
+  .gal-head{position:sticky;top:0;display:flex;align-items:center;gap:10px;padding:max(12px,env(safe-area-inset-top)) 14px 12px;border-bottom:1px solid var(--line);background:rgba(10,9,7,.98)}
+  .gal-x{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:9px 13px;font-size:14px;cursor:pointer}
+  .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-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}
+  .gtile.feat{border-color:var(--gold);box-shadow:0 0 0 2px rgba(200,162,74,.4)}
+  .gfeat-badge{position:absolute;top:6px;left:6px;background:var(--gold);color:#1b1407;font:700 10px/1 sans-serif;padding:3px 7px;border-radius:99px}
+  .gtile-acts{position:absolute;bottom:0;left:0;right:0;display:flex;gap:1px}
+  .gtile-acts button{flex:1;border:none;padding:9px 4px;font-size:12px;font-weight:600;cursor:pointer;background:rgba(15,14,12,.92);color:var(--ink)}
+  .gtile-acts .g-feat{color:var(--gold)} .gtile-acts .g-del{color:var(--red)}
+  .gal-empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px 10px}
 
   /* ===== Photo editor (opens after capture, before upload) ===== */
   .editor{position:fixed;inset:0;z-index:100;background:rgba(8,7,6,.97);display:flex;flex-direction:column}
@@ -118,6 +136,16 @@
 <main id="grid"></main>
 <div class="toast" id="toast"></div>
 
+<!-- ===== Photo gallery (all photos for a SKU: keep / remove / feature / add) ===== -->
+<div class="gallery" id="gallery" hidden>
+  <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">πŸ“· Add<input type="file" accept="image/*" capture="environment" id="galAdd"></label>
+  </div>
+  <div class="gal-grid" id="galGrid"></div>
+</div>
+
 <!-- ===== Photo editor modal (review before upload) ===== -->
 <div class="editor" id="editor" hidden>
   <div class="ed-stage"><canvas id="edcanvas"></canvas></div>
@@ -261,12 +289,14 @@ function render(){
         ${(x.keep_images&&x.image)?`<label class="onlyimg${x._replaceAll?' armed':''}"><input type="checkbox" class="onlyimg-cb"${x._replaceAll?' checked':''}> Replace all β€” make this the only image</label>`:''}
         <div class="actions">
           ${adjustable?'<button class="enh">✨ Enhance</button>':''}
+          ${x.product_id?'<button class="gal">πŸ–Ό Photos</button>':''}
           <label class="shoot">${isNew?'πŸ“· Create + Go Live':photographed?'πŸ“· Update Photo':'πŸ“· Add Photo'}<input type="file" accept="image/*" capture="environment"></label>
           ${(x.done||x.live||x.image||isNew)?'':'<button class="skip">Skip</button>'}
         </div>
       </div>`;
     const editSrc=()=> (x.photo||('/api/current-image?pid='+x.product_id))+(x.photo?'?':'&')+'t='+Date.now();
     el.querySelector('.shoot input').addEventListener('change',e=>shoot(x,e.target.files[0]));
+    const gal=el.querySelector('.gal'); if(gal) gal.addEventListener('click',()=>openGallery(x));
     const oi=el.querySelector('.onlyimg-cb'); if(oi) oi.addEventListener('change',e=>{ x._replaceAll=e.target.checked; el.querySelector('.onlyimg').classList.toggle('armed',e.target.checked); });
     const enh=el.querySelector('.enh'); if(enh) enh.addEventListener('click',()=>openEditor(x,editSrc()));
     const th=el.querySelector('.thumb.tap'); if(th) th.addEventListener('click',()=>openEditor(x,editSrc()));
@@ -292,6 +322,48 @@ async function shoot(x,file){
   openEditor(x,dataUrl);
 }
 
+/* ===== Photo gallery: see every photo on a SKU, keep / remove / feature / add ===== */
+let GAL_X=null;
+async function openGallery(x){
+  if(!x.product_id){ toast('Create the product first (take a photo)'); return; }
+  GAL_X=x;
+  $('#galName').textContent=(x.title||'').replace(/ \| Fentucci$/,'');
+  $('#galSku').textContent=x.dw_sku||'';
+  $('#gallery').hidden=false; document.body.style.overflow='hidden';
+  await renderGallery();
+}
+function closeGallery(){ $('#gallery').hidden=true; document.body.style.overflow=''; GAL_X=null; if(filter!=='any'&&filter!=='shop'&&filter!=='new') load(); }
+async function renderGallery(){
+  const g=$('#galGrid'); g.innerHTML='<div class="gal-empty">Loading…</div>';
+  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; }
+    g.innerHTML='';
+    imgs.forEach((im,i)=>{
+      const feat=im.position===1;
+      const t=document.createElement('div'); t.className='gtile'+(feat?' feat':'');
+      t.innerHTML=`<img src="${im.src}" loading="lazy">${feat?'<span class="gfeat-badge">β˜… FEATURED</span>':''}
+        <div class="gtile-acts">${feat?'':'<button class="g-feat">β˜… Feature</button>'}<button class="g-del">βœ• Remove</button></div>`;
+      const f=t.querySelector('.g-feat'); if(f) f.addEventListener('click',()=>imgAction('feature',im.id));
+      t.querySelector('.g-del').addEventListener('click',()=>{ if(confirm('Remove this photo?')) imgAction('delete',im.id); });
+      g.appendChild(t);
+    });
+  }catch(e){ g.innerHTML='<div class="gal-empty">Couldn\'t load photos.</div>'; }
+}
+async function imgAction(act,image_id){
+  toast(act==='delete'?'Removing…':'Setting featured…');
+  try{ const r=await fetch('/api/image/'+act,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pid:GAL_X.product_id,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);
+$('#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};
+  $('#gallery').hidden=true; document.body.style.overflow='';
+  shoot(gx,f);
+});
+
 // Send the (possibly edited) photo to the server β†’ local save + Shopify push + activation gate.
 async function uploadPhoto(x,dataUrl){
   toast((x.needs_create?'Creating ':'Uploading ')+x.dw_sku+'…');
@@ -301,6 +373,7 @@ async function uploadPhoto(x,dataUrl){
     const d=await r.json();
     if(d.ok){ Object.assign(x,{done:true,needs_create:false,created:d.created,photo:d.photo,ts:new Date().toISOString(),shopify_pushed:d.shopify_pushed,push_err:d.push_err,live:d.live,live_reason:d.live_reason});
       toast(d.created&&d.live?('🟒 Created + LIVE Β· '+x.dw_sku):d.created?('Created (draft) Β· '+x.dw_sku+(d.live_reason?' β€” '+d.live_reason:'')):d.live?('🟒 '+x.dw_sku+' is LIVE!'):d.shopify_pushed?('βœ“ Photo on Shopify Β· '+x.dw_sku+(d.live_reason?(' (needs '+d.live_reason.replace('needs ','')+')'):'')):('Saved '+x.dw_sku+(d.push_err?' (push failed)':''))); render();
+      if(x._fromGallery&&x.product_id) setTimeout(()=>openGallery(x),400); // back to the gallery to see the added photo
     } else toast('Error: '+(d.err||'failed'));
   }catch(e){toast('Upload failed β€” check connection');}
 }
diff --git a/server.js b/server.js
index 7596f47..fecedd9 100644
--- a/server.js
+++ b/server.js
@@ -210,6 +210,32 @@ const server = http.createServer((req, res) => {
     return send(res, 404, { err: 'not found' });
   }
 
+  // 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');
+    if (!pid) return send(res, 400, { err: 'pid required' });
+    (async () => {
+      const r = await shopifyReq('GET', `/products/${pid}/images.json`);
+      const imgs = ((r.body && r.body.images) || []).map(i => ({ id: i.id, src: i.src, position: i.position }));
+      send(res, 200, { images: imgs });
+    })();
+    return;
+  }
+  if ((u.pathname === '/api/image/delete' || u.pathname === '/api/image/feature') && req.method === 'POST') {
+    let body = ''; req.on('data', c => body += c);
+    req.on('end', async () => {
+      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
+      if (!p.pid || !p.image_id) return send(res, 400, { err: 'pid + image_id required' });
+      if (u.pathname === '/api/image/delete') {
+        const r = await shopifyReq('DELETE', `/products/${p.pid}/images/${p.image_id}.json`);
+        return send(res, 200, { ok: r.status >= 200 && r.status < 300 });
+      }
+      const r = await shopifyReq('PUT', `/products/${p.pid}/images/${p.image_id}.json`, { image: { id: p.image_id, position: 1 } });
+      return send(res, 200, { ok: r.status >= 200 && r.status < 300 });
+    });
+    return;
+  }
+
   // Same-origin proxy for a product's CURRENT Shopify image β€” lets the editor
   // load + re-adjust a done item that has no local photo (e.g. shot on another
   // device, or a pre-existing image), without a cross-origin canvas taint.

← 62e3b37 auto-save: 2026-06-24T18:28:52 (1 files) β€” data/queue.json  Β·  back to Dw Photo Capture  Β·  Add collections to search: each product carries its Shopify 418e972 β†’