← back to Dw Photo Capture
auto-save: 2026-06-24T16:58:22 (3 files) — public/index.html server.js data/build.json
daf466a6b6cda1d3427da5b4980540e35639bbb9 · 2026-06-24 16:58:24 -0700 · Steve Abrams
Files touched
A data/build.jsonM public/index.htmlM server.js
Diff
commit daf466a6b6cda1d3427da5b4980540e35639bbb9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jun 24 16:58:24 2026 -0700
auto-save: 2026-06-24T16:58:22 (3 files) — public/index.html server.js data/build.json
---
data/build.json | 8 ++++++
public/index.html | 80 ++++++++++++++++++++++++++++++++++++++++---------------
server.js | 35 +++++++++++++++++++++++-
3 files changed, 101 insertions(+), 22 deletions(-)
diff --git a/data/build.json b/data/build.json
new file mode 100644
index 0000000..85c4d36
--- /dev/null
+++ b/data/build.json
@@ -0,0 +1,8 @@
+{
+ "next": 5,
+ "map": {
+ "578af86f": 2,
+ "bc95fdb0": 3,
+ "3a3b2a43": 4
+ }
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index ea73eb6..c600f7a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -14,6 +14,7 @@
header{position:sticky;top:0;z-index:10;background:rgba(15,14,12,.96);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:max(10px,env(safe-area-inset-top)) 14px 10px}
h1{font:600 17px/1.1 "SF Pro Display",sans-serif;letter-spacing:.04em;margin:0 0 8px;text-transform:uppercase}
h1 span{color:var(--gold)}
+ h1 .ver{font:600 10px/1 ui-monospace,Menlo,monospace;color:#1b1407;background:var(--green);border-radius:99px;padding:3px 7px;letter-spacing:.04em;vertical-align:middle;text-transform:none;font-style:normal;margin-left:6px}
.bar{height:8px;border-radius:99px;background:#2a261f;overflow:hidden;margin:6px 0}
.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),#e7c878);width:0;transition:width .3s}
.meta{display:flex;justify-content:space-between;font-size:13px;color:var(--muted)}
@@ -29,6 +30,7 @@
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden;display:flex;flex-direction:column}
.card.done{border-color:var(--green)}
.thumb{aspect-ratio:4/3;background:#0c0b09 center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:#4a443b;font-size:34px;position:relative}
+ .thumb.tap{cursor:pointer} .thumb.tap:hover{filter:brightness(1.08)}
.badge{position:absolute;top:8px;right:8px;font-size:11px;padding:3px 8px;border-radius:99px;font-weight:700;letter-spacing:.03em}
.b-need{background:#3a2a14;color:#e7c878} .b-done{background:var(--green);color:#fff} .b-err{background:var(--red);color:#fff}
.b-live{background:#1f7a48;color:#fff;box-shadow:0 0 0 2px rgba(63,160,106,.4)}
@@ -46,6 +48,7 @@
.shoot{flex:1;position:relative;overflow:hidden;background:var(--gold);color:#1b1407;border:none;border-radius:12px;padding:13px;font-weight:700;font-size:15px;text-align:center;cursor:pointer}
.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}
.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}
@@ -83,7 +86,7 @@
</head>
<body>
<header>
- <h1>Mobile DW SKU <span>Photos</span></h1>
+ <h1>Mobile DW SKU <span>Photos</span> <em class="ver">__VER__</em></h1>
<div class="bar"><i id="prog"></i></div>
<div class="meta"><span id="count">…</span><span id="remain"></span></div>
<div class="controls">
@@ -174,8 +177,9 @@ function render(){
const el=document.createElement('div'); el.className='card'+(x.done?' done':'');
const badge=x.push_err?'<span class="badge b-err">PUSH ERR</span>':x.live?'<span class="badge b-live">● LIVE</span>':x.done?'<span class="badge b-done">✓ PHOTO</span>':'<span class="badge b-need">NEEDS</span>';
const thumb=x.photo?`style="background-image:url('${x.photo}')"`:'';
+ const adjustable=x.done; // any done item can be re-opened in the editor (local photo or live Shopify image)
el.innerHTML=`
- <div class="thumb" ${thumb}>${x.photo?'':'▦'}${badge}</div>
+ <div class="thumb ${adjustable?'tap':''}" ${thumb} title="${adjustable?'click to adjust':''}">${x.photo?'':'▦'}${badge}</div>
<div class="body">
<div class="ttl">${(x.title||'').replace(/ \\| Fentucci$/,'')}</div>
<div class="model" title="tap to copy model #" onclick="navigator.clipboard&&navigator.clipboard.writeText('${x.mfr||''}')"><small>Model #</small><b>${x.mfr||'—'}</b></div>
@@ -187,11 +191,15 @@ function render(){
${x.ts?`<div class="when">🕓 ${fmt(x.ts)}${x.live?' · 🟢 LIVE on store':x.shopify_pushed?(x.live_reason?(' · photo on Shopify ('+x.live_reason+')'):' · photo on Shopify'):''}</div>`:''}
${x.push_err?`<div class="when" style="color:var(--red)">${x.push_err}</div>`:''}
<div class="actions">
+ ${adjustable?'<button class="enh">✨ Enhance</button>':''}
<label class="shoot">${x.done?'📷 Retake':'📷 Open Camera'}<input type="file" accept="image/*" capture="environment"></label>
${x.done?'':'<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 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()));
const sk=el.querySelector('.skip'); if(sk) sk.addEventListener('click',()=>skip(x));
grid.appendChild(el);
}
@@ -258,27 +266,54 @@ function syncLabels(){
function signPct(v){const p=Math.round((+v-1)*100); return (p>0?'+':'')+p;}
function scheduleRedraw(){ if(edRAF)return; edRAF=requestAnimationFrame(()=>{edRAF=0;edRedraw();}); }
+const _cl=v=>v<0?0:v>255?255:v;
+function _hsl2rgb(h,s,l){ // h 0..1
+ const q=l<.5?l*(1+s):l+s-l*s, p=2*l-q;
+ const f=t=>{t=(t+1)%1; return t<1/6?p+(q-p)*6*t:t<1/2?q:t<2/3?p+(q-p)*(2/3-t)*6:p;};
+ return [f(h+1/3)*255,f(h)*255,f(h-1/3)*255];
+}
+function curFilter(){ return `brightness(${+$('#edB').value}) contrast(${+$('#edC').value}) saturate(${+$('#edS').value}) hue-rotate(${+$('#edH').value}deg)`; }
+// Bake the slider adjustments into real pixels (reliable on every device — no ctx.filter).
+function bakeAdjust(src,w,h){
+ const c=document.createElement('canvas'); c.width=w; c.height=h;
+ const ctx=c.getContext('2d'); ctx.drawImage(src,0,0,w,h);
+ const id=ctx.getImageData(0,0,w,h), d=id.data;
+ const b=+$('#edB').value, con=+$('#edC').value, sat=+$('#edS').value, hue=(+$('#edH').value)*Math.PI/180;
+ const cos=Math.cos(hue), sn=Math.sin(hue);
+ const m=[0.213+cos*0.787-sn*0.213,0.715-cos*0.715-sn*0.715,0.072-cos*0.072+sn*0.928,
+ 0.213-cos*0.213+sn*0.143,0.715+cos*0.285+sn*0.140,0.072-cos*0.072-sn*0.283,
+ 0.213-cos*0.213-sn*0.787,0.715-cos*0.715+sn*0.715,0.072+cos*0.928+sn*0.072];
+ const castOn=ES&&ES.ts>0.04; let cr=0,cg=0,cb=0,ca=0;
+ if(castOn){ const t=_hsl2rgb((ES.th||0)/360,1,.5); cr=t[0];cg=t[1];cb=t[2]; ca=ES.ts*0.6; }
+ for(let i=0;i<d.length;i+=4){
+ let r=d[i],g=d[i+1],bl=d[i+2];
+ r*=b;g*=b;bl*=b; // brightness
+ r=(r-128)*con+128;g=(g-128)*con+128;bl=(bl-128)*con+128; // contrast
+ const L=0.2126*r+0.7152*g+0.0722*bl; // saturation
+ r=L+(r-L)*sat;g=L+(g-L)*sat;bl=L+(bl-L)*sat;
+ const nr=r*m[0]+g*m[1]+bl*m[2],ng=r*m[3]+g*m[4]+bl*m[5],nb=r*m[6]+g*m[7]+bl*m[8]; // hue
+ r=nr;g=ng;bl=nb;
+ if(castOn){ r=r*(1-ca)+cr*ca;g=g*(1-ca)+cg*ca;bl=bl*(1-ca)+cb*ca; } // color cast
+ d[i]=_cl(r);d[i+1]=_cl(g);d[i+2]=_cl(bl);
+ }
+ ctx.putImageData(id,0,0); return c;
+}
function edRedraw(){
if(!ES)return;
- // 1) render the enhanced FLAT image to the offscreen canvas
- const fc=ES.flat, fx=fc.getContext('2d');
ES.b=+$('#edB').value; ES.c=+$('#edC').value; ES.s=+$('#edS').value; ES.h=+$('#edH').value;
- fx.clearRect(0,0,fc.width,fc.height);
- fx.filter=`brightness(${ES.b}) contrast(${ES.c}) saturate(${ES.s}) hue-rotate(${ES.h}deg)`;
- fx.drawImage(ES.img,0,0);
- fx.filter='none';
- if(ES.ts>0.04){ // color-balance tint (Photoshop-style soft-light cast)
- fx.globalCompositeOperation='soft-light';
- fx.globalAlpha=ES.ts*0.6; fx.fillStyle=`hsl(${ES.th},100%,50%)`;
- fx.fillRect(0,0,fc.width,fc.height);
- fx.globalAlpha=1; fx.globalCompositeOperation='source-over';
- }
- // 2) blit to the visible canvas — flat, or wrapped around a tube
const cv=$('#edcanvas'), ctx=cv.getContext('2d');
- ctx.clearRect(0,0,cv.width,cv.height);
- if(ES.rollMode==='tube') renderRoll(fc, ctx, cv.width, cv.height);
- else if(ES.rollMode==='sheet') renderSheet(fc, ctx, cv.width, cv.height);
- else ctx.drawImage(fc,0,0);
+ if(ES.rollMode==='tube'||ES.rollMode==='sheet'){
+ cv.style.filter='none';
+ const fc=bakeAdjust(ES.img,ES.img.width,ES.img.height);
+ ctx.clearRect(0,0,cv.width,cv.height);
+ (ES.rollMode==='tube'?renderRoll:renderSheet)(fc,ctx,cv.width,cv.height);
+ } else {
+ // FLAT: live preview via CSS filter (instant + iOS-reliable); cast painted in pixels
+ cv.style.filter=curFilter();
+ ctx.clearRect(0,0,cv.width,cv.height);
+ ctx.drawImage(ES.img,0,0);
+ if(ES.ts>0.04){ ctx.save(); ctx.globalCompositeOperation='soft-light'; ctx.globalAlpha=ES.ts*0.6; ctx.fillStyle=`hsl(${ES.th},100%,50%)`; ctx.fillRect(0,0,cv.width,cv.height); ctx.restore(); }
+ }
}
// Wrap the flat texture around a vertical cylinder ("roll around a tube,
@@ -397,8 +432,11 @@ $('#edReset').addEventListener('click',()=>{
});
$('#edCancel').addEventListener('click',()=>{toast('Edit cancelled');closeEditor();});
$('#edUse').addEventListener('click',()=>{
- if(!ES)return; const x=ES.x; edRedraw();
- const dataUrl=$('#edcanvas').toDataURL('image/jpeg',0.9);
+ if(!ES)return; const x=ES.x;
+ // BAKE the adjustment into pixels (CSS preview filter does NOT survive toDataURL).
+ let dataUrl;
+ if(ES.rollMode==='tube'||ES.rollMode==='sheet'){ edRedraw(); dataUrl=$('#edcanvas').toDataURL('image/jpeg',0.9); }
+ else { dataUrl=bakeAdjust(ES.img,ES.img.width,ES.img.height).toDataURL('image/jpeg',0.9); }
closeEditor(); uploadPhoto(x,dataUrl);
});
async function skip(x){await fetch('/api/skip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dw_sku:x.dw_sku,skipped:true})});x.skipped=true;render();}
diff --git a/server.js b/server.js
index 4c90fde..fe78c37 100644
--- a/server.js
+++ b/server.js
@@ -37,6 +37,19 @@ fs.mkdirSync(PHOTOS, { recursive: true });
const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { return d; } };
const saveJSON = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2));
+// Auto-version: derive a sequential build number from the actual file contents.
+// Any change to index.html or server.js → new hash → next build number. Never manual.
+const crypto = require('crypto');
+function buildLabel() {
+ let blob = '';
+ try { blob += fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8'); } catch (e) {}
+ try { blob += fs.readFileSync(path.join(ROOT, 'server.js'), 'utf8'); } catch (e) {}
+ const hash = crypto.createHash('md5').update(blob).digest('hex').slice(0, 8);
+ const reg = loadJSON(path.join(DATA, 'build.json'), { next: 2, map: {} });
+ if (!reg.map[hash]) { reg.map[hash] = reg.next++; saveJSON(path.join(DATA, 'build.json'), reg); }
+ return `v${reg.map[hash]} · editor`;
+}
+
// progress.json: { "<dw_sku>": { done, skipped, photo, ts, shopify_pushed, push_err } }
let progress = loadJSON(PROGRESS_FILE, {});
const saveProgress = () => saveJSON(PROGRESS_FILE, progress);
@@ -141,8 +154,9 @@ const server = http.createServer((req, res) => {
if (u.pathname === '/' || u.pathname === '/index.html') {
// no-store: the UI evolves fast and is read fresh per request — never let a
// browser serve a stale cached page (that hid the photo editor after a deploy).
+ let html = fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8').replace(/__VER__/g, buildLabel());
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, must-revalidate' }),
- res.end(fs.readFileSync(path.join(ROOT, 'public/index.html')));
+ res.end(html);
}
if (u.pathname === '/api/queue' && req.method === 'GET') {
@@ -156,6 +170,25 @@ const server = http.createServer((req, res) => {
return send(res, 404, { err: 'not found' });
}
+ // 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.
+ if (u.pathname === '/api/current-image' && 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) || [];
+ const src = imgs.length ? imgs[imgs.length - 1].src : null; // featured/last
+ if (!src) return send(res, 404, { err: 'no image' });
+ https.get(src, (ir) => {
+ res.writeHead(200, { 'Content-Type': ir.headers['content-type'] || 'image/jpeg', 'Cache-Control': 'no-store' });
+ ir.pipe(res);
+ }).on('error', e => send(res, 502, { err: e.message }));
+ })();
+ return;
+ }
+
if (u.pathname === '/api/photo' && req.method === 'POST') {
let body = '';
req.on('data', c => { body += c; if (body.length > 25 * 1024 * 1024) req.destroy(); });
← 571e941 auto-save: 2026-06-24T16:28:13 (4 files) — data/queue.json r
·
back to Dw Photo Capture
·
Fix grid density slider + editor preview-equals-export bake 89c5686 →