← back to Dw Photo Capture
Desktop-controlled remote shutter (DTD-C): /desk control page + /cam phone live-camera page, paired via SSE+POST over the tailnet. Desktop SHOOT -> SSE shoot event -> phone captures native-res frame from getUserMedia -> uploads via existing /api/photo -> desktop previews. iOS-hardened (playsinline/muted, 3-tier facingMode, auto-reconnect). No new Shopify auto-write; attach stays operator-triggered.
f61b633ad7a87e760a464595e27f1f44958d2131 · 2026-06-25 08:38:04 -0700 · Steve
Files touched
A public/cam.htmlA public/desk.html
Diff
commit f61b633ad7a87e760a464595e27f1f44958d2131
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jun 25 08:38:04 2026 -0700
Desktop-controlled remote shutter (DTD-C): /desk control page + /cam phone live-camera page, paired via SSE+POST over the tailnet. Desktop SHOOT -> SSE shoot event -> phone captures native-res frame from getUserMedia -> uploads via existing /api/photo -> desktop previews. iOS-hardened (playsinline/muted, 3-tier facingMode, auto-reconnect). No new Shopify auto-write; attach stays operator-triggered.
---
public/cam.html | 212 +++++++++++++++++++++++++++++++++++++++++++++++++++++
public/desk.html | 218 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 430 insertions(+)
diff --git a/public/cam.html b/public/cam.html
new file mode 100644
index 0000000..1e0d77a
--- /dev/null
+++ b/public/cam.html
@@ -0,0 +1,212 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no">
+<meta name="apple-mobile-web-app-capable" content="yes">
+<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+<meta name="apple-mobile-web-app-title" content="DW Cam">
+<meta name="theme-color" content="#0f0e0c">
+<link rel="apple-touch-icon" href="/apple-touch-icon.png">
+<title>DW Remote Cam</title>
+<style>
+ :root{ --bg:#0f0e0c; --ink:#f3efe7; --muted:#9a9184; --line:#2e2a25; --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
+ *{box-sizing:border-box}
+ html,body{margin:0;height:100%;background:#000;color:var(--ink);font:15px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;-webkit-text-size-adjust:100%;overscroll-behavior:none}
+ #stage{position:fixed;inset:0;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden}
+ video{width:100%;height:100%;object-fit:contain;background:#000}
+ /* flash on capture */
+ #flash{position:fixed;inset:0;background:#fff;opacity:0;pointer-events:none;transition:opacity .12s;z-index:40}
+ #flash.on{opacity:.85;transition:none}
+ /* top status bar */
+ .bar{position:fixed;left:0;right:0;top:0;z-index:30;display:flex;align-items:center;gap:10px;
+ padding:max(10px,env(safe-area-inset-top)) 14px 10px;background:linear-gradient(#000c,transparent)}
+ .logo{font:700 14px/1 "SF Pro Display",sans-serif;letter-spacing:.06em;text-transform:uppercase}
+ .logo b{color:var(--gold)}
+ .ver{font:600 9px/1 ui-monospace,Menlo,monospace;background:#1b1407;color:var(--gold);border-radius:99px;padding:3px 6px}
+ .pill{margin-left:auto;display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:600;
+ background:#16140fcc;border:1px solid var(--line);border-radius:99px;padding:6px 11px;backdrop-filter:blur(6px)}
+ .dot{width:9px;height:9px;border-radius:50%;background:var(--muted);box-shadow:0 0 0 0 transparent}
+ .dot.ok{background:var(--green);box-shadow:0 0 10px var(--green)}
+ .dot.warn{background:var(--gold)}
+ .dot.err{background:var(--red)}
+ /* bottom info */
+ .foot{position:fixed;left:0;right:0;bottom:0;z-index:30;padding:14px 14px max(14px,env(safe-area-inset-bottom));
+ background:linear-gradient(transparent,#000d);display:flex;flex-direction:column;gap:8px;align-items:center}
+ .target{font-size:13px;color:var(--muted);text-align:center;min-height:18px}
+ .target b{color:var(--gold);font:600 13px/1 ui-monospace,Menlo,monospace}
+ .hint{font-size:12px;color:var(--muted);text-align:center;max-width:340px}
+ .btn{appearance:none;border:1px solid var(--gold);background:#16140f;color:var(--gold);border-radius:12px;padding:13px 22px;font-weight:700;font-size:15px;cursor:pointer}
+ .btn.primary{background:var(--gold);color:#1b1407;border-color:var(--gold)}
+ .overlay{position:fixed;inset:0;z-index:50;background:#0f0e0cf2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px;text-align:center}
+ .overlay[hidden]{display:none}
+ .overlay h2{font:700 19px/1.2 "SF Pro Display",sans-serif;margin:0;color:var(--gold)}
+ .overlay p{color:var(--muted);margin:0;max-width:340px}
+ .toast{position:fixed;left:50%;bottom:120px;transform:translateX(-50%);background:#16140f;border:1px solid var(--gold);color:var(--ink);padding:10px 16px;border-radius:12px;font-size:14px;z-index:60;opacity:0;transition:opacity .2s;pointer-events:none}
+ .toast.show{opacity:1}
+</style>
+</head>
+<body>
+ <div id="stage"><video id="v" playsinline autoplay muted></video></div>
+ <div id="flash"></div>
+
+ <div class="bar">
+ <span class="logo"><b>DW</b> Remote Cam</span>
+ <span class="ver">__VER__</span>
+ <span class="pill"><span class="dot" id="dCam"></span><span id="sCam">starting…</span></span>
+ </div>
+
+ <div class="foot">
+ <div class="target" id="target">Desktop not connected yet</div>
+ <div class="hint" id="hint">Keep this page open and your phone awake. The desktop will trigger the shot.</div>
+ </div>
+
+ <!-- camera permission / wake gate -->
+ <div class="overlay" id="gate">
+ <h2 id="gTitle">Start the camera</h2>
+ <p id="gMsg">Tap below and allow camera access. Point the phone at the wallcovering roll or sheet — the desktop fires the shutter.</p>
+ <button class="btn primary" id="gBtn">📷 Start Camera</button>
+ </div>
+
+ <div class="toast" id="toast"></div>
+
+<script>
+const $ = s => document.querySelector(s);
+let stream = null, videoTrack = null, camLive = false, busy = false;
+let curTarget = null; // { dw_sku, product_id, keep_images, meta }
+let lastNonce = 0;
+
+function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),2200); }
+function setCam(state, label){
+ const d=$('#dCam'); d.className='dot'+(state?' '+state:'');
+ $('#sCam').textContent=label;
+}
+
+// ── getUserMedia: rear camera, hardened for iOS Safari ──
+// 1) try exact environment (true rear), 2) fall back to environment, 3) any camera.
+async function startCamera(){
+ if(stream){ return; }
+ $('#gBtn').textContent='Starting…';
+ const attempts = [
+ { video:{ facingMode:{ exact:'environment' }, width:{ ideal:4096 }, height:{ ideal:3072 } }, audio:false },
+ { video:{ facingMode:'environment', width:{ ideal:4096 }, height:{ ideal:3072 } }, audio:false },
+ { video:true, audio:false }
+ ];
+ let err=null;
+ for(const c of attempts){
+ try{ stream = await navigator.mediaDevices.getUserMedia(c); break; }
+ catch(e){ err=e; }
+ }
+ if(!stream){
+ $('#gTitle').textContent='Camera blocked';
+ $('#gMsg').textContent=(err&&err.name==='NotAllowedError')
+ ? 'Camera permission was denied. In Safari: aA menu → Website Settings → Camera → Allow, then reload.'
+ : 'Could not start the camera ('+((err&&err.name)||'error')+'). This page needs HTTPS (or localhost) for the camera on iOS Safari.';
+ $('#gBtn').textContent='Try Again';
+ return;
+ }
+ const v=$('#v'); v.srcObject=stream;
+ videoTrack = stream.getVideoTracks()[0];
+ // keep it playing (iOS pauses on backgrounding) + reflect resolution
+ v.onloadedmetadata=()=>{ v.play().catch(()=>{}); reportLive(); };
+ // surface track ending (OS reclaimed the camera, e.g. another app)
+ videoTrack.addEventListener('ended', ()=>{ camLive=false; setCam('err','camera stopped'); reportLive(); $('#gate').hidden=false; $('#gTitle').textContent='Camera stopped'; $('#gMsg').textContent='The camera was released (phone slept or another app grabbed it). Tap to resume.'; $('#gBtn').textContent='📷 Resume Camera'; });
+ $('#gate').hidden=true;
+ camLive=true;
+ reportLive();
+}
+function camResolution(){
+ const v=$('#v');
+ // settings is most reliable for true sensor res; fall back to the video element
+ const s = videoTrack && videoTrack.getSettings ? videoTrack.getSettings() : {};
+ const w = s.width || v.videoWidth || 0, h = s.height || v.videoHeight || 0;
+ const fm = s.facingMode || '';
+ return { w, h, label: (w&&h?`${w}×${h}`:'live') + (fm?(' · '+fm):'') };
+}
+function reportLive(){
+ const r=camResolution();
+ camLive = !!(stream && videoTrack && videoTrack.readyState==='live' && r.w>0);
+ setCam(camLive?'ok':'warn', camLive?('camera live · '+r.label):'camera idle');
+ fetch('/api/pair/cam-status',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({ live:camLive, info:r.label })}).catch(()=>{});
+}
+
+// ── Capture a frame at native resolution (NEVER upscale) ──
+function captureDataUrl(){
+ const v=$('#v');
+ const r=camResolution();
+ const w = r.w || v.videoWidth, h = r.h || v.videoHeight;
+ if(!w||!h) return null;
+ const c=document.createElement('canvas'); c.width=w; c.height=h; // exact native res, no scaling
+ c.getContext('2d').drawImage(v, 0, 0, w, h);
+ return c.toDataURL('image/jpeg', 0.92);
+}
+
+// ── Handle a SHOOT signal from the desktop ──
+async function onShoot(target){
+ if(busy) return;
+ if(!camLive){ toast('Camera not live'); reportLive(); return; }
+ if(target.nonce && target.nonce===lastNonce) return; // de-dupe repeated SSE delivery
+ lastNonce = target.nonce || Date.now();
+ busy=true;
+ // visual + capture
+ const f=$('#flash'); f.classList.add('on'); setTimeout(()=>f.classList.remove('on'),120);
+ if(navigator.vibrate) try{ navigator.vibrate(40);}catch(e){}
+ const dataUrl = captureDataUrl();
+ if(!dataUrl){ toast('Capture failed'); busy=false; return; }
+ toast('Captured — uploading…');
+ // Route into the SAME existing attach pipeline as a manual capture: /api/photo.
+ // keep_images defaults true (preserve existing imagery — safe for live SKUs).
+ // If no target SKU was selected, the photo still lands locally (push:false).
+ let res;
+ try{
+ const payload = target.dw_sku
+ ? { dw_sku:target.dw_sku, product_id:target.product_id||null, dataUrl,
+ keep_images: target.keep_images!==false, meta: target.meta||null }
+ : { dw_sku:'REMOTE-'+Date.now(), dataUrl, push:false }; // no SKU → local only, no Shopify write
+ const r=await fetch('/api/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+ res=await r.json();
+ }catch(e){ res={ ok:false, err:'upload failed — check connection' }; }
+ // tell the desktop what happened so it can preview + show the attach outcome
+ fetch('/api/pair/shot',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({
+ dw_sku: target.dw_sku||null, product_id: res&&res.product_id||target.product_id||null
+ }, res||{}))}).catch(()=>{});
+ toast(res&&res.ok ? (res.live?'🟢 LIVE · sent to desktop':'✓ Uploaded · sent to desktop') : ('Upload error'+(res&&res.err?': '+res.err:'')));
+ busy=false;
+}
+
+// ── SSE link to the server (auto-reconnects natively) ──
+let es=null;
+function connect(){
+ try{ if(es) es.close(); }catch(e){}
+ es=new EventSource('/cam/events');
+ es.addEventListener('shoot', e=>{ try{ onShoot(JSON.parse(e.data)); }catch(err){} });
+ es.addEventListener('status', e=>{ try{ applyStatus(JSON.parse(e.data)); }catch(err){} });
+ es.onerror=()=>{ /* EventSource auto-reconnects per the server's retry: hint */ };
+}
+function applyStatus(s){
+ $('#target').innerHTML = s.desk_connected
+ ? (s.last_shot && s.last_shot.dw_sku ? ('Last: <b>'+s.last_shot.dw_sku+'</b> '+(s.last_shot.live?'🟢 live':(s.last_shot.ok?'✓ uploaded':'✗ failed'))) : 'Desktop connected — waiting for SHOOT')
+ : 'Desktop not connected yet';
+}
+
+// ── Lifecycle hardening: re-assert the stream when the page comes back to foreground ──
+document.addEventListener('visibilitychange', ()=>{
+ if(document.visibilityState==='visible'){
+ const v=$('#v');
+ if(stream){ v.play().catch(()=>{}); reportLive(); }
+ }
+});
+// re-report camera state periodically so the desktop's "camera live" pill stays honest
+setInterval(()=>{ if(stream) reportLive(); }, 7000);
+
+$('#gBtn').addEventListener('click', startCamera);
+connect();
+// Wake Lock (where supported) keeps the screen on so the stream doesn't pause.
+let wakeLock=null;
+async function requestWake(){ try{ if('wakeLock' in navigator){ wakeLock=await navigator.wakeLock.request('screen'); } }catch(e){} }
+document.addEventListener('visibilitychange',()=>{ if(document.visibilityState==='visible') requestWake(); });
+requestWake();
+</script>
+</body>
+</html>
diff --git a/public/desk.html b/public/desk.html
new file mode 100644
index 0000000..3b86137
--- /dev/null
+++ b/public/desk.html
@@ -0,0 +1,218 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>DW Remote Shutter — Desktop</title>
+<style>
+ :root{ --bg:#0f0e0c; --card:#1b1916; --ink:#f3efe7; --muted:#9a9184; --line:#2e2a25;
+ --gold:#c8a24a; --green:#3fa06a; --red:#c0563f; }
+ *{box-sizing:border-box}
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.45 -apple-system,BlinkMacSystemFont,"SF Pro Text",Segoe UI,Roboto,sans-serif}
+ header{position:sticky;top:0;z-index:10;background:rgba(15,14,12,.97);backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:14px 20px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+ h1{font:600 18px/1.1 "SF Pro Display",sans-serif;letter-spacing:.04em;margin:0;text-transform:uppercase}
+ h1 span{color:var(--gold)}
+ .ver{font:600 10px/1 ui-monospace,Menlo,monospace;text-decoration:none;color:#1b1407;background:var(--green);border-radius:99px;padding:3px 7px;margin-left:6px}
+ .pills{margin-left:auto;display:flex;gap:10px;flex-wrap:wrap}
+ .pill{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:600;background:#16140f;border:1px solid var(--line);border-radius:99px;padding:7px 13px}
+ .dot{width:10px;height:10px;border-radius:50%;background:var(--muted)}
+ .dot.ok{background:var(--green);box-shadow:0 0 10px var(--green)} .dot.warn{background:var(--gold)} .dot.err{background:var(--red)}
+ main{max-width:1100px;margin:0 auto;padding:22px 20px;display:grid;grid-template-columns:1fr 1fr;gap:22px}
+ @media(max-width:820px){ main{grid-template-columns:1fr} }
+ .panel{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:18px}
+ .panel h2{font:600 13px/1 "SF Pro Display",sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--gold);margin:0 0 14px}
+ input[type=search]{width:100%;background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:11px 13px;font-size:15px}
+ .scope{display:flex;gap:8px;margin:10px 0}
+ .chip{padding:7px 13px;border-radius:99px;border:1px solid var(--line);background:#16140f;color:var(--muted);font-size:13px;cursor:pointer}
+ .chip.on{background:var(--gold);color:#1b1407;border-color:var(--gold);font-weight:600}
+ .results{max-height:300px;overflow:auto;margin-top:6px;display:flex;flex-direction:column;gap:7px}
+ .row{display:flex;gap:11px;align-items:center;padding:9px;border:1px solid var(--line);border-radius:11px;background:#16140f;cursor:pointer}
+ .row:hover{border-color:var(--gold)} .row.sel{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold)}
+ .row img{width:46px;height:46px;border-radius:8px;object-fit:cover;background:#0c0b09;flex:0 0 auto}
+ .row .ph{width:46px;height:46px;border-radius:8px;background:#0c0b09;display:flex;align-items:center;justify-content:center;color:#4a443b;flex:0 0 auto}
+ .row .meta{min-width:0;flex:1}
+ .row .t{font:600 14px/1.25 "SF Pro Display",sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .row .s{font:600 11px/1 ui-monospace,Menlo,monospace;color:var(--gold)} .row .v{font-size:11px;color:var(--muted)}
+ .hint{color:var(--muted);font-size:13px;margin:8px 0 0}
+ /* selected target */
+ .target{margin-top:14px;border:1px dashed var(--line);border-radius:12px;padding:13px;display:flex;gap:12px;align-items:center}
+ .target.set{border-style:solid;border-color:var(--gold)}
+ .target img{width:54px;height:54px;border-radius:9px;object-fit:cover;background:#0c0b09}
+ .target .ph{width:54px;height:54px;border-radius:9px;background:#0c0b09;display:flex;align-items:center;justify-content:center;color:#4a443b}
+ .target .info{min-width:0;flex:1}
+ .target .info b{display:block;font:600 15px/1.25 "SF Pro Display",sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .target .info span{font:600 12px/1 ui-monospace,Menlo,monospace;color:var(--gold)}
+ .keepwrap{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted);margin-top:10px;cursor:pointer;user-select:none}
+ .keepwrap input{accent-color:var(--gold)}
+ /* shoot */
+ .shoot{width:100%;margin-top:8px;background:var(--gold);color:#1b1407;border:none;border-radius:14px;padding:20px;font-weight:800;font-size:19px;letter-spacing:.03em;cursor:pointer}
+ .shoot:disabled{opacity:.4;cursor:not-allowed}
+ .shoot.armed{box-shadow:0 0 0 3px rgba(200,162,74,.35)}
+ .preview{margin-top:6px;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:#0c0b09;aspect-ratio:4/3;display:flex;align-items:center;justify-content:center;color:#4a443b;position:relative}
+ .preview img{width:100%;height:100%;object-fit:contain;display:block}
+ .stat{margin-top:10px;font-size:13px}
+ .stat .ok{color:var(--green)} .stat .err{color:var(--red)} .stat .warn{color:var(--gold)}
+ .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}
+ .biglink{color:var(--gold);font-size:13px;text-decoration:none}
+ .camlinkbox{font-size:13px;color:var(--muted);margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
+ .camlinkbox code{background:#0c0b09;border:1px solid var(--line);border-radius:6px;padding:2px 7px;color:var(--gold)}
+</style>
+</head>
+<body>
+<header>
+ <h1>DW <span>Remote Shutter</span><a class="ver" href="/selfcheck">__VER__</a></h1>
+ <div class="pills">
+ <span class="pill"><span class="dot" id="dPhone"></span><span id="sPhone">phone: …</span></span>
+ <span class="pill"><span class="dot warn" id="dDesk"></span>this desktop</span>
+ </div>
+</header>
+
+<main>
+ <!-- LEFT: pick the target SKU -->
+ <section class="panel">
+ <h2>1 · Target product</h2>
+ <input type="search" id="q" placeholder="Search a SKU, pattern, model #, or vendor…">
+ <div class="scope">
+ <div class="chip on" data-s="shop">🌐 All Shopify</div>
+ <div class="chip" data-s="fentucci">🔎 Fentucci / TWIL</div>
+ </div>
+ <div class="results" id="results"></div>
+ <p class="hint" id="searchHint">Type to find the product you want to photograph. The phone fires the shot; the photo attaches to this product exactly like a manual capture.</p>
+
+ <div class="target" id="target">
+ <div class="ph">▦</div>
+ <div class="info"><b>No product selected</b><span>Pick one above — or shoot with none selected to just save locally</span></div>
+ </div>
+ <label class="keepwrap"><input type="checkbox" id="keep" checked> Keep existing images (add as featured, don't wipe). Uncheck to make this the only image.</label>
+ </section>
+
+ <!-- RIGHT: shoot + preview -->
+ <section class="panel">
+ <h2>2 · Shoot & review</h2>
+ <button class="shoot" id="shootBtn" disabled>📷 SHOOT</button>
+ <div class="preview" id="preview">Last shot will preview here</div>
+ <div class="stat" id="stat"></div>
+ <div class="camlinkbox">
+ 📱 On the phone, open <code id="camUrl">/cam</code> (this same host) and tap <b>Start Camera</b>. Keep it open + the phone awake.
+ <br><a class="biglink" id="camLink" href="/cam" target="_blank">Open /cam here ↗</a> · <a class="biglink" href="/" target="_blank">Full capture app ↗</a>
+ </div>
+ </section>
+</main>
+
+<div class="toast" id="toast"></div>
+
+<script>
+const $=s=>document.querySelector(s);
+let scope='shop', target=null, _t=0, busyShoot=false;
+function toast(m){const t=$('#toast');t.textContent=m;t.classList.add('show');clearTimeout(t._t);t._t=setTimeout(()=>t.classList.remove('show'),2400);}
+
+$('#camUrl').textContent = location.origin + '/cam';
+
+// ── search (reuses the existing endpoints) ──
+async function search(){
+ const q=$('#q').value.trim();
+ const r=$('#results');
+ if(!q){ r.innerHTML=''; return; }
+ r.innerHTML='<div class="hint">Searching…</div>';
+ const url = scope==='shop' ? '/api/shopify-search?q='+encodeURIComponent(q) : '/api/lookup?q='+encodeURIComponent(q);
+ try{
+ const d=await(await fetch(url)).json();
+ const items=d.items||[];
+ if(!items.length){ r.innerHTML='<div class="hint">No match — try the SKU, pattern name, or vendor.</div>'; return; }
+ r.innerHTML='';
+ items.slice(0,40).forEach(it=>{
+ const el=document.createElement('div'); el.className='row';
+ const img=it.image?`<img src="${it.image}" loading="lazy">`:'<div class="ph">▦</div>';
+ el.innerHTML=`${img}<div class="meta"><div class="t">${(it.title||'').replace(/ \| Fentucci$/,'')}</div>
+ <div><span class="s">${it.dw_sku||'—'}</span> <span class="v">${it.vendor||it.status||''}${it.price?(' · $'+it.price):''}</span></div></div>`;
+ el.addEventListener('click',()=>selectTarget(it,el));
+ r.appendChild(el);
+ });
+ }catch(e){ r.innerHTML='<div class="hint" style="color:var(--red)">Search failed.</div>'; }
+}
+function selectTarget(it,el){
+ target={ dw_sku:it.dw_sku, product_id:it.product_id||null,
+ keep_images: it.keep_images!==false, title:it.title, image:it.image, vendor:it.vendor, status:it.status, needs_create:it.needs_create };
+ document.querySelectorAll('.row').forEach(r=>r.classList.remove('sel')); if(el) el.classList.add('sel');
+ const t=$('#target'); t.classList.add('set');
+ const img=it.image?`<img src="${it.image}">`:'<div class="ph">▦</div>';
+ t.innerHTML=`${img}<div class="info"><b>${(it.title||'').replace(/ \| Fentucci$/,'')}</b><span>${it.dw_sku||'—'}${it.product_id?'':' · NEW (will create)'}</span></div>`;
+ updateShoot();
+}
+$('#q').addEventListener('input',()=>{ clearTimeout(_t); _t=setTimeout(search, scope==='shop'?350:250); });
+document.querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>{
+ document.querySelectorAll('.chip').forEach(z=>z.classList.remove('on')); c.classList.add('on'); scope=c.dataset.s; search();
+}));
+
+// ── pairing status via SSE ──
+let phone={ connected:false, live:false };
+function applyStatus(s){
+ phone.connected=!!s.cam_connected; phone.live=!!s.cam_live;
+ const d=$('#dPhone'), t=$('#sPhone');
+ if(s.cam_live){ d.className='dot ok'; t.textContent='phone: camera live'+(s.cam_info?(' · '+s.cam_info):''); }
+ else if(s.cam_connected){ d.className='dot warn'; t.textContent='phone: connected, camera idle'; }
+ else { d.className='dot err'; t.textContent='phone: not connected'; }
+ updateShoot();
+}
+function updateShoot(){
+ const btn=$('#shootBtn');
+ const ready = phone.live;
+ btn.disabled = !ready || busyShoot;
+ btn.classList.toggle('armed', ready && !busyShoot);
+ btn.textContent = busyShoot?'⏳ Capturing…' : !phone.connected?'📷 SHOOT — phone not connected'
+ : !phone.live?'📷 SHOOT — wake the phone / start camera' : (target?('📷 SHOOT → '+(target.dw_sku||'product')):'📷 SHOOT (save locally)');
+}
+async function shoot(){
+ if(busyShoot||!phone.live) return;
+ busyShoot=true; updateShoot();
+ $('#stat').innerHTML='<span class="warn">Signalling phone…</span>';
+ const payload = target
+ ? { dw_sku:target.dw_sku, product_id:target.product_id, keep_images:$('#keep').checked,
+ meta:{ title:target.title||'', image:(target.image&&!/^\/photos\//.test(target.image))?target.image:null,
+ product_id:target.product_id||null, status:target.status||'', vendor:target.vendor||'' } }
+ : {};
+ try{
+ const r=await fetch('/api/pair/shoot',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+ const d=await r.json();
+ if(!d.ok){ $('#stat').innerHTML='<span class="err">'+(d.err||'could not signal phone')+'</span>'; busyShoot=false; updateShoot(); return; }
+ $('#stat').innerHTML='<span class="warn">Phone capturing — waiting for the photo…</span>';
+ // safety timeout so the button re-arms even if the phone never reports back
+ clearTimeout(window._shotTO); window._shotTO=setTimeout(()=>{ if(busyShoot){ busyShoot=false; updateShoot(); $('#stat').innerHTML='<span class="warn">No response from phone — check the phone is awake.</span>'; } }, 25000);
+ }catch(e){ $('#stat').innerHTML='<span class="err">Network error.</span>'; busyShoot=false; updateShoot(); }
+}
+$('#shootBtn').addEventListener('click',shoot);
+
+// the phone reports the shot result → preview it (pull the freshest image) + show outcome
+function onShot(s){
+ clearTimeout(window._shotTO);
+ busyShoot=false; updateShoot();
+ const pv=$('#preview');
+ // pull the photo back: prefer the just-attached Shopify image (proxied), else the local /photos file
+ const pid = s.product_id || (target&&target.product_id);
+ const src = s.photo ? (s.photo+'?t='+Date.now())
+ : pid ? ('/api/current-image?pid='+pid+'&t='+Date.now()) : null;
+ if(src){ pv.innerHTML=`<img src="${src}" alt="last shot">`; } else { pv.textContent='Captured (no preview source)'; }
+ if(s.ok){
+ const live = s.live ? ' · 🟢 LIVE on store' : (s.shopify_pushed ? (s.live_reason?(' · on Shopify (needs '+String(s.live_reason).replace('needs ','')+')'):' · on Shopify') : '');
+ $('#stat').innerHTML='<span class="ok">✓ Photo captured & attached'+(s.dw_sku?(' to '+s.dw_sku):'')+'</span>'+(live?('<span class="warn">'+live+'</span>'):'')
+ + (s.created?' <span class="ok">(product created)</span>':'')
+ + (s.product_id?(' <a class="biglink" target="_blank" href="https://admin.shopify.com/store/designer-laboratory-sandbox/products/'+s.product_id+'">open in Shopify ↗</a>'):'');
+ } else {
+ $('#stat').innerHTML='<span class="err">✗ '+(s.err||s.push_err||'capture/upload failed')+'</span>';
+ }
+}
+
+// ── SSE connect (auto-reconnects) ──
+let es=null;
+function connect(){
+ try{ if(es) es.close(); }catch(e){}
+ es=new EventSource('/desk/events');
+ es.addEventListener('status', e=>{ try{ applyStatus(JSON.parse(e.data)); }catch(err){} });
+ es.addEventListener('shot', e=>{ try{ onShot(JSON.parse(e.data)); }catch(err){} });
+ es.onerror=()=>{};
+}
+connect();
+</script>
+</body>
+</html>
← 8ca15e9 auto-save: 2026-06-25T08:32:54 (1 files) — server.js
·
back to Dw Photo Capture
·
Camera SKU scan: 🔢 Scan button → macOS Vision OCR (/api/ocr b34052e →