435 lines
17 KiB
JavaScript
435 lines
17 KiB
JavaScript
/* Brain of Reese — History page (saved chats, phase 50 task 04).
|
|
*
|
|
* TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat
|
|
* history in a new page, then return to that history with a click."
|
|
*
|
|
* Wires the admin-only `GET /api/chats` + `POST /api/chats/<id>/share`
|
|
* + `POST /api/chats/<id>/unshare` + `DELETE /api/chats/<id>`
|
|
* endpoints (phase 50 task 02; phase 51 task 01+02) into the page's
|
|
* full-width table:
|
|
*
|
|
* • Title — an `<a href="/?chat=<id>">`: Open IS the title link
|
|
* ("return to that history with a click") — the chat page boots
|
|
* into the saved conversation through ?chat= (task 03);
|
|
* • Messages — the row's message_count;
|
|
* • Updated — locale date+time, the full ISO in the title attribute;
|
|
* • Share — phase 51 (owner-locked 2026-08-29, TODO.md L6): the
|
|
* row's share state, rendered from the list's OWN share_url (the
|
|
* GET /api/chats endpoint populates it — no second fetch per row).
|
|
* Three states: unshared → [Create link] (POST share → the cell
|
|
* re-renders shared + the link is offered for copying); shared →
|
|
* [Copy] [Unshare]; confirming → the inline two-step "Unshare?
|
|
* [Yes] [No]" (the phase-50 Delete-confirm pattern + CSS — no
|
|
* native dialog) — Yes POSTs /api/chats/<id>/unshare (revokes),
|
|
* the cell re-renders unshared + the live region;
|
|
* • Actions — Delete, inline TWO-STEP confirm (owner-locked
|
|
* 2026-08-29: no native confirm dialog anywhere in this file) —
|
|
* the first click swaps the button for
|
|
* "Delete? [Yes] [No]" (focus moves to Yes, so the confirm is
|
|
* keyboard-reachable), Yes fires the DELETE and removes the row,
|
|
* No (or a failed request) keeps it.
|
|
*
|
|
* The share copy has the owner-locked inline-link fallback: a
|
|
* non-secure (http) homelab origin rejects navigator.clipboard, so the
|
|
* offer renders a transient .share-link-fallback <a> field (input-like,
|
|
* selects its full URL on focus) in the row's share cell — this file
|
|
* keeps its OWN copy of the ~10-line helper (the per-page duplication
|
|
* house style; app.js keeps the chat page's) rather than a new shared
|
|
* module.
|
|
*
|
|
* Every cell is built with the DOM APIs (textContent) — the title is
|
|
* user-derived (the auto-title is the first question), so it NEVER
|
|
* touches innerHTML (XSS-safe by construction, the sources.js house
|
|
* rule).
|
|
*
|
|
* The whoami gate (phase 19 shared-header module, cached promise):
|
|
* • anonymous → the #history-gate is shown, the table is hidden,
|
|
* and NO /api/chats request is made at all (the router 403s
|
|
* anonymous — the story E2E pins the request log);
|
|
* • admin → the gate hides and `loadChats()` renders the rows; a
|
|
* 0-row fetch reveals the empty-state row.
|
|
*
|
|
* Phase 19/34: the page joins the shared header — initSharedHeader()
|
|
* runs first (whoami + nav reveal + the steering panel), and the gate
|
|
* below reuses the SAME cached /api/whoami promise (one request per
|
|
* page).
|
|
*/
|
|
|
|
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
|
|
|
const tableWrap = document.querySelector("#history-table-wrap");
|
|
const tbody = document.querySelector("#history-tbody");
|
|
const emptyRow = document.querySelector("#history-empty-row");
|
|
const gateEl = document.querySelector("#history-gate");
|
|
const statusEl = document.querySelector("#history-status");
|
|
|
|
/* Action feedback — the role="status" live region above the table
|
|
(the "never stale" contract: every row action lands a line here,
|
|
success or failure alike). */
|
|
function announce(message) {
|
|
if (statusEl) statusEl.textContent = message;
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
try {
|
|
return new Date(iso).toLocaleString();
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
/* One row. The Title cell carries the Open link (/?chat=<id> — the
|
|
"return to that history with a click" requirement); the Updated
|
|
cell renders the locale date+time with the full ISO on hover. */
|
|
function makeRow(chat) {
|
|
const tr = document.createElement("tr");
|
|
|
|
const titleTd = document.createElement("td");
|
|
titleTd.className = "history-title-cell";
|
|
titleTd.title = chat.title; // full title on hover (the column ellipsizes)
|
|
const link = document.createElement("a");
|
|
link.className = "history-title-link";
|
|
link.href = "/?chat=" + chat.id; // Open: the chat page boots into this chat
|
|
link.textContent = chat.title; // user-derived — textContent only
|
|
titleTd.appendChild(link);
|
|
tr.appendChild(titleTd);
|
|
|
|
const countTd = document.createElement("td");
|
|
countTd.className = "history-count-cell";
|
|
countTd.textContent = String(chat.message_count);
|
|
tr.appendChild(countTd);
|
|
|
|
const updatedTd = document.createElement("td");
|
|
updatedTd.className = "history-updated-cell";
|
|
updatedTd.title = chat.updated_at; // full ISO on hover
|
|
updatedTd.textContent = fmtDate(chat.updated_at);
|
|
tr.appendChild(updatedTd);
|
|
|
|
// Phase 51: the Share cell (between Updated and Actions) — the
|
|
// three-state share control (unshared / shared / confirming-unshare).
|
|
const shareTd = document.createElement("td");
|
|
shareTd.className = "history-share-cell";
|
|
shareTd.appendChild(makeShareControl(chat));
|
|
tr.appendChild(shareTd);
|
|
|
|
const actionsTd = document.createElement("td");
|
|
actionsTd.className = "history-actions-cell";
|
|
actionsTd.appendChild(makeDeleteControl(chat, tr));
|
|
tr.appendChild(actionsTd);
|
|
return tr;
|
|
}
|
|
|
|
/* The inline two-step Delete (owner-locked 2026-08-29 — NO native
|
|
confirm dialog anywhere on this page). The Delete button is
|
|
replaced, in place, by the "Delete? [Yes] [No]" pair; focus moves
|
|
to Yes (keyboard-reachable confirm). Yes → DELETE /api/chats/<id>
|
|
→ the row is removed + the live region line; No or a failed
|
|
request keeps the row (+ the error line on failure). */
|
|
function makeDeleteControl(chat, row) {
|
|
const cell = document.createElement("span");
|
|
cell.className = "history-actions";
|
|
|
|
const del = document.createElement("button");
|
|
del.type = "button";
|
|
del.className = "history-delete";
|
|
del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`);
|
|
del.textContent = "Delete";
|
|
|
|
function restoreDelete() {
|
|
cell.replaceChildren(del);
|
|
del.focus(); // focus returns to the (restored) control
|
|
}
|
|
|
|
del.addEventListener("click", () => {
|
|
const label = document.createElement("span");
|
|
label.className = "history-confirm-text";
|
|
label.textContent = "Delete?";
|
|
const yes = document.createElement("button");
|
|
yes.type = "button";
|
|
yes.className = "history-confirm-yes";
|
|
yes.textContent = "Yes";
|
|
const no = document.createElement("button");
|
|
no.type = "button";
|
|
no.className = "history-confirm-no";
|
|
no.textContent = "No";
|
|
yes.addEventListener("click", () =>
|
|
confirmDelete(chat, row, yes, restoreDelete));
|
|
no.addEventListener("click", restoreDelete);
|
|
cell.replaceChildren(label, yes, no);
|
|
yes.focus(); // the confirm pair takes over the focus
|
|
});
|
|
|
|
cell.appendChild(del); // the shipped state IS the Delete button
|
|
return cell;
|
|
}
|
|
|
|
/* The confirmed delete: DELETE /api/chats/<id> → the row is removed
|
|
(+ the empty-state row reappears when it was the last one) and the
|
|
live region gets `Deleted "<title>".` A 404 means the row is gone
|
|
(deleted elsewhere) — drop the stale row and say so. Any other
|
|
failure or a network error keeps the row, restores the Delete
|
|
button (retryable), and lands the error line. */
|
|
async function confirmDelete(chat, row, yesBtn, restoreDelete) {
|
|
yesBtn.disabled = true; // no double-fire while the request is in flight
|
|
let r;
|
|
try {
|
|
r = await fetch(`/api/chats/${chat.id}`, { method: "DELETE" });
|
|
} catch {
|
|
announce(`Couldn't delete "${chat.title}" — is the app reachable?`);
|
|
restoreDelete();
|
|
return;
|
|
}
|
|
if (r.status === 404) {
|
|
row.remove();
|
|
showEmptyIfLast();
|
|
announce("That chat was already deleted.");
|
|
return;
|
|
}
|
|
if (!r.ok) {
|
|
announce(`Couldn't delete "${chat.title}" — try again.`);
|
|
restoreDelete();
|
|
return;
|
|
}
|
|
row.remove();
|
|
showEmptyIfLast();
|
|
announce(`Deleted "${chat.title}".`);
|
|
}
|
|
|
|
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
|
|
|
|
/* Select every text node in the link field (an <a> has no .select();
|
|
a range does the job) — best-effort: a selection failure only means
|
|
the user copies by hand. */
|
|
function selectAllInField(el) {
|
|
try {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(el);
|
|
const sel = window.getSelection();
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
} catch {
|
|
/* selection is best-effort — the field still shows the full URL */
|
|
}
|
|
}
|
|
|
|
/* Clipboard + the owner-locked inline-link fallback: a non-secure
|
|
(http) homelab origin rejects navigator.clipboard, so the failure
|
|
path renders a TRANSIENT <a> link field in the row's share cell —
|
|
input-like, it selects its full URL on focus (click or Tab, then
|
|
Ctrl/Cmd+C). One field at a time (a new offer replaces the old).
|
|
Returns true when the clipboard took it. (The per-page duplication
|
|
house style — this is history.js's OWN copy of the ~10-line helper;
|
|
app.js keeps the chat page's, no new shared module.) */
|
|
async function copyShareLink(cell, absoluteUrl) {
|
|
cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
|
try {
|
|
await navigator.clipboard.writeText(absoluteUrl);
|
|
return true;
|
|
} catch {
|
|
const field = document.createElement("a");
|
|
field.className = "share-link-fallback";
|
|
field.href = absoluteUrl; // carries the full URL (copy link address works too)
|
|
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
|
|
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
|
|
field.addEventListener("focus", () => selectAllInField(field));
|
|
cell.appendChild(field);
|
|
field.focus({ preventScroll: true }); // selects the URL — ready to copy
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/* The unshared state: the [Create link] button (a failed share keeps
|
|
the cell here — Create link is retryable). */
|
|
function renderShareUnshared(chat, cell) {
|
|
const create = document.createElement("button");
|
|
create.type = "button";
|
|
create.className = "history-share-create";
|
|
create.setAttribute("aria-label", `Create share link: ${chat.title}`);
|
|
create.textContent = "Create link";
|
|
create.addEventListener("click", () => void createShareLink(chat, cell, create));
|
|
cell.replaceChildren(create);
|
|
}
|
|
|
|
/* The shared state: [Copy] [Unshare]. Unshare is the inline two-step
|
|
(the phase-50 Delete-confirm pattern — same .history-confirm-* CSS,
|
|
focus moves to Yes so the confirm is keyboard-reachable); No or a
|
|
failed request restores this state (retryable). */
|
|
function renderShareShared(chat, cell) {
|
|
const copy = document.createElement("button");
|
|
copy.type = "button";
|
|
copy.className = "history-share-copy";
|
|
copy.setAttribute("aria-label", `Copy share link: ${chat.title}`);
|
|
copy.textContent = "Copy";
|
|
copy.addEventListener("click", () => void copyRowShareLink(chat, cell));
|
|
const unshare = document.createElement("button");
|
|
unshare.type = "button";
|
|
unshare.className = "history-unshare";
|
|
unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`);
|
|
unshare.textContent = "Unshare";
|
|
function restoreShared() {
|
|
cell.replaceChildren(copy, unshare);
|
|
unshare.focus({ preventScroll: true }); // focus returns to the (restored) control
|
|
}
|
|
unshare.addEventListener("click", () => {
|
|
const label = document.createElement("span");
|
|
label.className = "history-confirm-text";
|
|
label.textContent = "Unshare?";
|
|
const yes = document.createElement("button");
|
|
yes.type = "button";
|
|
yes.className = "history-confirm-yes";
|
|
yes.textContent = "Yes";
|
|
const no = document.createElement("button");
|
|
no.type = "button";
|
|
no.className = "history-confirm-no";
|
|
no.textContent = "No";
|
|
yes.addEventListener("click", () => void confirmUnshare(chat, cell, yes, restoreShared));
|
|
no.addEventListener("click", restoreShared);
|
|
cell.replaceChildren(label, yes, no);
|
|
yes.focus({ preventScroll: true }); // the confirm pair takes over the focus
|
|
});
|
|
cell.replaceChildren(copy, unshare);
|
|
}
|
|
|
|
/* The Share cell (phase 51): the span the row's Share <td> carries.
|
|
The shipped state comes from the row's share_url (the list endpoint
|
|
populates it — no second fetch): shared → Copy + Unshare, unshared →
|
|
Create link. No share action ever removes the ROW (only Delete
|
|
does) — the cell just re-renders between its states. */
|
|
function makeShareControl(chat) {
|
|
const cell = document.createElement("span");
|
|
cell.className = "history-share";
|
|
if (chat.share_url) {
|
|
renderShareShared(chat, cell);
|
|
} else {
|
|
renderShareUnshared(chat, cell);
|
|
}
|
|
return cell;
|
|
}
|
|
|
|
/* Create the link: POST /api/chats/<id>/share → the response's
|
|
share_url becomes the row's data (chat.share_url — the later Copy
|
|
uses it), the cell re-renders to the shared state, and the ABSOLUTE
|
|
link (the row's own origin supplies the scheme/host) is offered for
|
|
copying — clipboard → the inline-field fallback in the cell. A
|
|
non-2xx (a 404 — the row was deleted behind our back — or 5xx) or a
|
|
network error keeps the unshared state (Create link re-enabled,
|
|
retryable) and lands the error line. */
|
|
async function createShareLink(chat, cell, createBtn) {
|
|
createBtn.disabled = true; // no double-fire while the request is in flight
|
|
let r;
|
|
try {
|
|
r = await fetch(`/api/chats/${chat.id}/share`, { method: "POST" });
|
|
} catch {
|
|
announce(`Couldn't share "${chat.title}" — is the app reachable?`);
|
|
createBtn.disabled = false;
|
|
return;
|
|
}
|
|
if (!r.ok) {
|
|
announce(`Couldn't share "${chat.title}" — try again.`);
|
|
createBtn.disabled = false;
|
|
return;
|
|
}
|
|
const { share_url } = await r.json();
|
|
chat.share_url = share_url; // the row is shared from now on
|
|
renderShareShared(chat, cell);
|
|
const copied = await copyShareLink(
|
|
cell,
|
|
new URL(share_url, window.location.origin).toString(),
|
|
);
|
|
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
|
}
|
|
|
|
/* Copy (shared state): re-copy the row's share_url — the per-page
|
|
clipboard + fallback helper; the live region lands the outcome. */
|
|
async function copyRowShareLink(chat, cell) {
|
|
const copied = await copyShareLink(
|
|
cell,
|
|
new URL(chat.share_url, window.location.origin).toString(),
|
|
);
|
|
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
|
}
|
|
|
|
/* The confirmed unshare: POST /api/chats/<id>/unshare → the token is
|
|
NULL (revoked — the public link 404s from now on), the cell
|
|
re-renders to the unshared state (Create link) and the live region
|
|
gets `Unshared "<title>".` A non-2xx / a network error keeps the
|
|
shared state (restoreShared — Copy + Unshare, retryable) and lands
|
|
the error line. */
|
|
async function confirmUnshare(chat, cell, yesBtn, restoreShared) {
|
|
yesBtn.disabled = true; // no double-fire while the request is in flight
|
|
let r;
|
|
try {
|
|
r = await fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" });
|
|
} catch {
|
|
announce(`Couldn't unshare "${chat.title}" — is the app reachable?`);
|
|
restoreShared();
|
|
return;
|
|
}
|
|
if (!r.ok) {
|
|
announce(`Couldn't unshare "${chat.title}" — try again.`);
|
|
restoreShared();
|
|
return;
|
|
}
|
|
chat.share_url = null; // revoked: the row is unshared again
|
|
renderShareUnshared(chat, cell);
|
|
announce(`Unshared "${chat.title}".`);
|
|
}
|
|
|
|
/* The empty-state row reappears exactly when the last data row was
|
|
removed (the empty row itself ships in the tbody, hidden). */
|
|
function showEmptyIfLast() {
|
|
if (!emptyRow || !tbody) return;
|
|
emptyRow.hidden = tbody.querySelectorAll("tr").length > 1;
|
|
}
|
|
|
|
/* 0-row fetches, non-2xx, and network failures all land on the
|
|
empty-state row (the sources.js house fallback — the safe state
|
|
in every case). */
|
|
function showEmptyState() {
|
|
if (!tbody) return;
|
|
tbody.replaceChildren(emptyRow);
|
|
if (emptyRow) emptyRow.hidden = false;
|
|
}
|
|
|
|
/* GET /api/chats → render the rows (latest activity first — the
|
|
server's order). A 0-row fetch shows the empty-state row. */
|
|
async function loadChats() {
|
|
if (emptyRow) emptyRow.hidden = true;
|
|
let r;
|
|
try {
|
|
r = await fetch("/api/chats");
|
|
} catch {
|
|
showEmptyState();
|
|
return;
|
|
}
|
|
if (!r.ok) {
|
|
showEmptyState();
|
|
return;
|
|
}
|
|
const { chats } = await r.json();
|
|
if (!chats.length) {
|
|
showEmptyState();
|
|
return;
|
|
}
|
|
for (const chat of chats) {
|
|
tbody.appendChild(makeRow(chat));
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
// Phase 19/34: the shared header first (whoami + nav reveal + the
|
|
// steering panel) — the whoami promise is cached, so the gate below
|
|
// reuses the SAME single /api/whoami request.
|
|
await initSharedHeader();
|
|
if (!(await fetchIsAdmin())) {
|
|
// Anonymous: the gate in, the table out — and NO /api/chats
|
|
// request at all: the router 403s anonymous, so the page must
|
|
// never call it (the story E2E pins the request log).
|
|
if (tableWrap) tableWrap.hidden = true;
|
|
if (gateEl) gateEl.hidden = false;
|
|
return;
|
|
}
|
|
if (gateEl) gateEl.hidden = true;
|
|
loadChats();
|
|
})();
|