← 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>