Adopt the agents/ architecture proven on medellin.co (reference impl): - Move the content engine to a top-level agents/ dir: orchestrators, prompts, config, run.sh, admin console, shared libs. All content-pipeline literals repointed (config paths, scripts, admin, LLM-facing prompts/image.md string, configure.mjs, new-site.sh, astroagent tokenFile, .gitignore runtime block). - Every script carries a parseable @agent-manifest header: name, title, class (content|operational|runtime|plumbing), trigger, model, prompts, skills (MCP), tools, reads/writes tables. 5 content agents + 3 plumbing scripts. - New agents/catalog.mjs generates the catalog from the headers: agents/AGENTS.md (human, grouped by class) + agents/agents.json (machine manifest — a clone diffs it against a source to find missing tools/tables/MCP before running). configure.mjs regenerates the catalog on every identity stamp. No DB table, no watcher. - config.json gains paths.stateDir/newsDir; publish-tick, write-daily, and news-radar read them instead of hardcoding. - Full cut: content-pipeline/ deleted (the seed has no live crons, so no hybrid period needed). Docs updated (AGENTS.md structure + pipeline section, README paths). Clones migrating from content-pipeline/: see medellin.co's .memory/handoffs/agents-directory-migration.md for the cutover playbook (one cron set active at a time; migrate drafts/state after repointing cron). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FMQeUnUrAeexcZ7P2Hxa6G
456 lines
21 KiB
JavaScript
456 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
// Read-only admin dashboard for the Comiida content pipeline.
|
|
// Serves at /admin (proxied by Apache). Auth via secret token (?key= or cookie).
|
|
// Node built-ins only — no external deps.
|
|
import { createServer } from "node:http";
|
|
import { randomUUID } from "node:crypto";
|
|
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
import { dirname, resolve, join, extname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { readFrontmatter, fmString, fmArray, loadJson, todayInTz, addDays, slugify } from "../scripts/lib/util.mjs";
|
|
import { addEntry } from "../scripts/lib/calendar.mjs";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const PIPELINE = resolve(HERE, "..");
|
|
|
|
// --- env (.env) ---
|
|
function loadEnv() {
|
|
const p = join(PIPELINE, ".env");
|
|
if (!existsSync(p)) return;
|
|
for (const line of readFileSync(p, "utf8").split("\n")) {
|
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
|
}
|
|
}
|
|
loadEnv();
|
|
|
|
const cfg = loadJson(join(PIPELINE, "config.json"));
|
|
const root = cfg.paths.projectRoot;
|
|
const TOKEN = process.env.ADMIN_TOKEN || "";
|
|
const PORT = parseInt(process.env.ADMIN_PORT, 10) || 3010;
|
|
|
|
if (!TOKEN) {
|
|
console.error("[admin] ADMIN_TOKEN not set in .env — refusing to start.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// --- data gathering ---
|
|
const draftsDir = () => join(root, cfg.paths.draftsDir);
|
|
const blogDir = () => join(root, cfg.paths.blogContentDir);
|
|
|
|
function calendar() {
|
|
const p = join(root, cfg.paths.calendar);
|
|
return existsSync(p) ? loadJson(p) : [];
|
|
}
|
|
function isPublished(slug) {
|
|
return existsSync(join(blogDir(), slug, "index.mdx"));
|
|
}
|
|
function draftSlugs() {
|
|
const d = draftsDir();
|
|
return existsSync(d)
|
|
? readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name)
|
|
: [];
|
|
}
|
|
function draftMeta(slug) {
|
|
const mdx = join(draftsDir(), slug, "index.mdx");
|
|
if (!existsSync(mdx)) return null;
|
|
const fm = readFrontmatter(readFileSync(mdx, "utf8"));
|
|
let seo = null;
|
|
const seoPath = join(draftsDir(), slug, "seo-review.json");
|
|
if (existsSync(seoPath)) {
|
|
try {
|
|
seo = JSON.parse(readFileSync(seoPath, "utf8"));
|
|
} catch {
|
|
seo = null;
|
|
}
|
|
}
|
|
return {
|
|
slug,
|
|
title: fmString(fm, "title") || slug,
|
|
category: fmString(fm, "category") || "",
|
|
tags: fmArray(fm, "tags"),
|
|
date: fmString(fm, "date") || "",
|
|
hasCover: existsSync(join(draftsDir(), slug, "cover.jpg")),
|
|
seo,
|
|
};
|
|
}
|
|
|
|
// --- helpers ---
|
|
const esc = (s) =>
|
|
String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
|
|
const STATUS_COLORS = {
|
|
planned: "#8a8f98",
|
|
drafting: "#d98324",
|
|
drafted: "#2f6feb",
|
|
"drafted-no-image": "#b58900",
|
|
"draft-failed": "#cb2431",
|
|
published: "#1a7f37",
|
|
};
|
|
const badge = (status) =>
|
|
`<span style="background:${STATUS_COLORS[status] || "#666"};color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;white-space:nowrap">${esc(status)}</span>`;
|
|
|
|
const newsBadge = `<span style="background:#b5179e;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">NEWS</span>`;
|
|
const suggestedBadge = `<span style="background:#7048e8;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SUGGESTED</span>`;
|
|
function freshNewsCount() {
|
|
const p = join(root, "agents", "news-queue.json");
|
|
if (!existsSync(p)) return 0;
|
|
try {
|
|
return JSON.parse(readFileSync(p, "utf8")).filter((i) => i.status === "fresh").length;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
const SEO_COLORS = { pass: "#1a7f37", revise: "#d98324", fail: "#cb2431" };
|
|
const seoBadge = (seo) =>
|
|
seo
|
|
? `<span style="background:${SEO_COLORS[seo.verdict] || "#666"};color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;white-space:nowrap">SEO ${esc(seo.verdict)} ${esc(seo.overall)}</span>`
|
|
: `<span style="background:#aaa;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SEO —</span>`;
|
|
|
|
const messagesPath = () => join(root, "agents", "messages.json");
|
|
function messages() {
|
|
const p = messagesPath();
|
|
if (!existsSync(p)) return [];
|
|
try {
|
|
return JSON.parse(readFileSync(p, "utf8"));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function authed(req) {
|
|
const url = new URL(req.url, "http://x");
|
|
const qkey = url.searchParams.get("key");
|
|
if (qkey && qkey === TOKEN) return { ok: true, setCookie: true };
|
|
const cookie = (req.headers.cookie || "").match(/(?:^|;\s*)admin_key=([^;]+)/);
|
|
if (cookie && decodeURIComponent(cookie[1]) === TOKEN) return { ok: true };
|
|
return { ok: false };
|
|
}
|
|
|
|
// --- pages ---
|
|
function dashboardHtml() {
|
|
const today = todayInTz(cfg.editorial.timezone);
|
|
const weekEnd = addDays(today, 6);
|
|
const cal = calendar().slice().sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
|
const drafts = draftSlugs().map(draftMeta).filter(Boolean);
|
|
|
|
const inWeek = cal.filter((e) => e.date >= today && e.date <= weekEnd);
|
|
const counts = cal.reduce((m, e) => ((m[e.status] = (m[e.status] || 0) + 1), m), {});
|
|
const newsSlugs = new Set(cal.filter((e) => e.news).map((e) => e.slug));
|
|
drafts.forEach((d) => (d.news = newsSlugs.has(d.slug)));
|
|
|
|
const row = (e) => `
|
|
<tr>
|
|
<td style="white-space:nowrap;color:#555">${esc(e.date)}</td>
|
|
<td><strong>${esc(e.workingTitle || e.slug)}</strong> ${e.suggested ? suggestedBadge : ""}${e.news ? " " + newsBadge : ""}<br><span style="color:#888;font-size:12px">${esc(e.primaryKeyword || "")}</span></td>
|
|
<td><span style="color:#555;font-size:13px">${esc(e.type)}</span></td>
|
|
<td>${badge(isPublished(e.slug) ? "published" : e.status)}</td>
|
|
<td>${
|
|
existsSync(join(draftsDir(), e.slug, "index.mdx"))
|
|
? `<a href="/admin/draft/${esc(e.slug)}">preview</a>`
|
|
: isPublished(e.slug)
|
|
? `<a href="${esc(cfg.site.url)}/blog/${esc(e.slug)}/" target="_blank">live ↗</a>`
|
|
: "—"
|
|
}</td>
|
|
</tr>`;
|
|
|
|
const draftCards = drafts.length
|
|
? drafts
|
|
.map(
|
|
(d) => `
|
|
<div style="border:1px solid #e2e4e8;border-radius:10px;padding:14px;display:flex;gap:14px;align-items:center">
|
|
${d.hasCover ? `<img src="/admin/cover/${esc(d.slug)}" style="width:96px;height:64px;object-fit:cover;border-radius:6px;flex:none">` : `<div style="width:96px;height:64px;background:#f0f1f3;border-radius:6px;flex:none"></div>`}
|
|
<div style="flex:1">
|
|
<div style="display:flex;align-items:center;gap:8px"><strong>${esc(d.title)}</strong> ${d.news ? newsBadge : ""} ${seoBadge(d.seo)}</div>
|
|
<div style="color:#888;font-size:12px">${esc(d.date)} · ${esc(d.category)} · ${esc(d.tags.join(", "))}</div>
|
|
${d.seo?.blocking?.length ? `<div style="color:#cb2431;font-size:12px;margin-top:4px">⚠ ${esc(d.seo.blocking.length)} blocking: ${esc(d.seo.blocking[0])}${d.seo.blocking.length > 1 ? " …" : ""}</div>` : ""}
|
|
<div style="margin-top:6px"><a href="/admin/draft/${esc(d.slug)}">preview</a> · <code style="font-size:12px">approve.mjs ${esc(d.slug)}</code></div>
|
|
</div>
|
|
</div>`
|
|
)
|
|
.join("")
|
|
: `<p style="color:#888">No drafts awaiting review.</p>`;
|
|
|
|
const countPills = Object.entries(counts)
|
|
.map(([k, v]) => `${badge(k)} <span style="color:#555">${v}</span>`)
|
|
.join(" ");
|
|
|
|
const msgs = messages().slice().reverse();
|
|
const msgHtml = msgs.length
|
|
? msgs
|
|
.map(
|
|
(m) => `
|
|
<div style="border:1px solid #e2e4e8;border-radius:10px;padding:12px 14px;position:relative">
|
|
<button onclick="deleteMsg('${esc(m.id || m.at)}')" title="Delete message" style="position:absolute;top:10px;right:10px;border:0;background:#f0f1f3;border-radius:6px;padding:3px 9px;cursor:pointer;color:#999;line-height:1">✕</button>
|
|
<div style="font-size:13px;padding-right:36px"><strong>${esc(m.name)}</strong> <span style="color:#888"><${esc(m.email)}></span> <span style="color:#aaa">· ${esc((m.at || "").slice(0, 16).replace("T", " "))}</span></div>
|
|
<div style="margin-top:6px;white-space:pre-wrap">${esc(m.message)}</div>
|
|
</div>`
|
|
)
|
|
.join("")
|
|
: `<p class="muted">No messages yet.</p>`;
|
|
|
|
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Comiida — Content Admin</title>
|
|
<style>
|
|
body{font:15px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;margin:0;background:#fafbfc}
|
|
.wrap{max-width:960px;margin:0 auto;padding:28px 20px 60px}
|
|
h1{font-size:22px;margin:0 0 4px} h2{font-size:16px;margin:30px 0 12px}
|
|
table{width:100%;border-collapse:collapse} td,th{text-align:left;padding:10px 8px;border-bottom:1px solid #eceef1;vertical-align:top}
|
|
th{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#888}
|
|
a{color:#2f6feb;text-decoration:none} a:hover{text-decoration:underline}
|
|
.muted{color:#888;font-size:13px} .grid{display:flex;flex-direction:column;gap:10px}
|
|
.btn{display:inline-block;background:#7048e8;color:#fff;border:0;border-radius:8px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer}
|
|
.btn:hover{opacity:.92}
|
|
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;align-items:flex-start;justify-content:center;z-index:10}
|
|
.overlay.open{display:flex}
|
|
.modal{background:#fff;border-radius:12px;max-width:520px;width:calc(100% - 32px);margin-top:8vh;padding:22px;box-shadow:0 12px 40px rgba(0,0,0,.2)}
|
|
.modal h3{margin:0 0 4px;font-size:18px} .modal label{display:block;font-size:13px;font-weight:600;margin:14px 0 5px}
|
|
.modal input,.modal textarea{width:100%;box-sizing:border-box;border:1px solid #d6d9de;border-radius:8px;padding:9px 11px;font-size:14px;font-family:inherit}
|
|
.modal .row2{display:flex;gap:10px;justify-content:flex-end;margin-top:18px}
|
|
.modal .cancel{background:#eceef1;color:#333}
|
|
</style></head><body><div class="wrap">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
|
<h1>Comiida — Content Admin</h1>
|
|
<button class="btn" onclick="document.getElementById('suggestOverlay').classList.add('open')">+ Suggest an article</button>
|
|
</div>
|
|
<div class="muted">Today ${esc(today)} (${esc(cfg.editorial.timezone)}) · ${countPills} · 📰 news queue: ${freshNewsCount()} fresh</div>
|
|
|
|
<div class="overlay" id="suggestOverlay">
|
|
<div class="modal">
|
|
<h3>Suggest an article</h3>
|
|
<div class="muted">Added as a top-priority topic — the writer researches & drafts it next.</div>
|
|
<form id="suggestForm">
|
|
<label>Title *</label>
|
|
<input name="title" required maxlength="160" placeholder="e.g. The best late-night eats in Laureles" />
|
|
<label>Brief description</label>
|
|
<textarea name="description" rows="3" maxlength="600" placeholder="What angle / what to cover?"></textarea>
|
|
<label>Prompt / instructions for the agent (optional)</label>
|
|
<textarea name="instructions" rows="3" maxlength="4000" placeholder="e.g. Focus on vegan spots in Laureles; research the new Provenza openings; compare prices and hours"></textarea>
|
|
<label>Draft (optional)</label>
|
|
<textarea name="draft" rows="6" maxlength="20000" placeholder="Paste a draft or rough notes — the agent will build on, fact-check, and expand it."></textarea>
|
|
<label>Source link (optional)</label>
|
|
<input name="source" type="url" placeholder="https://… where you saw the idea" />
|
|
<div class="row2">
|
|
<button type="button" class="btn cancel" onclick="document.getElementById('suggestOverlay').classList.remove('open')">Cancel</button>
|
|
<button type="submit" class="btn" id="suggestSubmit">Add suggestion</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
document.getElementById('suggestForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const btn = document.getElementById('suggestSubmit');
|
|
const f = e.target;
|
|
const payload = { title: f.title.value.trim(), description: f.description.value.trim(), instructions: f.instructions.value.trim(), draft: f.draft.value.trim(), source: f.source.value.trim() };
|
|
if (!payload.title) return;
|
|
btn.disabled = true; btn.textContent = 'Adding…';
|
|
try {
|
|
const r = await fetch('/admin/suggest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
|
if (!r.ok) throw new Error((await r.json()).error || 'failed');
|
|
location.reload();
|
|
} catch (err) {
|
|
btn.disabled = false; btn.textContent = 'Add suggestion';
|
|
alert('Could not add suggestion: ' + err.message);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<h2>📬 Messages (${msgs.length})</h2>
|
|
<div class="grid">${msgHtml}</div>
|
|
<script>
|
|
window.deleteMsg = async (id) => {
|
|
if (!confirm('Delete this message?')) return;
|
|
try {
|
|
const r = await fetch('/admin/message-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
|
|
if (!r.ok) throw new Error();
|
|
location.reload();
|
|
} catch { alert('Could not delete the message.'); }
|
|
};
|
|
</script>
|
|
|
|
<h2>This week (${esc(today)} → ${esc(weekEnd)})</h2>
|
|
${inWeek.length ? `<table><tr><th>Date</th><th>Topic</th><th>Type</th><th>Status</th><th></th></tr>${inWeek.map(row).join("")}</table>` : `<p class="muted">Nothing scheduled this week. Run research.mjs to plan more.</p>`}
|
|
|
|
<h2>Drafts awaiting review (${drafts.length})</h2>
|
|
<div class="grid">${draftCards}</div>
|
|
|
|
<h2>Full calendar (${cal.length})</h2>
|
|
<table><tr><th>Date</th><th>Topic</th><th>Type</th><th>Status</th><th></th></tr>${cal.map(row).join("")}</table>
|
|
</div></body></html>`;
|
|
}
|
|
|
|
function draftDetailHtml(slug) {
|
|
const dir = join(draftsDir(), slug);
|
|
const mdxPath = join(dir, "index.mdx");
|
|
if (!existsSync(mdxPath)) return null;
|
|
const raw = readFileSync(mdxPath, "utf8");
|
|
const fm = readFrontmatter(raw);
|
|
const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
|
|
const sources = existsSync(join(dir, "sources.json"))
|
|
? readFileSync(join(dir, "sources.json"), "utf8")
|
|
: "(none)";
|
|
let seo = null;
|
|
if (existsSync(join(dir, "seo-review.json"))) {
|
|
try {
|
|
seo = JSON.parse(readFileSync(join(dir, "seo-review.json"), "utf8"));
|
|
} catch {
|
|
seo = null;
|
|
}
|
|
}
|
|
const seoSection = seo
|
|
? `<h2>SEO audit ${seoBadge(seo)}</h2>
|
|
<p class="muted">${esc(seo.summary || "")}</p>
|
|
<ul>${Object.entries(seo.dimensions || {})
|
|
.map(([k, v]) => `<li><strong>${esc(k)}</strong>: ${esc(v.score)}${v.issues?.length ? " — " + esc(v.issues.join("; ")) : ""}</li>`)
|
|
.join("")}</ul>
|
|
${seo.blocking?.length ? `<p style="color:#cb2431"><strong>Blocking:</strong></p><ul>${seo.blocking.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>` : ""}
|
|
${seo.topFixes?.length ? `<p><strong>Top fixes:</strong></p><ul>${seo.topFixes.map((f) => `<li>[${esc(f.severity)}/${esc(f.area)}] ${esc(f.fix)}</li>`).join("")}</ul>` : ""}`
|
|
: `<h2>SEO audit ${seoBadge(null)}</h2><p class="muted">No audit yet. Run <code>./run.sh write-daily.mjs</code> (auto-audits) or <code>node scripts/seo-review.mjs ${esc(slug)}</code>.</p>`;
|
|
const hasCover = existsSync(join(dir, "cover.jpg"));
|
|
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>${esc(fmString(fm, "title") || slug)} — draft</title>
|
|
<style>
|
|
body{font:15px/1.6 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;margin:0;background:#fafbfc}
|
|
.wrap{max-width:760px;margin:0 auto;padding:24px 20px 60px}
|
|
pre{background:#f4f5f7;border:1px solid #e6e8eb;border-radius:8px;padding:12px;overflow:auto;font-size:12.5px;white-space:pre-wrap}
|
|
a{color:#2f6feb} img{max-width:100%;border-radius:8px}
|
|
h1{font-size:21px}
|
|
</style></head><body><div class="wrap">
|
|
<p><a href="/admin">← back to dashboard</a></p>
|
|
${hasCover ? `<img src="/admin/cover/${esc(slug)}">` : ""}
|
|
${seoSection}
|
|
<h2>Frontmatter</h2><pre>${esc(fm)}</pre>
|
|
<h2>Body (raw MDX)</h2><pre>${esc(body)}</pre>
|
|
<h2>sources.json</h2><pre>${esc(sources)}</pre>
|
|
<p class="muted">To publish: <code>./run.sh approve.mjs ${esc(slug)}</code></p>
|
|
</div></body></html>`;
|
|
}
|
|
|
|
// --- server ---
|
|
const send = (res, code, body, type = "text/html; charset=utf-8", extra = {}) => {
|
|
res.writeHead(code, { "content-type": type, "cache-control": "no-store", ...extra });
|
|
res.end(body);
|
|
};
|
|
|
|
const server = createServer((req, res) => {
|
|
const url = new URL(req.url, "http://x");
|
|
const path = url.pathname.replace(/\/+$/, "") || "/admin";
|
|
|
|
// PUBLIC (no auth): contact form submission → saved to messages.json, viewed in /admin.
|
|
if (req.method === "POST" && path === "/contact-submit") {
|
|
let body = "";
|
|
req.on("data", (c) => {
|
|
body += c;
|
|
if (body.length > 20000) req.destroy(); // basic size guard
|
|
});
|
|
req.on("end", () => {
|
|
try {
|
|
const d = JSON.parse(body || "{}");
|
|
if (d.website) return send(res, 200, JSON.stringify({ ok: true }), "application/json"); // honeypot → silently drop
|
|
const name = String(d.name || "").trim().slice(0, 120);
|
|
const email = String(d.email || "").trim().slice(0, 160);
|
|
const message = String(d.message || "").trim().slice(0, 4000);
|
|
if (!name || !message || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
return send(res, 400, JSON.stringify({ error: "Please fill in your name, a valid email, and a message." }), "application/json");
|
|
}
|
|
const arr = messages();
|
|
arr.push({ id: randomUUID(), at: new Date().toISOString(), name, email, message });
|
|
writeFileSync(messagesPath(), JSON.stringify(arr, null, 2));
|
|
send(res, 200, JSON.stringify({ ok: true }), "application/json");
|
|
} catch (e) {
|
|
send(res, 500, JSON.stringify({ error: e.message }), "application/json");
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
const auth = authed(req);
|
|
if (!auth.ok) {
|
|
return send(res, 401, "<h1>401</h1><p>Add ?key=YOUR_TOKEN to the URL.</p>");
|
|
}
|
|
const cookieHeader = auth.setCookie
|
|
? { "set-cookie": `admin_key=${encodeURIComponent(TOKEN)}; Path=/admin; HttpOnly; SameSite=Lax; Max-Age=2592000` }
|
|
: {};
|
|
|
|
// suggest an article (POST) — inject a top-priority calendar entry
|
|
if (req.method === "POST" && path === "/admin/suggest") {
|
|
let body = "";
|
|
req.on("data", (c) => (body += c));
|
|
req.on("end", async () => {
|
|
try {
|
|
const data = JSON.parse(body || "{}");
|
|
const title = String(data.title || "").trim();
|
|
if (!title) return send(res, 400, JSON.stringify({ error: "Title is required" }), "application/json");
|
|
const description = String(data.description || "").trim();
|
|
const source = String(data.source || "").trim();
|
|
const instructions = String(data.instructions || "").trim().slice(0, 4000);
|
|
const draft = String(data.draft || "").trim().slice(0, 20000);
|
|
const today = todayInTz(cfg.editorial.timezone);
|
|
const entry = {
|
|
date: today,
|
|
slug: slugify(title),
|
|
workingTitle: title,
|
|
type: "guide",
|
|
primaryKeyword: title,
|
|
secondaryKeywords: [],
|
|
searchIntent: "informational",
|
|
audienceAngle: description,
|
|
sourceHints: source ? [source] : [],
|
|
eeatAngle: "User-suggested topic; research thoroughly and cite every claim.",
|
|
suggested: true,
|
|
status: "planned",
|
|
};
|
|
if (instructions) entry.instructions = instructions;
|
|
if (draft) entry.draft = draft;
|
|
await addEntry(join(root, cfg.paths.calendar), entry);
|
|
send(res, 200, JSON.stringify({ ok: true, slug: entry.slug }), "application/json", cookieHeader);
|
|
} catch (e) {
|
|
send(res, 500, JSON.stringify({ error: e.message }), "application/json");
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// delete a message (token-gated)
|
|
if (req.method === "POST" && path === "/admin/message-delete") {
|
|
let body = "";
|
|
req.on("data", (c) => (body += c));
|
|
req.on("end", () => {
|
|
try {
|
|
const { id } = JSON.parse(body || "{}");
|
|
const arr = messages().filter((m) => (m.id || m.at) !== id);
|
|
writeFileSync(messagesPath(), JSON.stringify(arr, null, 2));
|
|
send(res, 200, JSON.stringify({ ok: true }), "application/json", cookieHeader);
|
|
} catch (e) {
|
|
send(res, 500, JSON.stringify({ error: e.message }), "application/json");
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// cover image
|
|
const cover = path.match(/^\/admin\/cover\/([a-z0-9-]+)$/i);
|
|
if (cover) {
|
|
const f = join(draftsDir(), cover[1], "cover.jpg");
|
|
if (!existsSync(f)) return send(res, 404, "not found", "text/plain");
|
|
return send(res, 200, readFileSync(f), "image/jpeg", cookieHeader);
|
|
}
|
|
|
|
// draft detail
|
|
const draft = path.match(/^\/admin\/draft\/([a-z0-9-]+)$/i);
|
|
if (draft) {
|
|
const html = draftDetailHtml(draft[1]);
|
|
return html ? send(res, 200, html, "text/html; charset=utf-8", cookieHeader) : send(res, 404, "<h1>404</h1>");
|
|
}
|
|
|
|
// dashboard
|
|
if (path === "/admin" || path === "/admin/") {
|
|
return send(res, 200, dashboardHtml(), "text/html; charset=utf-8", cookieHeader);
|
|
}
|
|
|
|
return send(res, 404, "<h1>404</h1>");
|
|
});
|
|
|
|
server.listen(PORT, "127.0.0.1", () => console.log(`[admin] listening on 127.0.0.1:${PORT}`));
|