144 lines
4.8 KiB
JavaScript
144 lines
4.8 KiB
JavaScript
/* Brain of Reese — Sources page (knowledge base index view).
|
||
*
|
||
* Wires the real `GET /api/docs` endpoint (import phase): stat cards +
|
||
* full-width document table, or the designed empty state when nothing is
|
||
* indexed yet. Cells are built with DOM APIs (textContent) — never
|
||
* innerHTML with document-derived data (XSS-safe by construction).
|
||
*
|
||
* Phase 19: the page joins the shared header (assets/header.js) — the
|
||
* whoami gate below runs on the module's cached promise (one request per
|
||
* page, shared with the header toggling), and the header gains the New
|
||
* chat button: on a non-chat page "new chat" means going to the chat,
|
||
* fresh (clear the phase-14 conversation key, then navigate to "/").
|
||
*/
|
||
|
||
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
|
||
|
||
const tbody = document.querySelector("#docs-tbody");
|
||
const emptyEl = document.querySelector("#sources-empty");
|
||
const tableWrap = document.querySelector(".table-wrap");
|
||
const statCards = document.querySelector("#stat-cards");
|
||
const gateEl = document.querySelector("#sources-gate");
|
||
const statDocs = document.querySelector("#stat-docs");
|
||
const statChunks = document.querySelector("#stat-chunks");
|
||
const statLast = document.querySelector("#stat-last");
|
||
|
||
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
|
||
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the
|
||
* catalog is admin-only. The document viewer itself stays public (the
|
||
* soft rule), so the gate copy points at what keeps working.
|
||
* Phase 19: the whoami request is the shared header module's cached
|
||
* promise — the same single request initSharedHeader() awaited. */
|
||
function isAdmin() {
|
||
return fetchIsAdmin();
|
||
}
|
||
|
||
/* Phase 19: New chat on a non-chat page means "go to the chat, fresh":
|
||
* clear the phase-14 conversation key, then land on the chat page — its
|
||
* empty state, since the conversation is gone from storage. */
|
||
const newChatBtn = document.querySelector("#new-chat-btn");
|
||
if (newChatBtn) {
|
||
newChatBtn.addEventListener("click", () => {
|
||
clearChatStorage();
|
||
window.location.href = "/";
|
||
});
|
||
}
|
||
|
||
function fmtDate(iso) {
|
||
try {
|
||
return new Date(iso).toLocaleString();
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
/* Viewer link (phase 10) — same encoded URL the chat chips use; both query
|
||
* values are percent-encoded (paths contain slashes, sometimes spaces). */
|
||
export function documentUrl(source, path) {
|
||
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||
}
|
||
|
||
async function loadDocs() {
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/docs");
|
||
} catch {
|
||
showEmpty();
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
showEmpty();
|
||
return;
|
||
}
|
||
const { documents } = await r.json();
|
||
if (!documents.length) {
|
||
showEmpty();
|
||
return;
|
||
}
|
||
|
||
tbody.replaceChildren();
|
||
let totalChunks = 0;
|
||
let last = "";
|
||
for (const d of documents) {
|
||
totalChunks += d.chunks;
|
||
if (d.indexed_at > last) last = d.indexed_at;
|
||
tbody.appendChild(makeRow(d));
|
||
}
|
||
statDocs.textContent = String(documents.length);
|
||
statChunks.textContent = String(totalChunks);
|
||
statLast.textContent = last ? fmtDate(last) : "–";
|
||
emptyEl.hidden = true;
|
||
tableWrap.hidden = false;
|
||
}
|
||
|
||
function makeRow(d) {
|
||
const tr = document.createElement("tr");
|
||
|
||
const sourceTd = document.createElement("td");
|
||
sourceTd.textContent = d.source; // document-derived text — never innerHTML
|
||
tr.appendChild(sourceTd);
|
||
|
||
// Path cell: a link to the document viewer (phase 10), full path as the
|
||
// accessible/hover name (the column is ellipsized).
|
||
const pathTd = document.createElement("td");
|
||
pathTd.title = d.path; // full path on hover (column is ellipsized)
|
||
const link = document.createElement("a");
|
||
link.className = "doc-link";
|
||
link.href = documentUrl(d.source, d.path);
|
||
link.target = "_blank"; // open the full document in a new tab
|
||
link.rel = "noopener";
|
||
link.title = d.path; // full path as the link's hover/accessible name
|
||
link.textContent = d.path;
|
||
pathTd.appendChild(link);
|
||
tr.appendChild(pathTd);
|
||
|
||
for (const value of [d.title, String(d.chunks), fmtDate(d.indexed_at)]) {
|
||
const td = document.createElement("td");
|
||
td.textContent = value;
|
||
tr.appendChild(td);
|
||
}
|
||
return tr;
|
||
}
|
||
|
||
function showEmpty() {
|
||
statDocs.textContent = "0";
|
||
statChunks.textContent = "0";
|
||
statLast.textContent = "–";
|
||
emptyEl.hidden = false;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
}
|
||
|
||
(async () => {
|
||
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
|
||
if (!(await isAdmin())) {
|
||
// Anonymous: gate in, catalog out, and no /api/docs request at all.
|
||
if (statCards) statCards.hidden = true;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
if (emptyEl) emptyEl.hidden = true;
|
||
if (gateEl) gateEl.hidden = false;
|
||
return;
|
||
}
|
||
if (gateEl) gateEl.hidden = true;
|
||
loadDocs();
|
||
})();
|