feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare

This commit is contained in:
2026-08-30 01:34:44 -04:00
parent ece93a7c8f
commit 114b115034
28 changed files with 3442 additions and 54 deletions
+211 -5
View File
@@ -3,21 +3,40 @@
* 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:
* 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;
* • 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
* • 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
@@ -86,6 +105,13 @@ function makeRow(chat) {
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));
@@ -169,6 +195,186 @@ async function confirmDelete(chat, row, yesBtn, restoreDelete) {
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() {