@allbluecn/web-app 0.4.17 → 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.
Files changed (67) hide show
  1. package/package.json +10 -10
  2. package/src/components/billing/checkout-redirect.ts +1 -1
  3. package/src/components/changelog/changelog-page.tsx +1 -1
  4. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
  5. package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
  6. package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
  7. package/src/components/notifications/notification-list.tsx +232 -0
  8. package/src/components/notifications/notification-preferences.tsx +156 -0
  9. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  10. package/src/components/settings/ai/provider-grid.tsx +46 -25
  11. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  12. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  13. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  14. package/src/hooks/__tests__/use-notifications.test.ts +41 -0
  15. package/src/hooks/use-notifications.ts +119 -0
  16. package/src/lib/app-version.ts +4 -1
  17. package/src/lib/auth/auth.config.ts +17 -1
  18. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  19. package/src/lib/changelog/sdk-versions.ts +29 -19
  20. package/src/lib/collections/ai/index.ts +0 -1
  21. package/src/lib/collections/index.ts +0 -1
  22. package/src/plugins/.gen-manifest.json +13 -0
  23. package/src/plugins/modules.gen.ts +20 -0
  24. package/src/plugins/plugins.gen.ts +20 -0
  25. package/src/plugins/ui.gen.ts +10 -0
  26. package/src/routeTree.gen.ts +70 -0
  27. package/src/routes/_login/login/route.tsx +10 -1
  28. package/src/routes/_login/register/route.lazy.tsx +3 -2
  29. package/src/routes/_login/register/route.tsx +5 -0
  30. package/src/routes/_nav/inspiration/route.tsx +8 -0
  31. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  32. package/src/routes/_nav/notifications/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  37. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  38. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  39. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  40. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  41. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  43. package/src/routes/_nav/tags/index.tsx +8 -0
  44. package/src/routes/_nav/tags/route.tsx +8 -0
  45. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  46. package/src/routes/api/notifications/stream.ts +88 -0
  47. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  48. package/src/routes/invite/$token/route.tsx +16 -8
  49. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  50. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  51. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  52. package/src/server/notifications/event-bus.ts +16 -0
  53. package/src/server/notifications/notification-service.ts +86 -0
  54. package/src/server/resources.ts +10 -2
  55. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  56. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  57. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  58. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  59. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  60. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  61. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  62. package/src/server/serverFns/billing.ts +23 -2
  63. package/src/server/serverFns/notifications.ts +155 -0
  64. package/src/server/serverFns/story/story-comments.ts +42 -0
  65. package/src/server/serverFns/team.ts +573 -53
  66. package/src/start.ts +23 -1
  67. 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.17",
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.10.0",
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.12",
139
138
  "@allbluecn/kernel": "0.3.0",
140
- "@allbluecn/database": "0.4.12",
141
- "@allbluecn/plugins-core": "0.4.12",
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",
142
143
  "@allbluecn/prototype": "0.1.1",
143
- "@allbluecn/ui": "0.4.12",
144
- "@allbluecn/shared": "0.4.12",
145
- "@allbluecn/tiptap": "0.4.12"
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 notifications={notifications} />
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 notifications={notifications} />
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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
20
- import { Button } from "@/components/ui/button";
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 "@/components/ui/dropdown-menu";
29
- import { BellIcon } from "lucide-react";
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
- type Notification = {
32
- id: string;
33
- avatar: string;
34
- fallback: string;
35
- text: string;
36
- time: string;
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="Open notifications"
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
- <DropdownMenuLabel>Notifications</DropdownMenuLabel>
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
- {notifications.map(({ id, avatar, fallback, text, time }) => (
60
- <DropdownMenuItem key={id} className="flex items-start gap-3">
61
- <Avatar className="size-8">
62
- <AvatarImage src={avatar} alt="Avatar" />
63
- <AvatarFallback>{fallback}</AvatarFallback>
64
- </Avatar>
65
- <div className="flex flex-col">
66
- <span className="text-sm font-medium">{text}</span>
67
- <span className="text-xs text-muted-foreground">{time}</span>
68
- </div>
69
- </DropdownMenuItem>
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 className="justify-center text-sm text-muted-foreground hover:text-primary">
73
- View all notifications
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
+ }
@@ -77,7 +77,7 @@ export function UserMenu() {
77
77
 
78
78
  const handleSignOut = async () => {
79
79
  if (isLocalGuest) {
80
- await logoutRemoteFn();
80
+ await logoutRemoteFn({ data: {} });
81
81
  toast.success(t('sidebar.userMenu.logoutToGuest'));
82
82
  queryClient.resetQueries({ queryKey: ['session'] });
83
83
  queryClient.resetQueries({ queryKey: ['effective-plan'] });
@@ -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
+ }
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { useEffect, useState } from 'react'
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { toast } from 'sonner'
6
+ import { useNotifications } from '@/hooks/use-notifications'
7
+ import { getNotificationPreferencesFn, updateNotificationPreferencesFn } from '@/server/serverFns/notifications'
8
+ import type { CategoryPreferences, NotificationCategory, NotificationPreferences } from '@allbluecn/shared'
9
+
10
+ const CATEGORY_META: { key: NotificationCategory; labelKey: string; descKey: string }[] = [
11
+ { key: 'team', labelKey: 'preferences.categories.team', descKey: 'preferences.categories.teamDesc' },
12
+ { key: 'collaboration', labelKey: 'preferences.categories.collaboration', descKey: 'preferences.categories.collaborationDesc' },
13
+ { key: 'system', labelKey: 'preferences.categories.system', descKey: 'preferences.categories.systemDesc' },
14
+ { key: 'billing', labelKey: 'preferences.categories.billing', descKey: 'preferences.categories.billingDesc' },
15
+ { key: 'security', labelKey: 'preferences.categories.security', descKey: 'preferences.categories.securityDesc' },
16
+ ]
17
+
18
+ export function NotificationPreferences() {
19
+ const { t } = useTranslation('notifications')
20
+ const queryClient = useQueryClient()
21
+ const { setUnreadCount } = useNotifications()
22
+
23
+ const { data: prefsData, isLoading } = useQuery({
24
+ queryKey: ['notifications', 'preferences'],
25
+ queryFn: () => getNotificationPreferencesFn({ data: {} }),
26
+ })
27
+
28
+ const [categories, setCategories] = useState<CategoryPreferences | null>(null)
29
+ const [dndEnabled, setDndEnabled] = useState(false)
30
+ const [dndStart, setDndStart] = useState('22:00')
31
+ const [dndEnd, setDndEnd] = useState('08:00')
32
+
33
+ useEffect(() => {
34
+ if (prefsData) {
35
+ setCategories(prefsData.categories)
36
+ setDndEnabled(prefsData.dndEnabled)
37
+ setDndStart(prefsData.dndStart)
38
+ setDndEnd(prefsData.dndEnd)
39
+ }
40
+ }, [prefsData])
41
+
42
+ const save = async () => {
43
+ if (!categories) return
44
+ await updateNotificationPreferencesFn({
45
+ data: {
46
+ categories,
47
+ dndEnabled,
48
+ dndStart,
49
+ dndEnd,
50
+ },
51
+ })
52
+ queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] })
53
+ toast.success(t('preferences.saved'))
54
+ }
55
+
56
+ const toggleCategory = (key: NotificationCategory) => {
57
+ setCategories((prev) => (prev ? { ...prev, [key]: !prev[key] } : prev))
58
+ }
59
+
60
+ if (isLoading) {
61
+ return <div className="space-y-4">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-16 rounded-lg border bg-muted/30" />)}</div>
62
+ }
63
+
64
+ return (
65
+ <div className="space-y-6">
66
+ <div className="space-y-3">
67
+ {CATEGORY_META.map((meta) => (
68
+ <div key={meta.key} className="flex items-center justify-between rounded-lg border p-4">
69
+ <div className="flex flex-col gap-0.5">
70
+ <span className="text-sm font-medium">{t(meta.labelKey)}</span>
71
+ <span className="text-xs text-muted-foreground">{t(meta.descKey)}</span>
72
+ </div>
73
+ <button
74
+ type="button"
75
+ role="switch"
76
+ aria-checked={categories?.[meta.key] ?? false}
77
+ className={
78
+ 'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ' +
79
+ ((categories?.[meta.key] ?? false) ? 'bg-primary' : 'bg-input')
80
+ }
81
+ onClick={() => toggleCategory(meta.key)}
82
+ >
83
+ <span
84
+ className={
85
+ 'pointer-events-none block size-4 translate-x-0.5 rounded-full bg-background shadow ring-0 transition-transform ' +
86
+ ((categories?.[meta.key] ?? false) ? 'translate-x-5' : '')
87
+ }
88
+ />
89
+ </button>
90
+ </div>
91
+ ))}
92
+ </div>
93
+
94
+ <div className="rounded-lg border p-4 space-y-4">
95
+ <div className="flex flex-col gap-0.5">
96
+ <span className="text-sm font-medium">{t('preferences.dnd.title')}</span>
97
+ <span className="text-xs text-muted-foreground">{t('preferences.dnd.desc')}</span>
98
+ </div>
99
+
100
+ <div className="flex items-center justify-between">
101
+ <span className="text-sm">{t('preferences.dnd.enabled')}</span>
102
+ <button
103
+ type="button"
104
+ role="switch"
105
+ aria-checked={dndEnabled}
106
+ className={
107
+ 'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ' +
108
+ (dndEnabled ? 'bg-primary' : 'bg-input')
109
+ }
110
+ onClick={() => setDndEnabled(!dndEnabled)}
111
+ >
112
+ <span
113
+ className={
114
+ 'pointer-events-none block size-4 translate-x-0.5 rounded-full bg-background shadow ring-0 transition-transform ' +
115
+ (dndEnabled ? 'translate-x-5' : '')
116
+ }
117
+ />
118
+ </button>
119
+ </div>
120
+
121
+ {dndEnabled && (
122
+ <div className="flex gap-4">
123
+ <div className="flex-1 space-y-1">
124
+ <label className="text-xs text-muted-foreground">{t('preferences.dnd.start')}</label>
125
+ <input
126
+ type="time"
127
+ value={dndStart}
128
+ onChange={(e) => setDndStart(e.target.value)}
129
+ className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
130
+ />
131
+ </div>
132
+ <div className="flex-1 space-y-1">
133
+ <label className="text-xs text-muted-foreground">{t('preferences.dnd.end')}</label>
134
+ <input
135
+ type="time"
136
+ value={dndEnd}
137
+ onChange={(e) => setDndEnd(e.target.value)}
138
+ className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
139
+ />
140
+ </div>
141
+ </div>
142
+ )}
143
+ </div>
144
+
145
+ <div className="flex justify-end">
146
+ <button
147
+ type="button"
148
+ onClick={save}
149
+ className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90"
150
+ >
151
+ {t('actions.save', { defaultValue: '保存' })}
152
+ </button>
153
+ </div>
154
+ </div>
155
+ )
156
+ }