feat(ui): clickable document viewer — open any cited document in the browser from chat chips and the sources table
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/* Brain of Reese — document viewer (phase 10).
|
||||
*
|
||||
* Reads `source`/`path` query params, fetches the stateless content
|
||||
* endpoint (GET /api/documents/content — database only, no filesystem),
|
||||
* and renders:
|
||||
*
|
||||
* • md / markdown → the shared escape-first renderer (markdown.js) in a
|
||||
* ≤46rem centered column;
|
||||
* • any other → the raw content as a text node inside
|
||||
* <pre class="doc-raw"> (mono, horizontal scroll).
|
||||
*
|
||||
* XSS-safe by construction: markdown is escaped before transform, raw
|
||||
* formats are set via textContent, and every document-derived string
|
||||
* (title, badges, path) is written with textContent — never innerHTML.
|
||||
*
|
||||
* A missing document (unknown pair, missing params, network error) shows
|
||||
* the designed not-found card with a link back to the Sources page.
|
||||
*/
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const source = params.get("source") || "";
|
||||
const path = params.get("path") || "";
|
||||
|
||||
const titleEl = document.querySelector("#doc-title");
|
||||
const metaEl = document.querySelector("#doc-meta");
|
||||
const contentEl = document.querySelector("#doc-content");
|
||||
const notFoundEl = document.querySelector("#doc-not-found");
|
||||
const mainEl = document.querySelector("#main");
|
||||
const backLink = document.querySelector("#doc-back");
|
||||
|
||||
/* Back: prefer the browser's own history when there is one (the viewer was
|
||||
* opened from this tab's session); a fresh tab lands on the Sources page. */
|
||||
backLink.addEventListener("click", (e) => {
|
||||
if (window.history.length > 1) {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
});
|
||||
|
||||
function fmtDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function contentUrl(s, p) {
|
||||
return "/api/documents/content?source=" + encodeURIComponent(s) + "&path=" + encodeURIComponent(p);
|
||||
}
|
||||
|
||||
function metaBadge(cls, text) {
|
||||
const el = document.createElement("span");
|
||||
el.className = cls;
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function render(doc) {
|
||||
titleEl.textContent = doc.title;
|
||||
document.title = `${doc.title} · Brain of Reese`;
|
||||
|
||||
const pathCode = document.createElement("code");
|
||||
pathCode.className = "doc-path";
|
||||
pathCode.textContent = doc.path;
|
||||
metaEl.replaceChildren(
|
||||
metaBadge("doc-source-badge", doc.source),
|
||||
metaBadge("format-badge", doc.format),
|
||||
pathCode,
|
||||
metaBadge("doc-indexed", `Indexed ${fmtDate(doc.indexed_at)}`),
|
||||
metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
|
||||
);
|
||||
|
||||
notFoundEl.hidden = true;
|
||||
contentEl.replaceChildren();
|
||||
if (doc.format === "md" || doc.format === "markdown") {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "doc-md";
|
||||
wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
|
||||
contentEl.appendChild(wrap);
|
||||
} else {
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "doc-raw";
|
||||
pre.textContent = doc.content; // text node: never parsed as HTML
|
||||
contentEl.appendChild(pre);
|
||||
}
|
||||
}
|
||||
|
||||
function showNotFound() {
|
||||
titleEl.textContent = "Document not found";
|
||||
document.title = "Document not found · Brain of Reese";
|
||||
metaEl.replaceChildren();
|
||||
contentEl.replaceChildren();
|
||||
notFoundEl.hidden = false;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
if (!source || !path) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
const r = await fetch(contentUrl(source, path));
|
||||
if (!r.ok) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
render(await r.json());
|
||||
} catch {
|
||||
showNotFound();
|
||||
} finally {
|
||||
mainEl.focus(); // move focus to main on load (a11y)
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
Reference in New Issue
Block a user