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