← 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>
      &nbsp;
      <span><span class="sw edges"></span>edges-only</span>
      &nbsp;
      <span><span class="sw both"></span>both</span>
      &nbsp;
      <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 &amp; 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>