← back to Feature Harvest

public/index.html

444 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Feature Harvest — DW / WPB review</title>
<style>
  :root{
    --bg:#0f1115; --panel:#171a21; --panel2:#1d212b; --line:#2a2f3a;
    --ink:#e8eaf0; --mut:#8a92a6; --gold:#caa45a; --teal:#34a5f4;
    --dw:#caa45a; --wpb:#b07cf0; --both:#3ddc97; --skip:#5a6172;
    --win:#34a5f4; --app:#e0a93b; --tool:#b07cf0;
    --r:14px; --shadow:0 8px 30px rgba(0,0,0,.35);
  }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);
    font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
  a{color:var(--teal)}
  /* top bar */
  header{position:sticky;top:0;z-index:10;background:rgba(15,17,21,.92);
    backdrop-filter:blur(8px);border-bottom:1px solid var(--line);padding:12px 18px}
  .row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
  .brand{font-weight:700;letter-spacing:.3px;font-size:17px}
  .brand span{color:var(--gold)}
  .counts{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto}
  .chip{padding:3px 10px;border-radius:999px;font-size:12.5px;font-weight:600;
    background:var(--panel2);border:1px solid var(--line);white-space:nowrap}
  .chip.dw{color:var(--dw)} .chip.wpb{color:var(--wpb)}
  .chip.both{color:var(--both)} .chip.skip{color:var(--skip)}
  .chip.left{color:var(--ink)}
  button,select,input{font:inherit;color:var(--ink)}
  .btn{background:var(--panel2);border:1px solid var(--line);border-radius:10px;
    padding:7px 13px;cursor:pointer;transition:.12s}
  .btn:hover{border-color:var(--gold);background:#222734}
  .btn.primary{background:var(--gold);color:#1a1407;border-color:var(--gold);font-weight:700}
  .btn.ghost{background:transparent}
  .progress{height:5px;background:var(--panel2);border-radius:4px;margin-top:10px;overflow:hidden}
  .progress > i{display:block;height:100%;background:linear-gradient(90deg,var(--gold),var(--teal));width:0}
  /* controls */
  .controls{margin-top:10px;gap:8px}
  .controls select,.controls input[type=search]{
    background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:6px 10px}
  .seg{display:flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
  .seg button{background:transparent;border:0;padding:6px 13px;cursor:pointer;color:var(--mut)}
  .seg button.on{background:var(--gold);color:#1a1407;font-weight:700}
  .dens{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12.5px}
  /* main */
  main{padding:18px;max-width:1400px;margin:0 auto}
  /* QUEUE mode */
  .queue-wrap{max-width:760px;margin:0 auto}
  .qnav{display:flex;justify-content:space-between;align-items:center;color:var(--mut);
    font-size:13px;margin-bottom:10px}
  /* card */
  .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
    box-shadow:var(--shadow);overflow:hidden}
  .card .top{padding:6px 16px;display:flex;gap:8px;align-items:center;
    border-bottom:1px solid var(--line);background:var(--panel2)}
  .src{padding:2px 9px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
  .src.win{background:rgba(52,165,244,.16);color:var(--win)}
  .src.dw-app{background:rgba(224,169,59,.16);color:var(--app)}
  .src.wpb-tool{background:rgba(176,124,240,.16);color:var(--tool)}
  .src.skill{background:rgba(61,220,151,.16);color:#3ddc97}
  .src.agent{background:rgba(240,140,90,.16);color:#f08c5a}
  /* officer recommendation badge */
  .rec{display:inline-flex;gap:6px;align-items:center;margin:10px 0 0;padding:8px 11px;border-radius:9px;font-size:13px;
    border-left:3px solid var(--gold);background:rgba(202,164,90,.08)}
  .rec b{color:var(--gold)} .rec .v{font-weight:800;font-size:11px;padding:1px 7px;border-radius:5px;background:var(--gold);color:#1a1407}
  .rec .v.RETIRE{background:var(--skip);color:#fff}
  .mini .recdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--gold);margin-left:5px}
  .when{color:var(--mut);font-size:12px;margin-left:auto;white-space:nowrap}
  .verdict-tag{font-size:11px;font-weight:800;padding:2px 8px;border-radius:6px}
  .vt-dw{background:rgba(202,164,90,.18);color:var(--dw)}
  .vt-wpb{background:rgba(176,124,240,.18);color:var(--wpb)}
  .vt-both{background:rgba(61,220,151,.18);color:var(--both)}
  .vt-skip{background:rgba(90,97,114,.25);color:var(--skip)}
  /* preview (app items only): gradient poster background + screenshot on top */
  .preview{position:relative;width:100%;aspect-ratio:16/10;overflow:hidden;
    border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:center}
  .preview .thumb{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top}
  .poster-label{position:relative;z-index:1;color:#fff;font-weight:700;font-size:17px;
    text-align:center;padding:16px;text-shadow:0 1px 6px rgba(0,0,0,.45);letter-spacing:.3px}
  .card .body{padding:16px}
  .card h2{margin:0 0 4px;font-size:20px;line-height:1.25}
  .meta{color:var(--mut);font-size:12.5px;margin-bottom:12px}
  .meta code{background:var(--panel2);padding:1px 6px;border-radius:5px;color:var(--gold)}
  .summary{white-space:pre-wrap;color:#d3d7e0}
  .value{margin-top:10px;padding:10px 12px;border-left:3px solid var(--both);
    background:rgba(61,220,151,.06);border-radius:0 8px 8px 0;font-size:14px}
  .value b{color:var(--both)}
  .pathline{margin-top:10px;font-size:12px;color:var(--mut)}
  /* verdict bar */
  .verdict{display:flex;gap:8px;padding:14px 16px;border-top:1px solid var(--line);
    background:var(--panel2);flex-wrap:wrap;align-items:center}
  .vbtn{flex:1;min-width:90px;border:1px solid var(--line);border-radius:10px;padding:11px;
    cursor:pointer;font-weight:700;background:var(--panel);transition:.12s;text-align:center}
  .vbtn:hover{transform:translateY(-1px)}
  .vbtn .k{font-size:11px;opacity:.6;font-weight:600}
  .vbtn.dw{border-color:var(--dw)} .vbtn.dw.on{background:var(--dw);color:#1a1407}
  .vbtn.wpb{border-color:var(--wpb)} .vbtn.wpb.on{background:var(--wpb);color:#150b22}
  .vbtn.both{border-color:var(--both)} .vbtn.both.on{background:var(--both);color:#04130c}
  .vbtn.skip{border-color:var(--skip)} .vbtn.skip.on{background:var(--skip);color:#fff}
  .note{width:100%;margin-top:10px;background:var(--panel);border:1px solid var(--line);
    border-radius:9px;padding:9px 11px;resize:vertical;min-height:38px;color:var(--ink)}
  /* BOARD mode */
  .board{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;align-items:start}
  @media(max-width:1100px){.board{grid-template-columns:repeat(2,1fr)}}
  .col{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;min-height:120px}
  .col h3{margin:2px 4px 10px;font-size:13px;text-transform:uppercase;letter-spacing:.5px;
    display:flex;justify-content:space-between}
  .col.dw h3{color:var(--dw)} .col.wpb h3{color:var(--wpb)}
  .col.both h3{color:var(--both)} .col.skip h3{color:var(--skip)} .col.left h3{color:var(--ink)}
  .mini{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:8px 10px;
    margin-bottom:8px;cursor:grab;font-size:13px}
  .mini:hover{border-color:var(--gold)}
  .mini .mt{font-weight:600;line-height:1.3}
  .mini .preview{aspect-ratio:16/9;border:0;border-radius:7px;margin-bottom:7px}
  .mini .poster-label{font-size:11px;padding:7px}
  .mini .ms{color:var(--mut);font-size:11px;margin-top:3px;display:flex;gap:6px;align-items:center}
  .col.drop{outline:2px dashed var(--gold);outline-offset:-4px}
  .grid-board[data-cols]{}
  .empty{color:var(--mut);text-align:center;padding:40px;font-style:italic}
  .hint{color:var(--mut);font-size:12px}
  kbd{background:var(--panel2);border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:11px}
</style>
</head>
<body>
<header>
  <div class="row">
    <div class="brand">Feature <span>Harvest</span> · review a year of apps → DW / WPB</div>
    <div class="counts" id="counts"></div>
  </div>
  <div class="row controls">
    <div class="seg" id="mode">
      <button data-mode="queue" class="on">Queue</button>
      <button data-mode="board">Board</button>
    </div>
    <select id="fSource">
      <option value="">All sources</option>
      <option value="win">Shipped wins</option>
      <option value="dw-app">DW apps</option>
      <option value="wpb-tool">WPB tools</option>
      <option value="skill">Skills</option>
      <option value="agent">Agents</option>
    </select>
    <select id="fStatus">
      <option value="unreviewed">Unreviewed</option>
      <option value="">All statuses</option>
      <option value="dw">→ DW</option>
      <option value="wpb">→ WPB</option>
      <option value="both">→ Both</option>
      <option value="skip">Skipped</option>
    </select>
    <select id="fProject"><option value="">All projects</option></select>
    <select id="fSort">
      <option value="new">Newest</option>
      <option value="old">Oldest</option>
      <option value="title">Title A→Z</option>
    </select>
    <input type="search" id="fSearch" placeholder="search title / summary…" style="min-width:200px" />
    <div class="dens" id="densWrap" style="display:none">density
      <input type="range" id="dens" min="2" max="6" value="5" />
    </div>
    <button class="btn" id="refreshBtn" title="Re-pull wins from CNCP + rebuild">↻ Refresh</button>
    <button class="btn" id="thumbsBtn" title="Re-screenshot app UIs (headless Chrome — takes ~1 min)">⟳ Rebuild thumbnails</button>
    <button class="btn primary" id="exportBtn">⬇ Export shortlist</button>
  </div>
  <div class="progress"><i id="bar"></i></div>
</header>
<main>
  <div id="queue" class="queue-wrap"></div>
  <div id="board" style="display:none"></div>
</main>

<script>
const $ = (s,r=document)=>r.querySelector(s);
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
const SRC_LABEL = {win:'Shipped win', 'dw-app':'DW app', 'wpb-tool':'WPB tool', skill:'Skill', agent:'Agent'};
const DEC = {dw:'→ DW', wpb:'→ WPB', both:'Both', skip:'Skip'};
let ITEMS=[], V={}, qi=0;
const state = {
  mode:'queue',
  source:'', status:'unreviewed', project:'', sort:'new', search:'',
  cols: +(localStorage.getItem('fh_cols')||5),
};

// ---- load ----
async function load(){
  const r = await fetch('/api/items'); const d = await r.json();
  ITEMS = d.items; V = d.verdicts || {};
  buildProjectFacet();
  render();
}
function buildProjectFacet(){
  const sel = $('#fProject');
  const seen = new Set();
  const opts = [];
  for(const it of ITEMS){ const p=it.project||'(none)'; if(!seen.has(p)){seen.add(p); opts.push(p);} }
  opts.sort();
  sel.innerHTML = '<option value="">All projects</option>' +
    opts.map(p=>`<option value="${esc(p)}">${esc(p)}</option>`).join('');
}

// ---- helpers ----
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function hueFromId(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return (h>>>0)%360;}
function previewHTML(it){
  if(it.source!=='dw-app' && it.source!=='wpb-tool') return '';   // app items only
  const h=hueFromId(it.id);
  const grad=`linear-gradient(135deg,hsl(${h} 52% 33%),hsl(${(h+45)%360} 58% 19%))`;
  const img = it.thumb ? `<img class="thumb" loading="lazy" src="/thumb/${esc(it.thumb)}" alt="${esc(it.title)} preview" onerror="this.remove()">` : '';
  const label = it.thumb ? '' : `<span class="poster-label">${esc(it.title)}</span>`;
  return `<div class="preview" style="background:${grad}">${img}${label}</div>`;
}
function whenStr(it){
  if(!it.ts) return it.date||'';
  return new Date(it.ts).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
}
function filtered(){
  let a = ITEMS;
  if(state.source) a=a.filter(i=>i.source===state.source);
  if(state.project) a=a.filter(i=>(i.project||'(none)')===state.project);
  if(state.status==='unreviewed') a=a.filter(i=>!V[i.id]);
  else if(state.status) a=a.filter(i=>V[i.id]&&V[i.id].decision===state.status);
  if(state.search){const q=state.search.toLowerCase();
    a=a.filter(i=>(i.title+' '+i.summary+' '+i.value+' '+i.project).toLowerCase().includes(q));}
  a=[...a];
  if(state.sort==='new') a.sort((x,y)=>(y.ts||0)-(x.ts||0));
  else if(state.sort==='old') a.sort((x,y)=>(x.ts||0)-(y.ts||0));
  else if(state.sort==='title') a.sort((x,y)=>(x.title||'').localeCompare(y.title||''));
  return a;
}
function counts(){
  const c={dw:0,wpb:0,both:0,skip:0,left:0};
  for(const it of ITEMS){const v=V[it.id]; if(v&&c[v.decision]!=null)c[v.decision]++; else c.left++;}
  return c;
}
function renderCounts(){
  const c=counts();
  $('#counts').innerHTML =
    `<span class="chip left">${ITEMS.length} total</span>`+
    `<span class="chip dw">DW ${c.dw}</span>`+
    `<span class="chip wpb">WPB ${c.wpb}</span>`+
    `<span class="chip both">Both ${c.both}</span>`+
    `<span class="chip skip">Skip ${c.skip}</span>`+
    `<span class="chip left">${c.left} left</span>`;
  const reviewed=ITEMS.length-c.left;
  $('#bar').style.width = (ITEMS.length? (reviewed/ITEMS.length*100):0)+'%';
}

// ---- verdict ----
async function setVerdict(id, decision){
  const cur = V[id] && V[id].decision;
  if(cur===decision){ decision='unset'; }   // toggle off
  const note = (V[id]&&V[id].note)||'';
  if(decision==='unset') delete V[id]; else V[id]={decision,note,ts:Date.now()};
  renderCounts();
  fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({id,decision,note})});
}
function setNote(id,note){
  const decision = (V[id]&&V[id].decision)||'skip';
  if(!V[id]) V[id]={decision,note,ts:Date.now()}; else V[id].note=note;
  fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({id,decision:V[id].decision,note})});
}

// ---- render dispatch ----
function render(){
  renderCounts();
  $('#queue').style.display = state.mode==='queue'?'block':'none';
  $('#board').style.display = state.mode==='board'?'block':'none';
  $('#densWrap').style.display = state.mode==='board'?'flex':'none';
  if(state.mode==='queue') renderQueue(); else renderBoard();
}

// ---- QUEUE ----
function renderQueue(){
  const list = filtered();
  if(!list.length){ $('#queue').innerHTML = `<div class="empty">Nothing matches these filters. 🎉</div>`; return; }
  if(qi>=list.length) qi=list.length-1; if(qi<0) qi=0;
  const it = list[qi];
  $('#queue').innerHTML = `
    <div class="qnav">
      <button class="btn ghost" id="prev">← Prev</button>
      <div>${qi+1} / ${list.length} &nbsp;·&nbsp; <span class="hint">keys <kbd>D</kbd><kbd>W</kbd><kbd>B</kbd><kbd>S</kbd> · <kbd>←</kbd><kbd>→</kbd></span></div>
      <button class="btn ghost" id="next">Next →</button>
    </div>
    ${cardHTML(it,true)}`;
  $('#prev').onclick=()=>{qi--;renderQueue();};
  $('#next').onclick=()=>{qi++;renderQueue();};
  wireCard(it, ()=>{ // after a verdict in queue: advance to next unreviewed-in-list
    const nl=filtered();
    if(state.status==='unreviewed'){ if(qi>=nl.length)qi=nl.length-1; renderQueue(); }
    else { qi++; renderQueue(); }
  });
}
function cardHTML(it, big){
  const v = V[it.id];
  const tag = v ? `<span class="verdict-tag vt-${v.decision}">${DEC[v.decision]}</span>` : '';
  const path = it.path ? `<div class="pathline">📁 <code>${esc(it.path)}</code>${it.port?` · port ${esc(it.port)}`:''}${it.hasUI?' · has UI':''}</div>` : '';
  const commit = it.commit ? ` · <code>${esc(it.commit)}</code>` : '';
  const val = it.value ? `<div class="value"><b>Value:</b> ${esc(it.value)}</div>` : '';
  const rec = it.rec ? `<div class="rec"><span class="v ${it.rec.conflict?'RETIRE':''}">${it.rec.conflict?('CONFLICT: '+it.rec.verdicts.join(' / ')):it.rec.verdict}</span> <b>${it.rec.count} officer${it.rec.count>1?'s':''}</b> (${esc(it.rec.officers.join(', '))}) · ${esc(it.rec.practice)} — ${esc(it.rec.why)}</div>` : '';
  return `
  <div class="card" data-id="${esc(it.id)}">
    ${previewHTML(it)}
    <div class="top">
      <span class="src ${it.source}">${SRC_LABEL[it.source]||it.source}</span>
      ${tag}
      <span class="when" title="${esc(it.date)}">🕓 ${esc(whenStr(it))}</span>
    </div>
    <div class="body">
      <h2>${esc(it.title)}</h2>
      <div class="meta">${esc(it.project||'')}${commit}</div>
      <div class="summary">${esc(it.summary||'')||'<span class="hint">no description captured</span>'}</div>
      ${val}${rec}${path}
    </div>
    <div class="verdict">
      <div class="vbtn dw ${v&&v.decision==='dw'?'on':''}" data-d="dw">→ DW <span class="k">D</span></div>
      <div class="vbtn wpb ${v&&v.decision==='wpb'?'on':''}" data-d="wpb">→ WPB <span class="k">W</span></div>
      <div class="vbtn both ${v&&v.decision==='both'?'on':''}" data-d="both">Both <span class="k">B</span></div>
      <div class="vbtn skip ${v&&v.decision==='skip'?'on':''}" data-d="skip">Skip <span class="k">S</span></div>
      <textarea class="note" placeholder="note (why it fits / how to adapt)…">${esc(v&&v.note||'')}</textarea>
    </div>
  </div>`;
}
function wireCard(it, after){
  const card = $(`.card[data-id="${cssEsc(it.id)}"]`);
  if(!card) return;
  $$('.vbtn',card).forEach(b=>b.onclick=()=>{ setVerdict(it.id,b.dataset.d); after&&after(); });
  const note=$('.note',card);
  if(note) note.onchange=()=>setNote(it.id, note.value);
}
function cssEsc(s){ return (window.CSS&&CSS.escape)?CSS.escape(s):s.replace(/["\\]/g,'\\$&'); }

// ---- BOARD ----
function renderBoard(){
  const list = filtered();
  const cols=[
    {k:'left',label:'Unreviewed',test:it=>!V[it.id]},
    {k:'dw',label:'→ DW',test:it=>V[it.id]&&V[it.id].decision==='dw'},
    {k:'wpb',label:'→ WPB',test:it=>V[it.id]&&V[it.id].decision==='wpb'},
    {k:'both',label:'Both',test:it=>V[it.id]&&V[it.id].decision==='both'},
    {k:'skip',label:'Skip',test:it=>V[it.id]&&V[it.id].decision==='skip'},
  ];
  $('#board').innerHTML = `<div class="board">`+cols.map(c=>{
    const its=list.filter(c.test);
    return `<div class="col ${c.k}" data-col="${c.k}">
      <h3>${c.label}<span>${its.length}</span></h3>
      ${its.map(miniHTML).join('')||'<div class="hint" style="padding:8px">—</div>'}
    </div>`;
  }).join('')+`</div>`;
  // drag
  $$('.mini').forEach(m=>{
    m.draggable=true;
    m.addEventListener('dragstart',e=>e.dataTransfer.setData('text/plain',m.dataset.id));
  });
  $$('.col').forEach(col=>{
    col.addEventListener('dragover',e=>{e.preventDefault();col.classList.add('drop');});
    col.addEventListener('dragleave',()=>col.classList.remove('drop'));
    col.addEventListener('drop',e=>{
      e.preventDefault();col.classList.remove('drop');
      const id=e.dataTransfer.getData('text/plain');
      const dec=col.dataset.col==='left'?'unset':col.dataset.col;
      setVerdict(id, dec==='unset'?'unset':dec);
      if(V[id]&&V[id].decision===dec){} // no-op guard
      // setVerdict toggles if same; for board force the target:
      const cur=V[id]&&V[id].decision;
      if(dec!=='unset' && cur!==dec){ V[id]={decision:dec,note:(V[id]&&V[id].note)||'',ts:Date.now()};
        fetch('/api/verdict',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,decision:dec,note:V[id].note})});
      } else if(dec==='unset'){ delete V[id]; }
      renderBoard();renderCounts();
    });
  });
  $('#board .board').style.gridTemplateColumns=`repeat(${state.cols},1fr)`;
}
function miniHTML(it){
  return `<div class="mini" data-id="${esc(it.id)}" title="${esc(it.title)}">
    ${previewHTML(it)}
    <div class="mt">${esc(it.title)}${it.rec?'<span class="recdot" title="officer pick"></span>':''}</div>
    <div class="ms"><span class="src ${it.source}" style="font-size:9px;padding:1px 5px">${(SRC_LABEL[it.source]||it.source)}</span> ${esc(it.project||'')}</div>
  </div>`;
}

// ---- controls wiring ----
$$('#mode button').forEach(b=>b.onclick=()=>{
  state.mode=b.dataset.mode; $$('#mode button').forEach(x=>x.classList.toggle('on',x===b)); render();
});
$('#fSource').onchange=e=>{state.source=e.target.value;qi=0;render();};
$('#fStatus').onchange=e=>{state.status=e.target.value;qi=0;render();};
$('#fProject').onchange=e=>{state.project=e.target.value;qi=0;render();};
$('#fSort').onchange=e=>{state.sort=e.target.value;qi=0;render();};
$('#fSearch').oninput=e=>{state.search=e.target.value;qi=0;render();};
$('#dens').value=state.cols;
$('#dens').oninput=e=>{state.cols=+e.target.value;localStorage.setItem('fh_cols',state.cols);renderBoard();};
$('#exportBtn').onclick=()=>window.open('/api/export','_blank');
$('#refreshBtn').onclick=async()=>{
  $('#refreshBtn').textContent='↻ …';
  const r=await fetch('/api/refresh',{method:'POST'}); const d=await r.json();
  $('#refreshBtn').textContent='↻ Refresh';
  await load();
  alert(d.ok?`Refreshed — ${d.count} items`:`Kept cached corpus (${d.count}) — ${d.error||'CNCP unreachable'}`);
};
// rebuild thumbnails (async job + progress polling)
const thumbsBtn = $('#thumbsBtn');
let thumbPoll = null;
function bustThumbCache(){ // force <img> reload after regen (same filenames)
  $$('.preview .thumb').forEach(im=>{ const u=im.getAttribute('src').split('?')[0]; im.src=u+'?t='+Date.now(); });
}
async function pollThumbs(){
  const r = await fetch('/api/thumbs/status'); const s = await r.json();
  if(s.running){
    thumbsBtn.textContent = `⟳ Rebuilding… ${s.done}/${s.total||'?'}`;
  } else {
    clearInterval(thumbPoll); thumbPoll=null;
    thumbsBtn.disabled=false; thumbsBtn.textContent='⟳ Rebuild thumbnails';
    await load();            // re-fetch items (picks up new thumb manifest)
    bustThumbCache();
    if(s.error) alert('Thumbnail rebuild failed: '+s.error);
  }
}
thumbsBtn.onclick=async()=>{
  if(thumbPoll) return;
  thumbsBtn.disabled=true; thumbsBtn.textContent='⟳ Starting…';
  await fetch('/api/thumbs',{method:'POST'});
  thumbPoll=setInterval(pollThumbs,1500); pollThumbs();
};
// keyboard (queue mode)
document.addEventListener('keydown',e=>{
  if(state.mode!=='queue') return;
  if(/input|textarea|select/i.test(e.target.tagName)) return;
  const list=filtered(); if(!list.length) return; const it=list[Math.min(qi,list.length-1)];
  const map={d:'dw',w:'wpb',b:'both',s:'skip',u:'unset'};
  if(map[e.key.toLowerCase()]){ e.preventDefault();
    setVerdict(it.id, map[e.key.toLowerCase()]==='unset'?'unset':map[e.key.toLowerCase()]);
    if(state.status==='unreviewed') renderQueue(); else {qi++;renderQueue();}
  } else if(e.key==='ArrowRight'){qi++;renderQueue();}
  else if(e.key==='ArrowLeft'){qi--;renderQueue();}
});
load();
</script>
</body>
</html>