@allbluecn/web-app 0.4.22 → 0.5.1

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 (99) hide show
  1. package/package.json +12 -8
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  4. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  5. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  6. package/src/components/notifications/settings-notifications.tsx +198 -0
  7. package/src/components/notifications/team-notifications.tsx +343 -0
  8. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  9. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  10. package/src/components/shared/wave-physics-loader.tsx +155 -0
  11. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  12. package/src/components/story/constants.ts +124 -51
  13. package/src/components/story/create-story-dialog.tsx +12 -6
  14. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  15. package/src/components/story/detail/activity-timeline.tsx +15 -0
  16. package/src/components/story/detail/story-detail-body.tsx +14 -3
  17. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  18. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  19. package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
  20. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  21. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
  22. package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
  23. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  24. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  25. package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
  26. package/src/components/story/peek-panel.tsx +8 -1
  27. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  28. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  29. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  30. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  31. package/src/components/story/relations/file-preview.tsx +173 -0
  32. package/src/components/story/relations/format.ts +120 -0
  33. package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
  34. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  35. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  36. package/src/components/story/relations/story-ref-row.tsx +319 -0
  37. package/src/components/story/story-list-view.tsx +3 -1
  38. package/src/components/story/story-panel-header.tsx +4 -1
  39. package/src/components/story/types.ts +30 -18
  40. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  41. package/src/components/story/views/story-list/index.tsx +16 -5
  42. package/src/components/story/views/story-list/row.tsx +5 -4
  43. package/src/components/ui/separator.tsx +1 -1
  44. package/src/hooks/use-notifications.ts +4 -1
  45. package/src/lib/__tests__/datetime.test.ts +16 -1
  46. package/src/lib/changelog/sdk-versions.ts +19 -19
  47. package/src/lib/datetime.ts +1 -0
  48. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  49. package/src/lib/story/video-embed.ts +59 -0
  50. package/src/routeTree.gen.ts +65 -0
  51. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  52. package/src/routes/_nav/notifications/route.tsx +30 -0
  53. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  54. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  55. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  56. package/src/routes/api/attachments/$id/content.ts +60 -0
  57. package/src/routes/api/attachments/upload.ts +148 -0
  58. package/src/routes/api/notifications/stream.ts +4 -0
  59. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  60. package/src/server/notifications/notification-service.ts +137 -27
  61. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  62. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  63. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  64. package/src/server/serverFns/knowledge.ts +50 -4
  65. package/src/server/serverFns/locale.ts +15 -3
  66. package/src/server/serverFns/notifications.ts +18 -4
  67. package/src/server/serverFns/project.ts +43 -0
  68. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  69. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  70. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
  71. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  72. package/src/server/serverFns/story/index.ts +7 -6
  73. package/src/server/serverFns/story/kb-general.ts +39 -0
  74. package/src/server/serverFns/story/kb-scope.ts +52 -0
  75. package/src/server/serverFns/story/link-preview.ts +115 -0
  76. package/src/server/serverFns/story/story-assoc.ts +339 -50
  77. package/src/server/serverFns/story/story-attachments.ts +12 -43
  78. package/src/server/serverFns/story/story-crud.ts +21 -4
  79. package/src/server/serverFns/story/story-dependencies.ts +138 -162
  80. package/src/server/serverFns/story/story-groups.ts +23 -3
  81. package/src/server/serverFns/story/story-links.ts +13 -1
  82. package/src/server/serverFns/story/story-transfer.ts +5 -3
  83. package/src/server/serverFns/story/story-tree.ts +28 -1
  84. package/src/server/serverFns/story/types.ts +37 -0
  85. package/src/server/serverFns/team.ts +51 -2
  86. package/src/styles/globals.css +25 -0
  87. package/vite.shared.ts +2 -0
  88. package/src/components/notifications/notification-list.tsx +0 -232
  89. package/src/components/notifications/notification-preferences.tsx +0 -156
  90. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  91. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  92. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  93. package/src/components/story/relation-badge.tsx +0 -57
  94. package/src/components/story/relations/attachments-panel.tsx +0 -179
  95. package/src/components/story/relations/dependency-panel.tsx +0 -190
  96. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  97. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  98. package/src/components/story/relations/links-panel.tsx +0 -240
  99. package/src/components/story/relations/tab.tsx +0 -212
@@ -183,7 +183,7 @@ export const clearExpiredInvitationsFn = createServerFn({ method: 'POST' })
183
183
  const session = await requireAuth(request)
184
184
  const ownerId = session.user!.id!
185
185
  const res = await prisma.teamInvitation.deleteMany({
186
- where: { ownerId, status: 'expired' },
186
+ where: { ownerId, status: 'expired', channel: 'email' },
187
187
  })
188
188
  await auditLog({
189
189
  workspaceId: '',
@@ -279,8 +279,9 @@ export const refreshTeamInvitationLinkFn = createServerFn({ method: 'POST' })
279
279
  },
280
280
  data: { status: 'invalidated', processedAt: new Date() },
281
281
  })
282
- await tx.teamInvitation.deleteMany({
282
+ await tx.teamInvitation.updateMany({
283
283
  where: { ownerId, channel: 'link', status: { in: ['pending', 'pending_approval'] } },
284
+ data: { status: 'revoked' },
284
285
  })
285
286
  return tx.teamInvitation.create({
286
287
  data: {
@@ -493,6 +494,54 @@ export const rejectTeamJoinRequestFn = createServerFn({ method: 'POST' })
493
494
  return { success: true }
494
495
  })
495
496
 
497
+ export const deleteTeamInvitationFn = createServerFn({ method: 'POST' })
498
+ .validator(revokeSchema)
499
+ .handler(async ({ data }) => {
500
+ const request = getRequest()
501
+ const session = await requireAuth(request)
502
+ const ownerId = session.user!.id!
503
+
504
+ const invRow = await prisma.teamInvitation.findUnique({ where: { id: data.invitationId } })
505
+ if (!invRow || invRow.ownerId !== ownerId) throw new Error('邀请不存在')
506
+ // link 渠道行下挂 joinRequest(级联删除),禁止单独删除
507
+ if (invRow.channel === 'link') throw new Error('链接邀请记录不可删除')
508
+
509
+ await prisma.teamInvitation.delete({ where: { id: invRow.id } })
510
+
511
+ await auditLog({
512
+ workspaceId: '',
513
+ userId: ownerId,
514
+ resourceType: 'teamInvitation',
515
+ resourceId: invRow.id,
516
+ action: 'team.invite.delete',
517
+ metadata: { email: invRow.email },
518
+ })
519
+ return { success: true }
520
+ })
521
+
522
+ export const deleteTeamJoinRequestFn = createServerFn({ method: 'POST' })
523
+ .validator(joinRequestSchema)
524
+ .handler(async ({ data }) => {
525
+ const request = getRequest()
526
+ const session = await requireAuth(request)
527
+ const ownerId = session.user!.id!
528
+
529
+ const reqRow = await prisma.teamJoinRequest.findUnique({ where: { id: data.requestId } })
530
+ if (!reqRow || reqRow.ownerId !== ownerId) throw new Error('申请不存在')
531
+
532
+ await prisma.teamJoinRequest.delete({ where: { id: reqRow.id } })
533
+
534
+ await auditLog({
535
+ workspaceId: '',
536
+ userId: ownerId,
537
+ resourceType: 'teamJoinRequest',
538
+ resourceId: reqRow.id,
539
+ action: 'team.join.delete',
540
+ metadata: { email: reqRow.email },
541
+ })
542
+ return { success: true }
543
+ })
544
+
496
545
  export const listTeamUsersFn = createServerFn({ method: 'GET' }).handler(async () => {
497
546
  const request = getRequest()
498
547
  const session = await requireAuth(request)
@@ -493,3 +493,28 @@ html.theme-transitioning *::after {
493
493
  height: 40px !important;
494
494
  border-radius: 20px !important;
495
495
  }
496
+
497
+ /* 关联附件预览网格:笔记本4列,大屏(≥1536px)5列;4列时预览高度较矮 */
498
+ .attachment-preview-grid {
499
+ grid-template-columns: repeat(4, minmax(0, 1fr));
500
+ --attachment-preview-h: 18rem;
501
+ --attachment-preview-h-sub: 9rem;
502
+ }
503
+ @media (min-width: 1536px) {
504
+ .attachment-preview-grid {
505
+ grid-template-columns: repeat(5, minmax(0, 1fr));
506
+ /* 5列(大屏)保持原高度(h-96 / h-48) */
507
+ --attachment-preview-h: 24rem;
508
+ --attachment-preview-h-sub: 12rem;
509
+ }
510
+ }
511
+
512
+ /* open-file-viewer 预览(FilePreview):插件不支持的命令按钮处于 disabled 态,直接隐藏 */
513
+ .ofv-preview-scope .ofv-toolbar button:disabled {
514
+ display: none;
515
+ }
516
+ /* open-file-viewer 预览(FilePreview):默认隐藏 text/code 顶部标题栏,由组件切换类名显隐;
517
+ 音频等类型的标题不隐藏 */
518
+ .ofv-preview-title-hidden .ofv-code-header {
519
+ display: none;
520
+ }
package/vite.shared.ts CHANGED
@@ -160,6 +160,8 @@ export function createWebViteConfig(opts: WebViteConfigOptions) {
160
160
  '@allblue/devtools',
161
161
  '@allbluecn/plugins-core',
162
162
  '@allbluecn/tiptap',
163
+ '@open-file-viewer/core',
164
+ '@open-file-viewer/react',
163
165
  ],
164
166
  },
165
167
  }
@@ -1,232 +0,0 @@
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
- }
@@ -1,156 +0,0 @@
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
- }
@@ -1,73 +0,0 @@
1
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
- // AllBlue - 理想之海
3
- // Copyright (C) 2026 AllBlue Contributors
4
- //
5
- // This program is free software: you can redistribute it and/or modify
6
- // it under the terms of the GNU Affero General Public License as published by
7
- // the Free Software Foundation, either version 3 of the License, or
8
- // (at your option) any later version.
9
- //
10
- // This program is distributed in the hope that it will be useful,
11
- // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
- // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
- // GNU Affero General Public License for more details.
14
- //
15
- // You should have received a copy of the GNU Affero General Public License
16
- // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
-
18
- import { Paperclip } from "lucide-react";
19
- import { useTranslation } from "react-i18next";
20
- import { Button } from "@/components/ui/button";
21
- import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
22
- import { AttachmentsPanel } from "../../relations/attachments-panel";
23
- import type { RelationsData } from "../../types";
24
-
25
- export function StoryDetailAttachments({
26
- storyId,
27
- data,
28
- onRefresh,
29
- }: {
30
- storyId: string;
31
- data: RelationsData;
32
- onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
33
- }) {
34
- const { t } = useTranslation("story");
35
- const count = data.attachments.length;
36
-
37
- return (
38
- <Popover>
39
- <PopoverTrigger asChild>
40
- <Button
41
- variant="ghost"
42
- size="sm"
43
- className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
44
- title={t("relations.uploadAttachment")}
45
- >
46
- <Paperclip className="size-3.5 text-muted-foreground" />
47
- {t("relations.attachment")}
48
- {count > 0 && (
49
- <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
50
- {count}
51
- </span>
52
- )}
53
- </Button>
54
- </PopoverTrigger>
55
- <PopoverContent align="start" className="w-[380px] p-4">
56
- <div className="flex items-center justify-between mb-3">
57
- <span className="text-sm font-semibold">{t("relations.attachment")}</span>
58
- {count > 0 && <span className="text-xs text-muted-foreground">{t("relations.itemCount", { count })}</span>}
59
- </div>
60
- <AttachmentsPanel
61
- storyId={storyId}
62
- data={data.attachments}
63
- onUpdate={(newAttachments) =>
64
- onRefresh((prev) => ({
65
- ...prev,
66
- attachments: newAttachments,
67
- }))
68
- }
69
- />
70
- </PopoverContent>
71
- </Popover>
72
- );
73
- }
@@ -1,73 +0,0 @@
1
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
- // AllBlue - 理想之海
3
- // Copyright (C) 2026 AllBlue Contributors
4
- //
5
- // This program is free software: you can redistribute it and/or modify
6
- // it under the terms of the GNU Affero General Public License as published by
7
- // the Free Software Foundation, either version 3 of the License, or
8
- // (at your option) any later version.
9
- //
10
- // This program is distributed in the hope that it will be useful,
11
- // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
- // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
- // GNU Affero General Public License for more details.
14
- //
15
- // You should have received a copy of the GNU Affero General Public License
16
- // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
-
18
- import { BookOpenText } from "lucide-react";
19
- import { useTranslation } from "react-i18next";
20
- import { Button } from "@/components/ui/button";
21
- import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
22
- import { KnowledgePanel } from "../../relations/knowledge-panel";
23
- import type { RelationsData } from "../../types";
24
-
25
- export function StoryDetailKnowledge({
26
- storyId,
27
- data,
28
- onRefresh,
29
- }: {
30
- storyId: string;
31
- data: RelationsData;
32
- onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
33
- }) {
34
- const { t } = useTranslation("story");
35
- const count = data.knowledge.prds.length + data.knowledge.docs.length;
36
-
37
- return (
38
- <Popover>
39
- <PopoverTrigger asChild>
40
- <Button
41
- variant="ghost"
42
- size="sm"
43
- className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
44
- title={t("relations.knowledgeTitle")}
45
- >
46
- <BookOpenText className="size-3.5 text-muted-foreground" />
47
- {t("relations.knowledge")}
48
- {count > 0 && (
49
- <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
50
- {count}
51
- </span>
52
- )}
53
- </Button>
54
- </PopoverTrigger>
55
- <PopoverContent align="start" className="w-[380px] p-4">
56
- <div className="flex items-center justify-between mb-3">
57
- <span className="text-sm font-semibold">{t("relations.knowledge")}</span>
58
- {count > 0 && <span className="text-xs text-muted-foreground">{t("relations.itemCount", { count })}</span>}
59
- </div>
60
- <KnowledgePanel
61
- storyId={storyId}
62
- data={data.knowledge}
63
- onUpdate={(updates) =>
64
- onRefresh((prev) => ({
65
- ...prev,
66
- knowledge: { ...prev.knowledge, ...updates },
67
- }))
68
- }
69
- />
70
- </PopoverContent>
71
- </Popover>
72
- );
73
- }