@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.
- package/package.json +12 -8
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -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 +124 -51
- package/src/components/story/create-story-dialog.tsx +12 -6
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/activity-timeline.tsx +15 -0
- package/src/components/story/detail/story-detail-body.tsx +14 -3
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
- 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 +107 -16
- package/src/components/story/peek-panel.tsx +8 -1
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +191 -0
- 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 +30 -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/changelog/sdk-versions.ts +19 -19
- package/src/lib/datetime.ts +1 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/routeTree.gen.ts +65 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -0
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/api/notifications/stream.ts +4 -0
- 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__/project-kb.test.ts +147 -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.ts +50 -4
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -4
- package/src/server/serverFns/project.ts +43 -0
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +7 -6
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +339 -50
- package/src/server/serverFns/story/story-attachments.ts +12 -43
- package/src/server/serverFns/story/story-crud.ts +21 -4
- package/src/server/serverFns/story/story-dependencies.ts +138 -162
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/server/serverFns/team.ts +51 -2
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relation-badge.tsx +0 -57
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- 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/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/components/story/relations/tab.tsx +0 -212
|
@@ -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 { Link2 } 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 { LinksPanel } from "../../relations/links-panel";
|
|
23
|
-
import type { RelationsData } from "../../types";
|
|
24
|
-
|
|
25
|
-
export function StoryDetailLinks({
|
|
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.links.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.linksTitle")}
|
|
45
|
-
>
|
|
46
|
-
<Link2 className="size-3.5 text-muted-foreground" />
|
|
47
|
-
{t("relations.links")}
|
|
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.links")}</span>
|
|
58
|
-
{count > 0 && <span className="text-xs text-muted-foreground">{t("relations.itemCount", { count })}</span>}
|
|
59
|
-
</div>
|
|
60
|
-
<LinksPanel
|
|
61
|
-
storyId={storyId}
|
|
62
|
-
data={data.links}
|
|
63
|
-
onUpdate={(newLinks) =>
|
|
64
|
-
onRefresh((prev) => ({
|
|
65
|
-
...prev,
|
|
66
|
-
links: newLinks,
|
|
67
|
-
}))
|
|
68
|
-
}
|
|
69
|
-
/>
|
|
70
|
-
</PopoverContent>
|
|
71
|
-
</Popover>
|
|
72
|
-
);
|
|
73
|
-
}
|
|
@@ -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,179 +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 { Plus, Upload, FileText, Trash2 } from "lucide-react";
|
|
20
|
-
import { useTranslation } from "react-i18next";
|
|
21
|
-
import { Button } from "@/components/ui/button";
|
|
22
|
-
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
23
|
-
import { useServerFn } from "@tanstack/react-start";
|
|
24
|
-
import { getAttachmentsFn, removeAttachmentFn } from "@/server/serverFns/story";
|
|
25
|
-
import { toast } from "sonner";
|
|
26
|
-
import { type StoryAttachmentItem } from "../types";
|
|
27
|
-
|
|
28
|
-
export interface AttachmentsPanelProps {
|
|
29
|
-
storyId: string;
|
|
30
|
-
data: StoryAttachmentItem[];
|
|
31
|
-
onUpdate: (newAttachments: StoryAttachmentItem[]) => void;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function AttachmentsPanel({ storyId, data, onUpdate }: AttachmentsPanelProps) {
|
|
35
|
-
const { t } = useTranslation("story");
|
|
36
|
-
const [open, setOpen] = React.useState(false);
|
|
37
|
-
const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
|
|
38
|
-
const [uploading, setUploading] = React.useState(false);
|
|
39
|
-
|
|
40
|
-
const removeAttachment = useServerFn(removeAttachmentFn);
|
|
41
|
-
|
|
42
|
-
const handleUpload = async () => {
|
|
43
|
-
if (!selectedFile) {
|
|
44
|
-
toast.error(t("attachments.selectFile"));
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
setUploading(true);
|
|
49
|
-
try {
|
|
50
|
-
// 使用原生 fetch 调用 serverFn 端点上传(支持 FormData)
|
|
51
|
-
const formData = new FormData();
|
|
52
|
-
formData.append("storyId", storyId);
|
|
53
|
-
formData.append("file", selectedFile);
|
|
54
|
-
|
|
55
|
-
// 注意:serverFn 不支持 FormData,这里使用 Mock 实现
|
|
56
|
-
// 实际应用中应调用专用上传 API
|
|
57
|
-
const mockUrl = `/mock-storage/${Date.now()}-${selectedFile.name}`;
|
|
58
|
-
const newAttachment: StoryAttachmentItem = {
|
|
59
|
-
id: `mock-${Date.now()}`,
|
|
60
|
-
fileName: selectedFile.name,
|
|
61
|
-
fileSize: selectedFile.size,
|
|
62
|
-
mimeType: selectedFile.type || "application/octet-stream",
|
|
63
|
-
storageUrl: mockUrl,
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// 乐观更新
|
|
67
|
-
onUpdate([newAttachment, ...data]);
|
|
68
|
-
toast.success(t("attachments.uploaded"));
|
|
69
|
-
setOpen(false);
|
|
70
|
-
setSelectedFile(null);
|
|
71
|
-
} catch (error: any) {
|
|
72
|
-
console.error(t("attachments.uploadFailed"), error);
|
|
73
|
-
toast.error(error.message || t("attachments.uploadFailed"));
|
|
74
|
-
} finally {
|
|
75
|
-
setUploading(false);
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const handleRemove = async (attachmentId: string) => {
|
|
80
|
-
try {
|
|
81
|
-
// Mock 附件用本地移除
|
|
82
|
-
if (attachmentId.startsWith("mock-")) {
|
|
83
|
-
onUpdate(data.filter((att) => att.id !== attachmentId));
|
|
84
|
-
toast.success(t("attachments.removed"));
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
await removeAttachment({ data: { attachmentId } });
|
|
89
|
-
toast.success(t("attachments.removed"));
|
|
90
|
-
onUpdate(data.filter((att) => att.id !== attachmentId));
|
|
91
|
-
} catch (error: any) {
|
|
92
|
-
console.error(t("attachments.removeFailed"), error);
|
|
93
|
-
toast.error(error.message || t("attachments.removeFailed"));
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const formatFileSize = (bytes: number) => {
|
|
98
|
-
if (bytes === 0) return "0 B";
|
|
99
|
-
const k = 1024;
|
|
100
|
-
const sizes = ["B", "KB", "MB", "GB"];
|
|
101
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
102
|
-
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
<div className="space-y-4">
|
|
107
|
-
<div className="flex items-center justify-between">
|
|
108
|
-
<div className="text-sm font-medium">{t("attachments.title")}</div>
|
|
109
|
-
<Dialog open={open} onOpenChange={setOpen}>
|
|
110
|
-
<DialogTrigger asChild>
|
|
111
|
-
<Button type="button" variant="outline" size="sm">
|
|
112
|
-
<Plus className="h-4 w-4 mr-2" />
|
|
113
|
-
{t("attachments.add")}
|
|
114
|
-
</Button>
|
|
115
|
-
</DialogTrigger>
|
|
116
|
-
<DialogContent>
|
|
117
|
-
<DialogHeader>
|
|
118
|
-
<DialogTitle>{t("attachments.uploadTitle")}</DialogTitle>
|
|
119
|
-
</DialogHeader>
|
|
120
|
-
<div className="space-y-4">
|
|
121
|
-
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-md cursor-pointer hover:bg-muted transition-colors">
|
|
122
|
-
<Upload className="h-8 w-8 mb-2 text-muted-foreground" />
|
|
123
|
-
<span className="text-sm text-muted-foreground">
|
|
124
|
-
{selectedFile ? selectedFile.name : t("attachments.clickToSelect")}
|
|
125
|
-
</span>
|
|
126
|
-
<input
|
|
127
|
-
type="file"
|
|
128
|
-
className="hidden"
|
|
129
|
-
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
|
130
|
-
/>
|
|
131
|
-
</label>
|
|
132
|
-
<div className="flex justify-end gap-2">
|
|
133
|
-
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
|
134
|
-
{t("attachments.cancel")}
|
|
135
|
-
</Button>
|
|
136
|
-
<Button type="button" onClick={handleUpload} disabled={uploading || !selectedFile}>
|
|
137
|
-
{uploading ? t("attachments.uploading") : t("attachments.upload")}
|
|
138
|
-
</Button>
|
|
139
|
-
</div>
|
|
140
|
-
</div>
|
|
141
|
-
</DialogContent>
|
|
142
|
-
</Dialog>
|
|
143
|
-
</div>
|
|
144
|
-
|
|
145
|
-
{data.length > 0 ? (
|
|
146
|
-
<div className="space-y-2">
|
|
147
|
-
{data.map((att) => (
|
|
148
|
-
<div
|
|
149
|
-
key={att.id}
|
|
150
|
-
className="flex items-center justify-between p-3 bg-muted rounded-md"
|
|
151
|
-
>
|
|
152
|
-
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
153
|
-
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
154
|
-
<div className="flex-1 min-w-0">
|
|
155
|
-
<div className="text-sm font-medium truncate">{att.fileName}</div>
|
|
156
|
-
<div className="text-xs text-muted-foreground">
|
|
157
|
-
{formatFileSize(att.fileSize)} · {att.mimeType}
|
|
158
|
-
</div>
|
|
159
|
-
</div>
|
|
160
|
-
</div>
|
|
161
|
-
<Button
|
|
162
|
-
type="button"
|
|
163
|
-
variant="ghost"
|
|
164
|
-
size="icon"
|
|
165
|
-
onClick={() => handleRemove(att.id)}
|
|
166
|
-
>
|
|
167
|
-
<Trash2 className="h-4 w-4" />
|
|
168
|
-
</Button>
|
|
169
|
-
</div>
|
|
170
|
-
))}
|
|
171
|
-
</div>
|
|
172
|
-
) : (
|
|
173
|
-
<div className="text-sm text-muted-foreground text-center py-4">
|
|
174
|
-
{t("attachments.empty")}
|
|
175
|
-
</div>
|
|
176
|
-
)}
|
|
177
|
-
</div>
|
|
178
|
-
);
|
|
179
|
-
}
|
|
@@ -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
|
-
}
|
|
@@ -1,180 +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 { Plus, Minus, GitBranch } from "lucide-react";
|
|
20
|
-
import { useTranslation } from "react-i18next";
|
|
21
|
-
import { Button } from "@/components/ui/button";
|
|
22
|
-
import { StorySearchPopover } from "../search-popover";
|
|
23
|
-
import { useServerFn } from "@tanstack/react-start";
|
|
24
|
-
import { addChildFn, removeChildFn, getChildrenCountFn, getStoryDetailFn } from "@/server/serverFns/story";
|
|
25
|
-
import { toast } from "sonner";
|
|
26
|
-
import { type StoryBrief, StoryListItem } from "../types";
|
|
27
|
-
|
|
28
|
-
export interface HierarchyPanelProps {
|
|
29
|
-
storyId: string;
|
|
30
|
-
data: {
|
|
31
|
-
parent: StoryBrief | null;
|
|
32
|
-
children: StoryListItem[];
|
|
33
|
-
hasChildren: boolean;
|
|
34
|
-
};
|
|
35
|
-
onUpdate: (updates: Partial<{ parent: StoryBrief | null; children: StoryListItem[]; hasChildren: boolean }>) => void;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function HierarchyPanel({ storyId, data, onUpdate }: HierarchyPanelProps) {
|
|
39
|
-
const { t } = useTranslation("story");
|
|
40
|
-
const [addingChild, setAddingChild] = React.useState(false);
|
|
41
|
-
const addChild = useServerFn(addChildFn);
|
|
42
|
-
const removeChild = useServerFn(removeChildFn);
|
|
43
|
-
const getChildrenCount = useServerFn(getChildrenCountFn);
|
|
44
|
-
const getStoryDetail = useServerFn(getStoryDetailFn);
|
|
45
|
-
|
|
46
|
-
const handleAddChild = async (childId: string) => {
|
|
47
|
-
try {
|
|
48
|
-
await addChild({ data: { parentId: storyId, childId } });
|
|
49
|
-
toast.success(t("hierarchy.childAdded"));
|
|
50
|
-
|
|
51
|
-
const [{ count }, storyDetail] = await Promise.all([
|
|
52
|
-
getChildrenCount({ data: { parentId: storyId } }),
|
|
53
|
-
getStoryDetail({ data: { storyId: childId } }),
|
|
54
|
-
]);
|
|
55
|
-
|
|
56
|
-
onUpdate({
|
|
57
|
-
children: [...data.children, storyDetail as StoryListItem],
|
|
58
|
-
hasChildren: count > 0,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
setAddingChild(false);
|
|
62
|
-
} catch (error: any) {
|
|
63
|
-
console.error(t("hierarchy.addChildFailed"), error);
|
|
64
|
-
toast.error(error.message || t("hierarchy.addChildFailed"));
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const handleRemoveChild = async (childId: string) => {
|
|
69
|
-
try {
|
|
70
|
-
await removeChild({ data: { childId } });
|
|
71
|
-
toast.success(t("hierarchy.childRemoved"));
|
|
72
|
-
|
|
73
|
-
const { count } = await getChildrenCount({ data: { parentId: storyId } });
|
|
74
|
-
onUpdate({
|
|
75
|
-
children: data.children.filter((c) => c.id !== childId),
|
|
76
|
-
hasChildren: count > 0,
|
|
77
|
-
});
|
|
78
|
-
} catch (error: any) {
|
|
79
|
-
console.error(t("hierarchy.removeChildFailed"), error);
|
|
80
|
-
toast.error(error.message || t("hierarchy.removeChildFailed"));
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
return (
|
|
85
|
-
<div className="space-y-4">
|
|
86
|
-
<div>
|
|
87
|
-
<div className="text-sm font-medium mb-2">{t("hierarchy.parent")}</div>
|
|
88
|
-
{data.parent ? (
|
|
89
|
-
<div className="flex items-center justify-between p-3 bg-muted rounded-md">
|
|
90
|
-
<div className="flex items-center gap-2">
|
|
91
|
-
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
|
92
|
-
<span className="text-sm">{data.parent.title}</span>
|
|
93
|
-
</div>
|
|
94
|
-
<Button
|
|
95
|
-
type="button"
|
|
96
|
-
variant="ghost"
|
|
97
|
-
size="sm"
|
|
98
|
-
onClick={() => handleRemoveChild(storyId)}
|
|
99
|
-
>
|
|
100
|
-
<Minus className="h-4 w-4" />
|
|
101
|
-
</Button>
|
|
102
|
-
</div>
|
|
103
|
-
) : (
|
|
104
|
-
<div className="flex items-center justify-between">
|
|
105
|
-
<span className="text-sm text-muted-foreground">{t("hierarchy.noParent")}</span>
|
|
106
|
-
<StorySearchPopover
|
|
107
|
-
onSelect={handleAddChild}
|
|
108
|
-
placeholder={t("hierarchy.setParent")}
|
|
109
|
-
trigger={
|
|
110
|
-
<Button type="button" variant="outline" size="sm">
|
|
111
|
-
<Plus className="h-4 w-4 mr-2" />
|
|
112
|
-
{t("hierarchy.setParent")}
|
|
113
|
-
</Button>
|
|
114
|
-
}
|
|
115
|
-
/>
|
|
116
|
-
</div>
|
|
117
|
-
)}
|
|
118
|
-
</div>
|
|
119
|
-
|
|
120
|
-
<div>
|
|
121
|
-
<div className="flex items-center justify-between mb-2">
|
|
122
|
-
<div className="text-sm font-medium">{t("hierarchy.children")}</div>
|
|
123
|
-
<Button
|
|
124
|
-
type="button"
|
|
125
|
-
variant="outline"
|
|
126
|
-
size="sm"
|
|
127
|
-
onClick={() => setAddingChild(!addingChild)}
|
|
128
|
-
>
|
|
129
|
-
<Plus className="h-4 w-4 mr-2" />
|
|
130
|
-
{t("hierarchy.addChild")}
|
|
131
|
-
</Button>
|
|
132
|
-
</div>
|
|
133
|
-
|
|
134
|
-
{addingChild && (
|
|
135
|
-
<div className="mb-3">
|
|
136
|
-
<StorySearchPopover
|
|
137
|
-
onSelect={handleAddChild}
|
|
138
|
-
placeholder={t("hierarchy.searchStory")}
|
|
139
|
-
excludeIds={[storyId, ...data.children.map((c) => c.id)]}
|
|
140
|
-
trigger={
|
|
141
|
-
<Button type="button" variant="outline" size="sm" className="w-full">
|
|
142
|
-
<Plus className="h-4 w-4 mr-2" />
|
|
143
|
-
{t("hierarchy.selectChild")}
|
|
144
|
-
</Button>
|
|
145
|
-
}
|
|
146
|
-
/>
|
|
147
|
-
</div>
|
|
148
|
-
)}
|
|
149
|
-
|
|
150
|
-
{data.children.length > 0 ? (
|
|
151
|
-
<div className="space-y-2">
|
|
152
|
-
{data.children.map((child) => (
|
|
153
|
-
<div
|
|
154
|
-
key={child.id}
|
|
155
|
-
className="flex items-center justify-between p-3 bg-muted rounded-md"
|
|
156
|
-
>
|
|
157
|
-
<div className="flex items-center gap-2">
|
|
158
|
-
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
|
159
|
-
<span className="text-sm">{child.name}</span>
|
|
160
|
-
</div>
|
|
161
|
-
<Button
|
|
162
|
-
type="button"
|
|
163
|
-
variant="ghost"
|
|
164
|
-
size="sm"
|
|
165
|
-
onClick={() => handleRemoveChild(child.id)}
|
|
166
|
-
>
|
|
167
|
-
<Minus className="h-4 w-4" />
|
|
168
|
-
</Button>
|
|
169
|
-
</div>
|
|
170
|
-
))}
|
|
171
|
-
</div>
|
|
172
|
-
) : (
|
|
173
|
-
<div className="text-sm text-muted-foreground text-center py-4">
|
|
174
|
-
{t("hierarchy.noChildren")}
|
|
175
|
-
</div>
|
|
176
|
-
)}
|
|
177
|
-
</div>
|
|
178
|
-
</div>
|
|
179
|
-
);
|
|
180
|
-
}
|