feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/* 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` + `DELETE /api/chats/<id>`
|
||||
* endpoints (phase 50 task 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;
|
||||
* • Actions — Delete ONLY (phase 51 adds the share column), 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.
|
||||
*
|
||||
* 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);
|
||||
|
||||
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}".`);
|
||||
}
|
||||
|
||||
/* 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user