@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.
- package/CHANGELOG.md +49 -0
- package/biome.json +1 -1
- package/package.json +95 -49
- package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
- package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
- package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
- package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
- package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
- package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
- package/src/components/ui/custom/nav-link.tsx +7 -7
- package/src/components/ui/custom/nav.tsx +15 -63
- package/src/components/ui/custom/notification-card.tsx +361 -0
- package/src/components/ui/custom/notification-list.tsx +178 -0
- package/src/components/ui/custom/notification-popover-client.tsx +12 -780
- package/src/components/ui/custom/notification-runtime.tsx +23 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
- package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
- package/src/components/ui/custom/satellite-brand.tsx +122 -0
- package/src/components/ui/custom/satellite-content.tsx +148 -0
- package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
- package/src/components/ui/custom/satellite-language-item.tsx +26 -0
- package/src/components/ui/custom/satellite-navigation.tsx +86 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
- package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
- package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
- package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
- package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
- package/src/components/ui/custom/satellite-shell.tsx +50 -0
- package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
- package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
- package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
- package/src/components/ui/custom/sidebar-context.tsx +5 -14
- package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
- package/src/components/ui/custom/structure.tsx +14 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
- package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
- package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
- package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
- package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
- package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
- package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
- package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
- package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
- package/src/components/ui/pwa-status.test.tsx +53 -0
- package/src/components/ui/pwa-status.tsx +113 -0
- package/src/components/ui/report-problem-dialog-content.tsx +350 -0
- package/src/components/ui/report-problem-dialog.tsx +6 -975
- package/src/components/ui/report-problem-model.ts +133 -0
- package/src/components/ui/report-problem-mutation.ts +122 -0
- package/src/components/ui/save-notification.test.tsx +36 -0
- package/src/components/ui/save-notification.tsx +17 -0
- package/src/components/ui/use-report-problem.ts +488 -0
- package/src/declarations.d.ts +2 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
- package/src/hooks/calendar-date-utils.ts +11 -0
- package/src/hooks/calendar-event-lookup.test.ts +77 -0
- package/src/hooks/calendar-event-lookup.ts +47 -0
- package/src/hooks/calendar-range-cache.ts +52 -0
- package/src/hooks/pwa-install-prompt.ts +44 -0
- package/src/hooks/use-calendar-sync.tsx +23 -100
- package/src/hooks/use-calendar.tsx +21 -77
- package/src/hooks/use-notifications.ts +9 -24
- package/src/hooks/use-open-initial-calendar-event.ts +27 -0
- package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
- package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
- package/src/hooks/use-workspace-presence.ts +0 -18
- package/src/lib/tasks-app-url.test.ts +25 -0
- 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
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|