feat(kb): edit + re-embed document summaries from the viewer (admin)
This commit is contained in:
@@ -51,6 +51,15 @@
|
||||
* doc.summary renders as a labeled .doc-summary section above the
|
||||
* original content on BOTH surfaces (page + modal) through this one
|
||||
* core; the summary text is a text node (XSS contract unchanged).
|
||||
*
|
||||
* Phase 57 (task 02, D4): the panel gains an ADMIN-ONLY edit
|
||||
* affordance (docAdminReady() gate on the module-cached whoami): an
|
||||
* Edit button in the panel's header row opens an inline editor
|
||||
* (prefilled textarea + Save/Cancel + a role=status live region), and
|
||||
* Save PATCHes /api/documents/summary. The section is built for
|
||||
* everyone exactly as phase 36 — the public viewer stays
|
||||
* byte-for-byte identical (no button, no wiring, no admin-only
|
||||
* network call) until the admin gate resolves true.
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
@@ -125,6 +134,14 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
body.textContent = doc.summary; // text node — XSS contract unchanged
|
||||
section.append(title, body);
|
||||
contentEl.appendChild(section);
|
||||
// Phase 57 (task 02, D4): the section above is the phase-36 shape
|
||||
// for EVERYONE — only an authenticated admin (docAdminReady(), the
|
||||
// module-cached whoami promise) then gains the header row + Edit
|
||||
// button + editor wiring. Anonymous / fetch failure: the panel is
|
||||
// exactly what phase 36 built (byte-for-byte unchanged).
|
||||
void docAdminReady().then((admin) => {
|
||||
if (admin) wireSummaryEdit(section, doc);
|
||||
});
|
||||
}
|
||||
if (doc.format === "md" || doc.format === "markdown") {
|
||||
const wrap = document.createElement("div");
|
||||
@@ -139,6 +156,146 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- summary editing (phase 57, task 02 — D4, admin-only) ----------
|
||||
* The .doc-summary panel is the ONE place the stored summary is edited
|
||||
* (page + modal through this core). Only an admin (docAdminReady) ever
|
||||
* gets the affordance; the public viewer is byte-for-byte unchanged. */
|
||||
|
||||
/* The admin gate (D4 — the viewer stays public): the module-cached
|
||||
* /api/whoami promise (header.js's fetchIsAdmin — the SAME single
|
||||
* request per page the shared header already makes on every surface,
|
||||
* so this adds no request of its own). A non-admin or any fetch
|
||||
* failure resolves false → the anonymous viewer. */
|
||||
async function docAdminReady() {
|
||||
try {
|
||||
return (await fetchIsAdmin()) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* The edit affordance on one rendered .doc-summary section. The bare
|
||||
* h2 becomes a header row (label left, Edit button right). Edit swaps
|
||||
* the .doc-summary-text node for the inline editor — a prefilled
|
||||
* textarea (value, never innerHTML — XSS contract), Save / Cancel,
|
||||
* and a role=status live region. Save PATCHes /api/documents/summary
|
||||
* with { source, path, summary } — the pair comes from the doc object
|
||||
* (the same values the modal core carries, document-modal.js). Success
|
||||
* re-renders the text node via textContent and announces "Summary
|
||||
* updated."; an empty save that clears announces "Summary cleared."
|
||||
* and removes the panel a short beat later — the confirmation stays
|
||||
* readable, and the renderer only draws the panel for non-empty
|
||||
* summaries. Cancel restores the text node. A failure keeps the
|
||||
* editor open with the user's text and shows neutral retry copy
|
||||
* (phase-55 convention). */
|
||||
function wireSummaryEdit(section, doc) {
|
||||
const title = section.querySelector(".doc-summary-title");
|
||||
const body = section.querySelector(".doc-summary-text");
|
||||
if (!title || !body) return;
|
||||
|
||||
/* Header row: label left, Edit button right (admin-only — the
|
||||
* anonymous section keeps its bare h2). */
|
||||
const head = document.createElement("div");
|
||||
head.className = "doc-summary-head";
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.type = "button";
|
||||
editBtn.className = "doc-summary-edit";
|
||||
editBtn.textContent = "Edit";
|
||||
head.append(title, editBtn);
|
||||
section.replaceChildren(head, body);
|
||||
|
||||
/* Editor parts (built once; the textarea is rebuilt on every open so
|
||||
* it always starts from the CURRENT stored summary). */
|
||||
let editor = null;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "doc-summary-actions";
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "button";
|
||||
saveBtn.className = "doc-summary-save";
|
||||
saveBtn.textContent = "Save";
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "doc-summary-cancel";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
actions.append(saveBtn, cancelBtn);
|
||||
const status = document.createElement("p");
|
||||
status.className = "doc-summary-status";
|
||||
status.setAttribute("role", "status");
|
||||
status.setAttribute("aria-live", "polite");
|
||||
|
||||
/* Back to the display state: the text node re-rendered from the
|
||||
* doc object (the CURRENT stored summary), the live region (the
|
||||
* announced message), the Edit button available again. If the
|
||||
* summary is gone (a clear landed while the editor was open — e.g.
|
||||
* Cancel right after a successful empty save) the panel is gone
|
||||
* too: the renderer only draws it for non-empty summaries. */
|
||||
function closeEditor(message) {
|
||||
status.textContent = message;
|
||||
editBtn.hidden = false;
|
||||
if (typeof doc.summary !== "string" || doc.summary.trim() === "") {
|
||||
section.remove();
|
||||
return;
|
||||
}
|
||||
body.textContent = doc.summary; // text node — the CURRENT stored summary
|
||||
section.replaceChildren(head, body, status);
|
||||
editBtn.focus();
|
||||
}
|
||||
|
||||
async function saveSummary() {
|
||||
const value = editor.value;
|
||||
saveBtn.disabled = true; // one PATCH at a time (never stale)
|
||||
status.textContent = "";
|
||||
try {
|
||||
const res = await fetch("/api/documents/summary", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: doc.source, path: doc.path, summary: value }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Neutral retry copy (phase-55 convention) — the user's text
|
||||
// stays in the editor (the editor stays open on failure).
|
||||
status.textContent = "Couldn't update the summary — try again.";
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.summary === null) {
|
||||
// Cleared (D4): the panel disappears — the renderer only draws
|
||||
// it for non-empty summaries. The live-region confirmation
|
||||
// stays visible for a short beat before the panel leaves the
|
||||
// DOM (a screen reader must be able to read it; the removal
|
||||
// is a no-op if the surface re-rendered or closed meanwhile).
|
||||
doc.summary = null;
|
||||
status.textContent = "Summary cleared.";
|
||||
setTimeout(() => section.remove(), 2000);
|
||||
return;
|
||||
}
|
||||
doc.summary = data.summary;
|
||||
closeEditor("Summary updated.");
|
||||
} catch {
|
||||
// Network failure: same neutral shape, the reachable? copy.
|
||||
status.textContent = "Couldn't update the summary — is the app reachable?";
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
editor = document.createElement("textarea");
|
||||
editor.className = "doc-summary-editor";
|
||||
editor.value = typeof doc.summary === "string" ? doc.summary : ""; // value, never innerHTML
|
||||
status.textContent = "";
|
||||
editBtn.hidden = true;
|
||||
section.replaceChildren(head, editor, actions, status);
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
editBtn.addEventListener("click", openEditor);
|
||||
saveBtn.addEventListener("click", () => {
|
||||
void saveSummary();
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => closeEditor(""));
|
||||
}
|
||||
|
||||
/* ---------- /document.html page (phases 10/13/19) ----------
|
||||
* Phase 26: viewer-page-specific — see the import-safety note in the
|
||||
* header. The guard is #doc-title: it exists only on this page, so the
|
||||
|
||||
@@ -2382,6 +2382,97 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
color: var(--ink); /* on --surface ≈14.5:1 */
|
||||
}
|
||||
|
||||
/* Summary edit affordance (phase 57, task 02 — D4, admin-only): the
|
||||
header row (label + Edit button), the inline editor (prefilled
|
||||
textarea + Save/Cancel + a role=status live region). House
|
||||
dark-tech palette (phase-08 tokens), system fonts, no CDN;
|
||||
:focus-visible via the global 3px outline rule. Anonymous visitors
|
||||
never see any of it — the button and editor are wired only for
|
||||
admins (docAdminReady in document.js), so the public panel is
|
||||
byte-for-byte the phase-36 shape. */
|
||||
.doc-summary-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem; /* the old .doc-summary-title bottom margin */
|
||||
}
|
||||
.doc-summary-head .doc-summary-title { margin: 0; }
|
||||
.doc-summary-edit {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0.15rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-summary-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
|
||||
.doc-summary-edit[hidden] { display: none; } /* the hidden attr must beat the display above */
|
||||
.doc-summary-editor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 8rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg); /* inset against the --surface panel */
|
||||
color: var(--ink); /* 16.7:1 on --bg (AA) */
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
.doc-summary-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.doc-summary-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0.35rem 0.95rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* --bg on --brand = 5.2:1 (AA) */
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-summary-save:hover { background: #f55a72; } /* the house hover lightening */
|
||||
.doc-summary-save:disabled { opacity: 0.6; cursor: default; } /* one PATCH at a time */
|
||||
.doc-summary-cancel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0.35rem 0.95rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-summary-cancel:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.doc-summary-status {
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
|
||||
}
|
||||
.doc-summary-status:empty { margin-top: 0; }
|
||||
|
||||
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
|
||||
.doc-raw {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user