← back to Wallco Ai
public/luxe-curator.html
365 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Luxe Curator — hot-or-not picker</title>
<style>
:root {
--bg:#fafaf8; --fg:#1a1a1a; --muted:#6a6a6a; --card:#fff;
--border:#e8e6e1; --accent:#8b1f1f; --gold:#c9a14b;
--pick:#1d6b3a; --reject:#7a7a7a; --root:#2a4d6e;
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; }
header { padding:18px 28px 14px; border-bottom:1px solid var(--border); background:var(--card); position:sticky; top:0; z-index:10; }
header h1 { margin:0 0 4px; font-size:18px; font-weight:600; letter-spacing:-0.01em; }
header h1 .badge { display:inline-block; padding:2px 8px; font-size:11px; font-weight:600; border-radius:10px; background:#1a1a1a; color:#fff; margin-left:8px; vertical-align:middle; }
header .stats { color:var(--muted); font-size:12.5px; }
header .stats b { color:var(--fg); }
header .keyhelp { font-size:11px; color:var(--muted); margin-top:6px; }
header .keyhelp kbd { display:inline-block; padding:1px 5px; font-size:10px; background:#f1efe9; border:1px solid var(--border); border-radius:3px; font-family:ui-monospace,Menlo,monospace; margin:0 2px; }
main { padding:22px 28px 80px; }
.row {
display:grid; grid-template-columns:repeat(4, 1fr); gap:12px;
margin-bottom:18px; background:var(--card); border:2px solid var(--border);
border-radius:10px; padding:12px; transition:opacity .25s, border-color .15s, box-shadow .15s;
}
.row.decided { opacity:0.4; pointer-events:none; }
.row.focused {
border-color:var(--root);
box-shadow:0 0 0 4px rgba(42,77,110,0.12);
}
.row.focused .meta::after {
content:'⌨ focused — R=regen all 3 variants · 2/3/4=pick variant · 1/K=keep root';
display:block; font:10px ui-monospace,Menlo,monospace; color:var(--root);
margin-top:4px; letter-spacing:.02em;
}
.row .tile { position:relative; aspect-ratio:1; background:#f0eee8; overflow:hidden; border-radius:6px; cursor:pointer; transition:transform .12s, box-shadow .12s; border:3px solid transparent; }
.row .tile:hover { transform:scale(1.02); box-shadow:0 4px 14px rgba(0,0,0,0.08); }
.row .tile.picked { border-color:var(--pick); box-shadow:0 0 0 2px rgba(29,107,58,0.18); }
.row .tile img { width:100%; height:100%; object-fit:cover; display:block; }
.row .tile .label {
position:absolute; top:6px; left:6px; padding:2px 8px; font-size:10px;
letter-spacing:0.08em; text-transform:uppercase; border-radius:2px;
background:rgba(0,0,0,0.78); color:#fff; font-weight:600;
}
.row .tile .label.root { background:var(--root); }
.row .tile .label.A { background:#9b6b3f; }
.row .tile .label.B { background:#7b3b8e; }
.row .tile .label.C { background:#8c283c; }
.row .tile .label.D { background:#3f6b9b; }
.row .tile .label.E { background:#5a7a3a; }
.row .tile .ground {
position:absolute; bottom:6px; right:6px; padding:2px 6px; font-size:9.5px;
letter-spacing:0.05em; background:rgba(255,255,255,0.92); color:#1a1a1a;
border-radius:2px; font-weight:600;
}
.row .tile .when {
position:absolute; bottom:6px; left:6px; padding:2px 6px; font-size:9.5px;
background:rgba(255,255,255,0.92); color:#1a1a1a; border-radius:2px;
font-weight:600; font-variant-numeric:tabular-nums; letter-spacing:.01em;
}
.row .tile .regen-btn {
position:absolute; top:6px; right:6px; width:24px; height:24px;
border-radius:50%; background:rgba(255,255,255,0.92); border:none;
color:#1a1a1a; font-size:14px; cursor:pointer; display:flex;
align-items:center; justify-content:center; opacity:0;
transition:opacity .15s, transform .15s; line-height:1; padding:0;
}
.row .tile:hover .regen-btn { opacity:1; }
.row .tile .regen-btn:hover { background:#fff; transform:scale(1.1); }
.row .tile .regen-btn:disabled { cursor:wait; }
.row .tile.regenerating { position:relative; }
.row .tile.regenerating::after {
content:'⟳ regenerating…'; position:absolute; inset:0;
background:rgba(255,255,255,0.86); display:flex; align-items:center; justify-content:center;
font:600 11px var(--sans); color:var(--root); letter-spacing:.04em;
text-transform:uppercase; z-index:5;
}
.row .meta {
grid-column:1/-1; display:flex; justify-content:space-between; align-items:center;
font-size:11.5px; color:var(--muted); padding:4px 4px 0;
}
.row .meta .motif { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:8px; }
.row .meta .pick-status { font-weight:600; }
.row .meta button.keep-root {
padding:5px 12px; border:1px solid var(--border); background:var(--card);
border-radius:14px; font:11px var(--sans); cursor:pointer; color:var(--reject);
text-transform:uppercase; letter-spacing:.06em; font-weight:600;
}
.row .meta button.keep-root:hover { background:#f1efe9; border-color:var(--reject); }
.row .meta button.keep-root.picked { background:#f1efe9; color:var(--fg); }
.empty { padding:60px; text-align:center; color:var(--muted); font-size:14px; }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:8px 16px; background:#1a1a1a; color:#fff; border-radius:20px; font-size:12.5px; opacity:0; transition:opacity .2s; pointer-events:none; z-index:99; }
.toast.show { opacity:1; }
</style>
</head>
<body>
<header>
<h1>Luxe Curator <span class="badge" id="count">…</span></h1>
<div class="stats">
<b id="pendingCount">…</b> roots pending · <b id="decidedCount">…</b> decided.
Click <b>any variant</b> to pick → publishes that luxe + unpublishes the root family.
Click <b>"keep root"</b> to reject all 3 variants and keep the original.
</div>
<div class="keyhelp">
<kbd>1</kbd>/<kbd>K</kbd> keep root · <kbd>2</kbd> pick A · <kbd>3</kbd> pick C · <kbd>4</kbd> pick E · <kbd>R</kbd> regen all 3 variants in focused row (~$0.12) · <kbd>↓</kbd>/<kbd>J</kbd> next · <kbd>↑</kbd>/<kbd>Shift+K</kbd> prev
</div>
</header>
<main>
<div id="rows"><div class="empty">Loading…</div></div>
</main>
<div class="toast" id="toast"></div>
<script>
let DATA = [];
let FOCUS = 0;
async function loadData() {
const r = await fetch('/api/luxe-curator');
if (!r.ok) throw new Error('fetch failed: ' + r.status);
return r.json();
}
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' });
}
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._tm);
t._tm = setTimeout(() => t.classList.remove('show'), 1600);
}
function render() {
const main = document.getElementById('rows');
document.getElementById('count').textContent = DATA.length;
document.getElementById('pendingCount').textContent = DATA.length;
if (!DATA.length) {
main.innerHTML = '<div class="empty">No pending roots. Queue some with <code>node scripts/queue-luxe-curator.js --limit=10</code></div>';
return;
}
main.innerHTML = DATA.map((p, i) => {
const variantTiles = p.variants.map(v => `
<div class="tile" data-pick="variant" data-luxe-id="${v.new_id}" data-variant="${v.variant}" data-row="${i}" title="Pick variant ${v.variant} (${v.ground})">
<img loading="lazy" src="/designs/img/by-id/${v.new_id}" alt="${v.variant}">
<span class="label ${v.variant}">Variant ${v.variant}</span>
<span class="ground">${v.ground}</span>
<button class="regen-btn" data-regen="${v.variant}" data-row="${i}" title="Regenerate this variant (~10s, ~$0.04)">↻</button>
</div>
`).join('');
return `
<div class="row" data-row="${i}" data-root="${p.root_id}" id="row-${i}">
<div class="tile" data-pick="root" data-row="${i}" title="Keep root (don't publish any variant)">
<img loading="lazy" src="/designs/img/by-id/${p.root_id}?asis=1" alt="root ${p.root_id}">
<span class="label root">Root #${p.root_id}</span>
<span class="when" title="queued ${p.created_at||''}">🕓 ${fmtDate(p.created_at)}</span>
</div>
${variantTiles}
<div class="meta">
<span class="motif" title="${p.motif||''}">${p.motif || ''}</span>
<span class="pick-status" id="status-${i}"></span>
<button class="keep-root" data-pick="keep" data-row="${i}">Keep root — reject all 3</button>
</div>
</div>
`;
}).join('');
// Wire clicks
main.querySelectorAll('[data-pick]').forEach(el => {
el.addEventListener('click', (e) => {
// Regen button click bubbles up — let the regen handler claim it instead
if (e.target.matches('[data-regen]')) return;
const kind = el.dataset.pick;
const row = parseInt(el.dataset.row, 10);
if (kind === 'variant') {
pickVariant(row, parseInt(el.dataset.luxeId, 10), el.dataset.variant);
} else if (kind === 'keep' || kind === 'root') {
keepRoot(row);
}
});
});
// Regen buttons
main.querySelectorAll('[data-regen]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const row = parseInt(btn.dataset.row, 10);
const variant = btn.dataset.regen;
regenVariant(row, variant);
});
});
}
async function regenVariant(rowIdx, variant) {
const p = DATA[rowIdx];
if (!p) return;
// Mark the tile as regenerating
const tile = document.querySelector(`#row-${rowIdx} [data-variant="${variant}"]`);
if (tile) {
tile.classList.add('regenerating');
tile.querySelector('.regen-btn')?.setAttribute('disabled', 'true');
}
toast(`Regenerating ${variant} for root #${p.root_id} (~10s)`);
try {
const r = await fetch('/api/luxe-curator/regen', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ root_id: p.root_id, variant }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'regen failed');
// Swap the image to the new luxe id without a full reload — update DATA + re-render this row
const vIdx = p.variants.findIndex(v => v.variant === variant);
if (vIdx >= 0) {
p.variants[vIdx].new_id = j.new_id;
p.variants[vIdx].duration_ms = j.duration_ms;
}
// Re-render just this row
if (tile) {
const img = tile.querySelector('img');
if (img) img.src = `/designs/img/by-id/${j.new_id}?t=${Date.now()}`;
tile.dataset.luxeId = String(j.new_id);
tile.classList.remove('regenerating');
tile.querySelector('.regen-btn')?.removeAttribute('disabled');
}
toast(`Variant ${variant} regenerated → #${j.new_id} (${(j.duration_ms/1000).toFixed(1)}s)`);
} catch (e) {
if (tile) {
tile.classList.remove('regenerating');
tile.querySelector('.regen-btn')?.removeAttribute('disabled');
}
toast('Regen failed: ' + e.message);
}
}
async function pickVariant(rowIdx, luxeId, variant) {
const p = DATA[rowIdx];
if (!p) return;
try {
const r = await fetch('/api/luxe-curator/pick', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ root_id: p.root_id, picked_variant: variant, picked_luxe_id: luxeId }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'pick failed');
// Mark UI
const row = document.getElementById('row-' + rowIdx);
row.querySelectorAll('.tile').forEach(t => t.classList.remove('picked'));
row.querySelector(`[data-luxe-id="${luxeId}"]`)?.classList.add('picked');
document.getElementById('status-' + rowIdx).textContent = `✓ Variant ${variant} published · ${j.unpublished_descendants} descendants unpublished`;
row.classList.add('decided');
toast(`Variant ${variant} picked for root #${p.root_id}`);
advance(rowIdx);
} catch (e) { toast('ERR: ' + e.message); }
}
async function keepRoot(rowIdx) {
const p = DATA[rowIdx];
if (!p) return;
try {
const r = await fetch('/api/luxe-curator/pick', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ root_id: p.root_id, picked_variant: 'NONE' }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'keep failed');
const row = document.getElementById('row-' + rowIdx);
document.getElementById('status-' + rowIdx).textContent = '✗ All variants rejected — root kept';
row.classList.add('decided');
toast(`Root #${p.root_id} kept (all 3 rejected)`);
advance(rowIdx);
} catch (e) { toast('ERR: ' + e.message); }
}
function setFocus(rowIdx) {
// Strip prior focus + apply to new one
document.querySelectorAll('.row.focused').forEach(r => r.classList.remove('focused'));
const row = document.getElementById('row-' + rowIdx);
if (row) {
row.classList.add('focused');
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
FOCUS = rowIdx;
}
}
function advance(rowIdx) {
for (let i = rowIdx + 1; i < DATA.length; i++) {
const row = document.getElementById('row-' + i);
if (row && !row.classList.contains('decided')) { setFocus(i); return; }
}
}
// Regenerate ALL 3 variants of the focused row in parallel.
// Cost: ~$0.12 per call (3 × $0.04 Gemini Flash Image). Each tile shows
// its own "regenerating…" overlay; failures don't block the others.
async function regenFocusedRow(rowIdx) {
const p = DATA[rowIdx];
if (!p || !p.variants?.length) return;
toast(`Regenerating all ${p.variants.length} variants for root #${p.root_id} (~10-30s, ~$${(p.variants.length * 0.04).toFixed(2)})`);
// Fire all in parallel — each regenVariant is its own POST + tile update
await Promise.allSettled(p.variants.map(v => regenVariant(rowIdx, v.variant)));
toast(`Done regenerating row #${p.root_id}`);
}
document.addEventListener('keydown', (e) => {
if (e.target.matches('input,textarea')) return;
const p = DATA[FOCUS];
if (!p) return;
const key = e.key.toLowerCase();
// Navigation FIRST (shift modifies semantics, has to check before plain-key)
if ((key === 'k' && e.shiftKey) || key === 'arrowup') {
for (let i = FOCUS - 1; i >= 0; i--) {
if (!document.getElementById('row-' + i).classList.contains('decided')) {
setFocus(i); break;
}
}
e.preventDefault(); return;
}
if (key === 'j' || key === 'arrowdown') {
for (let i = FOCUS + 1; i < DATA.length; i++) {
if (!document.getElementById('row-' + i).classList.contains('decided')) {
setFocus(i); break;
}
}
e.preventDefault(); return;
}
// Picks
if (key === '1' || key === 'k') { keepRoot(FOCUS); e.preventDefault(); return; }
if (key === '2' || key === '3' || key === '4') {
const idx = parseInt(key, 10) - 2; // 2→0, 3→1, 4→2
const v = p.variants[idx];
if (v) { pickVariant(FOCUS, v.new_id, v.variant); e.preventDefault(); }
return;
}
// Regen
if (key === 'r') {
// shift+R confirms with a smaller toast; plain R goes straight (Steve hates
// confirmation prompts on muscle-memory keys per prior feedback)
regenFocusedRow(FOCUS);
e.preventDefault(); return;
}
});
// After any render, focus the first non-decided row so the keyboard works
// without requiring an explicit click.
function focusFirstUndecided() {
for (let i = 0; i < DATA.length; i++) {
const row = document.getElementById('row-' + i);
if (row && !row.classList.contains('decided')) { setFocus(i); break; }
}
}
loadData().then(d => {
DATA = d.items || [];
document.getElementById('decidedCount').textContent = d.decided || 0;
render();
focusFirstUndecided();
}).catch(e => {
document.getElementById('rows').innerHTML = '<div class="empty">Failed to load: ' + e.message + '</div>';
});
</script>
</body>
</html>