Files
WPSidekick/internal/api/ui/app.js
T
alexandrev-tibco 3a66d687e1 Minor updates
2026-02-20 16:21:18 +01:00

2270 lines
76 KiB
JavaScript

// Theme Management
const getSystemTheme = () =>
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const getStoredTheme = () => localStorage.getItem("wpsk-theme");
const setTheme = (theme) => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("wpsk-theme", theme);
};
const initTheme = () => {
const stored = getStoredTheme();
const theme = stored || getSystemTheme();
setTheme(theme);
// Listen for system theme changes
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
if (!getStoredTheme()) {
setTheme(e.matches ? "dark" : "light");
}
});
};
const toggleTheme = () => {
const current = document.documentElement.getAttribute("data-theme") || getSystemTheme();
setTheme(current === "dark" ? "light" : "dark");
};
// Initialize theme immediately to prevent flash
initTheme();
const apiFetch = async (path, options = {}) => {
const headers = { "Content-Type": "application/json", "X-WPSK-Client": "webui" };
const siteHeader = options.site !== undefined ? options.site : state.currentSite;
if (siteHeader) {
headers["X-WPSK-Site"] = siteHeader;
}
const res = await fetch(path, {
headers,
...options,
});
const text = await res.text();
if (!res.ok) {
throw new Error(text || res.statusText);
}
try {
return JSON.parse(text);
} catch {
return text;
}
};
const byId = (id) => document.getElementById(id);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Loading state helpers
const setLoading = (panelId, loading) => {
const panel = byId(panelId);
if (panel) {
panel.classList.toggle("is-loading", loading);
}
};
const setButtonLoading = (button, loading) => {
if (!button) return;
const isNavLink = button.classList.contains("nav-link");
if (loading) {
button.disabled = true;
if (isNavLink) {
// For nav-links with icons, just add a loading class
button.classList.add("is-loading");
} else {
button.dataset.originalText = button.textContent;
button.innerHTML = '<span class="spinner"></span>';
}
} else {
button.disabled = false;
if (isNavLink) {
button.classList.remove("is-loading");
} else if (button.dataset.originalText) {
button.textContent = button.dataset.originalText;
}
}
};
const DEFAULT_AD = {
name: "XSLT Playground",
url: "https://xsltplayground.com",
blurb: "Try XSLT transformations in the browser. Paste XML, run, and share instantly.",
};
const getStoredAd = () => {
try {
const raw = localStorage.getItem("wpsk-newsletter-ad");
return raw ? { ...DEFAULT_AD, ...JSON.parse(raw) } : { ...DEFAULT_AD };
} catch {
return { ...DEFAULT_AD };
}
};
const storeAd = (ad) => {
localStorage.setItem("wpsk-newsletter-ad", JSON.stringify(ad));
};
const getPaginationState = (key, size = 6) => {
if (!state.pagination[key]) {
state.pagination[key] = { page: 1, size };
}
if (!state.pagination[key].size) {
state.pagination[key].size = size;
}
return state.pagination[key];
};
const state = {
sites: [],
siteMap: {},
currentSite: null,
defaultSite: null,
pending: [],
ideas: [],
outreach: [],
newsletters: [],
audit: [],
brokenLinks: null,
imageAudit: null,
seoAudit: null,
orphans: [],
outreachStatus: localStorage.getItem("wpsk-outreach-status") || "new",
pagination: {},
seoRecommendations: {},
currentPage: "overview",
// Track in-progress operations to prevent duplicates
inProgress: {
ideaDrafts: new Set(), // idea IDs currently being drafted
seoOptimizations: new Set(), // post IDs currently being optimized
outreachGeneration: false,
newsletterGeneration: false,
},
// Track idea draft jobs for polling
ideaJobs: {}, // ideaId -> { jobId, status, drafts }
};
const setSiteLabel = (site) => {
const label = byId("site-label");
if (!label) return;
if (!site) {
label.textContent = "-";
return;
}
const name = site.name || site.id || "Site";
label.textContent = name;
};
const toggleHidden = (id, hidden) => {
const el = byId(id);
if (!el) return;
el.classList.toggle("hidden", hidden);
};
const siteSupportsOrphans = (site) => {
const langs = site?.languages || [];
return Array.isArray(langs) && langs.length > 1;
};
const applySiteFeatures = (site) => {
if (!site || !site.features) return;
toggleHidden("panel-newsletter", !site.features.newsletter);
toggleHidden("generate-newsletter", !site.features.newsletter);
toggleHidden("panel-outreach", !site.features.outreach);
toggleHidden("generate-outreach", !site.features.outreach);
toggleHidden("panel-ideas", !site.features.ideas);
toggleHidden("generate-ideas", !site.features.ideas);
toggleHidden("panel-orphans", !siteSupportsOrphans(site));
};
const setCurrentSite = async (siteId, { refresh = true } = {}) => {
if (!siteId || !state.siteMap[siteId]) return;
state.currentSite = siteId;
localStorage.setItem("wpsk-site", siteId);
const site = state.siteMap[siteId];
setSiteLabel(site);
applySiteFeatures(site);
const select = byId("site-select");
if (select && select.value !== siteId) {
select.value = siteId;
}
if (refresh) {
await refreshAll();
}
};
const renderSiteSwitcher = () => {
const select = byId("site-select");
if (!select) return;
select.innerHTML = "";
for (const site of state.sites) {
const opt = document.createElement("option");
opt.value = site.id;
opt.textContent = site.name || site.id;
select.appendChild(opt);
}
if (state.currentSite) {
select.value = state.currentSite;
}
select.addEventListener("change", async (e) => {
await setCurrentSite(e.target.value);
});
};
const initSites = async () => {
try {
const data = await apiFetch("/api/v1/sites", { site: null });
state.sites = data.sites || [];
state.defaultSite = data.default_site || (state.sites[0] && state.sites[0].id);
state.siteMap = {};
for (const site of state.sites) {
state.siteMap[site.id] = site;
}
const stored = localStorage.getItem("wpsk-site");
const initial = stored && state.siteMap[stored] ? stored : state.defaultSite;
await setCurrentSite(initial, { refresh: false });
renderSiteSwitcher();
} catch (err) {
appendOutput(`Error loading sites: ${err.message}`);
}
};
const appendOutput = (text) => {
const output = byId("command-output");
output.textContent = `${output.textContent}${text}\n`;
output.scrollTop = output.scrollHeight;
};
const setOutput = (text) => {
const output = byId("command-output");
output.textContent = text;
};
const stripHTML = (html) => {
const div = document.createElement("div");
div.innerHTML = html;
return div.textContent || div.innerText || "";
};
const { getIdeaDraftIds, diffTokens, renderDiffHTML } = window.WPSKUIUtils || {};
if (!getIdeaDraftIds || !diffTokens || !renderDiffHTML) {
throw new Error("WPSKUIUtils is required");
}
// Build newsletter in Substack-compatible markdown format
const buildNewsletterMarkdown = (item, ad, includeAd = true) => {
const subject = item.subject || "Newsletter Draft";
const preview = item.preview_text || "";
const body = item.body_text || "";
const lines = [
`# ${subject}`,
"",
`*${preview}*`,
"",
"---",
"",
body,
];
if (includeAd && ad) {
const adName = ad.name || DEFAULT_AD.name;
const adUrl = ad.url || DEFAULT_AD.url;
const adBlurb = ad.blurb || DEFAULT_AD.blurb;
lines.push(
"",
"---",
"",
"### Sponsor",
"",
`**[${adName}](${adUrl})**`,
"",
adBlurb,
"",
"*Want to promote your product or service here? Reply and we will set you up.*"
);
}
return lines.filter((line) => line !== undefined).join("\n").trim();
};
// Build plain text version for email clients that don't support markdown
const buildNewsletterPlainText = (item, ad) => {
const subject = item.subject || "Newsletter Draft";
const preview = item.preview_text || "";
const body = item.body_text || "";
const lines = [
subject.toUpperCase(),
"=".repeat(subject.length),
"",
preview,
"",
"-".repeat(40),
"",
body,
];
if (ad) {
const adName = ad.name || DEFAULT_AD.name;
const adUrl = ad.url || DEFAULT_AD.url;
const adBlurb = ad.blurb || DEFAULT_AD.blurb;
lines.push(
"",
"-".repeat(40),
"",
"SPONSOR",
"",
`${adName}: ${adUrl}`,
"",
adBlurb,
);
}
return lines.filter((line) => line !== undefined).join("\n").trim();
};
const modalState = {
mode: "full",
type: "text",
data: null,
};
const renderModal = () => {
const body = byId("modal-body");
const links = byId("modal-links");
body.innerHTML = "";
const fullBtn = byId("modal-mode-full");
const compareBtn = byId("modal-mode-compare");
const toggle = byId("modal-toggle");
fullBtn.classList.toggle("active", modalState.mode === "full");
compareBtn.classList.toggle("active", modalState.mode === "compare");
if (modalState.type === "text") {
if (toggle) {
toggle.classList.add("hidden");
}
if (links) {
links.classList.add("hidden");
links.innerHTML = "";
}
const pre = document.createElement("pre");
pre.className = "modal-text";
pre.textContent = modalState.data?.body || "";
body.appendChild(pre);
return;
}
if (modalState.type === "idea-draft") {
// Handled by renderIdeaDraftModal
if (toggle) toggle.classList.add("hidden");
if (links) {
links.classList.add("hidden");
links.innerHTML = "";
}
return;
}
if (modalState.type === "newsletter") {
if (toggle) {
toggle.classList.add("hidden");
}
if (links) {
links.classList.add("hidden");
links.innerHTML = "";
}
const item = modalState.data || {};
const ad = getStoredAd();
const header = document.createElement("div");
header.className = "newsletter-header";
header.innerHTML = `
<div>
<h4>Substack-ready format</h4>
<div class="meta muted">Copy-ready markdown with sponsor block.</div>
</div>
<button id="newsletter-copy" class="secondary">Copy Markdown</button>
`;
body.appendChild(header);
const markdown = buildNewsletterMarkdown(item, ad);
const pre = document.createElement("pre");
pre.className = "modal-text newsletter-markdown";
pre.textContent = markdown;
body.appendChild(pre);
const adPanel = document.createElement("div");
adPanel.className = "ad-panel";
adPanel.innerHTML = `
<h4>Ad slot</h4>
<label>Name</label>
<input id="ad-name" type="text" value="${ad.name}" />
<label>URL</label>
<input id="ad-url" type="text" value="${ad.url}" />
<label>Blurb</label>
<textarea id="ad-blurb">${ad.blurb}</textarea>
<div class="ad-actions">
<button id="ad-save">Update Ad</button>
</div>
<div class="meta muted">Want to promote a product or service? This slot is open for sponsors.</div>
`;
body.appendChild(adPanel);
const copyBtn = byId("newsletter-copy");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(markdown);
setOutput("Newsletter markdown copied to clipboard.");
} catch (err) {
appendOutput("Copy failed. Select the text manually.");
}
});
}
const saveBtn = byId("ad-save");
if (saveBtn) {
saveBtn.addEventListener("click", () => {
const nextAd = {
name: byId("ad-name").value.trim() || DEFAULT_AD.name,
url: byId("ad-url").value.trim() || DEFAULT_AD.url,
blurb: byId("ad-blurb").value.trim() || DEFAULT_AD.blurb,
};
storeAd(nextAd);
modalState.data = item;
renderModal();
});
}
return;
}
if (toggle) {
toggle.classList.remove("hidden");
}
const data = modalState.data || {};
const draft = data.draft || {};
const original = data.original || {};
const linkData = data.links || {};
if (links) {
const parts = [];
if (linkData.draft_edit) {
parts.push(`<a href="${linkData.draft_edit}" target="_blank" rel="noreferrer">Draft in WP Editor</a>`);
}
if (linkData.original_edit) {
parts.push(`<a href="${linkData.original_edit}" target="_blank" rel="noreferrer">Original in WP Editor</a>`);
}
links.innerHTML = parts.length ? parts.join(" ") : '<span class="muted">Links unavailable</span>';
links.classList.remove("hidden");
}
if (modalState.mode === "full") {
const seoBlock = document.createElement("div");
seoBlock.className = "modal-text";
seoBlock.textContent = `SEO Title:\n${draft.seo_title || ""}\n\nSEO Description:\n${draft.seo_description || ""}`;
body.appendChild(seoBlock);
const content = document.createElement("pre");
content.className = "modal-text";
content.textContent = stripHTML(draft.content || "");
body.appendChild(content);
if (draft.id) {
body.appendChild(renderFeedbackPanel(draft.id));
}
return;
}
const grid = document.createElement("div");
grid.className = "compare-grid";
const makeCol = (label, item) => {
const col = document.createElement("div");
col.className = "compare-col";
const title = document.createElement("h4");
title.textContent = label;
col.appendChild(title);
const meta = document.createElement("div");
meta.className = "compare-meta";
meta.textContent = item.title || "";
col.appendChild(meta);
const seo = document.createElement("div");
seo.className = "modal-text diff-block";
const content = document.createElement("div");
content.className = "modal-text diff-block";
col.appendChild(seo);
col.appendChild(content);
return col;
};
const originalCol = makeCol("Original", original);
const draftCol = makeCol("Draft", draft);
const originalBlocks = originalCol.querySelectorAll(".diff-block");
const draftBlocks = draftCol.querySelectorAll(".diff-block");
const originalTitle = original.seo_title || "";
const draftTitle = draft.seo_title || "";
const originalDesc = original.seo_description || "";
const draftDesc = draft.seo_description || "";
const originalContent = stripHTML(original.content || "");
const draftContent = stripHTML(draft.content || "");
originalBlocks[0].innerHTML = `SEO Title:\n${renderDiffHTML(originalTitle, draftTitle, "original")}\n\nSEO Description:\n${renderDiffHTML(originalDesc, draftDesc, "original")}`;
draftBlocks[0].innerHTML = `SEO Title:\n${renderDiffHTML(originalTitle, draftTitle, "draft")}\n\nSEO Description:\n${renderDiffHTML(originalDesc, draftDesc, "draft")}`;
originalBlocks[1].innerHTML = renderDiffHTML(originalContent, draftContent, "original");
draftBlocks[1].innerHTML = renderDiffHTML(originalContent, draftContent, "draft");
grid.appendChild(originalCol);
grid.appendChild(draftCol);
body.appendChild(grid);
if (draft.id) {
body.appendChild(renderFeedbackPanel(draft.id));
}
};
const renderFeedbackPanel = (draftId) => {
const panel = document.createElement("div");
panel.className = "feedback-panel";
const title = document.createElement("h4");
title.textContent = "Regenerate with feedback";
panel.appendChild(title);
const textarea = document.createElement("textarea");
textarea.placeholder = "Add specific feedback to re-run optimization (e.g., keep intro tone, fix FAQ Q3, adjust internal links).";
panel.appendChild(textarea);
const actions = document.createElement("div");
actions.className = "feedback-actions";
const button = document.createElement("button");
button.textContent = "Regenerate draft";
actions.appendChild(button);
panel.appendChild(actions);
button.addEventListener("click", async () => {
const feedback = textarea.value.trim();
if (!feedback) {
textarea.focus();
return;
}
setOutput("Submitting feedback and regenerating draft...");
try {
await apiFetch("/api/v1/optimize/feedback", {
method: "POST",
body: JSON.stringify({ draft_post_id: Number(draftId), feedback }),
});
textarea.value = "";
await loadPending();
await loadAudit();
} catch (err) {
appendOutput(`Error: ${err.message}`);
}
});
return panel;
};
const showModalText = (title, body) => {
modalState.type = "text";
modalState.mode = "full";
modalState.data = { body };
byId("modal-title").textContent = title;
byId("modal").classList.remove("hidden");
renderModal();
};
const showNewsletterModal = (item) => {
modalState.type = "newsletter";
modalState.mode = "full";
modalState.data = item;
byId("modal-title").textContent = item.subject || "Newsletter Draft";
byId("modal").classList.remove("hidden");
renderModal();
};
const showModalComparison = (data) => {
modalState.type = "compare";
modalState.mode = "compare";
modalState.data = data;
byId("modal-title").textContent = data?.draft?.title || "Draft";
byId("modal").classList.remove("hidden");
renderModal();
};
const hideModal = () => byId("modal").classList.add("hidden");
// Show idea draft modal for reviewing and editing drafts
const showIdeaDraftModal = async (idea) => {
modalState.type = "idea-draft";
modalState.mode = "full";
modalState.data = { idea, drafts: {} };
byId("modal-title").textContent = idea.seo_title || "Idea Draft";
byId("modal").classList.remove("hidden");
// Load draft content if available
const body = byId("modal-body");
body.innerHTML = `<div class="loading-container"><div class="meta"><span class="spinner"></span> Loading drafts...</div></div>`;
try {
// Fetch draft content for EN and ES if available
const drafts = {};
const ids = getIdeaDraftIds(idea);
if (ids.en) {
try {
const enDraft = await apiFetch(`/api/v1/drafts/${ids.en}`);
drafts.en = enDraft;
} catch (e) {
console.warn("Could not load EN draft", e);
}
}
if (ids.es) {
try {
const esDraft = await apiFetch(`/api/v1/drafts/${ids.es}`);
drafts.es = esDraft;
} catch (e) {
console.warn("Could not load ES draft", e);
}
}
modalState.data.drafts = drafts;
renderIdeaDraftModal();
} catch (err) {
body.innerHTML = `<div class="empty-state"><div class="empty-state-title">Error loading drafts</div><div class="empty-state-description">${err.message}</div></div>`;
}
};
const renderIdeaDraftModal = () => {
const body = byId("modal-body");
const { idea, drafts } = modalState.data;
const toggle = byId("modal-toggle");
if (toggle) toggle.classList.add("hidden");
const hasDrafts = drafts.en || drafts.es;
if (!hasDrafts) {
body.innerHTML = `<div class="empty-state"><div class="empty-state-title">No drafts available</div><div class="empty-state-description">Generate drafts first by clicking "Generate Draft" on the idea.</div></div>`;
return;
}
// Build tabs for EN/ES drafts
const tabs = [];
if (drafts.en) tabs.push({ key: "en", label: "English", draft: drafts.en });
if (drafts.es) tabs.push({ key: "es", label: "Spanish", draft: drafts.es });
const activeTab = modalState.data.activeTab || tabs[0]?.key || "en";
const activeDraft = drafts[activeTab];
const tabButtons = tabs.map(t => `
<button class="${t.key === activeTab ? "" : "secondary"}" data-draft-tab="${t.key}">${t.label}</button>
`).join("");
const draftContent = activeDraft ? stripHTML(activeDraft.content || "") : "No content available.";
const draftMarkdown = `# ${activeDraft?.title || idea.seo_title}\n\n${draftContent}`;
body.innerHTML = `
<div class="idea-draft-modal">
<div class="draft-tabs" style="display: flex; gap: 8px; margin-bottom: 16px;">
${tabButtons}
</div>
<div class="draft-meta" style="margin-bottom: 16px;">
<label>SEO Title</label>
<div class="meta">${activeDraft?.seo_title || activeDraft?.title || "N/A"}</div>
<label style="margin-top: 12px;">Meta Description</label>
<div class="meta">${activeDraft?.seo_description || "N/A"}</div>
</div>
<div class="draft-content">
<label>Content Preview</label>
<textarea id="draft-editor" class="draft-textarea" style="width: 100%; min-height: 400px; font-family: monospace; font-size: 13px; padding: 12px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text); resize: vertical;">${draftMarkdown}</textarea>
</div>
<div class="draft-actions" style="display: flex; gap: 8px; margin-top: 16px; justify-content: space-between;">
<div style="display: flex; gap: 8px;">
<button class="secondary" id="draft-download">Download Markdown</button>
<button class="secondary" id="draft-copy">Copy to Clipboard</button>
</div>
<div style="display: flex; gap: 8px;">
${activeDraft?.edit_link ? `<a href="${activeDraft.edit_link}" target="_blank" class="button secondary" style="display: inline-flex; align-items: center; padding: 8px 14px; text-decoration: none;">Edit in WordPress</a>` : ""}
</div>
</div>
<div class="feedback-panel" style="margin-top: 24px;">
<h4>Request Changes</h4>
<textarea id="draft-feedback" placeholder="Describe the changes you'd like (e.g., add more details about X, change the tone to be more technical, include a code example)..." style="width: 100%; min-height: 80px;"></textarea>
<div class="feedback-actions" style="margin-top: 12px;">
<button id="draft-regenerate">Regenerate with Feedback</button>
</div>
</div>
</div>
`;
// Tab switching
body.querySelectorAll("[data-draft-tab]").forEach(btn => {
btn.addEventListener("click", () => {
modalState.data.activeTab = btn.getAttribute("data-draft-tab");
renderIdeaDraftModal();
});
});
// Download markdown
byId("draft-download")?.addEventListener("click", () => {
const content = byId("draft-editor")?.value || "";
const blob = new Blob([content], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${idea.seo_title?.replace(/[^a-z0-9]/gi, "-") || "draft"}-${activeTab}.md`;
a.click();
URL.revokeObjectURL(url);
});
// Copy to clipboard
byId("draft-copy")?.addEventListener("click", async () => {
const content = byId("draft-editor")?.value || "";
try {
await navigator.clipboard.writeText(content);
setOutput("Content copied to clipboard.");
} catch {
setOutput("Copy failed. Please select and copy manually.");
}
});
// Regenerate with feedback (placeholder - would need backend support)
byId("draft-regenerate")?.addEventListener("click", async () => {
const feedback = byId("draft-feedback")?.value?.trim();
if (!feedback) {
byId("draft-feedback")?.focus();
return;
}
setOutput("Regeneration with feedback is not yet implemented in the backend. The feedback has been noted.");
// TODO: Implement backend endpoint for regenerating drafts with feedback
});
};
const renderPagination = (key, total, start, end, pageState, rerender) => {
const container = byId(`${key}-pagination`);
if (!container) return;
container.innerHTML = "";
const info = document.createElement("div");
info.className = "meta muted";
if (total === 0) {
info.textContent = "0 items";
} else {
info.textContent = `Showing ${start + 1}-${end} of ${total}`;
}
container.appendChild(info);
const totalPages = Math.max(1, Math.ceil(total / pageState.size));
if (totalPages === 1 && total <= pageState.size) {
return;
}
const controls = document.createElement("div");
controls.className = "pager-controls";
const prev = document.createElement("button");
prev.className = "secondary sm";
prev.textContent = "Prev";
prev.disabled = pageState.page <= 1;
prev.addEventListener("click", () => {
pageState.page = Math.max(1, pageState.page - 1);
rerender();
});
const next = document.createElement("button");
next.className = "secondary sm";
next.textContent = "Next";
next.disabled = pageState.page >= totalPages;
next.addEventListener("click", () => {
pageState.page = Math.min(totalPages, pageState.page + 1);
rerender();
});
const counter = document.createElement("span");
counter.className = "meta muted";
counter.textContent = `Page ${pageState.page} / ${totalPages}`;
const sizeSelect = document.createElement("select");
[5, 10, 20].forEach((size) => {
const option = document.createElement("option");
option.value = String(size);
option.textContent = `Show ${size}`;
if (pageState.size === size) option.selected = true;
sizeSelect.appendChild(option);
});
sizeSelect.addEventListener("change", (e) => {
pageState.size = Number(e.target.value);
pageState.page = 1;
rerender();
});
controls.appendChild(prev);
controls.appendChild(counter);
controls.appendChild(next);
controls.appendChild(sizeSelect);
container.appendChild(controls);
};
const renderPaginatedList = ({
key,
items,
listEl,
renderItem,
emptyMessage = "Nothing here yet.",
pageSize = 6,
rerender,
}) => {
const pageState = getPaginationState(key, pageSize);
const total = items.length;
const totalPages = Math.max(1, Math.ceil(total / pageState.size));
if (pageState.page > totalPages) pageState.page = totalPages;
const start = (pageState.page - 1) * pageState.size;
const end = Math.min(start + pageState.size, total);
listEl.innerHTML = "";
if (total === 0) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.innerHTML = `
<svg class="empty-state-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
<div class="empty-state-title">No items yet</div>
<div class="empty-state-description">${emptyMessage}</div>
`;
listEl.appendChild(empty);
renderPagination(key, total, 0, 0, pageState, rerender);
return;
}
items.slice(start, end).forEach(renderItem);
renderPagination(key, total, start, end, pageState, rerender);
};
const renderPending = () => {
const list = byId("pending-list");
renderPaginatedList({
key: "pending",
items: state.pending,
listEl: list,
pageSize: 6,
emptyMessage: "No pending drafts.",
rerender: renderPending,
renderItem: (item) => {
const el = document.createElement("div");
el.className = "item";
el.innerHTML = `
<h3>${item.post_title || "Untitled"}</h3>
<div class="meta">Draft ID: ${item.draft_post_id} • Post ID: ${item.original_post_id}</div>
<div class="actions">
<button class="secondary" data-view="${item.draft_post_id}">View</button>
<button data-approve="${item.draft_post_id}">Approve</button>
<button class="danger" data-reject="${item.draft_post_id}">Reject</button>
</div>
`;
list.appendChild(el);
},
});
};
const renderIdeas = () => {
const list = byId("ideas-list");
renderPaginatedList({
key: "ideas",
items: state.ideas,
listEl: list,
pageSize: 6,
emptyMessage: "No new ideas yet.",
rerender: renderIdeas,
renderItem: (item) => {
const el = document.createElement("div");
el.className = "item";
const summary = item.summary || "Summary not available yet.";
const sources = (item.sources || [])
.map(
(src) =>
`<a href="${src.url}" target="_blank" rel="noreferrer">${src.title || "Source"}</a>`
)
.join("");
const isInProgress = state.inProgress.ideaDrafts.has(item.id);
const isDrafted = item.status === "drafted";
const { en: draftEn, es: draftEs } = getIdeaDraftIds(item);
const hasDrafts = draftEn || draftEs;
// Build status badge
let statusBadge = `<span class="badge">${item.status}</span>`;
if (isInProgress) {
statusBadge = `<span class="badge" style="background: var(--color-warning-subtle); color: var(--color-warning);">Drafting...</span>`;
} else if (isDrafted) {
statusBadge = `<span class="badge internal">Drafted</span>`;
}
// Build action buttons based on state
let actionButtons = "";
if (isInProgress) {
actionButtons = `<button class="secondary" disabled><span class="spinner"></span></button>`;
} else if (isDrafted && hasDrafts) {
actionButtons = `
<button class="secondary" data-idea-review="${item.id}">Review Drafts</button>
<button data-idea-publish="${item.id}">Publish</button>
<button class="danger" data-idea-delete="${item.id}">Delete</button>
`;
} else {
actionButtons = `
<button class="secondary" data-idea-draft="${item.id}">Generate Draft</button>
<button class="danger" data-idea-delete="${item.id}">Delete</button>
`;
}
el.innerHTML = `
<h3>${item.seo_title}</h3>
<div class="meta">${statusBadge}${new Date(item.created_at).toLocaleString()}</div>
<div class="meta">${summary}</div>
${item.primary_keyword ? `<div class="meta">Primary keyword: ${item.primary_keyword}</div>` : ""}
${sources ? `<div class="source-links">${sources}</div>` : ""}
<div class="actions">${actionButtons}</div>
`;
list.appendChild(el);
},
});
};
const getOutreachFilter = () => {
const select = byId("outreach-status-filter");
if (select && select.value) return select.value;
return state.outreachStatus || "new";
};
const setOutreachFilter = (status) => {
const next = status || "new";
state.outreachStatus = next;
localStorage.setItem("wpsk-outreach-status", next);
const select = byId("outreach-status-filter");
if (select) {
select.value = next;
}
};
const renderOutreach = () => {
const list = byId("outreach-list");
renderPaginatedList({
key: "outreach",
items: state.outreach,
listEl: list,
pageSize: 6,
emptyMessage: "No outreach suggestions yet.",
rerender: renderOutreach,
renderItem: (item) => {
const el = document.createElement("div");
el.className = "item";
const status = item.status || "new";
const actions = [
`<button class="secondary" data-outreach-view="${item.id}">View</button>`,
];
if (status !== "drafted") {
actions.push(`<button class="secondary" data-outreach-status="${item.id}" data-status-value="drafted">Mark Drafted</button>`);
}
if (status !== "reviewed") {
actions.push(`<button class="secondary" data-outreach-status="${item.id}" data-status-value="reviewed">Mark Reviewed</button>`);
}
if (status !== "new") {
actions.push(`<button class="secondary" data-outreach-status="${item.id}" data-status-value="new">Move to New</button>`);
}
actions.push(`<button class="danger" data-outreach-delete="${item.id}">Delete</button>`);
el.innerHTML = `
<h3>${item.reddit_title || "Reddit Thread"}</h3>
<div class="meta">${item.article_title || "Article"}</div>
<div class="meta"><span class="badge">${status}</span></div>
<div class="actions">
${actions.join("")}
</div>
`;
list.appendChild(el);
},
});
};
const renderNewsletters = () => {
const list = byId("newsletter-list");
renderPaginatedList({
key: "newsletter",
items: state.newsletters,
listEl: list,
pageSize: 6,
emptyMessage: "No newsletter drafts yet.",
rerender: renderNewsletters,
renderItem: (item) => {
const el = document.createElement("div");
el.className = "item";
el.innerHTML = `
<h3>${item.subject || "Newsletter Draft"}</h3>
<div class="meta">${item.status}${new Date(item.created_at).toLocaleString()}</div>
<div class="actions">
<button class="secondary" data-newsletter-view="${item.id}">View</button>
<button class="danger" data-newsletter-delete="${item.id}">Delete</button>
</div>
`;
list.appendChild(el);
},
});
};
const renderBrokenLinks = () => {
const list = byId("broken-links-list");
const summary = byId("broken-links-summary");
list.innerHTML = "";
const report = state.brokenLinks?.report;
if (!report) {
summary.textContent = "No broken link report yet.";
return;
}
summary.textContent = `Last scan: ${new Date(report.created_at).toLocaleString()} • Posts checked: ${report.total_posts} • Links checked: ${report.links_checked} • Broken links: ${report.total_broken_links}`;
if (!report.posts || report.posts.length === 0) {
summary.textContent += " • No broken links found.";
renderPaginatedList({
key: "broken-links",
items: [],
listEl: list,
pageSize: 4,
emptyMessage: "No broken links found.",
rerender: renderBrokenLinks,
renderItem: () => {},
});
return;
}
renderPaginatedList({
key: "broken-links",
items: report.posts || [],
listEl: list,
pageSize: 4,
emptyMessage: "No broken links found.",
rerender: renderBrokenLinks,
renderItem: (post) => {
const el = document.createElement("div");
el.className = "item";
const brokenItems = (post.broken_links || [])
.map((link) => {
const badgeClass = link.internal ? "badge internal" : "badge external";
const errorClass = "badge error";
const badge = `<span class="${badgeClass}">${link.internal ? "Internal" : "External"}</span>`;
const status = `<span class="${errorClass}">${link.status || link.status_code || "error"}</span>`;
return `
<div class="broken-link">
<div>${badge} ${status}</div>
<a href="${link.url}" target="_blank" rel="noreferrer">${link.url}</a>
</div>
`;
})
.join("");
const postLink = post.post_url
? `<a href="${post.post_url}" target="_blank" rel="noreferrer">Open post</a>`
: "";
el.innerHTML = `
<h3>${post.post_title || "Untitled"}</h3>
<div class="meta">Post ID: ${post.post_id} • Broken: ${post.total_broken} (Internal ${post.internal_broken}, External ${post.external_broken})</div>
${postLink ? `<div class="actions">${postLink}</div>` : ""}
<div class="broken-links">${brokenItems}</div>
`;
list.appendChild(el);
},
});
};
const renderImageAudit = () => {
const summary = byId("image-audit-summary");
const externalList = byId("image-audit-external-list");
const featuredList = byId("image-audit-featured-list");
externalList.innerHTML = "";
featuredList.innerHTML = "";
const report = state.imageAudit?.report;
if (!report) {
summary.textContent = "No image audit report yet.";
return;
}
summary.textContent = `Last scan: ${new Date(report.created_at).toLocaleString()} • Posts checked: ${report.total_posts} • Images checked: ${report.images_checked} • External images: ${report.total_external_images}`;
if (!report.posts || report.posts.length === 0) {
renderPaginatedList({
key: "image-audit-external",
items: [],
listEl: externalList,
pageSize: 4,
emptyMessage: "No external image issues found.",
rerender: renderImageAudit,
renderItem: () => {},
});
} else {
renderPaginatedList({
key: "image-audit-external",
items: report.posts,
listEl: externalList,
pageSize: 4,
emptyMessage: "No external image issues found.",
rerender: renderImageAudit,
renderItem: (post) => {
const el = document.createElement("div");
el.className = "item";
const metaScore = post.meta
? `SEO Title Score: ${post.meta.title_score} • Description Score: ${post.meta.description_score}`
: "SEO meta not available";
const issues = post.meta
? [...(post.meta.title_issues || []), ...(post.meta.description_issues || [])].join(", ")
: "";
const images = (post.external_images || [])
.map(
(img) => `
<div class="broken-link">
<div><span class="badge external">External</span> <span class="badge error">${img.host}</span></div>
<a href="${img.url}" target="_blank" rel="noreferrer">${img.url}</a>
</div>
`
)
.join("");
el.innerHTML = `
<h3>${post.post_title || "Untitled"}</h3>
<div class="meta">Post ID: ${post.post_id} • External images: ${post.external_count}</div>
<div class="meta">${metaScore}${issues ? ` • Issues: ${issues}` : ""}</div>
<div class="actions">
${post.post_url ? `<a href="${post.post_url}" target="_blank" rel="noreferrer">Open post</a>` : ""}
<button class="secondary" data-image-fix="${post.post_id}">Fix (create draft)</button>
</div>
<div class="broken-links">${images}</div>
`;
externalList.appendChild(el);
},
});
}
if (!report.featured_image_mismatches || report.featured_image_mismatches.length === 0) {
renderPaginatedList({
key: "image-audit-featured",
items: [],
listEl: featuredList,
pageSize: 4,
emptyMessage: "No featured image mismatches found.",
rerender: renderImageAudit,
renderItem: () => {},
});
} else {
renderPaginatedList({
key: "image-audit-featured",
items: report.featured_image_mismatches,
listEl: featuredList,
pageSize: 4,
emptyMessage: "No featured image mismatches found.",
rerender: renderImageAudit,
renderItem: (item) => {
const el = document.createElement("div");
el.className = "item";
el.innerHTML = `
<h3>${item.english_title || "English Post"}</h3>
<div class="meta">EN Post ${item.english_post_id} • ES Post ${item.spanish_post_id}</div>
<div class="meta">EN Featured ID: ${item.english_featured_id} • ES Featured ID: ${item.spanish_featured_id}</div>
<div class="actions">
${item.english_url ? `<a href="${item.english_url}" target="_blank" rel="noreferrer">Open EN</a>` : ""}
${item.spanish_url ? `<a href="${item.spanish_url}" target="_blank" rel="noreferrer">Open ES</a>` : ""}
<button class="secondary" data-featured-sync="${item.english_post_id}">Sync EN → ES</button>
</div>
`;
featuredList.appendChild(el);
},
});
}
};
const renderSEOAudit = () => {
const summary = byId("seo-audit-summary");
const list = byId("seo-audit-list");
list.innerHTML = "";
const report = state.seoAudit?.report;
if (!report) {
summary.textContent = "No SEO audit report yet.";
return;
}
summary.textContent = `Last scan: ${new Date(report.created_at).toLocaleString()} • Posts checked: ${report.total_posts}`;
if (!report.posts || report.posts.length === 0) {
renderPaginatedList({
key: "seo-audit",
items: [],
listEl: list,
pageSize: 6,
emptyMessage: "No SEO audit data.",
rerender: renderSEOAudit,
renderItem: () => {},
});
return;
}
renderPaginatedList({
key: "seo-audit",
items: report.posts,
listEl: list,
pageSize: 6,
emptyMessage: "No SEO audit data.",
rerender: renderSEOAudit,
renderItem: (post) => {
const el = document.createElement("div");
el.className = "item";
const issues = (post.issues || []).map((issue) => `${issue.type}:${issue.details}`).join(", ");
const issueTypes = new Set((post.issues || []).map((issue) => issue.type || ""));
const hasTitleIssue = [...issueTypes].some((type) => type.toLowerCase().includes("title"));
const hasDescIssue = [...issueTypes].some((type) => type.toLowerCase().includes("description"));
const rec = state.seoRecommendations[post.post_id];
const isLoading = rec?.status === "loading" || state.inProgress.seoOptimizations.has(post.post_id);
const hasRecommendation = rec?.status === "ready";
const isApplied = rec?.status === "applied";
const hasError = rec?.status === "error";
// Build action buttons based on current state
let actionButtons = "";
if (isLoading) {
// Currently generating - show loading state
actionButtons = `<button class="secondary" disabled><span class="spinner"></span> Generating...</button>`;
} else if (hasRecommendation) {
// Has recommendation ready - only show Apply button (no regenerate to avoid confusion)
actionButtons = `
<button data-seo-apply-draft="${rec.draftId}" data-seo-post="${post.post_id}">Apply Changes</button>
<button class="secondary" data-seo-discard="${post.post_id}">Discard</button>
`;
} else if (isApplied) {
// Already applied - show done state
actionButtons = `<span class="badge internal">Changes Applied</span>`;
} else {
// No recommendation yet - show generate button
const scope = hasTitleIssue && hasDescIssue ? "full" : hasTitleIssue ? "title" : hasDescIssue ? "description" : "full";
const label = hasTitleIssue && hasDescIssue ? "Optimize SEO" : hasTitleIssue ? "Fix Title" : hasDescIssue ? "Fix Description" : "Optimize SEO";
actionButtons = `<button class="secondary" data-seo-generate="${post.post_id}" data-seo-scope="${scope}">${label}</button>`;
}
// Build recommendation display block
let recBlock = "";
if (hasError) {
recBlock = `
<div class="seo-recommendation error">
<div class="meta">${rec.message || "Failed to generate recommendation."}</div>
<button class="secondary" data-seo-retry="${post.post_id}">Retry</button>
</div>
`;
} else if (hasRecommendation) {
const titleDiff = rec.draftTitle !== rec.originalTitle ? `
<div class="seo-diff">
<div class="seo-label">Title</div>
<div class="seo-text seo-current">${renderDiffHTML(rec.originalTitle, rec.draftTitle, "original")}</div>
<div class="seo-text seo-recommended">${renderDiffHTML(rec.originalTitle, rec.draftTitle, "draft")}</div>
</div>
` : "";
const descDiff = rec.draftDesc !== rec.originalDesc ? `
<div class="seo-diff">
<div class="seo-label">Description</div>
<div class="seo-text seo-current">${renderDiffHTML(rec.originalDesc, rec.draftDesc, "original")}</div>
<div class="seo-text seo-recommended">${renderDiffHTML(rec.originalDesc, rec.draftDesc, "draft")}</div>
</div>
` : "";
if (titleDiff || descDiff) {
recBlock = `
<div class="seo-recommendation ready">
<div class="seo-pill">Preview</div>
${titleDiff}${descDiff}
</div>
`;
}
} else if (isLoading) {
recBlock = `
<div class="seo-recommendation loading">
<div class="meta"><span class="spinner"></span> Analyzing and generating optimizations...</div>
</div>
`;
}
el.innerHTML = `
<h3>${post.post_title || "Untitled"}</h3>
<div class="meta">Post ID: ${post.post_id} • Score: ${post.score}/100</div>
<div class="meta">Title: ${post.title_score} • Description: ${post.description_score} • Internal Links: ${post.internal_link_count} • External Links: ${post.external_link_count}</div>
${post.post_url ? `<div class="actions"><a href="${post.post_url}" target="_blank" rel="noreferrer">Open post</a></div>` : ""}
${issues ? `<div class="meta" style="color: var(--color-warning);">Issues: ${issues}</div>` : ""}
<div class="actions">${actionButtons}</div>
${recBlock}
`;
list.appendChild(el);
},
});
};
const renderOrphans = () => {
const list = byId("orphans-list");
const summary = byId("orphans-summary");
summary.textContent = `Orphan posts: ${state.orphans.length}`;
renderPaginatedList({
key: "orphans",
items: state.orphans,
listEl: list,
pageSize: 6,
emptyMessage: "No orphan posts found.",
rerender: renderOrphans,
renderItem: (item) => {
const el = document.createElement("div");
el.className = "item";
el.innerHTML = `
<h3>${item.title || "Untitled"}</h3>
<div class="meta">Post ID: ${item.post_id} • Missing: ${item.missing_lang}</div>
<div class="actions">
${item.url ? `<a href="${item.url}" target="_blank" rel="noreferrer">Open post</a>` : ""}
<button class="secondary" data-translate="${item.post_id}">Generate ES Draft</button>
</div>
`;
list.appendChild(el);
},
});
};
const renderAudit = () => {
const list = byId("audit-list");
renderPaginatedList({
key: "audit",
items: state.audit,
listEl: list,
pageSize: 8,
emptyMessage: "No audit records yet.",
rerender: renderAudit,
renderItem: (item) => {
const el = document.createElement("div");
el.className = "item";
const metaParts = [];
if (item.created_at) {
metaParts.push(new Date(item.created_at).toLocaleString());
}
if (item.actor) {
metaParts.push(item.actor);
}
if (item.action) {
metaParts.push(item.action);
}
const idParts = [];
if (item.post_id) {
idParts.push(`Post ${item.post_id}`);
}
if (item.draft_post_id) {
idParts.push(`Draft ${item.draft_post_id}`);
}
if (item.job_id) {
idParts.push(`Job ${item.job_id}`);
}
if (item.language) {
idParts.push(`Lang ${item.language}`);
}
el.innerHTML = `
<h3>${item.summary || item.action || "Audit entry"}</h3>
<div class="meta">${metaParts.join(" • ")}</div>
${idParts.length ? `<div class="meta">${idParts.join(" • ")}</div>` : ""}
`;
list.appendChild(el);
},
});
};
const loadHealth = async () => {
const res = await apiFetch("/api/v1/health");
byId("server-version").textContent = res.version || "unknown";
};
const loadPending = async () => {
setLoading("panel-pending", true);
try {
const res = await apiFetch("/api/v1/pending");
state.pending = res.records || [];
renderPending();
} finally {
setLoading("panel-pending", false);
}
};
const loadIdeas = async () => {
setLoading("panel-ideas", true);
try {
// Load both new and drafted ideas
const [newRes, draftedRes] = await Promise.all([
apiFetch("/api/v1/ideas?status=new"),
apiFetch("/api/v1/ideas?status=drafted"),
]);
const newIdeas = newRes.ideas || [];
const draftedIdeas = draftedRes.ideas || [];
// Combine and sort by created_at descending
state.ideas = [...newIdeas, ...draftedIdeas].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
);
renderIdeas();
} finally {
setLoading("panel-ideas", false);
}
};
const loadOutreach = async () => {
setLoading("panel-outreach", true);
try {
const status = getOutreachFilter();
const res = await apiFetch(`/api/v1/outreach?status=${encodeURIComponent(status)}`);
state.outreach = res.items || [];
renderOutreach();
const newRes = await apiFetch("/api/v1/outreach?status=new");
const alert = byId("outreach-alert");
if (newRes.count > 0) {
alert.textContent = `${newRes.count} pending outreach suggestions`;
alert.classList.remove("hidden");
} else {
alert.classList.add("hidden");
}
} finally {
setLoading("panel-outreach", false);
}
};
const loadNewsletters = async () => {
setLoading("panel-newsletter", true);
try {
const res = await apiFetch("/api/v1/newsletter?status=draft");
state.newsletters = res.items || [];
renderNewsletters();
} finally {
setLoading("panel-newsletter", false);
}
};
const loadBrokenLinks = async () => {
setLoading("panel-broken-links", true);
try {
const res = await apiFetch("/api/v1/broken-links");
state.brokenLinks = res || {};
renderBrokenLinks();
} finally {
setLoading("panel-broken-links", false);
}
};
const loadImageAudit = async () => {
setLoading("panel-image-audit", true);
try {
const res = await apiFetch("/api/v1/image-audit");
state.imageAudit = res || {};
renderImageAudit();
} finally {
setLoading("panel-image-audit", false);
}
};
const loadSEOAudit = async () => {
setLoading("panel-seo-audit", true);
try {
const res = await apiFetch("/api/v1/seo-audit");
state.seoAudit = res || {};
renderSEOAudit();
} finally {
setLoading("panel-seo-audit", false);
}
};
const loadOrphans = async () => {
const site = state.currentSite ? state.siteMap[state.currentSite] : null;
if (!siteSupportsOrphans(site)) {
state.orphans = [];
renderOrphans();
return;
}
setLoading("panel-orphans", true);
try {
const res = await apiFetch("/api/v1/orphans?source=en&target=es");
state.orphans = res.orphans || [];
renderOrphans();
} finally {
setLoading("panel-orphans", false);
}
};
const loadAudit = async () => {
setLoading("panel-audit", true);
try {
const res = await apiFetch("/api/v1/audit?limit=100");
state.audit = res.records || [];
renderAudit();
} finally {
setLoading("panel-audit", false);
}
};
const refreshAll = async () => {
const site = state.currentSite ? state.siteMap[state.currentSite] : null;
const features = site?.features || {};
const tasks = [
loadHealth(),
loadPending(),
loadBrokenLinks(),
loadImageAudit(),
loadSEOAudit(),
loadAudit(),
];
if (siteSupportsOrphans(site)) {
tasks.push(loadOrphans());
} else {
state.orphans = [];
renderOrphans();
}
if (features.ideas !== false) {
tasks.push(loadIdeas());
}
if (features.outreach !== false) {
tasks.push(loadOutreach());
}
if (features.newsletter) {
tasks.push(loadNewsletters());
}
await Promise.all(tasks);
};
const commandHandlers = {
help: async () =>
"Commands: health, version, pending [summary|text <id>], approve <id>, reject <id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], broken-links [scan], image-audit [scan|fix <post_id>|sync <post_id> [lang]], seo-audit [scan], search <text> [--case] [--exact] [--regex] [--block] [--max=N] [--posts=N], orphans, translate <post_id> [lang], optimize <post_id> [lang], changes <post_id>, status <job_id>, audit [limit]",
health: async () => JSON.stringify(await apiFetch("/api/v1/health"), null, 2),
version: async () => JSON.stringify(await apiFetch("/api/v1/health"), null, 2),
pending: async (args) => {
if (args[0] === "text" && args[1]) {
const draft = await apiFetch(`/api/v1/drafts/${args[1]}`);
return `${draft.title}\n\n${stripHTML(draft.content)}`;
}
return JSON.stringify(await apiFetch("/api/v1/pending"), null, 2);
},
approve: async (args) => {
await apiFetch("/api/v1/apply-draft", {
method: "POST",
body: JSON.stringify({ draft_post_id: Number(args[0]) }),
});
await loadPending();
return "Draft approved.";
},
reject: async (args) => {
await apiFetch("/api/v1/reject-draft", {
method: "POST",
body: JSON.stringify({ draft_post_id: Number(args[0]) }),
});
await loadPending();
return "Draft rejected.";
},
ideas: async (args) => {
if (args[0] === "generate") {
return JSON.stringify(await apiFetch("/api/v1/ideas/generate", { method: "POST" }), null, 2);
}
if (args[0] === "draft" && args[1]) {
return JSON.stringify(await apiFetch(`/api/v1/ideas/${args[1]}/draft`, { method: "POST" }), null, 2);
}
if (args[0] === "publish" && args[1]) {
return JSON.stringify(await apiFetch(`/api/v1/ideas/${args[1]}/publish`, { method: "POST" }), null, 2);
}
if (args[0] === "delete" && args[1]) {
return JSON.stringify(await apiFetch(`/api/v1/ideas/${args[1]}/delete`, { method: "POST" }), null, 2);
}
const status = args[0] || "new";
return JSON.stringify(await apiFetch(`/api/v1/ideas?status=${status}`), null, 2);
},
outreach: async (args) => {
if (args[0] === "generate") {
return JSON.stringify(await apiFetch("/api/v1/outreach/generate", { method: "POST" }), null, 2);
}
if (args[0] === "delete" && args[1]) {
return JSON.stringify(await apiFetch(`/api/v1/outreach/${args[1]}/delete`, { method: "POST" }), null, 2);
}
const status = args[0] || "new";
return JSON.stringify(await apiFetch(`/api/v1/outreach?status=${status}`), null, 2);
},
newsletter: async (args) => {
if (args[0] === "generate") {
return JSON.stringify(await apiFetch("/api/v1/newsletter/generate", { method: "POST" }), null, 2);
}
if (args[0] === "delete" && args[1]) {
return JSON.stringify(await apiFetch(`/api/v1/newsletter/${args[1]}/delete`, { method: "POST" }), null, 2);
}
const status = args[0] || "draft";
return JSON.stringify(await apiFetch(`/api/v1/newsletter?status=${status}`), null, 2);
},
"broken-links": async (args) => {
if (args[0] === "scan") {
await apiFetch("/api/v1/broken-links/scan", { method: "POST" });
return "Broken link scan started.";
}
return JSON.stringify(await apiFetch("/api/v1/broken-links"), null, 2);
},
"image-audit": async (args) => {
if (args[0] === "scan") {
await apiFetch("/api/v1/image-audit/scan", { method: "POST" });
return "Image audit started.";
}
if (args[0] === "fix" && args[1]) {
return JSON.stringify(
await apiFetch("/api/v1/image-audit/fix", {
method: "POST",
body: JSON.stringify({ post_id: Number(args[1]) }),
}),
null,
2
);
}
if (args[0] === "sync" && args[1]) {
return JSON.stringify(
await apiFetch("/api/v1/image-audit/sync-featured", {
method: "POST",
body: JSON.stringify({ post_id: Number(args[1]), target_lang: args[2] || "es" }),
}),
null,
2
);
}
return JSON.stringify(await apiFetch("/api/v1/image-audit"), null, 2);
},
"seo-audit": async (args) => {
if (args[0] === "scan") {
await apiFetch("/api/v1/seo-audit/scan", { method: "POST" });
return "SEO audit started.";
}
return JSON.stringify(await apiFetch("/api/v1/seo-audit"), null, 2);
},
search: async (args) => {
if (!args.length) return "search <text> [--case] [--exact] [--regex] [--block] [--max=N] [--posts=N]";
const flags = new Set(args.filter((t) => t.startsWith("--")));
const rawQuery = args.filter((t) => !t.startsWith("--")).join(" ").trim();
const maxFlag = args.find((t) => t.startsWith("--max="));
const postsFlag = args.find((t) => t.startsWith("--posts="));
const maxResults = maxFlag ? Number(maxFlag.split("=")[1]) : 50;
const maxPosts = postsFlag ? Number(postsFlag.split("=")[1]) : 0;
const payload = {
query: rawQuery,
case_sensitive: flags.has("--case"),
exact_match: flags.has("--exact"),
regex: flags.has("--regex"),
block_snippet: flags.has("--block"),
max_results: Number.isFinite(maxResults) ? maxResults : 50,
max_posts: Number.isFinite(maxPosts) ? maxPosts : 0,
};
return JSON.stringify(await apiFetch("/api/v1/search", { method: "POST", body: JSON.stringify(payload) }), null, 2);
},
orphans: async () => {
const site = state.currentSite ? state.siteMap[state.currentSite] : null;
if (!siteSupportsOrphans(site)) {
return "Orphan posts are only available for sites with multiple languages.";
}
return JSON.stringify(await apiFetch("/api/v1/orphans?source=en&target=es"), null, 2);
},
translate: async (args) => {
if (!args[0]) return "translate <post_id> [lang]";
return JSON.stringify(
await apiFetch("/api/v1/translate", {
method: "POST",
body: JSON.stringify({ post_id: Number(args[0]), target_lang: args[1] || "es" }),
}),
null,
2
);
},
optimize: async (args) => {
const payload = { post_id: Number(args[0]), language: args[1] || "en" };
return JSON.stringify(
await apiFetch("/api/v1/optimize", { method: "POST", body: JSON.stringify(payload) }),
null,
2
);
},
changes: async (args) => JSON.stringify(await apiFetch(`/api/v1/optimization/${args[0]}`), null, 2),
status: async (args) => JSON.stringify(await apiFetch(`/api/v1/status/${args[0]}`), null, 2),
audit: async (args) => {
const limit = args[0] ? Number(args[0]) : 100;
return JSON.stringify(await apiFetch(`/api/v1/audit?limit=${limit || 100}`), null, 2);
},
};
const commandSuggestions = [
{ label: "Pending drafts", command: "pending" },
{ label: "Run SEO audit", command: "seo-audit scan" },
{ label: "Broken links scan", command: "broken-links scan" },
{ label: "Image audit scan", command: "image-audit scan" },
{ label: "Generate ideas", command: "ideas generate" },
{ label: "Generate outreach", command: "outreach generate" },
{ label: "Generate newsletter", command: "newsletter generate" },
{ label: "Audit log", command: "audit 50" },
];
const renderCommandSuggestions = (filter = "") => {
const container = byId("command-suggestions");
if (!container) return;
const query = filter.trim().toLowerCase();
const suggestions = commandSuggestions.filter((item) =>
query ? item.label.toLowerCase().includes(query) || item.command.includes(query) : true
);
container.innerHTML = "";
suggestions.forEach((item) => {
const button = document.createElement("button");
button.className = "suggestion";
button.type = "button";
button.textContent = item.label;
button.setAttribute("data-command", item.command);
container.appendChild(button);
});
};
const setActivePage = (page) => {
state.currentPage = page;
localStorage.setItem("wpsk-active-page", page);
document.querySelectorAll(".section[data-page]").forEach((section) => {
section.classList.toggle("active", section.dataset.page === page);
});
document.querySelectorAll(".tab[data-page], .nav-link[data-page]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.page === page);
});
};
const initPage = () => {
const saved = localStorage.getItem("wpsk-active-page");
setActivePage(saved || "overview");
};
const waitForDraft = async (postId, attempts = 12, delayMs = 3000) => {
for (let i = 0; i < attempts; i += 1) {
const res = await apiFetch("/api/v1/pending");
const draft = (res.records || []).find((rec) => Number(rec.original_post_id) === Number(postId));
if (draft) return draft;
await sleep(delayMs);
}
return null;
};
const loadCompare = async (draftId) => apiFetch(`/api/v1/compare/${draftId}`);
const applyDraft = async (draftId) => {
await apiFetch("/api/v1/apply-draft", {
method: "POST",
body: JSON.stringify({ draft_post_id: Number(draftId) }),
});
};
const requestSEORecommendation = async (postId, scope = "title", autoApply = false) => {
// Prevent duplicate requests
if (state.inProgress.seoOptimizations.has(postId)) {
setOutput(`SEO optimization already in progress for post ${postId}`);
return;
}
state.inProgress.seoOptimizations.add(postId);
state.seoRecommendations[postId] = { status: "loading", scope };
renderSEOAudit();
setOutput(`Generating SEO ${scope} recommendation for post ${postId}...`);
try {
// Check if there's already a pending draft for this post
let draft = await waitForDraft(postId, 1, 0);
if (!draft) {
// Start optimization job
const res = await apiFetch("/api/v1/optimize", {
method: "POST",
body: JSON.stringify({ post_id: Number(postId), language: "en" }),
});
appendOutput(`Job ${res.job_id || "queued"} started. Waiting for completion...`);
// Poll for the draft with extended timeout
draft = await waitForDraft(postId, 20, 3000);
}
if (!draft) {
state.seoRecommendations[postId] = { status: "error", scope, message: "Optimization timed out. Check Pending Drafts." };
renderSEOAudit();
return;
}
// Load the comparison data
const comparison = await loadCompare(draft.draft_post_id);
const original = comparison.original || {};
const draftData = comparison.draft || {};
state.seoRecommendations[postId] = {
status: "ready",
scope,
draftId: draft.draft_post_id,
originalTitle: original.seo_title || original.title || "",
draftTitle: draftData.seo_title || draftData.title || "",
originalDesc: original.seo_description || "",
draftDesc: draftData.seo_description || "",
};
if (autoApply) {
await applyDraft(draft.draft_post_id);
state.seoRecommendations[postId] = {
status: "applied",
scope,
message: "Changes applied automatically.",
};
await loadPending();
await loadAudit();
}
setOutput(`SEO recommendation ready for post ${postId}.`);
renderSEOAudit();
} catch (err) {
state.seoRecommendations[postId] = { status: "error", scope, message: err.message };
appendOutput(`Error: ${err.message}`);
renderSEOAudit();
} finally {
state.inProgress.seoOptimizations.delete(postId);
}
};
const runCommand = async () => {
const input = byId("command-input");
const tokens = input.value.trim().split(/\s+/).filter(Boolean);
if (!tokens.length) return;
const cmd = tokens[0];
const args = tokens.slice(1);
const handler = commandHandlers[cmd];
if (!handler) {
appendOutput(`Unknown command: ${cmd}`);
return;
}
try {
const res = await handler(args);
setOutput(res);
const site = state.currentSite ? state.siteMap[state.currentSite] : null;
const reloadTasks = [
loadPending(),
loadIdeas(),
loadOutreach(),
loadNewsletters(),
loadBrokenLinks(),
loadImageAudit(),
loadSEOAudit(),
loadAudit(),
];
if (siteSupportsOrphans(site)) {
reloadTasks.push(loadOrphans());
} else {
state.orphans = [];
renderOrphans();
}
await Promise.all([
...reloadTasks,
]);
} catch (err) {
appendOutput(`Error: ${err.message}`);
}
};
const wireActions = () => {
// Theme toggle
const themeToggle = byId("theme-toggle");
if (themeToggle) {
themeToggle.addEventListener("click", toggleTheme);
}
document.querySelectorAll(".tab[data-page], .nav-link[data-page]").forEach((btn) => {
btn.addEventListener("click", () => setActivePage(btn.dataset.page));
});
const seoAutoApply = byId("seo-auto-apply");
if (seoAutoApply) {
seoAutoApply.checked = localStorage.getItem("wpsk-seo-auto-apply") === "true";
seoAutoApply.addEventListener("change", () => {
localStorage.setItem("wpsk-seo-auto-apply", String(seoAutoApply.checked));
});
}
byId("refresh-all").addEventListener("click", async (e) => {
const btn = e.currentTarget;
setButtonLoading(btn, true);
try {
await refreshAll();
} finally {
setButtonLoading(btn, false);
}
});
byId("generate-ideas").addEventListener("click", async (e) => {
const btn = e.currentTarget;
setButtonLoading(btn, true);
setOutput("Generating ideas...");
try {
await apiFetch("/api/v1/ideas/generate", { method: "POST" });
await loadIdeas();
await loadAudit();
} finally {
setButtonLoading(btn, false);
}
});
byId("generate-outreach").addEventListener("click", async (e) => {
const btn = e.currentTarget;
// Prevent duplicate generation
if (state.inProgress.outreachGeneration) {
setOutput("Outreach generation already in progress.");
return;
}
state.inProgress.outreachGeneration = true;
setButtonLoading(btn, true);
setOutput("Generating outreach suggestions...");
try {
await apiFetch("/api/v1/outreach/generate", { method: "POST" });
await loadOutreach();
await loadAudit();
setOutput("Outreach generation complete.");
} catch (err) {
appendOutput(`Error: ${err.message}`);
} finally {
state.inProgress.outreachGeneration = false;
setButtonLoading(btn, false);
}
});
byId("generate-newsletter").addEventListener("click", async (e) => {
const btn = e.currentTarget;
// Check if there's already a draft newsletter
if (state.newsletters.length > 0) {
setOutput("A newsletter draft already exists. Please review, publish, or delete it before generating a new one.");
return;
}
// Prevent duplicate generation
if (state.inProgress.newsletterGeneration) {
setOutput("Newsletter generation already in progress.");
return;
}
state.inProgress.newsletterGeneration = true;
setButtonLoading(btn, true);
setOutput("Generating newsletter draft...");
try {
await apiFetch("/api/v1/newsletter/generate", { method: "POST" });
await loadNewsletters();
await loadAudit();
setOutput("Newsletter draft generated. Review and edit before publishing to Substack.");
} catch (err) {
appendOutput(`Error: ${err.message}`);
} finally {
state.inProgress.newsletterGeneration = false;
setButtonLoading(btn, false);
}
});
byId("refresh-pending").addEventListener("click", loadPending);
byId("refresh-ideas").addEventListener("click", loadIdeas);
setOutreachFilter(state.outreachStatus);
byId("outreach-status-filter").addEventListener("change", async (e) => {
setOutreachFilter(e.target.value);
await loadOutreach();
});
byId("refresh-outreach").addEventListener("click", loadOutreach);
byId("refresh-newsletter").addEventListener("click", loadNewsletters);
byId("refresh-broken-links").addEventListener("click", loadBrokenLinks);
byId("scan-broken-links").addEventListener("click", async (e) => {
const btn = e.currentTarget;
setButtonLoading(btn, true);
setOutput("Starting broken link scan...");
try {
await apiFetch("/api/v1/broken-links/scan", { method: "POST" });
await loadBrokenLinks();
} finally {
setButtonLoading(btn, false);
}
});
byId("refresh-image-audit").addEventListener("click", loadImageAudit);
byId("scan-image-audit").addEventListener("click", async (e) => {
const btn = e.currentTarget;
setButtonLoading(btn, true);
setOutput("Starting image audit...");
try {
await apiFetch("/api/v1/image-audit/scan", { method: "POST" });
await loadImageAudit();
} finally {
setButtonLoading(btn, false);
}
});
byId("refresh-seo-audit").addEventListener("click", loadSEOAudit);
byId("scan-seo-audit").addEventListener("click", async (e) => {
const btn = e.currentTarget;
setButtonLoading(btn, true);
setOutput("Starting SEO audit...");
try {
await apiFetch("/api/v1/seo-audit/scan", { method: "POST" });
await loadSEOAudit();
} finally {
setButtonLoading(btn, false);
}
});
byId("refresh-orphans").addEventListener("click", loadOrphans);
byId("refresh-audit").addEventListener("click", loadAudit);
byId("command-run").addEventListener("click", runCommand);
byId("command-input").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
runCommand();
}
});
byId("command-input").addEventListener("input", (e) => {
renderCommandSuggestions(e.target.value);
});
byId("command-suggestions").addEventListener("click", (e) => {
const command = e.target.getAttribute("data-command");
if (!command) return;
const input = byId("command-input");
input.value = command;
runCommand();
});
byId("pending-list").addEventListener("click", async (e) => {
const view = e.target.getAttribute("data-view");
const approve = e.target.getAttribute("data-approve");
const reject = e.target.getAttribute("data-reject");
if (view) {
const comparison = await apiFetch(`/api/v1/compare/${view}`);
showModalComparison(comparison);
}
if (approve) {
await apiFetch("/api/v1/apply-draft", {
method: "POST",
body: JSON.stringify({ draft_post_id: Number(approve) }),
});
await loadPending();
await loadAudit();
}
if (reject) {
await apiFetch("/api/v1/reject-draft", {
method: "POST",
body: JSON.stringify({ draft_post_id: Number(reject) }),
});
await loadPending();
await loadAudit();
}
});
byId("ideas-list").addEventListener("click", async (e) => {
const draft = e.target.getAttribute("data-idea-draft");
const review = e.target.getAttribute("data-idea-review");
const publish = e.target.getAttribute("data-idea-publish");
const del = e.target.getAttribute("data-idea-delete");
// Generate draft (async with polling)
if (draft) {
if (state.inProgress.ideaDrafts.has(draft)) {
setOutput(`Draft generation already in progress for idea ${draft}`);
return;
}
state.inProgress.ideaDrafts.add(draft);
renderIdeas();
setOutput(`Starting draft generation for idea ${draft}...`);
try {
const res = await apiFetch(`/api/v1/ideas/${draft}/draft`, { method: "POST" });
appendOutput(`Draft job started. Waiting for completion...`);
// Poll for completion by checking idea status
let attempts = 0;
const maxAttempts = 40; // 2 minutes with 3s intervals
while (attempts < maxAttempts) {
await sleep(3000);
const ideasRes = await apiFetch("/api/v1/ideas?status=drafted");
const completed = (ideasRes.ideas || []).find(i => i.id === draft);
if (completed) {
appendOutput(`Draft completed for idea ${draft}.`);
break;
}
attempts++;
}
await loadIdeas();
await loadAudit();
} catch (err) {
appendOutput(`Error: ${err.message}`);
} finally {
state.inProgress.ideaDrafts.delete(draft);
renderIdeas();
}
}
// Review drafts - show in modal for editing
if (review) {
const idea = state.ideas.find(i => i.id === review);
if (idea) {
showIdeaDraftModal(idea);
}
}
// Publish
if (publish) {
setButtonLoading(e.target, true);
try {
await apiFetch(`/api/v1/ideas/${publish}/publish`, { method: "POST" });
await loadIdeas();
await loadAudit();
setOutput(`Idea ${publish} published successfully.`);
} catch (err) {
appendOutput(`Error publishing: ${err.message}`);
} finally {
setButtonLoading(e.target, false);
}
}
// Delete
if (del) {
if (!confirm("Are you sure you want to delete this idea?")) return;
await apiFetch(`/api/v1/ideas/${del}/delete`, { method: "POST" });
await loadIdeas();
await loadAudit();
}
});
byId("outreach-list").addEventListener("click", async (e) => {
const view = e.target.getAttribute("data-outreach-view");
const del = e.target.getAttribute("data-outreach-delete");
const statusID = e.target.getAttribute("data-outreach-status");
const statusValue = e.target.getAttribute("data-status-value");
if (view) {
const item = (state.outreach || []).find((it) => it.id === view);
if (item) {
showModalText(
"Outreach Suggestion",
`${item.reddit_url}\n\n${item.article_title}\n${item.article_url}\n\n${item.suggested_response}`
);
}
}
if (statusID && statusValue) {
await apiFetch(`/api/v1/outreach/${statusID}/status`, {
method: "POST",
body: JSON.stringify({ status: statusValue }),
});
await loadOutreach();
await loadAudit();
}
if (del) {
await apiFetch(`/api/v1/outreach/${del}/delete`, { method: "POST" });
await loadOutreach();
await loadAudit();
}
});
byId("newsletter-list").addEventListener("click", async (e) => {
const view = e.target.getAttribute("data-newsletter-view");
const del = e.target.getAttribute("data-newsletter-delete");
if (view) {
const res = await apiFetch("/api/v1/newsletter?status=draft");
const item = (res.items || []).find((it) => it.id === view);
if (item) {
showNewsletterModal(item);
}
}
if (del) {
await apiFetch(`/api/v1/newsletter/${del}/delete`, { method: "POST" });
await loadNewsletters();
await loadAudit();
}
});
byId("seo-audit-list").addEventListener("click", async (e) => {
const generate = e.target.getAttribute("data-seo-generate");
const retry = e.target.getAttribute("data-seo-retry");
const applyDraftId = e.target.getAttribute("data-seo-apply-draft");
const applyPost = e.target.getAttribute("data-seo-post");
const discard = e.target.getAttribute("data-seo-discard");
// Generate SEO recommendation (prevent duplicates)
if (generate || retry) {
const postId = generate || retry;
if (state.inProgress.seoOptimizations.has(postId)) {
setOutput(`SEO optimization already in progress for post ${postId}`);
return;
}
const scope = e.target.getAttribute("data-seo-scope") || "full";
const autoApply = seoAutoApply ? seoAutoApply.checked : false;
await requestSEORecommendation(postId, scope, autoApply);
}
// Apply the generated draft
if (applyDraftId && applyPost) {
setButtonLoading(e.target, true);
try {
await applyDraft(applyDraftId);
state.seoRecommendations[applyPost] = { status: "applied", message: "Changes applied successfully." };
await loadPending();
renderSEOAudit();
await loadAudit();
} catch (err) {
state.seoRecommendations[applyPost] = { status: "error", message: err.message };
renderSEOAudit();
} finally {
setButtonLoading(e.target, false);
}
}
// Discard recommendation
if (discard) {
delete state.seoRecommendations[discard];
state.inProgress.seoOptimizations.delete(discard);
renderSEOAudit();
}
});
byId("image-audit-external-list").addEventListener("click", async (e) => {
const fix = e.target.getAttribute("data-image-fix");
if (!fix) return;
setOutput(`Creating draft to fix external images for post ${fix}...`);
await apiFetch("/api/v1/image-audit/fix", {
method: "POST",
body: JSON.stringify({ post_id: Number(fix) }),
});
await loadPending();
await loadImageAudit();
await loadAudit();
});
byId("image-audit-featured-list").addEventListener("click", async (e) => {
const sync = e.target.getAttribute("data-featured-sync");
if (!sync) return;
setOutput(`Syncing featured image from EN post ${sync} to ES...`);
await apiFetch("/api/v1/image-audit/sync-featured", {
method: "POST",
body: JSON.stringify({ post_id: Number(sync), target_lang: "es" }),
});
await loadImageAudit();
await loadAudit();
});
byId("orphans-list").addEventListener("click", async (e) => {
const translate = e.target.getAttribute("data-translate");
if (!translate) return;
setOutput(`Generating Spanish draft for post ${translate}...`);
await apiFetch("/api/v1/translate", {
method: "POST",
body: JSON.stringify({ post_id: Number(translate), target_lang: "es" }),
});
await loadPending();
await loadOrphans();
await loadAudit();
});
byId("modal-close").addEventListener("click", hideModal);
byId("modal-mode-full").addEventListener("click", () => {
modalState.mode = "full";
renderModal();
});
byId("modal-mode-compare").addEventListener("click", () => {
modalState.mode = "compare";
renderModal();
});
byId("modal").addEventListener("click", (e) => {
if (e.target.id === "modal") hideModal();
});
};
window.addEventListener("DOMContentLoaded", async () => {
wireActions();
initPage();
renderCommandSuggestions();
await initSites();
await refreshAll();
});