← back to Site Factory

admin/public/editor.js

302 lines

// sf-admin editor — vanilla JS, no build step.
(() => {
  'use strict';

  const PORT_MAP = {
    'wholivedthere.com': 3001,
    'bubbesblock.com': 3002,
    'claimmyaddress.com': 3003,
  };

  const COPY_KEYS = ['hero_headline', 'hero_subhead', 'hero_cta', 'about', 'contact'];
  const PALETTE_KEYS = ['primary', 'secondary', 'accent', 'surface', 'ink'];

  const state = {
    sites: [],
    current: null,        // current site row
    skillTs: {},          // { [skill]: ISO ts }
    paletteDebounce: null,
  };

  const $ = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));

  // ─── api helpers ────────────────────────────────────────────────────────
  async function api(path, opts = {}) {
    const r = await fetch(path, {
      credentials: 'same-origin',
      headers: { 'content-type': 'application/json', ...(opts.headers || {}) },
      ...opts,
    });
    let body = null;
    try { body = await r.json(); } catch { /* non-json */ }
    if (!r.ok) {
      const msg = (body && (body.error || body.message)) || `HTTP ${r.status}`;
      throw new Error(msg);
    }
    return body;
  }

  function toast(msg, kind = 'ok') {
    const el = $('#toast');
    el.textContent = msg;
    el.className = `toast ${kind}`;
    setTimeout(() => el.classList.add('hidden'), 2400);
  }

  // ─── load + render sites ────────────────────────────────────────────────
  async function loadSites() {
    try {
      const me = await api('/api/me').catch(() => null);
      if (me && me.user) $('#me').textContent = me.user.email + (me.dev ? ' (dev)' : '');

      const { sites } = await api('/api/sites');
      state.sites = sites || [];
      $('#sites-count').textContent = String(state.sites.length);
      renderSitesList();

      // pick site from URL or first
      const urlDomain = decodeURIComponent((location.pathname.match(/^\/editor\/(.+)$/) || [])[1] || '');
      const target = state.sites.find(s => s.domain === urlDomain) || state.sites[0];
      if (target) selectSite(target.domain);
    } catch (e) {
      toast(`Failed to load sites: ${e.message}`, 'err');
    }
  }

  function renderSitesList() {
    const ul = $('#sites-list');
    ul.innerHTML = '';
    for (const s of state.sites) {
      const li = document.createElement('li');
      li.dataset.domain = s.domain;
      if (state.current && state.current.domain === s.domain) li.classList.add('active');
      const findings = Number(s.open_findings || 0);
      li.innerHTML = `
        <span class="domain">${escapeHtml(s.domain)}</span>
        <span class="meta">
          <span class="pip" style="background:${escapeAttr(s.primary_hex || '#444')}"></span>
          ${escapeHtml(s.palette_name || '—')} · stage ${Number(s.current_stage)}
          ${findings > 0 ? ` · <strong style="color:var(--danger)">${findings} ⚠</strong>` : ''}
        </span>`;
      li.addEventListener('click', () => selectSite(s.domain));
      ul.appendChild(li);
    }
  }

  // ─── select + populate ─────────────────────────────────────────────────
  async function selectSite(domain) {
    const site = state.sites.find(s => s.domain === domain);
    if (!site) return;
    state.current = site;
    history.replaceState(null, '', `/editor/${encodeURIComponent(domain)}`);

    // sites list active
    $$('#sites-list li').forEach(li => {
      li.classList.toggle('active', li.dataset.domain === domain);
    });

    // topbar
    $('#top-domain').textContent = domain;
    $('#top-palette').textContent = site.palette_name ? `Palette: ${site.palette_name}` : 'no palette assigned';

    // findings badge
    const fcount = Number(site.open_findings || 0);
    $('#findings-count').textContent = String(fcount);
    $('#findings-btn').classList.toggle('has-findings', fcount > 0);

    // palette pickers
    $('#palette-name').textContent = site.palette_name || '—';
    const overrides = site.theme_overrides || {};
    for (const key of PALETTE_KEYS) {
      const fallback = site[`${key}_hex`];
      const value = overrides[key] || fallback || '#000000';
      const input = $(`.picker input[data-key="${key}"]`);
      const code = $(`.picker input[data-key="${key}"] ~ code[data-hex]`);
      if (input) input.value = value;
      if (code) code.textContent = value;
    }
    $('#theme-status').textContent = '';

    // copy textareas
    const copy = site.copy_overrides || {};
    for (const k of COPY_KEYS) {
      const ta = $(`textarea[data-copy="${k}"]`);
      if (ta) ta.value = copy[k] || '';
    }
    $('#copy-status').textContent = '';

    // iframe
    const port = PORT_MAP[domain];
    const wrap = $('#iframe-wrap');
    const iframe = $('#preview');
    const empty = $('#iframe-empty');
    if (port) {
      const url = `http://localhost:${port}/`;
      iframe.src = url;
      $('#iframe-url').textContent = url;
      empty.classList.add('hidden');
    } else {
      iframe.removeAttribute('src');
      $('#iframe-url').textContent = `(no local dev port mapped for ${domain})`;
      empty.classList.remove('hidden');
    }
  }

  // ─── tabs ───────────────────────────────────────────────────────────────
  $$('.tab').forEach(t => t.addEventListener('click', () => {
    const name = t.dataset.tab;
    $$('.tab').forEach(x => x.classList.toggle('active', x === t));
    $$('.tab-panel').forEach(p => p.classList.toggle('hidden', p.dataset.panel !== name));
  }));

  // ─── theme: debounced PATCH per change ─────────────────────────────────
  $$('.picker input[type="color"]').forEach(input => {
    input.addEventListener('input', () => {
      const code = input.parentElement.querySelector('code[data-hex]');
      if (code) code.textContent = input.value;
      schedulePalettePush();
    });
  });

  function schedulePalettePush() {
    if (!state.current) return;
    if (state.paletteDebounce) clearTimeout(state.paletteDebounce);
    $('#theme-status').textContent = 'saving…';
    $('#theme-status').className = 'status muted small';
    state.paletteDebounce = setTimeout(pushPalette, 400);
  }

  async function pushPalette() {
    if (!state.current) return;
    const body = {};
    for (const k of PALETTE_KEYS) {
      const input = $(`.picker input[data-key="${k}"]`);
      if (input && /^#[0-9a-f]{6}$/i.test(input.value)) body[k] = input.value;
    }
    try {
      const { site } = await api(`/api/sites/${encodeURIComponent(state.current.domain)}/palette`, {
        method: 'PATCH',
        body: JSON.stringify(body),
      });
      // merge updated overrides locally
      const idx = state.sites.findIndex(s => s.domain === state.current.domain);
      if (idx >= 0) state.sites[idx] = { ...state.sites[idx], theme_overrides: site.theme_overrides };
      state.current = { ...state.current, theme_overrides: site.theme_overrides };
      $('#theme-status').textContent = `saved ${new Date().toLocaleTimeString()}`;
      $('#theme-status').className = 'status ok small';
    } catch (e) {
      $('#theme-status').textContent = `error: ${e.message}`;
      $('#theme-status').className = 'status err small';
    }
  }

  // ─── copy: explicit save ────────────────────────────────────────────────
  $('#save-copy').addEventListener('click', async () => {
    if (!state.current) return;
    const body = {};
    for (const k of COPY_KEYS) {
      const ta = $(`textarea[data-copy="${k}"]`);
      if (ta) body[k] = ta.value;
    }
    $('#copy-status').textContent = 'saving…';
    $('#copy-status').className = 'status muted small';
    try {
      const { site } = await api(`/api/sites/${encodeURIComponent(state.current.domain)}/copy`, {
        method: 'PATCH',
        body: JSON.stringify(body),
      });
      const idx = state.sites.findIndex(s => s.domain === state.current.domain);
      if (idx >= 0) state.sites[idx] = { ...state.sites[idx], copy_overrides: site.copy_overrides };
      state.current = { ...state.current, copy_overrides: site.copy_overrides };
      $('#copy-status').textContent = `saved ${new Date().toLocaleTimeString()}`;
      $('#copy-status').className = 'status ok small';
    } catch (e) {
      $('#copy-status').textContent = `error: ${e.message}`;
      $('#copy-status').className = 'status err small';
    }
  });

  // ─── skills ─────────────────────────────────────────────────────────────
  $$('.skill-btn').forEach(btn => {
    btn.addEventListener('click', async () => {
      if (!state.current) { toast('Select a site first', 'err'); return; }
      const skill = btn.dataset.skill;
      btn.disabled = true;
      try {
        await api('/api/actions', {
          method: 'POST',
          body: JSON.stringify({
            site_id: state.current.id,
            skill,
            action: 'trigger',
            status: 'queued',
            meta: { source: 'admin-editor' },
          }),
        });
        const ts = new Date();
        state.skillTs[skill] = ts.toISOString();
        const tsEl = btn.querySelector('.skill-ts');
        if (tsEl) tsEl.textContent = `last: ${ts.toLocaleTimeString()}`;
        toast(`Triggered ${skill}`);
      } catch (e) {
        toast(`Failed: ${e.message}`, 'err');
      } finally {
        btn.disabled = false;
      }
    });
  });

  // ─── findings modal ─────────────────────────────────────────────────────
  $('#findings-btn').addEventListener('click', async () => {
    if (!state.current) return;
    const body = $('#findings-body');
    body.innerHTML = '<p class="muted">Loading…</p>';
    $('#modal').classList.remove('hidden');
    try {
      const { findings } = await api(`/api/sites/${encodeURIComponent(state.current.domain)}/findings`);
      if (!findings || !findings.length) {
        body.innerHTML = '<p class="muted">No findings recorded for this site.</p>';
        return;
      }
      body.innerHTML = findings.map(f => `
        <div class="finding">
          <div>
            <span class="sev ${escapeAttr(severityClass(f.severity))}">${escapeHtml(f.severity)}</span>
            <strong>${escapeHtml(f.title)}</strong>
            <span class="muted small"> · stage ${Number(f.stage)} · ${escapeHtml(f.source || '')}${f.resolved ? ' · resolved' : ''}</span>
          </div>
          ${f.detail ? `<div class="muted small" style="margin-top:6px">${escapeHtml(f.detail)}</div>` : ''}
          ${f.suggested_fix ? `<div class="small" style="margin-top:6px"><em>fix:</em> ${escapeHtml(f.suggested_fix)}</div>` : ''}
        </div>`).join('');
    } catch (e) {
      body.innerHTML = `<p class="muted">Failed to load: ${escapeHtml(e.message)}</p>`;
    }
  });
  $('#modal-close').addEventListener('click', () => $('#modal').classList.add('hidden'));
  $('#modal').addEventListener('click', e => { if (e.target.id === 'modal') $('#modal').classList.add('hidden'); });

  // ─── iframe reload ──────────────────────────────────────────────────────
  $('#iframe-reload').addEventListener('click', () => {
    const iframe = $('#preview');
    if (iframe.src) iframe.src = iframe.src;
  });

  // ─── utils ──────────────────────────────────────────────────────────────
  function severityClass(s) {
    const v = String(s || '').toLowerCase();
    if (v === 'high' || v === 'critical') return 'high';
    if (v === 'medium' || v === 'med') return 'medium';
    return 'low';
  }
  function escapeHtml(s) {
    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
    }[c]));
  }
  function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '&quot;'); }

  // boot
  loadSites();
})();