Ava2lon's picture
Upload 20 files
038574d verified
Raw
History Blame Contribute Delete
24.8 kB
const state = {
view: location.hash.slice(1) || "overview",
apiKey: sessionStorage.getItem("maester_api_key") || "",
workspaceId: "",
campaigns: [], variants: [], jobs: [], assets: [], calendar: [], workspaces: [],
accounts: [], integrations: [], experiments: [], rules: [], audit: [], overview: {}, selectedVariantId: null,
};
const $ = (query, root = document) => root.querySelector(query);
const $$ = (query, root = document) => [...root.querySelectorAll(query)];
const escapeHtml = (value = "") => String(value).replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char]));
const badge = value => `<span class="badge ${escapeHtml(value)}">${escapeHtml(String(value).replaceAll("_", " "))}</span>`;
const date = value => value ? new Date(value).toLocaleString() : "-";
const tags = values => `<div class="platforms">${(values || []).map(value => `<span class="platform">${escapeHtml(String(value).replaceAll("_", " "))}</span>`).join("")}</div>`;
const jsonPost = body => ({method: "POST", body: JSON.stringify(body)});
function toast(message, error = false) {
const node = document.createElement("div");
node.className = `toast${error ? " error" : ""}`;
node.textContent = message;
$("#toast-region").append(node);
setTimeout(() => node.remove(), 4000);
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.apiKey) headers.set("X-API-Key", state.apiKey);
if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
const response = await fetch(path, {...options, headers, credentials: "same-origin"});
if (response.status === 401) {
$("#settings-dialog").showModal();
throw new Error("API key required");
}
if (!response.ok) {
const body = await response.json().catch(() => ({detail: response.statusText}));
throw new Error(typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail));
}
return response.status === 204 ? null : response.json();
}
async function loadData() {
const scope = state.workspaceId ? `?workspace_id=${encodeURIComponent(state.workspaceId)}` : "";
const values = await Promise.all([
api(`/automation/overview${scope}`), api("/automation/campaigns"), api("/automation/variants"), api("/automation/jobs"),
api("/automation/assets"), api("/automation/calendar"), api("/automation/workspaces"), api("/automation/accounts"),
api("/automation/integrations"), api("/automation/experiments"), api("/automation/rules"), api("/automation/audit?limit=100"),
]);
[state.overview, state.campaigns, state.variants, state.jobs, state.assets, state.calendar, state.workspaces, state.accounts, state.integrations, state.experiments, state.rules, state.audit] = values;
$(".status-dot").classList.add("online");
$("#connection-label").textContent = "Gateway online";
fillSelectors();
}
function fillSelectors() {
$("#workspace-filter").innerHTML = `<option value="">All workspaces</option>${state.workspaces.map(item => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join("")}`;
$("#workspace-filter").value = state.workspaceId;
const campaignOptions = state.campaigns.map(item => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join("");
const editorValue = $("#editor-campaign-filter").value;
$("#editor-campaign-filter").innerHTML = `<option value="">All campaigns</option>${campaignOptions}`;
$("#editor-campaign-filter").value = editorValue;
$("#calendar-campaign").innerHTML = campaignOptions;
const variantOptions = state.variants.map(item => `<option value="${item.id}">${escapeHtml(item.title)} · ${escapeHtml(item.platform)}</option>`).join("");
$("#analytics-variant").innerHTML = variantOptions;
$("#review-variants").innerHTML = variantOptions;
}
function metric(label, value, detail = "") {
return `<div class="metric"><div class="metric-label">${label}</div><div class="metric-value">${value}</div><div class="metric-detail">${escapeHtml(detail)}</div></div>`;
}
function renderOverview() {
const data = state.overview;
$("#overview-metrics").innerHTML = metric("Campaigns", data.campaigns || 0, "Production briefs") + metric("Variants", data.variants || 0, "Platform cuts") + metric("Queued", data.queue?.states?.queued || 0, `${data.queue?.total || 0} jobs`) + metric("Published", data.approvals?.published || 0, "Live variants");
const queue = data.queue?.states || {};
$("#overview-queue").innerHTML = Object.keys(queue).length ? Object.entries(queue).map(([name, count]) => `<div class="progress-row"><span>${escapeHtml(name)}</span><div class="progress"><span style="width:${count / Math.max(data.queue.total, 1) * 100}%"></span></div><strong>${count}</strong></div>`).join("") : `<div class="empty-state">Queue is clear</div>`;
const scores = data.analytics?.platform_average_scores || {};
$("#overview-performance").innerHTML = Object.keys(scores).length ? Object.entries(scores).map(([name, score]) => `<div class="progress-row"><span>${escapeHtml(name)}</span><div class="progress"><span style="width:${Math.min(score * 4, 100)}%"></span></div><strong>${score}</strong></div>`).join("") : `<div class="empty-state">No performance data</div>`;
$("#overview-approvals").innerHTML = `<div class="metric-grid">${Object.entries(data.approvals || {}).map(([name, count]) => metric(name.replaceAll("_", " "), count)).join("")}</div>`;
}
function renderCampaigns() {
const counts = state.variants.reduce((result, item) => ({...result, [item.campaign_id]: (result[item.campaign_id] || 0) + 1}), {});
$("#campaign-table").innerHTML = state.campaigns.map(item => `<tr><td><strong>${escapeHtml(item.name)}</strong><div class="muted">${date(item.updated_at)}</div></td><td>${escapeHtml(item.topic)}</td><td>${tags(item.platforms)}</td><td>${counts[item.id] || 0}</td><td>${badge(item.status)}</td><td><div class="table-actions"><button class="command" data-action="open-campaign" data-id="${item.id}">Edit</button><button class="command" data-action="generate-ai" data-id="${item.id}">AI generate</button><button class="command" data-action="schedule-campaign" data-id="${item.id}">Schedule</button></div></td></tr>`).join("") || `<tr><td colspan="6"><div class="empty-state">No campaigns</div></td></tr>`;
}
function renderEditor() {
const campaignId = $("#editor-campaign-filter").value;
const variants = state.variants.filter(item => !campaignId || item.campaign_id === campaignId);
$("#variant-list").innerHTML = variants.map(item => `<button class="selection-item ${item.id === state.selectedVariantId ? "active" : ""}" data-action="select-variant" data-id="${item.id}"><span class="selection-title">${escapeHtml(item.title)}</span><span class="selection-meta"><span>${escapeHtml(item.platform)}</span>${badge(item.approval_state)}</span></button>`).join("") || `<div class="empty-state">No variants</div>`;
const item = state.variants.find(variant => variant.id === state.selectedVariantId);
if (!item) return;
$("#variant-editor").innerHTML = `<form id="variant-form"><div class="section-head"><h2>${escapeHtml(item.title)}</h2>${badge(item.approval_state)}</div><div class="form-grid"><label class="span-2">Title<input name="title" value="${escapeHtml(item.title)}"></label><label class="span-2">Hook<textarea name="hook" rows="3">${escapeHtml(item.hook)}</textarea></label><label class="span-2">Script<textarea name="script" rows="10">${escapeHtml(item.script)}</textarea></label><label class="span-2">Caption<textarea name="caption" rows="4">${escapeHtml(item.caption)}</textarea></label><label>Hashtags<input name="hashtags" value="${escapeHtml(item.hashtags.join(", "))}"></label><label>Call to action<input name="cta" value="${escapeHtml(item.cta)}"></label><label>Template<input name="template" value="${escapeHtml(item.template)}"></label><label>Style<input name="creative_style" value="${escapeHtml(item.creative_style)}"></label><label>Duration<input name="duration_seconds" type="number" min="3" max="600" value="${item.duration_seconds}"></label></div><div class="editor-actions"><button class="command primary" type="submit">Save changes</button><button class="command" type="button" data-action="approve-variant" data-id="${item.id}">✓ Approve</button><button class="command" type="button" data-action="render-variant" data-id="${item.id}">▶ Render</button><button class="command" type="button" data-action="publish-variant" data-id="${item.id}">Publish draft</button><button class="command" type="button" data-action="rewrite-variant" data-id="${item.id}">Rewrite</button></div></form>`;
$("#variant-form").addEventListener("submit", saveVariant);
renderInspector(item);
}
async function renderInspector(item) {
try {
const quality = await api(`/automation/quality/variants/${item.id}`);
$("#variant-inspector").innerHTML = `<div class="section-head"><h2>Inspector</h2>${badge(item.job_state)}</div><div class="quality-grid"><div class="quality"><strong>${quality.hook.score}</strong><span>Hook</span></div><div class="quality"><strong>${quality.script.score}</strong><span>Script</span></div><div class="quality"><strong>${quality.caption.score}</strong><span>Caption</span></div><div class="quality"><strong>${Math.round(quality.retention.predicted_completion_rate * 100)}%</strong><span>Completion</span></div></div><h3>Render payload</h3><div class="json-box">${escapeHtml(JSON.stringify(item.render_payload, null, 2))}</div>`;
} catch (error) { $("#variant-inspector").innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
}
function renderCalendar() {
const variants = Object.fromEntries(state.variants.map(item => [item.id, item]));
$("#calendar-table").innerHTML = state.calendar.sort((a, b) => a.scheduled_at.localeCompare(b.scheduled_at)).map(item => `<tr><td>${date(item.scheduled_at)}</td><td>${tags([item.platform])}</td><td>${escapeHtml(variants[item.variant_id]?.title || item.variant_id)}</td><td>${badge(item.status)}</td></tr>`).join("") || `<tr><td colspan="4"><div class="empty-state">No scheduled content</div></td></tr>`;
}
function renderJobs() {
const filter = $("#job-filter").value;
$("#job-table").innerHTML = state.jobs.filter(item => !filter || item.state === filter).map(item => `<tr><td><strong>${escapeHtml(item.id)}</strong><div class="muted">${escapeHtml(item.idempotency_key || "")}</div></td><td>${escapeHtml(item.kind)}</td><td>${badge(item.state)}</td><td>${item.attempts}/${item.max_attempts}</td><td>${date(item.updated_at)}</td><td><div class="table-actions">${["failed", "cancelled"].includes(item.state) ? `<button class="command" data-action="retry-job" data-id="${item.id}">↻</button>` : ""}${!["published", "ready", "failed", "cancelled"].includes(item.state) ? `<button class="command danger" data-action="cancel-job" data-id="${item.id}">×</button>` : ""}</div></td></tr>`).join("") || `<tr><td colspan="6"><div class="empty-state">No matching jobs</div></td></tr>`;
}
function renderAnalytics() {
const data = state.overview.analytics || {}, winners = data.winners || [];
$("#analytics-metrics").innerHTML = metric("Winners", winners.length, "Viral score ≥ 20") + metric("Platforms", Object.keys(data.platform_average_scores || {}).length, "Reporting sources") + metric("Experiments", state.experiments.length, "Controlled tests") + metric("Top score", winners[0]?.score || 0, winners[0]?.platform || "No data");
$("#winner-board").innerHTML = winners.length ? `<div class="stack-list">${winners.map(item => `<div class="stack-row"><div class="stack-row-main"><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(item.platform)}</small></div>${badge(String(item.score))}</div>`).join("")}</div>` : `<div class="empty-state">No winners yet</div>`;
$("#experiment-list").innerHTML = state.experiments.length ? `<div class="stack-list">${state.experiments.map(item => `<div class="stack-row"><div class="stack-row-main"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.variable)} · ${item.variant_ids.length} variants</small></div>${badge(item.status)}<button class="command" data-action="evaluate-experiment" data-id="${item.id}">Evaluate</button></div>`).join("")}</div>` : `<div class="empty-state">No experiments</div>`;
}
function renderAssets() {
$("#asset-table").innerHTML = state.assets.map(item => `<tr><td><strong>${escapeHtml(item.filename)}</strong><div class="muted">${escapeHtml(item.checksum.slice(0, 12))}</div></td><td>${badge(item.kind)}</td><td>${Math.ceil(item.size_bytes / 1024)} KB</td><td>${escapeHtml(item.license)}<div class="muted">${escapeHtml(item.attribution)}</div></td><td>${tags(item.tags)}</td><td><button class="command danger" data-action="delete-asset" data-id="${item.id}">Delete</button></td></tr>`).join("") || `<tr><td colspan="6"><div class="empty-state">No assets</div></td></tr>`;
}
function renderReviews() {
const pending = state.variants.filter(item => ["draft", "needs_review"].includes(item.approval_state));
$("#pending-reviews").innerHTML = pending.length ? `<div class="stack-list">${pending.map(item => `<div class="stack-row"><div class="stack-row-main"><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(item.platform)}</small></div>${badge(item.approval_state)}</div>`).join("")}</div>` : `<div class="empty-state">Nothing awaiting review</div>`;
}
function renderIntegrations() {
$("#account-list").innerHTML = state.accounts.length ? `<div class="stack-list">${state.accounts.map(item => `<div class="stack-row"><div class="stack-row-main"><strong>${escapeHtml(item.display_name || item.handle)}</strong><small>${escapeHtml(item.platform)} · ${escapeHtml(item.auth_status)}</small></div><button class="command" data-action="sync-account" data-id="${item.id}">Sync</button></div>`).join("")}</div>` : `<div class="empty-state">No connected accounts</div>`;
$("#integration-list").innerHTML = state.integrations.length ? `<div class="stack-list">${state.integrations.map(item => `<div class="stack-row"><div class="stack-row-main"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.kind)}</small></div>${badge(item.active ? "ready" : "disabled")}</div>`).join("")}</div>` : `<div class="empty-state">No delivery integrations</div>`;
$("#rule-list").innerHTML = state.rules.length ? `<div class="stack-list">${state.rules.map(item => `<div class="stack-row"><div class="stack-row-main"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.event)} · ${item.actions.length} actions</small></div>${badge(item.active ? "ready" : "disabled")}</div>`).join("")}</div>` : `<div class="empty-state">No automation rules</div>`;
}
async function renderOperations() {
const [providers, services] = await Promise.all([api("/automation/providers"), api("/services")]);
$("#ops-metrics").innerHTML = metric("Workspaces", state.workspaces.length, "Tenant boundaries") + metric("Accounts", state.accounts.length, "Publishing targets") + metric("Assets", state.assets.length, "Managed media") + metric("Audit events", state.audit.length, "Recent operations");
$("#provider-status").innerHTML = `<div class="stack-list">${Object.entries(providers.providers).map(([name, item]) => `<div class="stack-row"><div class="stack-row-main"><strong>${escapeHtml(name)}</strong><small>${escapeHtml(item.model)}</small></div>${badge(item.configured ? "ready" : "not_configured")}</div>`).join("")}</div>`;
$("#service-status").innerHTML = `<div class="stack-list">${services.services.map(item => `<div class="stack-row"><div class="stack-row-main"><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(item.path)}</small></div>${badge(item.mounted ? "ready" : item.enabled ? "failed" : "disabled")}</div>`).join("")}</div>`;
$("#audit-table").innerHTML = state.audit.map(item => `<tr><td>${date(item.created_at)}</td><td>${escapeHtml(item.actor)}</td><td>${escapeHtml(item.action)}</td><td>${escapeHtml(item.resource_type)} · ${escapeHtml(item.resource_id)}</td></tr>`).join("") || `<tr><td colspan="4"><div class="empty-state">No audit events</div></td></tr>`;
}
const renderers = {overview: renderOverview, campaigns: renderCampaigns, editor: renderEditor, calendar: renderCalendar, jobs: renderJobs, analytics: renderAnalytics, assets: renderAssets, reviews: renderReviews, integrations: renderIntegrations, operations: renderOperations};
async function refresh() { try { await loadData(); await renderers[state.view]?.(); } catch (error) { $(".status-dot").classList.remove("online"); $("#connection-label").textContent = "Gateway unavailable"; toast(error.message, true); } }
function switchView(view) { state.view = view; location.hash = view; $$(".view").forEach(item => item.classList.toggle("active", item.id === `view-${view}`)); $$(".nav-item").forEach(item => item.classList.toggle("active", item.dataset.view === view)); $("#view-title").textContent = view[0].toUpperCase() + view.slice(1); $("#sidebar").classList.remove("open"); renderers[view]?.(); }
async function saveVariant(event) {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget));
data.hashtags = data.hashtags.split(",").map(item => item.trim()).filter(Boolean);
data.duration_seconds = Number(data.duration_seconds);
try { await api(`/automation/variants/${state.selectedVariantId}`, {method: "PATCH", body: JSON.stringify(data)}); toast("Variant saved"); await refresh(); } catch (error) { toast(error.message, true); }
}
async function action(name, id) {
try {
if (name === "process-queue") { const result = await api("/automation/queue/process", {method: "POST"}); toast(`${result.count} job processed`); }
if (name === "select-variant") { state.selectedVariantId = id; renderEditor(); return; }
if (name === "open-campaign") { $("#editor-campaign-filter").value = id; state.selectedVariantId = state.variants.find(item => item.campaign_id === id)?.id; switchView("editor"); return; }
if (name === "generate-ai") await api(`/automation/campaigns/${id}/generate-ai`, jsonPost({}));
if (name === "schedule-campaign") await api(`/automation/schedule/apply?campaign_id=${id}`, {method: "POST"});
if (name === "apply-schedule") { const start = $("#calendar-start").value; await api(`/automation/schedule/apply?campaign_id=${$("#calendar-campaign").value}${start ? `&start_at=${encodeURIComponent(new Date(start).toISOString())}` : ""}`, {method: "POST"}); }
if (name === "approve-variant") await api(`/automation/variants/${id}/approval`, jsonPost({state: "approved", reviewer: "Studio operator", notes: "Approved in Content Studio"}));
if (name === "render-variant") await api(`/automation/variants/${id}/render-job`, {method: "POST"});
if (name === "publish-variant") await api("/automation/publish", jsonPost({variant_id: id, draft: true}));
if (name === "rewrite-variant") await api(`/automation/variants/${id}/rewrite`, {method: "POST"});
if (name === "retry-job") await api(`/automation/jobs/${id}/retry`, {method: "POST"});
if (name === "cancel-job") await api(`/automation/jobs/${id}/cancel`, {method: "POST"});
if (name === "delete-asset") await api(`/automation/assets/${id}`, {method: "DELETE"});
if (name === "sync-account") await api(`/automation/accounts/${id}/analytics-sync`, {method: "POST"});
if (name === "evaluate-experiment") await api(`/automation/experiments/${id}/evaluate`, {method: "POST"});
if (name === "new-experiment") { const campaign = state.campaigns[0]; if (!campaign) throw new Error("Create a campaign first"); await api("/automation/experiments", jsonPost({campaign_id: campaign.id, name: `${campaign.name} hook test`, variable: "hook", variant_ids: state.variants.filter(item => item.campaign_id === campaign.id).map(item => item.id)})); }
if (name === "new-rule") await api("/automation/rules", jsonPost({name: "Render approved variants", event: "variant_approved", actions: [{type: "render"}], active: true}));
toast("Operation completed"); await refresh();
} catch (error) { toast(error.message, true); }
}
document.addEventListener("click", event => { const nav = event.target.closest("[data-view]"); if (nav) switchView(nav.dataset.view); const dialog = event.target.closest("[data-dialog]"); if (dialog) $(`#${dialog.dataset.dialog}`).showModal(); const button = event.target.closest("[data-action]"); if (button) action(button.dataset.action, button.dataset.id); });
$("#menu-button").addEventListener("click", () => $("#sidebar").classList.toggle("open"));
$("#refresh-button").addEventListener("click", refresh);
$("#settings-button").addEventListener("click", () => $("#settings-dialog").showModal());
$("#settings-form").addEventListener("submit", event => { event.preventDefault(); state.apiKey = $("#api-key").value.trim(); sessionStorage.setItem("maester_api_key", state.apiKey); $("#settings-dialog").close(); refresh(); });
$("#workspace-filter").addEventListener("change", event => { state.workspaceId = event.target.value; refresh(); });
$("#editor-campaign-filter").addEventListener("change", renderEditor);
$("#job-filter").addEventListener("change", renderJobs);
$("#campaign-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.currentTarget); const data = Object.fromEntries(form); data.platforms = form.getAll("platforms"); data.quantity = Number(data.quantity); data.workspace_id = state.workspaceId || null; data.brand_kit_id ||= null; try { await api("/automation/campaigns/generate", jsonPost(data)); event.currentTarget.reset(); $("#campaign-dialog").close(); await refresh(); } catch (error) { toast(error.message, true); } });
$("#repurpose-form").addEventListener("submit", async event => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget)); data.quantity = Number(data.quantity); data.workspace_id = state.workspaceId || null; data.platforms = ["tiktok", "instagram_reels", "youtube_shorts"]; try { await api("/automation/repurpose", jsonPost(data)); event.currentTarget.reset(); $("#repurpose-dialog").close(); await refresh(); } catch (error) { toast(error.message, true); } });
$("#analytics-form").addEventListener("submit", async event => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget)); ["views", "likes", "comments", "shares"].forEach(key => data[key] = Number(data[key])); data.completion_rate = Number(data.completion_rate); try { await api("/automation/analytics/events", jsonPost(data)); await refresh(); } catch (error) { toast(error.message, true); } });
$("#asset-form").addEventListener("submit", async event => { event.preventDefault(); const data = new FormData(event.currentTarget); if (state.workspaceId) data.set("workspace_id", state.workspaceId); try { await api("/automation/assets", {method: "POST", body: data}); event.currentTarget.reset(); await refresh(); } catch (error) { toast(error.message, true); } });
$("#review-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.currentTarget); const data = {variant_ids: form.getAll("variant_ids"), expires_in_days: Number(form.get("expires_in_days")), created_by: form.get("created_by"), workspace_id: state.workspaceId || null}; try { const result = await api("/automation/reviews", jsonPost(data)); const url = new URL(result.review_url, location.origin).href; $("#review-result").innerHTML = `<div class="link-output"><strong>Review link</strong><br><a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(url)}</a></div>`; await refresh(); } catch (error) { toast(error.message, true); } });
$("#account-form").addEventListener("submit", async event => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget)); data.workspace_id = state.workspaceId || null; data.metadata = {token_env: data.token_env, publish_endpoint: data.publish_endpoint, analytics_endpoint: data.analytics_endpoint}; delete data.token_env; delete data.publish_endpoint; delete data.analytics_endpoint; try { await api("/automation/accounts", jsonPost(data)); $("#account-dialog").close(); await refresh(); } catch (error) { toast(error.message, true); } });
$("#integration-form").addEventListener("submit", async event => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget)); data.workspace_id = state.workspaceId || null; try { await api("/automation/integrations", jsonPost(data)); $("#integration-dialog").close(); await refresh(); } catch (error) { toast(error.message, true); } });
$("#api-key").value = state.apiKey;
switchView(state.view in renderers ? state.view : "overview");
refresh();