// articles page — list + keyword/status/source filtering + edit modal
// filter state is persisted to the url query so reload/share keeps it.
// depends on: app.js (api, toast, escapeHtml, PAGE, badgeHtml, query*)
let articleOffset = 0;
let currentArticle = null;
async function loadSources() {
const sources = await api("/admin/api/sources");
const sel = document.getElementById("f-source");
const current = queryGet("source");
sources.forEach(s => {
const opt = document.createElement("option");
opt.value = s;
opt.textContent = s;
sel.appendChild(opt);
});
if (current) sel.value = current;
}
function getFilters() {
return {
keyword: document.getElementById("f-keyword").value.trim(),
source: document.getElementById("f-source").value,
content_status: document.getElementById("f-status").value,
from: document.getElementById("f-from").value,
to: document.getElementById("f-to").value,
};
}
// sync current filters + offset into url so the state is bookmarkable
function syncUrl() {
const f = getFilters();
queryWrite({
...f,
offset: articleOffset > 0 ? articleOffset : "",
});
}
async function loadArticles() {
const f = getFilters();
const params = new URLSearchParams({ limit: PAGE, offset: articleOffset });
if (f.keyword) params.set("keyword", f.keyword);
if (f.source) params.set("source", f.source);
if (f.content_status) params.set("content_status", f.content_status);
if (f.from) params.set("from", f.from + "T00:00:00");
if (f.to) params.set("to", f.to + "T23:59:59");
const data = await api(`/admin/api/articles?${params}`);
const tbody = document.getElementById("articleTable");
tbody.innerHTML = data.rows.map(r => `
| ${r.id} |
${escapeHtml(r.title)}
↗ ${new URL(r.url).hostname}
|
${r.source} |
${badgeHtml(r.content_status)} |
${r.ingested_at ? r.ingested_at.slice(0, 16) : "—"} |
|
`).join("");
const total = data.total;
document.getElementById("pageInfo").textContent =
`${articleOffset + 1}–${Math.min(articleOffset + PAGE, total)} of ${total.toLocaleString()}`;
document.getElementById("prevBtn").disabled = articleOffset === 0;
document.getElementById("nextBtn").disabled = articleOffset + PAGE >= total;
}
async function openArticle(id) {
currentArticle = await api(`/admin/api/articles/${id}`);
const a = currentArticle;
document.getElementById("modalTitle").textContent = `Article #${a.id}`;
document.getElementById("modalMeta").innerHTML = [
a.source && `source: ${escapeHtml(a.source)}`,
a.pub_date && `pub: ${a.pub_date.slice(0, 16)}`,
`has_embedding: ${a.has_embedding ? "yes" : "no"}`,
a.content_error && `error: ${escapeHtml(a.content_error.slice(0, 80))}`,
].filter(Boolean).join("");
document.getElementById("m-title").value = a.title || "";
document.getElementById("m-desc").value = a.description || "";
document.getElementById("m-content").value = a.content || "";
document.getElementById("m-status").value = a.content_status || "";
document.getElementById("m-lang").value = a.language || "";
document.getElementById("m-pubdate").value = a.pub_date || "";
document.getElementById("m-indexpage").value = String(a.is_index_page || 0);
document.getElementById("articleOverlay").classList.add("open");
}
document.addEventListener("DOMContentLoaded", async () => {
// restore filter inputs from the current url
queryApplyToInputs({
"f-keyword": "keyword",
"f-status": "content_status",
"f-from": "from",
"f-to": "to",
});
articleOffset = parseInt(queryGet("offset"), 10) || 0;
document.getElementById("searchBtn").onclick = () => {
articleOffset = 0;
syncUrl();
loadArticles();
};
document.getElementById("prevBtn").onclick = () => {
articleOffset = Math.max(0, articleOffset - PAGE);
syncUrl();
loadArticles();
};
document.getElementById("nextBtn").onclick = () => {
articleOffset += PAGE;
syncUrl();
loadArticles();
};
document.querySelector(".filters").addEventListener("keydown", e => {
if (e.key === "Enter") {
articleOffset = 0;
syncUrl();
loadArticles();
}
});
document.getElementById("cancelBtn").onclick = () =>
document.getElementById("articleOverlay").classList.remove("open");
document.getElementById("saveBtn").onclick = async () => {
if (!currentArticle) return;
try {
await api(`/admin/api/articles/${currentArticle.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: document.getElementById("m-title").value,
description: document.getElementById("m-desc").value,
content: document.getElementById("m-content").value,
content_status: document.getElementById("m-status").value || null,
language: document.getElementById("m-lang").value || null,
pub_date: document.getElementById("m-pubdate").value || null,
is_index_page: parseInt(document.getElementById("m-indexpage").value, 10),
}),
});
document.getElementById("articleOverlay").classList.remove("open");
toast("Saved");
loadArticles();
} catch (e) {
toast("Save failed", true);
}
};
document.getElementById("deleteBtn").onclick = async () => {
if (!currentArticle) return;
if (!confirm(`Delete article #${currentArticle.id}? This cannot be undone.`)) return;
try {
await api(`/admin/api/articles/${currentArticle.id}`, { method: "DELETE" });
document.getElementById("articleOverlay").classList.remove("open");
toast("Deleted");
loadArticles();
loadGlobalStats();
} catch (e) {
toast("Delete failed", true);
}
};
await loadSources(); // wait for dropdown to render the saved source selection
loadArticles();
});