@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
@@ -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
- }