Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
152
src/js/sw/shell/notification-bell.js
Normal file
152
src/js/sw/shell/notification-bell.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// ==========================================
|
||||
// Shell — Notification Bell Component
|
||||
// ==========================================
|
||||
// Bell icon with unread count badge and dropdown panel.
|
||||
// Fetches notifications on mount and listens for WS events.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useEffect, useCallback, useRef } = window.hooks;
|
||||
|
||||
const BELL_SVG = html`
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>`;
|
||||
|
||||
export function NotificationBell({ onNavigate } = {}) {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read_at).length;
|
||||
|
||||
// ── Fetch notifications ─────────────
|
||||
const reload = useCallback(async () => {
|
||||
if (!window.sw?.api?.notifications?.list) return;
|
||||
try {
|
||||
const resp = await window.sw.api.notifications.list({ per_page: 20 });
|
||||
const items = Array.isArray(resp) ? resp
|
||||
: Array.isArray(resp?.data) ? resp.data
|
||||
: Array.isArray(resp?.notifications) ? resp.notifications
|
||||
: [];
|
||||
setNotifications(items);
|
||||
} catch (_) {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// ── WebSocket live updates ──────────
|
||||
useEffect(() => {
|
||||
if (!window.sw?.on) return;
|
||||
const handler = () => reload();
|
||||
window.sw.on('notification.created', handler);
|
||||
return () => window.sw.off?.('notification.created', handler);
|
||||
}, [reload]);
|
||||
|
||||
// ── Close on outside click ──────────
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _close(e) {
|
||||
if (ref.current?.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
document.addEventListener('click', _close);
|
||||
return () => document.removeEventListener('click', _close);
|
||||
}, [open]);
|
||||
|
||||
// ── Mark as read ────────────────────
|
||||
const _markRead = useCallback(async (id) => {
|
||||
if (!window.sw?.api?.notifications?.markRead) return;
|
||||
try {
|
||||
await window.sw.api.notifications.markRead(id);
|
||||
setNotifications(prev => prev.map(n =>
|
||||
n.id === id ? { ...n, read_at: new Date().toISOString() } : n
|
||||
));
|
||||
} catch (_) {}
|
||||
}, []);
|
||||
|
||||
const _markAllRead = useCallback(async () => {
|
||||
if (window.sw?.api?.notifications?.markAllRead) {
|
||||
try {
|
||||
await window.sw.api.notifications.markAllRead();
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read_at: n.read_at || new Date().toISOString() })));
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
// Fallback: mark one by one
|
||||
const unread = notifications.filter(n => !n.read_at);
|
||||
for (const n of unread) {
|
||||
await _markRead(n.id);
|
||||
}
|
||||
}, [notifications, _markRead]);
|
||||
|
||||
const _toggle = useCallback(() => {
|
||||
setOpen(v => !v);
|
||||
}, []);
|
||||
|
||||
// ── Click-to-navigate ────────────────
|
||||
const _onClick = useCallback((n) => {
|
||||
if (!n.read_at) _markRead(n.id);
|
||||
if (n.resource_type === 'channel' && n.resource_id && onNavigate) {
|
||||
onNavigate(n.resource_id);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [_markRead, onNavigate]);
|
||||
|
||||
return html`
|
||||
<div class="sw-notification-bell" ref=${ref}>
|
||||
<button class="sw-notification-bell__trigger"
|
||||
onClick=${_toggle}
|
||||
title="Notifications"
|
||||
aria-label="Notifications"
|
||||
aria-expanded=${open}>
|
||||
${BELL_SVG}
|
||||
${unreadCount > 0 && html`
|
||||
<span class="sw-notification-bell__badge">${unreadCount > 9 ? '9+' : unreadCount}</span>`}
|
||||
</button>
|
||||
${open && html`
|
||||
<div class="sw-notification-bell__panel">
|
||||
<div class="sw-notification-bell__header">
|
||||
<span>Notifications</span>
|
||||
${unreadCount > 0 && html`
|
||||
<button class="sw-notification-bell__mark-all"
|
||||
onClick=${_markAllRead}>
|
||||
Mark all read
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="sw-notification-bell__list">
|
||||
${notifications.length === 0 && html`
|
||||
<div class="sw-notification-bell__empty">No notifications</div>`}
|
||||
${notifications.map(n => {
|
||||
const navigable = n.resource_type === 'channel' && n.resource_id && onNavigate;
|
||||
return html`
|
||||
<div key=${n.id}
|
||||
class=${'sw-notification-bell__item'
|
||||
+ (n.read_at ? '' : ' sw-notification-bell__item--unread')
|
||||
+ (navigable ? ' sw-notification-bell__item--navigable' : '')}
|
||||
onClick=${() => _onClick(n)}>
|
||||
<div class="sw-notification-bell__item-text">${n.title || n.message || 'Notification'}</div>
|
||||
${n.body && html`<div class="sw-notification-bell__item-body">${n.body}</div>`}
|
||||
<div class="sw-notification-bell__item-time">
|
||||
${_timeAgo(n.created_at)}
|
||||
${navigable && html`<span class="sw-notification-bell__item-action">Open</span>`}
|
||||
</div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _timeAgo(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return mins + 'm ago';
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return hrs + 'h ago';
|
||||
const days = Math.floor(hrs / 24);
|
||||
return days + 'd ago';
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
* extraItems — Additional menu items inserted after surface links
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useRef, useMemo } = hooks;
|
||||
const { useState, useRef, useMemo, useEffect } = hooks;
|
||||
|
||||
import { Avatar } from '../primitives/avatar.js';
|
||||
import { Menu } from '../primitives/menu.js';
|
||||
@@ -27,12 +27,23 @@ function _currentSurface() {
|
||||
|
||||
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [extSurfaces, setExtSurfaces] = useState([]);
|
||||
const btnRef = useRef(null);
|
||||
|
||||
const sw = window.sw;
|
||||
const user = sw?.auth?.user;
|
||||
const authenticated = sw?.auth?.isAuthenticated;
|
||||
|
||||
// Fetch extension surfaces once on mount
|
||||
useEffect(() => {
|
||||
if (!sw?.api?.surfaces?.list) return;
|
||||
sw.api.surfaces.list().then(data => {
|
||||
const all = data || [];
|
||||
const CORE = new Set(['chat', 'admin', 'notes', 'settings', 'team-admin', 'workflow', 'workflow-landing']);
|
||||
setExtSurfaces(all.filter(s => !CORE.has(s.id)));
|
||||
}).catch(() => {});
|
||||
}, [authenticated]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const current = _currentSurface();
|
||||
const list = [];
|
||||
@@ -44,10 +55,9 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
{ key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' },
|
||||
];
|
||||
|
||||
// Add extension surfaces from page data if available
|
||||
const extSurfaces = window.__EXTENSION_SURFACES__ || [];
|
||||
// Add extension surfaces fetched from API
|
||||
for (const ext of extSurfaces) {
|
||||
surfaces.push({ key: ext.id || ext.route, label: ext.title, icon: '\ud83e\udde9' });
|
||||
surfaces.push({ key: ext.id, label: ext.title, icon: '\ud83e\udde9', route: ext.route });
|
||||
}
|
||||
|
||||
const surfaceItems = surfaces
|
||||
@@ -72,18 +82,18 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
}
|
||||
|
||||
// Admin — requires admin role
|
||||
if ((!authenticated || sw?.isAdmin) && current !== 'admin') {
|
||||
if (authenticated && sw?.isAdmin && current !== 'admin') {
|
||||
list.push({ label: 'Admin', action: 'admin', icon: '\ud83d\udee1\ufe0f' });
|
||||
}
|
||||
|
||||
// Team Admin — requires admin role on at least one team
|
||||
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
|
||||
if ((!authenticated || hasTeamAdmin) && current !== 'team-admin') {
|
||||
if (authenticated && hasTeamAdmin && current !== 'team-admin') {
|
||||
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
|
||||
}
|
||||
|
||||
// Debug — admin only
|
||||
if (!authenticated || sw?.isAdmin) {
|
||||
if (authenticated && sw?.isAdmin) {
|
||||
list.push({ label: 'Debug', action: 'debug', icon: '\ud83d\udc1b' });
|
||||
}
|
||||
|
||||
@@ -91,7 +101,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
list.push({ label: 'Sign Out', action: 'sign-out' });
|
||||
|
||||
return list;
|
||||
}, [user, authenticated, extraItems]);
|
||||
}, [user, authenticated, extraItems, extSurfaces]);
|
||||
|
||||
function handleSelect(action) {
|
||||
setOpen(false);
|
||||
@@ -101,7 +111,9 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
const BASE = window.__BASE__ || '';
|
||||
switch (action) {
|
||||
case 'sign-out':
|
||||
window.sw?.auth?.logout();
|
||||
window.sw?.auth?.logout().then(() => {
|
||||
location.href = BASE + '/login';
|
||||
});
|
||||
break;
|
||||
case 'debug':
|
||||
const dbg = document.getElementById('debugModal');
|
||||
@@ -110,8 +122,12 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
case 'chat':
|
||||
location.href = BASE + '/';
|
||||
break;
|
||||
default:
|
||||
location.href = BASE + '/' + action;
|
||||
default: {
|
||||
// Extension surfaces use /s/{id} routes
|
||||
const ext = extSurfaces.find(s => s.id === action);
|
||||
location.href = BASE + (ext?.route || '/' + action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,17 +135,20 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
const avatar = user?.avatar || null;
|
||||
|
||||
return html`
|
||||
<button class="sw-user-menu__trigger" ref=${btnRef}
|
||||
onClick=${() => setOpen(!open)} aria-label="User menu">
|
||||
<${Avatar} src=${avatar} name=${displayName} size="sm" />
|
||||
</button>
|
||||
<${Menu}
|
||||
open=${open}
|
||||
anchor=${btnRef.current}
|
||||
direction=${placement}
|
||||
items=${items}
|
||||
onSelect=${handleSelect}
|
||||
onClose=${() => setOpen(false)}
|
||||
/>
|
||||
<div class="user-menu-wrap">
|
||||
<button class="user-btn" ref=${btnRef}
|
||||
onClick=${() => setOpen(!open)} aria-label="User menu">
|
||||
<${Avatar} src=${avatar} name=${displayName} size="sm" />
|
||||
<span class="sb-label">${displayName}</span>
|
||||
</button>
|
||||
<${Menu}
|
||||
open=${open}
|
||||
anchor=${btnRef.current}
|
||||
direction=${placement}
|
||||
items=${items}
|
||||
onSelect=${handleSelect}
|
||||
onClose=${() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user