From 194442ec4cc9b73491d96825ab8643725ee3c9dd Mon Sep 17 00:00:00 2001 From: ImBenji Date: Thu, 23 Apr 2026 23:03:11 +0100 Subject: [PATCH] add filtering and sorting features; enhance events and articles pages with URL state persistence --- public/admin/assets/js/articles.js | 58 +++++++++++++++-- public/admin/assets/js/events.js | 72 +++++++++++++++++++-- public/admin/assets/js/intel-graph.js | 25 +++++++ public/admin/assets/js/intel-knowledge.js | 27 ++++++++ public/admin/assets/js/intel-predictions.js | 23 +++++++ public/admin/assets/js/sql.js | 10 +++ public/admin/pages/events.html | 16 +++++ src/routes/admin.js | 59 +++++++++++++++-- 8 files changed, 274 insertions(+), 16 deletions(-) diff --git a/public/admin/assets/js/articles.js b/public/admin/assets/js/articles.js index 9925b57..f457afb 100644 --- a/public/admin/assets/js/articles.js +++ b/public/admin/assets/js/articles.js @@ -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(); }); diff --git a/public/admin/assets/js/events.js b/public/admin/assets/js/events.js index 47980ae..54a68a4 100644 --- a/public/admin/assets/js/events.js +++ b/public/admin/assets/js/events.js @@ -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"); diff --git a/public/admin/assets/js/intel-graph.js b/public/admin/assets/js/intel-graph.js index 065b4a9..552cfe4 100644 --- a/public/admin/assets/js/intel-graph.js +++ b/public/admin/assets/js/intel-graph.js @@ -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(); }); diff --git a/public/admin/assets/js/intel-knowledge.js b/public/admin/assets/js/intel-knowledge.js index 33643b9..51f29e3 100644 --- a/public/admin/assets/js/intel-knowledge.js +++ b/public/admin/assets/js/intel-knowledge.js @@ -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(); }); diff --git a/public/admin/assets/js/intel-predictions.js b/public/admin/assets/js/intel-predictions.js index d51d6f8..e5b97cf 100644 --- a/public/admin/assets/js/intel-predictions.js +++ b/public/admin/assets/js/intel-predictions.js @@ -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(); }); diff --git a/public/admin/assets/js/sql.js b/public/admin/assets/js/sql.js index 46718af..36deb1d 100644 --- a/public/admin/assets/js/sql.js +++ b/public/admin/assets/js/sql.js @@ -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 => { diff --git a/public/admin/pages/events.html b/public/admin/pages/events.html index bf6a672..d8e7a6f 100644 --- a/public/admin/pages/events.html +++ b/public/admin/pages/events.html @@ -30,6 +30,22 @@
+
+ + + + + + +
+
diff --git a/src/routes/admin.js b/src/routes/admin.js index 3cffe21..d1b9cee 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -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) => {