// 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() { const sel = document.getElementById("f-source"); return { keyword: document.getElementById("f-keyword").value.trim(), // fall back to url when the dropdown hasn't populated yet — // lets us fire loadArticles in parallel with loadSources on init source: sel.value || queryGet("source"), 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); } }; // both calls can run concurrently — loadArticles falls back to the // url param for source until the dropdown finishes populating. await Promise.all([loadSources(), loadArticles()]); });