2270 lines
76 KiB
JavaScript
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();
|
|
});
|