Skip to content

Commit 744c0e2

Browse files
committed
feat(web): add notification center (task-31)
- Create NotificationContext with centralized state management - Add NotificationCenter component with bell icon and dropdown - Integrate Mantine notifications for toast alerts - Support notification categories (success, error, info, warning) - Add mark as read, dismiss, and clear all functionality - 3-second auto-close per user decision - Max 50 notifications retained - Display unread count badge on bell icon
1 parent 4709f09 commit 744c0e2

File tree

4 files changed

+389
-10
lines changed

4 files changed

+389
-10
lines changed

apps/web/src/components/layout/MainLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { ColorSchemeSelector } from "./ColorSchemeSelector";
4646
import { HeaderSearchInput } from "./HeaderSearchInput";
4747
import { HistorySidebar } from "./HistorySidebar";
4848
import { LeftRibbon } from "./LeftRibbon";
49+
import { NotificationCenter } from "@/components/notifications/NotificationCenter";
4950
import { RightRibbon } from "./RightRibbon";
5051
import { RightSidebarContent } from "./RightSidebarContent";
5152
// import { ThemeDropdown } from "./ThemeDropdown";
@@ -308,6 +309,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
308309

309310
{/* Sidebar controls - simplified on mobile */}
310311
<Group gap="xs" visibleFrom="sm">
312+
<NotificationCenter />
311313
<ActionIcon
312314
onClick={toggleLeftSidebar}
313315
variant="subtle"
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* Notification Center Component
3+
*
4+
* Displays persistent notification center with notification history.
5+
* Shows toast-style notifications with mark as read and dismiss functionality.
6+
*/
7+
8+
import { useNotifications } from '@/contexts/NotificationContext';
9+
10+
import {
11+
ActionIcon,
12+
Box,
13+
Button,
14+
Center,
15+
Container,
16+
Group,
17+
Menu,
18+
Stack,
19+
Text,
20+
Title,
21+
UnstyledButton,
22+
useMantineTheme,
23+
} from '@mantine/core';
24+
import { useDisclosure } from '@mantine/hooks';
25+
import {
26+
IconBell,
27+
IconBellRinging,
28+
IconCheck,
29+
IconBellOff,
30+
IconX,
31+
} from '@tabler/icons-react';
32+
import { memo } from 'react';
33+
34+
import { ICON_SIZE } from '@/config/style-constants';
35+
36+
interface NotificationBellProps {
37+
onClick: () => void;
38+
unreadCount: number;
39+
}
40+
41+
const NotificationBell = memo(({ onClick, unreadCount }: NotificationBellProps) => {
42+
const theme = useMantineTheme();
43+
44+
return (
45+
<UnstyledButton onClick={onClick} aria-label={`Open notifications (${unreadCount} unread)`}>
46+
<Box pos="relative">
47+
<ActionIcon
48+
variant="subtle"
49+
color="gray"
50+
size="lg"
51+
>
52+
{unreadCount > 0 ? <IconBellRinging size={ICON_SIZE.MD} /> : <IconBell size={ICON_SIZE.MD} />}
53+
</ActionIcon>
54+
{unreadCount > 0 && (
55+
<Box
56+
pos="absolute"
57+
top={-4}
58+
right={-4}
59+
bg="red"
60+
c="white"
61+
style={{
62+
borderRadius: '9999px',
63+
minWidth: 18,
64+
height: 18,
65+
fontSize: 11,
66+
fontWeight: 700,
67+
display: 'flex',
68+
alignItems: 'center',
69+
justifyContent: 'center',
70+
border: `2px solid ${theme.colors.gray[0]}`,
71+
}}
72+
>
73+
{unreadCount > 9 ? '9+' : unreadCount}
74+
</Box>
75+
)}
76+
</Box>
77+
</UnstyledButton>
78+
);
79+
});
80+
81+
NotificationBell.displayName = 'NotificationBell';
82+
83+
interface NotificationItemProps {
84+
notification: {
85+
id: string;
86+
title: string;
87+
message: string;
88+
category: string;
89+
timestamp: Date;
90+
read: boolean;
91+
};
92+
onMarkAsRead: (id: string) => void;
93+
onDismiss: (id: string) => void;
94+
}
95+
96+
const NotificationItem = memo(({ notification, onMarkAsRead, onDismiss }: NotificationItemProps) => {
97+
const formatTime = (date: Date): string => {
98+
const now = new Date();
99+
const diffMs = now.getTime() - date.getTime();
100+
const diffMins = Math.floor(diffMs / 60000);
101+
102+
if (diffMins < 1) return 'Just now';
103+
if (diffMins < 60) return `${diffMins}m ago`;
104+
const diffHours = Math.floor(diffMins / 60);
105+
if (diffHours < 24) return `${diffHours}h ago`;
106+
const diffDays = Math.floor(diffHours / 24);
107+
if (diffDays < 7) return `${diffDays}d ago`;
108+
return date.toLocaleDateString();
109+
};
110+
111+
const categoryColors: Record<string, string> = {
112+
success: 'teal',
113+
error: 'red',
114+
warning: 'orange',
115+
info: 'blue',
116+
};
117+
118+
return (
119+
<UnstyledButton
120+
p="sm"
121+
style={{
122+
borderBottom: '1px solid var(--mantine-color-gray-2)',
123+
display: 'block',
124+
width: '100%',
125+
textAlign: 'left',
126+
backgroundColor: notification.read ? 'transparent' : 'var(--mantine-color-blue-0)',
127+
}}
128+
onClick={() => onMarkAsRead(notification.id)}
129+
>
130+
<Group gap="sm" wrap="nowrap">
131+
<Box
132+
style={{
133+
width: 8,
134+
height: 8,
135+
borderRadius: '50%',
136+
backgroundColor: `var(--mantine-color-${categoryColors[notification.category]}-6)`,
137+
flexShrink: 0,
138+
marginTop: 4,
139+
}}
140+
/>
141+
<Box style={{ flex: 1, minWidth: 0 }}>
142+
<Text size="sm" fw={500} lineClamp={1}>
143+
{notification.title}
144+
</Text>
145+
<Text size="xs" c="dimmed" lineClamp={2}>
146+
{notification.message}
147+
</Text>
148+
<Text size="xs" c="dimmed" mt={4}>
149+
{formatTime(notification.timestamp)}
150+
</Text>
151+
</Box>
152+
<ActionIcon
153+
size="sm"
154+
variant="subtle"
155+
color="gray"
156+
onClick={(e) => {
157+
e.stopPropagation();
158+
onDismiss(notification.id);
159+
}}
160+
aria-label={`Dismiss notification: ${notification.title}`}
161+
>
162+
<IconX size={ICON_SIZE.XS} />
163+
</ActionIcon>
164+
</Group>
165+
</UnstyledButton>
166+
);
167+
});
168+
169+
NotificationItem.displayName = 'NotificationItem';
170+
171+
export const NotificationCenter = memo(() => {
172+
const { notifications: allNotifications, unreadCount, markAsRead, markAllAsRead, dismissNotification, clearAll } = useNotifications();
173+
const [opened, { toggle, close }] = useDisclosure(false);
174+
175+
return (
176+
<Menu
177+
opened={opened}
178+
onChange={toggle}
179+
position="bottom-end"
180+
offset={12}
181+
width={350}
182+
shadow="md"
183+
>
184+
<Menu.Target>
185+
<NotificationBell onClick={toggle} unreadCount={unreadCount} />
186+
</Menu.Target>
187+
188+
<Menu.Dropdown p={0}>
189+
<Container py="xs" px="sm">
190+
<Group justify="space-between" align="center">
191+
<Group gap="xs">
192+
<Title order={6}>Notifications</Title>
193+
{unreadCount > 0 && (
194+
<Text size="xs" c="blue" fw={500}>
195+
{unreadCount} new
196+
</Text>
197+
)}
198+
</Group>
199+
{allNotifications.length > 0 && (
200+
<Group gap="xs">
201+
{unreadCount > 0 && (
202+
<Button
203+
size="xs"
204+
variant="subtle"
205+
leftSection={<IconCheck size={ICON_SIZE.XS} />}
206+
onClick={markAllAsRead}
207+
>
208+
Mark all read
209+
</Button>
210+
)}
211+
<Button
212+
size="xs"
213+
variant="subtle"
214+
color="red"
215+
leftSection={<IconBellOff size={ICON_SIZE.XS} />}
216+
onClick={clearAll}
217+
>
218+
Clear all
219+
</Button>
220+
</Group>
221+
)}
222+
</Group>
223+
</Container>
224+
225+
{allNotifications.length === 0 ? (
226+
<Center p="xl">
227+
<Stack align="center" gap="sm">
228+
<Box c="dimmed">
229+
<IconBell size={ICON_SIZE.EMPTY_STATE_SM} />
230+
</Box>
231+
<Text size="sm" c="dimmed">
232+
No notifications yet
233+
</Text>
234+
</Stack>
235+
</Center>
236+
) : (
237+
<Box style={{ maxHeight: 400, overflowY: 'auto' }}>
238+
{allNotifications.map((notification) => (
239+
<NotificationItem
240+
key={notification.id}
241+
notification={notification}
242+
onMarkAsRead={markAsRead}
243+
onDismiss={dismissNotification}
244+
/>
245+
))}
246+
</Box>
247+
)}
248+
</Menu.Dropdown>
249+
</Menu>
250+
);
251+
});
252+
253+
NotificationCenter.displayName = 'NotificationCenter';
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Notification Context
3+
*
4+
* Provides centralized notification state management for the entire application.
5+
* Supports toast notifications and persistent notification center.
6+
*/
7+
8+
import { type NotificationData, notifications } from '@mantine/notifications';
9+
import React, { createContext, useCallback, useContext, useState } from 'react';
10+
11+
export type NotificationCategory = 'success' | 'error' | 'info' | 'warning';
12+
13+
export interface AppNotification {
14+
id: string;
15+
title: string;
16+
message: string;
17+
category: NotificationCategory;
18+
timestamp: Date;
19+
read: boolean;
20+
}
21+
22+
interface NotificationContextValue {
23+
notifications: AppNotification[];
24+
unreadCount: number;
25+
showNotification: (notification: {
26+
title: string;
27+
message: string;
28+
category: NotificationCategory;
29+
}) => void;
30+
markAsRead: (id: string) => void;
31+
markAllAsRead: () => void;
32+
dismissNotification: (id: string) => void;
33+
clearAll: () => void;
34+
}
35+
36+
const NotificationContext = createContext<NotificationContextValue | null>(null);
37+
38+
const NOTIFICATION_AUTO_CLOSE_MS = 3000; // User decision: 3 seconds
39+
const MAX_NOTIFICATIONS = 50; // Keep last 50 notifications
40+
41+
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
42+
const [appNotifications, setAppNotifications] = useState<AppNotification[]>([]);
43+
44+
const showNotification = useCallback(
45+
({ title, message, category }: { title: string; message: string; category: NotificationCategory }) => {
46+
const id = crypto.randomUUID();
47+
48+
// Add to persistent list
49+
const notification: AppNotification = {
50+
id,
51+
title,
52+
message,
53+
category,
54+
timestamp: new Date(),
55+
read: false,
56+
};
57+
58+
setAppNotifications((prev) => {
59+
const updated = [notification, ...prev];
60+
// Keep only last MAX_NOTIFICATIONS
61+
return updated.slice(0, MAX_NOTIFICATIONS);
62+
});
63+
64+
// Show toast notification
65+
const mantelNotification: NotificationData = {
66+
id,
67+
title,
68+
message,
69+
color: category === 'error' ? 'red' : category === 'warning' ? 'orange' : category === 'success' ? 'green' : 'blue',
70+
autoClose: NOTIFICATION_AUTO_CLOSE_MS,
71+
};
72+
73+
notifications.show(mantelNotification);
74+
},
75+
[]
76+
);
77+
78+
const markAsRead = useCallback((id: string) => {
79+
setAppNotifications((prev) =>
80+
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
81+
);
82+
}, []);
83+
84+
const markAllAsRead = useCallback(() => {
85+
setAppNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
86+
}, []);
87+
88+
const dismissNotification = useCallback((id: string) => {
89+
setAppNotifications((prev) => prev.filter((n) => n.id !== id));
90+
}, []);
91+
92+
const clearAll = useCallback(() => {
93+
setAppNotifications([]);
94+
}, []);
95+
96+
const unreadCount = appNotifications.filter((n) => !n.read).length;
97+
98+
const value: NotificationContextValue = {
99+
notifications: appNotifications,
100+
unreadCount,
101+
showNotification,
102+
markAsRead,
103+
markAllAsRead,
104+
dismissNotification,
105+
clearAll,
106+
};
107+
108+
return (
109+
<NotificationContext.Provider value={value}>
110+
{children}
111+
</NotificationContext.Provider>
112+
);
113+
};
114+
115+
export const useNotifications = (): NotificationContextValue => {
116+
const context = useContext(NotificationContext);
117+
if (!context) {
118+
throw new Error('useNotifications must be used within NotificationProvider');
119+
}
120+
return context;
121+
};

0 commit comments

Comments
 (0)