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:
402
admin-ui/js/components/ds-notification-center.js
Normal file
402
admin-ui/js/components/ds-notification-center.js
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* @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);
|
||||
Reference in New Issue
Block a user