Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

View 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';
}

View File

@@ -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>
`;
}