← back to Wallco Ai
public/admin/prompt-vs-image.html
295 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prompt vs Image — wallco.ai admin</title>
<style>
/* Reuse the design-curator dark token system so this surface matches the
existing admin design language. */
:root { --cols:2; --bg:#0e0f10; --panel:#17191b; --line:#272b2e; --ink:#e9ecef;
--mut:#9aa3ab; --accent:#7bc96f; --good:#2e7d32; --bad:#a13030; --gold:#b5891a; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background:var(--bg); color:var(--ink); }
header { position:sticky; top:0; z-index:20; background:rgba(14,15,16,.96); backdrop-filter:blur(8px);
border-bottom:1px solid var(--line); padding:10px 16px; }
.row { display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
h1 { font-size:16px; margin:0; font-weight:650; letter-spacing:.2px; }
h1 .leaf { color:var(--accent); }
.sub { color:var(--mut); font-size:12px; }
a.back { color:var(--mut); text-decoration:none; font-size:12px; }
a.back:hover { color:var(--ink); }
label.ctl { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--mut); }
select, input[type=range] { accent-color:var(--accent); }
select { background:var(--panel); color:var(--ink); border:1px solid var(--line); border-radius:7px; padding:5px 8px; font-size:13px; }
input[type=range] { width:120px; }
.pill { background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:3px 10px; font-size:12px; color:var(--mut); }
.pill b { color:var(--ink); }
/* A/B rows — each pair is one full row: IMAGE on the left, PROMPT on the
right, rating bar across the bottom. Density slider controls how many
pairs per row. */
.grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:16px; padding:16px; padding-bottom:140px; }
.pair { background:var(--panel); border:1px solid var(--line); border-radius:12px; overflow:hidden;
display:flex; flex-direction:column; transition:opacity .2s, box-shadow .15s; }
.pair.rated { box-shadow:inset 0 0 0 2px var(--accent); }
.pair.rejected { box-shadow:inset 0 0 0 2px var(--bad); }
.ab { display:grid; grid-template-columns:1fr 1fr; min-height:0; }
.side-img { position:relative; background:#000; aspect-ratio:1/1; }
.side-img img { width:100%; height:100%; object-fit:cover; display:block; -webkit-user-drag:none; }
.side-img .tag { position:absolute; top:8px; left:8px; background:rgba(0,0,0,.7); color:#fff;
font-size:10px; font-weight:700; letter-spacing:.08em; padding:3px 7px; border-radius:5px; }
.side-prompt { padding:12px 14px; display:flex; flex-direction:column; gap:8px; border-left:1px solid var(--line);
max-height:420px; overflow:auto; }
.side-prompt .tag { color:var(--mut); font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
.prompt-text { font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--ink);
white-space:pre-wrap; word-break:break-word; }
.meta-line { display:flex; flex-wrap:wrap; gap:6px; align-items:center; margin-top:2px; }
.chip { font-size:11px; color:var(--mut); background:#1d2023; border:1px solid var(--line); border-radius:6px; padding:2px 7px; }
.when { font-size:11px; color:var(--gold); } /* created date+time (standing rule) */
.bar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding:10px 14px; border-top:1px solid var(--line); background:#131517; }
.stars { display:inline-flex; gap:3px; }
.star { cursor:pointer; font-size:20px; line-height:1; color:#3a3f43; transition:color .08s; user-select:none; }
.star.on { color:var(--gold); }
.btn { cursor:pointer; border:1px solid var(--line); background:#1d2023; color:var(--ink);
border-radius:8px; padding:6px 13px; font-size:13px; font-weight:600; }
.btn:hover { border-color:var(--mut); }
.btn.approve.on { background:var(--good); border-color:var(--good); color:#fff; }
.btn.reject.on { background:var(--bad); border-color:var(--bad); color:#fff; }
.saved { font-size:11px; color:var(--accent); opacity:0; transition:opacity .2s; }
.saved.show { opacity:1; }
.empty { color:var(--mut); padding:60px 20px; text-align:center; }
@media (max-width:900px){ .grid{ --cols:1 !important; } }
</style>
</head>
<body>
<header>
<div class="row">
<h1><span class="leaf">◧</span> Prompt vs Image</h1>
<span class="sub">A/B review — does the rendered image match the generation prompt?</span>
<a class="back" href="/admin/design-curator">← Design Curator</a>
<span style="flex:1"></span>
<label class="ctl">Collection
<select id="cat">
<option value="">All</option>
</select>
</label>
<label class="ctl">Show
<select id="filter">
<option value="unrated">Unrated only</option>
<option value="all">All</option>
</select>
</label>
<label class="ctl">Sort
<select id="sort">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="category">Category A→Z</option>
<option value="id">ID ↑</option>
<option value="id_desc">ID ↓</option>
</select>
</label>
<label class="ctl">Density
<input type="range" id="density" min="1" max="4" step="1" value="2"
aria-label="Pairs per row"><span id="density-val" class="pill"><b>2</b></span>
</label>
<span class="pill">Rated <b id="rated-count">0</b></span>
<span class="pill">Showing <b id="show-count">0</b></span>
</div>
</header>
<div id="grid" class="grid"></div>
<div id="empty" class="empty" style="display:none">Nothing to review here. Switch the filter to “All” or pick another collection.</div>
<script>
(function(){
// Carry the admin query param through (admin-gate accepts ?admin=... or host).
function q(p){ var s = location.search || ''; if(!s) return p; return p + (p.indexOf('?')<0?'?':'&') + s.slice(1); }
var grid = document.getElementById('grid');
var emptyEl = document.getElementById('empty');
var catSel = document.getElementById('cat');
var filterSel = document.getElementById('filter');
var sortSel = document.getElementById('sort');
var density = document.getElementById('density');
var ITEMS = [];
var densityVal = document.getElementById('density-val');
var ratedCountEl = document.getElementById('rated-count');
var showCountEl = document.getElementById('show-count');
// ── Density + filter persistence (Steve standing rule: every grid persists
// its controls to localStorage).
var DKEY='wallco.pvi.density', FKEY='wallco.pvi.filter', CKEY='wallco.pvi.cat', SKEY='wallco.pvi.sort';
try {
var sd = localStorage.getItem(DKEY); if(sd){ density.value = sd; }
var sf = localStorage.getItem(FKEY); if(sf){ filterSel.value = sf; }
var ss = localStorage.getItem(SKEY); if(ss){ sortSel.value = ss; }
} catch(e){}
function applyDensity(){
var n = parseInt(density.value,10) || 2;
document.documentElement.style.setProperty('--cols', String(n));
densityVal.innerHTML = '<b>'+n+'</b>';
try { localStorage.setItem(DKEY, density.value); } catch(e){}
}
density.addEventListener('input', applyDensity);
applyDensity();
filterSel.addEventListener('change', function(){ try{localStorage.setItem(FKEY,this.value);}catch(e){} load(); });
catSel.addEventListener('change', function(){ try{localStorage.setItem(CKEY,this.value);}catch(e){} load(); });
// Sort is client-side over the already-loaded set — no refetch needed.
sortSel.addEventListener('change', function(){ try{localStorage.setItem(SKEY,this.value);}catch(e){} render(); });
function sortItems(rows){
var r = rows.slice(), m = sortSel.value;
if(m==='newest') r.sort(function(a,b){ return String(b.created_at||'').localeCompare(String(a.created_at||'')); });
else if(m==='oldest') r.sort(function(a,b){ return String(a.created_at||'').localeCompare(String(b.created_at||'')); });
else if(m==='category') r.sort(function(a,b){ return String(a.category||'').localeCompare(String(b.category||'')) || (b.id-a.id); });
else if(m==='id') r.sort(function(a,b){ return a.id-b.id; });
else if(m==='id_desc') r.sort(function(a,b){ return b.id-a.id; });
return r;
}
function render(){
if(!ITEMS.length){ grid.innerHTML=''; emptyEl.style.display='block'; ratedCountEl.textContent='0'; showCountEl.textContent='0'; return; }
emptyEl.style.display='none';
showCountEl.textContent = ITEMS.length;
grid.innerHTML = sortItems(ITEMS).map(card).join('');
markRated();
}
function fmtDate(iso){
if(!iso) return '';
try {
var d = new Date(iso);
return '🕓 ' + d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
} catch(e){ return ''; }
}
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c];}); }
async function loadCats(){
try {
var r = await fetch(q('/api/admin/curator/collections'));
if(!r.ok) return;
var j = await r.json();
var lines = (j.lines || j.items || j || []);
lines.forEach(function(l){
var name = (typeof l === 'string') ? l : (l.line || l.category || l.name);
if(!name) return;
var o = document.createElement('option'); o.value=name; o.textContent=name;
catSel.appendChild(o);
});
try { var sc = localStorage.getItem(CKEY); if(sc){ catSel.value = sc; } } catch(e){}
} catch(e){}
}
function ratingBar(d){
var cur = d.prompt_match || 0;
var stars = '';
for(var i=1;i<=5;i++){ stars += '<span class="star'+(i<=cur?' on':'')+'" data-v="'+i+'" title="'+i+' / 5 match">★</span>'; }
return ''
+ '<span class="sub">Match</span>'
+ '<span class="stars" data-id="'+d.id+'">'+stars+'</span>'
+ '<button class="btn approve'+(d.prompt_verdict==='approve'?' on':'')+'" data-act="approve" data-id="'+d.id+'">✓ Matches</button>'
+ '<button class="btn reject'+(d.prompt_verdict==='reject'?' on':'')+'" data-act="reject" data-id="'+d.id+'">✕ Off-prompt</button>'
+ '<span style="flex:1"></span>'
+ '<a class="back" href="'+d.view_url+'" target="_blank" rel="noopener">open ↗</a>'
+ '<span class="saved" data-saved="'+d.id+'">saved ✓</span>';
}
function card(d){
var cls = 'pair' + (d.prompt_verdict==='approve'||d.prompt_match?' rated':'') + (d.prompt_verdict==='reject'?' rejected':'');
return ''
+ '<div class="'+cls+'" data-id="'+d.id+'">'
+ '<div class="ab">'
+ '<div class="side-img"><span class="tag">IMAGE · #'+d.id+'</span>'
+ '<img loading="lazy" src="'+esc(d.image_url)+'" alt="design #'+d.id+'"></div>'
+ '<div class="side-prompt">'
+ '<span class="tag">Generation prompt</span>'
+ '<div class="prompt-text">'+esc(d.prompt)+'</div>'
+ '<div class="meta-line">'
+ '<span class="chip">'+esc(d.category||'—')+'</span>'
+ (d.generator?'<span class="chip">'+esc(d.generator)+'</span>':'')
+ (d.seed!=null?'<span class="chip">seed '+esc(d.seed)+'</span>':'')
+ '<span class="chip">'+(d.is_published?'published':'unpublished')+'</span>'
+ '</div>'
+ '<div class="meta-line"><span class="when" title="'+esc(d.created_at)+'">'+fmtDate(d.created_at)+'</span></div>'
+ '</div>'
+ '</div>'
+ '<div class="bar">'+ratingBar(d)+'</div>'
+ '</div>';
}
async function save(id, body){
var r = await fetch(q('/api/admin/prompt-rate/'+id), {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
});
if(!r.ok) throw new Error('save failed '+r.status);
return r.json();
}
function flashSaved(id){
var el = grid.querySelector('[data-saved="'+id+'"]');
if(!el) return; el.classList.add('show'); setTimeout(function(){ el.classList.remove('show'); }, 1100);
}
// Event delegation for stars + verdict buttons.
grid.addEventListener('click', async function(e){
var star = e.target.closest('.star');
if(star){
var wrap = star.closest('.stars'); var id = parseInt(wrap.getAttribute('data-id'),10);
var v = parseInt(star.getAttribute('data-v'),10);
// Click the same value again to clear.
var already = wrap.querySelectorAll('.star.on').length === v && star.classList.contains('on') && v===wrap.querySelectorAll('.star').length===false;
var stars = [].slice.call(wrap.querySelectorAll('.star'));
var currentOn = stars.filter(function(s){return s.classList.contains('on');}).length;
var newVal = (currentOn === v) ? null : v;
stars.forEach(function(s,i){ s.classList.toggle('on', newVal!=null && (i+1)<=newVal); });
try { await save(id, { match:newVal }); flashSaved(id); markRated(); }
catch(err){ alert(err.message); }
var pair = star.closest('.pair'); if(pair) pair.classList.toggle('rated', !!newVal);
return;
}
var btn = e.target.closest('.btn[data-act]');
if(btn){
var id2 = parseInt(btn.getAttribute('data-id'),10);
var act = btn.getAttribute('data-act');
var bar = btn.closest('.bar');
var ap = bar.querySelector('.btn.approve'), rj = bar.querySelector('.btn.reject');
var newV;
if(act==='approve'){ newV = ap.classList.contains('on') ? null : 'approve'; }
else { newV = rj.classList.contains('on') ? null : 'reject'; }
ap.classList.toggle('on', newV==='approve');
rj.classList.toggle('on', newV==='reject');
try { await save(id2, { verdict:newV }); flashSaved(id2); markRated(); }
catch(err){ alert(err.message); }
var pair2 = btn.closest('.pair');
if(pair2){ pair2.classList.toggle('rejected', newV==='reject'); pair2.classList.toggle('rated', newV==='approve'); }
return;
}
});
function markRated(){
var n = grid.querySelectorAll('.pair.rated, .pair.rejected').length;
ratedCountEl.textContent = n;
}
async function load(){
grid.innerHTML = '<div class="empty">Loading…</div>';
var params = new URLSearchParams();
if(catSel.value) params.set('category', catSel.value);
params.set('include', filterSel.value === 'unrated' ? 'unrated' : 'all');
params.set('limit','120');
try {
var r = await fetch(q('/api/admin/prompt-rate/list?'+params.toString()));
var j = await r.json();
ITEMS = j.items || [];
render();
} catch(e){
grid.innerHTML = '<div class="empty">Error: '+esc(e.message)+'</div>';
}
}
loadCats().then(load);
})();
</script>
</body>
</html>