490 lines
14 KiB
JavaScript
490 lines
14 KiB
JavaScript
/**
|
|
* Notification Manager
|
|
* Handles toast notifications and user feedback
|
|
*/
|
|
|
|
import { escapeHtml } from './utils.js';
|
|
|
|
export class NotificationManager {
|
|
constructor() {
|
|
this.container = null;
|
|
this.notifications = new Map(); // id -> notification element
|
|
this.nextId = 1;
|
|
this.recentNotifications = new Map(); // title+message -> timestamp for deduplication
|
|
|
|
// Configuration
|
|
this.config = {
|
|
maxNotifications: 5,
|
|
defaultTimeout: 5000,
|
|
animationDuration: 300,
|
|
position: 'top-right' // top-right, top-left, bottom-right, bottom-left
|
|
};
|
|
|
|
// Notification types configuration
|
|
this.typeConfig = {
|
|
success: {
|
|
icon: 'check-circle',
|
|
timeout: 3000,
|
|
class: 'notification--success'
|
|
},
|
|
error: {
|
|
icon: 'alert-circle',
|
|
timeout: 7000,
|
|
class: 'notification--error'
|
|
},
|
|
warning: {
|
|
icon: 'alert-triangle',
|
|
timeout: 5000,
|
|
class: 'notification--warning'
|
|
},
|
|
info: {
|
|
icon: 'info',
|
|
timeout: 4000,
|
|
class: 'notification--info'
|
|
}
|
|
};
|
|
|
|
this.init();
|
|
}
|
|
|
|
/**
|
|
* Initialize notification system
|
|
*/
|
|
init() {
|
|
// Create or find notification container
|
|
this.container = document.getElementById('notifications');
|
|
|
|
if (!this.container) {
|
|
this.container = document.createElement('div');
|
|
this.container.id = 'notifications';
|
|
this.container.className = 'notifications';
|
|
document.body.appendChild(this.container);
|
|
}
|
|
|
|
console.log('Notification Manager initialized');
|
|
}
|
|
|
|
/**
|
|
* Show a notification
|
|
*/
|
|
show(options) {
|
|
// Validate options
|
|
if (!options || typeof options !== 'object') {
|
|
console.warn('Invalid notification options:', options);
|
|
return null;
|
|
}
|
|
|
|
const { type = 'info', title, message } = options;
|
|
|
|
// Don't show notification if both title and message are empty
|
|
if (!title && !message) {
|
|
console.warn('Skipping notification with empty title and message');
|
|
return null;
|
|
}
|
|
|
|
// Check for duplicate notifications within the last 2 seconds
|
|
const notificationKey = `${type}:${title}:${message}`;
|
|
const now = Date.now();
|
|
const lastShown = this.recentNotifications.get(notificationKey);
|
|
|
|
if (lastShown && (now - lastShown) < 2000) {
|
|
console.log('Skipping duplicate notification:', notificationKey);
|
|
return null; // Skip duplicate notification
|
|
}
|
|
|
|
this.recentNotifications.set(notificationKey, now);
|
|
|
|
// Clean up old entries (older than 5 seconds)
|
|
for (const [key, timestamp] of this.recentNotifications) {
|
|
if (now - timestamp > 5000) {
|
|
this.recentNotifications.delete(key);
|
|
}
|
|
}
|
|
|
|
const notification = this.createNotification(options);
|
|
this.addNotification(notification);
|
|
return notification.id;
|
|
}
|
|
|
|
/**
|
|
* Create notification object
|
|
*/
|
|
createNotification(options) {
|
|
const {
|
|
type = 'info',
|
|
title,
|
|
message,
|
|
timeout = null,
|
|
persistent = false,
|
|
actions = []
|
|
} = options;
|
|
|
|
const id = this.nextId++;
|
|
const config = this.typeConfig[type] || this.typeConfig.info;
|
|
const finalTimeout = persistent ? null : (timeout ?? config.timeout);
|
|
|
|
return {
|
|
id,
|
|
type,
|
|
title,
|
|
message,
|
|
timeout: finalTimeout,
|
|
persistent,
|
|
actions,
|
|
config,
|
|
element: null,
|
|
timer: null,
|
|
createdAt: new Date()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Add notification to DOM and manage queue
|
|
*/
|
|
addNotification(notification) {
|
|
// Remove oldest notifications if we exceed the limit
|
|
this.enforceMaxNotifications();
|
|
|
|
// Create DOM element
|
|
notification.element = this.createNotificationElement(notification);
|
|
|
|
// Add to container
|
|
this.container.appendChild(notification.element);
|
|
|
|
// Store notification
|
|
this.notifications.set(notification.id, notification);
|
|
|
|
// Animate in
|
|
setTimeout(() => {
|
|
notification.element.classList.add('notification--visible');
|
|
}, 10);
|
|
|
|
// Set up auto-dismiss timer
|
|
if (notification.timeout) {
|
|
notification.timer = setTimeout(() => {
|
|
this.dismiss(notification.id);
|
|
}, notification.timeout);
|
|
}
|
|
|
|
// Initialize Lucide icons
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
|
|
}
|
|
|
|
console.log(` Showing ${notification.type} notification:`, notification.title);
|
|
}
|
|
|
|
/**
|
|
* Create notification DOM element
|
|
*/
|
|
createNotificationElement(notification) {
|
|
const element = document.createElement('div');
|
|
element.className = `notification ${notification.config.class}`;
|
|
element.dataset.id = notification.id;
|
|
|
|
const iconHtml = `<i data-lucide="${notification.config.icon}" class="notification__icon"></i>`;
|
|
|
|
const titleHtml = notification.title ?
|
|
`<div class="notification__title">${escapeHtml(notification.title)}</div>` : '';
|
|
|
|
const messageHtml = notification.message ?
|
|
`<div class="notification__message">${escapeHtml(notification.message)}</div>` : '';
|
|
|
|
const actionsHtml = notification.actions.length > 0 ?
|
|
this.createActionsHtml(notification.actions) : '';
|
|
|
|
const closeHtml = `
|
|
<button class="notification__close" data-action="close" title="Close">
|
|
<i data-lucide="x"></i>
|
|
</button>
|
|
`;
|
|
|
|
element.innerHTML = `
|
|
${iconHtml}
|
|
<div class="notification__content">
|
|
${titleHtml}
|
|
${messageHtml}
|
|
${actionsHtml}
|
|
</div>
|
|
${closeHtml}
|
|
`;
|
|
|
|
// Add event listeners
|
|
this.setupNotificationEvents(element, notification);
|
|
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* Create actions HTML for notification
|
|
*/
|
|
createActionsHtml(actions) {
|
|
if (actions.length === 0) return '';
|
|
|
|
const actionsHtml = actions.map(action => {
|
|
const buttonClass = action.primary ? 'btn btn--primary btn--sm' : 'btn btn--ghost btn--sm';
|
|
return `
|
|
<button class="${buttonClass}" data-action="custom" data-action-id="${action.id}">
|
|
${action.icon ? `<i data-lucide="${action.icon}"></i>` : ''}
|
|
${escapeHtml(action.label)}
|
|
</button>
|
|
`;
|
|
}).join('');
|
|
|
|
return `<div class="notification__actions">${actionsHtml}</div>`;
|
|
}
|
|
|
|
/**
|
|
* Set up notification event listeners
|
|
*/
|
|
setupNotificationEvents(element, notification) {
|
|
element.addEventListener('click', (event) => {
|
|
const action = event.target.closest('[data-action]')?.dataset.action;
|
|
|
|
if (action === 'close') {
|
|
this.dismiss(notification.id);
|
|
} else if (action === 'custom') {
|
|
const actionId = event.target.closest('[data-action-id]')?.dataset.actionId;
|
|
const actionConfig = notification.actions.find(a => a.id === actionId);
|
|
|
|
if (actionConfig && actionConfig.handler) {
|
|
try {
|
|
actionConfig.handler(notification);
|
|
} catch (error) {
|
|
console.error('Error in notification action handler:', error);
|
|
}
|
|
}
|
|
|
|
// Auto-dismiss after action unless specified otherwise
|
|
if (!actionConfig || actionConfig.dismissOnClick !== false) {
|
|
this.dismiss(notification.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Pause auto-dismiss on hover
|
|
element.addEventListener('mouseenter', () => {
|
|
if (notification.timer) {
|
|
clearTimeout(notification.timer);
|
|
notification.timer = null;
|
|
}
|
|
});
|
|
|
|
// Resume auto-dismiss on leave (if not persistent)
|
|
element.addEventListener('mouseleave', () => {
|
|
if (notification.timeout && !notification.persistent && !notification.timer) {
|
|
notification.timer = setTimeout(() => {
|
|
this.dismiss(notification.id);
|
|
}, 1000); // Shorter timeout after hover
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dismiss notification by ID
|
|
*/
|
|
dismiss(id) {
|
|
const notification = this.notifications.get(id);
|
|
if (!notification) return;
|
|
|
|
console.log(` Dismissing notification: ${id}`);
|
|
|
|
// Clear timer
|
|
if (notification.timer) {
|
|
clearTimeout(notification.timer);
|
|
notification.timer = null;
|
|
}
|
|
|
|
// Animate out
|
|
if (notification.element) {
|
|
notification.element.classList.add('notification--dismissing');
|
|
|
|
setTimeout(() => {
|
|
if (notification.element && notification.element.parentNode) {
|
|
notification.element.parentNode.removeChild(notification.element);
|
|
}
|
|
this.notifications.delete(id);
|
|
}, this.config.animationDuration);
|
|
} else {
|
|
this.notifications.delete(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dismiss all notifications
|
|
*/
|
|
dismissAll() {
|
|
console.log('Dismissing all notifications');
|
|
|
|
for (const id of this.notifications.keys()) {
|
|
this.dismiss(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dismiss notifications by type
|
|
*/
|
|
dismissByType(type) {
|
|
console.log(` Dismissing all ${type} notifications`);
|
|
|
|
for (const notification of this.notifications.values()) {
|
|
if (notification.type === type) {
|
|
this.dismiss(notification.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update existing notification
|
|
*/
|
|
update(id, updates) {
|
|
const notification = this.notifications.get(id);
|
|
if (!notification) return false;
|
|
|
|
// Update properties
|
|
Object.assign(notification, updates);
|
|
|
|
// Update DOM if needed
|
|
if (updates.title || updates.message) {
|
|
const titleEl = notification.element.querySelector('.notification__title');
|
|
const messageEl = notification.element.querySelector('.notification__message');
|
|
|
|
if (titleEl && updates.title !== undefined) {
|
|
titleEl.textContent = updates.title;
|
|
}
|
|
|
|
if (messageEl && updates.message !== undefined) {
|
|
messageEl.textContent = updates.message;
|
|
}
|
|
}
|
|
|
|
// Update timeout
|
|
if (updates.timeout !== undefined) {
|
|
if (notification.timer) {
|
|
clearTimeout(notification.timer);
|
|
}
|
|
|
|
if (updates.timeout > 0) {
|
|
notification.timer = setTimeout(() => {
|
|
this.dismiss(id);
|
|
}, updates.timeout);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Enforce maximum number of notifications
|
|
*/
|
|
enforceMaxNotifications() {
|
|
const notifications = Array.from(this.notifications.values())
|
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
|
|
while (notifications.length >= this.config.maxNotifications) {
|
|
const oldest = notifications.shift();
|
|
if (oldest && !oldest.persistent) {
|
|
this.dismiss(oldest.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenience methods for different notification types
|
|
*/
|
|
success(title, message, options = {}) {
|
|
return this.show({
|
|
type: 'success',
|
|
title,
|
|
message,
|
|
...options
|
|
});
|
|
}
|
|
|
|
error(title, message, options = {}) {
|
|
return this.show({
|
|
type: 'error',
|
|
title,
|
|
message,
|
|
...options
|
|
});
|
|
}
|
|
|
|
warning(title, message, options = {}) {
|
|
return this.show({
|
|
type: 'warning',
|
|
title,
|
|
message,
|
|
...options
|
|
});
|
|
}
|
|
|
|
info(title, message, options = {}) {
|
|
return this.show({
|
|
type: 'info',
|
|
title,
|
|
message,
|
|
...options
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get notification statistics
|
|
*/
|
|
getStats() {
|
|
return {
|
|
total: this.notifications.size,
|
|
byType: this.getNotificationsByType(),
|
|
oldestTimestamp: this.getOldestNotificationTime(),
|
|
newestTimestamp: this.getNewestNotificationTime()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get notifications grouped by type
|
|
*/
|
|
getNotificationsByType() {
|
|
const byType = {};
|
|
|
|
for (const notification of this.notifications.values()) {
|
|
byType[notification.type] = (byType[notification.type] || 0) + 1;
|
|
}
|
|
|
|
return byType;
|
|
}
|
|
|
|
/**
|
|
* Get oldest notification timestamp
|
|
*/
|
|
getOldestNotificationTime() {
|
|
const notifications = Array.from(this.notifications.values());
|
|
if (notifications.length === 0) return null;
|
|
|
|
return Math.min(...notifications.map(n => n.createdAt.getTime()));
|
|
}
|
|
|
|
/**
|
|
* Get newest notification timestamp
|
|
*/
|
|
getNewestNotificationTime() {
|
|
const notifications = Array.from(this.notifications.values());
|
|
if (notifications.length === 0) return null;
|
|
|
|
return Math.max(...notifications.map(n => n.createdAt.getTime()));
|
|
}
|
|
|
|
/**
|
|
* Cleanup
|
|
*/
|
|
destroy() {
|
|
console.log('Cleaning up Notification Manager...');
|
|
|
|
this.dismissAll();
|
|
|
|
if (this.container && this.container.parentNode) {
|
|
this.container.parentNode.removeChild(this.container);
|
|
}
|
|
|
|
console.log('Notification Manager cleanup complete');
|
|
}
|
|
} |