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