189 lines
6.5 KiB
JavaScript
189 lines
6.5 KiB
JavaScript
// 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 => `
|
||
<tr>
|
||
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
|
||
<td>
|
||
<span class="truncate" title="${escapeHtml(r.title)}">${escapeHtml(r.title)}</span>
|
||
<a class="url-link" href="${r.url}" target="_blank" style="font-size:11px; display:block; margin-top:3px; color:var(--muted-dark)">↗ ${new URL(r.url).hostname}</a>
|
||
</td>
|
||
<td style="color:var(--muted)">${r.source}</td>
|
||
<td>${badgeHtml(r.content_status)}</td>
|
||
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.ingested_at ? r.ingested_at.slice(0, 16) : "—"}</td>
|
||
<td><button onclick="openArticle(${r.id})">Edit</button></td>
|
||
</tr>
|
||
`).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 && `<span>source: <b>${escapeHtml(a.source)}</b></span>`,
|
||
a.pub_date && `<span>pub: ${a.pub_date.slice(0, 16)}</span>`,
|
||
`<span>has_embedding: ${a.has_embedding ? "yes" : "no"}</span>`,
|
||
a.content_error && `<span style="color:#fca5a5">error: ${escapeHtml(a.content_error.slice(0, 80))}</span>`,
|
||
].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();
|
||
});
|