add filtering and sorting features; enhance events and articles pages with URL state persistence
This commit is contained in:
parent
bec6763191
commit
194442ec4c
8 changed files with 274 additions and 16 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue