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