@allbluecn/web-app 0.4.22 → 0.5.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 (70) hide show
  1. package/package.json +8 -8
  2. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  3. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  4. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  5. package/src/components/notifications/settings-notifications.tsx +198 -0
  6. package/src/components/notifications/team-notifications.tsx +343 -0
  7. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  8. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  9. package/src/components/shared/wave-physics-loader.tsx +155 -0
  10. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  11. package/src/components/story/constants.ts +114 -51
  12. package/src/components/story/detail/story-detail-body.tsx +13 -3
  13. package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
  14. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  15. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
  16. package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
  17. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  18. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  19. package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
  20. package/src/components/story/peek-panel.tsx +8 -1
  21. package/src/components/story/relations/story-ref-row.tsx +319 -0
  22. package/src/components/story/story-list-view.tsx +3 -1
  23. package/src/components/story/story-panel-header.tsx +4 -1
  24. package/src/components/story/types.ts +17 -18
  25. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  26. package/src/components/story/views/story-list/index.tsx +16 -5
  27. package/src/components/story/views/story-list/row.tsx +5 -4
  28. package/src/components/ui/separator.tsx +1 -1
  29. package/src/hooks/use-notifications.ts +4 -1
  30. package/src/lib/__tests__/datetime.test.ts +16 -1
  31. package/src/lib/changelog/sdk-versions.ts +19 -19
  32. package/src/lib/datetime.ts +1 -0
  33. package/src/routeTree.gen.ts +23 -0
  34. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  35. package/src/routes/_nav/notifications/route.tsx +30 -0
  36. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  37. package/src/routes/api/notifications/stream.ts +4 -0
  38. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  39. package/src/server/notifications/notification-service.ts +137 -27
  40. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  41. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  42. package/src/server/serverFns/locale.ts +15 -3
  43. package/src/server/serverFns/notifications.ts +18 -4
  44. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
  45. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  46. package/src/server/serverFns/story/index.ts +1 -2
  47. package/src/server/serverFns/story/story-dependencies.ts +107 -163
  48. package/src/server/serverFns/story/story-groups.ts +23 -3
  49. package/src/server/serverFns/story/story-transfer.ts +5 -3
  50. package/src/server/serverFns/team.ts +51 -2
  51. package/src/components/notifications/notification-list.tsx +0 -232
  52. package/src/components/notifications/notification-preferences.tsx +0 -156
  53. package/src/components/story/relation-badge.tsx +0 -57
  54. package/src/components/story/relations/dependency-panel.tsx +0 -190
  55. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  56. package/src/components/story/relations/tab.tsx +0 -212
  57. package/src/config/sidebar-notifications.ts +0 -50
  58. package/src/plugins/.gen-manifest.json +0 -13
  59. package/src/plugins/modules.gen.ts +0 -20
  60. package/src/plugins/plugins.gen.ts +0 -20
  61. package/src/plugins/ui.gen.ts +0 -8
  62. package/src/routes/_nav/inspiration/route.tsx +0 -8
  63. package/src/routes/_nav/prd/$id/route.tsx +0 -9
  64. package/src/routes/_nav/prd/index.tsx +0 -9
  65. package/src/routes/_nav/prd/route.tsx +0 -8
  66. package/src/routes/_nav/tags/$id/route.tsx +0 -8
  67. package/src/routes/_nav/tags/index.tsx +0 -8
  68. package/src/routes/_nav/tags/route.tsx +0 -8
  69. package/src/routes/_nav/tags/settings/route.tsx +0 -9
  70. package/src/routes/share/prd/$prdId/route.tsx +0 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.4.22",
3
+ "version": "0.5.0",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -127,14 +127,14 @@
127
127
  "three": "^0.180.0",
128
128
  "tw-animate-css": "^1.4.0",
129
129
  "zod": "^4.4.3",
130
- "@allbluecn/database": "0.4.14",
130
+ "@allbluecn/database": "0.4.15",
131
131
  "@allbluecn/kernel": "0.3.1",
132
- "@allbluecn/shared": "0.4.15",
133
- "@allbluecn/ai-gateway": "0.4.15",
134
- "@allbluecn/tiptap": "0.4.15",
135
- "@allbluecn/ui": "0.4.15",
136
- "@allbluecn/plugins-core": "0.4.14",
137
- "@allbluecn/prototype": "0.1.1"
132
+ "@allbluecn/plugins-core": "0.4.15",
133
+ "@allbluecn/prototype": "0.1.1",
134
+ "@allbluecn/ai-gateway": "0.4.16",
135
+ "@allbluecn/shared": "0.4.16",
136
+ "@allbluecn/tiptap": "0.4.16",
137
+ "@allbluecn/ui": "0.4.16"
138
138
  },
139
139
  "devDependencies": {
140
140
  "@babel/parser": "^8.0.4",
@@ -27,7 +27,7 @@ import {
27
27
  import { cn } from "@/lib/utils";
28
28
  import { m } from "motion/react";
29
29
  import DashboardNavigation from "@/components/layout/sidebar-03/nav-main";
30
- import { NotificationsPopover } from "@/components/layout/sidebar-03/nav-notifications";
30
+ import { NotificationsBell } from "@/components/layout/sidebar-03/nav-notifications";
31
31
  import { NavAnyHub } from "@/components/layout/sidebar-03/nav-anyhub";
32
32
  import { NavKnowledge } from "@/components/layout/sidebar-03/nav-knowledge";
33
33
  import { NavWorkspace } from "@/components/layout/sidebar-03/nav-workspace";
@@ -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
+ <NotificationsBell />
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
+ <NotificationsBell />
90
89
  <AnimatedThemeToggler />
91
90
 
92
91
  <SidebarTrigger />
@@ -15,64 +15,147 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
-
19
- import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
20
- import { Button } from "@/components/ui/button";
18
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
19
+ import { Link, useNavigate } from '@tanstack/react-router'
20
+ import { useTranslation } from 'react-i18next'
21
+ import { Bell, CheckCheck } from 'lucide-react'
22
+ import { Button } from '@/components/ui/button'
23
+ import {
24
+ Empty,
25
+ EmptyHeader,
26
+ EmptyMedia,
27
+ EmptyTitle,
28
+ } from '@/components/ui/empty'
29
+ import {
30
+ Popover,
31
+ PopoverContent,
32
+ PopoverTrigger,
33
+ } from '@/components/ui/popover'
34
+ import { Separator } from '@/components/ui/separator'
35
+ import { cn } from '@/lib/utils'
36
+ import { CATEGORY_ICONS, formatRelativeTime } from '@/components/notifications/team-notifications'
21
37
  import {
22
- DropdownMenu,
23
- DropdownMenuContent,
24
- DropdownMenuItem,
25
- DropdownMenuLabel,
26
- DropdownMenuSeparator,
27
- DropdownMenuTrigger,
28
- } from "@/components/ui/dropdown-menu";
29
- import { BellIcon } from "lucide-react";
38
+ useNotifications,
39
+ NOTIFICATION_POPOVER_KEY,
40
+ NOTIFICATION_ROOT_KEY,
41
+ } from '@/hooks/use-notifications'
42
+ import { listNotificationsFn, markAllNotificationsReadFn, markNotificationReadFn } from '@/server/serverFns/notifications'
43
+ import type { NotificationItem } from '@allbluecn/shared'
44
+
45
+ const POPOVER_ITEM_LIMIT = 8
46
+
47
+ export function NotificationsBell() {
48
+ const { t } = useTranslation('notifications')
49
+ const navigate = useNavigate()
50
+ const queryClient = useQueryClient()
51
+ const { unreadCount } = useNotifications()
30
52
 
31
- type Notification = {
32
- id: string;
33
- avatar: string;
34
- fallback: string;
35
- text: string;
36
- time: string;
37
- };
53
+ // 与详情页 useInfiniteQuery 的分页缓存形态不同,必须使用独立 queryKey
54
+ const { data } = useQuery({
55
+ queryKey: NOTIFICATION_POPOVER_KEY,
56
+ queryFn: () => listNotificationsFn({ data: {} }),
57
+ })
58
+ const items = (data?.items ?? []).slice(0, POPOVER_ITEM_LIMIT)
59
+
60
+ const invalidateNotificationData = () => {
61
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_ROOT_KEY })
62
+ }
63
+
64
+ const handleOpenItem = (item: NotificationItem) => {
65
+ if (!item.readAt) {
66
+ void markNotificationReadFn({ data: { id: item.id } }).then(invalidateNotificationData)
67
+ }
68
+ if (item.link) navigate({ to: item.link as never })
69
+ }
70
+
71
+ const handleMarkAllRead = async () => {
72
+ await markAllNotificationsReadFn({ data: {} })
73
+ invalidateNotificationData()
74
+ }
38
75
 
39
- export function NotificationsPopover({
40
- notifications,
41
- }: {
42
- notifications: Notification[];
43
- }) {
44
76
  return (
45
- <DropdownMenu>
46
- <DropdownMenuTrigger asChild>
77
+ <Popover>
78
+ <PopoverTrigger asChild>
47
79
  <Button
48
- variant="ghost"
80
+ aria-label={t('bell.label')}
81
+ className="relative rounded-full"
49
82
  size="icon"
50
- className="rounded-full"
51
- aria-label="Open notifications"
83
+ variant="ghost"
52
84
  >
53
- <BellIcon className="size-5" />
85
+ <Bell className="size-5" />
86
+ {unreadCount > 0 && (
87
+ <span className="absolute -top-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
88
+ {unreadCount > 9 ? '9+' : unreadCount}
89
+ </span>
90
+ )}
54
91
  </Button>
55
- </DropdownMenuTrigger>
56
- <DropdownMenuContent side="right" className="w-80 my-6">
57
- <DropdownMenuLabel>Notifications</DropdownMenuLabel>
58
- <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
- ))}
71
- <DropdownMenuSeparator />
72
- <DropdownMenuItem className="justify-center text-sm text-muted-foreground hover:text-primary">
73
- View all notifications
74
- </DropdownMenuItem>
75
- </DropdownMenuContent>
76
- </DropdownMenu>
77
- );
92
+ </PopoverTrigger>
93
+ <PopoverContent align="start" className="w-80 p-0">
94
+ <div className="flex h-6 mt-3 items-center justify-between px-4">
95
+ <span className="flex h-full items-center text-sm font-semibold">{t('bell.recentTitle')}</span>
96
+ {unreadCount > 0 && (
97
+ <Button onClick={handleMarkAllRead} size="sm" type="button" variant="ghost">
98
+ <CheckCheck className="size-4" />
99
+ {t('actions.markAllRead')}
100
+ </Button>
101
+ )}
102
+ </div>
103
+ {/* <Separator /> */}
104
+ {items.length === 0 ? (
105
+ <Empty>
106
+ <EmptyHeader>
107
+ <EmptyMedia variant="icon">
108
+ <Bell className="size-6" />
109
+ </EmptyMedia>
110
+ <EmptyTitle>{t('empty')}</EmptyTitle>
111
+ </EmptyHeader>
112
+ </Empty>
113
+ ) : (
114
+ <div className="max-h-96 overflow-y-auto border-t">
115
+ {items.map((item) => {
116
+ const Icon = CATEGORY_ICONS[item.category]
117
+ const isUnread = !item.readAt
118
+ return (
119
+ <div
120
+ key={item.id}
121
+ className={cn(
122
+ 'flex cursor-pointer items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50',
123
+ isUnread && 'bg-primary/5'
124
+ )}
125
+ onClick={() => handleOpenItem(item)}
126
+ >
127
+ <div
128
+ className={cn(
129
+ 'flex size-8 shrink-0 items-center justify-center rounded-full',
130
+ isUnread ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
131
+ )}
132
+ >
133
+ <Icon className="size-4" />
134
+ </div>
135
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
136
+ <span className={cn('truncate text-sm', isUnread ? 'font-medium' : 'text-muted-foreground')}>
137
+ {item.title}
138
+ </span>
139
+ <span className="text-muted-foreground text-xs">
140
+ {formatRelativeTime(item.createdAt, t)}
141
+ </span>
142
+ </div>
143
+ {isUnread && <span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />}
144
+ </div>
145
+ )
146
+ })}
147
+ </div>
148
+ )}
149
+ {/* <Separator /> */}
150
+ <div className="p-1 border-t">
151
+ <Link
152
+ className="flex h-10 w-full items-center justify-center rounded-md text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-primary"
153
+ to="/notifications"
154
+ >
155
+ {t('actions.viewAll')}
156
+ </Link>
157
+ </div>
158
+ </PopoverContent>
159
+ </Popover>
160
+ )
78
161
  }
@@ -46,7 +46,7 @@ import {
46
46
  import { signOut, useSession } from "@/lib/auth/auth-client";
47
47
  import { useRouter, useMatches } from "@tanstack/react-router";
48
48
  import { useTranslation } from "react-i18next";
49
- import { useQueryClient } from "@tanstack/react-query";
49
+ import { getQueryClient } from "@allbluecn/shared/lib/query-client-seam";
50
50
  import { UserAvatar } from "@allbluecn/ui";
51
51
  import type { AvatarConfig, ShapeType } from "@allbluecn/shared";
52
52
  import { SparklesText } from "@/components/magicui/sparkles-text";
@@ -55,7 +55,9 @@ export function UserMenu() {
55
55
  const { t } = useTranslation('common');
56
56
  const { isMobile } = useSidebar();
57
57
  const { data: clientSession } = useSession();
58
- const queryClient = useQueryClient();
58
+ // seam 单例替代 React Context 读取:SSR 下 npm 包内 react-query 可能与宿主
59
+ // 解析到不同物理实例,Context 分裂会抛 "No QueryClient set"
60
+ const queryClient = getQueryClient();
59
61
  const router = useRouter();
60
62
  const matches = useMatches();
61
63
 
@@ -0,0 +1,198 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Adapted from @hextaui/settings-notifications (https://www.hextaui.com)
3
+ import { useEffect, useState } from 'react'
4
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { toast } from 'sonner'
7
+ import { Bell } from 'lucide-react'
8
+ import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
9
+ import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from '@/components/ui/select'
17
+ import { Separator } from '@/components/ui/separator'
18
+ import { Switch } from '@/components/ui/switch'
19
+ import { useDebouncedCallback } from '@/lib/pacer'
20
+ import { getNotificationPreferencesFn, updateNotificationPreferencesFn } from '@/server/serverFns/notifications'
21
+ import type {
22
+ CategoryFrequencies,
23
+ NotificationCategory,
24
+ NotificationFrequency,
25
+ NotificationPreferences,
26
+ } from '@allbluecn/shared'
27
+ import { NOTIFICATION_FREQUENCIES } from '@allbluecn/shared'
28
+
29
+ const FREQUENCY_OPTIONS = NOTIFICATION_FREQUENCIES as readonly NotificationFrequency[]
30
+
31
+ const FREQUENCY_LABEL_KEYS: Record<NotificationFrequency, string> = {
32
+ realtime: 'preferences.frequency.realtime',
33
+ 'digest-daily': 'preferences.frequency.daily',
34
+ 'digest-weekly': 'preferences.frequency.weekly',
35
+ off: 'preferences.frequency.off',
36
+ }
37
+
38
+ const CATEGORY_META: { key: NotificationCategory; labelKey: string; descKey: string }[] = [
39
+ { key: 'team', labelKey: 'preferences.categories.team', descKey: 'preferences.categories.teamDesc' },
40
+ { key: 'collaboration', labelKey: 'preferences.categories.collaboration', descKey: 'preferences.categories.collaborationDesc' },
41
+ { key: 'system', labelKey: 'preferences.categories.system', descKey: 'preferences.categories.systemDesc' },
42
+ { key: 'billing', labelKey: 'preferences.categories.billing', descKey: 'preferences.categories.billingDesc' },
43
+ { key: 'security', labelKey: 'preferences.categories.security', descKey: 'preferences.categories.securityDesc' },
44
+ ]
45
+
46
+ const PREFERENCES_SAVE_DEBOUNCE_MS = 500
47
+
48
+ export function SettingsNotifications() {
49
+ const { t } = useTranslation('notifications')
50
+ const queryClient = useQueryClient()
51
+
52
+ const { data: prefsData, isLoading } = useQuery({
53
+ queryKey: ['notifications', 'preferences'],
54
+ queryFn: () => getNotificationPreferencesFn({ data: {} }),
55
+ })
56
+
57
+ const [prefs, setPrefs] = useState<NotificationPreferences | null>(null)
58
+
59
+ useEffect(() => {
60
+ if (prefsData) setPrefs(prefsData)
61
+ }, [prefsData])
62
+
63
+ const debouncedSave = useDebouncedCallback(async (next: NotificationPreferences) => {
64
+ try {
65
+ await updateNotificationPreferencesFn({ data: next })
66
+ queryClient.setQueryData(['notifications', 'preferences'], next)
67
+ } catch {
68
+ toast.error(t('preferences.saveFailed'))
69
+ queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] })
70
+ }
71
+ }, { wait: PREFERENCES_SAVE_DEBOUNCE_MS })
72
+
73
+ const patch = (partial: Partial<NotificationPreferences>) => {
74
+ if (!prefs) return
75
+ const next = { ...prefs, ...partial }
76
+ setPrefs(next)
77
+ debouncedSave(next)
78
+ }
79
+
80
+ const setCategoryFrequency = (key: NotificationCategory, frequency: NotificationFrequency) => {
81
+ if (!prefs) return
82
+ patch({ categories: { ...prefs.categories, [key]: frequency } as CategoryFrequencies })
83
+ }
84
+
85
+ if (isLoading || !prefs) {
86
+ return (
87
+ <div className="flex flex-col gap-4">
88
+ {Array.from({ length: 4 }).map((_, i) => (
89
+ <div key={i} className="h-16 rounded-lg border bg-muted/30" />
90
+ ))}
91
+ </div>
92
+ )
93
+ }
94
+
95
+ return (
96
+ <div className="flex flex-col gap-6">
97
+ <div className="flex flex-col gap-4">
98
+ {CATEGORY_META.map((meta) => {
99
+ const frequency = prefs.categories[meta.key]
100
+ return (
101
+ <div className="flex flex-col gap-4 rounded-lg border p-4" key={meta.key}>
102
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
103
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
104
+ <h4 className="font-medium text-sm">{t(meta.labelKey)}</h4>
105
+ <p className="text-muted-foreground text-xs">{t(meta.descKey)}</p>
106
+ </div>
107
+ <Select
108
+ onValueChange={(value) => setCategoryFrequency(meta.key, value as NotificationFrequency)}
109
+ value={frequency}
110
+ >
111
+ <SelectTrigger className="w-full shrink-0 sm:w-[140px]">
112
+ <SelectValue />
113
+ </SelectTrigger>
114
+ <SelectContent>
115
+ {FREQUENCY_OPTIONS.map((option) => (
116
+ <SelectItem key={option} value={option}>
117
+ {t(FREQUENCY_LABEL_KEYS[option])}
118
+ </SelectItem>
119
+ ))}
120
+ </SelectContent>
121
+ </Select>
122
+ </div>
123
+
124
+ <Separator />
125
+
126
+ <Field>
127
+ <div className="flex items-center justify-between">
128
+ <div className="flex items-center gap-2">
129
+ <Bell className="size-4 text-muted-foreground" />
130
+ <FieldLabel htmlFor={`${meta.key}-enable`}>
131
+ {t('preferences.enable')}
132
+ </FieldLabel>
133
+ </div>
134
+ <Switch
135
+ checked={frequency !== 'off'}
136
+ id={`${meta.key}-enable`}
137
+ onCheckedChange={(checked) => setCategoryFrequency(meta.key, checked ? 'realtime' : 'off')}
138
+ />
139
+ </div>
140
+ </Field>
141
+ </div>
142
+ )
143
+ })}
144
+ </div>
145
+
146
+ <Separator />
147
+
148
+ <div className="flex flex-col gap-4">
149
+ <h3 className="font-semibold text-base">{t('preferences.dnd.title')}</h3>
150
+ <Field>
151
+ <div className="flex items-center justify-between">
152
+ <div className="flex flex-col gap-1">
153
+ <FieldLabel htmlFor="dnd-enabled">{t('preferences.dnd.enabled')}</FieldLabel>
154
+ <FieldDescription>{t('preferences.dnd.desc')}</FieldDescription>
155
+ </div>
156
+ <Switch
157
+ checked={prefs.dndEnabled}
158
+ id="dnd-enabled"
159
+ onCheckedChange={(checked) => patch({ dndEnabled: checked })}
160
+ />
161
+ </div>
162
+ </Field>
163
+
164
+ {prefs.dndEnabled && (
165
+ <div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
166
+ <Field>
167
+ <FieldLabel htmlFor="dnd-start">{t('preferences.dnd.start')}</FieldLabel>
168
+ <FieldContent>
169
+ <InputGroup>
170
+ <InputGroupInput
171
+ id="dnd-start"
172
+ onChange={(e) => patch({ dndStart: e.target.value })}
173
+ type="time"
174
+ value={prefs.dndStart}
175
+ />
176
+ </InputGroup>
177
+ </FieldContent>
178
+ </Field>
179
+
180
+ <Field>
181
+ <FieldLabel htmlFor="dnd-end">{t('preferences.dnd.end')}</FieldLabel>
182
+ <FieldContent>
183
+ <InputGroup>
184
+ <InputGroupInput
185
+ id="dnd-end"
186
+ onChange={(e) => patch({ dndEnd: e.target.value })}
187
+ type="time"
188
+ value={prefs.dndEnd}
189
+ />
190
+ </InputGroup>
191
+ </FieldContent>
192
+ </Field>
193
+ </div>
194
+ )}
195
+ </div>
196
+ </div>
197
+ )
198
+ }