const popupData = { // Error modal (blocking, full-screen) - for 500/503 errors errorModal: { show: false, code: 0, title: "", message: "", details: "", }, // Toast notifications (non-blocking, stacked) - for success/warning/info toasts: [], toastIdCounter: 0, // Handle HTMX beforeSwap event - intercept both errors and successes handleBeforeSwap(event) { const xhr = event.detail.xhr; if (!xhr) return; const status = xhr.status; const trigger = xhr.getResponseHeader("HX-Trigger"); if (!trigger) return; try { const data = JSON.parse(trigger); // Handle 500/503 error modals if ((status === 500 || status === 503) && data.showErrorModal) { this.errorModal = { show: true, code: data.showErrorModal.code, title: data.showErrorModal.title, message: data.showErrorModal.message, details: data.showErrorModal.details, }; // Prevent swap but allow error logging event.detail.shouldSwap = false; return; } // Handle success/warning/info toasts (200-299) if (status >= 200 && status < 300 && data.showNotification) { this.addToast(data.showNotification); } } catch (e) { console.error("Failed to parse HX-Trigger:", e); } }, // Add toast to stack addToast(notification) { const toast = { id: ++this.toastIdCounter, type: notification.type, title: notification.title, message: notification.message, paused: false, progress: 0, }; this.toasts.push(toast); // Determine timeout based on type const timeout = notification.type === "warning" ? 5000 : 3000; // Start progress animation this.animateToastProgress(toast.id, timeout); }, // Animate toast progress bar and auto-dismiss animateToastProgress(toastId, duration) { const toast = this.toasts.find((t) => t.id === toastId); if (!toast) return; const startTime = performance.now(); let totalPausedTime = 0; let pauseStartTime = null; const animate = (currentTime) => { const toast = this.toasts.find((t) => t.id === toastId); if (!toast) return; // Toast was manually removed if (toast.paused) { // Track when pause started if (pauseStartTime === null) { pauseStartTime = currentTime; } // Keep animating while paused requestAnimationFrame(animate); return; } else { // If we were paused, accumulate the paused time if (pauseStartTime !== null) { totalPausedTime += currentTime - pauseStartTime; pauseStartTime = null; } } // Calculate actual elapsed time (excluding paused time) const elapsed = currentTime - startTime - totalPausedTime; toast.progress = Math.min((elapsed / duration) * 100, 100); if (elapsed >= duration) { this.removeToast(toastId); } else { requestAnimationFrame(animate); } }; requestAnimationFrame(animate); }, // Remove toast from stack removeToast(id) { this.toasts = this.toasts.filter((t) => t.id !== id); }, // Close error modal closeErrorModal() { this.errorModal.show = false; }, }; // Global function for WebSocket toasts to animate progress and auto-dismiss // Pure vanilla JavaScript - no Alpine.js dependency // Called via inline script in ToastNotificationWS template function animateToastProgress(toastId, duration) { const toastEl = document.getElementById(toastId); const progressEl = document.getElementById(toastId + "-progress"); if (!toastEl || !progressEl) { console.error("animateToastProgress: elements not found", toastId); return; } const startTime = performance.now(); let totalPausedTime = 0; let pauseStartTime = null; const animate = (currentTime) => { const el = document.getElementById(toastId); const progEl = document.getElementById(toastId + "-progress"); if (!el || !progEl) return; // Toast was manually removed // Check if paused (set via onmouseenter/onmouseleave) const isPaused = el.dataset.paused === "true"; if (isPaused) { // Track when pause started if (pauseStartTime === null) { pauseStartTime = currentTime; } // Keep animating while paused requestAnimationFrame(animate); return; } else { // If we were paused, accumulate the paused time if (pauseStartTime !== null) { totalPausedTime += currentTime - pauseStartTime; pauseStartTime = null; } } // Calculate actual elapsed time (excluding paused time) const elapsed = currentTime - startTime - totalPausedTime; // Progress bar goes from 100% to 0% const progress = Math.max(100 - (elapsed / duration) * 100, 0); progEl.style.width = progress + "%"; if (elapsed >= duration) { // Remove the toast element el.remove(); } else { requestAnimationFrame(animate); } }; requestAnimationFrame(animate); }