← 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 => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
}[c]));
}
function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '"'); }
// boot
loadSites();
})();