@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
|
@@ -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,57 +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 * as React from "react";
|
|
19
|
-
import { X } from "lucide-react";
|
|
20
|
-
import { cn } from "@/lib/utils";
|
|
21
|
-
import { DEPENDENCY_COLORS, DEPENDENCY_ICONS, DEPENDENCY_LABELS } from "./constants";
|
|
22
|
-
import type { DependencyType } from "./types";
|
|
23
|
-
|
|
24
|
-
export interface RelationBadgeProps {
|
|
25
|
-
type: DependencyType;
|
|
26
|
-
title: string;
|
|
27
|
-
onRemove?: () => void;
|
|
28
|
-
className?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function RelationBadge({ type, title, onRemove, className }: RelationBadgeProps) {
|
|
32
|
-
const Icon = DEPENDENCY_ICONS[type];
|
|
33
|
-
const colors = DEPENDENCY_COLORS[type];
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<div
|
|
37
|
-
className={cn(
|
|
38
|
-
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
|
|
39
|
-
colors.bg,
|
|
40
|
-
colors.text,
|
|
41
|
-
className
|
|
42
|
-
)}
|
|
43
|
-
>
|
|
44
|
-
<Icon className="h-3 w-3" />
|
|
45
|
-
<span className="max-w-[150px] truncate">{title}</span>
|
|
46
|
-
{onRemove && (
|
|
47
|
-
<button
|
|
48
|
-
type="button"
|
|
49
|
-
onClick={onRemove}
|
|
50
|
-
className="hover:opacity-70 transition-opacity"
|
|
51
|
-
>
|
|
52
|
-
<X className="h-3 w-3" />
|
|
53
|
-
</button>
|
|
54
|
-
)}
|
|
55
|
-
</div>
|
|
56
|
-
);
|
|
57
|
-
}
|
|
@@ -1,190 +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 * as React from "react";
|
|
19
|
-
import { Link } from "@tanstack/react-router";
|
|
20
|
-
import { Button } from "@/components/ui/button";
|
|
21
|
-
import { Badge } from "@/components/ui/badge";
|
|
22
|
-
import {
|
|
23
|
-
DropdownMenu,
|
|
24
|
-
DropdownMenuContent,
|
|
25
|
-
DropdownMenuItem,
|
|
26
|
-
DropdownMenuTrigger,
|
|
27
|
-
} from "@/components/ui/dropdown-menu";
|
|
28
|
-
import { Plus, X, ChevronDown } from "lucide-react";
|
|
29
|
-
import { useServerFn } from "@tanstack/react-start"
|
|
30
|
-
import { useTranslation } from "react-i18next";
|
|
31
|
-
import { addDependencyFn, removeDependencyFn } from "@/server/serverFns/story";
|
|
32
|
-
import { toast } from "sonner";
|
|
33
|
-
import { StorySearchPopover } from "../search-popover";
|
|
34
|
-
import { STATE_LABEL, STATE_COLOR, DEPENDENCY_TYPES, DEPENDENCY_LABELS } from "../constants";
|
|
35
|
-
import type { DependencyGroup, DependencyType, DependencyItem } from "../types";
|
|
36
|
-
|
|
37
|
-
type ServerDependencyType = "BLOCKS" | "BLOCKED_BY" | "RELATES_TO";
|
|
38
|
-
|
|
39
|
-
function toServerType(type: DependencyType): ServerDependencyType {
|
|
40
|
-
return type === "blocks" ? "BLOCKS" : type === "blocked_by" ? "BLOCKED_BY" : "RELATES_TO";
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface DependencyPanelProps {
|
|
44
|
-
storyId: string;
|
|
45
|
-
data: DependencyGroup;
|
|
46
|
-
onUpdate: (updates: Partial<DependencyGroup>) => void;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function DependencyPanel({ storyId, data, onUpdate }: DependencyPanelProps) {
|
|
50
|
-
const { t } = useTranslation("story");
|
|
51
|
-
const [selectedType, setSelectedType] = React.useState<DependencyType>("blocks");
|
|
52
|
-
const [adding, setAdding] = React.useState(false);
|
|
53
|
-
const addDependency = useServerFn(addDependencyFn);
|
|
54
|
-
const removeDependency = useServerFn(removeDependencyFn);
|
|
55
|
-
|
|
56
|
-
const handleAddDependency = async (targetStoryId: string, story?: { id: string; title: string; identifier: string }) => {
|
|
57
|
-
try {
|
|
58
|
-
await addDependency({
|
|
59
|
-
data: {
|
|
60
|
-
sourceId: storyId,
|
|
61
|
-
targetId: targetStoryId,
|
|
62
|
-
type: toServerType(selectedType),
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
toast.success(t("relation.added"));
|
|
66
|
-
|
|
67
|
-
const groupKey = selectedType === "blocks" ? "blocking" : selectedType === "blocked_by" ? "blockedBy" : "relatesTo";
|
|
68
|
-
onUpdate({
|
|
69
|
-
[groupKey]: [
|
|
70
|
-
...data[groupKey],
|
|
71
|
-
{
|
|
72
|
-
id: targetStoryId,
|
|
73
|
-
title: story?.title || t("relation.newTitle"),
|
|
74
|
-
status: "backlog",
|
|
75
|
-
type: selectedType,
|
|
76
|
-
},
|
|
77
|
-
],
|
|
78
|
-
});
|
|
79
|
-
setAdding(false);
|
|
80
|
-
} catch (error: any) {
|
|
81
|
-
console.error(t("relation.addFailed"), error);
|
|
82
|
-
toast.error(error.message || t("relation.addFailed"));
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const handleRemoveDependency = async (story: DependencyItem) => {
|
|
87
|
-
try {
|
|
88
|
-
const type = story.type;
|
|
89
|
-
const sourceId = storyId;
|
|
90
|
-
const targetId = story.id;
|
|
91
|
-
|
|
92
|
-
await removeDependency({ data: { sourceId, targetId, type: toServerType(type) } });
|
|
93
|
-
toast.success(t("relation.removed"));
|
|
94
|
-
|
|
95
|
-
const groupKey = type === "blocks" ? "blocking" : type === "blocked_by" ? "blockedBy" : "relatesTo";
|
|
96
|
-
onUpdate({
|
|
97
|
-
[groupKey]: data[groupKey].filter((d) => d.id !== story.id),
|
|
98
|
-
});
|
|
99
|
-
} catch (error: any) {
|
|
100
|
-
console.error(t("relation.removeFailed"), error);
|
|
101
|
-
toast.error(error.message || t("relation.removeFailed"));
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const renderDependencySection = (
|
|
106
|
-
title: string,
|
|
107
|
-
groupKey: keyof DependencyGroup,
|
|
108
|
-
type: DependencyType
|
|
109
|
-
) => {
|
|
110
|
-
const items = data[groupKey];
|
|
111
|
-
|
|
112
|
-
return (
|
|
113
|
-
<div key={groupKey}>
|
|
114
|
-
<p className="text-xs font-medium text-muted-foreground mb-2">
|
|
115
|
-
{title} ({items.length})
|
|
116
|
-
</p>
|
|
117
|
-
{items.length > 0 ? (
|
|
118
|
-
<div className="space-y-1">
|
|
119
|
-
{items.map((item) => (
|
|
120
|
-
<div key={item.id} className="group flex items-center gap-2 rounded-md border px-3 py-2">
|
|
121
|
-
<Link
|
|
122
|
-
to="/stories/$storyId"
|
|
123
|
-
params={{ storyId: item.id }}
|
|
124
|
-
className="font-mono text-xs text-primary hover:underline"
|
|
125
|
-
>
|
|
126
|
-
{item.id.slice(0, 8)}
|
|
127
|
-
</Link>
|
|
128
|
-
<span className="flex-1 truncate text-sm">{item.title}</span>
|
|
129
|
-
<Badge variant="outline" className={STATE_COLOR[item.status as keyof typeof STATE_COLOR]}>
|
|
130
|
-
{t(STATE_LABEL[item.status as keyof typeof STATE_LABEL])}
|
|
131
|
-
</Badge>
|
|
132
|
-
<button
|
|
133
|
-
onClick={() => handleRemoveDependency(item)}
|
|
134
|
-
className="opacity-0 transition-opacity group-hover:opacity-100"
|
|
135
|
-
>
|
|
136
|
-
<X className="size-3.5 text-muted-foreground hover:text-destructive" />
|
|
137
|
-
</button>
|
|
138
|
-
</div>
|
|
139
|
-
))}
|
|
140
|
-
</div>
|
|
141
|
-
) : (
|
|
142
|
-
<p className="py-1 text-xs text-muted-foreground">{t("relation.none")}</p>
|
|
143
|
-
)}
|
|
144
|
-
</div>
|
|
145
|
-
);
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
return (
|
|
149
|
-
<div className="space-y-3 py-2">
|
|
150
|
-
{renderDependencySection(t("dependency.blocking"), "blocking", "blocks")}
|
|
151
|
-
{renderDependencySection(t("dependency.blocked_by"), "blockedBy", "blocked_by")}
|
|
152
|
-
{renderDependencySection(t("dependency.relatesTo"), "relatesTo", "relates_to")}
|
|
153
|
-
|
|
154
|
-
{adding ? (
|
|
155
|
-
<div className="flex items-center gap-2">
|
|
156
|
-
<DropdownMenu>
|
|
157
|
-
<DropdownMenuTrigger asChild>
|
|
158
|
-
<Button variant="outline" size="sm">
|
|
159
|
-
{t(DEPENDENCY_LABELS[selectedType])}
|
|
160
|
-
<ChevronDown className="ml-1 size-3" />
|
|
161
|
-
</Button>
|
|
162
|
-
</DropdownMenuTrigger>
|
|
163
|
-
<DropdownMenuContent>
|
|
164
|
-
{Object.values(DEPENDENCY_TYPES).map((type) => (
|
|
165
|
-
<DropdownMenuItem key={type} onClick={() => setSelectedType(type)}>
|
|
166
|
-
{t(DEPENDENCY_LABELS[type])}
|
|
167
|
-
</DropdownMenuItem>
|
|
168
|
-
))}
|
|
169
|
-
</DropdownMenuContent>
|
|
170
|
-
</DropdownMenu>
|
|
171
|
-
<StorySearchPopover
|
|
172
|
-
onSelect={handleAddDependency}
|
|
173
|
-
excludeIds={[storyId]}
|
|
174
|
-
trigger={
|
|
175
|
-
<Button variant="outline" size="sm">
|
|
176
|
-
<Plus className="mr-2 h-4 w-4" />
|
|
177
|
-
{t("relation.search")}
|
|
178
|
-
</Button>
|
|
179
|
-
}
|
|
180
|
-
/>
|
|
181
|
-
</div>
|
|
182
|
-
) : (
|
|
183
|
-
<Button variant="outline" size="sm" onClick={() => setAdding(true)}>
|
|
184
|
-
<Plus className="mr-2 h-4 w-4" />
|
|
185
|
-
{t("relation.add")}
|
|
186
|
-
</Button>
|
|
187
|
-
)}
|
|
188
|
-
</div>
|
|
189
|
-
);
|
|
190
|
-
}
|