+1
This commit is contained in:
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user