feat(ui): one consistent navbar on every page (TODO.md L3)
This commit is contained in:
+503
-20
@@ -6,28 +6,60 @@
|
||||
*
|
||||
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
|
||||
* decided by /api/whoami at load);
|
||||
* • the admin-only nav links — "Sources" (#nav-sources) and "Tuning"
|
||||
* (#nav-tuning, on the Global Tuning page, phase 27) — phase 19 UX
|
||||
* revision (owner permission 2026-08-23): hidden for anonymous on
|
||||
* every page that has a nav (chat, sources, tuning, login),
|
||||
* revealed for admin. The links SHIP hidden in the HTML
|
||||
* • the admin-only nav links — "Sources" (#nav-sources, phase 19)
|
||||
* and "Tuning" (#nav-tuning, phase 29) — phase 19 UX revision
|
||||
* (owner permission 2026-08-23): hidden for anonymous on EVERY
|
||||
* page, revealed for admin. Phase 34 task 03 (owner confirmation
|
||||
* 2026-08-26): the SAME nav ships on all five pages (chat,
|
||||
* sources, document viewer, tuning, login) — the viewer's
|
||||
* "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
|
||||
* Sources page's "Sync sources" button (#sync-btn, phase 32) —
|
||||
* the same ship-hidden / reveal-for-admin contract on the SAME
|
||||
* cached whoami (one fetch, no extra request);
|
||||
* 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 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.
|
||||
* 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; on non-Sources pages the
|
||||
* failed state is visible in the button's title + aria-label;
|
||||
* • 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 "/";
|
||||
* • 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
|
||||
* from; the page markup keeps its own href as the no-JS fallback;
|
||||
* • clearChatStorage() — the phase-14 conversation key, for the
|
||||
* New Chat buttons on the NON-CHAT pages (sources / document
|
||||
* viewer / tuning): a new chat means going to the chat, fresh.
|
||||
* New chat action on the NON-CHAT pages (sources / document viewer /
|
||||
* tuning / login): a new chat means going to the chat, fresh.
|
||||
*
|
||||
* Every page loads this module (type="module", before its page script)
|
||||
* and its page script calls initSharedHeader() once at boot. init…
|
||||
* toggles ONLY the controls that exist on the page — a missing element
|
||||
* is a no-op, which is how the login page reuses the module without
|
||||
* gaining chat controls (no #new-chat-btn / #sign-in-link /
|
||||
* #sign-out-btn in its markup → none appear).
|
||||
* is a no-op. Phase 34 task 03 ships the SAME full header block on all
|
||||
* five pages (the login page included), so every control resolves on
|
||||
* every page; a page that lacks one simply skips it.
|
||||
*
|
||||
* whoami is fetched at most ONCE per page load: the promise is cached in
|
||||
* the module-level `adminPromise`, so app.js's tuning gate, the sources
|
||||
@@ -61,20 +93,42 @@ export function fetchIsAdmin() {
|
||||
cached promise makes both awaits the same single request). */
|
||||
export async function initSharedHeader() {
|
||||
const admin = await fetchIsAdmin();
|
||||
// The Sign in link: hidden for the admin, visible otherwise — and its
|
||||
// href is rewritten to return the admin to THIS page after login
|
||||
// (phase 34 task 02: "return to where you were"). The markup keeps its
|
||||
// own static ?next= as the no-JS fallback. location.pathname is always
|
||||
// a query-safe "/…" string (never "//"; ? # and spaces stay
|
||||
// percent-encoded in it), so it rides in next= as-is — the same shape
|
||||
// the static fallbacks use (login.js safeNext re-validates it).
|
||||
const signIn = document.querySelector("#sign-in-link");
|
||||
if (signIn) signIn.hidden = admin;
|
||||
if (signIn) {
|
||||
signIn.hidden = admin;
|
||||
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
|
||||
}
|
||||
const signOut = document.querySelector("#sign-out-btn");
|
||||
if (signOut) signOut.hidden = !admin;
|
||||
const navSources = document.querySelector("#nav-sources");
|
||||
if (navSources) navSources.hidden = !admin;
|
||||
// Phase 27: the Global Tuning page's own nav link — admin-only, the
|
||||
// same ship-hidden / reveal-for-admin contract as the Sources link.
|
||||
// Phase 29: the Global Tuning nav link (every page from phase 34
|
||||
// task 03) — admin-only, the same ship-hidden / reveal-for-admin
|
||||
// contract as the Sources link.
|
||||
const navTuning = document.querySelector("#nav-tuning");
|
||||
if (navTuning) navTuning.hidden = !admin;
|
||||
// Phase 32: the Sources page's "Sync sources" button — admin-only,
|
||||
// revealed on this same cached whoami (anonymous users never see it).
|
||||
const syncBtn = document.querySelector("#sync-btn");
|
||||
// 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:
|
||||
// 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.
|
||||
if (admin) {
|
||||
if (steeringPanel) refreshSteering();
|
||||
} else {
|
||||
steeringToggle?.remove();
|
||||
steeringPanel?.remove();
|
||||
}
|
||||
return admin;
|
||||
}
|
||||
|
||||
@@ -106,3 +160,432 @@ if (signOutBtn) {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- steering notes (phase 15; module-owned from phase 34) ----------
|
||||
*
|
||||
* 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
|
||||
* (app.js), which refreshes the panel through refreshSteering() and
|
||||
* announces through announceSteering().
|
||||
*
|
||||
* All elements are looked up null-safe (querySelector + guard): a page
|
||||
* 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");
|
||||
const steeringAnnouncer = document.querySelector("#steering-announcer");
|
||||
|
||||
/* Announce a steering change through the polite live region
|
||||
(role="status", aria-live="polite") — exported so the chat page's
|
||||
per-bubble Tune form (app.js) announces on the exact same channel. */
|
||||
export function announceSteering(message) {
|
||||
if (steeringAnnouncer) steeringAnnouncer.textContent = 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. */
|
||||
export async function refreshSteering() {
|
||||
let notes = [];
|
||||
try {
|
||||
const r = await fetch("/api/steering");
|
||||
if (r.ok) notes = (await r.json()).notes || [];
|
||||
} catch {
|
||||
/* API unreachable: the empty list state is the safe fallback */
|
||||
}
|
||||
renderSteeringPanel(notes);
|
||||
return notes;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
function renderSteeringPanel(notes) {
|
||||
if (!steeringList) return;
|
||||
steeringList.textContent = "";
|
||||
for (const n of notes) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "steering-note";
|
||||
const text = document.createElement("span");
|
||||
text.className = "steering-note-text";
|
||||
text.textContent = n.note; // rendered as text, never as HTML
|
||||
li.appendChild(text);
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "steering-delete";
|
||||
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
|
||||
del.innerHTML =
|
||||
'<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>';
|
||||
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
|
||||
li.appendChild(del);
|
||||
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
|
||||
/api/steering/{id}, re-load the list, announce through
|
||||
#steering-announcer. A 404 means the note was already gone — say so
|
||||
and still refresh; any other failure re-enables the button so the
|
||||
user can retry. */
|
||||
async function deleteSteeringNote(id, btn) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (r.status === 404) {
|
||||
announceSteering("That note was already removed.");
|
||||
await refreshSteering();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announceSteering("Could not delete the note — try again.");
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await refreshSteering();
|
||||
announceSteering("Tuning note deleted.");
|
||||
} catch {
|
||||
announceSteering("Could not delete the note — is the app reachable?");
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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) ----------
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
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 = "/";
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 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 (on non-Sources pages that IS where the
|
||||
* failure is visible; the Sources banner is the event).
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
|
||||
/* 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