← back to Whatsmystyle
public/admin-config.html
315 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>WhatsMyStyle — Admin Config</title>
<link rel="stylesheet" href="/css/app.css" />
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: #faf7f2; color: #1d1d1f; max-width: 720px; margin: 40px auto; padding: 0 24px; }
h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 8px; }
.sub { color: #707070; margin-bottom: 32px; }
.pill-row { background: #fff; border: 1px solid #e6e1d8; border-radius: 24px; padding: 20px 24px; margin-bottom: 14px; display: flex; align-items: center; gap: 16px; }
.pill-row label { flex: 1; }
.pill-row .name { font-weight: 600; font-size: 16px; display: block; margin-bottom: 4px; }
.pill-row .hint { font-size: 13px; color: #707070; }
.pill-row input[type=number], .pill-row input[type=text] { font: inherit; padding: 10px 16px; border: 1px solid #d6d0c4; border-radius: 14px; width: 140px; text-align: right; background: #faf7f2; }
.pill-row input[type=checkbox] { width: 22px; height: 22px; }
button.save { font: inherit; font-weight: 600; background: #1d1d1f; color: #fff; border: 0; padding: 14px 28px; border-radius: 999px; cursor: pointer; margin-top: 8px; }
button.save:hover { background: #3a3a3c; }
.status { padding: 12px 16px; border-radius: 12px; margin-top: 16px; font-size: 14px; }
.status.ok { background: #e6f6ec; color: #14572a; }
.status.err { background: #fbe4e4; color: #7a1717; }
.nav { margin-bottom: 24px; }
.nav a { color: #707070; text-decoration: none; font-size: 14px; }
.nav a:hover { color: #1d1d1f; }
</style>
</head>
<body>
<div class="nav"><a href="/">← back to app</a></div>
<h1>Admin config</h1>
<!-- Tick 26: quick-link nav across all admin surfaces -->
<nav class="admin-quicklinks">
<a href="#config-section">Config knobs</a>
<a href="#catalog-section">Catalog</a>
<a href="/admin/cron">Cron</a>
<a href="/admin/drifts">Drifts</a>
<a href="#role-section">Roles</a>
</nav>
<style>
.admin-quicklinks {
display: flex; flex-wrap: wrap; gap: 8px;
margin-bottom: 24px;
padding-bottom: 14px;
border-bottom: 1px solid #e6e1d8;
}
.admin-quicklinks a {
font-size: 13px;
padding: 6px 14px;
border: 1px solid #d6d0c4;
border-radius: 999px;
background: #fff;
color: #555;
text-decoration: none;
}
.admin-quicklinks a:hover { background: #faf7f2; color: #1d1d1f; }
</style>
<p class="sub">Knobs the duel engine + budget guard read on every request. Saved values persist in <code>app_config</code>.</p>
<form id="cfg" data-section="config"><a id="config-section"></a>
<div class="pill-row">
<label>
<span class="name">Cold-start cutoff</span>
<span class="hint">How many duels before we drop budget anchoring and go pure-random. Default 3.</span>
</label>
<input type="number" min="0" max="50" step="1" name="cold_start_cutoff" id="cold_start_cutoff" />
</div>
<div class="pill-row">
<label>
<span class="name">Outlier injection rate</span>
<span class="hint">Probability that during cold-start we swap one item for a random outlier. 0–1. Default 0.2.</span>
</label>
<input type="number" min="0" max="1" step="0.05" name="outlier_injection_rate" id="outlier_injection_rate" />
</div>
<div class="pill-row">
<label>
<span class="name">Per-user render budget (cents)</span>
<span class="hint">Hard cap on paid try-on calls per user. 500 = $5. Default 500.</span>
</label>
<input type="number" min="0" step="50" name="budget_cents_per_user" id="budget_cents_per_user" />
</div>
<div class="pill-row">
<label>
<span class="name">Couples pilot</span>
<span class="hint">Feature flag for the shared-closet pilot. DB scaffold ready; UI ships in a later tick. Default off.</span>
</label>
<input type="checkbox" name="couples_pilot" id="couples_pilot" />
</div>
<div class="pill-row">
<label>
<span class="name">Embed drainer (Mac1)</span>
<span class="hint">Background llava embedding for new catalog items. Routes to Mac1 Ollama (192.168.1.133:11434, llava:13b). OFF by default — flip ON when Mac1 is idle. 5-min cron, 20 items/run.</span>
</label>
<input type="checkbox" name="embed_drainer_enabled" id="embed_drainer_enabled" />
</div>
<button class="save" type="submit">Save</button>
<div id="status"></div>
</form>
<hr style="margin: 40px 0; border: 0; border-top: 1px solid #e6e1d8;" />
<h2 id="role-section" style="font-size: 24px; font-weight: 600; letter-spacing: -0.02em;">User layer · preview</h2>
<p class="sub">WhatsMyStyle has three user tiers. As admin you see everything. Use this toggle to preview what a public consumer (general user) or a film/TV set-decoration buyer sees — without losing your admin session.</p>
<div class="pill-row">
<label>
<span class="name">View as</span>
<span class="hint">Flip to a non-admin role. Header shows an "exit preview" pill while active.</span>
</label>
<select id="view_as_select" style="font: inherit; padding: 10px 16px; border: 1px solid #d6d0c4; border-radius: 14px; background: #faf7f2;">
<option value="">Admin (full view)</option>
<option value="public">General user — public (retail)</option>
<option value="set_decorator">General user — set decorator</option>
</select>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px;">
<button id="open_retail" class="save" type="button">Open retail view →</button>
<button id="open_setdec" class="save" type="button" style="background:#5a3a1f;">Open set-decorator view →</button>
<button id="exit_preview" class="save" type="button" style="background:#fff;color:#1d1d1f;border:1px solid #d6d0c4;">Exit preview</button>
</div>
<div id="role_status" class="status" style="margin-top: 16px; display: none;"></div>
<hr style="margin: 40px 0; border: 0; border-top: 1px solid #e6e1d8;" />
<h2 id="catalog-section" style="font-size: 24px; font-weight: 600; letter-spacing: -0.02em;">Clothing-API catalog</h2>
<p class="sub">WhatsMyStyle pulls from public Shopify storefronts (zero key) and optional Etsy / eBay / Amazon (free keys). One adapter per source in <code>scripts/clothing-apis/</code>.</p>
<div id="catalog_status" class="pill-row" style="display: block;">
<span class="muted">Loading catalog status…</span>
</div>
<!-- Tick 19: live embed progress bar. Polls /api/admin/catalog-status every
30s while unembedded > 0, fades out when the catalog is fully embedded. -->
<div id="embed_progress" class="embed-progress" hidden>
<div class="embed-progress-head">
<span><strong id="embed_pct">0</strong>% embedded · <span id="embed_remaining">0</span> items left</span>
<span class="muted" id="embed_eta"></span>
</div>
<div class="embed-progress-track"><div class="embed-progress-fill" id="embed_fill"></div></div>
</div>
<style>
.embed-progress { margin: 12px 0 16px; padding: 14px 18px; background: #fff; border: 1px solid #e6e1d8; border-radius: 16px; }
.embed-progress.fading { opacity: 0; transition: opacity 1.2s ease-out; pointer-events: none; }
.embed-progress-head { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; margin-bottom: 8px; }
.embed-progress-track { height: 10px; background: #f3eee2; border-radius: 999px; overflow: hidden; }
.embed-progress-fill { height: 100%; background: linear-gradient(90deg, #c9a96e 0%, #6b4f1a 100%); border-radius: 999px; width: 0%; transition: width 0.6s ease-out; }
</style>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px;">
<button id="import_all_shopify" class="save" type="button">Import all Shopify brands →</button>
<button id="import_one" class="save" type="button" style="background:#5a3a1f;">Import one brand…</button>
<button id="refresh_status" class="save" type="button" style="background:#fff;color:#1d1d1f;border:1px solid #d6d0c4;">Refresh</button>
</div>
<div id="import_status" class="status" style="margin-top: 16px; display: none;"></div>
<p class="muted" style="margin-top: 14px; font-size: 13px;">
Note: newly imported items don't carry embeddings yet — they won't appear in duels or /For You until <code>node scripts/embed-items.js</code> runs (llava → 32-d projection). Drift sweep + nightly re-embed schedule planned.
</p>
<script>
const $ = (id) => document.getElementById(id);
const isAdminQ = location.search.includes('admin=1') ? '?admin=1' : '';
async function load() {
const r = await fetch('/api/admin/config' + isAdminQ);
if (!r.ok) { $('status').className = 'status err'; $('status').textContent = 'admin gate failed'; return; }
const { config, defaults } = await r.json();
$('cold_start_cutoff').value = config.cold_start_cutoff ?? defaults.cold_start_cutoff;
$('outlier_injection_rate').value = config.outlier_injection_rate ?? defaults.outlier_injection_rate;
$('budget_cents_per_user').value = config.budget_cents_per_user ?? defaults.budget_cents_per_user;
$('couples_pilot').checked = config.couples_pilot ?? defaults.couples_pilot;
$('embed_drainer_enabled').checked = config.embed_drainer_enabled ?? defaults.embed_drainer_enabled;
}
document.getElementById('cfg').addEventListener('submit', async (e) => {
e.preventDefault();
const body = {
cold_start_cutoff: parseInt($('cold_start_cutoff').value, 10),
outlier_injection_rate: parseFloat($('outlier_injection_rate').value),
budget_cents_per_user: parseInt($('budget_cents_per_user').value, 10),
couples_pilot: $('couples_pilot').checked,
embed_drainer_enabled: $('embed_drainer_enabled').checked,
};
const r = await fetch('/api/admin/config' + isAdminQ, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
});
const j = await r.json();
const s = $('status');
if (r.ok) { s.className = 'status ok'; s.textContent = 'Saved: ' + (j.written || []).join(', '); }
else { s.className = 'status err'; s.textContent = j.error || 'save failed'; }
});
load();
// ---- View-as toggle ----------------------------------------------------
async function loadRole() {
const r = await fetch('/api/me');
const me = await r.json();
$('view_as_select').value = me.view_as || '';
}
async function setViewAs(v) {
const r = await fetch('/api/admin/view-as' + isAdminQ, {
method: 'PUT', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ view_as: v === '' ? null : v }),
});
const j = await r.json();
const s = $('role_status');
s.style.display = 'block';
if (r.ok) { s.className = 'status ok'; s.textContent = v ? `Now previewing as: ${v}` : 'Exited preview. You are back to full admin view.'; }
else { s.className = 'status err'; s.textContent = j.error || 'failed'; }
$('view_as_select').value = v || '';
}
$('view_as_select').addEventListener('change', e => setViewAs(e.target.value));
$('open_retail').addEventListener('click', async () => { await setViewAs('public'); location.href = '/'; });
$('open_setdec').addEventListener('click', async () => { await setViewAs('set_decorator'); location.href = '/'; });
$('exit_preview').addEventListener('click', () => setViewAs(''));
loadRole();
// ---- Catalog status + import ------------------------------------------
let _embedHistory = []; // {ts, embedded} samples for ETA estimate
async function loadCatalogStatus() {
const r = await fetch('/api/admin/catalog-status' + isAdminQ);
const j = await r.json();
if (!r.ok) { $('catalog_status').innerHTML = '<span class="muted">' + (j.error || 'failed') + '</span>'; return; }
const bySource = (j.by_source || []).map(s => `<span style="display:inline-block;background:#faf7f2;border:1px solid #e6e1d8;padding:3px 10px;border-radius:999px;font-size:12px;margin:2px;">${s.source}: <strong>${s.c}</strong></span>`).join('');
$('catalog_status').innerHTML = `
<div><strong>${j.total}</strong> items total · <strong>${j.embedded}</strong> embedded · <strong>${j.unembedded}</strong> pending re-embed</div>
<div style="margin-top:8px;">${bySource}</div>
`;
// Tick 19: progress bar + ETA. Bar shows when unembedded > 0, fades
// when catalog is fully embedded.
const bar = $('embed_progress');
if (j.unembedded > 0) {
bar.hidden = false;
bar.classList.remove('fading');
const pct = j.total > 0 ? Math.round((j.embedded / j.total) * 100) : 0;
$('embed_pct').textContent = pct;
$('embed_remaining').textContent = j.unembedded;
$('embed_fill').style.width = pct + '%';
// ETA via two-point rate estimate from the polling history.
_embedHistory.push({ ts: Date.now(), embedded: j.embedded });
if (_embedHistory.length > 8) _embedHistory.shift();
if (_embedHistory.length >= 2) {
const first = _embedHistory[0], last = _embedHistory[_embedHistory.length - 1];
const dEmbedded = last.embedded - first.embedded;
const dSec = (last.ts - first.ts) / 1000;
if (dEmbedded > 0 && dSec > 0) {
const rate = dEmbedded / dSec; // items/sec
const etaSec = Math.round(j.unembedded / rate);
const etaH = Math.floor(etaSec / 3600);
const etaM = Math.round((etaSec % 3600) / 60);
$('embed_eta').textContent = `eta ${etaH > 0 ? etaH + 'h ' : ''}${etaM}m at ${(rate * 60).toFixed(1)}/min`;
} else {
$('embed_eta').textContent = dEmbedded === 0 ? 'no progress — drainer paused?' : '';
}
}
} else {
// fully embedded — fade then hide
if (!bar.hidden && !bar.classList.contains('fading')) {
bar.classList.add('fading');
setTimeout(() => { bar.hidden = true; }, 1300);
}
}
}
// 30s poll while the page is open.
setInterval(() => loadCatalogStatus().catch(() => {}), 30 * 1000);
async function runImport(body) {
const s = $('import_status');
s.style.display = 'block';
s.className = 'status';
s.textContent = 'Importing… this can take 1-3 min for all brands.';
try {
const r = await fetch('/api/admin/import-catalog' + isAdminQ, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
});
const j = await r.json();
if (r.ok) {
s.className = 'status ok';
const t = j.totals;
s.innerHTML = `Imported <strong>${t.imported}</strong> new items (fetched ${t.fetched}, errors ${t.errors}, skipped ${t.skipped}).`;
await loadCatalogStatus();
} else {
s.className = 'status err';
s.textContent = j.error || 'import failed';
}
} catch (e) {
s.className = 'status err';
s.textContent = e.message;
}
}
$('import_all_shopify').addEventListener('click', () => runImport({ kind: 'shopify' }));
$('import_one').addEventListener('click', () => {
const id = prompt('Shopify brand id (allbirds, reformation, pact, tradlands, cuyana, kotn, etc.):');
if (id) runImport({ kind: 'shopify', id: id.trim() });
});
$('refresh_status').addEventListener('click', () => loadCatalogStatus());
loadCatalogStatus();
</script>
</body>
</html>