This commit is contained in:
2026-01-30 15:43:13 +01:00
parent 88ff1e1686
commit 4e52fadd81
5 changed files with 1808 additions and 704 deletions
+2 -2
View File
@@ -83,8 +83,8 @@ Respond with ONLY valid JSON (no markdown code blocks, no explanations outside J
{
"seo_meta": {
"title": "string (50-60 characters, include primary keyword, compelling)",
"description": "string (150-160 characters, include call-to-action, benefits, target keyword)"
"title": "string (50-60 characters, front-load primary keyword, clear intent, compelling but not clickbait)",
"description": "string (150-160 characters, include primary keyword, benefits, and a light call-to-action)"
},
"content_optimization": {
"changes": [
+368 -76
View File
@@ -1,3 +1,35 @@
// 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 res = await fetch(path, {
headers: { "Content-Type": "application/json", "X-WPSK-Client": "webui" },
@@ -18,6 +50,36 @@ 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",
@@ -58,6 +120,7 @@ const state = {
seoAudit: null,
orphans: [],
pagination: {},
seoRecommendations: {},
currentPage: "overview",
};
@@ -211,7 +274,7 @@ const renderModal = () => {
<h4>Substack-ready format</h4>
<div class="meta muted">Copy-ready markdown with sponsor block.</div>
</div>
<button id="newsletter-copy">Copy Markdown</button>
<button id="newsletter-copy" class="secondary">Copy Markdown</button>
`;
body.appendChild(header);
@@ -442,7 +505,7 @@ const renderPagination = (key, total, start, end, pageState, rerender) => {
controls.className = "pager-controls";
const prev = document.createElement("button");
prev.className = "ghost";
prev.className = "secondary sm";
prev.textContent = "Prev";
prev.disabled = pageState.page <= 1;
prev.addEventListener("click", () => {
@@ -451,7 +514,7 @@ const renderPagination = (key, total, start, end, pageState, rerender) => {
});
const next = document.createElement("button");
next.className = "ghost";
next.className = "secondary sm";
next.textContent = "Next";
next.disabled = pageState.page >= totalPages;
next.addEventListener("click", () => {
@@ -503,8 +566,15 @@ const renderPaginatedList = ({
if (total === 0) {
const empty = document.createElement("div");
empty.className = "meta muted";
empty.textContent = emptyMessage;
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;
@@ -530,7 +600,7 @@ const renderPending = () => {
<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 data-view="${item.draft_post_id}">View</button>
<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>
@@ -552,11 +622,21 @@ const 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("");
el.innerHTML = `
<h3>${item.seo_title}</h3>
<div class="meta">${item.status}${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">
<button data-idea-draft="${item.id}">Draft</button>
<button class="secondary" data-idea-draft="${item.id}">Draft</button>
<button data-idea-publish="${item.id}">Publish</button>
<button class="danger" data-idea-delete="${item.id}">Delete</button>
</div>
@@ -582,7 +662,7 @@ const renderOutreach = () => {
<h3>${item.reddit_title || "Reddit Thread"}</h3>
<div class="meta">${item.article_title || "Article"}</div>
<div class="actions">
<button data-outreach-view="${item.id}">View</button>
<button class="secondary" data-outreach-view="${item.id}">View</button>
<button class="danger" data-outreach-delete="${item.id}">Delete</button>
</div>
`;
@@ -607,7 +687,7 @@ const renderNewsletters = () => {
<h3>${item.subject || "Newsletter Draft"}</h3>
<div class="meta">${item.status}${new Date(item.created_at).toLocaleString()}</div>
<div class="actions">
<button data-newsletter-view="${item.id}">View</button>
<button class="secondary" data-newsletter-view="${item.id}">View</button>
<button class="danger" data-newsletter-delete="${item.id}">Delete</button>
</div>
`;
@@ -743,7 +823,7 @@ const renderImageAudit = () => {
<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 data-image-fix="${post.post_id}">Fix (create draft)</button>
<button class="secondary" data-image-fix="${post.post_id}">Fix (create draft)</button>
</div>
<div class="broken-links">${images}</div>
`;
@@ -780,7 +860,7 @@ const renderImageAudit = () => {
<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 data-featured-sync="${item.english_post_id}">Sync EN → ES</button>
<button class="secondary" data-featured-sync="${item.english_post_id}">Sync EN → ES</button>
</div>
`;
featuredList.appendChild(el);
@@ -831,13 +911,67 @@ const renderSEOAudit = () => {
const hasDescIssue = [...issueTypes].some((type) => type.toLowerCase().includes("description"));
const actionButtons = [];
if (hasTitleIssue) {
actionButtons.push(`<button data-seo-optimize="${post.post_id}" data-seo-scope="title">SEO title fix</button>`);
actionButtons.push(`<button class="secondary" data-seo-recommend="${post.post_id}" data-seo-scope="title">SEO title fix</button>`);
}
if (hasDescIssue) {
actionButtons.push(`<button data-seo-optimize="${post.post_id}" data-seo-scope="description">SEO description fix</button>`);
actionButtons.push(`<button class="secondary" data-seo-recommend="${post.post_id}" data-seo-scope="description">SEO description fix</button>`);
}
if (!actionButtons.length) {
actionButtons.push(`<button data-seo-optimize="${post.post_id}" data-seo-scope="full">Generate SEO refresh</button>`);
actionButtons.push(`<button class="secondary" data-seo-recommend="${post.post_id}" data-seo-scope="full">Generate SEO refresh</button>`);
}
const rec = state.seoRecommendations[post.post_id];
let recBlock = "";
if (rec) {
if (rec.status === "loading") {
recBlock = `
<div class="seo-recommendation loading">
<div class="meta">Generating recommendation…</div>
</div>
`;
} else if (rec.status === "error") {
recBlock = `
<div class="seo-recommendation error">
<div class="meta">${rec.message || "Failed to generate recommendation."}</div>
</div>
`;
} else if (rec.status === "applied") {
recBlock = `
<div class="seo-recommendation ready">
<div class="seo-pill">Applied</div>
<div class="meta">${rec.message || "Draft applied."}</div>
</div>
`;
} else if (rec.status === "ready") {
const titleDiff = `
<div class="seo-diff">
<div class="seo-label">Recommended 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 = `
<div class="seo-diff">
<div class="seo-label">Recommended 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>
`;
const diffContent =
rec.scope === "title"
? titleDiff
: rec.scope === "description"
? descDiff
: `${titleDiff}${descDiff}`;
recBlock = `
<div class="seo-recommendation ready">
<div class="seo-pill">Recommended</div>
${diffContent}
<div class="actions">
<button data-seo-apply-draft="${rec.draftId}" data-seo-post="${post.post_id}">Apply</button>
</div>
</div>
`;
}
}
el.innerHTML = `
@@ -848,8 +982,9 @@ const renderSEOAudit = () => {
${issues ? `<div class="meta">Issues: ${issues}</div>` : ""}
<div class="actions">
${actionButtons.join("")}
<button class="ghost" data-seo-apply="${post.post_id}" data-seo-scope="full">Generate + Apply</button>
<button data-seo-apply="${post.post_id}" data-seo-scope="full">Generate + Apply</button>
</div>
${recBlock}
`;
list.appendChild(el);
},
@@ -876,7 +1011,7 @@ const renderOrphans = () => {
<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 data-translate="${item.post_id}">Generate ES Draft</button>
<button class="secondary" data-translate="${item.post_id}">Generate ES Draft</button>
</div>
`;
list.appendChild(el);
@@ -935,64 +1070,109 @@ const loadHealth = async () => {
};
const loadPending = async () => {
const res = await apiFetch("/api/v1/pending");
state.pending = res.records || [];
renderPending();
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 () => {
const res = await apiFetch("/api/v1/ideas?status=new");
state.ideas = res.ideas || [];
renderIdeas();
setLoading("panel-ideas", true);
try {
const res = await apiFetch("/api/v1/ideas?status=new");
state.ideas = res.ideas || [];
renderIdeas();
} finally {
setLoading("panel-ideas", false);
}
};
const loadOutreach = async () => {
const res = await apiFetch("/api/v1/outreach?status=new");
state.outreach = res.items || [];
renderOutreach();
const alert = byId("outreach-alert");
if (res.count > 0) {
alert.textContent = `⚠ Pending outreach suggestions: ${res.count}`;
alert.classList.remove("hidden");
} else {
alert.classList.add("hidden");
setLoading("panel-outreach", true);
try {
const res = await apiFetch("/api/v1/outreach?status=new");
state.outreach = res.items || [];
renderOutreach();
const alert = byId("outreach-alert");
if (res.count > 0) {
alert.textContent = `${res.count} pending outreach suggestions`;
alert.classList.remove("hidden");
} else {
alert.classList.add("hidden");
}
} finally {
setLoading("panel-outreach", false);
}
};
const loadNewsletters = async () => {
const res = await apiFetch("/api/v1/newsletter?status=draft");
state.newsletters = res.items || [];
renderNewsletters();
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 () => {
const res = await apiFetch("/api/v1/broken-links");
state.brokenLinks = res || {};
renderBrokenLinks();
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 () => {
const res = await apiFetch("/api/v1/image-audit");
state.imageAudit = res || {};
renderImageAudit();
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 () => {
const res = await apiFetch("/api/v1/seo-audit");
state.seoAudit = res || {};
renderSEOAudit();
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 res = await apiFetch("/api/v1/orphans?source=en&target=es");
state.orphans = res.orphans || [];
renderOrphans();
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 () => {
const res = await apiFetch("/api/v1/audit?limit=100");
state.audit = res.records || [];
renderAudit();
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 () => {
@@ -1217,6 +1397,61 @@ const waitForDraft = async (postId, attempts = 12, delayMs = 3000) => {
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) => {
state.seoRecommendations[postId] = { status: "loading", scope };
renderSEOAudit();
setOutput(`Generating SEO ${scope} recommendation for post ${postId}...`);
let draft = await waitForDraft(postId, 1, 0);
if (!draft) {
await apiFetch("/api/v1/optimize", {
method: "POST",
body: JSON.stringify({ post_id: Number(postId), language: "en" }),
});
draft = await waitForDraft(postId);
}
if (!draft) {
state.seoRecommendations[postId] = { status: "error", scope, message: "Draft not found yet." };
renderSEOAudit();
return;
}
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: "Applied optimizer draft.",
};
await loadPending();
await loadSEOAudit();
await loadAudit();
}
renderSEOAudit();
};
const optimizePost = async (postId, scope = "full", autoApply = false) => {
setOutput(`Starting ${scope} optimization for post ${postId}...`);
const payload = { post_id: Number(postId), language: "en" };
@@ -1271,6 +1506,12 @@ const runCommand = async () => {
};
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));
});
@@ -1283,46 +1524,90 @@ const wireActions = () => {
});
}
byId("refresh-all").addEventListener("click", refreshAll);
byId("generate-ideas").addEventListener("click", async () => {
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...");
await apiFetch("/api/v1/ideas/generate", { method: "POST" });
await loadIdeas();
await loadAudit();
try {
await apiFetch("/api/v1/ideas/generate", { method: "POST" });
await loadIdeas();
await loadAudit();
} finally {
setButtonLoading(btn, false);
}
});
byId("generate-outreach").addEventListener("click", async () => {
byId("generate-outreach").addEventListener("click", async (e) => {
const btn = e.currentTarget;
setButtonLoading(btn, true);
setOutput("Generating outreach...");
await apiFetch("/api/v1/outreach/generate", { method: "POST" });
await loadOutreach();
await loadAudit();
try {
await apiFetch("/api/v1/outreach/generate", { method: "POST" });
await loadOutreach();
await loadAudit();
} finally {
setButtonLoading(btn, false);
}
});
byId("generate-newsletter").addEventListener("click", async () => {
byId("generate-newsletter").addEventListener("click", async (e) => {
const btn = e.currentTarget;
setButtonLoading(btn, true);
setOutput("Generating newsletter...");
await apiFetch("/api/v1/newsletter/generate", { method: "POST" });
await loadNewsletters();
await loadAudit();
try {
await apiFetch("/api/v1/newsletter/generate", { method: "POST" });
await loadNewsletters();
await loadAudit();
} finally {
setButtonLoading(btn, false);
}
});
byId("refresh-pending").addEventListener("click", loadPending);
byId("refresh-ideas").addEventListener("click", loadIdeas);
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 () => {
byId("scan-broken-links").addEventListener("click", async (e) => {
const btn = e.currentTarget;
setButtonLoading(btn, true);
setOutput("Starting broken link scan...");
await apiFetch("/api/v1/broken-links/scan", { method: "POST" });
await loadBrokenLinks();
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 () => {
byId("scan-image-audit").addEventListener("click", async (e) => {
const btn = e.currentTarget;
setButtonLoading(btn, true);
setOutput("Starting image audit...");
await apiFetch("/api/v1/image-audit/scan", { method: "POST" });
await loadImageAudit();
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 () => {
byId("scan-seo-audit").addEventListener("click", async (e) => {
const btn = e.currentTarget;
setButtonLoading(btn, true);
setOutput("Starting SEO audit...");
await apiFetch("/api/v1/seo-audit/scan", { method: "POST" });
await loadSEOAudit();
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);
@@ -1428,14 +1713,14 @@ const wireActions = () => {
});
byId("seo-audit-list").addEventListener("click", async (e) => {
const optimize = e.target.getAttribute("data-seo-optimize");
const recommend = e.target.getAttribute("data-seo-recommend");
const apply = e.target.getAttribute("data-seo-apply");
if (optimize) {
const applyDraftId = e.target.getAttribute("data-seo-apply-draft");
const applyPost = e.target.getAttribute("data-seo-post");
if (recommend) {
const scope = e.target.getAttribute("data-seo-scope") || "full";
const autoApply = seoAutoApply ? seoAutoApply.checked : false;
await optimizePost(optimize, scope, autoApply);
await loadSEOAudit();
await loadPending();
await requestSEORecommendation(recommend, scope, autoApply);
}
if (apply) {
const scope = e.target.getAttribute("data-seo-scope") || "full";
@@ -1443,6 +1728,13 @@ const wireActions = () => {
await loadSEOAudit();
await loadPending();
}
if (applyDraftId) {
await applyDraft(applyDraftId);
delete state.seoRecommendations[applyPost];
await loadPending();
await loadSEOAudit();
await loadAudit();
}
});
byId("image-audit-external-list").addEventListener("click", async (e) => {
+181 -59
View File
@@ -3,24 +3,38 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WPSideKick UI</title>
<meta name="color-scheme" content="light dark" />
<title>WPSideKick</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="app">
<header class="header">
<div class="brand">
<img class="logo" src="logo.svg" alt="WPSideKick logo" />
<img class="logo" src="logo.svg" alt="WPSideKick" />
<div>
<h1>WPSideKick</h1>
<div class="sub">Server: <span id="server-version">-</span></div>
<div class="sub">v<span id="server-version">-</span></div>
</div>
</div>
<div class="actions">
<button id="refresh-all">Refresh</button>
<button id="generate-ideas">Generate Ideas</button>
<button id="generate-outreach">Generate Outreach</button>
<button id="generate-newsletter">Generate Newsletter</button>
<div class="header-actions">
<button id="refresh-all" class="secondary">Refresh All</button>
<button id="theme-toggle" class="theme-toggle" title="Toggle theme">
<svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
</div>
</header>
@@ -36,18 +50,84 @@
<div class="layout">
<aside class="sidebar">
<div class="side-card">
<h2>Workspace</h2>
<button class="nav-link" data-page="overview">Overview</button>
<button class="nav-link" data-page="ops">Ops Queue</button>
<button class="nav-link" data-page="growth">Growth</button>
<button class="nav-link" data-page="health">Health</button>
<button class="nav-link" data-page="seo">SEO Focus</button>
<button class="nav-link" data-page="audit">Audit Log</button>
<h2>Navigation</h2>
<button class="nav-link" data-page="overview">
<svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Overview
</button>
<button class="nav-link" data-page="ops">
<svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
<path d="M9 14l2 2 4-4"></path>
</svg>
Ops Queue
</button>
<button class="nav-link" data-page="growth">
<svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 6l-9.5 9.5-5-5L1 18"></path>
<path d="M17 6h6v6"></path>
</svg>
Growth
</button>
<button class="nav-link" data-page="health">
<svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
Health
</button>
<button class="nav-link" data-page="seo">
<svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
SEO Focus
</button>
<button class="nav-link" data-page="audit">
<svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
Audit Log
</button>
</div>
<div class="side-card">
<h2>Quick Actions</h2>
<button class="nav-link" id="generate-ideas">
<svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18h6"></path>
<path d="M10 22h4"></path>
<path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"></path>
</svg>
Generate Ideas
</button>
<button class="nav-link" id="generate-outreach">
<svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
Generate Outreach
</button>
<button class="nav-link" id="generate-newsletter">
<svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
Generate Newsletter
</button>
</div>
<div class="side-card">
<h2>Alerts</h2>
<div id="outreach-alert" class="alert hidden"></div>
<p class="meta muted">Live alerts appear here.</p>
<p class="meta muted">Notifications appear here.</p>
</div>
</aside>
@@ -55,7 +135,7 @@
<section class="command section" data-page="overview">
<label for="command-input">Command Bar</label>
<div class="command-row">
<input id="command-input" placeholder="e.g., pending text 123 or outreach generate" />
<input id="command-input" placeholder="Type a command... (e.g., pending, seo-audit scan)" />
<button id="command-run">Run</button>
</div>
<div id="command-suggestions" class="command-suggestions"></div>
@@ -65,24 +145,33 @@
<section id="section-ops" class="section" data-page="ops">
<div class="section-header">
<h2>Ops Queue</h2>
<p>Drafts + missing translations you can action fast.</p>
<p>Review and approve drafts, handle missing translations.</p>
</div>
<div class="card-grid">
<div class="panel">
<div class="panel" id="panel-pending">
<div class="panel-header">
<h3>Pending Drafts</h3>
<button id="refresh-pending">Refresh</button>
<button id="refresh-pending" class="secondary sm">Refresh</button>
</div>
<div class="loading-container">
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div><div class="skeleton skeleton-button"></div></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div><div class="skeleton skeleton-button"></div></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div><div class="skeleton skeleton-button"></div></div></div>
</div>
<div id="pending-list" class="list"></div>
<div id="pending-pagination" class="list-footer"></div>
</div>
<div class="panel">
<div class="panel" id="panel-orphans">
<div class="panel-header">
<h3>Orphan Posts (Missing ES)</h3>
<button id="refresh-orphans">Refresh</button>
<h3>Orphan Posts</h3>
<button id="refresh-orphans" class="secondary sm">Refresh</button>
</div>
<div id="orphans-summary" class="meta muted" style="padding: 12px 16px 0;"></div>
<div class="loading-container">
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div></div></div>
</div>
<div id="orphans-summary" class="meta muted"></div>
<div id="orphans-list" class="list"></div>
<div id="orphans-pagination" class="list-footer"></div>
</div>
@@ -92,31 +181,43 @@
<section id="section-growth" class="section" data-page="growth">
<div class="section-header">
<h2>Growth</h2>
<p>Ideas, outreach, and newsletters.</p>
<p>Content ideas, outreach opportunities, and newsletter drafts.</p>
</div>
<div class="card-grid">
<div class="panel">
<div class="panel" id="panel-ideas">
<div class="panel-header">
<h3>Ideas</h3>
<button id="refresh-ideas">Refresh</button>
<h3>Content Ideas</h3>
<button id="refresh-ideas" class="secondary sm">Refresh</button>
</div>
<div class="loading-container">
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div><div class="skeleton skeleton-button"></div></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div><div class="skeleton skeleton-button"></div></div></div>
</div>
<div id="ideas-list" class="list"></div>
<div id="ideas-pagination" class="list-footer"></div>
</div>
<div class="panel">
<div class="panel" id="panel-outreach">
<div class="panel-header">
<h3>Outreach</h3>
<button id="refresh-outreach">Refresh</button>
<button id="refresh-outreach" class="secondary sm">Refresh</button>
</div>
<div class="loading-container">
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div></div></div>
</div>
<div id="outreach-list" class="list"></div>
<div id="outreach-pagination" class="list-footer"></div>
</div>
<div class="panel">
<div class="panel" id="panel-newsletter">
<div class="panel-header">
<h3>Newsletter</h3>
<button id="refresh-newsletter">Refresh</button>
<h3>Newsletter Drafts</h3>
<button id="refresh-newsletter" class="secondary sm">Refresh</button>
</div>
<div class="loading-container">
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div></div></div>
</div>
<div id="newsletter-list" class="list"></div>
<div id="newsletter-pagination" class="list-footer"></div>
@@ -126,39 +227,47 @@
<section id="section-health" class="section" data-page="health">
<div class="section-header">
<h2>Health</h2>
<p>Broken links + image audits prioritized by impact.</p>
<h2>Site Health</h2>
<p>Monitor broken links and image issues across your site.</p>
</div>
<div class="card-grid">
<div class="panel">
<div class="panel" id="panel-broken-links">
<div class="panel-header">
<h3>Broken Links</h3>
<div class="panel-actions">
<button id="scan-broken-links">Scan</button>
<button id="refresh-broken-links">Refresh</button>
<button id="scan-broken-links" class="sm">Scan Now</button>
<button id="refresh-broken-links" class="secondary sm">Refresh</button>
</div>
</div>
<div id="broken-links-summary" class="meta muted"></div>
<div id="broken-links-summary" class="meta muted" style="padding: 12px 16px 0;"></div>
<div class="loading-container">
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div></div>
</div>
<div id="broken-links-list" class="list"></div>
<div id="broken-links-pagination" class="list-footer"></div>
</div>
<div class="panel">
<div class="panel" id="panel-image-audit">
<div class="panel-header">
<h3>Image Audit</h3>
<div class="panel-actions">
<button id="scan-image-audit">Scan</button>
<button id="refresh-image-audit">Refresh</button>
<button id="scan-image-audit" class="sm">Scan Now</button>
<button id="refresh-image-audit" class="secondary sm">Refresh</button>
</div>
</div>
<div id="image-audit-summary" class="meta muted"></div>
<div id="image-audit-summary" class="meta muted" style="padding: 12px 16px 0;"></div>
<div class="loading-container">
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div></div>
</div>
<div class="subsection">
<h4>External Images</h4>
<div id="image-audit-external-list" class="list"></div>
<div id="image-audit-external-pagination" class="list-footer"></div>
</div>
<div class="subsection">
<h4>Featured Image Mismatches (EN → ES)</h4>
<h4>Featured Image Mismatches</h4>
<div id="image-audit-featured-list" class="list"></div>
<div id="image-audit-featured-pagination" class="list-footer"></div>
</div>
@@ -169,23 +278,30 @@
<section id="section-seo" class="section" data-page="seo">
<div class="section-header">
<h2>SEO Focus</h2>
<p>Prioritize title + description fixes and publish clean drafts.</p>
<p>Optimize titles, descriptions, and fix SEO issues.</p>
</div>
<div class="card-grid">
<div class="panel">
<div class="panel" id="panel-seo-audit">
<div class="panel-header">
<h3>SEO Audit</h3>
<div class="panel-actions">
<button id="scan-seo-audit">Scan</button>
<button id="refresh-seo-audit">Refresh</button>
<button id="scan-seo-audit" class="sm">Scan Now</button>
<button id="refresh-seo-audit" class="secondary sm">Refresh</button>
</div>
</div>
<label class="checkbox">
<input id="seo-auto-apply" type="checkbox" />
Auto-apply optimizer drafts
</label>
<div class="meta muted">Tip: Use "Generate + Apply" to approve a fix immediately.</div>
<div id="seo-audit-summary" class="meta muted"></div>
<div style="padding: 12px 16px;">
<label class="checkbox">
<input id="seo-auto-apply" type="checkbox" />
Auto-apply optimizer drafts
</label>
<div class="meta muted" style="margin-top: 8px;">Use "Generate + Apply" for immediate fixes.</div>
</div>
<div id="seo-audit-summary" class="meta muted" style="padding: 0 16px 12px;"></div>
<div class="loading-container">
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div><div class="skeleton skeleton-button"></div></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div><div class="skeleton skeleton-button"></div></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div><div class="skeleton-actions"><div class="skeleton skeleton-button"></div><div class="skeleton skeleton-button"></div></div></div>
</div>
<div id="seo-audit-list" class="list"></div>
<div id="seo-audit-pagination" class="list-footer"></div>
</div>
@@ -195,13 +311,19 @@
<section id="section-audit" class="section" data-page="audit">
<div class="section-header">
<h2>Audit Log</h2>
<p>Track every action in the system.</p>
<p>Track all actions and changes in the system.</p>
</div>
<div class="card-grid">
<div class="panel">
<div class="panel" id="panel-audit">
<div class="panel-header">
<h3>Audit Log</h3>
<button id="refresh-audit">Refresh</button>
<h3>Recent Activity</h3>
<button id="refresh-audit" class="secondary sm">Refresh</button>
</div>
<div class="loading-container">
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div></div>
<div class="skeleton-item"><div class="skeleton skeleton-title"></div><div class="skeleton skeleton-meta"></div></div>
</div>
<div id="audit-list" class="list"></div>
<div id="audit-pagination" class="list-footer"></div>
@@ -224,7 +346,7 @@
<button id="modal-mode-full" class="active">Full</button>
<button id="modal-mode-compare">Compare</button>
</div>
<button id="modal-close">Close</button>
<button id="modal-close" class="secondary">Close</button>
</div>
</div>
<div id="modal-body" class="modal-body"></div>
+1134 -563
View File
File diff suppressed because it is too large Load Diff
+123 -4
View File
@@ -6,6 +6,7 @@ import (
"sort"
"strings"
"time"
"unicode"
"golang.org/x/net/html"
@@ -127,8 +128,10 @@ func (s *Service) scorePost(post *wordpress.Post) models.PostSEOAudit {
}
seoDesc := getMetaString(post.Meta, wordpress.MetaRankMathDescription, wordpress.MetaYoastDescription)
titleScore, titleIssues := scoreTitle(seoTitle)
descScore, descIssues := scoreDescription(seoDesc)
contentText := extractText(post.Content.Rendered)
primaryKeyword := extractPrimaryKeyword(seoTitle, contentText)
titleScore, titleIssues := scoreTitle(seoTitle, primaryKeyword)
descScore, descIssues := scoreDescription(seoDesc, primaryKeyword)
internalCount, externalCount := s.countLinks(post.Content.Rendered)
internalScore, internalIssues := scoreInternalLinks(internalCount)
@@ -229,7 +232,7 @@ func buildIssues(title, desc, internal, external []string) []models.SEOAuditIssu
return issues
}
func scoreTitle(title string) (int, []string) {
func scoreTitle(title string, primaryKeyword string) (int, []string) {
trimmed := strings.TrimSpace(title)
if trimmed == "" {
return 0, []string{"missing"}
@@ -258,10 +261,38 @@ func scoreTitle(title string) (int, []string) {
score = 40
issues = append(issues, "too_long")
}
keyword := strings.TrimSpace(primaryKeyword)
if keyword != "" {
lowerTitle := strings.ToLower(trimmed)
lowerKeyword := strings.ToLower(keyword)
if !strings.Contains(lowerTitle, lowerKeyword) {
score -= 20
issues = append(issues, "missing_primary_keyword")
} else {
words := strings.Fields(lowerTitle)
position := -1
for i, word := range words {
if word == lowerKeyword {
position = i
break
}
}
if position > 2 {
score -= 10
issues = append(issues, "keyword_not_front_loaded")
}
}
}
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
return score, issues
}
func scoreDescription(desc string) (int, []string) {
func scoreDescription(desc string, primaryKeyword string) (int, []string) {
trimmed := strings.TrimSpace(desc)
if trimmed == "" {
return 0, []string{"missing"}
@@ -290,9 +321,97 @@ func scoreDescription(desc string) (int, []string) {
score = 40
issues = append(issues, "too_long")
}
keyword := strings.TrimSpace(primaryKeyword)
if keyword != "" {
lowerDesc := strings.ToLower(trimmed)
lowerKeyword := strings.ToLower(keyword)
if !strings.Contains(lowerDesc, lowerKeyword) {
score -= 15
issues = append(issues, "missing_primary_keyword")
}
}
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
return score, issues
}
func extractText(htmlContent string) string {
if strings.TrimSpace(htmlContent) == "" {
return ""
}
root, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return ""
}
var b strings.Builder
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
b.WriteString(n.Data)
b.WriteString(" ")
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
return strings.TrimSpace(b.String())
}
func extractPrimaryKeyword(title, content string) string {
text := strings.TrimSpace(title + " " + content)
if text == "" {
return ""
}
words := tokenize(text)
freq := map[string]int{}
for _, word := range words {
if len(word) < 3 {
continue
}
if stopwords[word] {
continue
}
freq[word] += 1
}
if len(freq) == 0 {
return ""
}
primary := ""
best := 0
for word, count := range freq {
if count > best {
best = count
primary = word
}
}
return primary
}
func tokenize(text string) []string {
lower := strings.ToLower(text)
fields := strings.FieldsFunc(lower, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
return fields
}
var stopwords = map[string]bool{
"the": true, "and": true, "for": true, "with": true, "that": true, "this": true,
"from": true, "into": true, "your": true, "you": true, "are": true, "was": true,
"were": true, "will": true, "has": true, "have": true, "had": true, "not": true,
"but": true, "can": true, "use": true, "using": true, "how": true, "why": true,
"what": true, "when": true, "where": true, "which": true, "about": true, "its": true,
"our": true, "their": true, "they": true, "them": true, "his": true, "her": true,
"she": true, "him": true, "he": true, "out": true, "via": true, "per": true,
"more": true, "most": true, "best": true, "new": true, "get": true, "set": true,
"over": true, "under": true, "between": true, "within": true, "across": true,
}
func scoreInternalLinks(count int) (int, []string) {
switch {
case count >= 3 && count <= 8: