← back to Wallco Ai

public/admin/bg-tester.html

598 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>BG Tester — wallco.ai admin</title>
<style>
  :root {
    --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
    --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
    --green:#1f6a2c; --red:#b3261e; --blue:#3b78d8;
    --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:14px 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; }
  header input.did-input {
    margin-left:14px; padding:6px 11px; border:1px solid var(--line);
    border-radius:4px; font:600 14px ui-monospace,Menlo,monospace; width:120px;
  }
  header button.load-btn {
    padding:7px 14px; border:1px solid var(--ink); background:var(--ink); color:#f4ece0;
    font:600 11px var(--sans); letter-spacing:.1em; text-transform:uppercase;
    border-radius:14px; cursor:pointer;
  }
  header .stats { margin-left:auto; font:600 12px ui-monospace,Menlo,monospace; color:var(--faint); }

  main { padding:18px; max-width:1700px; margin:0 auto; }

  .layout {
    display:grid; grid-template-columns:340px 1fr; gap:18px; align-items:start;
  }
  .panel {
    background:#fff; border:1px solid var(--line); border-radius:8px;
    padding:14px 16px;
  }
  .panel h3 { font:600 11px var(--sans); letter-spacing:.14em;
              text-transform:uppercase; color:var(--faint); margin:0 0 10px; }

  .source-card {
    border:1px solid var(--line); border-radius:6px; overflow:hidden;
    background:#f3eee2; aspect-ratio:1;
  }
  .source-card img { width:100%; height:100%; object-fit:cover; display:block; }
  .source-meta {
    margin-top:10px; font:11.5px var(--sans); color:var(--ink);
  }
  .source-meta b { font-family:ui-monospace,Menlo,monospace; }

  .filters {
    display:flex; gap:5px; flex-wrap:wrap; margin-bottom:10px;
  }
  .filters button {
    border:1px solid var(--line); background:#fff; border-radius:11px;
    padding:3px 9px; font:600 10px var(--sans); color:var(--faint); cursor:pointer;
    letter-spacing:.04em;
  }
  .filters button.active { background:var(--ink); color:#fff; border-color:var(--ink); }

  /* Texture chip grid */
  .tex-grid {
    display:grid; grid-template-columns:repeat(auto-fill,minmax(108px,1fr)); gap:8px;
    max-height:420px; overflow-y:auto; padding-right:4px;
  }
  .tex-chip {
    border:2px solid var(--line); border-radius:6px; overflow:hidden;
    cursor:pointer; background:#fff; transition:transform .1s, border-color .1s;
    position:relative;
  }
  .tex-chip:hover { transform:translateY(-1px); border-color:var(--gold); }
  .tex-chip.active { border-color:var(--blue); box-shadow:0 0 0 3px rgba(59,120,216,.25); }
  .tex-chip .thumb { aspect-ratio:1; background:#f3eee2; }
  .tex-chip .thumb img { width:100%; height:100%; object-fit:cover; display:block; }
  .tex-chip .lbl {
    font:600 9.5px var(--sans); padding:4px 6px;
    color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  }
  .tex-chip .brand {
    font:600 8.5px var(--sans); color:var(--faint); padding:0 6px 4px;
    text-transform:uppercase; letter-spacing:.06em;
  }

  /* Result row */
  .results {
    display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:14px;
    margin-top:18px;
  }
  .result {
    background:#fff; border:1px solid var(--line); border-radius:8px;
    overflow:hidden;
  }
  .result .head {
    padding:8px 12px; background:#fcfaf4; border-bottom:1px solid var(--line);
    display:flex; align-items:center; gap:8px; font:600 11px var(--sans);
  }
  .result .head .tex-name { color:var(--ink); }
  .result .head .elapsed {
    margin-left:auto; font:11px ui-monospace,Menlo,monospace; color:var(--faint);
  }
  .result .img-box {
    aspect-ratio:1; background:#f3eee2; position:relative;
  }
  .result .img-box img { width:100%; height:100%; object-fit:cover; display:block; }
  .result .img-box.loading::after {
    content:'⏳ ~10s'; position:absolute; inset:0;
    display:flex; align-items:center; justify-content:center;
    background:rgba(255,255,255,.92); font:600 16px var(--sans); color:var(--faint);
  }
  .result .img-box.error::after {
    content:'✗ ' attr(data-err); position:absolute; inset:0;
    display:flex; align-items:center; justify-content:center;
    background:rgba(252,232,230,.95); color:var(--red); font:600 12px var(--sans);
    padding:14px; text-align:center;
  }
  .result .actions {
    padding:8px 12px; display:flex; gap:6px; border-top:1px solid var(--line);
  }
  .result .actions a, .result .actions button {
    flex:1; padding:5px 8px; text-align:center; text-decoration:none;
    border:1px solid var(--line); border-radius:4px; background:#fff;
    font:600 10.5px var(--sans); letter-spacing:.04em; color:var(--ink); cursor:pointer;
    text-transform:uppercase;
  }
  .result .actions a:hover, .result .actions button:hover { background:var(--ink); color:#fff; }

  .toast {
    position:fixed; bottom:24px; left:50%; transform:translateX(-50%);
    background:rgba(31,24,8,.95); color:#fff; padding:9px 16px;
    border-radius:6px; font:600 12px var(--sans);
    opacity:0; transition:opacity .2s; pointer-events:none; z-index:200;
  }
  .toast.show { opacity:1; }

  .when { position:absolute; left:3px; bottom:3px; background:rgba(31,24,8,.78); color:#fff;
          font:600 8.5px ui-monospace,Menlo,monospace; padding:1px 5px; border-radius:2px;
          font-variant-numeric:tabular-nums; pointer-events:none; }
</style>
</head>
<body>

<header>
  <h1>BG Tester</h1>
  <span class="meta">dynamically swap backgrounds using real DW textures</span>
  <label style="font:600 11px var(--sans); letter-spacing:.08em; text-transform:uppercase; color:var(--faint); margin-left:14px;">Design ID</label>
  <input class="did-input" id="did-input" type="number" placeholder="39341" value="">
  <button class="load-btn" id="load-btn">Load</button>
  <span class="stats" id="stats">0 textures loaded · 0 results</span>
</header>

<main>
  <div class="layout">
    <div>
      <div class="panel">
        <h3>Source Design</h3>
        <div class="source-card" id="source-card">
          <div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--faint);font:12px var(--sans)">enter id and load</div>
        </div>
        <div class="source-meta" id="source-meta"></div>
      </div>
      <div class="panel" style="margin-top:14px">
        <h3>Recent Designs — click to load as source</h3>
        <div id="recent-grid" style="display:grid; grid-template-columns:repeat(3, 1fr); gap:6px; max-height:380px; overflow-y:auto"></div>
      </div>
      <div class="panel" style="margin-top:14px">
        <h3>Best Bets — auto-pick 4 textures</h3>
        <button id="auto-pick" style="width:100%; padding:10px 12px; border:1px solid var(--gold); color:#7a5500; background:linear-gradient(180deg,#fff7e0,#fef0c8); border-radius:6px; cursor:pointer; font:700 12px var(--sans); letter-spacing:.06em; text-transform:uppercase;">
          ✨ Try 4 Textures Now
        </button>
        <div style="font:11px var(--sans); color:var(--faint); margin-top:6px">picks one each: Grasscloth · Linen · Cork · Mica — fires all 4 in parallel</div>
      </div>
      <div class="panel" style="margin-top:14px">
        <h3>Bulk Apply — entire category</h3>
        <div style="display:flex; gap:6px; align-items:center; margin-bottom:8px">
          <label style="font:11px var(--sans); color:var(--faint)">Category</label>
          <select id="bulk-cat" style="flex:1; padding:5px 8px; border:1px solid var(--line); border-radius:4px; font:12px var(--sans);">
            <option value="">— pick category —</option>
          </select>
          <label style="font:11px var(--sans); color:var(--faint)">Limit</label>
          <input id="bulk-limit" type="number" value="100" min="1" max="5000" style="width:60px; padding:5px 6px; border:1px solid var(--line); border-radius:4px; font:12px ui-monospace,Menlo,monospace;">
        </div>
        <button id="bulk-fire" disabled style="width:100%; padding:10px 12px; border:1px solid var(--red); color:var(--red); background:#fff; border-radius:6px; cursor:pointer; font:700 12px var(--sans); letter-spacing:.06em; text-transform:uppercase; opacity:.5;">
          🚀 Click a texture chip first
        </button>
        <div id="bulk-status" style="display:none; margin-top:8px; padding:8px 10px; background:#fef0c8; border-left:3px solid var(--gold); border-radius:0 4px 4px 0; font:11.5px var(--sans);"></div>
      </div>
    </div>
    <div>
      <div class="panel">
        <h3>Texture Library — click any chip to apply
          <span style="margin-left:14px; font:11px var(--sans); color:var(--faint); text-transform:none; letter-spacing:0;">
            Sort:
            <select id="tex-sort" style="margin-left:4px; padding:2px 6px; font:11px var(--sans); border:1px solid var(--line); border-radius:3px;">
              <option value="category">by Style (grouped)</option>
              <option value="color">by Color (hue wheel)</option>
              <option value="name">by Name A→Z</option>
              <option value="random">Shuffle</option>
            </select>
          </span>
          <span id="tex-count" style="margin-left:10px; font:11px ui-monospace,Menlo,monospace; color:var(--faint);">— textures</span>
        </h3>
        <div class="filters" id="filters-mat">
          <span style="font:9px var(--sans); color:var(--faint); letter-spacing:.1em; text-transform:uppercase; align-self:center; margin-right:4px;">Style</span>
          <button class="active" data-f="all" data-type="material">All</button>
          <button data-f="grasscloth" data-type="material">Grasscloth</button>
          <button data-f="linen" data-type="material">Linen</button>
          <button data-f="silk" data-type="material">Silk</button>
          <button data-f="sisal" data-type="material">Sisal</button>
          <button data-f="hemp" data-type="material">Hemp</button>
          <button data-f="raffia" data-type="material">Raffia</button>
          <button data-f="paperweave" data-type="material">Paperweave</button>
          <button data-f="cork" data-type="material">Cork</button>
          <button data-f="mica" data-type="material">Mica</button>
          <button data-f="abaca" data-type="material">Abaca</button>
          <button data-f="jute" data-type="material">Jute</button>
          <button data-f="madagascar" data-type="material">Madagascar</button>
          <button data-f="burlap" data-type="material">Burlap</button>
          <button data-f="seagrass" data-type="material">Seagrass</button>
        </div>
        <div class="tex-grid" id="tex-grid"></div>
      </div>
      <div class="results" id="results"></div>
    </div>
  </div>
</main>

<div class="toast" id="toast"></div>

<script>
(() => {
  const $ = (id) => document.getElementById(id);
  let TEXTURES = [];
  let MAT_FILTER = 'all';
  let SORT_MODE = 'category';
  let CURRENT_DID = null;
  let RESULT_COUNT = 0;
  let SELECTED_TEXTURE = null;   // for bulk-apply

  // sRGB hex → CIE-LAB. Used for "by Color (hue wheel)" sort.
  function hexToLab(hex) {
    if (!hex) return null;
    const m = /^#?([0-9a-f]{6})$/i.exec(hex);
    if (!m) return null;
    const r = parseInt(m[1].slice(0,2), 16) / 255;
    const g = parseInt(m[1].slice(2,4), 16) / 255;
    const b = parseInt(m[1].slice(4,6), 16) / 255;
    const lin = c => c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
    const R = lin(r), G = lin(g), B = lin(b);
    const X = (R*0.4124 + G*0.3576 + B*0.1805) / 0.95047;
    const Y = (R*0.2126 + G*0.7152 + B*0.0722);
    const Z = (R*0.0193 + G*0.1192 + B*0.9505) / 1.08883;
    const f = t => t > 0.008856 ? Math.cbrt(t) : (7.787*t + 16/116);
    return [116*f(Y)-16, 500*(f(X)-f(Y)), 200*(f(Y)-f(Z))];
  }

  function toast(msg) {
    const t = $('toast');
    t.textContent = msg;
    t.classList.add('show');
    clearTimeout(t._tm);
    t._tm = setTimeout(() => t.classList.remove('show'), 1400);
  }

  async function loadTextures() {
    // Unified DW Textures pool: 5k+ vendor-stripped rows, deduped, with
    // material category + dominant hex. Vendor names intentionally not exposed.
    const r = await fetch('/api/bg-textures');
    const j = await r.json();
    TEXTURES = j.items || [];
    renderFilters();
  }

  function passesFilter(tex) {
    if (MAT_FILTER !== 'all') {
      const cat = (tex.category || '').toLowerCase();
      const name = (tex.name || '').toLowerCase();
      if (cat !== MAT_FILTER && !name.includes(MAT_FILTER)) return false;
    }
    return true;
  }
  function sortTextures(items) {
    const sorted = [...items];
    if (SORT_MODE === 'category') {
      sorted.sort((a,b) => (a.category || '').localeCompare(b.category || '') || (a.name || '').localeCompare(b.name || ''));
    } else if (SORT_MODE === 'color') {
      // LAB hue-wheel: angle in (a*, b*) plane, then lightness, then name
      const scored = sorted.map(t => {
        const lab = hexToLab(t.hex);
        return { t, h: lab ? Math.atan2(lab[2], lab[1]) : 999, L: lab ? lab[0] : 999 };
      });
      scored.sort((a,b) => (a.h - b.h) || (a.L - b.L) || (a.t.name || '').localeCompare(b.t.name || ''));
      return scored.map(s => s.t);
    } else if (SORT_MODE === 'name') {
      sorted.sort((a,b) => (a.name || '').localeCompare(b.name || ''));
    } else if (SORT_MODE === 'random') {
      for (let i = sorted.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [sorted[i], sorted[j]] = [sorted[j], sorted[i]];
      }
    }
    return sorted;
  }
  function renderFilters() {
    const grid = $('tex-grid');
    grid.innerHTML = '';
    const shown = sortTextures(TEXTURES.filter(passesFilter));
    const frag = document.createDocumentFragment();
    let currentSection = null;
    for (const tex of shown) {
      // Section dividers when grouped by category
      if (SORT_MODE === 'category' && tex.category !== currentSection) {
        currentSection = tex.category;
        const hdr = document.createElement('div');
        hdr.style.cssText = 'grid-column:1/-1; font:600 11px var(--sans); letter-spacing:.12em; text-transform:uppercase; color:var(--ink); padding:8px 0 4px; border-bottom:1px solid var(--line); margin-top:6px;';
        hdr.textContent = `${currentSection || 'uncategorized'} (${shown.filter(x => x.category === currentSection).length})`;
        frag.appendChild(hdr);
      }
      const chip = document.createElement('div');
      chip.className = 'tex-chip';
      chip.title = tex.name;
      chip.dataset.url = tex.image_url;
      chip.dataset.name = tex.name;
      const dot = tex.hex ? `<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${tex.hex};border:1px solid rgba(0,0,0,.15);margin-right:5px;vertical-align:middle"></span>` : '';
      chip.innerHTML = `
        <div class="thumb"><img loading="lazy" src="${tex.image_url}" alt=""></div>
        <div class="lbl">${tex.name}</div>
        <div class="brand">${dot}${tex.category || ''}</div>
      `;
      chip.onclick = () => applyTexture(tex, chip);
      frag.appendChild(chip);
    }
    grid.appendChild(frag);
    $('stats').textContent = `${shown.length} of ${TEXTURES.length} textures · ${RESULT_COUNT} results`;
    $('tex-count').textContent = `${shown.length} textures`;
  }

  document.querySelectorAll('#filters-mat button').forEach(b => {
    b.onclick = () => {
      document.querySelectorAll('#filters-mat button').forEach(x => x.classList.remove('active'));
      b.classList.add('active');
      MAT_FILTER = b.dataset.f;
      renderFilters();
    };
  });
  $('tex-sort').onchange = (e) => { SORT_MODE = e.target.value; renderFilters(); };

  $('load-btn').onclick = loadSource;
  $('did-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadSource(); });

  async function loadSource() {
    const id = parseInt($('did-input').value, 10);
    if (!Number.isFinite(id)) { toast('enter a design id'); return; }
    CURRENT_DID = id;
    $('source-card').innerHTML = `<img src="/designs/img/by-id/${id}" alt="${id}">`;
    try {
      const r = await fetch('/api/ghost-review/design/' + id);
      const j = await r.json();
      if (j.item) {
        $('source-meta').innerHTML = `<b>#${j.item.id}</b> · ${j.item.category || '—'}`;
      } else {
        $('source-meta').innerHTML = `<b>#${id}</b>`;
      }
    } catch { $('source-meta').innerHTML = `<b>#${id}</b>`; }
    toast('source loaded — click any texture chip');
  }

  async function applyTexture(tex, chip) {
    // Remember the last-clicked for bulk-apply
    SELECTED_TEXTURE = tex;
    updateBulkButton();
    if (!CURRENT_DID) { toast('load a source design first'); return; }
    document.querySelectorAll('.tex-chip').forEach(c => c.classList.remove('active'));
    chip.classList.add('active');
    // Make a result card now (with loading state)
    const results = $('results');
    const card = document.createElement('div');
    card.className = 'result';
    card.innerHTML = `
      <div class="head">
        <span class="tex-name">${tex.name}</span>
        <span style="color:var(--faint);font:10px var(--sans);text-transform:uppercase;letter-spacing:.06em">${tex.category || ''}</span>
        <span class="elapsed" id="el-${RESULT_COUNT}">⏳…</span>
      </div>
      <div class="img-box loading" id="ib-${RESULT_COUNT}"></div>
      <div class="actions">
        <a href="#" id="a-link-${RESULT_COUNT}" target="_blank" rel="noopener noreferrer">View</a>
        <button id="b-again-${RESULT_COUNT}">Retry</button>
      </div>
    `;
    results.prepend(card);
    const idx = RESULT_COUNT++;
    $('stats').textContent = `${TEXTURES.filter(passesFilter).length} textures · ${RESULT_COUNT} results`;
    const t0 = Date.now();
    try {
      const r = await fetch(`/api/design/${CURRENT_DID}/replace-bg`, {
        method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({
          texture_image_url: tex.image_url,
          texture_name: tex.name,
        }),
      });
      const j = await r.json();
      if (!r.ok) throw new Error(j.error || ('http ' + r.status));
      const dt = ((Date.now() - t0) / 1000).toFixed(1);
      const box = document.getElementById('ib-' + idx);
      box.classList.remove('loading');
      box.innerHTML = `<img src="/designs/img/by-id/${j.new_id}?t=${Date.now()}" alt="#${j.new_id}">`;
      document.getElementById('el-' + idx).textContent = `${dt}s → #${j.new_id}`;
      document.getElementById('a-link-' + idx).href = `/design/${j.new_id}`;
      document.getElementById('b-again-' + idx).onclick = () => applyTexture(tex, chip);
      toast(`✓ #${j.new_id} in ${dt}s`);
    } catch (e) {
      const dt = ((Date.now() - t0) / 1000).toFixed(1);
      const box = document.getElementById('ib-' + idx);
      box.classList.remove('loading');
      box.classList.add('error');
      box.dataset.err = e.message.slice(0, 80);
      document.getElementById('el-' + idx).textContent = `${dt}s · err`;
      toast('failed: ' + e.message);
    }
  }

  // Canonical fmtDate — matches admin/cactus-curator.html. Year + month + day + h:mm.
  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' });
  }

  // Load recent designs sidebar
  async function loadRecent() {
    try {
      const r = await fetch('/api/new-designs?hours=72');
      const j = await r.json();
      const grid = $('recent-grid');
      grid.innerHTML = '';
      const items = (j.items || []).slice(0, 36);
      for (const it of items) {
        const cell = document.createElement('div');
        cell.style.cssText = 'aspect-ratio:1; background:#f3eee2; border:1px solid var(--line); border-radius:4px; overflow:hidden; cursor:pointer; position:relative;';
        cell.title = `#${it.id} · ${it.category || ''} · ${it.created_at || ''} · click to load`;
        cell.innerHTML = `
          <img loading="lazy" src="/designs/img/by-id/${it.id}" alt="${it.id}" style="width:100%; height:100%; object-fit:cover; display:block;">
          <div style="position:absolute; top:3px; right:3px; background:rgba(31,24,8,.78); color:#fff; font:600 8.5px ui-monospace,Menlo,monospace; padding:1px 5px; border-radius:2px;">#${it.id}</div>
          <div class="when" title="${it.created_at || ''}">🕓 ${fmtDate(it.created_at)}</div>
        `;
        cell.onclick = () => {
          $('did-input').value = it.id;
          loadSource();
        };
        grid.appendChild(cell);
      }
    } catch (e) { console.warn('recent load failed:', e.message); }
  }

  // Auto-pick 4 textures — one each from 4 distinct categories
  $('auto-pick').onclick = async () => {
    if (!CURRENT_DID) { toast('load a source design first'); return; }
    const wantedCats = ['grasscloth', 'linen', 'cork', 'mica'];
    const picks = [];
    for (const cat of wantedCats) {
      const candidates = TEXTURES.filter(t => (t.category || '').toLowerCase() === cat);
      if (candidates.length) {
        picks.push(candidates[Math.floor(Math.random() * candidates.length)]);
      }
    }
    // Fall back if some categories missing
    while (picks.length < 4 && TEXTURES.length > picks.length) {
      const rand = TEXTURES[Math.floor(Math.random() * TEXTURES.length)];
      if (!picks.includes(rand)) picks.push(rand);
    }
    toast(`firing ${picks.length} textures in parallel · ~10s each`);
    for (const tex of picks) {
      const chip = [...document.querySelectorAll('.tex-chip')].find(c => c.dataset.url === tex.image_url);
      applyTexture(tex, chip || document.createElement('div'));
    }
  };

  // ── Bulk Apply ───────────────────────────────────────────────────────
  function updateBulkButton() {
    const btn = $('bulk-fire');
    const cat = $('bulk-cat').value;
    if (SELECTED_TEXTURE && cat) {
      btn.disabled = false;
      btn.style.opacity = '1';
      btn.style.background = 'var(--red)';
      btn.style.color = '#fff';
      btn.textContent = `🚀 Apply ${SELECTED_TEXTURE.name} to ${cat} (${$('bulk-limit').value} designs)`;
    } else if (SELECTED_TEXTURE && !cat) {
      btn.disabled = true;
      btn.style.opacity = '.5';
      btn.style.background = '#fff';
      btn.style.color = 'var(--red)';
      btn.textContent = `🚀 Pick a category to bulk-apply ${SELECTED_TEXTURE.name}`;
    } else {
      btn.disabled = true;
      btn.style.opacity = '.5';
      btn.style.background = '#fff';
      btn.style.color = 'var(--red)';
      btn.textContent = '🚀 Click a texture chip first';
    }
  }
  async function loadCategories() {
    try {
      const r = await fetch('/api/categories');
      if (!r.ok) return;
      const j = await r.json();
      const sel = $('bulk-cat');
      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 (e) {
      // Fallback: hardcoded popular categories
      const fallback = ['drunk-animals','muybridge-plate','designer-zoo-calm','face-skull-damask','cactus','damask','stripe','chinoiserie','designer-scenic'];
      const sel = $('bulk-cat');
      for (const c of fallback) {
        const opt = document.createElement('option');
        opt.value = c; opt.textContent = c;
        sel.appendChild(opt);
      }
    }
  }
  $('bulk-cat').onchange = updateBulkButton;
  $('bulk-limit').oninput = updateBulkButton;
  $('bulk-fire').onclick = async () => {
    if (!SELECTED_TEXTURE) { toast('pick a texture first'); return; }
    const cat = $('bulk-cat').value;
    if (!cat) { toast('pick a category'); return; }
    const limit = parseInt($('bulk-limit').value, 10) || 100;
    if (!confirm(`Apply "${SELECTED_TEXTURE.name}" to up to ${limit} designs in "${cat}"?\n\n~${(limit * 0.005).toFixed(2)} USD · ~${Math.ceil(limit * 3.3 / 60)} min wall clock at concurrency 3.`)) return;
    const stat = $('bulk-status');
    stat.style.display = '';
    stat.textContent = '🚀 launching…';
    try {
      const r = await fetch('/api/bulk-bg-swap', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          category: cat,
          texture_image_url: SELECTED_TEXTURE.image_url,
          texture_name: SELECTED_TEXTURE.name,
          limit,
        }),
      });
      const j = await r.json();
      if (!j.ok) throw new Error(j.error || 'launch failed');
      stat.innerHTML = `🚀 job <b>${j.job_id}</b> launched (pid ${j.pid}) · polling…`;
      pollBulk(j.job_id);
    } catch (e) {
      stat.style.background = '#fce8e6';
      stat.style.borderLeftColor = 'var(--red)';
      stat.innerHTML = `✗ ${e.message}`;
    }
  };
  async function pollBulk(jobId) {
    const stat = $('bulk-status');
    let tries = 0;
    const interval = setInterval(async () => {
      try {
        const r = await fetch(`/api/bulk-bg-swap/${jobId}/tail`);
        const j = await r.json();
        if (j.ok) {
          stat.innerHTML = `🚀 job <b>${jobId}</b> · <b>${j.ok_count}</b> ok · ${j.err_count} err ${j.done ? ' · <span style="color:var(--green)">✓ DONE</span>' : '· polling…'}`;
          if (j.done) {
            clearInterval(interval);
            stat.style.background = '#eef7ed';
            stat.style.borderLeftColor = 'var(--green)';
          }
        }
      } catch {}
      if (++tries > 1200) clearInterval(interval);
    }, 5000);
  }

  // Pre-load 39341 if nothing else specified
  loadTextures().then(() => {
    loadRecent();
    loadCategories();
    const q = new URLSearchParams(location.search);
    const did = q.get('design_id') || q.get('id');
    if (did) {
      $('did-input').value = did;
      loadSource();
    }
  });
})();
</script>
  <script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>