// Shared alert/notification utility for consistent Tailwind styling and Font Awesome icons // Provides: window.alerts.show(message, type?, options?) and compatibility shims (function () { const TYPE_ALIASES = { error: 'danger', success: 'success', warning: 'warning', info: 'info', danger: 'danger' }; const TYPE_CLASSES = { success: { container: 'border-green-300 dark:border-green-500 bg-green-50 dark:bg-green-800', icon: 'fa-solid fa-circle-check text-green-600 dark:text-green-300' }, danger: { container: 'border-red-300 dark:border-red-500 bg-red-50 dark:bg-red-800', icon: 'fa-solid fa-triangle-exclamation text-red-600 dark:text-red-300' }, warning: { container: 'border-yellow-300 dark:border-yellow-500 bg-yellow-50 dark:bg-yellow-800', icon: 'fa-solid fa-triangle-exclamation text-yellow-600 dark:text-yellow-300' }, info: { container: 'border-blue-300 dark:border-blue-500 bg-blue-50 dark:bg-blue-800', icon: 'fa-solid fa-circle-info text-blue-600 dark:text-blue-300' } }; function normalizeType(type) { const key = String(type || 'info').toLowerCase(); return TYPE_ALIASES[key] || 'info'; } // ---- DOMPurify Lazy Loader ------------------------------------------------ // Delegated sanitizer: uses shared htmlSanitizer if available, else performs a minimal fallback function sanitizeHTML(dirty) { if (window.htmlSanitizer && typeof window.htmlSanitizer.sanitize === 'function') { return window.htmlSanitizer.sanitize(dirty); } // Minimal inline fallback to guarantee some protection until sanitizer.js loads const temp = document.createElement('div'); temp.innerHTML = dirty; temp.querySelectorAll('script, style').forEach((el) => el.remove()); temp.querySelectorAll('*').forEach((el) => { Array.from(el.attributes).forEach((attr) => { const name = attr.name; const value = attr.value; if (/^on/i.test(name)) el.removeAttribute(name); if ((name === 'href' || name === 'src') && value && value.trim().toLowerCase().startsWith('javascript:')) { el.removeAttribute(name); } }); }); return temp.innerHTML; } function getOrCreateContainer(containerId = 'notification-container') { let container = document.getElementById(containerId); if (!container) { container = document.createElement('div'); container.id = containerId; container.className = 'fixed top-4 right-4 z-50 flex flex-col gap-2 p-0'; document.body.appendChild(container); } return container; } function show(message, type = 'info', options = {}) { const tone = normalizeType(type); const { duration = 5000, dismissible = true, containerId = 'notification-container', role = 'alert', ariaLive = 'polite', html = false, title = null, actions = [], onClose = null, id = null } = options; const container = getOrCreateContainer(containerId); const wrapper = document.createElement('div'); wrapper.className = `alert-notification max-w-sm w-[22rem] border-2 rounded-lg shadow-xl p-4 transition-all duration-300 translate-x-4 opacity-0 ${ (TYPE_CLASSES[tone] || TYPE_CLASSES.info).container }`; wrapper.setAttribute('role', role); wrapper.setAttribute('aria-live', ariaLive); if (id) wrapper.id = id; const inner = document.createElement('div'); inner.className = 'flex items-start'; const iconWrap = document.createElement('div'); iconWrap.className = 'flex-shrink-0'; const icon = document.createElement('i'); icon.className = (TYPE_CLASSES[tone] || TYPE_CLASSES.info).icon; iconWrap.appendChild(icon); const content = document.createElement('div'); content.className = 'ml-3 flex-1'; if (title) { const titleEl = document.createElement('p'); titleEl.className = 'text-sm font-bold text-neutral-900 dark:text-white'; titleEl.textContent = String(title); content.appendChild(titleEl); } const text = document.createElement('div'); text.className = 'text-sm mt-1 font-semibold text-neutral-900 dark:text-white'; if (message instanceof Node) { text.appendChild(message); } else if (html) { text.innerHTML = sanitizeHTML(String(message || '')); } else { text.textContent = String(message || ''); } content.appendChild(text); inner.appendChild(iconWrap); inner.appendChild(content); if (dismissible) { const closeWrap = document.createElement('div'); closeWrap.className = 'ml-4 flex-shrink-0'; const closeBtn = document.createElement('button'); closeBtn.setAttribute('aria-label', 'Close'); closeBtn.className = 'text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors'; closeBtn.addEventListener('click', () => { wrapper.remove(); if (typeof onClose === 'function') onClose(); }); const x = document.createElement('i'); x.className = 'fa-solid fa-xmark'; closeBtn.appendChild(x); closeWrap.appendChild(closeBtn); inner.appendChild(closeWrap); } // Actions (buttons) if (Array.isArray(actions) && actions.length > 0) { const actionsWrap = document.createElement('div'); actionsWrap.className = 'mt-2 flex gap-2 flex-wrap'; actions.forEach((action) => { if (!action || !action.label) return; const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = String(action.label); if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel); btn.className = action.classes || 'px-3 py-1 rounded text-xs transition-colors bg-neutral-200 hover:bg-neutral-300 text-neutral-800 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:text-neutral-200'; btn.addEventListener('click', (ev) => { try { if (typeof action.onClick === 'function') { action.onClick({ event: ev, wrapper }); } } finally { if (action.autoClose !== false) { wrapper.remove(); if (typeof onClose === 'function') onClose(); } } }); actionsWrap.appendChild(btn); }); content.appendChild(actionsWrap); } wrapper.appendChild(inner); container.appendChild(wrapper); // Animate in requestAnimationFrame(() => { wrapper.classList.remove('translate-x-4', 'opacity-0'); }); if (duration > 0) { setTimeout(() => { wrapper.classList.add('translate-x-4', 'opacity-0'); setTimeout(() => { wrapper.remove(); if (typeof onClose === 'function') onClose(); }, 250); }, duration); } return wrapper; } const alerts = { show, success: (message, options = {}) => show(message, 'success', options), error: (message, options = {}) => show(message, 'danger', options), warning: (message, options = {}) => show(message, 'warning', options), info: (message, options = {}) => show(message, 'info', options), getOrCreateContainer, // Internal: exposed for unit testing only (non-enumerable by default prototype iteration) _sanitize: sanitizeHTML, _ensureDOMPurifyLoaded: () => window.htmlSanitizer ? window.htmlSanitizer.ensureDOMPurifyLoaded() : Promise.resolve(null) }; // Expose globally window.alerts = alerts; // Backward-compatible shims window.showAlert = (message, type = 'info', duration = 5000) => alerts.show(message, type, { duration }); window.showNotification = (message, type = 'info', duration = 5000) => alerts.show(message, type, { duration }); window.showToast = (message, type = 'info', duration = 3000) => alerts.show(message, type, { duration }); })();