feat(ui): global confirm modal — browser dialogs retired sitewide (issues #5/#4/#7)

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.
This commit is contained in:
John O'Keefe committed 2026-10-29 20:12:30 -04:00
1 parent 30766020c8
commit acbcc61ae4
5 files changed
+113 -10

No files matched your search

+20
View File
@@ -352,4 +352,24 @@ templ SidebarSignIn(currentPath string) {
} }
</div> </div>
</div> </div>
<!-- Global confirm modal (issue #5): ONE styled dialog answers every
hx-confirm sitewide via the htmx:confirm interceptor in
web/src/confirm.ts, and exposes the confirmDialog() promise for
legacy JS call sites. Inert until shown. -->
<div
id="confirm-modal"
class="hidden fixed inset-0 z-[60] flex items-center justify-center p-4"
style="background-color: var(--surface-overlay);"
>
<div class="card w-full max-w-sm" style="box-shadow: var(--shadow-pop);">
<div class="p-5 border-b" style="border-color: var(--border);">
<p id="confirm-modal-message" class="text-sm font-medium" style="color: var(--text-primary)"></p>
</div>
<div class="p-4 flex justify-end gap-2">
<button id="confirm-modal-cancel" class="btn btn-secondary">Cancel</button>
<button id="confirm-modal-accept" class="btn btn-primary">Confirm</button>
</div>
</div>
</div>
} }
+7 -6
View File
@@ -1,3 +1,4 @@
import { confirmDialog } from "./confirm";
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
@@ -423,9 +424,9 @@ async function purgeArchivedItems(): Promise<void> {
if (!token) return; if (!token) return;
if ( if (
!window.confirm( !(await confirmDialog(
"Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.", "Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.",
) ))
) { ) {
return; return;
} }
@@ -475,9 +476,9 @@ async function deleteArchivedItem(id: string): Promise<void> {
const token = requireToken(); const token = requireToken();
if (!token) return; if (!token) return;
if ( if (
!window.confirm( !(await confirmDialog(
"Permanently delete this item? Its reading progress, notes, and highlights will be lost.", "Permanently delete this item? Its reading progress, notes, and highlights will be lost.",
) ))
) { ) {
return; return;
} }
@@ -504,9 +505,9 @@ async function mergeArchivedItem(sourceId: string, targetId: string): Promise<vo
const token = requireToken(); const token = requireToken();
if (!token) return; if (!token) return;
if ( if (
!window.confirm( !(await confirmDialog(
"Merge this archived copy's reading progress, notes, and highlights into the active copy? The archived entry is removed.", "Merge this archived copy's reading progress, notes, and highlights into the active copy? The archived entry is removed.",
) ))
) { ) {
return; return;
} }
+5 -4
View File
@@ -1,3 +1,4 @@
import { confirmDialog } from "./confirm";
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown"; import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
@@ -531,9 +532,9 @@ Alpine.data("bookDetail", () => {
async purgeDeletedAnnotation(annotationType: string, annotationId: string) { async purgeDeletedAnnotation(annotationType: string, annotationId: string) {
if ( if (
!confirm( !(await confirmDialog(
"Permanently delete this annotation? This cannot be undone and it will not reappear on any device.", "Permanently delete this annotation? This cannot be undone and it will not reappear on any device.",
) ))
) { ) {
return; return;
} }
@@ -1002,9 +1003,9 @@ Alpine.data("bookDetail", () => {
async resetMetadata() { async resetMetadata() {
if (this.resetting) return; if (this.resetting) return;
if ( if (
!window.confirm( !(await confirmDialog(
"Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.", "Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.",
) ))
) { ) {
return; return;
} }
+80
View File
@@ -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<boolean> {
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<boolean>((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);
});
});
+1
View File
@@ -25,6 +25,7 @@ import "./login";
import "./password_validation"; import "./password_validation";
import "./processing_issues"; import "./processing_issues";
import "./podcasts"; import "./podcasts";
import "./confirm";
import "./profile"; import "./profile";
import "./profile-modal"; import "./profile-modal";
import "./register"; import "./register";