← back to Wallco Ai

public/admin/bleed-review.html

447 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Bleed Review — wallco.ai admin</title>
<style>
  :root {
    --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
    --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
    --green:#1f6a2c; --red:#b3261e; --blood:#7a1818; --grey:#888;
    --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
    --serif:'Playfair Display','Didot',Georgia,serif;
  }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
  header {
    padding:12px 22px; border-bottom:1px solid var(--line); background:#fff;
    display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:5;
  }
  header h1 { font:400 22px/1.2 var(--serif); margin:0; }
  header h1 .drop { color:var(--blood); }
  header .meta { color:var(--faint); font-size:12px; letter-spacing:.04em; }
  header .stats { margin-left:auto; display:flex; gap:16px; font:600 12px ui-monospace,Menlo,monospace; }
  header .stats b { color:var(--ink); }
  header .stats .block b { color:var(--red); }
  header .stats .regen b { color:var(--gold); }
  header .stats .ok    b { color:var(--green); }

  main { max-width:1500px; margin:0 auto; padding:14px 18px 100px; }

  .controls {
    display:flex; gap:14px; align-items:center; padding:9px 14px;
    background:#fff; border:1px solid var(--line); border-radius:8px;
    margin-bottom:12px; flex-wrap:wrap; font-size:12px;
  }
  .controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
                    text-transform:uppercase; color:var(--faint); }
  .controls select { padding:5px 9px; border:1px solid var(--line);
                     border-radius:4px; background:#fff; font:12px var(--sans); }
  .controls input[type=range] { vertical-align:middle; width:160px; }
  .controls .density-val { font:600 11px ui-monospace,Menlo,monospace;
                           color:var(--faint); min-width:32px; display:inline-block; }
  .controls .info { margin-left:auto; font:11px var(--sans); color:var(--faint); }

  .ctl-btn {
    padding:5px 11px; border:1px solid var(--line); border-radius:4px;
    background:#fff; color:var(--ink); font:600 11.5px var(--sans); cursor:pointer;
  }
  .ctl-btn:hover { border-color:var(--gold); }

  .grid {
    display:grid;
    grid-template-columns:repeat(auto-fill,minmax(var(--card-min,200px),1fr));
    gap:10px;
  }
  .tile {
    position:relative; aspect-ratio:1; background:#f3eee2;
    border-radius:6px; overflow:hidden;
    cursor:pointer; transition:transform .12s, box-shadow .12s;
    outline:2px solid var(--blood); outline-offset:-2px;
  }
  .tile.v-regen { outline-color:var(--gold); }
  .tile.v-ok    { outline-color:var(--green); }
  .tile.v-other { outline-color:var(--grey); }
  .tile.selected { outline:3px solid var(--ink); outline-offset:-3px; }
  .tile img { width:100%; height:100%; object-fit:cover; display:block; }
  .tile:hover { transform:translateY(-2px); box-shadow:0 6px 16px rgba(0,0,0,.18); }

  .badge {
    position:absolute; top:6px; left:34px;
    background:var(--blood); color:#fff;
    font:700 9px var(--sans); letter-spacing:.16em; text-transform:uppercase;
    padding:3px 7px; border-radius:3px;
    box-shadow:0 1px 3px rgba(0,0,0,.25);
  }
  .tile.v-regen .badge { background:var(--gold); color:#1f1808; }
  .tile.v-ok    .badge { background:var(--green); }
  .tile.v-other .badge { background:var(--grey); }

  .id-tag {
    position:absolute; top:6px; right:6px;
    background:rgba(20,15,5,.78); color:#fff;
    font:600 10px ui-monospace,Menlo,monospace;
    padding:2px 6px; border-radius:3px;
  }
  .when {
    position:absolute; bottom:6px; left:6px;
    background:rgba(20,15,5,.78); color:#f3eee2;
    font:11px ui-monospace,Menlo,monospace;
    padding:2px 7px; border-radius:3px; letter-spacing:.02em;
  }
  .sel {
    position:absolute; top:6px; left:6px; z-index:3;
    width:22px; height:22px; cursor:pointer;
    accent-color:var(--gold);
  }
  .thumb { display:block; width:100%; height:100%; }

  /* Hover tooltip — settlement_verdict + bleed_reason */
  .tooltip {
    position:absolute; bottom:6px; right:6px; max-width:80%;
    background:rgba(20,15,5,.92); color:#f3eee2;
    font:10.5px var(--sans); letter-spacing:.02em;
    padding:5px 8px; border-radius:4px; line-height:1.35;
    opacity:0; pointer-events:none; transition:opacity .15s;
  }
  .tile:hover .tooltip { opacity:1; }

  .pager {
    display:flex; gap:10px; justify-content:center; align-items:center;
    margin-top:18px; font:12px var(--sans);
  }
  .pager button { padding:6px 12px; border:1px solid var(--line); border-radius:4px;
                  background:#fff; cursor:pointer; font:600 12px var(--sans); }
  .pager button:disabled { opacity:.35; cursor:not-allowed; }
  .pager .info { color:var(--faint); }

  .empty {
    text-align:center; padding:60px 20px; color:var(--faint);
    font:14px var(--sans);
  }
  .loading { padding:30px; text-align:center; color:var(--faint); }

  /* Bulk action bar */
  .bulkbar {
    position:fixed; left:50%; transform:translateX(-50%); bottom:18px; z-index:20;
    display:flex; gap:10px; align-items:center;
    background:var(--ink); color:#fff; padding:10px 16px; border-radius:10px;
    box-shadow:0 8px 28px rgba(0,0,0,.35); font:13px var(--sans);
  }
  .bulkbar[hidden] { display:none; }
  .bulkbar b { color:var(--gold); }
  .bulkbar button {
    padding:7px 13px; border:0; border-radius:6px; cursor:pointer;
    font:600 12px var(--sans);
  }
  .bulkbar .b-remove  { background:var(--red); color:#fff; }
  .bulkbar .b-publish { background:var(--gold); color:#1f1808; }
  .bulkbar .b-clear   { background:transparent; color:#bbb; border:1px solid #555; }
  .bulkbar button:disabled { opacity:.4; cursor:not-allowed; }
  .bulkbar .warn { color:#ffd68a; font:600 11.5px var(--sans); }
</style>
</head>
<body>
<header>
  <h1><span class="drop">●</span> Bleed Review</h1>
  <span class="meta">unpublished designs flagged BLEED_GHOST by bleed_guard (ghost/soft-edge quality)</span>
  <div class="stats" id="stats">
    <span class="block">BLOCK <b>—</b></span>
    <span class="regen">REGEN <b>—</b></span>
    <span class="ok">OK-notes <b>—</b></span>
    <span>total <b id="total-count">—</b></span>
  </div>
</header>

<main>
  <div class="controls">
    <label for="sort">Sort</label>
    <select id="sort">
      <option value="id_desc">Newest id</option>
      <option value="id_asc">Oldest id</option>
      <option value="created_desc">Newest created_at</option>
      <option value="created_asc">Oldest created_at</option>
    </select>

    <label for="vfilter">Verdict</label>
    <select id="vfilter">
      <option value="all">All BLEED_GHOST</option>
      <option value="BLEED_GHOST_BLOCK">BLOCK only</option>
      <option value="BLEED_GHOST_REGEN">REGEN only</option>
      <option value="BLEED_GHOST_OK">OK-with-notes only</option>
      <option value="other">Other / null verdict</option>
    </select>

    <label for="density">Density</label>
    <input id="density" type="range" min="120" max="380" step="20" value="200">
    <span class="density-val" id="density-val">200</span>

    <button class="ctl-btn" id="select-page" title="Select every card on this page">☑ Select page</button>

    <div class="info">200 per page · click image → PDP · settlement-trigger NOT firing on BLEED_GHOST (quality only)</div>
  </div>

  <div id="grid" class="grid"><div class="loading">loading bleed cohort…</div></div>
  <div id="pager" class="pager"></div>
</main>

<div class="bulkbar" id="bulkbar" hidden>
  <span><b id="sel-count">0</b> selected</span>
  <button class="b-remove"  id="b-remove">Remove (quarantine)</button>
  <button class="b-publish" id="b-publish">Publish anyway</button>
  <button class="b-clear"   id="b-clear">Clear</button>
  <span class="warn" id="warn-prod" hidden>⚠ writes apply to local PG only</span>
</div>

<script>
(function(){
  const PER = 200;
  const state = {
    sort:    localStorage.getItem('bleed-review-sort')    || 'id_desc',
    vfilter: localStorage.getItem('bleed-review-vfilter') || 'all',
    density: parseInt(localStorage.getItem('bleed-review-density') || '200', 10),
    offset:  0,
    items:   [],
    total:   0,
  };
  const selected = new Set();

  // Date+time chip per CLAUDE.md "Every admin card MUST show created date+time".
  function fmtDate(iso) {
    if (!iso) return '';
    const d = new Date(iso);
    if (isNaN(d)) return '';
    return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
  }

  function applyDensity(px) {
    document.documentElement.style.setProperty('--card-min', px + 'px');
    document.getElementById('density-val').textContent = px;
  }

  function verdictClass(v) {
    if (v === 'BLEED_GHOST_BLOCK') return 'v-block';
    if (v === 'BLEED_GHOST_REGEN') return 'v-regen';
    if (v === 'BLEED_GHOST_OK')    return 'v-ok';
    return 'v-other';
  }
  function verdictLabel(v) {
    if (v === 'BLEED_GHOST_BLOCK') return 'BLOCK';
    if (v === 'BLEED_GHOST_REGEN') return 'REGEN';
    if (v === 'BLEED_GHOST_OK')    return 'OK';
    return v || 'NOTES';
  }

  function render() {
    const grid = document.getElementById('grid');
    let items = state.items.slice();
    // Client-side verdict filter (server returns the whole BLEED_GHOST set per page).
    if (state.vfilter !== 'all') {
      items = items.filter(it => {
        if (state.vfilter === 'other') {
          return !it.settlement_verdict || !String(it.settlement_verdict).startsWith('BLEED_GHOST_');
        }
        return it.settlement_verdict === state.vfilter;
      });
    }

    // Header stat tally (over current page only — total is server-side).
    const tally = { block:0, regen:0, ok:0 };
    state.items.forEach(it => {
      const v = it.settlement_verdict;
      if (v === 'BLEED_GHOST_BLOCK') tally.block++;
      else if (v === 'BLEED_GHOST_REGEN') tally.regen++;
      else if (v === 'BLEED_GHOST_OK') tally.ok++;
    });
    document.querySelector('.stats .block b').textContent = tally.block;
    document.querySelector('.stats .regen b').textContent = tally.regen;
    document.querySelector('.stats .ok b').textContent    = tally.ok;
    document.getElementById('total-count').textContent    = state.total.toLocaleString();

    if (items.length === 0) {
      grid.innerHTML = '<div class="empty">no bleed-flagged designs match this filter</div>';
      return;
    }

    grid.innerHTML = items.map(item => {
      const v = item.settlement_verdict || '';
      const vc = verdictClass(v);
      const vl = verdictLabel(v);
      const isSel = selected.has(item.id);
      const reason = (item.bleed_reason || '').trim();
      const tooltip = reason
        ? `${vl} — ${reason}`
        : (v ? vl + ' (no notes detail)' : 'BLEED_GHOST flagged via notes');
      const when = fmtDate(item.created_at);
      const whenChip = when ? `<span class="when" title="${item.created_at}">🕓 ${when}</span>` : '';
      return `
        <div class="tile ${vc}${isSel ? ' selected' : ''}" data-id="${item.id}" title="#${item.id} — ${tooltip}">
          <input type="checkbox" class="sel" data-id="${item.id}"${isSel ? ' checked' : ''} aria-label="select #${item.id}">
          <a class="thumb" href="/design/${item.id}" target="_blank" rel="noopener">
            <img src="/designs/img/by-id/${item.id}" loading="lazy" alt="">
          </a>
          <span class="badge">${vl}</span>
          <span class="id-tag">#${item.id}</span>
          ${whenChip}
          <div class="tooltip">${tooltip}</div>
        </div>`;
    }).join('');

    // Wire selection
    grid.querySelectorAll('.sel').forEach(box => {
      box.addEventListener('click', (e) => {
        e.stopPropagation();
        const id = parseInt(box.dataset.id, 10);
        if (box.checked) selected.add(id); else selected.delete(id);
        updateBulkbar();
        const tile = box.closest('.tile');
        if (tile) tile.classList.toggle('selected', box.checked);
      });
    });
  }

  function updateBulkbar() {
    const bar = document.getElementById('bulkbar');
    document.getElementById('sel-count').textContent = selected.size;
    bar.hidden = selected.size === 0;
  }

  function renderPager() {
    const pager = document.getElementById('pager');
    const start = state.offset + 1;
    const end = Math.min(state.offset + state.items.length, state.total);
    const pages = Math.max(1, Math.ceil(state.total / PER));
    const cur = Math.floor(state.offset / PER) + 1;
    pager.innerHTML = `
      <button id="prev" ${state.offset === 0 ? 'disabled' : ''}>← Prev</button>
      <span class="info">page <b>${cur}</b> / ${pages} · showing ${start.toLocaleString()}–${end.toLocaleString()} of <b>${state.total.toLocaleString()}</b></span>
      <button id="next" ${end >= state.total ? 'disabled' : ''}>Next →</button>
    `;
    document.getElementById('prev').addEventListener('click', () => { state.offset = Math.max(0, state.offset - PER); load(); });
    document.getElementById('next').addEventListener('click', () => { state.offset += PER; load(); });
  }

  function load() {
    const grid = document.getElementById('grid');
    grid.innerHTML = '<div class="loading">loading bleed cohort…</div>';
    const qs = new URLSearchParams({ limit: PER, offset: state.offset, sort: state.sort });
    fetch('/api/admin/bleed-review/list?' + qs.toString(), { credentials:'same-origin' })
      .then(r => r.json())
      .then(d => {
        if (!d || d.ok === false) {
          grid.innerHTML = `<div class="empty">load failed: ${(d && d.error) || 'unknown'}</div>`;
          return;
        }
        state.items = d.items || [];
        state.total = d.total || 0;
        render();
        renderPager();
        // Detect localhost vs prod for the bulkbar warning
        const isLocal = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
        document.getElementById('warn-prod').hidden = !isLocal;
        if (isLocal) document.getElementById('warn-prod').textContent = '⚠ local PG only — Mac2 + prod still gated';
      })
      .catch(err => {
        grid.innerHTML = `<div class="empty">fetch failed: ${err.message}</div>`;
      });
  }

  // Bulk actions — both routed through /api/cactus-decision/bulk
  // ('bad' = soft-delete + quarantine PNG; 'live' force:true = publish anyway).
  // BLEED_GHOST is a QUALITY family — settlement_publish_check() trigger does
  // NOT fire on these rows (it only blocks 'BLOCK' / 'REVIEW-VISION-ERROR%'),
  // so force:true is sufficient to flip is_published=TRUE.
  async function bulkAction(action, label, confirmText) {
    const ids = Array.from(selected);
    if (!ids.length) return;
    if (!confirm(`${confirmText}\n\n${ids.length} design(s):\n  ${ids.slice(0, 8).map(i => '#' + i).join(', ')}${ids.length > 8 ? ', …' : ''}\n\nProceed?`)) return;
    const btns = document.querySelectorAll('.bulkbar button');
    btns.forEach(b => b.disabled = true);
    try {
      const body = action === 'live' ? { action:'live', ids, force:true } : { action, ids };
      const r = await fetch('/api/cactus-decision/bulk', {
        method:'POST',
        headers:{ 'Content-Type':'application/json' },
        body: JSON.stringify(body),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok || !j.ok) {
        alert(`${label} failed: ${j.error || ('HTTP ' + r.status)}`);
        return;
      }
      // Optimistically remove the acted-upon ids from the current page
      const actedIds = new Set(ids);
      state.items = state.items.filter(it => !actedIds.has(it.id));
      state.total = Math.max(0, state.total - actedIds.size);
      selected.clear();
      updateBulkbar();
      render();
      renderPager();
      const heldNote = (action === 'live' && j.gate_held)
        ? `\n\n${j.gate_held} row(s) held by a non-BLEED gate — re-check those individually.` : '';
      alert(`✓ ${label}: ${j.applied || j.total} applied, ${j.errored || 0} errored.${heldNote}`);
    } catch (e) {
      alert(`${label} error: ${e.message || e}`);
    } finally {
      btns.forEach(b => b.disabled = false);
    }
  }

  // Wire controls
  const sortSel = document.getElementById('sort');
  sortSel.value = state.sort;
  sortSel.addEventListener('change', () => {
    state.sort = sortSel.value;
    localStorage.setItem('bleed-review-sort', state.sort);
    state.offset = 0;
    load();
  });
  const vSel = document.getElementById('vfilter');
  vSel.value = state.vfilter;
  vSel.addEventListener('change', () => {
    state.vfilter = vSel.value;
    localStorage.setItem('bleed-review-vfilter', state.vfilter);
    render();
  });
  const dSlider = document.getElementById('density');
  dSlider.value = state.density;
  applyDensity(state.density);
  dSlider.addEventListener('input', () => {
    state.density = parseInt(dSlider.value, 10);
    localStorage.setItem('bleed-review-density', String(state.density));
    applyDensity(state.density);
  });
  document.getElementById('select-page').addEventListener('click', () => {
    document.querySelectorAll('.tile').forEach(t => {
      const id = parseInt(t.dataset.id, 10);
      if (!Number.isFinite(id)) return;
      selected.add(id);
      t.classList.add('selected');
      const box = t.querySelector('.sel');
      if (box) box.checked = true;
    });
    updateBulkbar();
  });
  document.getElementById('b-clear').addEventListener('click', () => {
    selected.clear();
    document.querySelectorAll('.tile').forEach(t => t.classList.remove('selected'));
    document.querySelectorAll('.sel').forEach(b => b.checked = false);
    updateBulkbar();
  });
  document.getElementById('b-remove').addEventListener('click', () =>
    bulkAction('bad', 'Removed',
      '⚠ Soft-delete (user_removed=TRUE) + quarantine PNG. Reversible via DB UPDATE.'));
  document.getElementById('b-publish').addEventListener('click', () =>
    bulkAction('live', 'Published',
      '⚠ FORCE-PUBLISH bypasses the bleed-quality gate. BLEED_GHOST is a quality flag — these designs may have ghost layers or soft edges. Only do this for designs you have visually inspected.'));

  // Boot
  load();
})();
</script>
  <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>