@checkstack/notification-frontend 0.2.36 → 0.3.1

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.
@@ -1,16 +1,20 @@
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
- DropdownMenu,
7
- DropdownMenuContent,
8
- DropdownMenuItem,
9
- DropdownMenuTrigger,
10
- DropdownMenuSeparator,
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
20
  import { resolveRoute } from "@checkstack/common";
@@ -18,6 +22,10 @@ import {
18
22
  NotificationApi,
19
23
  notificationRoutes,
20
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";
21
29
  import { authApiRef } from "@checkstack/auth-frontend/api";
22
30
 
23
31
  export const NotificationBell = () => {
@@ -25,8 +33,24 @@ export const NotificationBell = () => {
25
33
  const { data: session, isPending: isAuthLoading } = authApi.useSession();
26
34
  const notificationClient = usePluginClient(NotificationApi);
27
35
  const toast = useToast();
36
+ const isMobile = useIsMobile();
28
37
 
29
38
  const [isOpen, setIsOpen] = useState(false);
39
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
40
+ () => new Set(),
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
+ }, []);
30
54
 
31
55
  // Realtime updates arrive via SignalAutoInvalidator on `[["notification"]]`,
32
56
  // so both queries stay fresh without per-component signal handlers.
@@ -62,7 +86,6 @@ export const NotificationBell = () => {
62
86
  setIsOpen(false);
63
87
  }, []);
64
88
 
65
- // Hide notification bell for unauthenticated users
66
89
  if (isAuthLoading || !session) {
67
90
  return;
68
91
  }
@@ -77,119 +100,174 @@ export const NotificationBell = () => {
77
100
  );
78
101
  }
79
102
 
80
- return (
81
- <DropdownMenu>
82
- <DropdownMenuTrigger
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"
83
126
  onClick={() => {
84
- setIsOpen(!isOpen);
127
+ void handleMarkAllAsRead();
85
128
  }}
86
129
  >
87
- <Button variant="ghost" size="icon" className="relative group">
88
- <Bell className="h-5 w-5 transition-transform group-hover:scale-110" />
89
- {unreadCount > 0 && (
90
- <span className="absolute -top-1 -right-1 flex h-5 min-w-[20px] items-center justify-center">
91
- <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
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
+ >
92
175
  <Badge
93
- variant="destructive"
94
- className="relative h-5 min-w-[20px] flex items-center justify-center p-0 text-xs font-bold"
176
+ variant="secondary"
177
+ className="text-[10px] h-5 cursor-pointer hover:bg-accent"
95
178
  >
96
- {unreadCount > 99 ? "99+" : unreadCount}
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
+ )}
97
185
  </Badge>
98
- </span>
186
+ </button>
99
187
  )}
100
- </Button>
101
- </DropdownMenuTrigger>
102
- <DropdownMenuContent
103
- isOpen={isOpen}
104
- onClose={handleClose}
105
- className="w-80"
106
- >
107
- {/* Header */}
108
- <div className="flex items-center justify-between px-3 py-2 border-b border-border">
109
- <span className="font-semibold text-sm">Notifications</span>
110
- {unreadCount > 0 && (
111
- <Button
112
- variant="ghost"
113
- size="sm"
114
- className="h-6 text-xs"
115
- onClick={() => {
116
- 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();
117
202
  }}
118
203
  >
119
- <CheckCheck className="h-3 w-3 mr-1" />
120
- Mark all read
121
- </Button>
122
- )}
123
- </div>
204
+ {notification.action.label}
205
+ </Link>
206
+ </div>
207
+ )}
208
+ {group.collapsed && isExpanded && (
209
+ <CollapsedGroupTimeline notifications={group.notifications} />
210
+ )}
211
+ </div>
212
+ );
213
+ };
124
214
 
125
- {/* Notification List */}
126
- <div className="max-h-[400px] overflow-y-auto">
127
- {recentNotifications.length === 0 ? (
128
- <div className="px-3 py-6 text-center text-sm text-muted-foreground">
129
- No unread notifications
130
- </div>
131
- ) : (
132
- <>
133
- {recentNotifications.map((notification) => (
134
- <DropdownMenuItem
135
- key={notification.id}
136
- className={`flex flex-col items-start gap-1 px-3 py-2 cursor-pointer ${
137
- notification.importance === "critical"
138
- ? "border-l-2 border-l-destructive"
139
- : notification.importance === "warning"
140
- ? "border-l-2 border-l-warning"
141
- : ""
142
- }`}
143
- >
144
- <div
145
- className={`font-medium text-sm ${
146
- notification.importance === "critical"
147
- ? "text-destructive"
148
- : notification.importance === "warning"
149
- ? "text-warning"
150
- : "text-foreground"
151
- }`}
152
- >
153
- {notification.title}
154
- </div>
155
- <div className="text-xs text-muted-foreground line-clamp-2">
156
- {stripMarkdown(notification.body)}
157
- </div>
158
- {notification.action && (
159
- <div className="flex gap-2 mt-1">
160
- <Link
161
- to={notification.action.url}
162
- className="text-xs text-primary hover:underline"
163
- onClick={(e: React.MouseEvent) => {
164
- e.stopPropagation();
165
- }}
166
- >
167
- {notification.action.label}
168
- </Link>
169
- </div>
170
- )}
171
- </DropdownMenuItem>
172
- ))}
173
- </>
174
- )}
175
- </div>
215
+ const collapsedGroups = groupByCollapseKey(recentNotifications);
176
216
 
177
- <DropdownMenuSeparator />
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
+ );
178
225
 
179
- {/* Footer */}
180
- <DropdownMenuItem
181
- onClick={() => {
182
- handleClose();
183
- }}
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
+ );
235
+
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()}
184
244
  >
185
- <Link
186
- to={resolveRoute(notificationRoutes.routes.home)}
187
- className="w-full text-center text-sm text-primary"
188
- >
189
- View all notifications
190
- </Link>
191
- </DropdownMenuItem>
192
- </DropdownMenuContent>
193
- </DropdownMenu>
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>
194
272
  );
195
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
+ }