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