← back to Wallco Ai
public/admin/edges-review.html
672 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>Edges Review — wallco.ai admin</title>
<style>
:root {
--ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
--bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
--green:#1f6a2c; --red:#b3261e; --orange:#c0660b; --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 .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 span b { color:var(--ink); }
header .stats .mids b { color:var(--orange); }
header .stats .edges b { color:var(--red); }
header .stats .both b { color:#7a1818; }
header .stats .err b { color:var(--grey); }
main { max-width:1500px; margin:0 auto; padding:14px 18px 80px; }
.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 .legend { margin-left:auto; font:11px var(--sans); color:var(--faint); }
.controls .legend .sw {
display:inline-block; width:10px; height:10px; vertical-align:-1px;
margin-right:4px; border-radius:2px;
}
.controls .legend .sw.mids { background:var(--orange); }
.controls .legend .sw.edges { background:var(--red); }
.controls .legend .sw.both { background:#7a1818; }
.controls .legend .sw.err { background:var(--grey); }
.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(--red); outline-offset:-2px;
}
.tile.grid-mids { outline-color:var(--orange); }
.tile.grid-edges { outline-color:var(--red); }
.tile.grid-both { outline-color:#7a1818; }
.tile.grid-err { outline-color:var(--grey); }
.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(--red); 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.grid-mids .badge { background:var(--orange); }
.tile.grid-both .badge { background:#7a1818; }
.tile.grid-err .badge { background:var(--grey); }
.badge.st-removed { background:#444 !important; } /* deleted */
.badge.st-active { background:var(--green) !important; } /* still published */
.id-tag {
position:absolute; top:6px; right:6px;
background:rgba(0,0,0,.65); color:#fff;
font:600 10px ui-monospace,Menlo,monospace;
padding:2px 6px; border-radius:3px;
}
/* Hover tooltip — shows lens scores */
.tooltip {
position:absolute; bottom:30px; left:6px; right:6px;
background:rgba(20,12,4,.92); color:#fff;
font:11px ui-monospace,Menlo,monospace;
padding:6px 8px; border-radius:4px;
opacity:0; pointer-events:none; transition:opacity .14s;
z-index:2; line-height:1.4;
}
.tooltip .why { color:#ffd68a; font-weight:700; margin-bottom:4px; padding-bottom:3px;
border-bottom:1px solid rgba(255,255,255,.18); white-space:normal; line-height:1.3; }
.tooltip .row { display:flex; justify-content:space-between; }
.tooltip .row .k { color:#c9a14b; }
.tooltip .row .v { color:#fff; }
.tooltip .row .v.fail { color:#ff8a7a; font-weight:700; }
.tooltip .row .v.warn { color:#ffd68a; }
.tile:hover .tooltip { opacity:1; }
.empty {
text-align:center; padding:60px 20px; color:var(--faint);
font:14px var(--sans);
}
.loading { text-align:center; padding:40px; color:var(--faint); }
/* Pagination */
.pager { margin-top:18px; display:flex; gap:6px; justify-content:center;
flex-wrap:wrap; font:12px var(--sans); }
.pager a, .pager span {
padding:5px 10px; border:1px solid var(--line); border-radius:4px;
color:var(--ink); text-decoration:none; background:#fff;
}
.pager a:hover { background:#f3eee2; }
.pager .active { background:var(--ink); color:#fff; border-color:var(--ink); }
/* Source tabs (Live DB vs shipped Snapshot) */
.tabs { display:flex; gap:6px; margin:0 0 12px; flex-wrap:wrap; }
.tabs button {
padding:7px 14px; border:1px solid var(--line); border-radius:999px;
background:#fff; color:var(--faint); font:600 12px var(--sans);
cursor:pointer; transition:all .12s; letter-spacing:.02em;
}
.tabs button:hover { border-color:var(--gold); color:var(--ink); }
.tabs button.active { background:var(--ink); color:#fff; border-color:var(--ink); }
.tabs button .n { font:600 11px ui-monospace,Menlo,monospace; opacity:.7; margin-left:6px; }
.tabs button.active .n { opacity:.85; }
/* Created date+time chip (admin-card rule) */
.when {
position:absolute; bottom:6px; left:6px; right:6px;
background:rgba(0,0,0,.62); color:#fff;
font:600 9.5px ui-monospace,Menlo,monospace;
padding:2px 6px; border-radius:3px;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
/* Multi-select */
.tile .sel {
position:absolute; top:6px; left:6px; z-index:3;
width:22px; height:22px; margin:0; cursor:pointer;
accent-color:var(--gold);
}
.tile.selected { outline-width:4px; box-shadow:0 0 0 3px var(--gold) inset; }
.tile.selected::after {
content:'✓'; position:absolute; inset:0; z-index:1;
display:flex; align-items:center; justify-content:center;
font:700 48px var(--sans); color:rgba(201,161,75,.85);
text-shadow:0 2px 8px rgba(0,0,0,.5); pointer-events:none;
}
.thumb { display:block; width:100%; height:100%; }
.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); }
/* Per-card action bar (Fix / Redo) — hover-revealed, sits above the tooltip */
.tile .acts {
position:absolute; left:0; right:0; bottom:0; z-index:4;
display:flex; gap:4px; justify-content:center; padding:6px 5px;
background:linear-gradient(transparent, rgba(0,0,0,.85) 45%);
opacity:0; transition:opacity .12s; pointer-events:none;
}
.tile:hover .acts { opacity:1; pointer-events:auto; }
.tile .acts button, .tile .acts a {
font:700 10px var(--sans); letter-spacing:.05em; text-transform:uppercase;
padding:4px 9px; border-radius:4px; border:0; cursor:pointer; text-decoration:none;
line-height:1.2;
}
.tile .acts .a-fix { background:#143b1d; color:#9be7a0; }
.tile .acts .a-redo { background:#1d2c3b; color:#7ec8ff; }
.tile .acts button:hover, .tile .acts a:hover { filter:brightness(1.3); }
.tile .acts button:disabled { opacity:.5; cursor:wait; }
/* Toast */
#toast {
position:fixed; bottom:74px; left:50%; transform:translateX(-50%);
background:var(--ink); color:#fff; padding:9px 16px; border-radius:8px;
font:13px var(--sans); z-index:50; box-shadow:0 6px 20px rgba(0,0,0,.35);
opacity:0; transition:opacity .2s; pointer-events:none; max-width:80vw;
}
#toast.show { opacity:1; }
.bulkbar .b-fix { background:#143b1d; color:#9be7a0; }
/* 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-reject { background:#3a3322; color:#f0e6cf; }
.bulkbar .b-delete { background:var(--red); color:#fff; }
.bulkbar .b-clear { background:transparent; color:#bbb; border:1px solid #555; }
.bulkbar button:disabled { opacity:.4; cursor:not-allowed; }
.bulkbar .prod-note { color:#ffd68a; font:600 11.5px var(--sans); }
</style>
</head>
<body>
<header>
<h1>Edges Review</h1>
<span class="meta">designs unpublished because the 6-lens seamless scan failed</span>
<div class="stats" id="stats">
<span class="mids">mids-only <b>—</b></span>
<span class="edges">edges-only <b>—</b></span>
<span class="both">both <b>—</b></span>
<span class="err">scan-err <b>—</b></span>
<span>total <b id="total-count">—</b></span>
</div>
</header>
<main>
<div class="tabs" id="tabs">
<button data-src="live" title="spoon_all_designs rows on this server (near-empty on prod)">Live DB<span class="n" id="n-live">—</span></button>
<button data-src="snapshot" title="flagged designs in the shipped data/designs.json (what prod actually has)">Snapshot (shipped to prod)<span class="n" id="n-snapshot">—</span></button>
<button data-src="aesthetic" title="designs the load-time aesthetic gate HIDES from the public catalog (neon/fluoro/rainbow or >3 hues) — gate behavior unchanged, this is audit-only">Aesthetic-gated (hidden)<span class="n" id="n-aesthetic">—</span></button>
</div>
<div class="controls">
<label for="sort">Sort</label>
<select id="sort">
<option value="max_desc">Worst ΔE first</option>
<option value="max_asc">Best ΔE first</option>
<option value="id_desc">Newest id</option>
<option value="id_asc">Oldest id</option>
<option value="grid">By defect type</option>
<option value="removed_first">Status: removed first</option>
<option value="active_first">Status: active first</option>
</select>
<label for="filter">Defect</label>
<select id="filter">
<option value="all">All defects</option>
<option value="mids_only">SDXL grid-leak (mids only)</option>
<option value="edges_only">Edge-wrap defect</option>
<option value="both">Catastrophic (both)</option>
<option value="err">Scan error / missing file</option>
</select>
<label for="status-filter">Status</label>
<select id="status-filter">
<option value="all">All statuses</option>
<option value="active">Active (published)</option>
<option value="inactive">Not active (flagged)</option>
<option value="removed">Removed</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="legend">
<span><span class="sw mids"></span>mids-only</span>
<span><span class="sw edges"></span>edges-only</span>
<span><span class="sw both"></span>both</span>
<span><span class="sw err"></span>scan-err</span>
</div>
</div>
<div id="grid" class="grid"><div class="loading">loading flagged designs…</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-fix" id="b-fix" title="Seam-heal each selected in place (fix-seam.py — preserves composition, reversible)">⚕ Fix selected</button>
<button class="b-reject" id="b-reject">Reject (keep unpublished)</button>
<button class="b-delete" id="b-delete">Delete (quarantine)</button>
<button class="b-clear" id="b-clear">Clear</button>
<span class="prod-note" id="prod-note" hidden>⚠ read-only on prod — curate on localhost:9905</span>
</div>
<div id="toast"></div>
<script>
(function(){
const PER = 200;
const state = {
sort: localStorage.getItem('edges-review-sort') || 'max_desc',
filter: localStorage.getItem('edges-review-filter') || 'all',
status: localStorage.getItem('edges-review-status') || 'all',
density: parseInt(localStorage.getItem('edges-review-density') || '200', 10),
source: localStorage.getItem('edges-review-source') || 'snapshot',
page: 1,
items: [],
cache: { live: null, snapshot: null, aesthetic: null }, // per-source item cache
};
const ENDPOINT = {
live: '/api/admin/edges-review/flagged',
snapshot: '/api/admin/edges-review/snapshot-flagged',
aesthetic: '/api/admin/edges-review/aesthetic-gated',
};
// Created date+time chip (admin-card standing rule). Local tz, date AND 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' });
}
const selected = new Set(); // selected design ids (survives re-render/pagination)
function classifyGrid(item) {
if (!item.ok) return 'err';
const e = item.edges_max || 0, m = item.mids_max || 0;
if (e > 12 && m > 12) return 'both';
if (m > 12) return 'mids';
if (e > 12) return 'edges';
return 'err';
}
function applyDensity(px) {
document.documentElement.style.setProperty('--card-min', px + 'px');
document.getElementById('density-val').textContent = px;
}
function render() {
const grid = document.getElementById('grid');
const items = state.items.slice();
// Filter
const filtered = items.filter(it => {
// Status gate (active/published · not-active/flagged · removed)
if (state.status === 'active' && !it.published) return false;
if (state.status === 'inactive' && (it.published || it.removed)) return false;
if (state.status === 'removed' && !it.removed) return false;
// Defect gate
if (state.filter === 'all') return true;
const g = classifyGrid(it);
return (state.filter === 'mids_only' && g === 'mids')
|| (state.filter === 'edges_only' && g === 'edges')
|| (state.filter === 'both' && g === 'both')
|| (state.filter === 'err' && g === 'err');
});
// Sort
filtered.sort((a, b) => {
if (state.sort === 'max_desc') return (b.max_de || 0) - (a.max_de || 0);
if (state.sort === 'max_asc') return (a.max_de || 0) - (b.max_de || 0);
if (state.sort === 'id_desc') return b.id - a.id;
if (state.sort === 'id_asc') return a.id - b.id;
if (state.sort === 'grid') {
const order = { both:0, edges:1, mids:2, err:3 };
return (order[classifyGrid(a)] || 9) - (order[classifyGrid(b)] || 9);
}
// Status rank: removed (2) > unpublished/flagged (1) > active/published (0)
const rank = it => it.removed ? 2 : (it.published ? 0 : 1);
if (state.sort === 'removed_first') return rank(b) - rank(a) || b.id - a.id;
if (state.sort === 'active_first') return rank(a) - rank(b) || b.id - a.id;
return 0;
});
// Update stats
const stats = { mids:0, edges:0, both:0, err:0 };
items.forEach(it => { stats[classifyGrid(it)]++; });
document.querySelector('.stats .mids b').textContent = stats.mids;
document.querySelector('.stats .edges b').textContent = stats.edges;
document.querySelector('.stats .both b').textContent = stats.both;
document.querySelector('.stats .err b').textContent = stats.err;
document.getElementById('total-count').textContent = items.length;
const pages = Math.max(1, Math.ceil(filtered.length / PER));
if (state.page > pages) state.page = 1;
const slice = filtered.slice((state.page - 1) * PER, state.page * PER);
if (slice.length === 0) {
grid.innerHTML = '<div class="empty">no flagged designs match this filter</div>';
} else {
grid.innerHTML = slice.map(item => {
const g = classifyGrid(item);
// Status badge: removed (deleted) / active (still published) / flagged (unpublished)
const badge = item.removed ? 'REMOVED'
: item.published ? 'ACTIVE'
: (g === 'err' ? 'SCAN ERR' : 'NOT ACTIVE');
const badgeCls = item.removed ? 'st-removed' : item.published ? 'st-active' : '';
const lenses = item.lenses || {};
const lensRows = ['top','bottom','left','right','h_mid','v_mid'].map(k => {
const v = lenses[k];
if (!v) return '';
const cls = v.verdict === 'FAIL' ? 'fail' : (v.verdict === 'WARN' ? 'warn' : '');
return `<div class="row"><span class="k">${k}</span><span class="v ${cls}">ΔE ${v.score}</span></div>`;
}).join('');
// Aesthetic-gated rows carry gate_reasons[] (why the gate hid them) +
// hue_count, instead of edges lens scores. Surface that as the reason.
const reason = (Array.isArray(item.gate_reasons) && item.gate_reasons.length)
? '⊘ ' + item.gate_reasons.join(' · ')
: (item.reason || (item.ok === false ? (item.error || 'scan error') : 'edges_fail'));
const when = fmtDate(item.created_at);
const whenChip = when ? `<span class="when" title="${item.created_at}">🕓 ${when}</span>` : '';
const isSel = selected.has(item.id);
return `
<div class="tile grid-${g}${isSel ? ' selected' : ''}" data-id="${item.id}" title="${reason}">
<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 ${badgeCls}">${badge}</span>
<span class="id-tag">#${item.id}</span>
${whenChip}
<div class="tooltip">
<div class="why">${reason}</div>
${lensRows || `<div class="row"><span class="k">error</span><span class="v fail">${item.error || 'unknown'}</span></div>`}
</div>
<div class="acts">
${IS_PROD ? '' : `<button class="a-fix" data-fix="${item.id}" title="Seam-heal in place (fix-seam.py — preserves composition, reversible via __preseam backup)">⚕ Fix</button>`}
<a class="a-redo" href="/admin/seam-debug/${item.id}" target="_blank" rel="noopener" title="Open the seam debugger — box-heal · regenerate-from-prompt (redo) · preview & approve">⟲ Redo →</a>
</div>
</div>`;
}).join('');
}
const pager = document.getElementById('pager');
if (pages > 1) {
const links = [];
for (let p = 1; p <= pages; p++) {
links.push(p === state.page
? `<span class="active">${p}</span>`
: `<a href="#" data-page="${p}">${p}</a>`);
}
pager.innerHTML = links.join('');
pager.querySelectorAll('a').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
state.page = parseInt(a.dataset.page, 10);
render();
window.scrollTo(0, 0);
});
});
} else {
pager.innerHTML = '';
}
}
// Wire controls
document.getElementById('sort').value = state.sort;
document.getElementById('filter').value = state.filter;
document.getElementById('status-filter').value = state.status;
document.getElementById('density').value = state.density;
applyDensity(state.density);
document.getElementById('status-filter').addEventListener('change', e => {
state.status = e.target.value;
localStorage.setItem('edges-review-status', state.status);
state.page = 1;
render();
});
document.getElementById('sort').addEventListener('change', e => {
state.sort = e.target.value;
localStorage.setItem('edges-review-sort', state.sort);
state.page = 1;
render();
});
document.getElementById('filter').addEventListener('change', e => {
state.filter = e.target.value;
localStorage.setItem('edges-review-filter', state.filter);
state.page = 1;
render();
});
document.getElementById('density').addEventListener('input', e => {
state.density = parseInt(e.target.value, 10);
localStorage.setItem('edges-review-density', String(state.density));
applyDensity(state.density);
});
// ── Multi-select + bulk actions ──
// Actions mutate the catalog on whatever host runs the server. The flagged
// rows + PNGs live on Mac2, so on prod (wallco.ai) they 404/no-op — gate
// destructive buttons off there and show a notice.
const IS_PROD = !/^(localhost|127\.0\.0\.1|::1|\[::1\])$/.test(location.hostname);
const bulkbar = document.getElementById('bulkbar');
function updateBulkBar() {
document.getElementById('sel-count').textContent = selected.size;
bulkbar.hidden = selected.size === 0;
}
function toggleSelect(id, on) {
id = parseInt(id, 10);
if (on) selected.add(id); else selected.delete(id);
const tile = document.querySelector('.tile[data-id="' + id + '"]');
if (tile) tile.classList.toggle('selected', on);
updateBulkBar();
}
document.getElementById('grid').addEventListener('change', e => {
if (e.target.classList && e.target.classList.contains('sel'))
toggleSelect(e.target.dataset.id, e.target.checked);
});
document.getElementById('select-page').addEventListener('click', () => {
document.querySelectorAll('#grid .sel').forEach(cb => { cb.checked = true; toggleSelect(cb.dataset.id, true); });
});
document.getElementById('b-clear').addEventListener('click', () => {
selected.clear();
document.querySelectorAll('#grid .sel:checked').forEach(cb => cb.checked = false);
document.querySelectorAll('#grid .tile.selected').forEach(t => t.classList.remove('selected'));
updateBulkBar();
});
function gateProd() {
if (!IS_PROD) return;
document.getElementById('b-reject').disabled = true;
document.getElementById('b-delete').disabled = true;
document.getElementById('b-fix').disabled = true;
document.getElementById('prod-note').hidden = false;
}
gateProd();
// ── Toast ──
function toast(msg, ms) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove('show'), ms || 2800);
}
// ── Per-card Fix (seam-heal in place — fix-seam.py via /api/design/:id/fix) ──
// Free + local + reversible (original kept alongside as __preseam.png). Mirrors
// the cactus-curator Fix button. Redo lives on /admin/seam-debug/:id (regenerate
// from prompt + preview/approve), reached via the ⟲ Redo link.
function fixDesign(id, btn) {
if (IS_PROD) { toast('⚠ Fix runs on localhost:9905 only'); return; }
const orig = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = '⚕ …'; }
return fetch('/api/design/' + id + '/fix', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}'
})
.then(r => r.json())
.then(j => {
if (!j.ok) { toast('Fix #' + id + ' failed: ' + (j.error || '?')); return false; }
if (j.fixed > 0 && j.promoted) {
toast('⚕ #' + id + ' seam-healed in place');
const img = document.querySelector('.tile[data-id="' + id + '"] img');
if (img) img.src = '/designs/img/by-id/' + id + '?t=' + Date.now();
return true;
}
toast('#' + id + (j.already_seamless > 0 ? ': no seam defect — untouched' : ': no change applied'));
return false;
})
.catch(e => { toast('Fix #' + id + ' error: ' + e.message); return false; })
.finally(() => { if (btn) { btn.disabled = false; btn.textContent = orig; } });
}
// Per-card Fix click (event-delegated). Redo is a plain <a> → seam-debug, no JS.
document.getElementById('grid').addEventListener('click', e => {
const fb = e.target.closest('[data-fix]');
if (fb) { e.preventDefault(); e.stopPropagation(); fixDesign(parseInt(fb.dataset.fix, 10), fb); }
});
// ── Bulk Fix selected (concurrency-limited pool of 3) ──
document.getElementById('b-fix').addEventListener('click', async () => {
if (IS_PROD) return;
const ids = [...selected];
if (!ids.length) return;
if (!confirm(`Seam-heal ${ids.length} selected design(s) in place?\nReversible (originals kept as __preseam.png). Then redeploy the snapshot.`)) return;
const btns = bulkbar.querySelectorAll('button'); btns.forEach(b => b.disabled = true);
let done = 0, healed = 0, i = 0;
async function worker() {
while (i < ids.length) {
const id = ids[i++];
const ok = await fixDesign(id, null);
done++; if (ok) healed++;
toast(`⚕ fixing ${done}/${ids.length} — ${healed} healed`);
}
}
await Promise.all([worker(), worker(), worker()]);
toast(`⚕ Fix done: ${healed}/${ids.length} seam-healed`, 4000);
btns.forEach(b => b.disabled = false); gateProd();
});
function bulkAction(verdict, label) {
if (IS_PROD) return;
const ids = [...selected];
if (!ids.length) return;
if (!confirm(`${label} ${ids.length} design(s)?\nRuns against the LOCAL catalog (then redeploy the snapshot).`)) return;
const btns = bulkbar.querySelectorAll('button');
btns.forEach(b => b.disabled = true);
fetch('/api/fix-decision/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, verdict })
})
.then(r => r.json())
.then(j => {
if (!j.ok) { alert('Bulk ' + verdict + ' failed: ' + (j.error || 'unknown')); return; }
const gone = new Set(ids);
state.cache[state.source] = (state.cache[state.source] || []).filter(it => !gone.has(it.id));
state.items = state.items.filter(it => !gone.has(it.id));
const n = document.getElementById('n-' + state.source);
if (n) n.textContent = state.cache[state.source].length;
selected.clear();
render();
updateBulkBar();
alert(`${label}: ${j.applied != null ? j.applied : ids.length} done` + (j.errored ? `, ${j.errored} errored` : ''));
})
.catch(err => alert('Network error: ' + err.message))
.finally(() => { btns.forEach(b => b.disabled = false); gateProd(); });
}
document.getElementById('b-reject').addEventListener('click', () => bulkAction('reject', 'Reject (keep unpublished)'));
document.getElementById('b-delete').addEventListener('click', () => bulkAction('delete', 'Delete (quarantine)'));
// ── Source tabs (Live DB vs shipped Snapshot) ──
function setActiveTab() {
document.querySelectorAll('#tabs button').forEach(b =>
b.classList.toggle('active', b.dataset.src === state.source));
}
function loadSource(src, force) {
state.source = src;
localStorage.setItem('edges-review-source', src);
setActiveTab();
state.page = 1;
selected.clear(); updateBulkBar();
if (!force && state.cache[src]) { state.items = state.cache[src]; render(); return; }
const grid = document.getElementById('grid');
grid.innerHTML = '<div class="loading">loading flagged designs…</div>';
fetch(ENDPOINT[src])
.then(r => r.json())
.then(data => {
if (!data.ok) {
grid.innerHTML = `<div class="empty">error: ${data.error || 'unknown'}</div>`;
return;
}
const items = data.items || [];
state.cache[src] = items;
const n = document.getElementById('n-' + src);
if (n) n.textContent = items.length;
if (state.source === src) { state.items = items; render(); }
})
.catch(err => {
grid.innerHTML = `<div class="empty">fetch failed: ${err.message}</div>`;
});
}
document.querySelectorAll('#tabs button').forEach(b =>
b.addEventListener('click', () => loadSource(b.dataset.src)));
// Initial load + background count for the inactive tab so both badges fill in.
setActiveTab();
loadSource(state.source);
const other = state.source === 'live' ? 'snapshot' : 'live';
fetch(ENDPOINT[other]).then(r => r.json()).then(d => {
if (d && d.ok) {
state.cache[other] = d.items || [];
const n = document.getElementById('n-' + other);
if (n) n.textContent = (d.items || []).length;
}
}).catch(() => {});
})();
</script>
<script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>