Files
brain-of-reese/frontend/assets/tuning.js
T

350 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Brain of Reese — Global Tuning page (phase 27, task 03).
*
* The standalone manager for steering notes: create / list / edit /
* delete WITHOUT a chat conversation. This module is the single owner
* of the page's behaviour:
*
* • loadNotes() — GET /api/steering → the newest-first note list
* (#tune-list) + the empty state. A failed fetch (API down, or the
* anonymous direct-URL 403) keeps the LAST RENDERED list —
* progressive enhancement, never a blanked panel.
* • create — #tune-form submit → POST /api/steering. 201 clears the
* textarea, announces through the live region, and reloads the
* list; any failure keeps the form (the instruction survives) and
* shows the API detail inline under the button (role=alert).
* #tune-save is disabled while the request is out.
* • edit — a row's Edit button swaps the text for an inline
* .tuning-edit-form: a prefilled textarea (maxlength 2000) +
* Save / Cancel. Save → PUT /api/steering/{id}; 200 replaces the
* form with the .tuning-saved status (role=status) and announces;
* a failure keeps the form + an inline error; Cancel reverts to
* the text span. The note id rides on the form (data attribute).
* • delete — DELETE /api/steering/{id}. 204 removes the row
* immediately (optimistic) and announces; 404 also drops the row
* and reloads to resync; other failures re-enable the button and
* announce a retry. The empty state is re-checked on every removal.
* • announce(msg) — #tune-announcer (role=status, aria-live=polite),
* the screen-reader confirmation for create / edit / delete.
* • header boot (task 02) — initSharedHeader(): Sign in / Sign out,
* the admin-only Sources link, and this page's own admin-only
* "Tuning" nav link (#nav-tuning), all decided by the module's
* cached whoami promise (exactly one /api/whoami request per
* page); plus the non-chat New chat binding — "new chat" means
* going to the chat, fresh (clear the phase-14 conversation key,
* then navigate to "/"), the same contract as sources.js /
* document.js.
*
* Anonymous-safe (task 03): the header already hides the "Tuning" nav
* link for anonymous visitors; a DIRECT anonymous URL still gets a safe
* page — loadNotes() only runs when the cached whoami says admin (the
* Sources page gate pattern), the list stays on its empty state, and
* the create form 403s gracefully on submit (the inline error carries
* the API detail). Note text is always rendered with textContent —
* never innerHTML (XSS-safe, like app.js's steering panel).
*
* The shared header module loads through this script's own relative
* import ("./header.js") — a hoisted import evaluated before this body
* runs (single-evaluation design: no direct <script> tag; esbuild
* inlines it into the page bundle in the image build).
*/
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
/* ---------- page elements (tuning.html, task 02) ---------- */
const tuneForm = document.querySelector("#tune-form");
const tuneNote = document.querySelector("#tune-note");
const tuneSave = document.querySelector("#tune-save");
const tuneList = document.querySelector("#tune-list");
const tuneEmpty = document.querySelector("#tune-empty");
const tuneAnnouncer = document.querySelector("#tune-announcer");
/* The create form's inline error (role=alert) — created once, hidden
by default, and kept between attempts: a failed POST keeps the form
AND its message until the next submit. */
const createError = document.createElement("p");
createError.className = "tuning-error";
createError.setAttribute("role", "alert");
createError.hidden = true;
if (tuneForm) tuneForm.appendChild(createError);
/* Polite live region: the screen-reader confirmation for create /
edit / delete (task 03). */
function announce(message) {
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
}
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
carry their own labels), the same marks as app.js's steering panel. */
const EDIT_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.2-4.2L16.7 4.3a2.1 2.1 0 0 1 3 3L8.2 18.8 4 20Z"/><path d="M14.7 6.3l3 3"/></svg>';
const DELETE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
/* FastAPI error bodies: a string detail or the validation-error array
(the first entry's msg is the human line). Same extraction as app.js. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* ---------- load / render (newest first — the API's list order) ---------- */
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
an anonymous direct-URL visit) keeps the last rendered list —
progressive enhancement, never a blanked panel. */
async function loadNotes() {
let r;
try {
r = await fetch("/api/steering");
} catch {
return; // API unreachable: keep the last rendered list
}
if (!r.ok) return; // e.g. anonymous 403: keep the last rendered list
let notes;
try {
notes = (await r.json()).notes || [];
} catch {
return; // corrupt body: keep the last rendered list
}
renderNotes(notes);
}
function renderNotes(notes) {
if (!tuneList) return;
tuneList.textContent = "";
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
syncEmptyState(notes.length);
}
/* The empty state tracks the list's rendered rows (the HTML ships on
the "No tuning notes yet" text; it hides as soon as one row shows). */
function syncEmptyState(count) {
if (!tuneEmpty || !tuneList) return;
const rows = typeof count === "number" ? count : tuneList.children.length;
tuneEmpty.hidden = rows > 0;
}
/* One list row: the note text (textContent — XSS-safe, never
innerHTML) + the Edit and Delete buttons. */
function makeNoteRow(n) {
const li = document.createElement("li");
li.className = "tuning-note";
const text = document.createElement("span");
text.className = "tuning-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "tuning-edit";
editBtn.innerHTML = EDIT_ICON + "<span>Edit</span>";
editBtn.addEventListener("click", () => openEditForm(li, n));
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "tuning-delete";
delBtn.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
delBtn.innerHTML = DELETE_ICON + "<span>Delete</span>";
delBtn.addEventListener("click", () => deleteNote(n.id, delBtn, li));
li.append(editBtn, delBtn);
return li;
}
/* ---------- create (POST /api/steering) ---------- */
if (tuneForm) {
tuneForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (tuneSave) tuneSave.disabled = true; // one note per click
createError.hidden = true;
try {
const r = await fetch("/api/steering", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: tuneNote ? tuneNote.value : "" }),
});
if (r.ok) {
if (tuneNote) tuneNote.value = ""; // 201: the note is stored
announce("Tuning note added. Future answers will follow it.");
await loadNotes(); // the new note lands in the list, newest first
} else {
createError.textContent = await apiDetail(r, "Could not add the note — try again.");
createError.hidden = false; // form kept — the instruction survives
}
} catch {
createError.textContent = "Could not add the note — is the app reachable?";
createError.hidden = false;
} finally {
if (tuneSave) tuneSave.disabled = false;
}
});
}
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
* The row swaps to the inline form — the is-editing class does the
* visual swap (styles.css hides the text + the row buttons). One open
* form page-wide: opening a new one reverts the others. Cancel reverts
* to the text span; a failed save keeps the form + the inline error.
*/
let editSeq = 0; // unique ids for the edit forms' labeled textareas
function openEditForm(li, n) {
if (li.classList.contains("is-editing")) return; // one per row
// One open form page-wide: close any other row's first.
document.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
other.classList.remove("is-editing");
other.querySelector(".tuning-edit-form")?.remove();
});
li.querySelector(".tuning-saved")?.remove(); // a stale "Saved" pill
li.classList.add("is-editing");
editSeq += 1;
const inputId = `tuning-edit-input-${editSeq}`;
const form = document.createElement("form");
form.className = "tuning-edit-form";
form.dataset.noteId = n.id; // the note id rides on the form
const label = document.createElement("label");
label.className = "visually-hidden";
label.htmlFor = inputId;
label.textContent = `Edit tuning note: ${n.note}`;
const textarea = document.createElement("textarea");
textarea.id = inputId;
textarea.className = "tuning-edit-input";
textarea.rows = 2;
textarea.maxLength = 2000; // client-side 1–2000 contract (server re-validates)
textarea.required = true;
textarea.value = n.note; // prefilled with the current text
const actions = document.createElement("div");
actions.className = "tuning-edit-form-actions";
const saveBtn = document.createElement("button");
saveBtn.type = "submit";
saveBtn.className = "tune-save";
saveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "tune-cancel";
cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn);
const error = document.createElement("p");
error.className = "tuning-error";
error.setAttribute("role", "alert");
error.hidden = true;
form.append(label, textarea, actions, error);
form.addEventListener("submit", (e) => handleEditSave(e, li, form, textarea, saveBtn, error));
cancelBtn.addEventListener("click", () => {
li.classList.remove("is-editing"); // revert to the text span
form.remove();
li.querySelector(".tuning-edit")?.focus();
});
li.insertBefore(form, li.querySelector(".tuning-edit"));
textarea.focus();
}
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
e.preventDefault();
saveBtn.disabled = true;
error.hidden = true;
const id = form.dataset.noteId;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: textarea.value }),
});
if (r.ok) {
let saved = textarea.value.trim();
try {
saved = (await r.json()).note ?? saved;
} catch {
/* keep the trimmed local text */
}
const textEl = li.querySelector(".tuning-note-text");
if (textEl) textEl.textContent = saved; // the list shows the stored text
const savedPill = document.createElement("p");
savedPill.className = "tuning-saved";
savedPill.setAttribute("role", "status");
savedPill.textContent = "Saved";
form.replaceWith(savedPill);
li.classList.remove("is-editing"); // updated text + row buttons come back
announce("Tuning note updated.");
return;
}
error.textContent = await apiDetail(r, "Could not update the note — try again.");
error.hidden = false; // form kept — the edit survives the failure
saveBtn.disabled = false;
} catch {
error.textContent = "Could not update the note — is the app reachable?";
error.hidden = false;
saveBtn.disabled = false;
}
}
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
* The row leaves the DOM the moment the server agrees (204); a 404
* (already gone) also drops the row and reloads to resync; any other
* failure re-enables the button and says to retry. */
async function deleteNote(id, btn, li) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
li.remove(); // already gone on the server — drop it and resync
syncEmptyState();
announce("That note was already removed.");
await loadNotes();
return;
}
if (!r.ok) {
announce("Could not delete the note — try again.");
btn.disabled = false;
return;
}
li.remove(); // 204: the server confirmed — the row goes now
syncEmptyState();
announce("Tuning note deleted.");
} catch {
announce("Could not delete the note — is the app reachable?");
btn.disabled = false;
}
}
/* ---------- non-chat New chat + header boot (task 02) ---------- */
/* Phase 19 contract: 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 (the same contract as sources.js / document.js). */
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
clearChatStorage();
window.location.href = "/";
});
}
/* Boot: the shared header FIRST (Sign in/out + the admin-only nav
links — one cached whoami), then the note list — admin data only
(the Sources page gate pattern): an anonymous visitor gets the page
frame with the empty state, and the create form 403s gracefully on
submit if one tries. */
(async () => {
await initSharedHeader(); // phase 19: whoami + Sign in/out + nav links
if (await fetchIsAdmin()) loadNotes(); // phase 27: the list is admin-only
})();