@checkstack/notification-frontend 0.2.35 → 0.3.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 +225 -0
- package/package.json +7 -7
- package/src/components/CollapsedGroupTimeline.tsx +63 -0
- package/src/components/NotificationBell.tsx +189 -190
- package/src/components/NotificationSubjects.tsx +154 -0
- package/src/components/NotificationSubscriptionsManager.tsx +239 -0
- package/src/components/SubjectKindRegistry.ts +57 -0
- package/src/components/SubscriptionRow.tsx +191 -0
- package/src/components/SubscriptionSubControlsRegistry.ts +45 -0
- package/src/components/collapse.ts +59 -0
- package/src/index.tsx +23 -0
- package/src/pages/NotificationsPage.tsx +158 -89
|
@@ -1,28 +1,31 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
1
|
+
import { useState, useCallback, type ReactNode } from "react";
|
|
2
2
|
import { Link } from "react-router-dom";
|
|
3
3
|
import { Bell, CheckCheck } from "lucide-react";
|
|
4
4
|
import {
|
|
5
5
|
Badge,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
Popover,
|
|
7
|
+
PopoverContent,
|
|
8
|
+
PopoverTrigger,
|
|
9
|
+
Sheet,
|
|
10
|
+
SheetContent,
|
|
11
|
+
SheetTrigger,
|
|
12
|
+
SheetHeader,
|
|
13
|
+
SheetTitle,
|
|
11
14
|
Button,
|
|
12
15
|
stripMarkdown,
|
|
13
16
|
useToast,
|
|
17
|
+
useIsMobile,
|
|
14
18
|
} from "@checkstack/ui";
|
|
15
19
|
import { useApi, usePluginClient } from "@checkstack/frontend-api";
|
|
16
|
-
import { useSignal } from "@checkstack/signal-frontend";
|
|
17
20
|
import { resolveRoute } from "@checkstack/common";
|
|
18
|
-
import type { Notification } from "@checkstack/notification-common";
|
|
19
21
|
import {
|
|
20
22
|
NotificationApi,
|
|
21
|
-
NOTIFICATION_RECEIVED,
|
|
22
|
-
NOTIFICATION_COUNT_CHANGED,
|
|
23
|
-
NOTIFICATION_READ,
|
|
24
23
|
notificationRoutes,
|
|
25
24
|
} from "@checkstack/notification-common";
|
|
25
|
+
import { NotificationSubjects } from "./NotificationSubjects";
|
|
26
|
+
import { groupByCollapseKey, type CollapsedNotification } from "./collapse";
|
|
27
|
+
import { CollapsedGroupTimeline } from "./CollapsedGroupTimeline";
|
|
28
|
+
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
26
29
|
import { authApiRef } from "@checkstack/auth-frontend/api";
|
|
27
30
|
|
|
28
31
|
export const NotificationBell = () => {
|
|
@@ -30,25 +33,33 @@ export const NotificationBell = () => {
|
|
|
30
33
|
const { data: session, isPending: isAuthLoading } = authApi.useSession();
|
|
31
34
|
const notificationClient = usePluginClient(NotificationApi);
|
|
32
35
|
const toast = useToast();
|
|
36
|
+
const isMobile = useIsMobile();
|
|
33
37
|
|
|
34
38
|
const [isOpen, setIsOpen] = useState(false);
|
|
39
|
+
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
|
40
|
+
() => new Set(),
|
|
41
|
+
);
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
const toggleExpanded = useCallback((key: string) => {
|
|
44
|
+
setExpandedGroups((prev) => {
|
|
45
|
+
const next = new Set(prev);
|
|
46
|
+
if (next.has(key)) {
|
|
47
|
+
next.delete(key);
|
|
48
|
+
} else {
|
|
49
|
+
next.add(key);
|
|
50
|
+
}
|
|
51
|
+
return next;
|
|
52
|
+
});
|
|
53
|
+
}, []);
|
|
43
54
|
|
|
44
|
-
//
|
|
55
|
+
// Realtime updates arrive via SignalAutoInvalidator on `[["notification"]]`,
|
|
56
|
+
// so both queries stay fresh without per-component signal handlers.
|
|
45
57
|
const { data: unreadData, isLoading: unreadLoading } =
|
|
46
58
|
notificationClient.getUnreadCount.useQuery(undefined, {
|
|
47
59
|
enabled: !!session,
|
|
48
60
|
staleTime: 30_000,
|
|
49
61
|
});
|
|
50
62
|
|
|
51
|
-
// Fetch recent notifications via useQuery
|
|
52
63
|
const { data: notificationsData, isLoading: notificationsLoading } =
|
|
53
64
|
notificationClient.getNotifications.useQuery(
|
|
54
65
|
{ limit: 5, offset: 0, unreadOnly: true },
|
|
@@ -58,80 +69,14 @@ export const NotificationBell = () => {
|
|
|
58
69
|
},
|
|
59
70
|
);
|
|
60
71
|
|
|
61
|
-
// Mark all as read mutation
|
|
62
72
|
const markAsReadMutation = notificationClient.markAsRead.useMutation();
|
|
63
73
|
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
const recentNotifications =
|
|
67
|
-
signalNotifications ?? notificationsData?.notifications ?? [];
|
|
68
|
-
|
|
69
|
-
// ==========================================================================
|
|
70
|
-
// REALTIME SIGNAL SUBSCRIPTIONS (replaces polling)
|
|
71
|
-
// ==========================================================================
|
|
72
|
-
|
|
73
|
-
// Handle new notification received
|
|
74
|
-
useSignal(
|
|
75
|
-
NOTIFICATION_RECEIVED,
|
|
76
|
-
useCallback((payload) => {
|
|
77
|
-
// Increment unread count
|
|
78
|
-
setSignalUnreadCount((prev) => (prev ?? 0) + 1);
|
|
79
|
-
|
|
80
|
-
// Add to recent notifications if dropdown is open
|
|
81
|
-
setSignalNotifications((prev) => [
|
|
82
|
-
{
|
|
83
|
-
id: payload.id,
|
|
84
|
-
title: payload.title,
|
|
85
|
-
body: payload.body,
|
|
86
|
-
importance: payload.importance,
|
|
87
|
-
userId: "", // Not needed for display
|
|
88
|
-
isRead: false,
|
|
89
|
-
createdAt: new Date(),
|
|
90
|
-
},
|
|
91
|
-
...(prev ?? []).slice(0, 4), // Keep only 5 items
|
|
92
|
-
]);
|
|
93
|
-
}, []),
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
// Handle count changes from other sources
|
|
97
|
-
useSignal(
|
|
98
|
-
NOTIFICATION_COUNT_CHANGED,
|
|
99
|
-
useCallback((payload) => {
|
|
100
|
-
setSignalUnreadCount(payload.unreadCount);
|
|
101
|
-
}, []),
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
// Handle notification marked as read
|
|
105
|
-
useSignal(
|
|
106
|
-
NOTIFICATION_READ,
|
|
107
|
-
useCallback(
|
|
108
|
-
(payload) => {
|
|
109
|
-
if (payload.notificationId) {
|
|
110
|
-
// Single notification marked as read - remove from list
|
|
111
|
-
setSignalNotifications((prev) =>
|
|
112
|
-
(prev ?? []).filter((n) => n.id !== payload.notificationId),
|
|
113
|
-
);
|
|
114
|
-
// Use unreadData?.count as fallback when signalUnreadCount hasn't been set yet
|
|
115
|
-
setSignalUnreadCount((prev) =>
|
|
116
|
-
Math.max(0, (prev ?? unreadData?.count ?? 1) - 1),
|
|
117
|
-
);
|
|
118
|
-
} else {
|
|
119
|
-
// All marked as read - clear the list
|
|
120
|
-
setSignalNotifications([]);
|
|
121
|
-
setSignalUnreadCount(0);
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
[unreadData?.count],
|
|
125
|
-
),
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
// ==========================================================================
|
|
74
|
+
const unreadCount = unreadData?.count ?? 0;
|
|
75
|
+
const recentNotifications = notificationsData?.notifications ?? [];
|
|
129
76
|
|
|
130
77
|
const handleMarkAllAsRead = async () => {
|
|
131
78
|
try {
|
|
132
79
|
await markAsReadMutation.mutateAsync({});
|
|
133
|
-
setSignalUnreadCount(0);
|
|
134
|
-
setSignalNotifications([]);
|
|
135
80
|
} catch {
|
|
136
81
|
toast.error("Failed to mark all as read");
|
|
137
82
|
}
|
|
@@ -141,7 +86,6 @@ export const NotificationBell = () => {
|
|
|
141
86
|
setIsOpen(false);
|
|
142
87
|
}, []);
|
|
143
88
|
|
|
144
|
-
// Hide notification bell for unauthenticated users
|
|
145
89
|
if (isAuthLoading || !session) {
|
|
146
90
|
return;
|
|
147
91
|
}
|
|
@@ -156,119 +100,174 @@ export const NotificationBell = () => {
|
|
|
156
100
|
);
|
|
157
101
|
}
|
|
158
102
|
|
|
159
|
-
|
|
160
|
-
<
|
|
161
|
-
<
|
|
103
|
+
const trigger = (
|
|
104
|
+
<Button variant="ghost" size="icon" className="relative group">
|
|
105
|
+
<Bell className="h-5 w-5 transition-transform group-hover:scale-110" />
|
|
106
|
+
{unreadCount > 0 && (
|
|
107
|
+
<span className="absolute -top-1 -right-1 flex h-5 min-w-[20px] items-center justify-center">
|
|
108
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
|
|
109
|
+
<Badge
|
|
110
|
+
variant="destructive"
|
|
111
|
+
className="relative h-5 min-w-[20px] flex items-center justify-center p-0 text-xs font-bold"
|
|
112
|
+
>
|
|
113
|
+
{unreadCount > 99 ? "99+" : unreadCount}
|
|
114
|
+
</Badge>
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</Button>
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const headerActions =
|
|
121
|
+
unreadCount > 0 ? (
|
|
122
|
+
<Button
|
|
123
|
+
variant="ghost"
|
|
124
|
+
size="sm"
|
|
125
|
+
className="h-7 text-xs"
|
|
162
126
|
onClick={() => {
|
|
163
|
-
|
|
127
|
+
void handleMarkAllAsRead();
|
|
164
128
|
}}
|
|
165
129
|
>
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
130
|
+
<CheckCheck className="h-3 w-3 mr-1" />
|
|
131
|
+
Mark all read
|
|
132
|
+
</Button>
|
|
133
|
+
) : undefined;
|
|
134
|
+
|
|
135
|
+
const renderItem = (group: CollapsedNotification): ReactNode => {
|
|
136
|
+
const notification = group.representative;
|
|
137
|
+
const isExpanded = expandedGroups.has(group.key);
|
|
138
|
+
return (
|
|
139
|
+
<div
|
|
140
|
+
key={group.key}
|
|
141
|
+
className={`flex flex-col items-start gap-1 px-3 py-2 hover:bg-accent transition-colors ${
|
|
142
|
+
notification.importance === "critical"
|
|
143
|
+
? "border-l-2 border-l-destructive"
|
|
144
|
+
: notification.importance === "warning"
|
|
145
|
+
? "border-l-2 border-l-warning"
|
|
146
|
+
: ""
|
|
147
|
+
}`}
|
|
148
|
+
>
|
|
149
|
+
<div className="flex items-center gap-2 w-full">
|
|
150
|
+
<div
|
|
151
|
+
className={`font-medium text-sm flex-1 truncate ${
|
|
152
|
+
notification.importance === "critical"
|
|
153
|
+
? "text-destructive"
|
|
154
|
+
: notification.importance === "warning"
|
|
155
|
+
? "text-warning"
|
|
156
|
+
: "text-foreground"
|
|
157
|
+
}`}
|
|
158
|
+
>
|
|
159
|
+
{notification.title}
|
|
160
|
+
</div>
|
|
161
|
+
{group.collapsed && (
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
className="inline-flex items-center gap-0.5 shrink-0"
|
|
165
|
+
onClick={(e: React.MouseEvent) => {
|
|
166
|
+
e.stopPropagation();
|
|
167
|
+
toggleExpanded(group.key);
|
|
168
|
+
}}
|
|
169
|
+
aria-label={
|
|
170
|
+
isExpanded
|
|
171
|
+
? "Collapse update history"
|
|
172
|
+
: "Show update history"
|
|
173
|
+
}
|
|
174
|
+
>
|
|
171
175
|
<Badge
|
|
172
|
-
variant="
|
|
173
|
-
className="
|
|
176
|
+
variant="secondary"
|
|
177
|
+
className="text-[10px] h-5 cursor-pointer hover:bg-accent"
|
|
174
178
|
>
|
|
175
|
-
{
|
|
179
|
+
+{group.count - 1} updates
|
|
180
|
+
{isExpanded ? (
|
|
181
|
+
<ChevronUp className="ml-0.5 h-3 w-3" />
|
|
182
|
+
) : (
|
|
183
|
+
<ChevronDown className="ml-0.5 h-3 w-3" />
|
|
184
|
+
)}
|
|
176
185
|
</Badge>
|
|
177
|
-
</
|
|
186
|
+
</button>
|
|
178
187
|
)}
|
|
179
|
-
</
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
{
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
className="h-6 text-xs"
|
|
194
|
-
onClick={() => {
|
|
195
|
-
void handleMarkAllAsRead();
|
|
188
|
+
</div>
|
|
189
|
+
<div className="text-xs text-muted-foreground line-clamp-2">
|
|
190
|
+
{stripMarkdown(notification.body)}
|
|
191
|
+
</div>
|
|
192
|
+
{notification.subjects && notification.subjects.length > 0 && (
|
|
193
|
+
<NotificationSubjects subjects={notification.subjects} />
|
|
194
|
+
)}
|
|
195
|
+
{notification.action && (
|
|
196
|
+
<div className="flex gap-2 mt-1">
|
|
197
|
+
<Link
|
|
198
|
+
to={notification.action.url}
|
|
199
|
+
className="text-xs text-primary hover:underline"
|
|
200
|
+
onClick={(e: React.MouseEvent) => {
|
|
201
|
+
e.stopPropagation();
|
|
196
202
|
}}
|
|
197
203
|
>
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
204
|
+
{notification.action.label}
|
|
205
|
+
</Link>
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
{group.collapsed && isExpanded && (
|
|
209
|
+
<CollapsedGroupTimeline notifications={group.notifications} />
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
203
214
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
key={notification.id}
|
|
215
|
-
className={`flex flex-col items-start gap-1 px-3 py-2 cursor-pointer ${
|
|
216
|
-
notification.importance === "critical"
|
|
217
|
-
? "border-l-2 border-l-destructive"
|
|
218
|
-
: notification.importance === "warning"
|
|
219
|
-
? "border-l-2 border-l-warning"
|
|
220
|
-
: ""
|
|
221
|
-
}`}
|
|
222
|
-
>
|
|
223
|
-
<div
|
|
224
|
-
className={`font-medium text-sm ${
|
|
225
|
-
notification.importance === "critical"
|
|
226
|
-
? "text-destructive"
|
|
227
|
-
: notification.importance === "warning"
|
|
228
|
-
? "text-warning"
|
|
229
|
-
: "text-foreground"
|
|
230
|
-
}`}
|
|
231
|
-
>
|
|
232
|
-
{notification.title}
|
|
233
|
-
</div>
|
|
234
|
-
<div className="text-xs text-muted-foreground line-clamp-2">
|
|
235
|
-
{stripMarkdown(notification.body)}
|
|
236
|
-
</div>
|
|
237
|
-
{notification.action && (
|
|
238
|
-
<div className="flex gap-2 mt-1">
|
|
239
|
-
<Link
|
|
240
|
-
to={notification.action.url}
|
|
241
|
-
className="text-xs text-primary hover:underline"
|
|
242
|
-
onClick={(e: React.MouseEvent) => {
|
|
243
|
-
e.stopPropagation();
|
|
244
|
-
}}
|
|
245
|
-
>
|
|
246
|
-
{notification.action.label}
|
|
247
|
-
</Link>
|
|
248
|
-
</div>
|
|
249
|
-
)}
|
|
250
|
-
</DropdownMenuItem>
|
|
251
|
-
))}
|
|
252
|
-
</>
|
|
253
|
-
)}
|
|
254
|
-
</div>
|
|
215
|
+
const collapsedGroups = groupByCollapseKey(recentNotifications);
|
|
216
|
+
|
|
217
|
+
const list =
|
|
218
|
+
collapsedGroups.length === 0 ? (
|
|
219
|
+
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
220
|
+
No unread notifications
|
|
221
|
+
</div>
|
|
222
|
+
) : (
|
|
223
|
+
<>{collapsedGroups.map((g) => renderItem(g))}</>
|
|
224
|
+
);
|
|
255
225
|
|
|
256
|
-
|
|
226
|
+
const footer = (
|
|
227
|
+
<Link
|
|
228
|
+
to={resolveRoute(notificationRoutes.routes.home)}
|
|
229
|
+
className="block w-full text-center text-sm text-primary hover:bg-accent transition-colors px-3 py-2"
|
|
230
|
+
onClick={handleClose}
|
|
231
|
+
>
|
|
232
|
+
View all notifications
|
|
233
|
+
</Link>
|
|
234
|
+
);
|
|
257
235
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
236
|
+
if (isMobile) {
|
|
237
|
+
return (
|
|
238
|
+
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
|
239
|
+
<SheetTrigger asChild>{trigger}</SheetTrigger>
|
|
240
|
+
<SheetContent
|
|
241
|
+
size="full"
|
|
242
|
+
className="flex flex-col p-0"
|
|
243
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
263
244
|
>
|
|
264
|
-
<
|
|
265
|
-
|
|
266
|
-
className="
|
|
267
|
-
>
|
|
268
|
-
|
|
269
|
-
</
|
|
270
|
-
</
|
|
271
|
-
</
|
|
272
|
-
|
|
245
|
+
<SheetHeader className="flex flex-row items-center justify-between gap-2 px-4 py-3 border-b border-border space-y-0">
|
|
246
|
+
<SheetTitle className="text-base">Notifications</SheetTitle>
|
|
247
|
+
<div className="pr-8">{headerActions}</div>
|
|
248
|
+
</SheetHeader>
|
|
249
|
+
<div className="flex-1 overflow-y-auto">{list}</div>
|
|
250
|
+
<div className="border-t border-border">{footer}</div>
|
|
251
|
+
</SheetContent>
|
|
252
|
+
</Sheet>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
258
|
+
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
|
259
|
+
<PopoverContent
|
|
260
|
+
className="w-80 p-0"
|
|
261
|
+
align="end"
|
|
262
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
263
|
+
>
|
|
264
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
|
265
|
+
<span className="font-semibold text-sm">Notifications</span>
|
|
266
|
+
{headerActions}
|
|
267
|
+
</div>
|
|
268
|
+
<div className="max-h-[400px] overflow-y-auto">{list}</div>
|
|
269
|
+
<div className="border-t border-border">{footer}</div>
|
|
270
|
+
</PopoverContent>
|
|
271
|
+
</Popover>
|
|
273
272
|
);
|
|
274
273
|
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
Popover,
|
|
5
|
+
PopoverContent,
|
|
6
|
+
PopoverTrigger,
|
|
7
|
+
Button,
|
|
8
|
+
usePerformance,
|
|
9
|
+
} from "@checkstack/ui";
|
|
10
|
+
import type { NotificationSubject } from "@checkstack/notification-common";
|
|
11
|
+
import { getSubjectKindRenderer } from "./SubjectKindRegistry";
|
|
12
|
+
|
|
13
|
+
const STATUS_DOT_COLOR: Record<
|
|
14
|
+
NonNullable<NotificationSubject["status"]>,
|
|
15
|
+
string
|
|
16
|
+
> = {
|
|
17
|
+
healthy: "bg-emerald-500",
|
|
18
|
+
degraded: "bg-amber-500",
|
|
19
|
+
unhealthy: "bg-rose-500",
|
|
20
|
+
unknown: "bg-zinc-400",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface NotificationSubjectsProps {
|
|
24
|
+
subjects: NotificationSubject[];
|
|
25
|
+
/** Inline cap before collapsing the rest into a "+N" overflow popover. */
|
|
26
|
+
maxVisible?: number;
|
|
27
|
+
/** Optional callback fired when a chip's link is clicked. */
|
|
28
|
+
onSubjectClick?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compact chip list of affected entities. Each chip shows the kind icon (via
|
|
33
|
+
* the kind registry) and the subject name; clicking links to the subject's
|
|
34
|
+
* url when present. Status maps to a small colored dot.
|
|
35
|
+
*
|
|
36
|
+
* On low-power devices, transitions are disabled.
|
|
37
|
+
*/
|
|
38
|
+
export function NotificationSubjects({
|
|
39
|
+
subjects,
|
|
40
|
+
maxVisible = 3,
|
|
41
|
+
onSubjectClick,
|
|
42
|
+
}: NotificationSubjectsProps) {
|
|
43
|
+
const [overflowOpen, setOverflowOpen] = useState(false);
|
|
44
|
+
const { isLowPower } = usePerformance();
|
|
45
|
+
|
|
46
|
+
if (subjects.length === 0) return <></>;
|
|
47
|
+
|
|
48
|
+
const visible = subjects.slice(0, maxVisible);
|
|
49
|
+
const overflow = subjects.slice(maxVisible);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
|
53
|
+
{visible.map((subject) => (
|
|
54
|
+
<SubjectChip
|
|
55
|
+
key={`${subject.kind}:${subject.id}`}
|
|
56
|
+
subject={subject}
|
|
57
|
+
isLowPower={isLowPower}
|
|
58
|
+
onClick={onSubjectClick}
|
|
59
|
+
/>
|
|
60
|
+
))}
|
|
61
|
+
{overflow.length > 0 && (
|
|
62
|
+
<Popover open={overflowOpen} onOpenChange={setOverflowOpen}>
|
|
63
|
+
<PopoverTrigger asChild>
|
|
64
|
+
<Button
|
|
65
|
+
type="button"
|
|
66
|
+
variant="outline"
|
|
67
|
+
size="sm"
|
|
68
|
+
className="h-6 px-2 text-xs"
|
|
69
|
+
>
|
|
70
|
+
+{overflow.length} more
|
|
71
|
+
</Button>
|
|
72
|
+
</PopoverTrigger>
|
|
73
|
+
<PopoverContent
|
|
74
|
+
className="w-72 p-2"
|
|
75
|
+
align="start"
|
|
76
|
+
onCloseAutoFocus={(e) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<div className="flex flex-col gap-1">
|
|
81
|
+
{overflow.map((subject) => (
|
|
82
|
+
<SubjectChip
|
|
83
|
+
key={`${subject.kind}:${subject.id}`}
|
|
84
|
+
subject={subject}
|
|
85
|
+
isLowPower={isLowPower}
|
|
86
|
+
onClick={() => {
|
|
87
|
+
setOverflowOpen(false);
|
|
88
|
+
onSubjectClick?.();
|
|
89
|
+
}}
|
|
90
|
+
fullWidth
|
|
91
|
+
/>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
</PopoverContent>
|
|
95
|
+
</Popover>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface SubjectChipProps {
|
|
102
|
+
subject: NotificationSubject;
|
|
103
|
+
isLowPower: boolean;
|
|
104
|
+
onClick?: () => void;
|
|
105
|
+
fullWidth?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function SubjectChip({
|
|
109
|
+
subject,
|
|
110
|
+
isLowPower,
|
|
111
|
+
onClick,
|
|
112
|
+
fullWidth,
|
|
113
|
+
}: SubjectChipProps) {
|
|
114
|
+
const renderer = getSubjectKindRenderer(subject.kind);
|
|
115
|
+
const Icon = renderer.icon;
|
|
116
|
+
const dotColor = subject.status
|
|
117
|
+
? STATUS_DOT_COLOR[subject.status]
|
|
118
|
+
: undefined;
|
|
119
|
+
|
|
120
|
+
const baseClass = [
|
|
121
|
+
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md",
|
|
122
|
+
"border border-border bg-card text-foreground",
|
|
123
|
+
"text-xs font-medium max-w-[14rem] truncate",
|
|
124
|
+
isLowPower ? "" : "transition-colors hover:bg-accent",
|
|
125
|
+
fullWidth ? "w-full" : "",
|
|
126
|
+
]
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.join(" ");
|
|
129
|
+
|
|
130
|
+
const content = (
|
|
131
|
+
<>
|
|
132
|
+
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
133
|
+
{dotColor && (
|
|
134
|
+
<span
|
|
135
|
+
className={`inline-block h-2 w-2 rounded-full shrink-0 ${dotColor}`}
|
|
136
|
+
aria-label={subject.status ?? undefined}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
<span className="truncate" title={`${renderer.label}: ${subject.name}`}>
|
|
140
|
+
{subject.name}
|
|
141
|
+
</span>
|
|
142
|
+
</>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (subject.url) {
|
|
146
|
+
return (
|
|
147
|
+
<Link to={subject.url} className={baseClass} onClick={onClick}>
|
|
148
|
+
{content}
|
|
149
|
+
</Link>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return <span className={baseClass}>{content}</span>;
|
|
154
|
+
}
|