← back to Dw Photo Capture

public/captures.html

117 lines

<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Capture History · DW</title>
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png"><link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- TK-12228 — Steve: "every photo, front and back, must show display on the app."
     One card per captured item with its FRONT and BACK photos side by side (a missing side is shown
     as a visible gap, never silently dropped). Sort + density slider persist in localStorage. -->
<style>
  :root{--bg:#0f0e0c;--card:#1b1916;--ink:#f3efe7;--muted:#9a9184;--line:#2e2a25;--gold:#c8a24a;--warn:#e6a23a;--cardw:260px}
  *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.4 -apple-system,system-ui,sans-serif}
  header{position:sticky;top:0;z-index:5;background:var(--bg);border-bottom:1px solid var(--line);padding:12px 16px}
  h1{margin:0 0 10px;font-size:18px} h1 a{color:var(--muted);font-size:13px;text-decoration:none;margin-left:8px}
  .controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
  select,input[type=search]{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:14px}
  .dens{color:var(--muted);font-size:13px;display:flex;align-items:center;gap:6px}
  .stat{color:var(--muted);font-size:13px} .stat b{color:var(--ink)} .stat .w{color:var(--warn)}
  main{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cardw),1fr));gap:12px;padding:14px 16px}
  .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
  .card.miss{border-color:#6b551f}
  .pair{display:grid;grid-template-columns:1fr 1fr;gap:2px;background:#000}
  .pair figure{margin:0;position:relative;cursor:zoom-in}
  .pair img{width:100%;aspect-ratio:1;object-fit:cover;display:block;background:#000}
  .pair figcaption{position:absolute;left:5px;bottom:5px;font:800 10px/1 -apple-system,sans-serif;letter-spacing:.05em;text-transform:uppercase;background:#000b;padding:3px 5px;border-radius:4px}
  .pair .gap{cursor:default;aspect-ratio:1;display:flex;align-items:center;justify-content:center;text-align:center;color:var(--warn);font-size:12px;padding:8px;background:#16120a}
  .extras{display:flex;gap:2px;background:#000}
  .extras figure{margin:0;flex:1;cursor:zoom-in} .extras img{width:100%;height:56px;object-fit:cover;display:block}
  .meta{padding:8px 10px;font-size:12px;display:grid;gap:3px}
  .sku{font-weight:600;font-size:13px}
  .sub{color:var(--muted)}
  .when{color:var(--gold)}
  .bad{color:var(--warn)}
  .empty{padding:40px 16px;color:var(--muted);text-align:center;grid-column:1/-1}
  #lb{position:fixed;inset:0;z-index:50;background:#000e;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px}
  #lb[hidden]{display:none}
  #lb img{max-width:100%;max-height:84vh;object-fit:contain;border-radius:8px}
  #lb .cap{font-weight:700;letter-spacing:.05em;text-transform:uppercase;font-size:14px}
  #lb button{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:10px;padding:9px 16px;font-size:14px;cursor:pointer}
</style></head>
<body>
<header>
  <h1>Capture History <span id="count" style="color:var(--muted);font-weight:400;font-size:14px"></span><a href="/app">← capture</a><a href="/vendor-photos">vendor photos</a></h1>
  <div class="controls">
    <input type="search" id="q" placeholder="Search SKU, vendor, source">
    <select id="sort" aria-label="Sort">
      <option value="newest">Newest</option>
      <option value="oldest">Oldest</option>
      <option value="missing">Missing a side first</option>
      <option value="sku">SKU A→Z</option>
      <option value="vendor">Vendor A→Z</option>
      <option value="source">Source</option>
    </select>
    <label class="dens">Size <input type="range" id="dens" min="160" max="520" step="20" value="260" aria-label="Card size"></label>
    <span class="stat" id="stat"></span>
  </div>
</header>
<main id="grid"></main>
<div id="lb" hidden><div class="cap" id="lbCap"></div><img id="lbImg" alt="captured photo full size"><button id="lbClose">✕ Close</button></div>
<script>
const $ = s => document.querySelector(s);
const ls = { get(k,d){ try{ return localStorage.getItem(k) ?? d; }catch(e){ return d; } }, set(k,v){ try{ localStorage.setItem(k,v); }catch(e){} } };
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const fmtDate = iso => { const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
const LBL = { front:'Front', back:'Back', extra:'Extra', photo:'Photo' };
let all = [], sort = ls.get('cap.sort','newest');
$('#sort').value = sort; $('#dens').value = ls.get('cap.dens','260');
document.documentElement.style.setProperty('--cardw', $('#dens').value + 'px');
const side = (c, s) => c.photos.find(p => p.side === s);
// a FRONT/BACK item that lacks one side; single-shot surfaces (remote cam, side 'photo' only) never have a pair
const missing = c => { const f = side(c,'front'), b = side(c,'back'); return !!(f || b) && !(f && b); };
function fig(p, cls){ const lbl = LBL[p.side] || p.side;
  return `<figure class="${cls||''}" data-src="${esc(p.url)}" data-cap="${esc(lbl)}"><img loading="lazy" src="${esc(p.url)}" alt="${esc(lbl)} photo" data-side="${esc(p.side)}">${cls==='x'?'':`<figcaption${p.inferred?' title="side inferred from the filename (captured before side-tagging)"':''}>${esc(lbl)}${p.inferred?'*':''}</figcaption>`}</figure>`; }
function card(c){
  // single-shot surfaces (remote cam / legacy single photo) have no front/back split — show what exists.
  const f = side(c,'front'), b = side(c,'back');
  const loose = c.photos.filter(p => p.side === 'photo');
  let pair;
  if (!f && !b && loose.length) pair = `<div class="pair" style="grid-template-columns:1fr">${fig(loose[0])}</div>`;
  else pair = `<div class="pair">${f ? fig(f) : '<div class="gap">⚠ no FRONT photo</div>'}${b ? fig(b) : '<div class="gap">⚠ no BACK photo</div>'}</div>`;
  const rest = c.photos.filter(p => p !== f && p !== b && !(p === loose[0] && !f && !b));
  const extras = rest.length ? `<div class="extras">${rest.map(p => fig(p,'x')).join('')}</div>` : '';
  const status = c.ok === false ? `<div class="bad">✗ create failed${c.err ? ': ' + esc(c.err) : ''}</div>` : '';
  return `<div class="card${missing(c) ? ' miss' : ''}" data-id="${esc(c.id)}">${pair}${extras}
    <div class="meta"><div class="sku">${esc(c.dw_sku || '—')}${c.title ? ' <span class="sub">· ' + esc(c.title) + '</span>' : ''}</div>
    <div class="sub">${esc(c.vendor || 'no vendor')} · ${esc(c.source || '')} · ${c.photos.length} photo${c.photos.length === 1 ? '' : 's'}</div>
    ${status}<div class="when" title="${esc(c.at)}">🕓 ${esc(fmtDate(c.at))}</div></div></div>`;
}
function render(){
  const q = $('#q').value.trim().toLowerCase();
  let rows = all.filter(c => !q || [c.dw_sku, c.vendor, c.source, c.title, c.mfr].some(v => String(v || '').toLowerCase().includes(q)));
  const newest = (a,b) => String(b.at).localeCompare(String(a.at));
  const by = { newest, oldest:(a,b)=>String(a.at).localeCompare(String(b.at)),
    missing:(a,b)=>(missing(b)-missing(a)) || newest(a,b),
    sku:(a,b)=>String(a.dw_sku||'').localeCompare(String(b.dw_sku||'')) || newest(a,b),
    vendor:(a,b)=>String(a.vendor||'').localeCompare(String(b.vendor||'')) || newest(a,b),
    source:(a,b)=>String(a.source||'').localeCompare(String(b.source||'')) || newest(a,b) };
  rows.sort(by[sort] || newest);
  $('#count').textContent = rows.length + ' of ' + all.length;
  $('#grid').innerHTML = rows.length ? rows.map(card).join('') : '<div class="empty">No captures yet — shoot a front + back and it appears here.</div>';
}
$('#grid').addEventListener('click', e => { const f = e.target.closest('figure[data-src]'); if (!f) return;
  $('#lbImg').src = f.dataset.src; $('#lbCap').textContent = f.dataset.cap || ''; $('#lb').hidden = false; });
const closeLb = () => { $('#lb').hidden = true; $('#lbImg').removeAttribute('src'); };
$('#lbClose').addEventListener('click', closeLb);
$('#lb').addEventListener('click', e => { if (e.target.id === 'lb') closeLb(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLb(); });
$('#sort').addEventListener('change', e => { sort = e.target.value; ls.set('cap.sort', sort); render(); });
$('#dens').addEventListener('input', e => { document.documentElement.style.setProperty('--cardw', e.target.value + 'px'); ls.set('cap.dens', e.target.value); });
$('#q').addEventListener('input', render);
fetch(location.origin + '/api/captures?limit=2000').then(r => r.json()).then(d => {
  all = d.captures || [];
  $('#stat').innerHTML = `<b>${d.total}</b> items` + (d.missing_back ? ` · <span class="w">${d.missing_back} without a back photo</span>` : '');
  render();
}).catch(() => { $('#grid').innerHTML = '<div class="empty">Could not load captures.</div>'; });
</script>
</body></html>