← back to Wallco Ai
public/admin/status-browser.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>Status Browser — wallco.ai admin</title>
<style>
:root {
--ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
--bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
--green:#1f6a2c; --red:#b3261e; --orange:#c0660b; --blue:#3b78d8; --purple:#7a4ab8;
--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:10;
}
header h1 { font:400 22px/1.2 var(--serif); margin:0; }
header .meta { color:var(--faint); font:12px var(--sans); }
header .stats { margin-left:auto; display:flex; gap:14px; font:600 12px ui-monospace,Menlo,monospace; }
header .stats b { color:var(--ink); }
header .stats .pub b { color:var(--green); }
header .stats .unpub b { color:var(--orange); }
header .stats .etsy b { color:#7a5500; }
header .stats .rm b { color:var(--red); }
.layout {
display:grid;
grid-template-columns: 280px 1fr;
min-height:calc(100vh - 56px);
}
/* LEFT SIDEBAR — filters + new-design generator */
.sidebar {
background:#fff; border-right:1px solid var(--line);
padding:14px 16px; overflow-y:auto;
position:sticky; top:56px; align-self:start;
max-height:calc(100vh - 56px);
}
.sidebar h3 {
font:600 10px var(--sans); letter-spacing:.16em;
text-transform:uppercase; color:var(--faint);
margin:18px 0 8px; padding-top:14px; border-top:1px solid var(--line);
}
.sidebar h3:first-child { border-top:none; padding-top:0; margin-top:0; }
.sidebar label { display:block; font:11px var(--sans); color:var(--faint); margin-bottom:4px; }
.sidebar select, .sidebar input[type=text], .sidebar input[type=number] {
width:100%; padding:6px 9px; border:1px solid var(--line); border-radius:4px;
background:#fff; font:13px var(--sans); margin-bottom:10px;
}
.sidebar input[type=range] { width:100%; }
.sidebar .slider-row {
display:flex; align-items:center; gap:8px; font:11px ui-monospace,Menlo,monospace;
color:var(--faint); margin-bottom:10px;
}
.sidebar .slider-row .val { min-width:38px; text-align:right; color:var(--ink); font-weight:600; }
.sidebar .chips { display:flex; gap:5px; flex-wrap:wrap; margin-bottom:10px; }
.sidebar .chips button {
background:#fff; border:1px solid var(--line); border-radius:11px;
padding:3px 9px; font:600 10.5px var(--sans); color:var(--faint); cursor:pointer;
letter-spacing:.03em;
}
.sidebar .chips button.active { background:var(--ink); color:#fff; border-color:var(--ink); }
.sidebar .chips button.status-published.active { background:var(--green); border-color:var(--green); }
.sidebar .chips button.status-unpublished.active { background:var(--orange); border-color:var(--orange); }
.sidebar .chips button.status-etsy.active { background:#7a5500; border-color:#c9a14b; color:#fff; }
.sidebar .chips button.status-removed.active { background:var(--red); border-color:var(--red); }
.gen-button {
width:100%; padding:11px 14px;
background:linear-gradient(180deg, #fff7e0, #fef0c8);
border:1px solid var(--gold); border-radius:6px;
font:700 12px var(--sans); letter-spacing:.06em; text-transform:uppercase;
color:#7a5500; cursor:pointer;
}
.gen-button:hover { background:var(--gold); color:#fff; }
/* MAIN — grid + bulk bar */
.main { padding:14px 18px 80px; }
.topbar {
display:flex; gap:12px; align-items:center; flex-wrap:wrap;
padding:8px 0 12px; border-bottom:1px solid var(--line); margin-bottom:14px;
}
.topbar .ledger {
font:600 11px ui-monospace,Menlo,monospace; color:var(--faint);
}
.topbar select { padding:5px 9px; border:1px solid var(--line); border-radius:4px; font:12px var(--sans); }
#bulk-bar {
display:none; padding:10px 14px; background:#fcf4d8;
border:1px solid var(--gold); border-radius:8px; margin-bottom:14px;
align-items:center; gap:10px; flex-wrap:wrap;
}
#bulk-bar.open { display:flex; }
#bulk-bar .count { font:700 13px ui-monospace,Menlo,monospace; color:var(--ink); }
#bulk-bar button {
padding:6px 14px; border-radius:4px; cursor:pointer;
font:700 11px var(--sans); letter-spacing:.06em; background:#fff;
}
#bulk-bar .b-keep { border:1px solid var(--green); color:var(--green); }
#bulk-bar .b-keep:hover { background:var(--green); color:#fff; }
#bulk-bar .b-rej { border:1px solid var(--orange); color:var(--orange); }
#bulk-bar .b-rej:hover { background:var(--orange); color:#fff; }
#bulk-bar .b-etsy { border:1px solid var(--gold); color:#7a5500; }
#bulk-bar .b-etsy:hover { background:var(--gold); color:#fff; }
#bulk-bar .b-del { border:1px solid var(--red); color:#fff; background:var(--red); }
#bulk-bar .b-del:hover { background:#7a0017; }
#bulk-bar .b-fix { border:1px solid var(--blue); color:#fff; background:var(--blue); }
#bulk-bar .b-fix:hover { background:#1e5cb0; }
.grid {
display:grid; grid-template-columns:repeat(auto-fill, minmax(var(--card-min,180px), 1fr)); gap:10px;
}
.card {
position:relative; aspect-ratio:1; border:1px solid var(--line); border-radius:6px;
overflow:hidden; background:#f3eee2; cursor:pointer;
transition:transform .12s, box-shadow .12s;
}
.card:hover { transform:translateY(-1px); box-shadow:0 4px 14px rgba(0,0,0,.08); }
.card img { width:100%; height:100%; object-fit:cover; display:block; }
.card .cb-wrap {
position:absolute; top:6px; left:6px; z-index:2;
background:rgba(255,255,255,.92); border-radius:3px; padding:2px 4px;
}
.card .cb-wrap input { width:16px; height:16px; cursor:pointer; }
.card .id {
position:absolute; top:6px; right:6px;
background:rgba(31,24,8,.78); color:#fff;
font:600 9.5px ui-monospace,Menlo,monospace; padding:3px 7px; border-radius:3px;
}
.card .status-pill {
position:absolute; left:0; right:0; bottom:0;
padding:4px 0; text-align:center;
font:700 9.5px var(--sans); letter-spacing:.1em; color:#fff;
}
.card.s-published .status-pill { background:var(--green); }
.card.s-unpublished .status-pill { background:var(--orange); }
.card.s-etsy .status-pill { background:#7a5500; color:#fff; }
.card.s-removed .status-pill { background:var(--red); opacity:.7; }
.card.s-removed { opacity:.5; }
/* Regression flag: this card was generated by a Gemini-image redo path
(regenerate-reverse / smart-fix legacy / image-edit) — composition was
potentially replaced rather than preserved. Red corner badge. */
.card.redo-flag::before {
content:'🚩 REDO'; position:absolute; top:6px; left:36px; z-index:3;
background:var(--red); color:#fff;
font:700 9px var(--sans); letter-spacing:.08em; padding:2px 6px;
border-radius:3px; pointer-events:none;
}
.card .cat {
position:absolute; left:6px; bottom:28px;
background:rgba(31,24,8,.78); color:#fff;
font:600 9px var(--sans); padding:2px 5px; border-radius:3px;
max-width:calc(100% - 16px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.card .when {
position:absolute; left:6px; bottom:46px;
background:rgba(31,24,8,.78); color:#fff;
font:600 9px var(--sans); font-variant-numeric:tabular-nums;
padding:2px 5px; border-radius:3px;
max-width:calc(100% - 16px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.card.selected {
outline:3px solid var(--blue); outline-offset:-3px;
}
.empty { padding:80px 20px; text-align:center; color:var(--faint); font:14px var(--sans); }
.empty h2 { font:400 26px var(--serif); color:var(--ink); margin:0 0 8px; }
/* Marquee (rubber-band) selection overlay — drag to multi-select cards.
Hold Shift = add to existing selection · plain drag = replace selection · Alt+drag = subtract. */
#marquee {
position:fixed; border:2px dashed var(--blue);
background:rgba(59,120,216,.12); pointer-events:none;
z-index:50; display:none;
}
.main { user-select:none; }
.card { user-select:none; -webkit-user-select:none; }
.card img { -webkit-user-drag:none; user-select:none; pointer-events:none; }
.pager {
margin-top:18px; display:flex; gap:10px; align-items:center; justify-content:center;
font:12px var(--sans); color:var(--faint);
}
.pager button {
padding:5px 12px; border:1px solid var(--line); border-radius:4px;
background:#fff; cursor:pointer; font:600 11px var(--sans);
}
</style>
</head>
<body>
<header>
<h1>Status Browser</h1>
<span class="meta">every wallco.ai design · every status · curate in bulk</span>
<div class="stats">
<span class="pub">PUB <b id="s-pub">—</b></span>
<span class="unpub">UNPUB <b id="s-unpub">—</b></span>
<span class="etsy">ETSY <b id="s-etsy">—</b></span>
<span class="rm">RM <b id="s-rm">—</b></span>
<span>TOTAL <b id="s-total">—</b></span>
</div>
</header>
<div class="layout">
<aside class="sidebar">
<h3>Status</h3>
<div class="chips" id="status-chips">
<button class="active" data-status="all">All</button>
<button class="status-published" data-status="published">Published</button>
<button class="status-unpublished" data-status="unpublished">Unpublished</button>
<button class="status-etsy" data-status="etsy">Etsy-tagged</button>
<button class="status-removed" data-status="removed">Removed</button>
</div>
<h3>Duplicate detection</h3>
<button id="dedupe-btn" style="width:100%; padding:9px 12px; border:1px solid var(--blue); color:#fff; background:var(--blue); border-radius:6px; cursor:pointer; font:700 11px var(--sans); letter-spacing:.06em; text-transform:uppercase;">
🔍 Detect duplicates
</button>
<div class="slider-row" style="margin-top:8px">
<label style="margin:0">Sensitivity</label>
<input type="range" id="dedupe-thresh" min="2" max="14" step="1" value="8">
<span class="val" id="dedupe-thresh-val">8</span>
</div>
<div style="font:10.5px var(--sans); color:var(--faint); margin-top:4px; line-height:1.4">
Pre-checks all near-identical images except the lowest-id keeper in each cluster.
Lower sensitivity = stricter (only exact matches). Higher = looser (catches colorway variants).
</div>
<div id="dedupe-status" style="margin-top:8px; font:11px var(--sans); color:var(--faint);"></div>
<h3>Regression flag</h3>
<label style="display:flex; gap:8px; align-items:center; font:12px var(--sans); color:var(--ink); cursor:pointer;">
<input type="checkbox" id="only-redos">
<span>Show only 🚩 REDOS</span>
</label>
<div style="font:10.5px var(--sans); color:var(--faint); margin-top:6px; line-height:1.4">
A 🚩 REDO is a child design generated via a Gemini-image regenerate path
(regenerate-reverse, smart-fix legacy). Composition was potentially replaced.
Use Fix instead (PIL opacity-snap, preserves composition).
</div>
<h3>Category</h3>
<select id="cat-select"><option value="">— all categories —</option></select>
<h3>Sort</h3>
<select id="sort-select">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="category">By category</option>
<option value="id-asc">ID ↑</option>
<option value="id-desc">ID ↓</option>
</select>
<h3>Density</h3>
<div class="slider-row">
<label style="margin:0">Card</label>
<input type="range" id="density" min="120" max="320" step="20" value="180">
<span class="val" id="density-val">180px</span>
</div>
<h3>New design generator</h3>
<label>Style scale (tight ↔ open)</label>
<div class="slider-row">
<span>tight</span>
<input type="range" id="gen-scale" min="1" max="10" step="1" value="5">
<span class="val" id="gen-scale-val">5</span>
<span>open</span>
</div>
<label>Adherence (strict ↔ loose)</label>
<div class="slider-row">
<span>strict</span>
<input type="range" id="gen-loose" min="1" max="10" step="1" value="5">
<span class="val" id="gen-loose-val">5</span>
<span>loose</span>
</div>
<label>Fix flow</label>
<select id="gen-flow">
<option value="opacity-snap">Opacity Snap (PIL, preserves composition)</option>
<option value="crop-fix">Crop & Fix (Gemini, marked-region only)</option>
<option value="replace-bg">Replace BG (texture swap)</option>
<option value="smart-fix">Smart Fix (Gemini, regenerate clean)</option>
</select>
<button class="gen-button" id="apply-fix-selected">Apply fix-flow to selected</button>
<div style="font:10.5px var(--sans); color:var(--faint); margin-top:6px; line-height:1.4">
Fix-flow runs on every selected card · Opacity Snap is free (PIL) · others use Gemini.
</div>
</aside>
<main class="main">
<div class="topbar">
<span class="ledger" id="ledger">loading…</span>
<span style="margin-left:auto"></span>
<button id="select-visible" style="background:#fff;border:1px solid var(--line);padding:5px 10px;border-radius:4px;font:600 11px var(--sans);cursor:pointer;color:var(--faint)">Select visible</button>
<button id="select-clear" style="background:none;border:1px solid var(--line);padding:5px 10px;border-radius:4px;font:600 11px var(--sans);cursor:pointer;color:var(--faint)">Clear</button>
</div>
<div id="bulk-bar">
<span class="count" id="bulk-count">0 selected</span>
<button class="b-keep" id="bulk-keep">✓ Keep all</button>
<button class="b-rej" id="bulk-reject">↩ Reject all</button>
<button class="b-etsy" id="bulk-etsy">🏪 Tag for Etsy</button>
<button class="b-fix" id="bulk-fix">🔧 Apply fix-flow</button>
<button class="b-del" id="bulk-delete">⚠ Delete all</button>
</div>
<div style="font:11px var(--sans);color:var(--faint);margin-bottom:6px">
💡 Drag to marquee-select cards · Shift+drag = add · Alt+drag = remove · Click clears
</div>
<div class="grid" id="grid"></div>
<div id="marquee"></div>
<div id="empty" class="empty" style="display:none">
<h2>No designs in this filter</h2>
<p>Switch status above.</p>
</div>
<div class="pager" id="pager"></div>
</main>
</div>
<script>
(() => {
const $ = (id) => document.getElementById(id);
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' });
}
let STATUS = 'all', CAT = '', SORT = 'newest', OFFSET = 0;
let ONLY_REDOS = false;
const LIMIT = 200;
const SELECTED = new Set();
// Density
const dens = $('density'), densVal = $('density-val');
dens.value = localStorage.getItem('sb-density') || 180;
densVal.textContent = dens.value + 'px';
document.documentElement.style.setProperty('--card-min', dens.value + 'px');
dens.oninput = () => {
document.documentElement.style.setProperty('--card-min', dens.value + 'px');
densVal.textContent = dens.value + 'px';
localStorage.setItem('sb-density', dens.value);
};
// Sliders
$('gen-scale').oninput = e => $('gen-scale-val').textContent = e.target.value;
$('gen-loose').oninput = e => $('gen-loose-val').textContent = e.target.value;
// Status chips
document.querySelectorAll('#status-chips button').forEach(b => {
b.onclick = () => {
document.querySelectorAll('#status-chips button').forEach(x => x.classList.remove('active'));
b.classList.add('active');
STATUS = b.dataset.status;
OFFSET = 0; SELECTED.clear(); refreshBulk();
load();
};
});
$('cat-select').onchange = e => { CAT = e.target.value; OFFSET = 0; load(); };
$('sort-select').onchange = e => { SORT = e.target.value; OFFSET = 0; load(); };
$('only-redos').onchange = e => { ONLY_REDOS = e.target.checked; OFFSET = 0; load(); };
// Categories loader
async function loadCategories() {
try {
const r = await fetch('/api/categories');
const j = await r.json();
const sel = $('cat-select');
for (const c of (j.items || [])) {
const opt = document.createElement('option');
opt.value = c.category; opt.textContent = `${c.category} (${c.count})`;
sel.appendChild(opt);
}
} catch {}
}
// Stats loader
async function loadStats() {
try {
const [pub, unpub, etsy, rm, total] = await Promise.all([
fetch('/api/status-browser?status=published&limit=1').then(r=>r.json()),
fetch('/api/status-browser?status=unpublished&limit=1').then(r=>r.json()),
fetch('/api/status-browser?status=etsy&limit=1').then(r=>r.json()),
fetch('/api/status-browser?status=removed&limit=1').then(r=>r.json()),
fetch('/api/status-browser?status=all&limit=1').then(r=>r.json()),
]);
$('s-pub').textContent = (pub.total || 0).toLocaleString();
$('s-unpub').textContent = (unpub.total || 0).toLocaleString();
$('s-etsy').textContent = (etsy.total || 0).toLocaleString();
$('s-rm').textContent = (rm.total || 0).toLocaleString();
$('s-total').textContent = (total.total || 0).toLocaleString();
} catch {}
}
async function load() {
$('ledger').textContent = 'loading…';
const qs = new URLSearchParams({ status: STATUS, sort: SORT, limit: LIMIT, offset: OFFSET });
if (CAT) qs.set('category', CAT);
const r = await fetch('/api/status-browser?' + qs);
const j = await r.json();
render(j);
}
function render(j) {
const grid = $('grid'); grid.innerHTML = '';
let items = j.items || [];
if (ONLY_REDOS) items = items.filter(i => i.is_redo);
if (!items.length) { $('empty').style.display = ''; $('ledger').textContent = '0'; return; }
$('empty').style.display = 'none';
$('ledger').textContent = `showing ${OFFSET + 1}–${OFFSET + items.length} of ${j.total.toLocaleString()} · ${SELECTED.size} selected`;
const frag = document.createDocumentFragment();
for (const it of items) {
const card = document.createElement('div');
card.className = 'card s-' + it.computed_status;
if (it.is_redo) card.classList.add('redo-flag');
if (SELECTED.has(it.id)) card.classList.add('selected');
card.dataset.id = it.id;
card.innerHTML = `
<div class="cb-wrap"><input type="checkbox" class="sb-cb" ${SELECTED.has(it.id) ? 'checked' : ''}></div>
<div class="id">#${it.id}</div>
<img loading="lazy" src="/designs/img/by-id/${it.id}" alt="${it.id}">
<div class="when" title="created ${it.created_at || ''}">🕓 ${fmtDate(it.created_at)}</div>
<div class="cat">${it.category || ''}</div>
<div class="status-pill">${it.computed_status.toUpperCase()}</div>
`;
card.querySelector('.sb-cb').onchange = (e) => {
e.stopPropagation();
if (e.target.checked) { SELECTED.add(it.id); card.classList.add('selected'); }
else { SELECTED.delete(it.id); card.classList.remove('selected'); }
refreshBulk();
$('ledger').textContent = `showing ${OFFSET + 1}–${OFFSET + items.length} of ${j.total.toLocaleString()} · ${SELECTED.size} selected`;
};
card.onclick = (e) => {
if (e.target.classList.contains('sb-cb')) return;
window.open('/design/' + it.id, '_blank');
};
frag.appendChild(card);
}
grid.appendChild(frag);
// Pager
const pager = $('pager');
pager.innerHTML = '';
const pages = Math.ceil(j.total / LIMIT);
const cur = Math.floor(OFFSET / LIMIT) + 1;
if (pages > 1) {
const prev = document.createElement('button');
prev.textContent = '‹ prev'; prev.disabled = OFFSET === 0;
prev.onclick = () => { OFFSET = Math.max(0, OFFSET - LIMIT); load(); window.scrollTo(0, 0); };
pager.appendChild(prev);
pager.appendChild(document.createTextNode(` page ${cur} / ${pages} `));
const nxt = document.createElement('button');
nxt.textContent = 'next ›'; nxt.disabled = cur >= pages;
nxt.onclick = () => { OFFSET = OFFSET + LIMIT; load(); window.scrollTo(0, 0); };
pager.appendChild(nxt);
}
}
function refreshBulk() {
const bar = $('bulk-bar');
$('bulk-count').textContent = `${SELECTED.size} selected`;
if (SELECTED.size > 0) bar.classList.add('open');
else bar.classList.remove('open');
}
$('select-visible').onclick = () => {
document.querySelectorAll('.sb-cb').forEach(cb => {
cb.checked = true;
const card = cb.closest('.card');
SELECTED.add(parseInt(card.dataset.id, 10));
card.classList.add('selected');
});
refreshBulk(); load();
};
$('select-clear').onclick = () => {
SELECTED.clear();
document.querySelectorAll('.sb-cb').forEach(cb => cb.checked = false);
document.querySelectorAll('.card').forEach(c => c.classList.remove('selected'));
refreshBulk();
};
async function bulkVerdict(verdict, btn) {
if (!SELECTED.size) return;
if (!confirm(`${verdict.toUpperCase()} ${SELECTED.size} designs?\n\nKeep = no-op (already published)\nReject = unpublish + republish source (if any)\nEtsy = unpublish + tag etsy-digital-file\nDelete = unpublish + quarantine PNG + republish source`)) return;
const ids = [...SELECTED];
btn.disabled = true; const orig = btn.textContent; btn.textContent = '…';
try {
const r = await fetch('/api/fix-decision/bulk', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ ids, verdict }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'http ' + r.status);
SELECTED.clear(); refreshBulk();
alert(`${verdict}: ${j.accepted}/${j.total} ok · ${j.errored} errs`);
loadStats(); load();
} catch (e) { alert('failed: ' + e.message); }
finally { btn.disabled = false; btn.textContent = orig; }
}
$('bulk-keep').onclick = (e) => bulkVerdict('accept', e.currentTarget);
$('bulk-reject').onclick = (e) => bulkVerdict('reject', e.currentTarget);
$('bulk-etsy').onclick = (e) => bulkVerdict('etsy', e.currentTarget);
$('bulk-delete').onclick = (e) => bulkVerdict('delete', e.currentTarget);
async function applyFixFlowBulk(btn) {
if (!SELECTED.size) return;
const flow = $('gen-flow').value;
if (!confirm(`Apply ${flow} to ${SELECTED.size} designs?\n\n${flow === 'opacity-snap' ? 'FREE (pure PIL, preserves composition)' : 'Uses Gemini · ~$0.005/design'}`)) return;
btn.disabled = true; const orig = btn.textContent; btn.textContent = '…';
let ok = 0, err = 0;
for (const id of [...SELECTED]) {
try {
let endpoint;
if (flow === 'opacity-snap') endpoint = `/api/design/${id}/opacity-snap`;
else if (flow === 'replace-bg') { alert('replace-bg needs a texture — use the bg-tester for that flow'); break; }
else if (flow === 'crop-fix') { alert('crop-fix needs marked regions — use /admin/ghost-review for that flow'); break; }
else endpoint = `/api/design/${id}/smart-fix`;
const r = await fetch(endpoint, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({}) });
const j = await r.json();
if (j.ok) ok++; else err++;
} catch { err++; }
btn.textContent = `… ${ok}/${SELECTED.size}`;
}
btn.disabled = false; btn.textContent = orig;
alert(`${flow}: ${ok} ok · ${err} err`);
loadStats(); load();
}
$('bulk-fix').onclick = (e) => applyFixFlowBulk(e.currentTarget);
$('apply-fix-selected').onclick = (e) => applyFixFlowBulk(e.currentTarget);
// ── Dedupe button ────────────────────────────────────────────────────
$('dedupe-thresh').oninput = e => $('dedupe-thresh-val').textContent = e.target.value;
$('dedupe-btn').onclick = async () => {
const btn = $('dedupe-btn');
const stat = $('dedupe-status');
const threshold = parseInt($('dedupe-thresh').value, 10);
btn.disabled = true; btn.textContent = '⏳ scanning…';
stat.textContent = `hashing ${LIMIT} ${STATUS} ${CAT || 'all-category'}…`;
try {
const r = await fetch('/api/dedupe/scan', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ status: STATUS, category: CAT, limit: LIMIT, threshold }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'http ' + r.status);
const dupeCount = j.clusters.reduce((s, c) => s + c.dupes.length, 0);
stat.innerHTML = `<b>${j.clusters.length}</b> clusters · <b>${dupeCount}</b> dupes pre-checked · ${j.scanned} scanned · ${j.time_ms}ms`;
// Pre-check every dupe (NOT the keeper) on currently-rendered cards
SELECTED.clear();
for (const c of j.clusters) for (const id of c.dupes) SELECTED.add(id);
// Highlight kept ones with a thin border
document.querySelectorAll('.sb-cb').forEach(cb => {
const card = cb.closest('.card');
const id = parseInt(card.dataset.id, 10);
if (SELECTED.has(id)) {
cb.checked = true;
card.classList.add('selected');
}
});
refreshBulk();
if (dupeCount === 0) {
stat.innerHTML += '<br>✓ no near-duplicates at this threshold';
} else {
stat.innerHTML += `<br>👉 review selection above, then hit ⚠ Delete all (or ↩ Reject all) in the bulk bar`;
}
} catch (e) {
stat.innerHTML = `<span style="color:var(--red)">err: ${e.message}</span>`;
} finally {
btn.disabled = false; btn.textContent = '🔍 Detect duplicates';
}
};
// ── Marquee (rubber-band) drag-selection ────────────────────────────
// Drag anywhere over the grid → draws a dashed box. On mouseup, every card
// whose bounding box intersects the marquee gets its checkbox toggled into the
// SELECTED set. Modifiers: plain = replace selection, Shift = add, Alt = remove.
(function initMarquee() {
const marquee = document.getElementById('marquee');
const grid = $('grid');
let startX = 0, startY = 0, dragging = false, mode = 'replace';
let startSnapshot = null;
grid.addEventListener('mousedown', (e) => {
// Ignore clicks on a checkbox or the card itself (let those handle their own click)
if (e.target.tagName === 'INPUT') return;
if (e.button !== 0) return;
const movedFromTarget = e.target.closest('.card');
// Allow drag-to-marquee even when starting inside a card; require ≥6px move to "commit" to marquee.
dragging = true;
startX = e.clientX; startY = e.clientY;
mode = e.shiftKey ? 'add' : (e.altKey ? 'remove' : 'replace');
startSnapshot = new Set(SELECTED);
marquee.style.display = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
const x = Math.min(e.clientX, startX);
const y = Math.min(e.clientY, startY);
const w = Math.abs(dx), h = Math.abs(dy);
marquee.style.left = x + 'px';
marquee.style.top = y + 'px';
marquee.style.width = w + 'px';
marquee.style.height = h + 'px';
marquee.style.display = '';
// Live preview: compute which cards intersect the marquee
const mLeft = x, mTop = y, mRight = x + w, mBottom = y + h;
document.querySelectorAll('.card').forEach(card => {
const r = card.getBoundingClientRect();
const hits = !(r.right < mLeft || r.left > mRight || r.bottom < mTop || r.top > mBottom);
const id = parseInt(card.dataset.id, 10);
let isSelected;
if (mode === 'replace') isSelected = hits;
else if (mode === 'add') isSelected = startSnapshot.has(id) || hits;
else if (mode === 'remove') isSelected = startSnapshot.has(id) && !hits;
if (isSelected) SELECTED.add(id); else SELECTED.delete(id);
card.classList.toggle('selected', isSelected);
const cb = card.querySelector('.sb-cb');
if (cb) cb.checked = isSelected;
});
refreshBulk();
});
document.addEventListener('mouseup', (e) => {
if (!dragging) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
const wasMarquee = Math.abs(dx) >= 6 || Math.abs(dy) >= 6;
dragging = false;
marquee.style.display = 'none';
// Click without drag = clear selection (only if user clicked on an empty grid area, not on a card)
if (!wasMarquee) {
const onCard = e.target.closest('.card');
if (!onCard) {
SELECTED.clear();
document.querySelectorAll('.sb-cb').forEach(cb => cb.checked = false);
document.querySelectorAll('.card').forEach(c => c.classList.remove('selected'));
refreshBulk();
}
}
// Update the ledger count
const j_total = parseInt($('ledger').textContent.match(/of ([\d,]+)/)?.[1].replace(/,/g, '') || '0', 10);
if (j_total) $('ledger').textContent = $('ledger').textContent.replace(/· \d+ selected/, '· ' + SELECTED.size + ' selected');
});
// ESC clears selection
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && SELECTED.size) {
SELECTED.clear();
document.querySelectorAll('.sb-cb').forEach(cb => cb.checked = false);
document.querySelectorAll('.card').forEach(c => c.classList.remove('selected'));
refreshBulk();
}
});
})();
loadCategories(); loadStats(); load();
})();
</script>
<script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>