@allbluecn/web-app 0.4.21 → 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.
- package/package.json +25 -33
- package/src/components/command/command-palette.tsx +1 -1
- package/src/components/knowledge/knowledge-toolbar.tsx +29 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
- package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
- package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
- package/src/components/notifications/settings-notifications.tsx +198 -0
- package/src/components/notifications/team-notifications.tsx +343 -0
- package/src/components/settings/personal-team/team/team-client.tsx +51 -12
- package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
- package/src/components/shared/wave-physics-loader.tsx +155 -0
- package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
- package/src/components/story/constants.ts +114 -51
- package/src/components/story/detail/story-detail-body.tsx +13 -3
- package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
- package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
- package/src/components/story/display/use-paged-story-groups.ts +370 -28
- package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
- package/src/components/story/peek-panel.tsx +8 -1
- package/src/components/story/relations/story-ref-row.tsx +319 -0
- package/src/components/story/story-list-view.tsx +3 -1
- package/src/components/story/story-panel-header.tsx +4 -1
- package/src/components/story/types.ts +17 -18
- package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
- package/src/components/story/views/story-list/index.tsx +16 -5
- package/src/components/story/views/story-list/row.tsx +5 -4
- package/src/components/ui/separator.tsx +1 -1
- package/src/hooks/use-notifications.ts +4 -1
- package/src/lib/__tests__/datetime.test.ts +16 -1
- package/src/lib/__tests__/rag.test.ts +37 -2
- package/src/lib/changelog/sdk-versions.ts +30 -30
- package/src/lib/commands/registry.ts +1 -1
- package/src/lib/commands/types.ts +3 -0
- package/src/lib/datetime.ts +1 -0
- package/src/lib/rag.ts +9 -2
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -0
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/notifications/stream.ts +4 -0
- package/src/server/ai-ports.ts +1 -1
- package/src/server/knowledge-public/github-sync.ts +1 -1
- package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
- package/src/server/notifications/notification-service.ts +137 -27
- package/src/server/serverFns/__tests__/knowledge-index.test.ts +178 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
- package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
- package/src/server/serverFns/knowledge-index-utils.ts +104 -0
- package/src/server/serverFns/knowledge-index.ts +6 -67
- package/src/server/serverFns/knowledge.ts +8 -2
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +1 -2
- package/src/server/serverFns/story/story-dependencies.ts +107 -163
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/team.ts +51 -2
- package/src/components/knowledge/knowledge-settings-panel.tsx +0 -148
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- package/src/components/story/relation-badge.tsx +0 -57
- package/src/components/story/relations/dependency-panel.tsx +0 -190
- package/src/components/story/relations/hierarchy-panel.tsx +0 -180
- package/src/components/story/relations/tab.tsx +0 -212
- package/src/config/sidebar-notifications.ts +0 -50
- package/src/lib/openai-client.ts +0 -62
- package/src/plugins/.gen-manifest.json +0 -13
- package/src/plugins/modules.gen.ts +0 -20
- package/src/plugins/plugins.gen.ts +0 -20
- package/src/plugins/ui.gen.ts +0 -8
- package/src/routes/_nav/inspiration/route.tsx +0 -8
- package/src/routes/_nav/prd/$id/route.tsx +0 -9
- package/src/routes/_nav/prd/index.tsx +0 -9
- package/src/routes/_nav/prd/route.tsx +0 -8
- package/src/routes/_nav/tags/$id/route.tsx +0 -8
- package/src/routes/_nav/tags/index.tsx +0 -8
- package/src/routes/_nav/tags/route.tsx +0 -8
- package/src/routes/_nav/tags/settings/route.tsx +0 -9
- 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.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "AGPL-3.0-or-later",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -34,31 +34,24 @@
|
|
|
34
34
|
"@radix-ui/react-popover": "^1.1.15",
|
|
35
35
|
"@radix-ui/react-slot": "^1.2.4",
|
|
36
36
|
"@radix-ui/react-visually-hidden": "^1.2.11",
|
|
37
|
-
"@tanstack/ai": "^0.
|
|
38
|
-
"@tanstack/ai-
|
|
39
|
-
"@tanstack/ai-
|
|
40
|
-
"@tanstack/
|
|
41
|
-
"@tanstack/ai-groq": "^0.5.3",
|
|
42
|
-
"@tanstack/ai-ollama": "^0.8.16",
|
|
43
|
-
"@tanstack/ai-openai": "^0.17.1",
|
|
44
|
-
"@tanstack/ai-openrouter": "^0.15.10",
|
|
45
|
-
"@tanstack/ai-persistence": "^0.1.0",
|
|
46
|
-
"@tanstack/ai-react": "^0.18.1",
|
|
47
|
-
"@tanstack/db": "0.6.17",
|
|
37
|
+
"@tanstack/ai": "^0.52.0",
|
|
38
|
+
"@tanstack/ai-persistence": "^0.5.4",
|
|
39
|
+
"@tanstack/ai-react": "^0.22.4",
|
|
40
|
+
"@tanstack/db": "0.8.6",
|
|
48
41
|
"@tanstack/hotkeys": "^0.8.0",
|
|
49
|
-
"@tanstack/pacer": "^0.
|
|
50
|
-
"@tanstack/query-db-collection": "1.2.
|
|
51
|
-
"@tanstack/react-db": "0.
|
|
52
|
-
"@tanstack/react-form": "2.0.0-alpha.
|
|
42
|
+
"@tanstack/pacer": "^0.22.0",
|
|
43
|
+
"@tanstack/query-db-collection": "1.2.11",
|
|
44
|
+
"@tanstack/react-db": "0.3.6",
|
|
45
|
+
"@tanstack/react-form": "2.0.0-alpha.2",
|
|
53
46
|
"@tanstack/react-hotkeys": "^0.10.0",
|
|
54
|
-
"@tanstack/react-pacer": "^0.
|
|
55
|
-
"@tanstack/react-query": "^5.
|
|
56
|
-
"@tanstack/react-router": "1.170.
|
|
57
|
-
"@tanstack/react-router-ssr-query": "^1.167.
|
|
58
|
-
"@tanstack/react-start": "^1.168.
|
|
59
|
-
"@tanstack/react-store": "^0.11.
|
|
60
|
-
"@tanstack/react-table": "^9.
|
|
61
|
-
"@tanstack/react-virtual": "^3.14.
|
|
47
|
+
"@tanstack/react-pacer": "^0.23.0",
|
|
48
|
+
"@tanstack/react-query": "^5.102.8",
|
|
49
|
+
"@tanstack/react-router": "1.170.32",
|
|
50
|
+
"@tanstack/react-router-ssr-query": "^1.167.2",
|
|
51
|
+
"@tanstack/react-start": "^1.168.49",
|
|
52
|
+
"@tanstack/react-store": "^0.11.1",
|
|
53
|
+
"@tanstack/react-table": "^9.2.3",
|
|
54
|
+
"@tanstack/react-virtual": "^3.14.10",
|
|
62
55
|
"@tanstack/store": "^0.11.1",
|
|
63
56
|
"@tiptap/core": "^3.27.4",
|
|
64
57
|
"@tiptap/extension-code-block-lowlight": "^3.27.4",
|
|
@@ -115,7 +108,6 @@
|
|
|
115
108
|
"next-themes": "^0.4.6",
|
|
116
109
|
"nitro": "3.0.260603-beta",
|
|
117
110
|
"ogl": "^1.0.11",
|
|
118
|
-
"openai": "^6.39.1",
|
|
119
111
|
"prisma": "^7.8.0",
|
|
120
112
|
"radix-ui": "^1.4.3",
|
|
121
113
|
"react": "19.2.4",
|
|
@@ -135,14 +127,14 @@
|
|
|
135
127
|
"three": "^0.180.0",
|
|
136
128
|
"tw-animate-css": "^1.4.0",
|
|
137
129
|
"zod": "^4.4.3",
|
|
138
|
-
"@allbluecn/
|
|
139
|
-
"@allbluecn/kernel": "0.3.
|
|
140
|
-
"@allbluecn/
|
|
141
|
-
"@allbluecn/plugins-core": "0.4.13",
|
|
130
|
+
"@allbluecn/database": "0.4.15",
|
|
131
|
+
"@allbluecn/kernel": "0.3.1",
|
|
132
|
+
"@allbluecn/plugins-core": "0.4.15",
|
|
142
133
|
"@allbluecn/prototype": "0.1.1",
|
|
143
|
-
"@allbluecn/
|
|
144
|
-
"@allbluecn/
|
|
145
|
-
"@allbluecn/
|
|
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"
|
|
146
138
|
},
|
|
147
139
|
"devDependencies": {
|
|
148
140
|
"@babel/parser": "^8.0.4",
|
|
@@ -152,7 +144,7 @@
|
|
|
152
144
|
"@playwright/mcp": "^0.0.75",
|
|
153
145
|
"@playwright/test": "^1.60.0",
|
|
154
146
|
"@tailwindcss/vite": "^4.3.2",
|
|
155
|
-
"@tanstack/react-query-devtools": "5.
|
|
147
|
+
"@tanstack/react-query-devtools": "5.102.8",
|
|
156
148
|
"@tanstack/react-router-devtools": "1.167.1",
|
|
157
149
|
"@testing-library/jest-dom": "^6.9.1",
|
|
158
150
|
"@testing-library/react": "^16.3.2",
|
|
@@ -195,7 +195,7 @@ function CommandPaletteList({
|
|
|
195
195
|
key={cmd.id}
|
|
196
196
|
index={idx}
|
|
197
197
|
onSelect={() => onRun(cmd)}
|
|
198
|
-
shortcut={cmd.shortcut ? resolveShortcut(cmd.shortcut) : undefined}
|
|
198
|
+
shortcut={cmd.shortcut ?? cmd.chord ? resolveShortcut(cmd.shortcut ?? cmd.chord!) : undefined}
|
|
199
199
|
icon={Icon && <Icon className="size-4 shrink-0" />}
|
|
200
200
|
>
|
|
201
201
|
<span className="truncate">{cmd.title}</span>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { useState, useEffect, useRef } from 'react'
|
|
19
19
|
import { useNavigate } from '@tanstack/react-router'
|
|
20
|
-
import { Settings, Globe, Lock, Trash2, Columns2, Minimize2, ArrowLeftRight, Maximize2, FileText, Grid3X3, Network, Table, PanelLeftOpen, PanelLeftClose, Plus, Check, ListIndentIncrease, ListIndentDecrease, Pencil } from 'lucide-react'
|
|
20
|
+
import { Settings, Globe, Lock, Trash2, RefreshCw, Columns2, Minimize2, ArrowLeftRight, Maximize2, FileText, Grid3X3, Network, Table, PanelLeftOpen, PanelLeftClose, Plus, Check, ListIndentIncrease, ListIndentDecrease, Pencil } from 'lucide-react'
|
|
21
21
|
import { Input } from '@/components/ui/input'
|
|
22
22
|
import { Badge } from '@/components/ui/badge'
|
|
23
23
|
import {
|
|
@@ -67,6 +67,8 @@ import { KnowledgeTagInput } from './knowledge-tag-input'
|
|
|
67
67
|
import { useEditorWidth } from './editor/editor-width-context'
|
|
68
68
|
import type { EditorWidth } from './editor/editor-width-context'
|
|
69
69
|
import { deleteKnowledgeFn, updateKnowledgeFn } from '@/server/serverFns/knowledge'
|
|
70
|
+
import { reindexKnowledgeBaseFn } from '@/server/serverFns/knowledge-index'
|
|
71
|
+
import { toast } from '@/components/ui/sonner'
|
|
70
72
|
import { KnowledgeFormDialog } from '@/components/knowledge/dialog/knowledge-form-dialog'
|
|
71
73
|
import { cn } from '@/lib/utils'
|
|
72
74
|
import { useTranslation } from 'react-i18next'
|
|
@@ -109,6 +111,7 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
|
|
|
109
111
|
const [deleteConfirm, setDeleteConfirm] = useState('')
|
|
110
112
|
const [editOpen, setEditOpen] = useState(false)
|
|
111
113
|
const [deleting, setDeleting] = useState(false)
|
|
114
|
+
const [reindexing, setReindexing] = useState(false)
|
|
112
115
|
const { effectiveWidth, setWidth } = useEditorWidth()
|
|
113
116
|
const [createPopover, setCreatePopover] = useState(false)
|
|
114
117
|
const [createType, setCreateType] = useState('doc')
|
|
@@ -166,6 +169,21 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
|
|
|
166
169
|
setDeleting(false)
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
const handleReindex = async () => {
|
|
173
|
+
setReindexing(true)
|
|
174
|
+
try {
|
|
175
|
+
const { indexed, documentCount } = await reindexKnowledgeBaseFn({ data: { kbId: kb.id } })
|
|
176
|
+
if (documentCount === 0) {
|
|
177
|
+
toast.success(t('toolbar.noDocs'))
|
|
178
|
+
} else {
|
|
179
|
+
toast.success(t('toolbar.reindexDone', { indexed, documentCount }))
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
toast.error(`${t('toolbar.reindexFailed')}:${e instanceof Error ? e.message : String(e)}`)
|
|
183
|
+
}
|
|
184
|
+
setReindexing(false)
|
|
185
|
+
}
|
|
186
|
+
|
|
169
187
|
return (
|
|
170
188
|
<div className="flex items-center gap-4 px-4 py-2 border-b bg-background shrink-0">
|
|
171
189
|
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={onToggleTree}>
|
|
@@ -466,6 +484,16 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
|
|
|
466
484
|
</DropdownMenuSub>
|
|
467
485
|
|
|
468
486
|
|
|
487
|
+
<DropdownMenuSeparator />
|
|
488
|
+
|
|
489
|
+
<DropdownMenuItem
|
|
490
|
+
onSelect={(e) => { e.preventDefault(); if (!reindexing) handleReindex() }}
|
|
491
|
+
disabled={reindexing}
|
|
492
|
+
>
|
|
493
|
+
<RefreshCw className={`size-3.5 mr-2 ${reindexing ? 'animate-spin' : ''}`} />
|
|
494
|
+
{reindexing ? t('toolbar.reindexing') : t('toolbar.rebuildIndex')}
|
|
495
|
+
</DropdownMenuItem>
|
|
496
|
+
|
|
469
497
|
<DropdownMenuSeparator />
|
|
470
498
|
|
|
471
499
|
<DropdownMenuItem
|
|
@@ -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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 {
|
|
20
|
-
import {
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
<
|
|
46
|
-
<
|
|
77
|
+
<Popover>
|
|
78
|
+
<PopoverTrigger asChild>
|
|
47
79
|
<Button
|
|
48
|
-
|
|
80
|
+
aria-label={t('bell.label')}
|
|
81
|
+
className="relative rounded-full"
|
|
49
82
|
size="icon"
|
|
50
|
-
|
|
51
|
-
aria-label="Open notifications"
|
|
83
|
+
variant="ghost"
|
|
52
84
|
>
|
|
53
|
-
<
|
|
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
|
-
</
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|