@checkmate-monitor/notification-frontend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { Bell, Check, Trash2, ChevronDown } from "lucide-react";
4
+ import {
5
+ PageLayout,
6
+ Badge,
7
+ Button,
8
+ Card,
9
+ useToast,
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuTrigger,
14
+ Markdown,
15
+ } from "@checkmate-monitor/ui";
16
+ import { useApi, rpcApiRef } from "@checkmate-monitor/frontend-api";
17
+ import type { Notification } from "@checkmate-monitor/notification-common";
18
+ import { NotificationApi } from "@checkmate-monitor/notification-common";
19
+
20
+ export const NotificationsPage = () => {
21
+ const rpcApi = useApi(rpcApiRef);
22
+ const notificationClient = rpcApi.forPlugin(NotificationApi);
23
+ const toast = useToast();
24
+
25
+ const [notifications, setNotifications] = useState<Notification[]>([]);
26
+ const [total, setTotal] = useState(0);
27
+ const [loading, setLoading] = useState(true);
28
+ const [filter, setFilter] = useState<"all" | "unread">("all");
29
+ const [page, setPage] = useState(0);
30
+ const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
31
+ const pageSize = 20;
32
+
33
+ const fetchNotifications = useCallback(async () => {
34
+ try {
35
+ setLoading(true);
36
+ const { notifications: data, total: totalCount } =
37
+ await notificationClient.getNotifications({
38
+ limit: pageSize,
39
+ offset: page * pageSize,
40
+ unreadOnly: filter === "unread",
41
+ });
42
+ setNotifications(data);
43
+ setTotal(totalCount);
44
+ } catch (error) {
45
+ const message =
46
+ error instanceof Error
47
+ ? error.message
48
+ : "Failed to fetch notifications";
49
+ toast.error(message);
50
+ } finally {
51
+ setLoading(false);
52
+ }
53
+ }, [notificationClient, page, filter, toast]);
54
+
55
+ useEffect(() => {
56
+ void fetchNotifications();
57
+ }, [fetchNotifications]);
58
+
59
+ const handleMarkAsRead = async (notificationId: string) => {
60
+ try {
61
+ await notificationClient.markAsRead({ notificationId });
62
+ setNotifications((prev) =>
63
+ prev.map((n) => (n.id === notificationId ? { ...n, isRead: true } : n))
64
+ );
65
+ toast.success("Notification marked as read");
66
+ } catch (error) {
67
+ const message =
68
+ error instanceof Error ? error.message : "Failed to mark as read";
69
+ toast.error(message);
70
+ }
71
+ };
72
+
73
+ const handleDelete = async (notificationId: string) => {
74
+ try {
75
+ await notificationClient.deleteNotification({ notificationId });
76
+ setNotifications((prev) => prev.filter((n) => n.id !== notificationId));
77
+ setTotal((prev) => prev - 1);
78
+ toast.success("Notification deleted");
79
+ } catch (error) {
80
+ const message =
81
+ error instanceof Error
82
+ ? error.message
83
+ : "Failed to delete notification";
84
+ toast.error(message);
85
+ }
86
+ };
87
+
88
+ const handleMarkAllAsRead = async () => {
89
+ try {
90
+ await notificationClient.markAsRead({});
91
+ setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
92
+ toast.success("All notifications marked as read");
93
+ } catch (error) {
94
+ const message =
95
+ error instanceof Error ? error.message : "Failed to mark all as read";
96
+ toast.error(message);
97
+ }
98
+ };
99
+
100
+ const getImportanceBadge = (importance: Notification["importance"]) => {
101
+ switch (importance) {
102
+ case "critical": {
103
+ return <Badge variant="destructive">Critical</Badge>;
104
+ }
105
+ case "warning": {
106
+ return <Badge variant="warning">Warning</Badge>;
107
+ }
108
+ default: {
109
+ return <Badge variant="info">Info</Badge>;
110
+ }
111
+ }
112
+ };
113
+
114
+ const formatDate = (date: Date) => {
115
+ const d = new Date(date);
116
+ const now = new Date();
117
+ const diffMs = now.getTime() - d.getTime();
118
+ const diffMins = Math.floor(diffMs / 60_000);
119
+ const diffHours = Math.floor(diffMs / 3_600_000);
120
+ const diffDays = Math.floor(diffMs / 86_400_000);
121
+
122
+ if (diffMins < 1) {
123
+ return "Just now";
124
+ }
125
+ if (diffMins < 60) {
126
+ return `${diffMins}m ago`;
127
+ }
128
+ if (diffHours < 24) {
129
+ return `${diffHours}h ago`;
130
+ }
131
+ if (diffDays < 7) {
132
+ return `${diffDays}d ago`;
133
+ }
134
+ return d.toLocaleDateString();
135
+ };
136
+
137
+ return (
138
+ <PageLayout title="Notifications" loading={loading}>
139
+ <div className="space-y-4">
140
+ {/* Header with filters */}
141
+ <div className="flex items-center justify-between">
142
+ <div className="flex items-center gap-2">
143
+ <DropdownMenu>
144
+ <DropdownMenuTrigger
145
+ onClick={() => {
146
+ setFilterDropdownOpen(!filterDropdownOpen);
147
+ }}
148
+ >
149
+ <Button variant="outline" size="sm">
150
+ {filter === "all" ? "All" : "Unread"}{" "}
151
+ <ChevronDown className="h-4 w-4 ml-1" />
152
+ </Button>
153
+ </DropdownMenuTrigger>
154
+ <DropdownMenuContent
155
+ isOpen={filterDropdownOpen}
156
+ onClose={() => {
157
+ setFilterDropdownOpen(false);
158
+ }}
159
+ >
160
+ <DropdownMenuItem
161
+ onClick={() => {
162
+ setFilter("all");
163
+ setFilterDropdownOpen(false);
164
+ }}
165
+ >
166
+ All notifications
167
+ </DropdownMenuItem>
168
+ <DropdownMenuItem
169
+ onClick={() => {
170
+ setFilter("unread");
171
+ setFilterDropdownOpen(false);
172
+ }}
173
+ >
174
+ Unread only
175
+ </DropdownMenuItem>
176
+ </DropdownMenuContent>
177
+ </DropdownMenu>
178
+ <span className="text-sm text-muted-foreground">
179
+ {total} notification{total === 1 ? "" : "s"}
180
+ </span>
181
+ </div>
182
+ <Button
183
+ variant="outline"
184
+ size="sm"
185
+ onClick={() => {
186
+ void handleMarkAllAsRead();
187
+ }}
188
+ >
189
+ <Check className="h-4 w-4 mr-1" /> Mark all read
190
+ </Button>
191
+ </div>
192
+
193
+ {/* Notifications list */}
194
+ {notifications.length === 0 ? (
195
+ <Card className="p-8 text-center text-muted-foreground">
196
+ <Bell className="h-12 w-12 mx-auto mb-4 opacity-50" />
197
+ <p>No notifications</p>
198
+ </Card>
199
+ ) : (
200
+ <div className="space-y-2">
201
+ {notifications.map((notification) => (
202
+ <Card
203
+ key={notification.id}
204
+ className={`p-4 ${
205
+ notification.isRead ? "" : "border-l-4 border-l-primary"
206
+ }`}
207
+ >
208
+ <div className="flex items-start justify-between gap-4">
209
+ <div className="flex-1 min-w-0">
210
+ <div className="flex items-center gap-2 mb-1">
211
+ {getImportanceBadge(notification.importance)}
212
+ <span className="text-xs text-muted-foreground">
213
+ {formatDate(notification.createdAt)}
214
+ </span>
215
+ </div>
216
+ <h3
217
+ className={`font-medium ${
218
+ notification.isRead
219
+ ? "text-muted-foreground"
220
+ : "text-foreground"
221
+ }`}
222
+ >
223
+ {notification.title}
224
+ </h3>
225
+ <Markdown size="sm" className="text-muted-foreground mt-1">
226
+ {notification.body}
227
+ </Markdown>
228
+ {notification.action && (
229
+ <div className="flex gap-2 mt-2">
230
+ <Link
231
+ to={notification.action.url}
232
+ className="text-sm text-primary hover:text-primary/80"
233
+ >
234
+ {notification.action.label}
235
+ </Link>
236
+ </div>
237
+ )}
238
+ </div>
239
+ <div className="flex items-center gap-1">
240
+ {!notification.isRead && (
241
+ <Button
242
+ variant="ghost"
243
+ size="icon"
244
+ onClick={() => {
245
+ void handleMarkAsRead(notification.id);
246
+ }}
247
+ title="Mark as read"
248
+ >
249
+ <Check className="h-4 w-4" />
250
+ </Button>
251
+ )}
252
+ <Button
253
+ variant="ghost"
254
+ size="icon"
255
+ onClick={() => {
256
+ void handleDelete(notification.id);
257
+ }}
258
+ title="Delete"
259
+ >
260
+ <Trash2 className="h-4 w-4 text-destructive" />
261
+ </Button>
262
+ </div>
263
+ </div>
264
+ </Card>
265
+ ))}
266
+ </div>
267
+ )}
268
+
269
+ {/* Pagination */}
270
+ {total > pageSize && (
271
+ <div className="flex items-center justify-center gap-2">
272
+ <Button
273
+ variant="outline"
274
+ size="sm"
275
+ disabled={page === 0}
276
+ onClick={() => {
277
+ setPage((p) => p - 1);
278
+ }}
279
+ >
280
+ Previous
281
+ </Button>
282
+ <span className="text-sm text-muted-foreground">
283
+ Page {page + 1} of {Math.ceil(total / pageSize)}
284
+ </span>
285
+ <Button
286
+ variant="outline"
287
+ size="sm"
288
+ disabled={(page + 1) * pageSize >= total}
289
+ onClick={() => {
290
+ setPage((p) => p + 1);
291
+ }}
292
+ >
293
+ Next
294
+ </Button>
295
+ </div>
296
+ )}
297
+ </div>
298
+ </PageLayout>
299
+ );
300
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkmate-monitor/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }