Files
dss/admin-ui/js/components/ds-notification-center.js
Digital Production Factory 276ed71f31 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
2025-12-09 18:45:48 -03:00

403 lines
12 KiB
JavaScript

/**
* @fileoverview A popover component to display user notifications.
* Grouped by date (Today, Yesterday, Earlier) with mark as read support.
*/
import notificationService from '../services/notification-service.js';
class DsNotificationCenter extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._isConnected = false;
}
connectedCallback() {
this._isConnected = true;
this.render();
this._updateNotifications = this._updateNotifications.bind(this);
notificationService.addEventListener('notifications-updated', this._updateNotifications);
// Initialize the service and get initial notifications
// Only update if component is still connected when promise resolves
notificationService.init().then(() => {
if (this._isConnected) {
this._updateNotifications({ detail: { notifications: notificationService.getAll() } });
}
}).catch((error) => {
console.error('[DsNotificationCenter] Failed to initialize notifications:', error);
});
this.shadowRoot.getElementById('mark-all-read').addEventListener('click', () => {
notificationService.markAllAsRead();
});
this.shadowRoot.getElementById('clear-all').addEventListener('click', () => {
notificationService.clearAll();
});
this.shadowRoot.getElementById('notification-list').addEventListener('click', this._handleNotificationClick.bind(this));
}
disconnectedCallback() {
this._isConnected = false;
notificationService.removeEventListener('notifications-updated', this._updateNotifications);
}
_handleNotificationClick(e) {
const notificationEl = e.target.closest('.notification');
if (!notificationEl) return;
const id = notificationEl.dataset.id;
if (!id) return;
// Mark as read if it was unread
if (notificationEl.classList.contains('unread')) {
notificationService.markAsRead(id);
}
// Handle action button clicks
const actionButton = e.target.closest('[data-event]');
if (actionButton) {
let payload = {};
try {
payload = JSON.parse(actionButton.dataset.payload || '{}');
} catch (e) {
console.error('Invalid action payload:', e);
}
this.dispatchEvent(new CustomEvent('notification-action', {
bubbles: true,
composed: true,
detail: {
event: actionButton.dataset.event,
payload
}
}));
// Close the notification center
this.removeAttribute('open');
}
// Handle delete button
const deleteButton = e.target.closest('.delete-btn');
if (deleteButton) {
e.stopPropagation();
notificationService.delete(id);
}
}
_updateNotifications({ detail }) {
const { notifications } = detail;
const listEl = this.shadowRoot?.getElementById('notification-list');
// Null safety check - component may be disconnecting
if (!listEl) {
console.warn('[DsNotificationCenter] Notification list element not found');
return;
}
if (!notifications || notifications.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/>
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
</svg>
<p>No notifications yet</p>
<span>You're all caught up!</span>
</div>
`;
return;
}
const grouped = this._groupNotificationsByDate(notifications);
let html = '';
for (const [groupTitle, groupNotifications] of Object.entries(grouped)) {
html += `
<div class="group">
<div class="group__title">${groupTitle}</div>
${groupNotifications.map(n => this._renderNotification(n)).join('')}
</div>
`;
}
listEl.innerHTML = html;
}
_groupNotificationsByDate(notifications) {
const groups = {};
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const isSameDay = (d1, d2) =>
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
notifications.forEach(n => {
const date = new Date(n.timestamp);
let groupName;
if (isSameDay(date, today)) {
groupName = 'Today';
} else if (isSameDay(date, yesterday)) {
groupName = 'Yesterday';
} else {
groupName = 'Earlier';
}
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(n);
});
return groups;
}
_renderNotification(n) {
const time = new Date(n.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
const actionsHtml = (n.actions || []).map(action =>
`<button class="action-btn" data-event="${action.event}" data-payload='${JSON.stringify(action.payload || {})}'>${action.label}</button>`
).join('');
return `
<div class="notification ${n.read ? '' : 'unread'}" data-id="${n.id}">
<div class="icon-container">
<div class="dot ${n.type || 'info'}"></div>
</div>
<div class="notification-content">
<p class="title">${this._escapeHtml(n.title)}</p>
${n.message ? `<p class="message">${this._escapeHtml(n.message)}</p>` : ''}
<div class="meta">
<span class="time">${time}</span>
${n.source ? `<span class="source">${n.source}</span>` : ''}
</div>
${actionsHtml ? `<div class="actions">${actionsHtml}</div>` : ''}
</div>
<button class="delete-btn" aria-label="Delete notification">
<svg width="14" height="14" 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>
`;
}
_escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: none;
position: absolute;
top: calc(100% + var(--space-2));
right: 0;
width: 380px;
z-index: 100;
}
:host([open]) {
display: block;
}
.panel {
background: var(--popover);
color: var(--popover-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
max-height: 480px;
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.header h3 {
margin: 0;
font-size: var(--text-base);
font-weight: var(--font-semibold);
}
.header-actions {
display: flex;
gap: var(--space-2);
}
.header-actions button {
font-size: var(--text-xs);
color: var(--primary);
cursor: pointer;
background: none;
border: none;
padding: 0;
}
.header-actions button:hover {
text-decoration: underline;
}
.content {
overflow-y: auto;
flex: 1;
}
.empty-state {
text-align: center;
padding: var(--space-8);
color: var(--muted-foreground);
}
.empty-state svg {
opacity: 0.5;
margin-bottom: var(--space-3);
}
.empty-state p {
margin: 0;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--foreground);
}
.empty-state span {
font-size: var(--text-xs);
}
.group {
border-bottom: 1px solid var(--border);
}
.group:last-child {
border-bottom: none;
}
.group__title {
padding: var(--space-2) var(--space-4);
font-size: var(--text-xs);
font-weight: var(--font-medium);
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--muted);
}
.notification {
display: flex;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
position: relative;
cursor: pointer;
transition: background 0.15s ease;
}
.notification.unread {
background-color: oklch(from var(--primary) l c h / 0.05);
}
.notification:hover {
background-color: var(--accent);
}
.icon-container {
flex-shrink: 0;
width: 10px;
padding-top: 4px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot.info { background-color: var(--primary); }
.dot.success { background-color: var(--success); }
.dot.warning { background-color: var(--warning); }
.dot.error { background-color: var(--destructive); }
.notification-content {
flex: 1;
min-width: 0;
}
.notification-content .title {
margin: 0;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--foreground);
line-height: 1.3;
}
.notification-content .message {
margin: var(--space-1) 0 0;
font-size: var(--text-xs);
color: var(--muted-foreground);
line-height: 1.4;
}
.meta {
display: flex;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--muted-foreground);
margin-top: var(--space-1);
}
.source {
background: var(--muted);
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
}
.actions {
margin-top: var(--space-2);
display: flex;
gap: var(--space-2);
}
.action-btn {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
background: var(--muted);
border: 1px solid var(--border);
color: var(--foreground);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s ease;
}
.action-btn:hover {
background: var(--accent);
}
.delete-btn {
position: absolute;
top: var(--space-2);
right: var(--space-2);
background: none;
border: none;
color: var(--muted-foreground);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--radius);
opacity: 0;
transition: opacity 0.15s ease;
}
.notification:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: var(--destructive);
color: white;
}
</style>
<div class="panel">
<div class="header">
<h3>Notifications</h3>
<div class="header-actions">
<button id="mark-all-read">Mark all read</button>
<button id="clear-all">Clear all</button>
</div>
</div>
<div class="content" id="notification-list">
<!-- Notifications will be rendered here -->
</div>
</div>
`;
}
}
customElements.define('ds-notification-center', DsNotificationCenter);