← back to Dw Photo Capture
Add Photoshop-style photo editor (brightness/contrast/saturation/hue + color-balance wheel) with review-before-upload step
dcdf1c353e7888ed6d138b404f38c5a9ab84ff71 · 2026-06-24 15:56:21 -0700 · Steve Abrams
Files touched
Diff
commit dcdf1c353e7888ed6d138b404f38c5a9ab84ff71
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jun 24 15:56:21 2026 -0700
Add Photoshop-style photo editor (brightness/contrast/saturation/hue + color-balance wheel) with review-before-upload step
---
public/index.html | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 159 insertions(+), 7 deletions(-)
diff --git a/public/index.html b/public/index.html
index c55dc4b..0447db3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -33,8 +33,11 @@
.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)}
.body{padding:11px 12px 13px;display:flex;flex-direction:column;gap:6px;flex:1}
- .ttl{font:600 14px/1.25 "SF Pro Display",sans-serif}
- .sku{font:700 15px/1 ui-monospace,Menlo,monospace;color:var(--gold);letter-spacing:.02em}
+ .ttl{font:600 15px/1.25 "SF Pro Display",sans-serif}
+ .model{display:inline-flex;align-items:center;gap:6px;background:#22201a;border:1px solid var(--line);border-radius:8px;padding:5px 9px;align-self:flex-start;cursor:pointer}
+ .model small{font-size:9.5px;letter-spacing:.12em;color:var(--muted);text-transform:uppercase}
+ .model b{font:800 16px/1 ui-monospace,Menlo,monospace;color:var(--ink);letter-spacing:.03em}
+ .sku{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--gold);letter-spacing:.02em}
.sub{font-size:11.5px;color:var(--muted);display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.price{color:var(--green);font-weight:700}
.price.no{color:var(--muted)}
@@ -47,6 +50,33 @@
.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 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}
+ .editor[hidden]{display:none}
+ .ed-stage{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:12px 12px 0;overflow:hidden}
+ #edcanvas{max-width:100%;max-height:100%;border-radius:12px;box-shadow:0 8px 40px rgba(0,0,0,.6);background:#000}
+ .ed-panel{flex:0 0 auto;background:var(--card);border-top:1px solid var(--line);padding:12px 14px max(14px,env(safe-area-inset-bottom));max-height:54vh;overflow-y:auto}
+ .ed-head{display:flex;align-items:center;gap:10px;margin-bottom:10px}
+ .ed-head .t{font:600 13px/1 "SF Pro Display",sans-serif;letter-spacing:.05em;text-transform:uppercase;color:var(--gold)}
+ .ed-head .s{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--muted)}
+ .ed-grid{display:grid;grid-template-columns:auto 1fr;gap:14px;align-items:start}
+ .ed-wheel-wrap{display:flex;flex-direction:column;align-items:center;gap:6px}
+ .wheel-box{position:relative;width:128px;height:128px;touch-action:none}
+ #wheel{width:128px;height:128px;border-radius:50%;cursor:crosshair;box-shadow:inset 0 0 0 1px var(--line)}
+ .wheel-thumb{position:absolute;width:16px;height:16px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.5);transform:translate(-50%,-50%);left:50%;top:50%;pointer-events:none}
+ .wheel-lbl{font-size:10.5px;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;text-align:center}
+ .wheel-lbl b{display:block;color:var(--ink);font-size:11px;letter-spacing:.02em;text-transform:none;margin-top:2px}
+ .sliders{display:flex;flex-direction:column;gap:9px}
+ .sl{display:grid;grid-template-columns:74px 1fr 44px;align-items:center;gap:10px}
+ .sl label{font-size:12px;color:var(--muted)}
+ .sl input[type=range]{width:100%;accent-color:var(--gold)}
+ .sl .v{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--ink);text-align:right}
+ .ed-actions{display:flex;gap:10px;margin-top:14px}
+ .ed-actions button{flex:1;border:none;border-radius:12px;padding:14px;font-weight:700;font-size:15px;cursor:pointer}
+ .ed-reset{background:#16140f;border:1px solid var(--line);color:var(--muted)}
+ .ed-cancel{background:#16140f;border:1px solid var(--red);color:var(--red)}
+ .ed-use{background:var(--gold);color:#1b1407;flex:1.6}
</style>
</head>
<body>
@@ -55,7 +85,7 @@
<div class="bar"><i id="prog"></i></div>
<div class="meta"><span id="count">…</span><span id="remain"></span></div>
<div class="controls">
- <input type="search" id="q" placeholder="Search pattern or SKU…">
+ <input type="search" id="q" placeholder="🔎 Search name or model #…" autocapitalize="characters">
<select id="sort">
<option value="ready">Priced first</option>
<option value="title">Title A→Z</option>
@@ -74,6 +104,34 @@
</header>
<main id="grid"></main>
<div class="toast" id="toast"></div>
+
+<!-- ===== Photo editor modal (review before upload) ===== -->
+<div class="editor" id="editor" hidden>
+ <div class="ed-stage"><canvas id="edcanvas"></canvas></div>
+ <div class="ed-panel">
+ <div class="ed-head"><span class="t">Enhance</span><span class="s" id="ed-sku"></span></div>
+ <div class="ed-grid">
+ <div class="ed-wheel-wrap">
+ <div class="wheel-box" id="wheelBox">
+ <canvas id="wheel" width="128" height="128"></canvas>
+ <div class="wheel-thumb" id="wheelThumb"></div>
+ </div>
+ <div class="wheel-lbl">Color Balance<b id="wheelVal">neutral</b></div>
+ </div>
+ <div class="sliders">
+ <div class="sl"><label>Brightness</label><input type="range" id="edB" min="0.4" max="1.6" step="0.01" value="1"><span class="v" id="edBv">0</span></div>
+ <div class="sl"><label>Contrast</label><input type="range" id="edC" min="0.5" max="1.6" step="0.01" value="1"><span class="v" id="edCv">0</span></div>
+ <div class="sl"><label>Saturation</label><input type="range" id="edS" min="0" max="2.2" step="0.01" value="1"><span class="v" id="edSv">0</span></div>
+ <div class="sl"><label>Hue</label><input type="range" id="edH" min="-180" max="180" step="1" value="0"><span class="v" id="edHv">0°</span></div>
+ </div>
+ </div>
+ <div class="ed-actions">
+ <button class="ed-reset" id="edReset">Reset</button>
+ <button class="ed-cancel" id="edCancel">Cancel</button>
+ <button class="ed-use" id="edUse">✓ Use Photo</button>
+ </div>
+ </div>
+</div>
<script>
const $=s=>document.querySelector(s), grid=$('#grid');
let ITEMS=[], filter='need';
@@ -117,9 +175,9 @@ function render(){
<div class="thumb" ${thumb}>${x.photo?'':'▦'}${badge}</div>
<div class="body">
<div class="ttl">${(x.title||'').replace(/ \\| Fentucci$/,'')}</div>
- <div class="sku">${x.dw_sku||'—'}</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>
<div class="sub">
- <span>mfr: ${x.mfr||'—'}</span>
+ <span class="sku">${x.dw_sku||'—'}</span>
<span class="price ${priced(x)?'':'no'}">${priced(x)?'$'+x.price:'no price'}</span>
<span>${x.status}</span>
</div>
@@ -145,9 +203,17 @@ function downscale(file,max=2000){return new Promise((res)=>{
};
img.src=url;
});}
+// Capture → open the editor (review + enhance) instead of uploading immediately.
async function shoot(x,file){
- if(!file)return; toast('Uploading '+x.dw_sku+'…');
- const dataUrl=await downscale(file);
+ if(!file)return;
+ toast('Loading editor…');
+ const dataUrl=await downscale(file); // honors never-upscale (shrink only)
+ openEditor(x,dataUrl);
+}
+
+// Send the (possibly edited) photo to the server → local save + Shopify push + activation gate.
+async function uploadPhoto(x,dataUrl){
+ toast('Uploading '+x.dw_sku+'…');
try{
const r=await fetch('/api/photo',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({dw_sku:x.dw_sku,product_id:x.product_id,dataUrl})});
@@ -157,6 +223,92 @@ async function shoot(x,file){
} else toast('Error: '+(d.err||'failed'));
}catch(e){toast('Upload failed — check connection');}
}
+
+/* ===================== Photo editor ===================== */
+let ES=null, edRAF=0;
+const WHEEL_R=64; // half of 128
+function openEditor(x,dataUrl){
+ const img=new Image();
+ img.onload=()=>{
+ ES={x,img,b:1,c:1,s:1,h:0,th:0,ts:0};
+ const cv=$('#edcanvas'); cv.width=img.width; cv.height=img.height;
+ $('#ed-sku').textContent=x.dw_sku||'';
+ // reset controls
+ $('#edB').value=1; $('#edC').value=1; $('#edS').value=1; $('#edH').value=0;
+ setThumb(0,0); $('#wheelVal').textContent='neutral';
+ syncLabels(); edRedraw();
+ $('#editor').hidden=false; document.body.style.overflow='hidden';
+ };
+ img.src=dataUrl;
+}
+function closeEditor(){ $('#editor').hidden=true; document.body.style.overflow=''; ES=null; }
+
+function syncLabels(){
+ $('#edBv').textContent=signPct($('#edB').value);
+ $('#edCv').textContent=signPct($('#edC').value);
+ $('#edSv').textContent=signPct($('#edS').value);
+ $('#edHv').textContent=Math.round(+$('#edH').value)+'°';
+}
+function signPct(v){const p=Math.round((+v-1)*100); return (p>0?'+':'')+p;}
+
+function scheduleRedraw(){ if(edRAF)return; edRAF=requestAnimationFrame(()=>{edRAF=0;edRedraw();}); }
+function edRedraw(){
+ if(!ES)return; const cv=$('#edcanvas'), ctx=cv.getContext('2d');
+ ES.b=+$('#edB').value; ES.c=+$('#edC').value; ES.s=+$('#edS').value; ES.h=+$('#edH').value;
+ ctx.clearRect(0,0,cv.width,cv.height);
+ ctx.filter=`brightness(${ES.b}) contrast(${ES.c}) saturate(${ES.s}) hue-rotate(${ES.h}deg)`;
+ ctx.drawImage(ES.img,0,0);
+ ctx.filter='none';
+ if(ES.ts>0.04){ // color-balance tint (Photoshop-style soft-light cast)
+ 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.globalAlpha=1; ctx.globalCompositeOperation='source-over';
+ }
+}
+
+// paint the color wheel once (white center → full hue at rim)
+(function paintWheel(){
+ const w=$('#wheel'), ctx=w.getContext('2d'), R=w.width/2;
+ for(let a=0;a<360;a++){
+ ctx.beginPath(); ctx.moveTo(R,R);
+ ctx.arc(R,R,R,(a-1)*Math.PI/180,(a+1)*Math.PI/180); ctx.closePath();
+ const g=ctx.createRadialGradient(R,R,0,R,R,R);
+ g.addColorStop(0,'#fff'); g.addColorStop(1,`hsl(${a},100%,50%)`);
+ ctx.fillStyle=g; ctx.fill();
+ }
+})();
+function setThumb(dx,dy){ $('#wheelThumb').style.left=(WHEEL_R+dx)+'px'; $('#wheelThumb').style.top=(WHEEL_R+dy)+'px'; }
+function wheelPick(ev){
+ if(!ES)return; ev.preventDefault();
+ const w=$('#wheel'), r=w.getBoundingClientRect();
+ const t=ev.touches?ev.touches[0]:ev;
+ let dx=(t.clientX-r.left)*(w.width/r.width)-WHEEL_R;
+ let dy=(t.clientY-r.top)*(w.height/r.height)-WHEEL_R;
+ let d=Math.hypot(dx,dy);
+ if(d>WHEEL_R){dx*=WHEEL_R/d;dy*=WHEEL_R/d;d=WHEEL_R;}
+ let hue=Math.atan2(dy,dx)*180/Math.PI; if(hue<0)hue+=360;
+ ES.th=hue; ES.ts=d/WHEEL_R;
+ setThumb(dx,dy);
+ $('#wheelVal').textContent= ES.ts<0.04?'neutral':(Math.round(hue)+'° · '+Math.round(ES.ts*100)+'%');
+ scheduleRedraw();
+}
+const wb=$('#wheelBox');
+wb.addEventListener('pointerdown',e=>{wb.setPointerCapture(e.pointerId);wheelPick(e);});
+wb.addEventListener('pointermove',e=>{if(e.buttons||e.pressure)wheelPick(e);});
+
+['edB','edC','edS','edH'].forEach(id=>$('#'+id).addEventListener('input',()=>{syncLabels();scheduleRedraw();}));
+$('#edReset').addEventListener('click',()=>{
+ $('#edB').value=1;$('#edC').value=1;$('#edS').value=1;$('#edH').value=0;
+ if(ES){ES.th=0;ES.ts=0;} setThumb(0,0); $('#wheelVal').textContent='neutral'; syncLabels(); edRedraw();
+});
+$('#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);
+ 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();}
$('#q').addEventListener('input',render);
← ea8d5f1 auto-save: 2026-06-24T15:27:54 (2 files) — public/index.html
·
back to Dw Photo Capture
·
auto-save: 2026-06-24T15:58:04 (2 files) — data/queue.json s 9e8d499 →