@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.
- package/CHANGELOG.md +87 -0
- package/package.json +31 -0
- package/src/components/NotificationBell.tsx +283 -0
- package/src/components/StrategyCard.tsx +159 -0
- package/src/components/UserChannelCard.tsx +310 -0
- package/src/components/UserMenuItems.tsx +15 -0
- package/src/index.tsx +39 -0
- package/src/pages/NotificationSettingsPage.tsx +501 -0
- package/src/pages/NotificationsPage.tsx +300 -0
- package/tsconfig.json +6 -0
|
@@ -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
|
+
};
|