← back to Wallco Ai

public/js/admin.js

198 lines

/* wallco.ai admin UI helpers */
(function () {
  // Density slider
  const dens = document.getElementById('density');
  if (dens) {
    dens.value = localStorage.getItem('wc.admin.density') || 5;
    document.documentElement.style.setProperty('--cols', dens.value);
    dens.addEventListener('input', () => {
      document.documentElement.style.setProperty('--cols', dens.value);
      localStorage.setItem('wc.admin.density', dens.value);
    });
  }

  // "+ Inspiration" buttons on product cards
  document.querySelectorAll('.p-card .p-add').forEach(btn => {
    btn.addEventListener('click', async (e) => {
      e.preventDefault();
      const card = btn.closest('.p-card');
      const payload = {
        vendor_code: card.dataset.vendor,
        mfr_sku:     card.dataset.sku,
        pattern_name:card.dataset.pattern,
        image_url:   card.dataset.img,
        product_url: card.dataset.url
      };
      btn.disabled = true; btn.textContent = '…';
      try {
        const r = await fetch('/api/admin/inspiration/add', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload), credentials: 'include'
        });
        if (r.ok) { card.classList.add('is-picked'); btn.textContent = '✓ Added'; }
        else      { btn.textContent = '✗ failed'; btn.disabled = false; }
      } catch (e2) { btn.textContent = '✗ ' + e2.message.slice(0,20); btn.disabled = false; }
    });
  });

  // Inspiration page: remove buttons
  document.querySelectorAll('.p-remove').forEach(btn => {
    btn.addEventListener('click', async () => {
      const id = parseInt(btn.dataset.id, 10);
      btn.disabled = true; btn.textContent = '…';
      const r = await fetch('/api/admin/inspiration/remove', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ id })
      });
      if (r.ok) btn.closest('.p-card').remove(); else btn.disabled = false;
    });
  });

  // Two-step Generate flow + Export decisions.json.
  //   Step 1 — Click gen-btn → POST /api/admin/inspiration/prompts → 3 SDXL
  //            prompts come back with per-prompt settlement_warnings. The
  //            admin can edit each prompt in a textarea before rendering.
  //   Step 2 — Click "Render →" on a card → POST /api/admin/inspiration/generate
  //            with { prompts:[edited] } → just THAT prompt is screened +
  //            sent to Replicate. The card swaps in either the new design
  //            tile or a clear error (blocked term, network, etc.).
  // Every action lands in localStorage `wallco.admin.decisions` so the admin
  // has an audit trail of which prompts were edited, which rendered, and
  // which were blocked. "Export decisions.json" downloads that ledger.
  const gen = document.getElementById('gen-btn');
  if (gen) {
    const DEC_KEY = 'wallco.admin.decisions';
    const loadDecisions = () => { try { return JSON.parse(localStorage.getItem(DEC_KEY) || '[]'); } catch { return []; } };
    const saveDecision = (d) => {
      const a = loadDecisions();
      a.push({ ts: new Date().toISOString(), ...d });
      localStorage.setItem(DEC_KEY, JSON.stringify(a));
    };
    const escHtml = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    const origGenText = gen.textContent;

    function refreshDecCount() {
      const el = document.getElementById('dec-count');
      if (el) el.textContent = loadDecisions().length;
    }

    function renderPromptTray(container, prompts, warnings) {
      container.innerHTML =
        '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">'
        + '<h2 style="font:300 22px/1 var(--serif,Georgia);margin:0;color:var(--ink,#1a1208)">'
        + prompts.length + ' prompt' + (prompts.length === 1 ? '' : 's') + ' — edit, then render</h2>'
        + '<button id="dec-export" type="button" style="padding:6px 12px;border:1px solid var(--gold,#c69b3e);background:transparent;color:var(--gold,#c69b3e);border-radius:4px;font:600 11px var(--sans,system-ui);letter-spacing:.08em;text-transform:uppercase;cursor:pointer">'
        + 'Export decisions.json (<span id="dec-count">' + loadDecisions().length + '</span>)</button>'
        + '</div>'
        + prompts.map(function (p, i) {
            const w = warnings[i] || {};
            const warnBanner = w.verdict === 'BLOCK'
              ? '<div style="margin:8px 0;padding:8px 10px;background:#3a2419;border-left:3px solid #c46a3a;color:#e6b896;font:12px var(--sans,system-ui);border-radius:0 4px 4px 0">⚖ Contains "<b>' + escHtml(w.term) + '</b>" — the settlement gate will BLOCK this. Edit it out, then render.</div>'
              : '<div style="margin:4px 0 8px;font:11px var(--sans,system-ui);color:#7fc77f;letter-spacing:.05em">✓ settlement-safe</div>';
            return ''
              + '<div class="dec-card" data-i="' + i + '" data-original="' + escHtml(p) + '"'
              + ' style="margin-bottom:16px;padding:14px;background:var(--card-bg,#1a140c);border:1px solid var(--card-border,#3a2f1a);border-radius:6px">'
              + '<div style="font:600 11px var(--sans,system-ui);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-muted,#8a7960);margin-bottom:6px">Prompt ' + (i + 1) + '</div>'
              + warnBanner
              + '<textarea class="dec-prompt" rows="4" style="width:100%;min-height:80px;padding:9px 11px;background:#0f0c08;color:var(--ink,#f4ece0);border:1px solid var(--card-border,#3a2f1a);border-radius:4px;font:13px/1.5 var(--sans,system-ui);box-sizing:border-box;resize:vertical">' + escHtml(p) + '</textarea>'
              + '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">'
              +   '<span style="font:11px var(--sans,system-ui);color:var(--ink-muted,#8a7960)">≈ $0.003 to render via Replicate · ~30 s</span>'
              +   '<button class="dec-render" type="button" style="padding:7px 14px;border:0;border-radius:4px;background:var(--gold,#c69b3e);color:#1a1208;font:600 11px var(--sans,system-ui);letter-spacing:.08em;text-transform:uppercase;cursor:pointer">Render →</button>'
              + '</div>'
              + '<div class="dec-result" style="margin-top:10px"></div>'
              + '</div>';
          }).join('');

      container.querySelectorAll('.dec-render').forEach(btn => {
        btn.addEventListener('click', async () => {
          const card = btn.closest('.dec-card');
          const original = card.dataset.original;
          const ta = card.querySelector('.dec-prompt');
          const edited = (ta.value || '').trim();
          const resultEl = card.querySelector('.dec-result');
          if (edited.length < 10) { resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">Prompt too short — write at least 10 chars.</div>'; return; }
          btn.disabled = true; btn.textContent = 'Rendering…';
          resultEl.innerHTML = '<div style="color:var(--ink-muted,#8a7960);font:12px var(--sans,system-ui)">~30 s on Replicate…</div>';
          const decBase = { original_prompt: original, edited_prompt: edited, edited: edited !== original, action: 'render' };
          try {
            const r = await fetch('/api/admin/inspiration/generate', {
              method: 'POST', headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ prompts: [edited] }), credentials: 'include'
            });
            const j = await r.json().catch(() => ({}));
            if (!r.ok) {
              const msg = j.error || ('HTTP ' + r.status);
              const hint = j.hint ? ' — ' + j.hint : (j.url ? ' (url: ' + j.url + ')' : '');
              resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">' + escHtml(msg) + escHtml(hint) + '</div>';
              saveDecision({ ...decBase, result: { error: msg, url: j.url || null } });
            } else {
              const d = (j.designs || [])[0] || {};
              if (d.id) {
                resultEl.innerHTML =
                  '<a href="/design/' + d.id + '" style="display:flex;gap:12px;align-items:center;padding:10px;background:#0f0c08;border:1px solid var(--card-border,#3a2f1a);border-radius:4px;text-decoration:none;color:inherit">'
                  + '<div style="width:60px;height:60px;background:url(\'' + escHtml(d.image_url) + '\') center/cover;border-radius:3px;flex:none"></div>'
                  + '<div><div style="font:600 13px var(--sans,system-ui)">Design #' + d.id + ' ✓</div>'
                  + '<div style="font:11px var(--sans,system-ui);color:var(--ink-muted,#8a7960)"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + escHtml(d.dominant_hex || '#888') + ';vertical-align:middle;margin-right:4px"></span>' + escHtml(d.dominant_hex || '') + '</div></div>'
                  + '</a>';
                saveDecision({ ...decBase, result: { id: d.id, image_url: d.image_url, dominant_hex: d.dominant_hex } });
              } else {
                const err = d.error || 'no_design_returned';
                const term = d.term ? ' — blocked term: "' + d.term + '"' : '';
                resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">⚠ ' + escHtml(err) + escHtml(term) + '. Edit the prompt and try again.</div>';
                saveDecision({ ...decBase, result: { error: err, blocked_term: d.term || null } });
              }
            }
          } catch (e) {
            resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">Network error: ' + escHtml(e.message) + '</div>';
            saveDecision({ ...decBase, result: { error: 'network: ' + e.message } });
          }
          btn.disabled = false; btn.textContent = 'Render →';
          refreshDecCount();
        });
      });

      document.getElementById('dec-export').addEventListener('click', () => {
        const data = { exported_at: new Date().toISOString(), decisions: loadDecisions() };
        const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'wallco-decisions-' + new Date().toISOString().slice(0, 10) + '.json';
        document.body.appendChild(a); a.click(); a.remove();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
      });
    }

    gen.addEventListener('click', async () => {
      gen.disabled = true;
      gen.textContent = 'Fetching 3 prompts from Ollama… (~15 s)';
      try {
        const r = await fetch('/api/admin/inspiration/prompts', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          credentials: 'include'
        });
        const j = await r.json().catch(() => ({}));
        if (!r.ok) {
          const msg = j.error || ('HTTP ' + r.status);
          const hint = j.hint ? '\n\n' + j.hint : '';
          const url = j.url ? '\n\nURL tried: ' + j.url : '';
          alert('Prompt fetch failed: ' + msg + hint + url);
          gen.disabled = false; gen.textContent = origGenText; return;
        }
        saveDecision({ action: 'fetched_prompts', prompts: j.prompts, settlement_warnings: j.settlement_warnings });
        const prev = document.getElementById('dec-tray'); if (prev) prev.remove();
        const out = document.createElement('div');
        out.id = 'dec-tray';
        out.style.cssText = 'margin:20px 0';
        gen.after(out);
        renderPromptTray(out, j.prompts, j.settlement_warnings || []);
        gen.textContent = '↺ Re-fetch prompts';
        gen.disabled = false;
      } catch (e) {
        alert('Network error: ' + e.message);
        gen.disabled = false; gen.textContent = origGenText;
      }
    });
  }
})();