← 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,'"')+'">'+(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>