// Import the component loader and page utilities import { importComponent } from "/js/components.js"; import { callJsExtensions } from "/js/extensions.js"; // Modal functionality const modalStack = []; function sameModalPath(left = "", right = "") { return String(left || "").replace(/^\/+/, "") === String(right || "").replace(/^\/+/, ""); } function modalHasClass(modalOrElement, className) { const element = modalOrElement?.element || modalOrElement; return Boolean( element?.classList?.contains(className) || element?.querySelector?.(".modal-inner")?.classList?.contains(className) ); } function modalDatasetFlag(modalOrElement, name) { const element = modalOrElement?.element || modalOrElement; const inner = element?.querySelector?.(".modal-inner"); const value = element?.dataset?.[name] ?? inner?.dataset?.[name] ?? ""; return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); } function modalRequiresExplicitClose(modalOrElement) { return modalHasClass(modalOrElement, "modal-explicit-close") || modalDatasetFlag(modalOrElement, "modalExplicitClose"); } function modalSuppressesBackdrop(modalOrElement) { return modalHasClass(modalOrElement, "modal-no-backdrop") || modalDatasetFlag(modalOrElement, "modalNoBackdrop"); } function dispatchModalEvent(name, modal, detail = {}) { document.dispatchEvent( new CustomEvent(name, { detail: { modalPath: modal?.path ?? null, modal, modalStack: getModalStack(), ...detail, }, }), ); } function activateModal(modal) { if (!modal) return; updateModalZIndexes(); restoreModalScrollSnapshot(modal); dispatchModalEvent("modal-activated", modal); } function findModalIndexByPath(modalPath) { return modalStack.findIndex((modal) => sameModalPath(modal.path, modalPath)); } function focusModal(modalPath) { const modalIndex = findModalIndexByPath(modalPath); if (modalIndex === -1) return false; const currentTopModal = modalStack[modalStack.length - 1]; if (currentTopModal) { currentTopModal.savedScrollSnapshot = captureModalScrollSnapshot(currentTopModal); } const [modal] = modalStack.splice(modalIndex, 1); modalStack.push(modal); activateModal(modal); return true; } function getModalScrollElement(modal) { return modal?.element?.querySelector(".modal-scroll"); } function captureModalScrollSnapshot(modal) { const modalScroll = getModalScrollElement(modal); if (!modalScroll) return null; return { scrollTop: modalScroll.scrollTop, scrollLeft: modalScroll.scrollLeft, }; } function restoreModalScrollSnapshot(modal) { const snapshot = modal?.savedScrollSnapshot; if (!snapshot) return; requestAnimationFrame(() => { const modalScroll = getModalScrollElement(modal); if (!modalScroll) return; modalScroll.scrollTop = snapshot.scrollTop; modalScroll.scrollLeft = snapshot.scrollLeft; modal.savedScrollSnapshot = null; }); } // Create a single backdrop for all modals const backdrop = document.createElement("div"); backdrop.className = "modal-backdrop"; backdrop.style.display = "none"; backdrop.style.backdropFilter = "blur(8px) saturate(112%)"; document.body.appendChild(backdrop); // Function to update z-index for all modals and backdrop function updateModalZIndexes() { // Base z-index for modals const baseZIndex = 3000; // Update z-index for all modals modalStack.forEach((modal, index) => { // For first modal, z-index is baseZIndex // For second modal, z-index is baseZIndex + 20 // This leaves room for the backdrop between them modal.element.style.zIndex = baseZIndex + index * 20; }); const backdropModalStack = modalStack.filter((modal) => !modalSuppressesBackdrop(modal)); if (backdropModalStack.length === 0) { backdrop.style.display = "none"; return; } backdrop.style.display = "block"; backdrop.style.backdropFilter = "blur(8px) saturate(112%)"; backdrop.style.backgroundColor = ""; if (backdropModalStack.length === modalStack.length && modalStack.length > 1) { const topModalIndex = modalStack.length - 1; backdrop.style.zIndex = baseZIndex + (topModalIndex - 1) * 20 + 10; } else { const topBackdropModal = backdropModalStack[backdropModalStack.length - 1]; const topBackdropModalIndex = modalStack.indexOf(topBackdropModal); backdrop.style.zIndex = topBackdropModalIndex > 0 ? baseZIndex + (topBackdropModalIndex - 1) * 20 + 10 : baseZIndex - 1; } } // Function to create a new modal element function createModalElement(path) { // Create modal element const newModal = document.createElement("div"); newModal.className = "modal"; newModal.path = path; // save name to the object newModal.dataset.modalPath = path; // Add click handlers to only close modal if both mousedown and mouseup are on the modal container let mouseDownTarget = null; newModal.addEventListener("mousedown", (event) => { mouseDownTarget = event.target; }); newModal.addEventListener("mouseup", (event) => { if ( event.target === newModal && mouseDownTarget === newModal && !modalRequiresExplicitClose(newModal) ) { closeModal(); } mouseDownTarget = null; }); // Create modal structure newModal.innerHTML = ` `; // Setup close button handler for this specific modal const close_button = newModal.querySelector(".modal-close"); close_button.addEventListener("click", () => closeModal()); // Add modal to DOM document.body.appendChild(newModal); // Show the modal newModal.classList.add("show"); // Update modal z-indexes updateModalZIndexes(); return { path: path, element: newModal, title: newModal.querySelector(".modal-title"), header: newModal.querySelector(".modal-header"), body: newModal.querySelector(".modal-bd"), close: close_button, footerSlot: newModal.querySelector(".modal-footer-slot"), inner: newModal.querySelector(".modal-inner"), styles: [], scripts: [], beforeClose: null, savedScrollSnapshot: null, }; } // Function to open modal with content from URL export async function openModal(modalPath, beforeClose = null) { const openCtx = { modalPath, modal: null, cancel: false }; await callJsExtensions("open_modal_before", openCtx); if (openCtx.cancel) return; modalPath = openCtx.modalPath; return new Promise((resolve) => { try { const currentTopModal = modalStack[modalStack.length - 1]; if (currentTopModal) { currentTopModal.savedScrollSnapshot = captureModalScrollSnapshot(currentTopModal); } // Create new modal instance const modal = createModalElement(modalPath); modal.beforeClose = beforeClose; openCtx.modal = modal; new MutationObserver( (_, o) => !document.contains(modal.element) && (o.disconnect(), resolve()) ).observe(document.body, { childList: true, subtree: true }); // Set a loading state modal.body.innerHTML = '
Loading...
'; // Already added to stack above // Use importComponent to load the modal content // This handles all HTML, styles, scripts and nested components // Updated path to use the new folder structure with modal.html const componentPath = modalPath; // `modals/${modalPath}/modal.html`; // Use importComponent which now returns the parsed document importComponent(componentPath, modal.body) .then(async (doc) => { // Set the title from the document modal.title.innerHTML = doc.title || modalPath; const htmlElement = doc.documentElement; if (htmlElement && htmlElement.classList) { const inner = modal.element.querySelector(".modal-inner"); if (inner) inner.classList.add(...htmlElement.classList); } if (doc.body && doc.body.classList) { modal.body.classList.add(...doc.body.classList); } await callJsExtensions("modal_content_loaded", { modalPath, modal, doc, }); dispatchModalEvent("modal-content-loaded", modal, { doc }); refreshModalStack(); // Some modals have a footer. Check if it exists and move it to footer slot // Use requestAnimationFrame to let Alpine mount the component first requestAnimationFrame(() => { const componentFooter = modal.body.querySelector('[data-modal-footer]'); if (componentFooter && modal.footerSlot) { // Move footer outside modal-scroll scrollable area modal.footerSlot.appendChild(componentFooter); modal.footerSlot.style.display = 'block'; modal.inner.classList.add('modal-with-footer'); } }); }) .catch((error) => { console.error("Error loading modal content:", error); modal.body.innerHTML = `
Failed to load modal content: ${error.message}
`; }); // Add modal to stack and show it // Add modal to stack modal.path = modalPath; modalStack.push(modal); document.body.style.overflow = "hidden"; activateModal(modal); } catch (error) { console.error("Error loading modal content:", error); resolve(); } }); } export function isModalOpen(modalPath) { return findModalIndexByPath(modalPath) !== -1; } export function getModalStack() { return modalStack.slice(); } export function refreshModalStack() { if (modalStack.length === 0) { updateModalZIndexes(); return; } activateModal(modalStack[modalStack.length - 1]); } export async function ensureModalOpen(modalPath, beforeClose = null) { if (focusModal(modalPath)) return null; return openModal(modalPath, beforeClose); } export async function toggleModal(modalPath, beforeClose = null) { if (!isModalOpen(modalPath)) { return openModal(modalPath, beforeClose); } while (isModalOpen(modalPath)) { const closed = await closeModal(modalPath); if (closed === false) return false; } return true; } // Function to close modal export async function closeModal(modalPath = null) { if (modalStack.length === 0) return; let modalIndex = modalStack.length - 1; // Default to last modal let modal; if (modalPath) { // Find the modal with the specified name in the stack modalIndex = findModalIndexByPath(modalPath); if (modalIndex === -1) return; // Modal not found in stack // Get the modal from stack at the found index modal = modalStack[modalIndex]; } else { // Just get the last modal (removal happens after beforeClose) modal = modalStack[modalStack.length - 1]; } const closeCtx = { modalPath: modalPath ?? null, modal, cancel: false }; await callJsExtensions("close_modal_before", closeCtx); if (closeCtx.cancel) return false; const canClose = async () => { if (!modal.beforeClose) return true; try { const result = await Promise.resolve(modal.beforeClose()); return result !== false; } catch (error) { console.error("Error in beforeClose handler:", error); return true; } }; return Promise.resolve(canClose()).then((shouldClose) => { if (!shouldClose) return false; if (modalPath) { // Remove the modal from stack after beforeClose check modalStack.splice(modalIndex, 1); } else { modalStack.pop(); } // Remove modal-specific styles and scripts immediately modal.styles.forEach((styleId) => { document.querySelector(`[data-modal-style="${styleId}"]`)?.remove(); }); modal.scripts.forEach((scriptId) => { document.querySelector(`[data-modal-script="${scriptId}"]`)?.remove(); }); // First remove the show class to trigger the transition modal.element.classList.remove("show"); // commented out to prevent race conditions // // Remove the modal element from DOM after animation // modal.element.addEventListener( // "transitionend", // () => { // // Make sure the modal is completely removed from the DOM // if (modal.element.parentNode) { // modal.element.parentNode.removeChild(modal.element); // } // }, // { once: true } // ); // // Fallback in case the transition event doesn't fire // setTimeout(() => { // if (modal.element.parentNode) { // modal.element.parentNode.removeChild(modal.element); // } // }, 500); // 500ms should be enough for the transition to complete // remove immediately if (modal.element.parentNode) { modal.element.parentNode.removeChild(modal.element); } // Handle backdrop visibility and body overflow if (modalStack.length === 0) { // Hide backdrop when no modals are left backdrop.style.display = "none"; document.body.style.overflow = ""; } else { activateModal(modalStack[modalStack.length - 1]); } document.dispatchEvent( new CustomEvent("modal-closed", { detail: { modalPath: modal.path ?? null, remainingModalCount: modalStack.length, }, }), ); return true; }); } // Function to scroll to element by ID within the last modal export function scrollModal(id) { if (!id) return; // Get the last modal in the stack const lastModal = modalStack[modalStack.length - 1].element; if (!lastModal) return; // Find the modal container and target element const modalContainer = lastModal.querySelector(".modal-scroll"); const targetElement = lastModal.querySelector(`#${id}`); if (modalContainer && targetElement) { modalContainer.scrollTo({ top: targetElement.offsetTop - 20, // 20px padding from top behavior: "smooth", }); } } // Make scrollModal globally available globalThis.scrollModal = scrollModal; // Handle modal content loading from clicks document.addEventListener("click", async (e) => { const modalTrigger = e.target.closest("[data-modal-content]"); if (modalTrigger) { e.preventDefault(); if ( modalTrigger.hasAttribute("disabled") || modalTrigger.classList.contains("disabled") ) { return; } const modalPath = modalTrigger.getAttribute("href"); await openModal(modalPath); } }); // Close modal on escape key (closes only the top modal) document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modalStack.length > 0) { if (modalRequiresExplicitClose(modalStack[modalStack.length - 1])) return; closeModal(); } }); // also export as global function globalThis.openModal = openModal; globalThis.closeModal = closeModal; globalThis.scrollModal = scrollModal; globalThis.isModalOpen = isModalOpen; globalThis.ensureModalOpen = ensureModalOpen; globalThis.toggleModal = toggleModal;