← 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{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>