@allbluecn/web-app 0.4.16 → 0.4.18
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/package.json +10 -10
- package/src/components/billing/checkout-redirect.ts +1 -1
- package/src/components/changelog/changelog-page.tsx +1 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
- package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
- package/src/components/layout/sidebar-03/user-menu.tsx +5 -4
- package/src/components/notifications/notification-list.tsx +232 -0
- package/src/components/notifications/notification-preferences.tsx +156 -0
- package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
- package/src/components/settings/ai/provider-grid.tsx +46 -25
- package/src/components/settings/personal-team/team/team-client.tsx +118 -15
- package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
- package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
- package/src/components/story/display/__tests__/display-dropdown.test.tsx +30 -7
- package/src/components/story/display/display-dropdown.tsx +6 -7
- package/src/components/story/inline-editors/assignee-editor.tsx +72 -57
- package/src/components/story/story-list-view.tsx +13 -2
- package/src/hooks/__tests__/use-notifications.test.ts +41 -0
- package/src/hooks/use-notifications.ts +119 -0
- package/src/lib/app-version.ts +4 -1
- package/src/lib/auth/auth.config.ts +17 -1
- package/src/lib/changelog/sdk-versions-types.ts +1 -0
- package/src/lib/changelog/sdk-versions.ts +29 -19
- package/src/lib/collections/ai/index.ts +0 -1
- package/src/lib/collections/index.ts +0 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +10 -0
- package/src/routeTree.gen.ts +70 -0
- package/src/routes/_login/login/route.tsx +10 -1
- package/src/routes/_login/register/route.lazy.tsx +3 -2
- package/src/routes/_login/register/route.tsx +5 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
- package/src/routes/_nav/notifications/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
- package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
- package/src/routes/_nav/settings/notifications/route.tsx +20 -0
- package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
- package/src/routes/_nav/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +2 -3
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/notifications/stream.ts +88 -0
- package/src/routes/invite/$token/route.lazy.tsx +308 -44
- package/src/routes/invite/$token/route.tsx +16 -8
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
- package/src/server/notifications/__tests__/stream.test.ts +30 -0
- package/src/server/notifications/event-bus.ts +16 -0
- package/src/server/notifications/notification-service.ts +86 -0
- package/src/server/resources.ts +10 -2
- package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
- package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
- package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
- package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
- package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
- package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
- package/src/server/serverFns/billing.ts +23 -2
- package/src/server/serverFns/notifications.ts +155 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
- package/src/server/serverFns/story/story-comments.ts +42 -0
- package/src/server/serverFns/story/story-crud/labels.ts +45 -6
- package/src/server/serverFns/story/story-crud.ts +8 -2
- package/src/server/serverFns/story/story-groups.ts +26 -7
- package/src/server/serverFns/story/story-search.ts +4 -0
- package/src/server/serverFns/story-display-options.ts +15 -4
- package/src/server/serverFns/team.ts +573 -53
- package/src/start.ts +23 -1
- package/vite.shared.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/web-app",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.18",
|
|
4
4
|
"license": "AGPL-3.0-or-later",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@fontsource-variable/geist": "^5.3.0",
|
|
28
28
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
29
29
|
"@iconify/react": "^6.0.2",
|
|
30
|
-
"@lobehub/icons": "^5.
|
|
30
|
+
"@lobehub/icons": "^5.16.0",
|
|
31
31
|
"@prisma/client": "^7.8.0",
|
|
32
32
|
"@radix-ui/react-dialog": "^1.1.23",
|
|
33
33
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
@@ -135,14 +135,14 @@
|
|
|
135
135
|
"three": "^0.180.0",
|
|
136
136
|
"tw-animate-css": "^1.4.0",
|
|
137
137
|
"zod": "^4.4.3",
|
|
138
|
-
"@allbluecn/ai-gateway": "0.4.11",
|
|
139
138
|
"@allbluecn/kernel": "0.3.0",
|
|
140
|
-
"@allbluecn/
|
|
139
|
+
"@allbluecn/database": "0.4.13",
|
|
140
|
+
"@allbluecn/ui": "0.4.13",
|
|
141
|
+
"@allbluecn/shared": "0.4.13",
|
|
142
|
+
"@allbluecn/ai-gateway": "0.4.13",
|
|
141
143
|
"@allbluecn/prototype": "0.1.1",
|
|
142
|
-
"@allbluecn/
|
|
143
|
-
"@allbluecn/
|
|
144
|
-
"@allbluecn/ui": "0.4.11",
|
|
145
|
-
"@allbluecn/database": "0.4.11"
|
|
144
|
+
"@allbluecn/tiptap": "0.4.12",
|
|
145
|
+
"@allbluecn/plugins-core": "0.4.12"
|
|
146
146
|
},
|
|
147
147
|
"devDependencies": {
|
|
148
148
|
"@babel/parser": "^8.0.4",
|
|
@@ -197,8 +197,8 @@
|
|
|
197
197
|
"setup": "pnpm --filter @allbluecn/database generate && pnpm --filter @allbluecn/database migrate:dev",
|
|
198
198
|
"check:features": "tsx scripts/check-feature-keys.ts",
|
|
199
199
|
"assemble": "pnpm -w exec tsx packages/kernel/src/bin.ts --profile oss --assemble --app-dir apps/web",
|
|
200
|
-
"predev": "pnpm assemble",
|
|
201
|
-
"prebuild": "pnpm assemble",
|
|
200
|
+
"predev": "pnpm assemble && node scripts/check-sdk-versions.mjs",
|
|
201
|
+
"prebuild": "pnpm assemble && node scripts/check-sdk-versions.mjs",
|
|
202
202
|
"pretest": "pnpm assemble",
|
|
203
203
|
"pretest:run": "pnpm assemble"
|
|
204
204
|
}
|
|
@@ -38,7 +38,7 @@ export async function redirectToPortal(): Promise<void> {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
try {
|
|
41
|
-
const { url } = await createCustomerPortalSessionFn()
|
|
41
|
+
const { url } = await createCustomerPortalSessionFn({ data: {} })
|
|
42
42
|
window.location.href = url
|
|
43
43
|
} catch (error) {
|
|
44
44
|
toast.error(
|
|
@@ -378,7 +378,7 @@ function SdkGroupSection({
|
|
|
378
378
|
className="flex items-center justify-between gap-2 py-1.5 group"
|
|
379
379
|
>
|
|
380
380
|
<span className="text-xs font-medium text-muted-foreground group-hover:text-primary transition-colors truncate">
|
|
381
|
-
{sdk.name}
|
|
381
|
+
{sdk.displayName || sdk.name}
|
|
382
382
|
</span>
|
|
383
383
|
<code className="text-[10px] font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded shrink-0 group-hover:text-primary transition-colors">
|
|
384
384
|
{sdk.version}
|
|
@@ -37,7 +37,6 @@ import { ThemeToggle } from "@/components/shared/theme-toggle";
|
|
|
37
37
|
import { AnimatedThemeToggler } from '@/components/magicui/animated-theme-toggler'
|
|
38
38
|
import { Badge } from "@/components/ui/badge"
|
|
39
39
|
import { workspaceRoutes } from "@/config/sidebar-routes";
|
|
40
|
-
import { notifications } from "@/config/sidebar-notifications";
|
|
41
40
|
import type { Route } from "@/config/sidebar-routes";
|
|
42
41
|
import { SidebarSearchTrigger } from "@/components/layout/sidebar-03/nav-search-trigger"
|
|
43
42
|
import { NavSecondary } from "@/components/layout/sidebar-03/nav-secondary"
|
|
@@ -65,7 +64,7 @@ export function DashboardSidebar() {
|
|
|
65
64
|
animate={{ opacity: 1 }}
|
|
66
65
|
transition={{ duration: 0.8 }}
|
|
67
66
|
>
|
|
68
|
-
<NotificationsPopover
|
|
67
|
+
<NotificationsPopover />
|
|
69
68
|
<SidebarTrigger />
|
|
70
69
|
</m.div>
|
|
71
70
|
</div>
|
|
@@ -86,7 +85,7 @@ export function DashboardSidebar() {
|
|
|
86
85
|
animate={{ opacity: 1 }}
|
|
87
86
|
transition={{ duration: 0.8 }}
|
|
88
87
|
>
|
|
89
|
-
<NotificationsPopover
|
|
88
|
+
<NotificationsPopover />
|
|
90
89
|
<AnimatedThemeToggler />
|
|
91
90
|
|
|
92
91
|
<SidebarTrigger />
|
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
19
|
+
import { useNavigate } from '@tanstack/react-router'
|
|
20
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
21
|
+
import { useTranslation } from 'react-i18next'
|
|
22
|
+
import { BellIcon, CheckCheck } from 'lucide-react'
|
|
23
|
+
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|
24
|
+
import { Button } from '@/components/ui/button'
|
|
21
25
|
import {
|
|
22
26
|
DropdownMenu,
|
|
23
27
|
DropdownMenuContent,
|
|
@@ -25,54 +29,130 @@ import {
|
|
|
25
29
|
DropdownMenuLabel,
|
|
26
30
|
DropdownMenuSeparator,
|
|
27
31
|
DropdownMenuTrigger,
|
|
28
|
-
} from
|
|
29
|
-
import {
|
|
32
|
+
} from '@/components/ui/dropdown-menu'
|
|
33
|
+
import {
|
|
34
|
+
listNotificationsFn,
|
|
35
|
+
markAllNotificationsReadFn,
|
|
36
|
+
markNotificationReadFn,
|
|
37
|
+
} from '@/server/serverFns/notifications'
|
|
38
|
+
import { NOTIFICATION_LIST_KEY, NOTIFICATION_UNREAD_KEY, useNotifications } from '@/hooks/use-notifications'
|
|
39
|
+
import { CATEGORY_ICONS } from '@/components/notifications/notification-list'
|
|
40
|
+
|
|
41
|
+
export function NotificationsPopover() {
|
|
42
|
+
const { t } = useTranslation('notifications')
|
|
43
|
+
const navigate = useNavigate()
|
|
44
|
+
const queryClient = useQueryClient()
|
|
45
|
+
const { unreadCount, setUnreadCount } = useNotifications()
|
|
46
|
+
|
|
47
|
+
const { data } = useQuery({
|
|
48
|
+
queryKey: NOTIFICATION_LIST_KEY,
|
|
49
|
+
queryFn: () => listNotificationsFn({ data: {} }),
|
|
50
|
+
})
|
|
30
51
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
const recent = (data?.items ?? []).slice(0, 5)
|
|
53
|
+
|
|
54
|
+
const handleClickItem = async (
|
|
55
|
+
id: string,
|
|
56
|
+
link: string | null,
|
|
57
|
+
isUnread: boolean,
|
|
58
|
+
) => {
|
|
59
|
+
if (isUnread) {
|
|
60
|
+
await markNotificationReadFn({ data: { id } })
|
|
61
|
+
setUnreadCount((c) => Math.max(0, c - 1))
|
|
62
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
|
|
63
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_UNREAD_KEY })
|
|
64
|
+
}
|
|
65
|
+
if (link) {
|
|
66
|
+
navigate({ to: link as never })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const handleMarkAll = async () => {
|
|
71
|
+
await markAllNotificationsReadFn({ data: {} })
|
|
72
|
+
setUnreadCount(0)
|
|
73
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
|
|
74
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_UNREAD_KEY })
|
|
75
|
+
}
|
|
38
76
|
|
|
39
|
-
export function NotificationsPopover({
|
|
40
|
-
notifications,
|
|
41
|
-
}: {
|
|
42
|
-
notifications: Notification[];
|
|
43
|
-
}) {
|
|
44
77
|
return (
|
|
45
78
|
<DropdownMenu>
|
|
46
79
|
<DropdownMenuTrigger asChild>
|
|
47
80
|
<Button
|
|
48
81
|
variant="ghost"
|
|
49
82
|
size="icon"
|
|
50
|
-
className="rounded-full"
|
|
51
|
-
aria-label=
|
|
83
|
+
className="relative rounded-full"
|
|
84
|
+
aria-label={t('bell.label')}
|
|
52
85
|
>
|
|
53
86
|
<BellIcon className="size-5" />
|
|
87
|
+
{unreadCount > 0 && (
|
|
88
|
+
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground">
|
|
89
|
+
{unreadCount > 99 ? '99+' : unreadCount}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
54
92
|
</Button>
|
|
55
93
|
</DropdownMenuTrigger>
|
|
56
94
|
<DropdownMenuContent side="right" className="w-80 my-6">
|
|
57
|
-
<
|
|
95
|
+
<div className="flex items-center justify-between px-2 py-1.5">
|
|
96
|
+
<DropdownMenuLabel className="p-0">{t('bell.recentTitle')}</DropdownMenuLabel>
|
|
97
|
+
{unreadCount > 0 && (
|
|
98
|
+
<Button
|
|
99
|
+
variant="ghost"
|
|
100
|
+
size="sm"
|
|
101
|
+
className="h-7 gap-1 px-2 text-xs"
|
|
102
|
+
onClick={handleMarkAll}
|
|
103
|
+
>
|
|
104
|
+
<CheckCheck className="size-3.5" />
|
|
105
|
+
{t('actions.markAllRead')}
|
|
106
|
+
</Button>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
58
109
|
<DropdownMenuSeparator />
|
|
59
|
-
{
|
|
60
|
-
<
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
110
|
+
{recent.length === 0 && (
|
|
111
|
+
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
|
|
112
|
+
{t('empty')}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
{recent.map((item) => {
|
|
116
|
+
const Icon = CATEGORY_ICONS[item.category]
|
|
117
|
+
const isUnread = !item.readAt
|
|
118
|
+
return (
|
|
119
|
+
<DropdownMenuItem
|
|
120
|
+
key={item.id}
|
|
121
|
+
className="flex items-start gap-3"
|
|
122
|
+
onClick={() => handleClickItem(item.id, item.link, isUnread)}
|
|
123
|
+
>
|
|
124
|
+
<Avatar className="size-8">
|
|
125
|
+
<AvatarFallback className="bg-primary/10 text-primary">
|
|
126
|
+
<Icon className="size-4" />
|
|
127
|
+
</AvatarFallback>
|
|
128
|
+
</Avatar>
|
|
129
|
+
<div className="flex min-w-0 flex-col">
|
|
130
|
+
<span
|
|
131
|
+
className={
|
|
132
|
+
isUnread
|
|
133
|
+
? 'text-sm font-medium'
|
|
134
|
+
: 'text-sm font-medium opacity-60'
|
|
135
|
+
}
|
|
136
|
+
>
|
|
137
|
+
{item.title}
|
|
138
|
+
</span>
|
|
139
|
+
{item.body && (
|
|
140
|
+
<span className="line-clamp-1 text-xs text-muted-foreground">
|
|
141
|
+
{item.body}
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</DropdownMenuItem>
|
|
146
|
+
)
|
|
147
|
+
})}
|
|
71
148
|
<DropdownMenuSeparator />
|
|
72
|
-
<DropdownMenuItem
|
|
73
|
-
|
|
149
|
+
<DropdownMenuItem
|
|
150
|
+
className="justify-center text-sm text-muted-foreground hover:text-primary"
|
|
151
|
+
onClick={() => navigate({ to: '/notifications' })}
|
|
152
|
+
>
|
|
153
|
+
{t('actions.viewAll')}
|
|
74
154
|
</DropdownMenuItem>
|
|
75
155
|
</DropdownMenuContent>
|
|
76
156
|
</DropdownMenu>
|
|
77
|
-
)
|
|
78
|
-
}
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
LogIn,
|
|
24
24
|
LogOut,
|
|
25
25
|
Settings,
|
|
26
|
+
Users,
|
|
26
27
|
} from "lucide-react";
|
|
27
28
|
import { logoutRemoteFn } from '@/server/serverFns/auth/session'
|
|
28
29
|
import { toast } from 'sonner'
|
|
@@ -76,7 +77,7 @@ export function UserMenu() {
|
|
|
76
77
|
|
|
77
78
|
const handleSignOut = async () => {
|
|
78
79
|
if (isLocalGuest) {
|
|
79
|
-
await logoutRemoteFn();
|
|
80
|
+
await logoutRemoteFn({ data: {} });
|
|
80
81
|
toast.success(t('sidebar.userMenu.logoutToGuest'));
|
|
81
82
|
queryClient.resetQueries({ queryKey: ['session'] });
|
|
82
83
|
queryClient.resetQueries({ queryKey: ['effective-plan'] });
|
|
@@ -160,9 +161,9 @@ export function UserMenu() {
|
|
|
160
161
|
<Settings />
|
|
161
162
|
{t('sidebar.userMenu.settings')}
|
|
162
163
|
</DropdownMenuItem>
|
|
163
|
-
<DropdownMenuItem onClick={async () => { await router.navigate({ to: "/settings/
|
|
164
|
-
<
|
|
165
|
-
{t('sidebar.userMenu.
|
|
164
|
+
<DropdownMenuItem onClick={async () => { await router.navigate({ to: "/settings/team" }) }}>
|
|
165
|
+
<Users />
|
|
166
|
+
{t('sidebar.userMenu.teamManagement')}
|
|
166
167
|
</DropdownMenuItem>
|
|
167
168
|
|
|
168
169
|
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { useCallback, useState } from 'react'
|
|
3
|
+
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import { useNavigate } from '@tanstack/react-router'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { format } from 'date-fns'
|
|
7
|
+
import { zhCN } from 'date-fns/locale'
|
|
8
|
+
import { Bell, CreditCard, MessageSquare, Megaphone, ShieldCheck, Trash2, Users } from 'lucide-react'
|
|
9
|
+
import type { LucideIcon } from 'lucide-react'
|
|
10
|
+
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|
11
|
+
import { Button } from '@/components/ui/button'
|
|
12
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
13
|
+
import { cn } from '@/lib/utils'
|
|
14
|
+
import type { NotificationCategory } from '@allbluecn/shared'
|
|
15
|
+
import {
|
|
16
|
+
deleteNotificationFn,
|
|
17
|
+
listNotificationsFn,
|
|
18
|
+
markAllNotificationsReadFn,
|
|
19
|
+
markNotificationReadFn,
|
|
20
|
+
} from '@/server/serverFns/notifications'
|
|
21
|
+
import {
|
|
22
|
+
NOTIFICATION_LIST_KEY,
|
|
23
|
+
NOTIFICATION_UNREAD_KEY,
|
|
24
|
+
} from '@/hooks/use-notifications'
|
|
25
|
+
|
|
26
|
+
export const CATEGORY_ICONS: Record<NotificationCategory, LucideIcon> & { all: LucideIcon } = {
|
|
27
|
+
all: Bell,
|
|
28
|
+
team: Users,
|
|
29
|
+
collaboration: MessageSquare,
|
|
30
|
+
system: Megaphone,
|
|
31
|
+
billing: CreditCard,
|
|
32
|
+
security: ShieldCheck,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TABS: { key: 'all' | NotificationCategory; labelKey: string }[] = [
|
|
36
|
+
{ key: 'all', labelKey: 'tabs.all' },
|
|
37
|
+
{ key: 'team', labelKey: 'tabs.team' },
|
|
38
|
+
{ key: 'collaboration', labelKey: 'tabs.collaboration' },
|
|
39
|
+
{ key: 'system', labelKey: 'tabs.system' },
|
|
40
|
+
{ key: 'billing', labelKey: 'tabs.billing' },
|
|
41
|
+
{ key: 'security', labelKey: 'tabs.security' },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
export interface NotificationListProps {
|
|
45
|
+
category?: NotificationCategory
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function NotificationList({ category }: NotificationListProps) {
|
|
49
|
+
const { t } = useTranslation('notifications')
|
|
50
|
+
const navigate = useNavigate()
|
|
51
|
+
const queryClient = useQueryClient()
|
|
52
|
+
const [activeTab, setActiveTab] = useState<'all' | NotificationCategory>(category ?? 'all')
|
|
53
|
+
|
|
54
|
+
const { data, isLoading, fetchNextPage, isFetchingNextPage, hasNextPage } = useInfiniteQuery({
|
|
55
|
+
queryKey: [...NOTIFICATION_LIST_KEY, activeTab === 'all' ? undefined : activeTab],
|
|
56
|
+
queryFn: async ({ pageParam }) => {
|
|
57
|
+
const base = activeTab === 'all' ? {} : { category: activeTab }
|
|
58
|
+
return listNotificationsFn({
|
|
59
|
+
data: pageParam ? { ...base, cursor: pageParam } : base,
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
initialPageParam: undefined as string | undefined,
|
|
63
|
+
getNextPageParam: (lastPage) =>
|
|
64
|
+
lastPage.hasMore ? lastPage.items[lastPage.items.length - 1].id : undefined,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const handleMarkRead = useCallback(
|
|
68
|
+
async (id: string) => {
|
|
69
|
+
await markNotificationReadFn({ data: { id } })
|
|
70
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
|
|
71
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_UNREAD_KEY })
|
|
72
|
+
},
|
|
73
|
+
[queryClient]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const handleDelete = useCallback(
|
|
77
|
+
async (id: string, e: React.MouseEvent) => {
|
|
78
|
+
e.stopPropagation()
|
|
79
|
+
await deleteNotificationFn({ data: { id } })
|
|
80
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
|
|
81
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_UNREAD_KEY })
|
|
82
|
+
},
|
|
83
|
+
[queryClient]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const handleMarkAll = useCallback(async () => {
|
|
87
|
+
await markAllNotificationsReadFn({ data: {} })
|
|
88
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
|
|
89
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_UNREAD_KEY })
|
|
90
|
+
}, [queryClient])
|
|
91
|
+
|
|
92
|
+
const handleClickItem = useCallback(
|
|
93
|
+
(item: { id: string; link: string | null; readAt: string | null }) => {
|
|
94
|
+
if (!item.readAt) {
|
|
95
|
+
void handleMarkRead(item.id)
|
|
96
|
+
}
|
|
97
|
+
if (item.link) {
|
|
98
|
+
navigate({ to: item.link as never })
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
[handleMarkRead, navigate]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const formatRelativeTime = (createdAt: string) => {
|
|
105
|
+
const date = new Date(createdAt)
|
|
106
|
+
const now = new Date()
|
|
107
|
+
const diffMs = now.getTime() - date.getTime()
|
|
108
|
+
const diffMin = Math.floor(diffMs / 60000)
|
|
109
|
+
const diffHour = Math.floor(diffMs / 3600000)
|
|
110
|
+
const diffDay = Math.floor(diffMs / 86400000)
|
|
111
|
+
|
|
112
|
+
if (diffMin < 1) return t('relativeTime.justNow')
|
|
113
|
+
if (diffMin < 60) return t('relativeTime.minutesAgo', { count: diffMin })
|
|
114
|
+
if (diffHour < 24) return t('relativeTime.hoursAgo', { count: diffHour })
|
|
115
|
+
if (diffDay < 30) return t('relativeTime.daysAgo', { count: diffDay })
|
|
116
|
+
return format(date, 'MM/dd/yyyy HH:mm', { locale: zhCN })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (isLoading) {
|
|
120
|
+
return (
|
|
121
|
+
<div className="flex flex-col gap-3">
|
|
122
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
123
|
+
<div key={i} className="flex items-center gap-3 rounded-lg border p-3">
|
|
124
|
+
<Skeleton className="size-9 shrink-0 rounded-full" />
|
|
125
|
+
<div className="flex-1 space-y-2">
|
|
126
|
+
<Skeleton className="h-4 w-3/4" />
|
|
127
|
+
<Skeleton className="h-3 w-1/2" />
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const items = data?.pages.flatMap((page) => page.items) ?? []
|
|
136
|
+
const hasMore = hasNextPage ?? false
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className="flex flex-col gap-4">
|
|
140
|
+
<div className="flex items-center justify-between">
|
|
141
|
+
<div className="flex gap-1">
|
|
142
|
+
{TABS.map((tab) => {
|
|
143
|
+
const Icon = CATEGORY_ICONS[tab.key]
|
|
144
|
+
return (
|
|
145
|
+
<Button
|
|
146
|
+
key={tab.key}
|
|
147
|
+
variant={activeTab === tab.key ? 'secondary' : 'ghost'}
|
|
148
|
+
size="sm"
|
|
149
|
+
className="gap-1.5"
|
|
150
|
+
onClick={() => setActiveTab(tab.key)}
|
|
151
|
+
>
|
|
152
|
+
<Icon className="size-4" />
|
|
153
|
+
{t(tab.labelKey)}
|
|
154
|
+
</Button>
|
|
155
|
+
)
|
|
156
|
+
})}
|
|
157
|
+
</div>
|
|
158
|
+
{items.some((item) => !item.readAt) && (
|
|
159
|
+
<Button variant="ghost" size="sm" onClick={handleMarkAll}>
|
|
160
|
+
{t('actions.markAllRead')}
|
|
161
|
+
</Button>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{items.length === 0 ? (
|
|
166
|
+
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
167
|
+
<Bell className="size-12 text-muted-foreground/40" />
|
|
168
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
169
|
+
{activeTab === 'all' ? t('empty') : t('emptyFiltered')}
|
|
170
|
+
</p>
|
|
171
|
+
</div>
|
|
172
|
+
) : (
|
|
173
|
+
<div className="flex flex-col gap-1">
|
|
174
|
+
{items.map((item) => {
|
|
175
|
+
const Icon = CATEGORY_ICONS[item.category]
|
|
176
|
+
const isUnread = !item.readAt
|
|
177
|
+
return (
|
|
178
|
+
<div
|
|
179
|
+
key={item.id}
|
|
180
|
+
className={cn(
|
|
181
|
+
'group flex items-start gap-3 rounded-lg border p-3 transition-colors',
|
|
182
|
+
isUnread ? 'bg-muted/30' : 'opacity-70',
|
|
183
|
+
'hover:bg-muted/50 cursor-pointer'
|
|
184
|
+
)}
|
|
185
|
+
onClick={() => handleClickItem(item)}
|
|
186
|
+
>
|
|
187
|
+
<Avatar className="size-9 shrink-0">
|
|
188
|
+
<AvatarFallback className="bg-primary/10 text-primary">
|
|
189
|
+
<Icon className="size-4" />
|
|
190
|
+
</AvatarFallback>
|
|
191
|
+
</Avatar>
|
|
192
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
193
|
+
<div className="flex items-center gap-2">
|
|
194
|
+
{isUnread && <span className="size-2 shrink-0 rounded-full bg-primary" />}
|
|
195
|
+
<span className={cn('text-sm font-medium', isUnread ? '' : 'text-muted-foreground')}>
|
|
196
|
+
{item.title}
|
|
197
|
+
</span>
|
|
198
|
+
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
|
199
|
+
{formatRelativeTime(item.createdAt)}
|
|
200
|
+
</span>
|
|
201
|
+
</div>
|
|
202
|
+
{item.body && (
|
|
203
|
+
<span className="line-clamp-2 text-xs text-muted-foreground">{item.body}</span>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
<Button
|
|
207
|
+
variant="ghost"
|
|
208
|
+
size="icon"
|
|
209
|
+
className="size-7 shrink-0 opacity-0 group-hover:opacity-100"
|
|
210
|
+
onClick={(e) => handleDelete(item.id, e)}
|
|
211
|
+
>
|
|
212
|
+
<Trash2 className="size-3.5" />
|
|
213
|
+
</Button>
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
})}
|
|
217
|
+
{hasMore && (
|
|
218
|
+
<Button
|
|
219
|
+
variant="ghost"
|
|
220
|
+
size="sm"
|
|
221
|
+
className="mt-1 mx-auto"
|
|
222
|
+
onClick={() => void fetchNextPage()}
|
|
223
|
+
disabled={isFetchingNextPage}
|
|
224
|
+
>
|
|
225
|
+
{t('actions.loadMore')}
|
|
226
|
+
</Button>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
}
|