/** * UI Utilities * Reusable UI components and helpers */ import CONFIG from '../config.js'; /** * Modal Manager * Simplifies modal open/close operations */ export class ModalManager { /** * Show a modal * @param {string} modalId - ID of modal element * @param {Function} onOpen - Optional callback when modal opens */ static show(modalId, onOpen = null) { const modal = document.getElementById(modalId); if (!modal) { console.error(`Modal #${modalId} not found`); return; } modal.classList.remove('hidden'); if (onOpen) { onOpen(modal); } } /** * Hide a modal * @param {string} modalId - ID of modal element * @param {Function} onClose - Optional callback when modal closes */ static hide(modalId, onClose = null) { const modal = document.getElementById(modalId); if (!modal) { console.error(`Modal #${modalId} not found`); return; } modal.classList.add('hidden'); if (onClose) { onClose(modal); } } /** * Setup modal with close handlers * @param {string} modalId - ID of modal element * @param {string} closeButtonId - ID of close button * @param {Function} onClose - Optional cleanup callback * @returns {Function} Cleanup function */ static setup(modalId, closeButtonId, onClose = null) { const modal = document.getElementById(modalId); const closeBtn = document.getElementById(closeButtonId); if (!modal || !closeBtn) { console.error(`Modal #${modalId} or close button #${closeButtonId} not found`); return () => {}; } const handleClose = () => { this.hide(modalId, onClose); }; // Close on button click closeBtn.addEventListener('click', handleClose); // Close on outside click modal.addEventListener('click', (e) => { if (e.target === modal) { handleClose(); } }); // Close on ESC key const handleEscape = (e) => { if (e.key === 'Escape') { handleClose(); } }; document.addEventListener('keydown', handleEscape); // Return cleanup function return () => { closeBtn.removeEventListener('click', handleClose); document.removeEventListener('keydown', handleEscape); }; } } /** * Toast Notification System * Better than window.alert() */ export class Toast { static container = null; /** * Initialize toast container */ static init() { if (this.container) return; this.container = document.createElement('div'); this.container.id = 'toast-container'; this.container.style.cssText = ` position: fixed; top: ${CONFIG.UI.TOOLBAR_HEIGHT + 20}px; right: 20px; z-index: ${CONFIG.COLORS.PRIMARY}; display: flex; flex-direction: column; gap: 10px; pointer-events: none; `; document.body.appendChild(this.container); } /** * Show a toast notification * @param {string} message - Message to display * @param {string} type - 'success', 'error', 'warning', 'info' * @param {number} duration - Display duration in ms (0 = persist) */ static show(message, type = 'info', duration = 3000) { this.init(); const toast = document.createElement('div'); toast.className = `toast toast-${type}`; const colors = { success: CONFIG.COLORS.SUCCESS, error: CONFIG.COLORS.DANGER, warning: CONFIG.COLORS.WARNING, info: CONFIG.COLORS.PRIMARY }; toast.style.cssText = ` background-color: ${colors[type] || colors.info}; color: white; padding: 12px 20px; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); max-width: 400px; word-wrap: break-word; pointer-events: auto; cursor: pointer; animation: slideIn 0.3s ease; font-size: 14px; `; toast.textContent = message; // Click to dismiss toast.addEventListener('click', () => { this.remove(toast); }); this.container.appendChild(toast); // Auto-remove after duration if (duration > 0) { setTimeout(() => { this.remove(toast); }, duration); } return toast; } /** * Remove a toast */ static remove(toast) { toast.style.animation = 'slideOut 0.3s ease'; setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 300); } // Convenience methods static success(message, duration) { return this.show(message, 'success', duration); } static error(message, duration) { return this.show(message, 'error', duration); } static warning(message, duration) { return this.show(message, 'warning', duration); } static info(message, duration) { return this.show(message, 'info', duration); } } /** * Loading Indicator */ export class LoadingIndicator { static overlay = null; static show(message = 'Loading...') { if (this.overlay) return; this.overlay = document.createElement('div'); this.overlay.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; `; const spinner = document.createElement('div'); spinner.style.cssText = ` background-color: white; padding: 30px 40px; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); text-align: center; font-size: 16px; color: #333; `; spinner.innerHTML = `
${message} `; this.overlay.appendChild(spinner); document.body.appendChild(this.overlay); } static hide() { if (this.overlay && this.overlay.parentNode) { this.overlay.parentNode.removeChild(this.overlay); this.overlay = null; } } } /** * Confirmation Dialog * Better than window.confirm() */ export class Confirm { /** * Show confirmation dialog * @param {string} message - Confirmation message * @param {Object} options - Options {title, confirmText, cancelText, onConfirm, onCancel} * @returns {Promise} Resolves to true if confirmed */ static async show(message, options = {}) { const { title = 'Confirm', confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = options; return new Promise((resolve) => { // Create modal const modal = document.createElement('div'); modal.className = 'modal'; modal.innerHTML = ` `; document.body.appendChild(modal); const confirmBtn = modal.querySelector('.confirm-btn'); const cancelBtn = modal.querySelector('.cancel-btn'); const cleanup = () => { if (modal.parentNode) { modal.parentNode.removeChild(modal); } }; confirmBtn.addEventListener('click', () => { cleanup(); resolve(true); }); cancelBtn.addEventListener('click', () => { cleanup(); resolve(false); }); modal.addEventListener('click', (e) => { if (e.target === modal) { cleanup(); resolve(false); } }); }); } } /** * Prompt Dialog * Better than window.prompt() */ export class Prompt { /** * Show prompt dialog * @param {string} message - Prompt message * @param {Object} options - Options {title, defaultValue, placeholder, okText, cancelText} * @returns {Promise} Resolves to input value or null if cancelled */ static async show(message, options = {}) { const { title = 'Input Required', defaultValue = '', placeholder = '', okText = 'OK', cancelText = 'Cancel' } = options; return new Promise((resolve) => { const modal = document.createElement('div'); modal.className = 'modal'; modal.innerHTML = ` `; document.body.appendChild(modal); const input = modal.querySelector('.prompt-input'); const okBtn = modal.querySelector('.ok-btn'); const cancelBtn = modal.querySelector('.cancel-btn'); input.focus(); input.select(); const cleanup = () => { if (modal.parentNode) { modal.parentNode.removeChild(modal); } }; const submit = () => { const value = input.value.trim(); cleanup(); resolve(value || null); }; okBtn.addEventListener('click', submit); cancelBtn.addEventListener('click', () => { cleanup(); resolve(null); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { submit(); } else if (e.key === 'Escape') { cleanup(); resolve(null); } }); }); } } /** * Add CSS animations for toasts */ const style = document.createElement('style'); style.textContent = ` @keyframes slideIn { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(400px); opacity: 0; } } .spinner { border: 3px solid #f3f3f3; border-top: 3px solid ${CONFIG.COLORS.PRIMARY}; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `; document.head.appendChild(style); // Initialize toast system Toast.init();