|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UIManager {
|
|
|
constructor() {
|
|
|
this.toasts = [];
|
|
|
this.modals = new Map();
|
|
|
this.loading = new Set();
|
|
|
this.init();
|
|
|
}
|
|
|
|
|
|
init() {
|
|
|
this.createToastContainer();
|
|
|
this.initializeGlobalHandlers();
|
|
|
this.setupAccessibility();
|
|
|
console.log('✅ UI Manager initialized');
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
createToastContainer() {
|
|
|
if (!document.getElementById('toast-container')) {
|
|
|
const container = document.createElement('div');
|
|
|
container.id = 'toast-container';
|
|
|
container.setAttribute('aria-live', 'polite');
|
|
|
container.setAttribute('aria-atomic', 'true');
|
|
|
container.style.cssText = `
|
|
|
position: fixed;
|
|
|
top: 1rem;
|
|
|
right: 1rem;
|
|
|
z-index: 9999;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: 0.5rem;
|
|
|
`;
|
|
|
document.body.appendChild(container);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
showToast(message, type = 'info', duration = 3000) {
|
|
|
const container = document.getElementById('toast-container');
|
|
|
if (!container) return;
|
|
|
|
|
|
const toast = document.createElement('div');
|
|
|
const id = `toast-${Date.now()}-${Math.random()}`;
|
|
|
toast.id = id;
|
|
|
toast.className = `toast ${type}`;
|
|
|
|
|
|
|
|
|
const icons = {
|
|
|
success: '✅',
|
|
|
error: '❌',
|
|
|
warning: '⚠️',
|
|
|
info: 'ℹ️'
|
|
|
};
|
|
|
|
|
|
toast.innerHTML = `
|
|
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
|
|
<span style="font-size: 1.25rem;">${icons[type] || icons.info}</span>
|
|
|
<span style="flex: 1;">${this.escapeHtml(message)}</span>
|
|
|
<button onclick="uiManager.closeToast('${id}')" style="background: none; border: none; color: inherit; cursor: pointer; opacity: 0.7; padding: 0.25rem;">
|
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
|
</svg>
|
|
|
</button>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
container.appendChild(toast);
|
|
|
this.toasts.push(id);
|
|
|
|
|
|
|
|
|
if (duration > 0) {
|
|
|
setTimeout(() => this.closeToast(id), duration);
|
|
|
}
|
|
|
|
|
|
return id;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
closeToast(id) {
|
|
|
const toast = document.getElementById(id);
|
|
|
if (toast) {
|
|
|
toast.style.animation = 'slideOutRight 0.3s ease-out';
|
|
|
setTimeout(() => {
|
|
|
toast.remove();
|
|
|
this.toasts = this.toasts.filter(t => t !== id);
|
|
|
}, 300);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
showLoading(elementId, text = 'Loading...') {
|
|
|
const element = document.getElementById(elementId);
|
|
|
if (!element) return;
|
|
|
|
|
|
this.loading.add(elementId);
|
|
|
|
|
|
const originalContent = element.innerHTML;
|
|
|
element.dataset.originalContent = originalContent;
|
|
|
|
|
|
element.innerHTML = `
|
|
|
<div class="loading-container">
|
|
|
<div class="spinner"></div>
|
|
|
<p style="color: var(--text-secondary); margin-top: 1rem;">${this.escapeHtml(text)}</p>
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hideLoading(elementId, content = null) {
|
|
|
const element = document.getElementById(elementId);
|
|
|
if (!element) return;
|
|
|
|
|
|
this.loading.delete(elementId);
|
|
|
|
|
|
if (content) {
|
|
|
element.innerHTML = content;
|
|
|
} else if (element.dataset.originalContent) {
|
|
|
element.innerHTML = element.dataset.originalContent;
|
|
|
delete element.dataset.originalContent;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
showModal(options = {}) {
|
|
|
const {
|
|
|
id = `modal-${Date.now()}`,
|
|
|
title = 'Modal',
|
|
|
content = '',
|
|
|
size = 'md',
|
|
|
onClose = null
|
|
|
} = options;
|
|
|
|
|
|
|
|
|
if (this.modals.has(id)) {
|
|
|
const existing = this.modals.get(id);
|
|
|
existing.modal.classList.add('active');
|
|
|
return id;
|
|
|
}
|
|
|
|
|
|
const modal = document.createElement('div');
|
|
|
modal.id = id;
|
|
|
modal.className = 'modal active';
|
|
|
modal.innerHTML = `
|
|
|
<div class="modal-backdrop" onclick="uiManager.closeModal('${id}')"></div>
|
|
|
<div class="modal-content modal-${size}">
|
|
|
<div class="modal-header" style="display: flex; justify-content: space-between; align-items: center; padding: 1.5rem; border-bottom: 1px solid rgba(255,255,255,0.1);">
|
|
|
<h2 style="margin: 0; font-size: 1.25rem; font-weight: 700;">${this.escapeHtml(title)}</h2>
|
|
|
<button onclick="uiManager.closeModal('${id}')" class="btn-icon" aria-label="Close">
|
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
|
</svg>
|
|
|
</button>
|
|
|
</div>
|
|
|
<div class="modal-body" style="padding: 1.5rem; overflow-y: auto; max-height: 60vh;">
|
|
|
${content}
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
document.body.appendChild(modal);
|
|
|
this.modals.set(id, { modal, onClose });
|
|
|
|
|
|
|
|
|
const handleEscape = (e) => {
|
|
|
if (e.key === 'Escape') {
|
|
|
this.closeModal(id);
|
|
|
}
|
|
|
};
|
|
|
document.addEventListener('keydown', handleEscape);
|
|
|
modal.dataset.escapeHandler = handleEscape;
|
|
|
|
|
|
return id;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
closeModal(id) {
|
|
|
const modalData = this.modals.get(id);
|
|
|
if (!modalData) return;
|
|
|
|
|
|
const { modal, onClose } = modalData;
|
|
|
|
|
|
modal.classList.remove('active');
|
|
|
setTimeout(() => {
|
|
|
modal.remove();
|
|
|
this.modals.delete(id);
|
|
|
if (onClose) onClose();
|
|
|
}, 300);
|
|
|
|
|
|
|
|
|
if (modal.dataset.escapeHandler) {
|
|
|
document.removeEventListener('keydown', modal.dataset.escapeHandler);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async confirm(message, title = 'Confirm') {
|
|
|
return new Promise((resolve) => {
|
|
|
const id = this.showModal({
|
|
|
title,
|
|
|
content: `
|
|
|
<p style="margin-bottom: 1.5rem; color: var(--text-primary);">${this.escapeHtml(message)}</p>
|
|
|
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
|
|
|
<button class="btn btn-secondary" onclick="uiManager.closeModal('${id}'); window.uiManagerResolve(false);">
|
|
|
Cancel
|
|
|
</button>
|
|
|
<button class="btn btn-primary" onclick="uiManager.closeModal('${id}'); window.uiManagerResolve(true);">
|
|
|
Confirm
|
|
|
</button>
|
|
|
</div>
|
|
|
`,
|
|
|
onClose: () => resolve(false)
|
|
|
});
|
|
|
|
|
|
window.uiManagerResolve = resolve;
|
|
|
});
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
showError(message, details = null) {
|
|
|
const content = `
|
|
|
<div style="color: var(--danger);">
|
|
|
<h3 style="margin-bottom: 0.5rem;">⚠️ Error</h3>
|
|
|
<p>${this.escapeHtml(message)}</p>
|
|
|
${details ? `<pre style="margin-top: 1rem; padding: 1rem; background: rgba(0,0,0,0.3); border-radius: 0.5rem; overflow-x: auto; font-size: 0.875rem;">${this.escapeHtml(details)}</pre>` : ''}
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
this.showModal({
|
|
|
title: 'Error',
|
|
|
content,
|
|
|
size: 'md'
|
|
|
});
|
|
|
|
|
|
this.showToast(message, 'error');
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
initializeGlobalHandlers() {
|
|
|
|
|
|
document.addEventListener('click', (e) => {
|
|
|
const button = e.target.closest('button, .btn');
|
|
|
if (button && !button.classList.contains('unstyled')) {
|
|
|
|
|
|
this.createRipple(e, button);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
|
|
|
document.addEventListener('submit', (e) => {
|
|
|
const form = e.target;
|
|
|
if (form.tagName === 'FORM' && !form.classList.contains('no-prevent')) {
|
|
|
|
|
|
}
|
|
|
});
|
|
|
|
|
|
|
|
|
window.addEventListener('beforeunload', (e) => {
|
|
|
if (this.loading.size > 0) {
|
|
|
e.preventDefault();
|
|
|
e.returnValue = 'Operations in progress...';
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
createRipple(event, button) {
|
|
|
const circle = document.createElement('span');
|
|
|
const diameter = Math.max(button.clientWidth, button.clientHeight);
|
|
|
const radius = diameter / 2;
|
|
|
|
|
|
const rect = button.getBoundingClientRect();
|
|
|
circle.style.width = circle.style.height = `${diameter}px`;
|
|
|
circle.style.left = `${event.clientX - rect.left - radius}px`;
|
|
|
circle.style.top = `${event.clientY - rect.top - radius}px`;
|
|
|
circle.classList.add('ripple');
|
|
|
|
|
|
const ripple = button.getElementsByClassName('ripple')[0];
|
|
|
if (ripple) {
|
|
|
ripple.remove();
|
|
|
}
|
|
|
|
|
|
circle.style.cssText += `
|
|
|
position: absolute;
|
|
|
border-radius: 50%;
|
|
|
background: rgba(255, 255, 255, 0.3);
|
|
|
transform: scale(0);
|
|
|
animation: ripple 0.6s ease-out;
|
|
|
pointer-events: none;
|
|
|
`;
|
|
|
|
|
|
button.style.position = 'relative';
|
|
|
button.style.overflow = 'hidden';
|
|
|
button.appendChild(circle);
|
|
|
|
|
|
setTimeout(() => circle.remove(), 600);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setupAccessibility() {
|
|
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
|
|
|
|
if (e.key === 'Tab' && this.modals.size > 0) {
|
|
|
|
|
|
const activeModal = Array.from(this.modals.values())
|
|
|
.map(m => m.modal)
|
|
|
.find(m => m.classList.contains('active'));
|
|
|
|
|
|
if (activeModal) {
|
|
|
const focusableElements = activeModal.querySelectorAll(
|
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
|
);
|
|
|
|
|
|
const firstElement = focusableElements[0];
|
|
|
const lastElement = focusableElements[focusableElements.length - 1];
|
|
|
|
|
|
if (e.shiftKey && document.activeElement === firstElement) {
|
|
|
lastElement.focus();
|
|
|
e.preventDefault();
|
|
|
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
|
|
firstElement.focus();
|
|
|
e.preventDefault();
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
escapeHtml(text) {
|
|
|
const div = document.createElement('div');
|
|
|
div.textContent = text;
|
|
|
return div.innerHTML;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
animateIn(element, animation = 'fadeIn') {
|
|
|
if (typeof element === 'string') {
|
|
|
element = document.getElementById(element);
|
|
|
}
|
|
|
if (!element) return;
|
|
|
|
|
|
element.style.animation = `${animation} 0.3s ease-out`;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scrollTo(elementId, offset = 0) {
|
|
|
const element = document.getElementById(elementId);
|
|
|
if (!element) return;
|
|
|
|
|
|
const top = element.getBoundingClientRect().top + window.pageYOffset - offset;
|
|
|
window.scrollTo({
|
|
|
top,
|
|
|
behavior: 'smooth'
|
|
|
});
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async copyToClipboard(text) {
|
|
|
try {
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
this.showToast('Copied to clipboard!', 'success', 2000);
|
|
|
return true;
|
|
|
} catch (err) {
|
|
|
this.showToast('Failed to copy', 'error');
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
formatNumber(number, decimals = 2) {
|
|
|
return new Intl.NumberFormat('en-US', {
|
|
|
minimumFractionDigits: decimals,
|
|
|
maximumFractionDigits: decimals
|
|
|
}).format(number);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
formatCurrency(amount, currency = 'USD') {
|
|
|
return new Intl.NumberFormat('en-US', {
|
|
|
style: 'currency',
|
|
|
currency
|
|
|
}).format(amount);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
formatRelativeTime(timestamp) {
|
|
|
const now = Date.now();
|
|
|
const diff = now - timestamp;
|
|
|
const seconds = Math.floor(diff / 1000);
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
const hours = Math.floor(minutes / 60);
|
|
|
const days = Math.floor(hours / 24);
|
|
|
|
|
|
if (seconds < 60) return 'just now';
|
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
|
if (hours < 24) return `${hours}h ago`;
|
|
|
if (days < 7) return `${days}d ago`;
|
|
|
return new Date(timestamp).toLocaleDateString();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
const uiManager = new UIManager();
|
|
|
|
|
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
|
module.exports = { UIManager, uiManager };
|
|
|
}
|
|
|
|
|
|
|
|
|
window.uiManager = uiManager;
|
|
|
window.UIManager = UIManager;
|
|
|
|
|
|
|
|
|
const style = document.createElement('style');
|
|
|
style.textContent = `
|
|
|
@keyframes ripple {
|
|
|
to {
|
|
|
transform: scale(4);
|
|
|
opacity: 0;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@keyframes fadeIn {
|
|
|
from {
|
|
|
opacity: 0;
|
|
|
transform: translateY(1rem);
|
|
|
}
|
|
|
to {
|
|
|
opacity: 1;
|
|
|
transform: translateY(0);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@keyframes slideOutRight {
|
|
|
to {
|
|
|
transform: translateX(100%);
|
|
|
opacity: 0;
|
|
|
}
|
|
|
}
|
|
|
`;
|
|
|
document.head.appendChild(style);
|
|
|
|
|
|
console.log('✅ UI Manager loaded and ready');
|
|
|
|