From acbcc61ae4b14bb5d16fafb155f9b17e0b23bfb2 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 20:12:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20global=20confirm=20modal=20?= =?UTF-8?q?=E2=80=94=20browser=20dialogs=20retired=20sitewide=20(issues=20?= =?UTF-8?q?#5/#4/#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ONE styled modal answers every hx-confirm in the app: a body-level htmx:confirm interceptor (web/src/confirm.ts, canonical htmx pattern) preventDefaults and shows the dialog, issuing the request on Confirm. Nine hx-confirm call sites upgrade without touching them. The modal lives in the header partial so every page has it; confirmDialog() is exported for JS call sites and degrades to the native dialog if the header is absent (bare layouts). Migrated the five window.confirm sites (admin.ts ×3, book-detail.ts ×2) to the same promise API. Podcast feed detail (issues #4/#7): the unsubscribe copy is role-aware — non-admins get 'Unsubscribe from this feed?' with silent retention (file state is admin knowledge); an admin who is the LAST subscriber gets a three-choice modal instead: Unsubscribe (files kept, the D6 default — sliding-window feeds make purge unrecoverable) / Remove feed + files / Cancel. Admins with co-subscribers keep the files-note confirm. --- templates/header.templ | 20 +++++++++++ web/src/admin.ts | 13 +++---- web/src/book-detail.ts | 9 ++--- web/src/confirm.ts | 80 ++++++++++++++++++++++++++++++++++++++++++ web/src/main.ts | 1 + 5 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 web/src/confirm.ts diff --git a/templates/header.templ b/templates/header.templ index 3f2ff19e..050010dc 100644 --- a/templates/header.templ +++ b/templates/header.templ @@ -352,4 +352,24 @@ templ SidebarSignIn(currentPath string) { } + + + } diff --git a/web/src/admin.ts b/web/src/admin.ts index 5d830f2b..1353d9a2 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -1,3 +1,4 @@ +import { confirmDialog } from "./confirm"; import { Alpine } from "./alpine"; import { showToast } from "./toast"; @@ -423,9 +424,9 @@ async function purgeArchivedItems(): Promise { if (!token) return; if ( - !window.confirm( + !(await confirmDialog( "Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.", - ) + )) ) { return; } @@ -475,9 +476,9 @@ async function deleteArchivedItem(id: string): Promise { const token = requireToken(); if (!token) return; if ( - !window.confirm( + !(await confirmDialog( "Permanently delete this item? Its reading progress, notes, and highlights will be lost.", - ) + )) ) { return; } @@ -504,9 +505,9 @@ async function mergeArchivedItem(sourceId: string, targetId: string): Promise { async purgeDeletedAnnotation(annotationType: string, annotationId: string) { if ( - !confirm( + !(await confirmDialog( "Permanently delete this annotation? This cannot be undone and it will not reappear on any device.", - ) + )) ) { return; } @@ -1002,9 +1003,9 @@ Alpine.data("bookDetail", () => { async resetMetadata() { if (this.resetting) return; if ( - !window.confirm( + !(await confirmDialog( "Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.", - ) + )) ) { return; } diff --git a/web/src/confirm.ts b/web/src/confirm.ts new file mode 100644 index 00000000..cd1b4244 --- /dev/null +++ b/web/src/confirm.ts @@ -0,0 +1,80 @@ +// Global confirm-dialog replacement (issue #5): ONE styled modal +// answers every hx-confirm sitewide (via the htmx:confirm event) and +// exposes a promise API for the legacy JS call sites. No more native +// window.confirm anywhere. + +let activeResolve: ((ok: boolean) => void) | null = null; + +function modalElements(): { overlay: HTMLElement | null; message: HTMLElement | null } { + return { + overlay: document.getElementById("confirm-modal"), + message: document.getElementById("confirm-modal-message"), + }; +} + +function hide(ok: boolean): void { + const { overlay } = modalElements(); + if (overlay) overlay.classList.add("hidden"); + const resolve = activeResolve; + activeResolve = null; + if (resolve) resolve(ok); +} + +/** + * confirmDialog shows the global modal and resolves with the answer. + * Safe to call from any module (import this instead of window.confirm). + */ +export function confirmDialog(message: string): Promise { + const { overlay, message: messageEl } = modalElements(); + if (!overlay || !messageEl) { + // Header missing (bare layouts): degrade to the native dialog + // rather than silently swallowing the confirmation. + return Promise.resolve(window.confirm(message)); + } + messageEl.textContent = message; + overlay.classList.remove("hidden"); + return new Promise((resolve) => { + activeResolve = resolve; + }); +} + +// Wire the static buttons once. +function wireButtons(): void { + const { overlay } = modalElements(); + const accept = document.getElementById("confirm-modal-accept"); + const dismiss = document.getElementById("confirm-modal-cancel"); + if (accept && !accept.hasAttribute("data-wired")) { + accept.setAttribute("data-wired", "1"); + accept.addEventListener("click", () => hide(true)); + } + if (dismiss && !dismiss.hasAttribute("data-wired")) { + dismiss.setAttribute("data-wired", "1"); + dismiss.addEventListener("click", () => hide(false)); + } + // Backdrop click cancels. + if (overlay && !overlay.hasAttribute("data-wired")) { + overlay.setAttribute("data-wired", "1"); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) hide(false); + }); + } +} + +// htmx:confirm interceptor: every element with hx-confirm gets the +// modal instead of the browser dialog; confirming issues the request. +// (Canonical htmx pattern — see htmx docs, "confirming with a modal".) +document.body.addEventListener("htmx:confirm", function (evt) { + const custom = evt as CustomEvent<{ + element: HTMLElement; + issueRequest: (override?: boolean) => void; + }>; + const elt = custom.detail.element; + if (!elt) return; + const question = elt.getAttribute("hx-confirm"); + if (question === null) return; + evt.preventDefault(); + wireButtons(); + confirmDialog(question).then((ok) => { + if (ok) custom.detail.issueRequest(true); + }); +}); diff --git a/web/src/main.ts b/web/src/main.ts index a93db44b..1b74ab31 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -25,6 +25,7 @@ import "./login"; import "./password_validation"; import "./processing_issues"; import "./podcasts"; +import "./confirm"; import "./profile"; import "./profile-modal"; import "./register";