Files
delphi-database/static/js/alerts.js
2025-08-11 21:58:25 -05:00

219 lines
7.8 KiB
JavaScript

// 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 });
})();