diff --git a/apps/dashboard/src/app/app-shell.tsx b/apps/dashboard/src/app/app-shell.tsx index 04aab3b..0399ef6 100644 --- a/apps/dashboard/src/app/app-shell.tsx +++ b/apps/dashboard/src/app/app-shell.tsx @@ -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 ( -
+
Skip to content
+ + ); } diff --git a/apps/dashboard/src/app/notifications.css b/apps/dashboard/src/app/notifications.css new file mode 100644 index 0000000..203362d --- /dev/null +++ b/apps/dashboard/src/app/notifications.css @@ -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; } } diff --git a/apps/dashboard/src/app/notifications.tsx b/apps/dashboard/src/app/notifications.tsx new file mode 100644 index 0000000..2ad1b09 --- /dev/null +++ b/apps/dashboard/src/app/notifications.tsx @@ -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; + +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(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(loadNotifications); + const [toasts, setToasts] = useState([]); + + 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(() => ({ + isOpen, + notifications, + toasts, + unreadCount: notifications.filter((item) => !item.isRead).length, + clearAll: () => setNotifications([]), + dismissToast, + setOpen, + }), [dismissToast, isOpen, notifications, setOpen, toasts]); + + return {children}; +} + +function useNotifications(): NotificationContextValue { + const context = useContext(NotificationContext); + if (!context) throw new Error('Notifications must be used within NotificationProvider'); + return context; +} + +function BellIcon() { + return ( + + ); +} + +export function NotificationBell({ className = '' }: { className?: string }) { + const { isOpen, setOpen, unreadCount } = useNotifications(); + return ( + + ); +} + +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 ( + <> +