various fixes
This commit is contained in:
+57
-476
@@ -16,9 +16,6 @@
|
||||
* "no nav" bar is gone. The links SHIP hidden in the HTML
|
||||
* (anonymous-safe default — the phase-16 "absent, not hidden"
|
||||
* spirit), so no anonymous user ever sees one for a frame; and
|
||||
* the "Sync sources" button (#sync-btn, phase 32 — every page
|
||||
* from phase 34 task 03) — the same ship-hidden / reveal-for-admin
|
||||
* contract on the SAME cached whoami (one fetch, no extra request);
|
||||
* • the sign-out click binding (POST /api/logout → reload) — moved
|
||||
* here from app.js so there is exactly one implementation;
|
||||
* • the mobile hamburger binding (phase 46, owner permission
|
||||
@@ -33,43 +30,28 @@
|
||||
* page without either element is a no-op. The binding toggles
|
||||
* ONLY the container — the nav links keep their ship-hidden
|
||||
* whoami contract (hidden links stay hidden inside the menu);
|
||||
* • the steering-notes controls (phase 15, moved here from app.js in
|
||||
* phase 34) — the #steering-toggle open/close + the #steering-panel
|
||||
* list (newest-first, textContent-rendered, per-note delete, count
|
||||
* badge, the #steering-announcer live region) — so the toggle can
|
||||
* sit in every page's header with zero page-script duplication.
|
||||
* The toggle SHIPS hidden in every page (phase 40, 2026-08-27,
|
||||
* TODO.md L3 — the exact ship-hidden / reveal-for-admin contract
|
||||
* the admin-only nav links use: anonymous never sees it for a
|
||||
* single frame) and initSharedHeader unhides it only when whoami
|
||||
* says admin. refreshSteering() / announceSteering() are exported
|
||||
* for the chat page's per-bubble Tune form (which stays in app.js);
|
||||
* anonymous visitors get the phase-16 "absent, not hidden"
|
||||
* treatment (toggle + panel removed from the DOM, /api/steering
|
||||
* never fetched);
|
||||
* • the Sync sources state machine (phase 32, moved here from
|
||||
* sources.js in phase 34 task 02) — the §7.4 never-stale lifecycle
|
||||
* for #sync-btn (idle → running → success | failed): admin-only
|
||||
* boot re-attach on the SAME cached whoami (non-admins never poll),
|
||||
* POST /api/sync (202 start / 409 adopt), the 2 s
|
||||
* GET /api/sync/status poll (one live timer, NO client-side hard
|
||||
* timeout — the server state is authoritative). Every state change
|
||||
* dispatches window "bor:sync-status" (detail = the status object)
|
||||
* so the Sources page renders its #sync-result line +
|
||||
* #sync-error-banner off the event; the button title/aria are the
|
||||
* secondary failure surfaces — and a failed run ALSO opens the
|
||||
* module-owned error modal (phase 41, 2026-08-27, TODO.md L4, the
|
||||
* primary readable failure surface): lazily built by this module,
|
||||
* appended to <body>, error text textContent-rendered, closable via
|
||||
* its button / Esc / backdrop, focus in-and-out to #sync-btn —
|
||||
* every page carrying #sync-btn gets it with zero page-markup
|
||||
* changes;
|
||||
* • the steering-notes panel (phase 15, moved here from app.js in
|
||||
* phase 34) — the #steering-panel list (newest-first,
|
||||
* textContent-rendered, per-note delete) + the #steering-announcer
|
||||
* live region — so every page that ships the panel markup gets
|
||||
* exactly this behavior with zero page-script duplication. The
|
||||
* navbar #steering-toggle was REMOVED at owner request
|
||||
* (2026-08-28) — note management now lives on /tuning.html — so
|
||||
* the panel ships hidden on every page and is only kept fresh,
|
||||
* never opened from the header. refreshSteering() /
|
||||
* announceSteering() are exported for the chat page's per-bubble
|
||||
* Tune form (which stays in app.js); anonymous visitors get the
|
||||
* phase-16 "absent, not hidden" treatment (panel removed from the
|
||||
* DOM, /api/steering never fetched);
|
||||
* • the SINGLE New chat binding (phase 34 task 02 — it was
|
||||
* duplicated across app.js / sources.js / tuning.js / document.js):
|
||||
* on the chat page (#messages exists) the module dispatches
|
||||
* window "bor:new-chat" and app.js acts (it owns the in-flight-turn
|
||||
* guard + the list reset); on every other page it means "go to the
|
||||
* chat, fresh" — clearChatStorage() + navigate to "/";
|
||||
* duplicated across app.js / sources.js / tuning.js / document.js;
|
||||
* moved from the navbar to the chat page at owner request):
|
||||
* the button now lives ONLY on the chat page (inside .chat-shell,
|
||||
* above #messages). When clicked it dispatches window
|
||||
* "bor:new-chat" and app.js acts (it owns the in-flight-turn
|
||||
* guard + the list reset).
|
||||
* • clearChatStorage() — the phase-14 conversation key, for clearing
|
||||
* the conversation when navigating away from the chat page.
|
||||
* • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader
|
||||
* points #sign-in-link at /login.html?next=<current pathname>
|
||||
* (default "/"), so the admin lands back on the page they signed in
|
||||
@@ -129,8 +111,7 @@ export async function initSharedHeader() {
|
||||
signIn.hidden = admin;
|
||||
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
|
||||
}
|
||||
const signOut = document.querySelector("#sign-out-btn");
|
||||
if (signOut) signOut.hidden = !admin;
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
|
||||
const navSources = document.querySelector("#nav-sources");
|
||||
if (navSources) navSources.hidden = !admin;
|
||||
// Phase 35 (owner permission 2026-08-26): the Git sources nav link —
|
||||
@@ -143,22 +124,15 @@ export async function initSharedHeader() {
|
||||
// contract as the Sources link.
|
||||
const navTuning = document.querySelector("#nav-tuning");
|
||||
if (navTuning) navTuning.hidden = !admin;
|
||||
// Phase 32: the "Sync sources" button — admin-only, revealed on this
|
||||
// same cached whoami (anonymous users never see it).
|
||||
if (syncBtn) syncBtn.hidden = !admin;
|
||||
// Phase 34: the steering controls (phase 15) are module-owned. Admin:
|
||||
// unhide the toggle — it SHIPS hidden (phase 40, 2026-08-27, TODO.md
|
||||
// L3, the same ship-hidden / reveal-for-admin contract as the
|
||||
// admin-only nav links) — then refresh the list so the count badge is
|
||||
// right before the panel is ever opened (fire-and-forget, as the chat
|
||||
// page did before the move). Anonymous: the toggle + panel are REMOVED
|
||||
// from the DOM entirely — the phase-16 contract says "absent", not
|
||||
// just hidden — and /api/steering is never fetched.
|
||||
// Phase 34: the steering panel (phase 15) is module-owned. The
|
||||
// navbar #steering-toggle was removed at owner request (2026-08-28)
|
||||
// — the panel ships hidden and is only kept fresh. Admin: refresh
|
||||
// the list (fire-and-forget). Anonymous: the panel is REMOVED from
|
||||
// the DOM entirely — the phase-16 contract says "absent", not just
|
||||
// hidden — and /api/steering is never fetched.
|
||||
if (admin) {
|
||||
if (steeringToggle) steeringToggle.hidden = false;
|
||||
if (steeringPanel) refreshSteering();
|
||||
} else {
|
||||
steeringToggle?.remove();
|
||||
steeringPanel?.remove();
|
||||
}
|
||||
return admin;
|
||||
@@ -177,13 +151,14 @@ export function clearChatStorage() {
|
||||
|
||||
/* Sign-out binding (phase 16 behavior, now module-owned): runs at module
|
||||
import, so every page that loads header.js gets it exactly once.
|
||||
Disable during the call, POST /api/logout (the result is ignored —
|
||||
the reload resets the UI either way), then reload so the header
|
||||
re-resolves to the anonymous state (Sign in back, Sources gone). */
|
||||
const signOutBtn = document.querySelector("#sign-out-btn");
|
||||
if (signOutBtn) {
|
||||
signOutBtn.addEventListener("click", async () => {
|
||||
signOutBtn.disabled = true;
|
||||
Binds to all .sign-out-btn elements (bar copy for desktop + mobile
|
||||
dropdown copy for ≤640px). Disable during the call, POST /api/logout
|
||||
(the result is ignored — the reload resets the UI either way), then
|
||||
reload so the header re-resolves to the anonymous state (Sign in
|
||||
back, Sources gone). */
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await fetch("/api/logout", { method: "POST" });
|
||||
} catch {
|
||||
@@ -191,7 +166,7 @@ if (signOutBtn) {
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- mobile hamburger (phase 46; module-owned) ----------
|
||||
* ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the
|
||||
@@ -216,8 +191,8 @@ if (navToggle && appNav) {
|
||||
appNav.addEventListener("click", (e) => {
|
||||
if (e.target.closest("a")) setNavMenu(false);
|
||||
});
|
||||
// Esc closes while open (document-level; the sync failure modal's
|
||||
// Esc acts only while IT is open — the two never fight for a key).
|
||||
// Esc closes while open (document-level — no other modal to fight
|
||||
// for a key).
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && appNav.classList.contains("is-open")) {
|
||||
setNavMenu(false);
|
||||
@@ -237,10 +212,12 @@ if (navToggle && appNav) {
|
||||
*
|
||||
* The owner's tuning notes steer every future answer: they live in
|
||||
* Postgres (stateless API, A10) and the chat turn reads them into the
|
||||
* system prompt. The header panel — toggle, list, per-note delete, count
|
||||
* badge, announcer — is owned by THIS module: every page that ships the
|
||||
* panel markup gets exactly this behavior, with zero page-script
|
||||
* duplication. The chat page keeps only its per-bubble Tune form
|
||||
* system prompt. The header panel — list, per-note delete, announcer —
|
||||
* is owned by THIS module: every page that ships the panel markup gets
|
||||
* exactly this behavior, with zero page-script duplication. The navbar
|
||||
* #steering-toggle was removed at owner request (2026-08-28) — note
|
||||
* management now lives on /tuning.html — so the panel ships hidden and
|
||||
* is only kept fresh. The chat page keeps only its per-bubble Tune form
|
||||
* (app.js), which refreshes the panel through refreshSteering() and
|
||||
* announces through announceSteering().
|
||||
*
|
||||
@@ -248,8 +225,6 @@ if (navToggle && appNav) {
|
||||
* that lacks the panel markup is a no-op — the same contract as
|
||||
* initSharedHeader().
|
||||
*/
|
||||
const steeringToggle = document.querySelector("#steering-toggle");
|
||||
const steeringCount = document.querySelector("#steering-count");
|
||||
const steeringPanel = document.querySelector("#steering-panel");
|
||||
const steeringList = document.querySelector("#steering-list");
|
||||
const steeringEmpty = document.querySelector("#steering-empty");
|
||||
@@ -263,10 +238,10 @@ export function announceSteering(message) {
|
||||
}
|
||||
|
||||
/* Fetch + render the note list (exported — the chat page's per-bubble
|
||||
Tune form calls it on save, so the panel + count badge update without
|
||||
owning the fetch itself). Non-2xx (the anonymous 403) or an
|
||||
unreachable API render the empty state: count badge 0, the "no notes
|
||||
yet" text visible — the safe fallback in either case. */
|
||||
Tune form calls it on save, so the panel updates without owning the
|
||||
fetch itself). Non-2xx (the anonymous 403) or an unreachable API
|
||||
render the empty state: the "no notes yet" text visible — the safe
|
||||
fallback in either case. */
|
||||
export async function refreshSteering() {
|
||||
let notes = [];
|
||||
try {
|
||||
@@ -281,8 +256,7 @@ export async function refreshSteering() {
|
||||
|
||||
/* Newest-first list — the note is ALWAYS rendered with textContent
|
||||
(XSS-safe, never innerHTML), a per-note Remove button with a labeled
|
||||
aria-label, the empty text toggled on notes.length, and the header
|
||||
count badge. */
|
||||
aria-label, and the empty text toggled on notes.length. */
|
||||
function renderSteeringPanel(notes) {
|
||||
if (!steeringList) return;
|
||||
steeringList.textContent = "";
|
||||
@@ -304,7 +278,6 @@ function renderSteeringPanel(notes) {
|
||||
steeringList.appendChild(li);
|
||||
}
|
||||
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
|
||||
if (steeringCount) steeringCount.textContent = String(notes.length);
|
||||
}
|
||||
|
||||
/* Per-note delete: disable the row button (no double-fire), DELETE
|
||||
@@ -334,412 +307,20 @@ async function deleteSteeringNote(id, btn) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Open/close the panel, keeping the toggle's aria-expanded in sync —
|
||||
the exact phase-15 chat-page contract (open on click, close on click;
|
||||
the panel itself is a plain region — no Esc / outside-click close in
|
||||
the original, so none here). Re-opening refreshes the list, so notes
|
||||
changed elsewhere (the Tuning page, another tab) show up. */
|
||||
function setSteeringPanel(open) {
|
||||
if (!steeringPanel || !steeringToggle) return;
|
||||
steeringPanel.hidden = !open;
|
||||
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
/* Toggle binding (module-owned, like the sign-out binding): runs at
|
||||
module import, so a page with the toggle markup gets exactly one
|
||||
implementation. */
|
||||
if (steeringToggle && steeringPanel) {
|
||||
steeringToggle.addEventListener("click", () => {
|
||||
setSteeringPanel(steeringPanel.hidden);
|
||||
if (!steeringPanel.hidden) refreshSteering(); // refresh when (re)opened
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- New chat (the SINGLE binding — module-owned from phase 34
|
||||
* task 02) ----------
|
||||
* task 02; moved from navbar to chat page at owner request) ----------
|
||||
*
|
||||
* The binding used to be duplicated across app.js / sources.js /
|
||||
* tuning.js / document.js with the same page-kind branch. It lives here
|
||||
* exactly once (module import, like the sign-out binding): on the chat
|
||||
* page (#messages exists) the module dispatches window "bor:new-chat"
|
||||
* and app.js acts — the chat script owns the in-flight-turn guard and
|
||||
* the rendered-list reset; on every other page "new chat" means go to
|
||||
* the chat, fresh: clear the phase-14 conversation key, then navigate
|
||||
* to "/" (its empty state, since the conversation is gone from storage).
|
||||
* tuning.js / document.js. It lives here exactly once (module import,
|
||||
* like the sign-out binding). The button now lives ONLY on the chat
|
||||
* page (inside .chat-shell, above #messages), so the click always
|
||||
* dispatches "bor:new-chat" — app.js acts (it owns the in-flight-turn
|
||||
* guard and the rendered-list reset).
|
||||
*/
|
||||
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||
if (newChatBtn) {
|
||||
newChatBtn.addEventListener("click", () => {
|
||||
if (document.querySelector("#messages")) {
|
||||
window.dispatchEvent(new CustomEvent("bor:new-chat"));
|
||||
return;
|
||||
}
|
||||
clearChatStorage();
|
||||
window.location.href = "/";
|
||||
window.dispatchEvent(new CustomEvent("bor:new-chat"));
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- sync sources (phase 32; module-owned from phase 34 task 02) ----------
|
||||
*
|
||||
* The "never stale" lifecycle for the long background sync job, moved
|
||||
* here from sources.js so the SAME #sync-btn markup on ANY page (phase
|
||||
* 34 task 03) behaves identically. The button is the module's; the
|
||||
* Sources page's #sync-result line + #sync-error-banner render off the
|
||||
* "bor:sync-status" event this machine dispatches (sources.js
|
||||
* subscribes):
|
||||
*
|
||||
* idle → click → POST /api/sync
|
||||
* 202 → running (disabled, aria-busy, spinning icon, "Syncing…")
|
||||
* + a 2 s poll of GET /api/sync/status;
|
||||
* 409 → the in-flight run is ADOPTED the same way (one sync
|
||||
* at a time, one poll loop at a time);
|
||||
* success → "Synced HH:MM"; failed → retry-ready "Sync sources"
|
||||
* + the sanitized error in the button's title +
|
||||
* aria-label + the module-owned error modal (phase 41,
|
||||
* 2026-08-27, TODO.md L4 — the primary readable failure
|
||||
* surface on every page; the button affordance and the
|
||||
* Sources banner stay the secondary surfaces).
|
||||
*
|
||||
* Boot (admin only — non-admins never poll, the status endpoint is
|
||||
* admin-only): one GET /api/sync/status on the SAME cached whoami —
|
||||
* running re-enters the running state (reload mid-sync), a terminal
|
||||
* state renders its last result. NO client-side hard timeout (phase 32
|
||||
* locked decision): a sync can legitimately outlive the page, so the
|
||||
* 2 s poll is the feedback loop and the server state is authoritative.
|
||||
*
|
||||
* All elements are looked up null-safe: a page that doesn't (yet) carry
|
||||
* the #sync-btn markup is a complete no-op, exactly like the rest of
|
||||
* this module.
|
||||
*/
|
||||
const syncBtn = document.querySelector("#sync-btn");
|
||||
const syncLabel = document.querySelector("#sync-label");
|
||||
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||||
|
||||
const SYNC_POLL_MS = 2000; // the 2 s status poll (phase 32 contract)
|
||||
let syncPollTimer = null; // at most ONE live poll loop
|
||||
let lastSyncState = null; // the last state emitted on bor:sync-status
|
||||
|
||||
/* The module → page channel: detail is the GET /api/sync/status object
|
||||
(or the synthetic { state: "running" } frame the click path emits
|
||||
before the first poll tick — the Sources handlers only need the
|
||||
state, the next real object carries the full fields). */
|
||||
function emitSyncStatus(status) {
|
||||
lastSyncState = status.state;
|
||||
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
|
||||
}
|
||||
|
||||
function stopSyncPolling() {
|
||||
if (syncPollTimer !== null) {
|
||||
clearTimeout(syncPollTimer);
|
||||
syncPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* The local HH:MM of finished_at — 24-hour, locale-independent, so the
|
||||
* "Synced 14:32" last-result label is deterministic. */
|
||||
function fmtSyncTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/* The last-result line for the Sources page's #sync-result (aria-live).
|
||||
* "added" is ALWAYS announced (the run's headline term); "updated" /
|
||||
* "pruned" only when they happened (zero terms omitted); "unchanged"
|
||||
* whenever it is non-zero — or whenever nothing was added or updated,
|
||||
* so a no-op re-sync reads "0 added · 1 unchanged" instead of an empty
|
||||
* live region (the story gate's idempotency check). Exported so the
|
||||
* Sources page renders the counts from ONE implementation. */
|
||||
export function fmtSyncResult(detail) {
|
||||
const d = detail || {};
|
||||
const added = d.added || 0;
|
||||
const updated = d.updated || 0;
|
||||
const parts = [`${added} added`];
|
||||
if (updated > 0) parts.push(`${updated} updated`);
|
||||
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
|
||||
parts.push(`${d.unchanged || 0} unchanged`);
|
||||
}
|
||||
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/* The failed-state affordance text for the button's title + aria-label
|
||||
* (non-Sources pages: that is where the failure is visible). The server
|
||||
* already masks any embedded credentials (sync.py _sanitize_error);
|
||||
* here the string is collapsed to a single line and capped so a chatty
|
||||
* git stderr can't bloat the attributes. */
|
||||
function sanitizeSyncError(message) {
|
||||
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
|
||||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||||
}
|
||||
|
||||
/* §7.4 running state: disabled + aria-busy + spinning icon + the
|
||||
* "Syncing…" label — and a fresh run starts clean: the previous
|
||||
* failure's affordances (title / aria-label / .is-error) come off NOW,
|
||||
* not when the run settles. The button only — the Sources page's
|
||||
* result line / banner clear off the matching "running" event (no
|
||||
* 2 s lag). */
|
||||
function enterSyncRunningState() {
|
||||
if (!syncBtn) return;
|
||||
syncBtn.disabled = true;
|
||||
syncBtn.setAttribute("aria-busy", "true");
|
||||
syncBtn.removeAttribute("title");
|
||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||
syncBtn.classList.remove("is-error");
|
||||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||||
if (syncLabel) syncLabel.textContent = "Syncing…";
|
||||
}
|
||||
|
||||
/* Settle the button back to clickable + un-spun with the given label,
|
||||
* dropping the failed-state affordances (a fresh run starts clean). */
|
||||
function settleSyncButton(label) {
|
||||
if (!syncBtn) return;
|
||||
syncBtn.disabled = false;
|
||||
syncBtn.removeAttribute("aria-busy");
|
||||
syncBtn.removeAttribute("title");
|
||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||
syncBtn.classList.remove("is-error");
|
||||
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
||||
if (syncLabel) syncLabel.textContent = label;
|
||||
}
|
||||
|
||||
function applySyncSuccess(status) {
|
||||
const time = fmtSyncTime(status.finished_at);
|
||||
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
||||
emitSyncStatus(status);
|
||||
}
|
||||
|
||||
function applySyncFailure(status) {
|
||||
const error = sanitizeSyncError(status.error);
|
||||
settleSyncButton("Sync sources"); // retry-ready
|
||||
if (syncBtn) {
|
||||
// The failed look: error text in title + aria-label (and the
|
||||
// .is-error class for the non-Sources pages' visible error state).
|
||||
syncBtn.title = error;
|
||||
syncBtn.setAttribute("aria-label", error);
|
||||
syncBtn.classList.add("is-error");
|
||||
}
|
||||
emitSyncStatus(status);
|
||||
// Phase 41 (2026-08-27, TODO.md L4): the module-owned error modal —
|
||||
// the primary readable failure surface (the button title/aria and the
|
||||
// Sources banner above stay as the secondary surfaces).
|
||||
showSyncModal(error);
|
||||
}
|
||||
|
||||
/* ---------- sync failure modal (phase 41, 2026-08-27, TODO.md L4) ----------
|
||||
*
|
||||
* A tooltip on the button is not a readable error — a failed sync ALSO
|
||||
* opens a modal dialog. It is built by THIS module (the owner of the
|
||||
* sync state machine), so every page carrying #sync-btn gets it with
|
||||
* zero page-markup changes: created lazily ONCE (module-level
|
||||
* `syncModal`) and appended to <body> — a .sync-modal-backdrop (fixed,
|
||||
* full-viewport dim) holding the .sync-modal panel
|
||||
* (role="alertdialog", aria-modal, labelled + described). The error
|
||||
* text is ALWAYS set via textContent (XSS-safe — never innerHTML with
|
||||
* user data); a second failure while open updates the text IN PLACE
|
||||
* (no stacking). Closes via the close button, Esc (ONE document
|
||||
* keydown binding, acting only while open), or a click on the backdrop
|
||||
* itself (never the panel); focus moves to the close button on open
|
||||
* and back to the remembered element (#sync-btn — the control that
|
||||
* started the run) on close. Null-safe: no #sync-btn (or no <body>) →
|
||||
* no modal, exactly like the rest of this module.
|
||||
*/
|
||||
let syncModal = null; // the backdrop element — created once, lazily
|
||||
let syncModalReturnFocus = null; // the element to refocus on close
|
||||
|
||||
function createSyncModal() {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "sync-modal-backdrop";
|
||||
// Static skeleton — no user data anywhere in it; the error text is
|
||||
// filled via textContent in showSyncModal, never interpolated here.
|
||||
backdrop.innerHTML =
|
||||
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
|
||||
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
|
||||
'<h2 id="sync-modal-title">Sync failed</h2>' +
|
||||
'<p id="sync-modal-error"></p>' +
|
||||
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
|
||||
"</div>";
|
||||
document.body.appendChild(backdrop);
|
||||
// Close path 1: the close button (×).
|
||||
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
|
||||
// Close path 2: a click on the backdrop element itself — never one
|
||||
// that bubbles up from the panel (event.target check).
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) closeSyncModal();
|
||||
});
|
||||
// Close path 3: Esc — ONE document-level keydown binding for the
|
||||
// life of the page, acting only while the modal is open.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
|
||||
});
|
||||
return backdrop;
|
||||
}
|
||||
|
||||
function showSyncModal(error) {
|
||||
if (!syncBtn || !document.body) return; // null-safe: pages without the button
|
||||
if (!syncModal) syncModal = createSyncModal();
|
||||
// The sanitized error as TEXT (XSS-safe) — a second failure while
|
||||
// open updates the text in place (no stacking, no focus jump).
|
||||
syncModal.querySelector("#sync-modal-error").textContent = error;
|
||||
if (syncModal.classList.contains("is-open")) return;
|
||||
// First open: remember the focused element and move focus into the
|
||||
// dialog (the close button). While the run was in flight the button
|
||||
// was disabled (focus had fallen to <body>), so a body-level active
|
||||
// element means "no meaningful focus target" — remember #sync-btn,
|
||||
// the control that started the run, so the close returns focus there.
|
||||
const active = document.activeElement;
|
||||
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
|
||||
syncModal.classList.add("is-open");
|
||||
syncModal.querySelector(".sync-modal-close").focus();
|
||||
}
|
||||
|
||||
function closeSyncModal() {
|
||||
if (!syncModal || !syncModal.classList.contains("is-open")) return;
|
||||
syncModal.classList.remove("is-open");
|
||||
// Focus returns to the remembered element — #sync-btn when present.
|
||||
const target = syncModalReturnFocus;
|
||||
syncModalReturnFocus = null;
|
||||
if (target && document.contains(target)) target.focus();
|
||||
}
|
||||
|
||||
/* A run can only vanish with a server restart mid-sync (status resets
|
||||
* to idle — the phase-accepted behavior): retry-ready, no error to
|
||||
* name. Also the post-403 cleanup. */
|
||||
function applySyncIdle(status) {
|
||||
settleSyncButton("Sync sources");
|
||||
emitSyncStatus(status || { state: "idle" });
|
||||
}
|
||||
|
||||
/* The 2 s poll loop — the ONLY feedback timer (no client-side hard
|
||||
* timeout, phase 32 locked decision). One tick at a time (re-scheduled
|
||||
* only while the run is still live, so an in-flight fetch can never
|
||||
* overlap the next tick), and startSyncPolling refuses to run a second
|
||||
* loop (a 409 adoption or a reload never doubles the polling). */
|
||||
function startSyncPolling() {
|
||||
if (syncPollTimer !== null) return;
|
||||
const tick = async () => {
|
||||
let status = null;
|
||||
let notAdmin = false;
|
||||
try {
|
||||
const r = await fetch("/api/sync/status");
|
||||
if (r.status === 403) notAdmin = true;
|
||||
else if (r.ok) status = await r.json();
|
||||
} catch {
|
||||
/* network blip — the next tick retries (no client timeout to trip) */
|
||||
}
|
||||
if (notAdmin) {
|
||||
// Session lost mid-sync: defense in depth — hide the button.
|
||||
stopSyncPolling();
|
||||
if (syncBtn) syncBtn.hidden = true;
|
||||
applySyncIdle();
|
||||
return;
|
||||
}
|
||||
if (!status) {
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
return;
|
||||
}
|
||||
if (status.state === "success") {
|
||||
stopSyncPolling();
|
||||
applySyncSuccess(status);
|
||||
return;
|
||||
}
|
||||
if (status.state === "failed") {
|
||||
stopSyncPolling();
|
||||
applySyncFailure(status);
|
||||
return;
|
||||
}
|
||||
if (status.state === "idle") {
|
||||
// The run died with a server restart — retry-ready, no banner.
|
||||
stopSyncPolling();
|
||||
applySyncIdle(status);
|
||||
return;
|
||||
}
|
||||
// Still running: keep the button state honest (idempotent) and
|
||||
// re-schedule. No event — the running frame was already emitted
|
||||
// when the state entered (click / boot), and the Sources handlers
|
||||
// are no-ops for repeated running frames anyway.
|
||||
enterSyncRunningState();
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
};
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
}
|
||||
|
||||
/* Click → POST /api/sync. 202 starts the run; 409 adopts the in-flight
|
||||
* one (started elsewhere — e.g. a second tab); 403 hides the button
|
||||
* (defense in depth); anything else names the failure (banner on
|
||||
* Sources via the event, button affordance everywhere). */
|
||||
async function startSync() {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/sync", { method: "POST" });
|
||||
} catch {
|
||||
applySyncFailure({
|
||||
state: "failed",
|
||||
error: "Could not reach the server to start the sync — try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 403) {
|
||||
stopSyncPolling();
|
||||
if (syncBtn) syncBtn.hidden = true;
|
||||
applySyncIdle();
|
||||
return;
|
||||
}
|
||||
if (r.status === 202 || r.status === 409) {
|
||||
enterSyncRunningState();
|
||||
// The synthetic running frame clears the Sources result line /
|
||||
// banner IMMEDIATELY (before the first poll tick, 2 s away) — the
|
||||
// exact sources.js enterRunningState behavior, now event-driven.
|
||||
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
|
||||
startSyncPolling();
|
||||
return;
|
||||
}
|
||||
let detail = "";
|
||||
try {
|
||||
detail = (await r.json()).detail || "";
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
applySyncFailure({
|
||||
state: "failed",
|
||||
error: detail || `The server refused to start the sync (${r.status}).`,
|
||||
});
|
||||
}
|
||||
|
||||
/* Load-time re-attach (ADMIN ONLY — non-admins never poll, the status
|
||||
* endpoint is admin-only): a running run re-enters the running state
|
||||
* (the user may have reloaded mid-sync), a terminal run renders its
|
||||
* last result, idle settles nothing visible. Awaits the SAME cached
|
||||
* whoami promise — exactly one /api/whoami per page load, unchanged. */
|
||||
async function initSyncButton() {
|
||||
if (!syncBtn) return;
|
||||
if (!(await fetchIsAdmin())) return; // anonymous: the button stays hidden
|
||||
let status;
|
||||
try {
|
||||
const r = await fetch("/api/sync/status");
|
||||
if (r.status === 403) {
|
||||
syncBtn.hidden = true; // defense in depth
|
||||
return;
|
||||
}
|
||||
if (!r.ok) return;
|
||||
status = await r.json();
|
||||
} catch {
|
||||
return; // network blip — the button stays idle and clickable
|
||||
}
|
||||
if (status.state === "running") {
|
||||
enterSyncRunningState();
|
||||
emitSyncStatus(status);
|
||||
startSyncPolling();
|
||||
} else if (status.state === "success") {
|
||||
applySyncSuccess(status);
|
||||
} else if (status.state === "failed") {
|
||||
applySyncFailure(status);
|
||||
} else {
|
||||
applySyncIdle(status); // idle: settle + the idle frame
|
||||
}
|
||||
}
|
||||
|
||||
if (syncBtn) {
|
||||
syncBtn.addEventListener("click", startSync);
|
||||
initSyncButton(); // re-attach to a running / last sync run (admin only)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user