Duriin-API/public/admin/assets/js/articles.js

193 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 => `
<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);
}
};
// both calls can run concurrently — loadArticles falls back to the
// url param for source until the dropdown finishes populating.
await Promise.all([loadSources(), loadArticles()]);
});