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:
1 parent
30766020c8
commit
acbcc61ae4
5 files changed
+113
-10
No files matched your search
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in new issue
Block a user