@tuturuuu/ui 0.30.2 → 0.32.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +95 -49
  4. package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
  5. package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
  7. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
  8. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
  9. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
  10. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
  11. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
  12. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
  13. package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
  14. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
  15. package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
  16. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  17. package/src/components/ui/custom/nav-link.tsx +7 -7
  18. package/src/components/ui/custom/nav.tsx +15 -63
  19. package/src/components/ui/custom/notification-card.tsx +361 -0
  20. package/src/components/ui/custom/notification-list.tsx +178 -0
  21. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  22. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  24. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  25. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  26. package/src/components/ui/custom/satellite-content.tsx +148 -0
  27. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  28. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  29. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  30. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  31. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  32. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  33. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  34. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  35. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  36. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  37. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  38. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  39. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  40. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  41. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  42. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  43. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  44. package/src/components/ui/custom/structure.tsx +14 -0
  45. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  46. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  47. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  48. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  49. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  50. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  51. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  52. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  53. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  54. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  55. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  56. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  57. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  58. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  59. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  60. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  61. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  62. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  63. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  64. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  65. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  66. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  67. package/src/components/ui/pwa-status.test.tsx +53 -0
  68. package/src/components/ui/pwa-status.tsx +113 -0
  69. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  70. package/src/components/ui/report-problem-dialog.tsx +6 -975
  71. package/src/components/ui/report-problem-model.ts +133 -0
  72. package/src/components/ui/report-problem-mutation.ts +122 -0
  73. package/src/components/ui/save-notification.test.tsx +36 -0
  74. package/src/components/ui/save-notification.tsx +17 -0
  75. package/src/components/ui/use-report-problem.ts +488 -0
  76. package/src/declarations.d.ts +2 -0
  77. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  78. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  79. package/src/hooks/calendar-date-utils.ts +11 -0
  80. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  81. package/src/hooks/calendar-event-lookup.ts +47 -0
  82. package/src/hooks/calendar-range-cache.ts +52 -0
  83. package/src/hooks/pwa-install-prompt.ts +44 -0
  84. package/src/hooks/use-calendar-sync.tsx +23 -100
  85. package/src/hooks/use-calendar.tsx +21 -77
  86. package/src/hooks/use-notifications.ts +9 -24
  87. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  88. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  89. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  90. package/src/hooks/use-workspace-presence.ts +0 -18
  91. package/src/lib/tasks-app-url.test.ts +25 -0
  92. package/src/lib/tasks-app-url.ts +8 -0
@@ -1,9 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { Check, Hexagon } from '@tuturuuu/icons';
4
3
  import { useRouter } from 'next/navigation';
5
- import { DropdownMenuItem } from '../dropdown-menu';
6
4
  import { persistLocalePreference } from './locale-preference';
5
+ import { SatelliteLanguageItem } from './satellite-language-item';
7
6
 
8
7
  interface Props {
9
8
  label: string;
@@ -32,17 +31,10 @@ export function LanguageDropdownItem({
32
31
  };
33
32
 
34
33
  return (
35
- <DropdownMenuItem
36
- className="cursor-pointer"
37
- onClick={useLocale}
38
- disabled={selected}
39
- >
40
- {selected ? (
41
- <Check className="h-4 w-4 text-dynamic-indigo" />
42
- ) : (
43
- <Hexagon className="h-4 w-4 text-dynamic-indigo" />
44
- )}
45
- {label}
46
- </DropdownMenuItem>
34
+ <SatelliteLanguageItem
35
+ label={label}
36
+ selected={selected}
37
+ onSelect={useLocale}
38
+ />
47
39
  );
48
40
  }
@@ -33,6 +33,7 @@ import {
33
33
  } from '../dropdown-menu';
34
34
  import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
35
35
  import type { NavLink as NavLinkType } from './navigation';
36
+ import { satelliteNavigationItemClass } from './satellite-navigation';
36
37
 
37
38
  function matchesPathPrefix(targetPath: string, pathPrefix: string) {
38
39
  return targetPath === pathPrefix || targetPath.startsWith(`${pathPrefix}/`);
@@ -362,16 +363,15 @@ export function NavLink({
362
363
 
363
364
  const commonProps = {
364
365
  className: cn(
365
- 'group/navlink flex w-full cursor-pointer items-center justify-between rounded-md p-2 font-medium text-sm',
366
- isCollapsed && 'justify-center',
367
- isActive && 'bg-accent text-accent-foreground',
366
+ satelliteNavigationItemClass({
367
+ isCollapsed,
368
+ isActive: Boolean(isActive),
369
+ isDisabled,
370
+ }),
368
371
  link.preferenceHiddenActive &&
369
372
  'bg-dynamic-amber/10 ring-1 ring-dynamic-amber/40',
370
373
  link.isBack && 'mb-2 cursor-pointer',
371
- isResolvingHref && 'pointer-events-none opacity-70',
372
- isDisabled
373
- ? 'cursor-not-allowed opacity-50'
374
- : 'hover:bg-accent hover:text-accent-foreground'
374
+ isResolvingHref && 'pointer-events-none opacity-70'
375
375
  ),
376
376
  onClick: (event: React.MouseEvent<HTMLElement>) => {
377
377
  if (isTierRestricted) {
@@ -1,10 +1,7 @@
1
1
  'use client';
2
-
3
- import { cn } from '@tuturuuu/utils/format';
4
- import { usePathname } from 'next/navigation';
5
- import { useEffect, useState } from 'react';
6
2
  import { NavLink } from './nav-link';
7
3
  import type { NavLink as NavLinkType } from './navigation';
4
+ import { SidebarNavigation } from './satellite-navigation';
8
5
 
9
6
  interface NavProps {
10
7
  wsId: string;
@@ -14,7 +11,6 @@ interface NavProps {
14
11
  onClick: () => void;
15
12
  className?: string;
16
13
  }
17
-
18
14
  export function Nav({
19
15
  wsId,
20
16
  links,
@@ -23,64 +19,20 @@ export function Nav({
23
19
  onClick,
24
20
  className,
25
21
  }: NavProps) {
26
- const pathname = usePathname();
27
-
28
- const [urlToLoad, setUrlToLoad] = useState<string>();
29
-
30
- useEffect(() => {
31
- if (urlToLoad && urlToLoad === pathname) setUrlToLoad(undefined);
32
- }, [pathname, urlToLoad]);
33
-
34
- if (!links?.length) {
35
- return null;
36
- }
37
-
38
22
  return (
39
- <div
40
- data-collapsed={isCollapsed}
41
- className={cn('group flex flex-col gap-y-0.5 p-2', className)}
42
- >
43
- <nav className="grid gap-y-0.5">
44
- {links.map((link, index) => {
45
- if (!link) {
46
- return (
47
- <div
48
- key={`nav-divider-${index}`}
49
- className={cn(
50
- 'my-1 border-b',
51
- isCollapsed ? 'mx-auto w-1/2' : 'w-auto'
52
- )}
53
- />
54
- );
55
- }
56
-
57
- const previousSectionLabel = links
58
- .slice(0, index)
59
- .reverse()
60
- .find((previousLink) => previousLink)?.sectionLabel;
61
- const shouldShowSectionLabel =
62
- link.sectionLabel && link.sectionLabel !== previousSectionLabel;
63
-
64
- return (
65
- <div key={`nav-item-${link.href || link.title}-${index}`}>
66
- {shouldShowSectionLabel && !isCollapsed && (
67
- <div className="px-2 pt-3 pb-1 first:pt-0">
68
- <span className="font-semibold text-[11px] text-muted-foreground/80 uppercase tracking-wider">
69
- {link.sectionLabel}
70
- </span>
71
- </div>
72
- )}
73
- <NavLink
74
- wsId={wsId}
75
- link={link}
76
- isCollapsed={isCollapsed}
77
- onSubMenuClick={onSubMenuClick}
78
- onClick={onClick}
79
- />
80
- </div>
81
- );
82
- })}
83
- </nav>
84
- </div>
23
+ <SidebarNavigation
24
+ links={links}
25
+ isCollapsed={isCollapsed}
26
+ className={className}
27
+ renderLink={(link) => (
28
+ <NavLink
29
+ wsId={wsId}
30
+ link={link}
31
+ isCollapsed={isCollapsed}
32
+ onSubMenuClick={onSubMenuClick}
33
+ onClick={onClick}
34
+ />
35
+ )}
36
+ />
85
37
  );
86
38
  }
@@ -0,0 +1,361 @@
1
+ 'use client';
2
+
3
+ import {
4
+ AtSign,
5
+ Bell,
6
+ Calendar,
7
+ Check,
8
+ CheckCircle2,
9
+ ClipboardList,
10
+ Edit3,
11
+ Eye,
12
+ EyeOff,
13
+ Loader2,
14
+ Mail,
15
+ RotateCcw,
16
+ Shield,
17
+ UserPlus,
18
+ X,
19
+ XCircle,
20
+ } from '@tuturuuu/icons';
21
+ import { updateNotificationMetadata } from '@tuturuuu/internal-api';
22
+ import {
23
+ acceptWorkspaceInvite,
24
+ declineWorkspaceInvite,
25
+ } from '@tuturuuu/internal-api/workspaces';
26
+ import { Button } from '@tuturuuu/ui/button';
27
+ import type { Notification } from '@tuturuuu/ui/hooks/use-notifications';
28
+ import {
29
+ dispatchRequestOpenTask,
30
+ waitForTaskOpenResult,
31
+ } from '@tuturuuu/ui/lib/task-open-events';
32
+ import { toast } from '@tuturuuu/ui/sonner';
33
+ import { cn } from '@tuturuuu/utils/format';
34
+ import dayjs from 'dayjs';
35
+ import relativeTime from 'dayjs/plugin/relativeTime';
36
+ import { useState } from 'react';
37
+
38
+ import { useNotificationRuntime } from './notification-runtime';
39
+
40
+ dayjs.extend(relativeTime);
41
+ interface NotificationCardProps {
42
+ notification: Notification;
43
+ wsId?: string;
44
+ onMarkAsRead: (id: string, isUnread: boolean) => void;
45
+ markAsReadText: string;
46
+ markAsUnreadText: string;
47
+ queryClient: any;
48
+ onActionComplete?: () => void;
49
+ acceptText: string;
50
+ declineText: string;
51
+ acceptedText: string;
52
+ declinedText: string;
53
+ }
54
+
55
+ function getWorkspaceInviteWorkspaceId(notification: Notification) {
56
+ const candidates = [
57
+ notification.data?.workspace_id,
58
+ notification.entity_id,
59
+ notification.ws_id,
60
+ ];
61
+
62
+ return (
63
+ candidates.find(
64
+ (candidate): candidate is string =>
65
+ typeof candidate === 'string' && candidate.length > 0
66
+ ) ?? null
67
+ );
68
+ }
69
+
70
+ export function NotificationCard({
71
+ notification,
72
+ wsId,
73
+ onMarkAsRead,
74
+ markAsReadText,
75
+ markAsUnreadText,
76
+ queryClient,
77
+ onActionComplete,
78
+ acceptText,
79
+ declineText,
80
+ acceptedText,
81
+ declinedText,
82
+ }: NotificationCardProps) {
83
+ const isUnread = !notification.read_at;
84
+ const [processingAction, setProcessingAction] = useState<string | null>(null);
85
+ const { router, params, pathname, Link } = useNotificationRuntime();
86
+
87
+ const handleAction = async (actionType: string, payload: any) => {
88
+ setProcessingAction(actionType);
89
+
90
+ try {
91
+ switch (actionType) {
92
+ case 'WORKSPACE_INVITE_ACCEPT':
93
+ case 'WORKSPACE_INVITE_DECLINE': {
94
+ const accept = actionType === 'WORKSPACE_INVITE_ACCEPT';
95
+ const targetWsId = payload.wsId;
96
+ if (!targetWsId) {
97
+ toast.error('Failed to process invite');
98
+ break;
99
+ }
100
+
101
+ await (accept
102
+ ? acceptWorkspaceInvite(targetWsId)
103
+ : declineWorkspaceInvite(targetWsId));
104
+ await updateNotificationMetadata(notification.id, {
105
+ action_taken: accept ? 'accepted' : 'declined',
106
+ action_timestamp: new Date().toISOString(),
107
+ });
108
+
109
+ await Promise.all([
110
+ queryClient.invalidateQueries({
111
+ queryKey: ['workspaces'],
112
+ refetchType: 'active',
113
+ }),
114
+ queryClient.invalidateQueries({
115
+ queryKey: ['notifications'],
116
+ refetchType: 'active',
117
+ }),
118
+ queryClient.refetchQueries({
119
+ queryKey: ['notifications'],
120
+ type: 'active',
121
+ }),
122
+ ]);
123
+
124
+ toast.success(accept ? acceptedText : declinedText);
125
+
126
+ onMarkAsRead(notification.id, true);
127
+ router.refresh();
128
+ onActionComplete?.();
129
+ break;
130
+ }
131
+ default:
132
+ break;
133
+ }
134
+ } catch (error) {
135
+ console.error('Action error:', error);
136
+ toast.error('An error occurred');
137
+ } finally {
138
+ setProcessingAction(null);
139
+ }
140
+ };
141
+
142
+ const notificationWsId = notification.ws_id || wsId;
143
+ const isTaskEntityNotification =
144
+ notification.entity_type === 'task' && !!notification.entity_id;
145
+ const entityLink =
146
+ notification.entity_type === 'time_tracking_request' &&
147
+ notification.entity_id &&
148
+ notificationWsId
149
+ ? `/${notificationWsId}/time-tracker/requests`
150
+ : null;
151
+
152
+ return (
153
+ <div
154
+ className={cn(
155
+ 'group relative rounded-xl border p-3 transition-all hover:border-foreground/20 hover:shadow-sm',
156
+ isUnread
157
+ ? 'border-dynamic-blue/40 bg-dynamic-blue/5'
158
+ : 'bg-foreground/2 hover:bg-foreground/4'
159
+ )}
160
+ >
161
+ <div className="flex items-start gap-3">
162
+ {/* Icon */}
163
+ <div
164
+ className={cn(
165
+ 'flex h-8 w-8 flex-none items-center justify-center rounded-full transition-colors',
166
+ isUnread
167
+ ? 'bg-dynamic-blue/20 text-dynamic-blue'
168
+ : 'bg-foreground/10 text-foreground/60'
169
+ )}
170
+ >
171
+ {getNotificationIcon(notification.type)}
172
+ </div>
173
+
174
+ {/* Content */}
175
+ <div className="min-w-0 flex-1">
176
+ <div className="mb-1 font-medium text-sm leading-snug">
177
+ {notification.title}
178
+ </div>
179
+
180
+ {notification.description && (
181
+ <div className="mb-1.5 line-clamp-2 text-foreground/70 text-xs leading-relaxed">
182
+ {notification.description}
183
+ </div>
184
+ )}
185
+
186
+ <div className="mb-2 text-foreground/40 text-xs">
187
+ {dayjs(notification.created_at).fromNow()}
188
+ </div>
189
+
190
+ {/* Action buttons or status */}
191
+ {notification.type === 'workspace_invite' &&
192
+ notification.data?.action_taken ? (
193
+ <div className="mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
194
+ {notification.data.action_taken === 'accepted' ? (
195
+ <>
196
+ <CheckCircle2 className="h-3 w-3 text-dynamic-green" />
197
+ <span className="font-medium text-dynamic-green">
198
+ {acceptedText}
199
+ </span>
200
+ </>
201
+ ) : (
202
+ <>
203
+ <XCircle className="h-3 w-3 text-foreground/40" />
204
+ <span className="font-medium text-foreground/60">
205
+ {declinedText}
206
+ </span>
207
+ </>
208
+ )}
209
+ </div>
210
+ ) : notification.type === 'workspace_invite' &&
211
+ !notification.data?.action_taken ? (
212
+ <div className="mt-2 flex items-center gap-1.5">
213
+ <Button
214
+ variant="outline"
215
+ size="sm"
216
+ onClick={() =>
217
+ handleAction('WORKSPACE_INVITE_DECLINE', {
218
+ wsId: getWorkspaceInviteWorkspaceId(notification),
219
+ })
220
+ }
221
+ disabled={!!processingAction}
222
+ className="h-7 gap-1 text-xs"
223
+ >
224
+ {processingAction === 'WORKSPACE_INVITE_DECLINE' ? (
225
+ <Loader2 className="h-3 w-3 animate-spin" />
226
+ ) : (
227
+ <X className="h-3 w-3" />
228
+ )}
229
+ {declineText}
230
+ </Button>
231
+ <Button
232
+ size="sm"
233
+ onClick={() =>
234
+ handleAction('WORKSPACE_INVITE_ACCEPT', {
235
+ wsId: getWorkspaceInviteWorkspaceId(notification),
236
+ })
237
+ }
238
+ disabled={!!processingAction}
239
+ className="h-7 gap-1 text-xs"
240
+ >
241
+ {processingAction === 'WORKSPACE_INVITE_ACCEPT' ? (
242
+ <Loader2 className="h-3 w-3 animate-spin" />
243
+ ) : (
244
+ <Check className="h-3 w-3" />
245
+ )}
246
+ {acceptText}
247
+ </Button>
248
+ </div>
249
+ ) : isTaskEntityNotification ? (
250
+ <Button
251
+ variant="link"
252
+ size="sm"
253
+ className="h-auto p-0 text-xs hover:text-dynamic-blue"
254
+ disabled={!!processingAction}
255
+ onClick={async () => {
256
+ if (!notification.entity_id) return;
257
+ setProcessingAction('OPEN_TASK');
258
+ try {
259
+ const { handled, requestId } = dispatchRequestOpenTask({
260
+ taskId: notification.entity_id,
261
+ wsId: notificationWsId,
262
+ });
263
+
264
+ const opened = handled
265
+ ? await waitForTaskOpenResult(requestId, 6000)
266
+ : false;
267
+
268
+ if (!opened && notificationWsId) {
269
+ const locale =
270
+ typeof params?.locale === 'string' ? params.locale : null;
271
+ const targetWorkspacePath = locale
272
+ ? `/${locale}/${notificationWsId}`
273
+ : `/${notificationWsId}`;
274
+ const isAlreadyInTargetWorkspace =
275
+ pathname === targetWorkspacePath ||
276
+ pathname.startsWith(`${targetWorkspacePath}/`);
277
+
278
+ const openTaskParam = `openTaskId=${encodeURIComponent(notification.entity_id)}`;
279
+ const fallbackUrl = isAlreadyInTargetWorkspace
280
+ ? `${pathname}?${openTaskParam}`
281
+ : `${targetWorkspacePath}?${openTaskParam}`;
282
+
283
+ router.push(fallbackUrl);
284
+ }
285
+ onActionComplete?.();
286
+ } finally {
287
+ setProcessingAction(null);
288
+ }
289
+ }}
290
+ >
291
+ {processingAction === 'OPEN_TASK' ? (
292
+ <>
293
+ <Loader2 className="h-3 w-3 animate-spin" />
294
+ Opening...
295
+ </>
296
+ ) : (
297
+ 'View details →'
298
+ )}
299
+ </Button>
300
+ ) : entityLink ? (
301
+ <Link href={entityLink} onClick={onActionComplete}>
302
+ <Button
303
+ variant="link"
304
+ size="sm"
305
+ className="h-auto p-0 text-xs hover:text-dynamic-blue"
306
+ >
307
+ View details →
308
+ </Button>
309
+ </Link>
310
+ ) : null}
311
+ </div>
312
+
313
+ {/* Mark as read/unread button */}
314
+ <Button
315
+ variant="ghost"
316
+ size="icon"
317
+ className={cn(
318
+ 'h-7 w-7 flex-none transition-opacity hover:bg-foreground/10',
319
+ 'opacity-0 group-hover:opacity-100'
320
+ )}
321
+ onClick={() => onMarkAsRead(notification.id, isUnread)}
322
+ title={isUnread ? markAsReadText : markAsUnreadText}
323
+ >
324
+ {isUnread ? (
325
+ <EyeOff className="h-4 w-4" />
326
+ ) : (
327
+ <Eye className="h-4 w-4" />
328
+ )}
329
+ </Button>
330
+ </div>
331
+ </div>
332
+ );
333
+ }
334
+
335
+ function getNotificationIcon(type: string) {
336
+ const iconClassName = 'h-4 w-4';
337
+
338
+ switch (type) {
339
+ case 'task_assigned':
340
+ return <ClipboardList className={iconClassName} />;
341
+ case 'task_updated':
342
+ return <Edit3 className={iconClassName} />;
343
+ case 'task_completed':
344
+ return <CheckCircle2 className={iconClassName} />;
345
+ case 'task_reopened':
346
+ return <RotateCcw className={iconClassName} />;
347
+ case 'task_mention':
348
+ return <AtSign className={iconClassName} />;
349
+ case 'task_due_date_changed':
350
+ case 'task_start_date_changed':
351
+ return <Calendar className={iconClassName} />;
352
+ case 'workspace_invite':
353
+ return <Mail className={iconClassName} />;
354
+ case 'account_update':
355
+ return <UserPlus className={iconClassName} />;
356
+ case 'security_alert':
357
+ return <Shield className={iconClassName} />;
358
+ default:
359
+ return <Bell className={iconClassName} />;
360
+ }
361
+ }
@@ -0,0 +1,178 @@
1
+ 'use client';
2
+ import { Archive, Bell, Inbox, Loader2, RotateCcw } from '@tuturuuu/icons';
3
+ import { Button } from '@tuturuuu/ui/button';
4
+ import type {
5
+ Notification,
6
+ useInfiniteNotifications,
7
+ } from '@tuturuuu/ui/hooks/use-notifications';
8
+ import { useCallback, useEffect, useRef } from 'react';
9
+ import { NotificationCard } from './notification-card';
10
+
11
+ type TabType = 'inbox' | 'archive';
12
+ export function NotificationList({
13
+ query,
14
+ notifications,
15
+ hasNotifications,
16
+ activeTab,
17
+ wsId,
18
+ noNotificationsText,
19
+ emptyArchiveText,
20
+ loadingMoreText,
21
+ retryText,
22
+ acceptText,
23
+ declineText,
24
+ acceptedText,
25
+ declinedText,
26
+ markAsReadText,
27
+ markAsUnreadText,
28
+ onMarkAsRead,
29
+ queryClient,
30
+ onActionComplete,
31
+ }: {
32
+ query: ReturnType<typeof useInfiniteNotifications>;
33
+ notifications: Notification[];
34
+ hasNotifications: boolean;
35
+ activeTab: TabType;
36
+ wsId?: string;
37
+ noNotificationsText: string;
38
+ emptyArchiveText: string;
39
+ loadingMoreText: string;
40
+ retryText: string;
41
+ acceptText: string;
42
+ declineText: string;
43
+ acceptedText: string;
44
+ declinedText: string;
45
+ markAsReadText: string;
46
+ markAsUnreadText: string;
47
+ onMarkAsRead: (id: string, isUnread: boolean) => void;
48
+ queryClient: any;
49
+ onActionComplete: () => void;
50
+ }) {
51
+ const scrollRef = useRef<HTMLDivElement>(null);
52
+ const sentinelRef = useRef<HTMLDivElement>(null);
53
+
54
+ // Infinite scroll via IntersectionObserver
55
+ const handleIntersect = useCallback(
56
+ (entries: IntersectionObserverEntry[]) => {
57
+ const entry = entries[0];
58
+ if (
59
+ entry?.isIntersecting &&
60
+ query.hasNextPage &&
61
+ !query.isFetchingNextPage
62
+ ) {
63
+ query.fetchNextPage();
64
+ }
65
+ },
66
+ [query]
67
+ );
68
+
69
+ useEffect(() => {
70
+ const sentinel = sentinelRef.current;
71
+ if (!sentinel) return;
72
+
73
+ const observer = new IntersectionObserver(handleIntersect, {
74
+ root: scrollRef.current,
75
+ rootMargin: '100px',
76
+ });
77
+
78
+ observer.observe(sentinel);
79
+ return () => observer.disconnect();
80
+ }, [handleIntersect]);
81
+
82
+ const emptyText =
83
+ activeTab === 'inbox' ? noNotificationsText : emptyArchiveText;
84
+
85
+ if (query.error) {
86
+ return (
87
+ <div className="flex h-90 flex-col items-center justify-center text-center">
88
+ <Bell className="mb-3 h-10 w-10 text-dynamic-red/30" />
89
+ <p className="text-foreground/60 text-sm">
90
+ Failed to load notifications
91
+ </p>
92
+ <p className="mt-1 text-foreground/40 text-xs">
93
+ {query.error instanceof Error ? query.error.message : 'Unknown error'}
94
+ </p>
95
+ <Button
96
+ className="mt-3"
97
+ disabled={query.isFetching}
98
+ onClick={() => query.refetch()}
99
+ size="sm"
100
+ type="button"
101
+ variant="outline"
102
+ >
103
+ {query.isFetching ? (
104
+ <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
105
+ ) : (
106
+ <RotateCcw className="mr-1.5 h-3.5 w-3.5" />
107
+ )}
108
+ {retryText}
109
+ </Button>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ if (query.isLoading) {
115
+ return (
116
+ <div className="space-y-2 p-2">
117
+ {[...Array(3)].map((_, i) => (
118
+ <div key={i} className="rounded-xl border bg-foreground/2 p-3">
119
+ <div className="flex items-start gap-3">
120
+ <div className="h-8 w-8 flex-none animate-pulse rounded-full bg-foreground/10" />
121
+ <div className="min-w-0 flex-1 space-y-2">
122
+ <div className="h-3 w-24 animate-pulse rounded bg-foreground/10" />
123
+ <div className="h-4 w-full animate-pulse rounded bg-foreground/10" />
124
+ <div className="h-3 w-3/4 animate-pulse rounded bg-foreground/10" />
125
+ </div>
126
+ </div>
127
+ </div>
128
+ ))}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ if (!hasNotifications) {
134
+ return (
135
+ <div className="flex h-90 flex-col items-center justify-center text-center">
136
+ {activeTab === 'inbox' ? (
137
+ <Inbox className="mb-3 h-10 w-10 text-foreground/20" />
138
+ ) : (
139
+ <Archive className="mb-3 h-10 w-10 text-foreground/20" />
140
+ )}
141
+ <p className="text-foreground/60 text-sm">{emptyText}</p>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ return (
147
+ <div ref={scrollRef} className="max-h-90 overflow-y-auto">
148
+ <div className="space-y-1.5 p-2">
149
+ {notifications.map((notification) => (
150
+ <NotificationCard
151
+ key={notification.id}
152
+ notification={notification}
153
+ wsId={wsId}
154
+ onMarkAsRead={onMarkAsRead}
155
+ markAsReadText={markAsReadText}
156
+ markAsUnreadText={markAsUnreadText}
157
+ queryClient={queryClient}
158
+ onActionComplete={onActionComplete}
159
+ acceptText={acceptText}
160
+ declineText={declineText}
161
+ acceptedText={acceptedText}
162
+ declinedText={declinedText}
163
+ />
164
+ ))}
165
+
166
+ {/* Sentinel for infinite scroll */}
167
+ <div ref={sentinelRef} className="h-1" />
168
+
169
+ {query.isFetchingNextPage && (
170
+ <div className="flex items-center justify-center gap-2 py-3 text-foreground/40 text-xs">
171
+ <Loader2 className="h-3 w-3 animate-spin" />
172
+ {loadingMoreText}
173
+ </div>
174
+ )}
175
+ </div>
176
+ </div>
177
+ );
178
+ }