add filtering and sorting features; enhance events and articles pages with URL state persistence

This commit is contained in:
ImBenji 2026-04-23 23:03:11 +01:00
parent bec6763191
commit 194442ec4c
8 changed files with 274 additions and 16 deletions

View file

@ -1,5 +1,6 @@
// articles page — list + keyword/status/source filtering + edit modal
// depends on: app.js (api, toast, escapeHtml, PAGE, badgeHtml)
// 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;
@ -8,12 +9,17 @@ 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;
}
@ -28,6 +34,16 @@ function getFilters() {
}
// 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 });
@ -88,14 +104,42 @@ async function openArticle(id) {
}
document.addEventListener("DOMContentLoaded", () => {
document.addEventListener("DOMContentLoaded", async () => {
document.getElementById("searchBtn").onclick = () => { articleOffset = 0; loadArticles(); };
document.getElementById("prevBtn").onclick = () => { articleOffset = Math.max(0, articleOffset - PAGE); loadArticles(); };
document.getElementById("nextBtn").onclick = () => { articleOffset += PAGE; loadArticles(); };
// 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; loadArticles(); }
if (e.key === "Enter") {
articleOffset = 0;
syncUrl();
loadArticles();
}
});
@ -140,6 +184,6 @@ document.addEventListener("DOMContentLoaded", () => {
}
};
loadSources();
await loadSources(); // wait for dropdown to render the saved source selection
loadArticles();
});

View file

@ -1,12 +1,43 @@
// events page — list + title edit + detach-and-delete
// events page — list + title edit + detach-and-delete, with filters
// filter/sort/pagination state persists to url query params
// depends on: app.js
let eventOffset = 0;
let currentEvent = null;
function getEventFilters() {
return {
keyword: document.getElementById("ef-keyword").value.trim(),
min_articles: document.getElementById("ef-min").value.trim(),
from: document.getElementById("ef-from").value,
to: document.getElementById("ef-to").value,
sort: document.getElementById("ef-sort").value,
};
}
function syncUrl() {
const f = getEventFilters();
queryWrite({
...f,
// default sort isn't worth surfacing in the url
sort: f.sort && f.sort !== "created_desc" ? f.sort : "",
offset: eventOffset > 0 ? eventOffset : "",
});
}
async function loadEvents() {
const data = await api(`/admin/api/events?limit=${PAGE}&offset=${eventOffset}`);
const f = getEventFilters();
const params = new URLSearchParams({ limit: PAGE, offset: eventOffset });
if (f.keyword) params.set("keyword", f.keyword);
if (f.min_articles) params.set("min_articles", f.min_articles);
if (f.from) params.set("from", f.from + "T00:00:00");
if (f.to) params.set("to", f.to + "T23:59:59");
if (f.sort) params.set("sort", f.sort);
const data = await api(`/admin/api/events?${params}`);
const tbody = document.getElementById("eventTable");
tbody.innerHTML = data.rows.map(r => `
@ -36,8 +67,41 @@ function openEvent(id, title) {
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("ePrevBtn").onclick = () => { eventOffset = Math.max(0, eventOffset - PAGE); loadEvents(); };
document.getElementById("eNextBtn").onclick = () => { eventOffset += PAGE; loadEvents(); };
// restore filter state from url
queryApplyToInputs({
"ef-keyword": "keyword",
"ef-min": "min_articles",
"ef-from": "from",
"ef-to": "to",
"ef-sort": "sort",
});
eventOffset = parseInt(queryGet("offset"), 10) || 0;
document.getElementById("eSearchBtn").onclick = () => {
eventOffset = 0;
syncUrl();
loadEvents();
};
document.getElementById("ePrevBtn").onclick = () => {
eventOffset = Math.max(0, eventOffset - PAGE);
syncUrl();
loadEvents();
};
document.getElementById("eNextBtn").onclick = () => {
eventOffset += PAGE;
syncUrl();
loadEvents();
};
document.querySelector(".filters").addEventListener("keydown", e => {
if (e.key === "Enter") {
eventOffset = 0;
syncUrl();
loadEvents();
}
});
document.getElementById("eCancelBtn").onclick = () =>
document.getElementById("eventOverlay").classList.remove("open");

View file

@ -86,6 +86,14 @@ let graphSearchTerm = "";
let graphSelectedId = null;
function syncGraphUrl() {
queryWrite({
q: graphSearchTerm,
type: graphFilterType && graphFilterType !== "all" ? graphFilterType : "",
});
}
async function loadIntelGraph() {
const data = await api("/admin/api/intelligence/graph");
@ -547,8 +555,24 @@ async function toggleEvidenceRow(row) {
document.addEventListener("DOMContentLoaded", async () => {
// restore search/type from url
const urlSearch = queryGet("q");
const urlType = queryGet("type") || "all";
if (urlSearch) {
document.getElementById("graph-search").value = urlSearch;
graphSearchTerm = urlSearch.toLowerCase();
}
if (urlType && urlType !== "all") {
graphFilterType = urlType;
document.querySelectorAll(".graph-chip").forEach(c =>
c.classList.toggle("active", c.dataset.type === urlType)
);
}
document.getElementById("graph-search").addEventListener("input", ev => {
graphSearchTerm = ev.target.value.trim().toLowerCase();
syncGraphUrl();
applyGraphFilters();
});
@ -558,6 +582,7 @@ document.addEventListener("DOMContentLoaded", async () => {
document.querySelectorAll(".graph-chip").forEach(c => c.classList.remove("active"));
btn.classList.add("active");
graphFilterType = btn.dataset.type;
syncGraphUrl();
applyGraphFilters();
});

View file

@ -1,9 +1,23 @@
// intelligence → knowledge table
// filter/sort/pagination persisted via url query params
// depends on: app.js, intel-shared.js
let knowledgeOffset = 0;
function syncUrl() {
const companyId = document.getElementById("i-company").value;
const type = document.getElementById("i-type").value;
const sort = document.getElementById("i-sort").value;
queryWrite({
company_id: companyId,
type: type,
sort: sort && sort !== "id" ? sort : "",
offset: knowledgeOffset > 0 ? knowledgeOffset : "",
});
}
async function loadKnowledge() {
const companyId = document.getElementById("i-company").value;
const type = document.getElementById("i-type").value;
@ -45,19 +59,32 @@ async function loadKnowledge() {
document.addEventListener("DOMContentLoaded", async () => {
document.getElementById("iPrevBtn").onclick = () => {
knowledgeOffset = Math.max(0, knowledgeOffset - PAGE);
syncUrl();
loadKnowledge();
};
document.getElementById("iNextBtn").onclick = () => {
knowledgeOffset += PAGE;
syncUrl();
loadKnowledge();
};
document.getElementById("i-filter-btn").onclick = () => {
knowledgeOffset = 0;
syncUrl();
loadKnowledge();
};
const ok = await loadIntelStatsRow();
if (!ok) return;
await loadIntelCompanies();
// restore from url (after dropdown is populated so company_id options exist)
queryApplyToInputs({
"i-company": "company_id",
"i-type": "type",
"i-sort": "sort",
});
knowledgeOffset = parseInt(queryGet("offset"), 10) || 0;
loadKnowledge();
});

View file

@ -1,9 +1,21 @@
// intelligence → predictions table
// filter/sort/pagination persisted via url query params
// depends on: app.js, intel-shared.js
let predictionsOffset = 0;
function syncUrl() {
const companyId = document.getElementById("i-company").value;
const sort = document.getElementById("i-sort").value;
queryWrite({
company_id: companyId,
sort: sort && sort !== "id" ? sort : "",
offset: predictionsOffset > 0 ? predictionsOffset : "",
});
}
async function loadPredictions() {
const companyId = document.getElementById("i-company").value;
const sort = document.getElementById("i-sort").value;
@ -43,19 +55,30 @@ async function loadPredictions() {
document.addEventListener("DOMContentLoaded", async () => {
document.getElementById("iPrevBtn").onclick = () => {
predictionsOffset = Math.max(0, predictionsOffset - PAGE);
syncUrl();
loadPredictions();
};
document.getElementById("iNextBtn").onclick = () => {
predictionsOffset += PAGE;
syncUrl();
loadPredictions();
};
document.getElementById("i-filter-btn").onclick = () => {
predictionsOffset = 0;
syncUrl();
loadPredictions();
};
const ok = await loadIntelStatsRow();
if (!ok) return;
await loadIntelCompanies();
queryApplyToInputs({
"i-company": "company_id",
"i-sort": "sort",
});
predictionsOffset = parseInt(queryGet("offset"), 10) || 0;
loadPredictions();
});

View file

@ -63,6 +63,16 @@ async function runSql() {
document.addEventListener("DOMContentLoaded", () => {
// restore selected db from url
const urlDb = queryGet("db");
if (urlDb === "archive" || urlDb === "intelligence") {
document.getElementById("sql-db").value = urlDb;
}
document.getElementById("sql-db").addEventListener("change", ev => {
queryWrite({ db: ev.target.value === "archive" ? "" : ev.target.value });
});
document.getElementById("sql-run-btn").onclick = runSql;
document.getElementById("sql-input").addEventListener("keydown", e => {

View file

@ -30,6 +30,22 @@
<main class="content">
<div class="filters">
<label>Keyword <input type="text" id="ef-keyword" placeholder="search title..." /></label>
<label>Min articles <input type="text" id="ef-min" placeholder="e.g. 2" style="min-width:90px" /></label>
<label>From <input type="date" id="ef-from" /></label>
<label>To <input type="date" id="ef-to" /></label>
<label>Sort
<select id="ef-sort">
<option value="created_desc">Newest first</option>
<option value="created_asc">Oldest first</option>
<option value="articles_desc">Most articles</option>
<option value="articles_asc">Fewest articles</option>
</select>
</label>
<button class="primary" id="eSearchBtn" style="align-self:flex-end">Filter</button>
</div>
<div class="table-wrap">
<table>
<thead>

View file

@ -224,7 +224,7 @@ async function adminRoutes(fastify) {
return rows.map(r => r.source);
});
// events
// events — supports keyword / date range / min-article-count / sort
fastify.get('/admin/api/events', async (request, reply) => {
if (!checkAuth(request, reply)) return;
@ -232,17 +232,66 @@ async function adminRoutes(fastify) {
const limit = Math.min(parseInt(q.limit, 10) || 50, 200);
const offset = parseInt(q.offset, 10) || 0;
const total = db.prepare(`SELECT COUNT(*) as n FROM events`).get().n;
const where = [];
const whereParams = [];
if (q.keyword) {
where.push('e.title LIKE ?');
whereParams.push(`%${q.keyword}%`);
}
if (q.from) {
where.push('e.created_at >= ?');
whereParams.push(q.from);
}
if (q.to) {
where.push('e.created_at <= ?');
whereParams.push(q.to);
}
// having filters operate on the aggregate count
const having = [];
const havingParams = [];
if (q.min_articles) {
const n = parseInt(q.min_articles, 10);
if (!isNaN(n)) { having.push('article_count >= ?'); havingParams.push(n); }
}
// whitelist sort columns + direction so user input cant break the query
const sortMap = {
created_desc: 'e.created_at DESC',
created_asc: 'e.created_at ASC',
articles_desc: 'article_count DESC',
articles_asc: 'article_count ASC',
};
const orderBy = sortMap[q.sort] || sortMap.created_desc;
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
const havingClause = having.length ? `HAVING ${having.join(' AND ')}` : '';
// total count has to respect the HAVING clause too, so wrap the grouped query
const totalRow = db.prepare(`
SELECT COUNT(*) as n FROM (
SELECT e.id, COUNT(a.id) as article_count
FROM events e
LEFT JOIN articles a ON a.event_id = e.id
${whereClause}
GROUP BY e.id
${havingClause}
)
`).get(...whereParams, ...havingParams);
const rows = db.prepare(`
SELECT e.id, e.title, e.created_at, COUNT(a.id) as article_count
FROM events e
LEFT JOIN articles a ON a.event_id = e.id
${whereClause}
GROUP BY e.id
ORDER BY e.created_at DESC
${havingClause}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?
`).all(limit, offset);
`).all(...whereParams, ...havingParams, limit, offset);
return { total, rows };
return { total: totalRow.n, rows };
});
fastify.delete('/admin/api/events/:id', async (request, reply) => {