feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare
This commit is contained in:
+211
-5
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user