← back to Dw Photo Capture
public/desk.html
219 lines
<!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>