Initial commit: Clean DSS implementation
Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
This commit is contained in:
298
admin-ui/js/services/notification-service.js
Normal file
298
admin-ui/js/services/notification-service.js
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @fileoverview Manages application-wide notifications.
|
||||
* Handles persistence via IndexedDB, real-time updates via SSE, and state management.
|
||||
*/
|
||||
|
||||
import dssDB from '../db/indexed-db.js';
|
||||
|
||||
const NOTIFICATION_STORE = 'notifications';
|
||||
|
||||
class NotificationService extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this._eventSource = null;
|
||||
this._initialized = false;
|
||||
this._broadcastChannel = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this._initialized) return;
|
||||
this._initialized = true;
|
||||
|
||||
await this._loadFromStorage();
|
||||
this._connectToEvents();
|
||||
this._setupCrossTabSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup cross-tab synchronization via BroadcastChannel API
|
||||
* When notifications are modified in another tab, reload them
|
||||
*/
|
||||
_setupCrossTabSync() {
|
||||
try {
|
||||
this._broadcastChannel = new BroadcastChannel('dss-notifications');
|
||||
|
||||
this._broadcastChannel.onmessage = (event) => {
|
||||
if (event.data?.type === 'notifications-updated') {
|
||||
console.log('[NotificationService] Notifications updated in another tab, reloading...');
|
||||
this._loadFromStorage();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[NotificationService] BroadcastChannel not available:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify other tabs about notification changes
|
||||
* @private
|
||||
*/
|
||||
_notifyOtherTabs() {
|
||||
if (this._broadcastChannel) {
|
||||
try {
|
||||
this._broadcastChannel.postMessage({ type: 'notifications-updated' });
|
||||
} catch (error) {
|
||||
console.warn('[NotificationService] Failed to broadcast update:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _loadFromStorage() {
|
||||
try {
|
||||
this.notifications = await dssDB.getAll(NOTIFICATION_STORE) || [];
|
||||
this._sortNotifications();
|
||||
this._updateUnreadCount();
|
||||
this.dispatchEvent(new CustomEvent('notifications-updated', {
|
||||
detail: { notifications: this.notifications }
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load notifications from storage:', error);
|
||||
this.notifications = [];
|
||||
}
|
||||
}
|
||||
|
||||
_connectToEvents() {
|
||||
// Only connect if SSE endpoint exists and we have an auth token
|
||||
try {
|
||||
// Get access token from localStorage
|
||||
const authTokens = localStorage.getItem('auth_tokens');
|
||||
if (!authTokens) {
|
||||
console.log('[NotificationService] No auth token available, skipping SSE connection');
|
||||
return;
|
||||
}
|
||||
|
||||
const { accessToken } = JSON.parse(authTokens);
|
||||
if (!accessToken) {
|
||||
console.log('[NotificationService] No access token found, skipping SSE connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Construct SSE URL with token parameter (EventSource can't send custom headers)
|
||||
const sseUrl = `/api/notifications/events?token=${encodeURIComponent(accessToken)}`;
|
||||
console.log('[NotificationService] Connecting to SSE with authentication...');
|
||||
|
||||
this._eventSource = new EventSource(sseUrl);
|
||||
|
||||
this._eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const notificationData = JSON.parse(event.data);
|
||||
console.log('[NotificationService] SSE notification received:', notificationData.type);
|
||||
// From SSE, persist to local storage
|
||||
this.create(notificationData, true);
|
||||
} catch (error) {
|
||||
console.error('[NotificationService] Error parsing SSE notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this._eventSource.onerror = (err) => {
|
||||
console.warn('[NotificationService] SSE connection error, using local-only mode');
|
||||
this._eventSource.close();
|
||||
this._eventSource = null;
|
||||
};
|
||||
|
||||
this._eventSource.onopen = () => {
|
||||
console.log('[NotificationService] ✅ SSE connection established successfully');
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[NotificationService] SSE setup error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
_sortNotifications() {
|
||||
this.notifications.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
_updateUnreadCount() {
|
||||
const newCount = this.notifications.filter(n => !n.read).length;
|
||||
if (newCount !== this.unreadCount) {
|
||||
this.unreadCount = newCount;
|
||||
this.dispatchEvent(new CustomEvent('unread-count-changed', {
|
||||
detail: { count: this.unreadCount }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
_dispatchUpdate() {
|
||||
this._sortNotifications();
|
||||
this._updateUnreadCount();
|
||||
this.dispatchEvent(new CustomEvent('notifications-updated', {
|
||||
detail: { notifications: this.notifications }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new notification.
|
||||
* @param {object} notificationData - The notification data.
|
||||
* @param {string} notificationData.title - Notification title
|
||||
* @param {string} [notificationData.message] - Notification message
|
||||
* @param {string} [notificationData.type='info'] - 'info', 'success', 'warning', 'error'
|
||||
* @param {string} [notificationData.source] - Source service name
|
||||
* @param {Array} [notificationData.actions] - Action buttons
|
||||
* @param {boolean} [persist=true] - Whether to save to IndexedDB.
|
||||
* @returns {Promise<object>} The created notification.
|
||||
*/
|
||||
async create(notificationData, persist = true) {
|
||||
const newNotification = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
read: false,
|
||||
type: 'info',
|
||||
...notificationData,
|
||||
};
|
||||
|
||||
this.notifications.unshift(newNotification);
|
||||
|
||||
if (persist) {
|
||||
try {
|
||||
await dssDB.put(NOTIFICATION_STORE, newNotification);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this._dispatchUpdate();
|
||||
this._notifyOtherTabs();
|
||||
|
||||
// Also show as toast for immediate visibility
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast({
|
||||
message: `<strong>${newNotification.title}</strong>${newNotification.message ? `<br>${newNotification.message}` : ''}`,
|
||||
type: newNotification.type,
|
||||
duration: 5000,
|
||||
dismissible: true
|
||||
});
|
||||
}
|
||||
|
||||
return newNotification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all notifications.
|
||||
* @returns {object[]}
|
||||
*/
|
||||
getAll() {
|
||||
return [...this.notifications];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count
|
||||
* @returns {number}
|
||||
*/
|
||||
getUnreadCount() {
|
||||
return this.unreadCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a specific notification as read.
|
||||
* @param {string} id - The ID of the notification to update.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async markAsRead(id) {
|
||||
const notification = this.notifications.find(n => n.id === id);
|
||||
if (notification && !notification.read) {
|
||||
notification.read = true;
|
||||
try {
|
||||
await dssDB.put(NOTIFICATION_STORE, notification);
|
||||
} catch (error) {
|
||||
console.error('Failed to update notification:', error);
|
||||
}
|
||||
this._dispatchUpdate();
|
||||
this._notifyOtherTabs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all unread notifications as read.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async markAllAsRead() {
|
||||
const updates = [];
|
||||
this.notifications.forEach(n => {
|
||||
if (!n.read) {
|
||||
n.read = true;
|
||||
updates.push(dssDB.put(NOTIFICATION_STORE, n));
|
||||
}
|
||||
});
|
||||
|
||||
if (updates.length > 0) {
|
||||
try {
|
||||
await Promise.all(updates);
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all as read:', error);
|
||||
}
|
||||
this._dispatchUpdate();
|
||||
this._notifyOtherTabs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a notification by its ID.
|
||||
* @param {string} id
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async delete(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
try {
|
||||
await dssDB.delete(NOTIFICATION_STORE, id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notification:', error);
|
||||
}
|
||||
this._dispatchUpdate();
|
||||
this._notifyOtherTabs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all notifications.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async clearAll() {
|
||||
this.notifications = [];
|
||||
try {
|
||||
await dssDB.clear(NOTIFICATION_STORE);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear notifications:', error);
|
||||
}
|
||||
this._dispatchUpdate();
|
||||
this._notifyOtherTabs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup on app shutdown
|
||||
*/
|
||||
destroy() {
|
||||
if (this._eventSource) {
|
||||
this._eventSource.close();
|
||||
this._eventSource = null;
|
||||
}
|
||||
if (this._broadcastChannel) {
|
||||
this._broadcastChannel.close();
|
||||
this._broadcastChannel = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
const notificationService = new NotificationService();
|
||||
export default notificationService;
|
||||
Reference in New Issue
Block a user