Add notification centre and realtime toasts

This commit is contained in:
jjbrunton committed 2026-08-09 08:08:44 +01:00
1 parent b400042244
commit a85b99ceb6
5 files changed
+515 -12

No files matched your search

+18 -12
View File
@@ -2,12 +2,13 @@ import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { navigationItems } from './navigation.js';
import { ShellStatusPanel } from './shell-status-panel.js';
import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js';
export function AppShell() {
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
return (
<div className="app-shell">
<NotificationProvider><div className="app-shell">
<a className="skip-link" href="#main-content">Skip to content</a>
<aside className={isNavigationOpen ? 'sidebar is-open' : 'sidebar'}>
<div className="brand">
@@ -16,6 +17,7 @@ export function AppShell() {
<strong>Reddit Bot</strong>
<span>Operator console</span>
</div>
<NotificationBell />
</div>
<nav aria-label="Primary navigation" id="primary-navigation">
{navigationItems.map((item) => (
@@ -45,21 +47,25 @@ export function AppShell() {
<div className="workspace">
<header className="mobile-header">
<span>Reddit Bot</span>
<button
aria-controls="primary-navigation"
aria-expanded={isNavigationOpen}
aria-label={isNavigationOpen ? 'Close navigation' : 'Open navigation'}
className="icon-button"
onClick={() => setIsNavigationOpen((current) => !current)}
type="button"
>
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
</button>
<div className="mobile-header-actions">
<NotificationBell />
<button
aria-controls="primary-navigation"
aria-expanded={isNavigationOpen}
aria-label={isNavigationOpen ? 'Close navigation' : 'Open navigation'}
className="icon-button"
onClick={() => setIsNavigationOpen((current) => !current)}
type="button"
>
<span aria-hidden="true">{isNavigationOpen ? '×' : '☰'}</span>
</button>
</div>
</header>
<main id="main-content" tabIndex={-1}>
<Outlet />
</main>
</div>
</div>
<NotificationViewport />
</div></NotificationProvider>
);
}
+57
View File
@@ -0,0 +1,57 @@
.notification-bell {
position: relative;
display: grid;
flex: 0 0 auto;
place-items: center;
width: 2.5rem;
height: 2.5rem;
padding: 0;
color: #dfe9e3;
background: var(--surface);
border: 1px solid var(--border);
border-radius: .7rem;
}
.notification-bell:hover { color: var(--accent); border-color: #3d5a4c; }
.notification-bell:focus-visible, .notification-close:focus-visible, .notification-clear:focus-visible, .notification-entry:focus-visible, .notification-toast a:focus-visible, .notification-toast button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.notification-bell svg, .notification-empty svg { width: 1.2rem; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
.notification-badge { position: absolute; top: -.35rem; right: -.35rem; display: grid; place-items: center; min-width: 1.15rem; height: 1.15rem; padding: 0 .25rem; color: #07130c; background: var(--accent); border: 2px solid #0c1511; border-radius: 999px; font-size: .62rem; font-weight: 900; line-height: 1; }
.brand { justify-content: space-between; }
.brand > div { margin-right: auto; }
.notification-backdrop { position: fixed; inset: 0; z-index: 69; width: 100%; padding: 0; background: rgba(0, 0, 0, .52); border: 0; }
.notification-centre { position: fixed; inset: 0 0 0 auto; z-index: 70; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(27rem, 100%); visibility: hidden; background: #0c1511; border-left: 1px solid var(--border); box-shadow: -1.5rem 0 4rem rgba(0, 0, 0, .38); transform: translateX(100%); transition: transform 220ms ease, visibility 220ms; }
.notification-centre.is-open { visibility: visible; transform: translateX(0); }
.notification-centre > header { display: flex; align-items: center; justify-content: space-between; min-height: 5.5rem; padding: 1.2rem 1.25rem; border-bottom: 1px solid var(--border); }
.notification-centre h2 { font-size: 1.35rem; letter-spacing: -.025em; }
.notification-centre .eyebrow { margin-bottom: .25rem; }
.notification-close { display: grid; place-items: center; width: 2.35rem; height: 2.35rem; padding: 0; color: var(--muted); background: transparent; border: 1px solid var(--border); border-radius: .6rem; font-size: 1.5rem; line-height: 1; }
.notification-list { min-height: 0; padding: 0; margin: 0; overflow-y: auto; list-style: none; }
.notification-list li { border-bottom: 1px solid var(--border); }
.notification-entry { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .8rem; padding: 1rem 1.25rem; color: inherit; text-decoration: none; }
a.notification-entry:hover { background: var(--surface); }
.notification-signal { width: .55rem; height: .55rem; margin-top: .3rem; background: #77a6d8; border-radius: 50%; box-shadow: 0 0 0 .3rem rgba(119, 166, 216, .1); }
.notification-signal.success { background: var(--accent); box-shadow: 0 0 0 .3rem rgba(117, 214, 157, .1); }
.notification-signal.warning { background: var(--warning); box-shadow: 0 0 0 .3rem rgba(243, 199, 107, .1); }
.notification-signal.error { background: var(--danger); box-shadow: 0 0 0 .3rem rgba(255, 138, 132, .1); }
.notification-copy { display: grid; gap: .28rem; min-width: 0; }
.notification-copy strong { color: #edf5f0; font-size: .9rem; line-height: 1.3; }
.notification-copy > span { overflow: hidden; color: var(--muted); font-size: .82rem; line-height: 1.45; text-overflow: ellipsis; }
.notification-copy time { color: #71857b; font-size: .7rem; }
.notification-centre > footer { display: flex; justify-content: flex-end; padding: .8rem 1.25rem; border-top: 1px solid var(--border); }
.notification-clear { padding: .4rem 0; color: var(--muted); background: transparent; border: 0; font-size: .78rem; font-weight: 700; }
.notification-clear:hover { color: #edf5f0; }
.notification-empty { display: grid; place-items: center; align-content: center; padding: 2rem; text-align: center; }
.notification-empty > span { display: grid; place-items: center; width: 3.25rem; height: 3.25rem; margin-bottom: .9rem; color: var(--accent); background: var(--accent-dark); border: 1px solid #28593d; border-radius: 1rem; }
.notification-empty strong { margin-bottom: .35rem; font-size: 1rem; }
.notification-empty p { max-width: 18rem; margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.5; }
.toast-stack { position: fixed; right: 1rem; bottom: 1rem; z-index: 75; display: grid; gap: .65rem; width: min(24rem, calc(100% - 2rem)); pointer-events: none; }
.notification-toast { position: relative; overflow: hidden; background: #15221c; border: 1px solid #355044; border-radius: .85rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .4); pointer-events: auto; animation: notification-arrive 220ms ease-out both; }
.notification-toast.warning { background: #282112; border-color: #5b4922; }
.notification-toast.error { background: #2d1715; border-color: #69332e; }
.notification-toast > a, .notification-toast > div { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .75rem; padding: .9rem 2.75rem .9rem 1rem; color: inherit; text-decoration: none; }
.notification-toast > a:hover { background: rgba(255, 255, 255, .035); }
.notification-toast .notification-copy time { display: none; }
.notification-toast > button { position: absolute; top: .5rem; right: .5rem; width: 1.8rem; height: 1.8rem; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: .35rem; font-size: 1.2rem; }
@keyframes notification-arrive { from { opacity: 0; transform: translateY(.75rem) scale(.98); } }
@media (prefers-reduced-motion: reduce) { .notification-centre, .notification-toast { transition: none; animation: none; } }
@media (max-width: 47.999rem) { .brand .notification-bell { display: none; } .toast-stack { bottom: .75rem; } }
@media (min-width: 48rem) { .mobile-header .notification-bell { display: none; } }
+346
View File
@@ -0,0 +1,346 @@
import type { DashboardEvent } from '../../../../packages/api-contracts/src/index.js';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from 'react';
import { Link } from 'react-router-dom';
import { z } from 'zod';
import { useDashboardRealtime } from './realtime-provider.js';
import './notifications.css';
export type NotificationTone = 'info' | 'success' | 'warning' | 'error';
export interface NotificationItem {
id: string;
title: string;
message: string;
occurredAt: string;
tone: NotificationTone;
href?: string;
isRead: boolean;
}
export type NotificationDraft = Omit<NotificationItem, 'isRead'>;
interface NotificationContextValue {
isOpen: boolean;
notifications: readonly NotificationItem[];
toasts: readonly NotificationItem[];
unreadCount: number;
clearAll(): void;
dismissToast(id: string): void;
setOpen(isOpen: boolean): void;
}
interface RealtimeSubscription {
subscribe(listener: (event: DashboardEvent) => void): () => void;
}
const STORAGE_KEY = 'reddit-bot.notifications.v1';
const MAX_NOTIFICATIONS = 50;
const NotificationContext = createContext<NotificationContextValue | null>(null);
const StoredNotificationsSchema = z.array(z.strictObject({
id: z.string(),
title: z.string(),
message: z.string(),
occurredAt: z.string(),
tone: z.enum(['info', 'success', 'warning', 'error']),
href: z.string().optional(),
isRead: z.boolean(),
}));
function notificationStorage(): Storage | undefined {
try {
return globalThis.localStorage;
} catch {
return undefined;
}
}
function titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1).replaceAll('-', ' ');
}
export function notificationForEvent(event: DashboardEvent): NotificationDraft | null {
switch (event.type) {
case 'reply.received':
return {
id: event.id,
occurredAt: event.occurredAt,
title: `New reply from u/${event.payload.sender}`,
message: event.payload.preview?.trim() || 'A new message arrived in this conversation.',
tone: 'success',
href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`,
};
case 'message.sent':
return {
id: event.id,
occurredAt: event.occurredAt,
title: `Message sent to u/${event.payload.recipient}`,
message: 'The message was delivered successfully.',
tone: 'success',
href: event.payload.conversationId
? `/conversations/${encodeURIComponent(event.payload.conversationId)}`
: '/conversations',
};
case 'message.failed':
return {
id: event.id,
occurredAt: event.occurredAt,
title: `Message to u/${event.payload.recipient} failed`,
message: event.payload.reason,
tone: 'error',
href: event.payload.conversationId
? `/conversations/${encodeURIComponent(event.payload.conversationId)}`
: '/operations',
};
case 'bot.status.changed':
return {
id: event.id,
occurredAt: event.occurredAt,
title: `Bot ${event.payload.status}`,
message: event.payload.reason ?? `The bot is now ${event.payload.status}.`,
tone: event.payload.status === 'running' ? 'success' : event.payload.status === 'stopped' ? 'warning' : 'info',
href: '/operations',
};
case 'sync.completed':
return {
id: event.id,
occurredAt: event.occurredAt,
title: `${titleCase(event.payload.kind)} sync complete`,
message: `${event.payload.processed.toLocaleString()} items processed.`,
tone: 'success',
href: '/operations',
};
case 'sync.failed':
return {
id: event.id,
occurredAt: event.occurredAt,
title: `${titleCase(event.payload.kind)} sync failed`,
message: event.payload.reason,
tone: 'error',
href: '/operations',
};
case 'rate-limit.changed':
return {
id: event.id,
occurredAt: event.occurredAt,
title: event.payload.limited ? 'Rate limit reached' : 'Rate limit cleared',
message: event.payload.reason ?? (event.payload.limited
? 'Sending will resume when the limit resets.'
: 'The bot can send messages again.'),
tone: event.payload.limited ? 'warning' : 'success',
href: '/operations',
};
case 'reddit.session.changed':
if (!['authenticated', 'expired', 'logged-out', 'error'].includes(event.payload.status)) return null;
return {
id: event.id,
occurredAt: event.occurredAt,
title: event.payload.status === 'authenticated' ? 'Reddit account connected' : 'Reddit account needs attention',
message: event.payload.reason ?? (event.payload.username
? `Signed in as u/${event.payload.username}.`
: `Session status changed to ${event.payload.status}.`),
tone: event.payload.status === 'authenticated' ? 'success' : 'error',
href: '/settings',
};
case 'conversation.updated':
case 'sync.started':
case 'sync.progress':
case 'log.created':
return null;
}
}
function loadNotifications(): NotificationItem[] {
const storage = notificationStorage();
if (!storage) return [];
try {
const value: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? '[]');
const parsed = StoredNotificationsSchema.safeParse(value);
return parsed.success ? parsed.data.slice(0, MAX_NOTIFICATIONS) : [];
} catch {
return [];
}
}
export interface NotificationProviderProps extends PropsWithChildren {
realtime?: RealtimeSubscription;
}
export function NotificationProvider({ children, realtime: realtimeOverride }: NotificationProviderProps) {
const dashboardRealtime = useDashboardRealtime();
const realtime = realtimeOverride ?? dashboardRealtime;
const [isOpen, setIsOpenState] = useState(false);
const [notifications, setNotifications] = useState<NotificationItem[]>(loadNotifications);
const [toasts, setToasts] = useState<NotificationItem[]>([]);
const dismissToast = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const setOpen = useCallback((nextOpen: boolean) => {
setIsOpenState(nextOpen);
if (nextOpen) setNotifications((current) => current.map((item) => ({ ...item, isRead: true })));
}, []);
useEffect(() => {
try {
notificationStorage()?.setItem(STORAGE_KEY, JSON.stringify(notifications));
} catch {
// Storage can be unavailable in private or restricted browser contexts.
}
}, [notifications]);
useEffect(() => {
if (isOpen && notifications.some((item) => !item.isRead)) {
setNotifications((current) => current.map((item) => ({ ...item, isRead: true })));
}
}, [isOpen, notifications]);
useEffect(() => realtime.subscribe((event) => {
const draft = notificationForEvent(event);
if (!draft) return;
const item = { ...draft, isRead: false };
setNotifications((current) => current.some((entry) => entry.id === item.id)
? current
: [item, ...current].slice(0, MAX_NOTIFICATIONS));
setToasts((current) => current.some((entry) => entry.id === item.id)
? current
: [...current, item].slice(-3));
}), [realtime]);
useEffect(() => {
if (toasts.length === 0) return undefined;
const timers = toasts.map((toast) => globalThis.setTimeout(
() => dismissToast(toast.id),
toast.tone === 'error' ? 8_000 : 5_000,
));
return () => timers.forEach((timer) => globalThis.clearTimeout(timer));
}, [dismissToast, toasts]);
useEffect(() => {
function closeOnEscape(event: KeyboardEvent): void {
if (event.key === 'Escape') setOpen(false);
}
globalThis.addEventListener('keydown', closeOnEscape);
return () => globalThis.removeEventListener('keydown', closeOnEscape);
}, [setOpen]);
const value = useMemo<NotificationContextValue>(() => ({
isOpen,
notifications,
toasts,
unreadCount: notifications.filter((item) => !item.isRead).length,
clearAll: () => setNotifications([]),
dismissToast,
setOpen,
}), [dismissToast, isOpen, notifications, setOpen, toasts]);
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>;
}
function useNotifications(): NotificationContextValue {
const context = useContext(NotificationContext);
if (!context) throw new Error('Notifications must be used within NotificationProvider');
return context;
}
function BellIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4" />
</svg>
);
}
export function NotificationBell({ className = '' }: { className?: string }) {
const { isOpen, setOpen, unreadCount } = useNotifications();
return (
<button
aria-controls="notification-centre"
aria-expanded={isOpen}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
className={`notification-bell ${className}`.trim()}
onClick={() => setOpen(!isOpen)}
type="button"
>
<BellIcon />
{unreadCount > 0 ? <span className="notification-badge">{unreadCount > 99 ? '99+' : unreadCount}</span> : null}
</button>
);
}
function formattedTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date);
}
function NotificationContent({ item }: { item: NotificationItem }) {
return (
<>
<span className={`notification-signal ${item.tone}`} aria-hidden="true" />
<span className="notification-copy">
<strong>{item.title}</strong>
<span>{item.message}</span>
<time dateTime={item.occurredAt}>{formattedTime(item.occurredAt)}</time>
</span>
</>
);
}
export function NotificationViewport() {
const { clearAll, dismissToast, isOpen, notifications, setOpen, toasts } = useNotifications();
return (
<>
{isOpen ? <button aria-label="Close notifications" className="notification-backdrop" onClick={() => setOpen(false)} type="button" /> : null}
<aside aria-label="Notifications" className={isOpen ? 'notification-centre is-open' : 'notification-centre'} id="notification-centre">
<header>
<div>
<span className="eyebrow">Activity inbox</span>
<h2>Notifications</h2>
</div>
<button aria-label="Close notification centre" className="notification-close" onClick={() => setOpen(false)} type="button">×</button>
</header>
{notifications.length > 0 ? (
<>
<ol className="notification-list">
{notifications.map((item) => (
<li key={item.id}>
{item.href ? (
<Link className="notification-entry" onClick={() => setOpen(false)} to={item.href}>
<NotificationContent item={item} />
</Link>
) : <div className="notification-entry"><NotificationContent item={item} /></div>}
</li>
))}
</ol>
<footer><button className="notification-clear" onClick={clearAll} type="button">Clear all</button></footer>
</>
) : (
<div className="notification-empty">
<span aria-hidden="true"><BellIcon /></span>
<strong>All quiet</strong>
<p>Replies, delivery updates, syncs, and account alerts will appear here.</p>
</div>
)}
</aside>
<div aria-atomic="false" aria-live="polite" className="toast-stack">
{toasts.map((toast) => (
<div className={`notification-toast ${toast.tone}`} key={toast.id} role={toast.tone === 'error' ? 'alert' : 'status'}>
{toast.href ? (
<Link onClick={() => dismissToast(toast.id)} to={toast.href}><NotificationContent item={toast} /></Link>
) : <div><NotificationContent item={toast} /></div>}
<button aria-label={`Dismiss ${toast.title}`} onClick={() => dismissToast(toast.id)} type="button">×</button>
</div>
))}
</div>
</>
);
}