← back to Wallco Ai

public/variant-curator.html

165 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Variant Curator — Wave-1 Drunk Animals</title>
<style>
  :root { --bg:#12100e; --panel:#1c1813; --line:#33291f; --ink:#f3ece1; --muted:#a08f78;
          --gold:#caa45a; --keep:#3fa34d; --keepbg:#15301a; }
  * { box-sizing: border-box; }
  body { margin:0; background:var(--bg); color:var(--ink);
         font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
  header { position:sticky; top:0; z-index:5; background:linear-gradient(180deg,#1c1813,#1c1813ee);
           border-bottom:1px solid var(--line); padding:14px 20px; display:flex; align-items:center; gap:16px; }
  header h1 { font-size:17px; margin:0; letter-spacing:.3px; font-weight:600; }
  header .sub { color:var(--muted); font-size:12px; }
  .live { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--muted); }
  .dot { width:8px; height:8px; border-radius:50%; background:var(--keep); box-shadow:0 0 8px var(--keep);
         animation:pulse 1.6s infinite; }
  @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
  .wrap { padding:18px 20px 60px; max-width:1500px; margin:0 auto; }
  .row { display:flex; gap:14px; align-items:flex-start; padding:14px 0; border-bottom:1px solid var(--line); }
  .root { flex:0 0 190px; }
  .root .rthumb { width:190px; height:190px; border-radius:8px; object-fit:cover;
                  border:2px solid var(--gold); background:#000; display:block; }
  .root .rmeta { margin-top:6px; }
  .root .rid { font-weight:600; color:var(--gold); font-size:13px; }
  .root .rcat { color:var(--muted); font-size:11px; }
  .root .rprompt { color:#cdbfa8; font-size:11px; margin-top:3px; max-height:46px; overflow:hidden; }
  .count { font-size:12px; color:var(--muted); margin-top:6px; }
  .count b { color:var(--ink); }
  .variants { flex:1; display:flex; flex-wrap:wrap; gap:12px; align-content:flex-start; }
  .empty { color:var(--muted); font-style:italic; padding:30px 6px; font-size:13px; }
  .card { width:150px; border:1px solid var(--line); border-radius:8px; overflow:hidden;
          background:var(--panel); display:flex; flex-direction:column; transition:border-color .15s; }
  .card.kept { border-color:var(--keep); box-shadow:0 0 0 2px var(--keep) inset; }
  .card img { width:150px; height:150px; object-fit:cover; background:#000; cursor:zoom-in; display:block; }
  .card .meta { padding:6px 8px; font-size:11px; }
  .vid { color:var(--muted); }
  .cov { color:#cdbfa8; }
  .when { color:var(--muted); display:block; margin-top:2px; font-size:10px; }
  .pickbtn { border:0; cursor:pointer; padding:7px 8px; font-size:12px; font-weight:600;
             color:#1a140c; background:var(--gold); text-align:center; }
  .pickbtn.kept { background:var(--keep); color:#fff; }
  .pickbtn:disabled { opacity:.5; cursor:wait; }
  .new { animation:flash 1.4s ease-out; }
  @keyframes flash { 0%{box-shadow:0 0 0 3px var(--gold)} 100%{box-shadow:0 0 0 0 transparent} }
  #lightbox { position:fixed; inset:0; background:#000d; display:none; align-items:center;
              justify-content:center; z-index:20; cursor:zoom-out; }
  #lightbox img { max-width:92vw; max-height:92vh; border-radius:6px; }
  .err { color:#e07a5f; font-size:12px; }
</style>
</head>
<body>
<header>
  <h1>🥃 Variant Curator</h1>
  <span class="sub">Wave-1 drunk-animal composition variants · multi-keep for variety · click <b>KEEP</b> to publish that variant</span>
  <span class="live" id="live"><span class="dot"></span> live · <span id="ts">—</span></span>
  <span class="err" id="err"></span>
</header>
<div class="wrap" id="wrap"><div class="empty">Loading…</div></div>
<div id="lightbox" onclick="this.style.display='none'"><img id="lbimg" alt=""></div>

<script>
const ROOT_THUMB = id => '/designs/img/by-id/' + id;
const seen = new Set();          // child ids already rendered (for "new" flash)
let firstPaint = true;

function fmtDate(iso){
  if(!iso) return '—';
  try{
    return new Date(iso).toLocaleString(undefined,
      { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
  }catch(e){ return iso; }
}

function lightbox(src){
  document.getElementById('lbimg').src = src;
  document.getElementById('lightbox').style.display = 'flex';
}

async function pick(btn, rootId, designId, keep){
  btn.disabled = true;
  try{
    const r = await fetch('/api/variant-curator/pick', {
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({ root_id:rootId, design_id:designId, keep })
    });
    const j = await r.json();
    if(!j.ok) throw new Error(j.error || 'pick failed');
    const card = btn.closest('.card');
    const kept = j.kept === true;
    card.classList.toggle('kept', kept);
    btn.classList.toggle('kept', kept);
    btn.textContent = kept ? '✓ KEPT' : 'KEEP';
    btn.onclick = () => pick(btn, rootId, designId, !kept);
  }catch(e){
    document.getElementById('err').textContent = 'pick error: ' + e.message;
  }finally{ btn.disabled = false; }
}

function render(data){
  const wrap = document.getElementById('wrap');
  document.getElementById('ts').textContent = fmtDate(data.generated_at);
  document.getElementById('err').textContent = '';
  const cap = data.cap || 10;
  wrap.innerHTML = '';
  for(const root of data.roots){
    const row = document.createElement('div'); row.className = 'row';

    const rootDiv = document.createElement('div'); rootDiv.className = 'root';
    rootDiv.innerHTML =
      '<img class="rthumb" src="'+ROOT_THUMB(root.root_id)+'" alt="root '+root.root_id+'" '+
        'onclick="lightbox(this.src)" style="cursor:zoom-in">'+
      '<div class="rmeta">'+
        '<span class="rid">root #'+root.root_id+'</span> '+
        '<span class="rcat">'+(root.category||'')+'</span>'+
        '<div class="rprompt" title="'+(root.prompt||'').replace(/"/g,'&quot;')+'">'+(root.prompt||'')+'</div>'+
        '<div class="count">(<b>'+root.variant_count+'</b>/'+cap+') variants</div>'+
      '</div>';
    row.appendChild(rootDiv);

    const vbox = document.createElement('div'); vbox.className = 'variants';
    if(!root.variants.length){
      vbox.innerHTML = '<div class="empty">no variants yet — generating…</div>';
    } else {
      for(const v of root.variants){
        const card = document.createElement('div');
        card.className = 'card' + (v.kept ? ' kept' : '') + ((!firstPaint && !seen.has(v.id)) ? ' new' : '');
        seen.add(v.id);
        const cov = (v.coverage_pct != null) ? (v.coverage_pct.toFixed ? v.coverage_pct.toFixed(1) : v.coverage_pct) + '% cov' : '';
        card.innerHTML =
          '<img src="'+v.image_url+'" alt="variant '+v.id+'" onclick="lightbox(this.src)">'+
          '<div class="meta">'+
            '<span class="vid">#'+v.id+'</span>'+(cov? ' · <span class="cov">'+cov+'</span>':'')+
            '<span class="when" title="'+(v.created_at||'')+'">🕓 '+fmtDate(v.created_at)+'</span>'+
          '</div>';
        const btn = document.createElement('button');
        btn.className = 'pickbtn' + (v.kept ? ' kept' : '');
        btn.textContent = v.kept ? '✓ KEPT' : 'KEEP';
        btn.onclick = () => pick(btn, root.root_id, v.id, !v.kept);
        card.appendChild(btn);
        vbox.appendChild(card);
      }
    }
    row.appendChild(vbox);
    wrap.appendChild(row);
  }
  firstPaint = false;
}

async function poll(){
  try{
    const r = await fetch('/api/variant-curator', { cache:'no-store' });
    const j = await r.json();
    if(j.ok) render(j);
    else document.getElementById('err').textContent = j.error || 'load failed';
  }catch(e){ document.getElementById('err').textContent = 'poll error: ' + e.message; }
}
poll();
setInterval(poll, 5000);
</script>
</body>
</html>