@leanspec/ui 0.2.14 → 0.2.15-dev.21025278490
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/bin/leanspec-ui.js +191 -0
- package/dist/assets/_baseUniq-CRqreL7N.js +1 -0
- package/dist/assets/arc-DMhx9AJT.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-DM0L0YzO.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-DHQXDHsD.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-0L7o2gpH.js +10 -0
- package/dist/assets/channel-2tOl0nAZ.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-CwFT-Uaj.js +1 -0
- package/dist/assets/chunk-55IACEB6-CjvuUHHG.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-BRJBysMK.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-BnNEeoaA.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-BK2l30pm.js +15 -0
- package/dist/assets/chunk-QN33PNHL-BN_cZkCU.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-Brc3Yrub.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-D2zzpLfO.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BB9CSNmS.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BB9CSNmS.js +1 -0
- package/dist/assets/clone-BjxVFtyI.js +1 -0
- package/dist/assets/core-DV6XEvTN.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-CLJgM3XR.js +1 -0
- package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
- package/dist/assets/dagre-6UL2VRFP-_IFvBJKJ.js +4 -0
- package/dist/assets/diagram-PSM6KHXK--83HIYSQ.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-6jAWnCnZ.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-D5pwHvjZ.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-B4FV3mTd.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-mtD2kF4M.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-BKALgqTK.js +267 -0
- package/dist/assets/gitGraphDiagram-NY62KEGX-Bd7r0pAf.js +65 -0
- package/dist/assets/graph-B2rEI7cK.js +1 -0
- package/dist/assets/index-Bekv_o1t.css +1 -0
- package/dist/assets/index-DSRxU-E5.js +389 -0
- package/dist/assets/infoDiagram-WHAUD3N6--nJOBKqh.js +2 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-BzGutKN3.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-DyQO17vq.js +89 -0
- package/dist/assets/katex-XbL3y5x-.js +261 -0
- package/dist/assets/layout-iCSHU015.js +1 -0
- package/dist/assets/min-BK_AIJdo.js +1 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BZMj_6zo.js +68 -0
- package/dist/assets/pieDiagram-ADFJNKIX-CkAGsq9p.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-CWa93px1.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-CufFVR8c.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-BEPgVgU4.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-BkdBWhel.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-D5T73yx0.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-9hJWG2n6.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-CX7kTdU2.js +61 -0
- package/dist/assets/treemap-KMMF4GRG-ftWCQ9lJ.js +128 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-Ngrels4n.js +7 -0
- package/{index.html → dist/index.html} +2 -1
- package/package.json +12 -2
- package/eslint.config.js +0 -23
- package/package.json.backup +0 -83
- package/postcss.config.js +0 -6
- package/src/App.css +0 -42
- package/src/App.tsx +0 -17
- package/src/assets/react.svg +0 -1
- package/src/components/LanguageSwitcher.tsx +0 -67
- package/src/components/Layout.tsx +0 -88
- package/src/components/MainSidebar.tsx +0 -163
- package/src/components/MermaidDiagram.tsx +0 -85
- package/src/components/MinimalLayout.tsx +0 -51
- package/src/components/Navigation.tsx +0 -254
- package/src/components/PriorityBadge.tsx +0 -59
- package/src/components/ProjectSwitcher.tsx +0 -222
- package/src/components/QuickSearch.tsx +0 -225
- package/src/components/RootRedirect.tsx +0 -40
- package/src/components/SpecDetailLayout.context.ts +0 -10
- package/src/components/SpecDetailLayout.tsx +0 -14
- package/src/components/SpecsNavSidebar.tsx +0 -615
- package/src/components/StatusBadge.tsx +0 -59
- package/src/components/ThemeToggle.tsx +0 -25
- package/src/components/Tooltip.tsx +0 -29
- package/src/components/context/ContextClient.tsx +0 -471
- package/src/components/context/ContextFileDetail.tsx +0 -163
- package/src/components/dashboard/ActivityItem.tsx +0 -36
- package/src/components/dashboard/DashboardClient.tsx +0 -218
- package/src/components/dashboard/SpecListItem.tsx +0 -58
- package/src/components/dashboard/StatCard.tsx +0 -52
- package/src/components/dependencies/SpecNode.tsx +0 -128
- package/src/components/dependencies/SpecSidebar.tsx +0 -256
- package/src/components/dependencies/constants.ts +0 -25
- package/src/components/dependencies/types.ts +0 -38
- package/src/components/dependencies/utils.ts +0 -261
- package/src/components/metadata-editors/PriorityEditor.tsx +0 -89
- package/src/components/metadata-editors/StatusEditor.tsx +0 -85
- package/src/components/metadata-editors/TagsEditor.tsx +0 -207
- package/src/components/projects/CreateProjectDialog.tsx +0 -162
- package/src/components/projects/DirectoryPicker.tsx +0 -182
- package/src/components/shared/BackToTop.tsx +0 -39
- package/src/components/shared/ColorPicker.tsx +0 -68
- package/src/components/shared/EmptyState.tsx +0 -35
- package/src/components/shared/ErrorBoundary.tsx +0 -79
- package/src/components/shared/PageHeader.tsx +0 -23
- package/src/components/shared/PageTransition.tsx +0 -40
- package/src/components/shared/ProjectAvatar.tsx +0 -107
- package/src/components/shared/Skeletons.tsx +0 -184
- package/src/components/spec-detail/EditableMetadata.tsx +0 -129
- package/src/components/spec-detail/MarkdownRenderer.tsx +0 -47
- package/src/components/spec-detail/TableOfContents.tsx +0 -150
- package/src/components/specs/BoardView.tsx +0 -204
- package/src/components/specs/ListView.tsx +0 -62
- package/src/components/specs/SpecsFilters.tsx +0 -190
- package/src/contexts/KeyboardShortcutsContext.tsx +0 -95
- package/src/contexts/LayoutContext.tsx +0 -45
- package/src/contexts/ProjectContext.tsx +0 -163
- package/src/contexts/ThemeContext.tsx +0 -90
- package/src/contexts/index.ts +0 -7
- package/src/hooks/useKeyboardShortcuts.ts +0 -87
- package/src/index.css +0 -624
- package/src/lib/api.ts +0 -72
- package/src/lib/backend-adapter.ts +0 -382
- package/src/lib/date-utils.ts +0 -122
- package/src/lib/i18n.test.ts +0 -57
- package/src/lib/i18n.ts +0 -51
- package/src/lib/markdown-utils.ts +0 -38
- package/src/lib/sub-spec-utils.ts +0 -166
- package/src/lib/utils.ts +0 -6
- package/src/locales/en/common.json +0 -660
- package/src/locales/en/errors.json +0 -20
- package/src/locales/en/help.json +0 -8
- package/src/locales/zh-CN/common.json +0 -660
- package/src/locales/zh-CN/errors.json +0 -20
- package/src/locales/zh-CN/help.json +0 -8
- package/src/main.tsx +0 -12
- package/src/pages/ContextPage.tsx +0 -111
- package/src/pages/DashboardPage.tsx +0 -97
- package/src/pages/DependenciesPage.tsx +0 -881
- package/src/pages/ProjectsPage.tsx +0 -432
- package/src/pages/SpecDetailPage.tsx +0 -592
- package/src/pages/SpecsPage.tsx +0 -319
- package/src/pages/StatsPage.tsx +0 -307
- package/src/router/projectRoutes.tsx +0 -36
- package/src/router.tsx +0 -33
- package/src/test/setup.ts +0 -39
- package/src/types/api.ts +0 -185
- package/tailwind.config.ts +0 -57
- package/tsconfig.app.json +0 -29
- package/tsconfig.json +0 -7
- package/tsconfig.node.json +0 -26
- package/tsconfig.tsbuildinfo +0 -1
- package/vite.config.ts +0 -27
- package/vitest.config.ts +0 -18
- /package/{public → dist}/favicon.ico +0 -0
- /package/{public → dist}/github-mark-white.svg +0 -0
- /package/{public → dist}/github-mark.svg +0 -0
- /package/{public → dist}/logo-dark-bg.svg +0 -0
- /package/{public → dist}/logo-with-bg.svg +0 -0
- /package/{public → dist}/logo.svg +0 -0
- /package/{public → dist}/vite.svg +0 -0
|
@@ -1,881 +0,0 @@
|
|
|
1
|
-
import * as React from 'react';
|
|
2
|
-
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
|
3
|
-
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import { Check, ChevronsUpDown, X } from 'lucide-react';
|
|
5
|
-
import {
|
|
6
|
-
Button,
|
|
7
|
-
Command,
|
|
8
|
-
CommandEmpty,
|
|
9
|
-
CommandGroup,
|
|
10
|
-
CommandInput,
|
|
11
|
-
CommandItem,
|
|
12
|
-
CommandList,
|
|
13
|
-
Popover,
|
|
14
|
-
PopoverContent,
|
|
15
|
-
PopoverTrigger,
|
|
16
|
-
} from '@leanspec/ui-components';
|
|
17
|
-
import ReactFlow, {
|
|
18
|
-
Background,
|
|
19
|
-
Controls,
|
|
20
|
-
type Edge,
|
|
21
|
-
MiniMap,
|
|
22
|
-
MarkerType,
|
|
23
|
-
type Node,
|
|
24
|
-
Position,
|
|
25
|
-
type ReactFlowInstance,
|
|
26
|
-
} from 'reactflow';
|
|
27
|
-
import 'reactflow/dist/style.css';
|
|
28
|
-
import { cn } from '../lib/utils';
|
|
29
|
-
import { api } from '../lib/api';
|
|
30
|
-
import type { DependencyGraph } from '../types/api';
|
|
31
|
-
import { useProject } from '../contexts';
|
|
32
|
-
|
|
33
|
-
import { nodeTypes } from '../components/dependencies/SpecNode';
|
|
34
|
-
import { SpecSidebar } from '../components/dependencies/SpecSidebar';
|
|
35
|
-
import { getConnectionDepths, layoutGraph } from '../components/dependencies/utils';
|
|
36
|
-
import { DEPENDS_ON_COLOR, toneBgColors } from '../components/dependencies/constants';
|
|
37
|
-
import type { SpecNodeData, GraphTone, FocusedNodeDetails, ConnectionStats } from '../components/dependencies/types';
|
|
38
|
-
import { PageHeader } from '../components/shared/PageHeader';
|
|
39
|
-
|
|
40
|
-
export function DependenciesPage() {
|
|
41
|
-
const { specName, projectId } = useParams<{ specName?: string; projectId?: string }>();
|
|
42
|
-
const navigate = useNavigate();
|
|
43
|
-
const [searchParams, setSearchParams] = useSearchParams();
|
|
44
|
-
const { t } = useTranslation();
|
|
45
|
-
const { currentProject, loading: projectLoading } = useProject();
|
|
46
|
-
const projectReady = !projectId || currentProject?.id === projectId;
|
|
47
|
-
|
|
48
|
-
const specParam = searchParams.get('spec');
|
|
49
|
-
const basePath = projectId ? `/projects/${projectId}` : '/projects/default';
|
|
50
|
-
|
|
51
|
-
// Helper to generate project-scoped URLs
|
|
52
|
-
const getSpecUrl = React.useCallback((specNumber: number | string) => {
|
|
53
|
-
return `${basePath}/specs/${specNumber}`;
|
|
54
|
-
}, [basePath]);
|
|
55
|
-
|
|
56
|
-
const [data, setData] = React.useState<DependencyGraph | null>(null);
|
|
57
|
-
const [loading, setLoading] = React.useState(true);
|
|
58
|
-
const [error, setError] = React.useState<string | null>(null);
|
|
59
|
-
const [instance, setInstance] = React.useState<ReactFlowInstance | null>(null);
|
|
60
|
-
const [showStandalone, setShowStandalone] = React.useState(false);
|
|
61
|
-
const [statusFilter, setStatusFilter] = React.useState<string[]>([]);
|
|
62
|
-
const [focusedNodeId, setFocusedNodeId] = React.useState<string | null>(null);
|
|
63
|
-
const [viewMode, setViewMode] = React.useState<'graph' | 'focus'>('graph');
|
|
64
|
-
const [isCompact, setIsCompact] = React.useState(false);
|
|
65
|
-
const [selectorOpen, setSelectorOpen] = React.useState(false);
|
|
66
|
-
const [selectorQuery, setSelectorQuery] = React.useState('');
|
|
67
|
-
|
|
68
|
-
// Track if we've completed initial URL-to-state sync
|
|
69
|
-
const initialSyncComplete = React.useRef(false);
|
|
70
|
-
// Track the expected focusedNodeId after URL initialization (to avoid URL update race)
|
|
71
|
-
const initialFocusedNodeId = React.useRef<string | null>(null);
|
|
72
|
-
|
|
73
|
-
// Load data
|
|
74
|
-
React.useEffect(() => {
|
|
75
|
-
if (!projectReady || projectLoading) return;
|
|
76
|
-
|
|
77
|
-
setLoading(true);
|
|
78
|
-
api.getDependencies(specName)
|
|
79
|
-
.then((responseData) => {
|
|
80
|
-
setData(responseData);
|
|
81
|
-
setIsCompact(responseData.nodes.length > 30);
|
|
82
|
-
})
|
|
83
|
-
.catch((err) => setError(err instanceof Error ? err.message : t('errors:loadingError')))
|
|
84
|
-
.finally(() => setLoading(false));
|
|
85
|
-
}, [projectLoading, projectReady, specName, t]);
|
|
86
|
-
|
|
87
|
-
// Initialize focused node from URL param on mount
|
|
88
|
-
React.useEffect(() => {
|
|
89
|
-
if (data && !initialSyncComplete.current) {
|
|
90
|
-
if (specParam) {
|
|
91
|
-
const node = data.nodes.find((n) => n.number.toString() === specParam);
|
|
92
|
-
if (node) {
|
|
93
|
-
initialFocusedNodeId.current = node.id;
|
|
94
|
-
setFocusedNodeId(node.id);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
initialSyncComplete.current = true;
|
|
98
|
-
}
|
|
99
|
-
}, [specParam, data]);
|
|
100
|
-
|
|
101
|
-
// Sync URL with focused node state
|
|
102
|
-
React.useEffect(() => {
|
|
103
|
-
if (!data) return;
|
|
104
|
-
// Skip if initial sync hasn't completed
|
|
105
|
-
if (!initialSyncComplete.current) return;
|
|
106
|
-
|
|
107
|
-
// Skip if this is the initial focusedNodeId set from URL
|
|
108
|
-
if (initialFocusedNodeId.current !== null) {
|
|
109
|
-
if (focusedNodeId === initialFocusedNodeId.current) {
|
|
110
|
-
// This is the initial sync, clear the ref but don't update URL
|
|
111
|
-
initialFocusedNodeId.current = null;
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
// focusedNodeId changed to something else, clear the ref
|
|
115
|
-
initialFocusedNodeId.current = null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const focusedNode = focusedNodeId ? data.nodes.find((n) => n.id === focusedNodeId) : null;
|
|
119
|
-
const newSpecParam = focusedNode ? focusedNode.number.toString() : null;
|
|
120
|
-
|
|
121
|
-
// Only update if different from current URL
|
|
122
|
-
if (newSpecParam !== specParam) {
|
|
123
|
-
const params = new URLSearchParams(searchParams);
|
|
124
|
-
if (newSpecParam) {
|
|
125
|
-
params.set('spec', newSpecParam);
|
|
126
|
-
} else {
|
|
127
|
-
params.delete('spec');
|
|
128
|
-
}
|
|
129
|
-
setSearchParams(params, { replace: true });
|
|
130
|
-
}
|
|
131
|
-
}, [focusedNodeId, data, specParam, searchParams, setSearchParams]);
|
|
132
|
-
|
|
133
|
-
// Only use dependsOn edges (DAG only)
|
|
134
|
-
const dependsOnEdges = React.useMemo(
|
|
135
|
-
() => (data?.edges || []).filter((e) => e.type === 'dependsOn'),
|
|
136
|
-
[data?.edges]
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
const adjacencyMaps = React.useMemo(() => {
|
|
140
|
-
const upstream = new Map<string, Set<string>>();
|
|
141
|
-
const downstream = new Map<string, Set<string>>();
|
|
142
|
-
|
|
143
|
-
dependsOnEdges.forEach((e) => {
|
|
144
|
-
if (!upstream.has(e.source)) upstream.set(e.source, new Set());
|
|
145
|
-
upstream.get(e.source)!.add(e.target);
|
|
146
|
-
|
|
147
|
-
if (!downstream.has(e.target)) downstream.set(e.target, new Set());
|
|
148
|
-
downstream.get(e.target)!.add(e.source);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
return { upstream, downstream };
|
|
152
|
-
}, [dependsOnEdges]);
|
|
153
|
-
|
|
154
|
-
// Get connection depths for focused node (all transitive deps)
|
|
155
|
-
const connectionDepths = React.useMemo(() => {
|
|
156
|
-
if (!focusedNodeId) return null;
|
|
157
|
-
return getConnectionDepths(focusedNodeId, dependsOnEdges, Infinity);
|
|
158
|
-
}, [focusedNodeId, dependsOnEdges]);
|
|
159
|
-
|
|
160
|
-
React.useEffect(() => {
|
|
161
|
-
if (!focusedNodeId && viewMode === 'focus') {
|
|
162
|
-
setViewMode('graph');
|
|
163
|
-
}
|
|
164
|
-
}, [focusedNodeId, viewMode]);
|
|
165
|
-
|
|
166
|
-
// Helper to get all transitive IDs
|
|
167
|
-
const getAllTransitiveIds = React.useCallback((startId: string, adjacencyMap: Map<string, Set<string>>) => {
|
|
168
|
-
const visited = new Set<string>();
|
|
169
|
-
const queue = [startId];
|
|
170
|
-
while (queue.length > 0) {
|
|
171
|
-
const id = queue.shift()!;
|
|
172
|
-
const neighbors = adjacencyMap.get(id);
|
|
173
|
-
if (neighbors) {
|
|
174
|
-
neighbors.forEach(n => {
|
|
175
|
-
if (!visited.has(n)) {
|
|
176
|
-
visited.add(n);
|
|
177
|
-
queue.push(n);
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return visited;
|
|
183
|
-
}, []);
|
|
184
|
-
|
|
185
|
-
// Get detailed info for focused node (for sidebar)
|
|
186
|
-
const focusedNodeDetails = React.useMemo((): FocusedNodeDetails | null => {
|
|
187
|
-
if (!focusedNodeId || !data) return null;
|
|
188
|
-
const node = data.nodes.find((n) => n.id === focusedNodeId);
|
|
189
|
-
if (!node) return null;
|
|
190
|
-
|
|
191
|
-
const nodeMap = new Map(data.nodes.map((n) => [n.id, n]));
|
|
192
|
-
|
|
193
|
-
// BFS to get all upstream specs grouped by depth
|
|
194
|
-
const getTransitiveDeps = (
|
|
195
|
-
startId: string,
|
|
196
|
-
adjacencyMap: Map<string, Set<string>>
|
|
197
|
-
): { depth: number; specs: typeof data.nodes }[] => {
|
|
198
|
-
const visited = new Set<string>([startId]);
|
|
199
|
-
const result: { depth: number; specs: typeof data.nodes }[] = [];
|
|
200
|
-
let currentLevel = new Set([startId]);
|
|
201
|
-
let depth = 1;
|
|
202
|
-
|
|
203
|
-
while (currentLevel.size > 0) {
|
|
204
|
-
const nextLevel = new Set<string>();
|
|
205
|
-
const specsAtDepth: typeof data.nodes = [];
|
|
206
|
-
|
|
207
|
-
currentLevel.forEach((nodeId) => {
|
|
208
|
-
const neighbors = adjacencyMap.get(nodeId);
|
|
209
|
-
if (neighbors) {
|
|
210
|
-
neighbors.forEach((neighborId) => {
|
|
211
|
-
if (!visited.has(neighborId)) {
|
|
212
|
-
visited.add(neighborId);
|
|
213
|
-
nextLevel.add(neighborId);
|
|
214
|
-
const spec = nodeMap.get(neighborId);
|
|
215
|
-
if (spec) specsAtDepth.push(spec);
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
if (specsAtDepth.length > 0) {
|
|
222
|
-
result.push({ depth, specs: specsAtDepth });
|
|
223
|
-
}
|
|
224
|
-
currentLevel = nextLevel;
|
|
225
|
-
depth++;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return result;
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
const upstream = getTransitiveDeps(focusedNodeId, adjacencyMaps.upstream);
|
|
232
|
-
const downstream = getTransitiveDeps(focusedNodeId, adjacencyMaps.downstream);
|
|
233
|
-
|
|
234
|
-
return { node, upstream, downstream };
|
|
235
|
-
}, [focusedNodeId, data, adjacencyMaps]);
|
|
236
|
-
|
|
237
|
-
// Build the graph
|
|
238
|
-
const graph = React.useMemo(() => {
|
|
239
|
-
if (!data) return { nodes: [], edges: [] };
|
|
240
|
-
|
|
241
|
-
const isFocusMode = viewMode === 'focus' && !!focusedNodeId;
|
|
242
|
-
|
|
243
|
-
if (isFocusMode && focusedNodeId) {
|
|
244
|
-
const upstreamIds = getAllTransitiveIds(focusedNodeId, adjacencyMaps.upstream);
|
|
245
|
-
const downstreamIds = getAllTransitiveIds(focusedNodeId, adjacencyMaps.downstream);
|
|
246
|
-
const visibleNodeIds = new Set<string>([focusedNodeId, ...upstreamIds, ...downstreamIds]);
|
|
247
|
-
|
|
248
|
-
const visibleNodes = data.nodes.filter((n) => visibleNodeIds.has(n.id));
|
|
249
|
-
|
|
250
|
-
const nodes: Node<SpecNodeData>[] = visibleNodes.map((node) => {
|
|
251
|
-
const isFocused = focusedNodeId === node.id;
|
|
252
|
-
const connectionDepth = isFocused ? 0 : connectionDepths?.get(node.id);
|
|
253
|
-
|
|
254
|
-
return {
|
|
255
|
-
id: node.id,
|
|
256
|
-
type: 'specNode',
|
|
257
|
-
data: {
|
|
258
|
-
label: node.name,
|
|
259
|
-
shortLabel: node.name.length > 14 ? node.name.slice(0, 12) + '…' : node.name,
|
|
260
|
-
badge: node.status === 'in-progress' ? 'WIP' : node.status.slice(0, 3).toUpperCase(),
|
|
261
|
-
number: node.number,
|
|
262
|
-
tone: node.status as GraphTone,
|
|
263
|
-
priority: node.priority,
|
|
264
|
-
href: getSpecUrl(node.number),
|
|
265
|
-
interactive: true,
|
|
266
|
-
isFocused,
|
|
267
|
-
connectionDepth,
|
|
268
|
-
isDimmed: false,
|
|
269
|
-
isCompact,
|
|
270
|
-
isSecondary: false,
|
|
271
|
-
},
|
|
272
|
-
position: { x: 0, y: 0 },
|
|
273
|
-
draggable: true,
|
|
274
|
-
selectable: true,
|
|
275
|
-
sourcePosition: Position.Right,
|
|
276
|
-
targetPosition: Position.Left,
|
|
277
|
-
};
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
const edges: Edge[] = dependsOnEdges
|
|
281
|
-
.filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target))
|
|
282
|
-
.map((edge) => {
|
|
283
|
-
const isHighlighted = edge.source === focusedNodeId || edge.target === focusedNodeId;
|
|
284
|
-
|
|
285
|
-
return {
|
|
286
|
-
id: `${edge.source}-${edge.target}-dependsOn`,
|
|
287
|
-
source: edge.source,
|
|
288
|
-
target: edge.target,
|
|
289
|
-
type: 'smoothstep',
|
|
290
|
-
animated: isHighlighted,
|
|
291
|
-
markerEnd: {
|
|
292
|
-
type: MarkerType.ArrowClosed,
|
|
293
|
-
color: DEPENDS_ON_COLOR,
|
|
294
|
-
width: 18,
|
|
295
|
-
height: 18,
|
|
296
|
-
},
|
|
297
|
-
style: {
|
|
298
|
-
stroke: DEPENDS_ON_COLOR,
|
|
299
|
-
strokeWidth: isHighlighted ? 2.75 : 2,
|
|
300
|
-
opacity: 1,
|
|
301
|
-
},
|
|
302
|
-
};
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
return layoutGraph(nodes, edges, isCompact, false, {
|
|
306
|
-
mode: 'focus',
|
|
307
|
-
focusedNodeId,
|
|
308
|
-
upstreamIds,
|
|
309
|
-
downstreamIds,
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Primary nodes: those matching the status filter
|
|
314
|
-
const primaryNodes = data.nodes.filter(
|
|
315
|
-
(node) => statusFilter.length === 0 || statusFilter.includes(node.status)
|
|
316
|
-
);
|
|
317
|
-
const primaryNodeIds = new Set(primaryNodes.map((n) => n.id));
|
|
318
|
-
|
|
319
|
-
// Find all nodes in the critical path (connected to primary nodes via dependencies)
|
|
320
|
-
const criticalPathIds = new Set<string>(primaryNodeIds);
|
|
321
|
-
|
|
322
|
-
// BFS to find all connected nodes through dependencies
|
|
323
|
-
const queue = [...primaryNodeIds];
|
|
324
|
-
while (queue.length > 0) {
|
|
325
|
-
const nodeId = queue.shift()!;
|
|
326
|
-
dependsOnEdges.forEach((e) => {
|
|
327
|
-
// Check both directions - upstream and downstream dependencies
|
|
328
|
-
if (e.source === nodeId && !criticalPathIds.has(e.target)) {
|
|
329
|
-
criticalPathIds.add(e.target);
|
|
330
|
-
queue.push(e.target);
|
|
331
|
-
}
|
|
332
|
-
if (e.target === nodeId && !criticalPathIds.has(e.source)) {
|
|
333
|
-
criticalPathIds.add(e.source);
|
|
334
|
-
queue.push(e.source);
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Get all nodes in critical path
|
|
340
|
-
const criticalPathNodes = data.nodes.filter((n) => criticalPathIds.has(n.id));
|
|
341
|
-
|
|
342
|
-
// Filter edges to only those between critical path nodes
|
|
343
|
-
const filteredEdges = dependsOnEdges.filter(
|
|
344
|
-
(e) => criticalPathIds.has(e.source) && criticalPathIds.has(e.target)
|
|
345
|
-
);
|
|
346
|
-
|
|
347
|
-
// Filter to nodes with dependencies unless showStandalone
|
|
348
|
-
let visibleNodes = criticalPathNodes;
|
|
349
|
-
if (!showStandalone) {
|
|
350
|
-
const nodesWithDeps = new Set<string>();
|
|
351
|
-
filteredEdges.forEach((e) => {
|
|
352
|
-
nodesWithDeps.add(e.source);
|
|
353
|
-
nodesWithDeps.add(e.target);
|
|
354
|
-
});
|
|
355
|
-
visibleNodes = criticalPathNodes.filter((n) => nodesWithDeps.has(n.id));
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const visibleNodeIds = new Set(visibleNodes.map((n) => n.id));
|
|
359
|
-
|
|
360
|
-
// Track which nodes are "secondary" (shown due to critical path, not primary filter)
|
|
361
|
-
const secondaryNodeIds = new Set(
|
|
362
|
-
[...visibleNodeIds].filter((id) => !primaryNodeIds.has(id))
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
const nodes: Node<SpecNodeData>[] = visibleNodes.map((node) => {
|
|
366
|
-
const isFocused = focusedNodeId === node.id;
|
|
367
|
-
const isSecondary = secondaryNodeIds.has(node.id);
|
|
368
|
-
|
|
369
|
-
let connectionDepth: number | undefined;
|
|
370
|
-
let isDimmed = false;
|
|
371
|
-
|
|
372
|
-
if (focusedNodeId) {
|
|
373
|
-
connectionDepth = connectionDepths?.get(node.id);
|
|
374
|
-
isDimmed = connectionDepth === undefined;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
return {
|
|
378
|
-
id: node.id,
|
|
379
|
-
type: 'specNode',
|
|
380
|
-
data: {
|
|
381
|
-
label: node.name,
|
|
382
|
-
shortLabel: node.name.length > 14 ? node.name.slice(0, 12) + '…' : node.name,
|
|
383
|
-
badge: node.status === 'in-progress' ? 'WIP' : node.status.slice(0, 3).toUpperCase(),
|
|
384
|
-
number: node.number,
|
|
385
|
-
tone: node.status as GraphTone,
|
|
386
|
-
priority: node.priority,
|
|
387
|
-
href: getSpecUrl(node.number),
|
|
388
|
-
interactive: true,
|
|
389
|
-
isFocused,
|
|
390
|
-
connectionDepth,
|
|
391
|
-
isDimmed,
|
|
392
|
-
isCompact,
|
|
393
|
-
isSecondary,
|
|
394
|
-
},
|
|
395
|
-
position: { x: 0, y: 0 },
|
|
396
|
-
draggable: true,
|
|
397
|
-
selectable: true,
|
|
398
|
-
sourcePosition: Position.Right,
|
|
399
|
-
targetPosition: Position.Left,
|
|
400
|
-
};
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
const edges: Edge[] = filteredEdges
|
|
404
|
-
.filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target))
|
|
405
|
-
.map((edge) => {
|
|
406
|
-
let isHighlighted = true;
|
|
407
|
-
let opacity = 0.7;
|
|
408
|
-
|
|
409
|
-
if (focusedNodeId) {
|
|
410
|
-
const sourceDepth = connectionDepths?.get(edge.source);
|
|
411
|
-
const targetDepth = connectionDepths?.get(edge.target);
|
|
412
|
-
isHighlighted =
|
|
413
|
-
sourceDepth !== undefined &&
|
|
414
|
-
targetDepth !== undefined &&
|
|
415
|
-
(sourceDepth === 0 || targetDepth === 0);
|
|
416
|
-
opacity = isHighlighted
|
|
417
|
-
? 1
|
|
418
|
-
: sourceDepth !== undefined && targetDepth !== undefined
|
|
419
|
-
? 0.4
|
|
420
|
-
: 0.1;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
return {
|
|
424
|
-
id: `${edge.source}-${edge.target}-dependsOn`,
|
|
425
|
-
source: edge.source,
|
|
426
|
-
target: edge.target,
|
|
427
|
-
type: 'smoothstep',
|
|
428
|
-
animated: isHighlighted && focusedNodeId !== null,
|
|
429
|
-
markerEnd: {
|
|
430
|
-
type: MarkerType.ArrowClosed,
|
|
431
|
-
color: DEPENDS_ON_COLOR,
|
|
432
|
-
width: 18,
|
|
433
|
-
height: 18,
|
|
434
|
-
},
|
|
435
|
-
style: {
|
|
436
|
-
stroke: DEPENDS_ON_COLOR,
|
|
437
|
-
strokeWidth: isHighlighted ? 2.5 : 1.5,
|
|
438
|
-
opacity,
|
|
439
|
-
},
|
|
440
|
-
};
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
return layoutGraph(nodes, edges, isCompact, showStandalone, { mode: 'graph' });
|
|
444
|
-
}, [
|
|
445
|
-
data,
|
|
446
|
-
dependsOnEdges,
|
|
447
|
-
statusFilter,
|
|
448
|
-
focusedNodeId,
|
|
449
|
-
connectionDepths,
|
|
450
|
-
isCompact,
|
|
451
|
-
showStandalone,
|
|
452
|
-
adjacencyMaps,
|
|
453
|
-
viewMode,
|
|
454
|
-
getSpecUrl,
|
|
455
|
-
getAllTransitiveIds,
|
|
456
|
-
]);
|
|
457
|
-
|
|
458
|
-
// Connection stats
|
|
459
|
-
const connectionStats = React.useMemo((): ConnectionStats => {
|
|
460
|
-
if (!data) return { connected: 0, standalone: 0 };
|
|
461
|
-
|
|
462
|
-
const nodesWithDeps = new Set<string>();
|
|
463
|
-
dependsOnEdges.forEach((e) => {
|
|
464
|
-
nodesWithDeps.add(e.source);
|
|
465
|
-
nodesWithDeps.add(e.target);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
return {
|
|
469
|
-
connected: nodesWithDeps.size,
|
|
470
|
-
standalone: data.nodes.length - nodesWithDeps.size,
|
|
471
|
-
};
|
|
472
|
-
}, [dependsOnEdges, data]);
|
|
473
|
-
|
|
474
|
-
const statusCounts = React.useMemo(() => {
|
|
475
|
-
if (!data) return {};
|
|
476
|
-
const counts: Record<string, number> = {};
|
|
477
|
-
data.nodes.forEach((node) => {
|
|
478
|
-
counts[node.status] = (counts[node.status] || 0) + 1;
|
|
479
|
-
});
|
|
480
|
-
return counts;
|
|
481
|
-
}, [data]);
|
|
482
|
-
|
|
483
|
-
const handleInit = React.useCallback((flowInstance: ReactFlowInstance) => {
|
|
484
|
-
setInstance(flowInstance);
|
|
485
|
-
requestAnimationFrame(() => {
|
|
486
|
-
flowInstance.fitView({ padding: 0.15, duration: 300 });
|
|
487
|
-
});
|
|
488
|
-
}, []);
|
|
489
|
-
|
|
490
|
-
React.useEffect(() => {
|
|
491
|
-
if (!instance) return;
|
|
492
|
-
const timer = setTimeout(() => {
|
|
493
|
-
instance.fitView({ padding: 0.15, duration: 300 });
|
|
494
|
-
}, 50);
|
|
495
|
-
return () => clearTimeout(timer);
|
|
496
|
-
}, [instance, graph, statusFilter, showStandalone]);
|
|
497
|
-
|
|
498
|
-
// Center on focused node when set from URL param
|
|
499
|
-
React.useEffect(() => {
|
|
500
|
-
if (!instance || !focusedNodeId || !specParam) return;
|
|
501
|
-
const node = graph.nodes.find((n) => n.id === focusedNodeId);
|
|
502
|
-
if (node) {
|
|
503
|
-
const timer = setTimeout(() => {
|
|
504
|
-
instance.setCenter(node.position.x + 80, node.position.y + 30, {
|
|
505
|
-
duration: 400,
|
|
506
|
-
zoom: 1,
|
|
507
|
-
});
|
|
508
|
-
}, 400);
|
|
509
|
-
return () => clearTimeout(timer);
|
|
510
|
-
}
|
|
511
|
-
}, [instance, focusedNodeId, specParam, graph.nodes]);
|
|
512
|
-
|
|
513
|
-
// Filter specs for selector dropdown
|
|
514
|
-
const filteredSpecs = React.useMemo(() => {
|
|
515
|
-
if (!data) return [];
|
|
516
|
-
if (!selectorQuery.trim()) return data.nodes.slice(0, 15);
|
|
517
|
-
const q = selectorQuery.toLowerCase();
|
|
518
|
-
return data.nodes
|
|
519
|
-
.filter(
|
|
520
|
-
(n) =>
|
|
521
|
-
n.name.toLowerCase().includes(q) ||
|
|
522
|
-
n.number.toString().includes(q) ||
|
|
523
|
-
n.tags.some((t) => t.toLowerCase().includes(q))
|
|
524
|
-
)
|
|
525
|
-
.slice(0, 15);
|
|
526
|
-
}, [data, selectorQuery]);
|
|
527
|
-
|
|
528
|
-
// Get focused spec name for display
|
|
529
|
-
const focusedSpec = React.useMemo(
|
|
530
|
-
() => (focusedNodeId && data ? data.nodes.find((n) => n.id === focusedNodeId) : null),
|
|
531
|
-
[focusedNodeId, data]
|
|
532
|
-
);
|
|
533
|
-
|
|
534
|
-
const handleNodeClick = React.useCallback(
|
|
535
|
-
(event: React.MouseEvent, node: Node<SpecNodeData>) => {
|
|
536
|
-
if (!node?.data) return;
|
|
537
|
-
if (event.detail === 2 && node.data.href) {
|
|
538
|
-
navigate(node.data.href);
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
setFocusedNodeId((prev) => (prev === node.id ? null : node.id));
|
|
542
|
-
},
|
|
543
|
-
[navigate]
|
|
544
|
-
);
|
|
545
|
-
|
|
546
|
-
const handlePaneClick = React.useCallback(() => {
|
|
547
|
-
setFocusedNodeId(null);
|
|
548
|
-
}, []);
|
|
549
|
-
|
|
550
|
-
const toggleStatus = (status: string) => {
|
|
551
|
-
setStatusFilter((prev) =>
|
|
552
|
-
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
|
553
|
-
);
|
|
554
|
-
setFocusedNodeId(null);
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
const clearFilters = () => {
|
|
558
|
-
setStatusFilter([]);
|
|
559
|
-
setFocusedNodeId(null);
|
|
560
|
-
setSelectorQuery('');
|
|
561
|
-
};
|
|
562
|
-
|
|
563
|
-
const handleSelectSpec = (specId: string) => {
|
|
564
|
-
setFocusedNodeId(specId);
|
|
565
|
-
setSelectorOpen(false);
|
|
566
|
-
setSelectorQuery('');
|
|
567
|
-
// Center on the selected node
|
|
568
|
-
if (instance) {
|
|
569
|
-
const node = graph.nodes.find((n) => n.id === specId);
|
|
570
|
-
if (node) {
|
|
571
|
-
instance.setCenter(node.position.x + 80, node.position.y + 30, {
|
|
572
|
-
duration: 400,
|
|
573
|
-
zoom: 1,
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
};
|
|
578
|
-
|
|
579
|
-
const hasFilters = statusFilter.length > 0 || focusedNodeId;
|
|
580
|
-
|
|
581
|
-
if (loading) {
|
|
582
|
-
return (
|
|
583
|
-
<div className="container mx-auto p-6">
|
|
584
|
-
<div className="flex items-center justify-center h-[calc(100vh-10rem)]">
|
|
585
|
-
<p className="text-muted-foreground">{t('dependenciesPage.state.loading')}</p>
|
|
586
|
-
</div>
|
|
587
|
-
</div>
|
|
588
|
-
);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
if (error || !data) {
|
|
592
|
-
return (
|
|
593
|
-
<div className="container mx-auto p-6">
|
|
594
|
-
<div className="flex items-center justify-center h-[calc(100vh-10rem)]">
|
|
595
|
-
<div className="text-center">
|
|
596
|
-
<p className="text-lg font-semibold text-destructive mb-2">{t('dependenciesPage.state.errorTitle')}</p>
|
|
597
|
-
<p className="text-sm text-muted-foreground">{error || t('dependenciesPage.state.errorDescription')}</p>
|
|
598
|
-
</div>
|
|
599
|
-
</div>
|
|
600
|
-
</div>
|
|
601
|
-
);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
if (data.nodes.length === 0) {
|
|
605
|
-
return (
|
|
606
|
-
<div className="container mx-auto p-6">
|
|
607
|
-
<div className="rounded-lg border border-border bg-muted/30 p-8 text-center">
|
|
608
|
-
<h2 className="text-xl font-semibold mb-2">{t('dependenciesPage.empty.noDependencies')}</h2>
|
|
609
|
-
<p className="text-muted-foreground">{t('dependenciesPage.empty.noDependenciesDescription')}</p>
|
|
610
|
-
</div>
|
|
611
|
-
</div>
|
|
612
|
-
);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
return (
|
|
616
|
-
<div className="container mx-auto p-6 h-[calc(100vh-7rem)]">
|
|
617
|
-
<div className="flex h-full flex-col gap-4">
|
|
618
|
-
<PageHeader
|
|
619
|
-
title={t('dependenciesPage.title')}
|
|
620
|
-
description={t('dependenciesPage.description')}
|
|
621
|
-
actions={(
|
|
622
|
-
<Popover open={selectorOpen} onOpenChange={setSelectorOpen}>
|
|
623
|
-
<PopoverTrigger asChild>
|
|
624
|
-
<Button
|
|
625
|
-
variant="outline"
|
|
626
|
-
role="combobox"
|
|
627
|
-
aria-expanded={selectorOpen}
|
|
628
|
-
className={cn(
|
|
629
|
-
'w-[240px] h-9 justify-between px-3 text-xs',
|
|
630
|
-
focusedNodeId && 'border-primary/60 bg-primary/10 text-foreground'
|
|
631
|
-
)}
|
|
632
|
-
>
|
|
633
|
-
{focusedSpec ? (
|
|
634
|
-
<span className="truncate flex items-center">
|
|
635
|
-
<span className="text-muted-foreground mr-2 font-mono">#{focusedSpec.number.toString().padStart(3, '0')}</span>
|
|
636
|
-
<span className="truncate">{focusedSpec.name}</span>
|
|
637
|
-
</span>
|
|
638
|
-
) : (
|
|
639
|
-
<span className="text-muted-foreground font-normal">{t('dependenciesPage.selector.placeholder')}</span>
|
|
640
|
-
)}
|
|
641
|
-
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
642
|
-
</Button>
|
|
643
|
-
</PopoverTrigger>
|
|
644
|
-
<PopoverContent className="w-[300px] p-0" align="end">
|
|
645
|
-
<Command shouldFilter={false}>
|
|
646
|
-
<CommandInput
|
|
647
|
-
placeholder={t('dependenciesPage.selector.filterPlaceholder')}
|
|
648
|
-
value={selectorQuery}
|
|
649
|
-
onValueChange={setSelectorQuery}
|
|
650
|
-
className="text-xs"
|
|
651
|
-
/>
|
|
652
|
-
<CommandList>
|
|
653
|
-
<CommandEmpty className="py-2 text-center text-xs text-muted-foreground">
|
|
654
|
-
{t('dependenciesPage.selector.empty')}
|
|
655
|
-
</CommandEmpty>
|
|
656
|
-
<CommandGroup>
|
|
657
|
-
{focusedNodeId && (
|
|
658
|
-
<CommandItem
|
|
659
|
-
onSelect={() => {
|
|
660
|
-
setFocusedNodeId(null);
|
|
661
|
-
setSelectorOpen(false);
|
|
662
|
-
setSelectorQuery('');
|
|
663
|
-
}}
|
|
664
|
-
className="text-muted-foreground"
|
|
665
|
-
>
|
|
666
|
-
<X className="mr-2 h-3.5 w-3.5" />
|
|
667
|
-
{t('dependenciesPage.selector.clearSelection')}
|
|
668
|
-
</CommandItem>
|
|
669
|
-
)}
|
|
670
|
-
{filteredSpecs.map((spec) => (
|
|
671
|
-
<CommandItem
|
|
672
|
-
key={spec.id}
|
|
673
|
-
value={spec.id}
|
|
674
|
-
onSelect={() => handleSelectSpec(spec.id)}
|
|
675
|
-
>
|
|
676
|
-
<span className="text-muted-foreground font-mono mr-2">#{spec.number.toString().padStart(3, '0')}</span>
|
|
677
|
-
<span className="truncate flex-1">{spec.name}</span>
|
|
678
|
-
<span
|
|
679
|
-
className={cn(
|
|
680
|
-
'text-[9px] px-1 py-0.5 rounded uppercase font-medium ml-2 shrink-0',
|
|
681
|
-
spec.status === 'planned' && 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
|
|
682
|
-
spec.status === 'in-progress' && 'bg-orange-500/20 text-orange-600 dark:text-orange-400',
|
|
683
|
-
spec.status === 'complete' && 'bg-green-500/20 text-green-600 dark:text-green-400',
|
|
684
|
-
spec.status === 'archived' && 'bg-gray-500/20 text-gray-500 dark:text-gray-400'
|
|
685
|
-
)}
|
|
686
|
-
>
|
|
687
|
-
{spec.status === 'in-progress' ? 'WIP' : spec.status.slice(0, 3)}
|
|
688
|
-
</span>
|
|
689
|
-
<div
|
|
690
|
-
className={cn(
|
|
691
|
-
'mr-2 flex h-4 w-4 items-center justify-center',
|
|
692
|
-
focusedNodeId === spec.id ? 'opacity-100' : 'opacity-0'
|
|
693
|
-
)}
|
|
694
|
-
>
|
|
695
|
-
<Check className="h-4 w-4" />
|
|
696
|
-
</div>
|
|
697
|
-
</CommandItem>
|
|
698
|
-
))}
|
|
699
|
-
</CommandGroup>
|
|
700
|
-
</CommandList>
|
|
701
|
-
</Command>
|
|
702
|
-
</PopoverContent>
|
|
703
|
-
</Popover>
|
|
704
|
-
)}
|
|
705
|
-
/>
|
|
706
|
-
|
|
707
|
-
<div className="text-sm text-muted-foreground">
|
|
708
|
-
{connectionStats.connected > 0 ? (
|
|
709
|
-
<>
|
|
710
|
-
<span className="text-emerald-600 dark:text-emerald-400">
|
|
711
|
-
{t('dependenciesPage.header.summary.connected', { count: connectionStats.connected })}
|
|
712
|
-
</span>
|
|
713
|
-
{connectionStats.standalone > 0 && (
|
|
714
|
-
<>
|
|
715
|
-
{' • '}
|
|
716
|
-
<span className="text-muted-foreground">
|
|
717
|
-
{t('dependenciesPage.header.summary.standalone', { count: connectionStats.standalone })}
|
|
718
|
-
</span>
|
|
719
|
-
</>
|
|
720
|
-
)}
|
|
721
|
-
</>
|
|
722
|
-
) : (
|
|
723
|
-
<span>{t('dependenciesPage.header.summary.none')}</span>
|
|
724
|
-
)}
|
|
725
|
-
</div>
|
|
726
|
-
|
|
727
|
-
{/* Filters */}
|
|
728
|
-
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
|
729
|
-
{(['planned', 'in-progress', 'complete', 'archived'] as const).map((status) => {
|
|
730
|
-
const isActive = statusFilter.length === 0 || statusFilter.includes(status);
|
|
731
|
-
const label = t(`status.${status}`);
|
|
732
|
-
const count = statusCounts[status] || 0;
|
|
733
|
-
return (
|
|
734
|
-
<button
|
|
735
|
-
key={status}
|
|
736
|
-
onClick={() => toggleStatus(status)}
|
|
737
|
-
className={cn(
|
|
738
|
-
'rounded border px-2 py-1 font-medium transition-colors',
|
|
739
|
-
isActive && status === 'planned' && 'border-blue-500/60 bg-blue-500/20 text-blue-700 dark:text-blue-300',
|
|
740
|
-
isActive && status === 'in-progress' && 'border-orange-500/60 bg-orange-500/20 text-orange-700 dark:text-orange-300',
|
|
741
|
-
isActive && status === 'complete' && 'border-green-500/60 bg-green-500/20 text-green-700 dark:text-green-300',
|
|
742
|
-
isActive && status === 'archived' && 'border-gray-500/60 bg-gray-500/20 text-gray-600 dark:text-gray-300',
|
|
743
|
-
!isActive && 'border-border bg-background text-muted-foreground/40'
|
|
744
|
-
)}
|
|
745
|
-
>
|
|
746
|
-
{label}
|
|
747
|
-
<span className="ml-1 opacity-60">{t('dependenciesPage.filters.count', { count })}</span>
|
|
748
|
-
</button>
|
|
749
|
-
);
|
|
750
|
-
})}
|
|
751
|
-
|
|
752
|
-
<span className="h-3 w-px bg-border" />
|
|
753
|
-
|
|
754
|
-
<button
|
|
755
|
-
onClick={() => setShowStandalone(!showStandalone)}
|
|
756
|
-
className={cn(
|
|
757
|
-
'rounded border px-2 py-1 font-medium transition-colors',
|
|
758
|
-
showStandalone
|
|
759
|
-
? 'border-violet-500/60 bg-violet-500/20 text-violet-700 dark:text-violet-300'
|
|
760
|
-
: 'border-border bg-background hover:bg-accent text-muted-foreground'
|
|
761
|
-
)}
|
|
762
|
-
>
|
|
763
|
-
{t('dependenciesPage.filters.showStandalone', { count: connectionStats.standalone })}
|
|
764
|
-
</button>
|
|
765
|
-
|
|
766
|
-
<span className="h-3 w-px bg-border" />
|
|
767
|
-
|
|
768
|
-
<button
|
|
769
|
-
onClick={() => setIsCompact(!isCompact)}
|
|
770
|
-
className={cn(
|
|
771
|
-
'rounded border px-2 py-1 font-medium transition-colors',
|
|
772
|
-
isCompact
|
|
773
|
-
? 'border-primary/60 bg-primary/20 text-primary'
|
|
774
|
-
: 'border-border bg-background hover:bg-accent text-muted-foreground'
|
|
775
|
-
)}
|
|
776
|
-
>
|
|
777
|
-
{t('dependenciesPage.filters.compact')}
|
|
778
|
-
</button>
|
|
779
|
-
|
|
780
|
-
{focusedNodeId && (
|
|
781
|
-
<button
|
|
782
|
-
onClick={() => setViewMode((prev) => (prev === 'graph' ? 'focus' : 'graph'))}
|
|
783
|
-
className={cn(
|
|
784
|
-
'rounded border px-2 py-1 font-medium transition-colors',
|
|
785
|
-
viewMode === 'focus'
|
|
786
|
-
? 'border-primary/60 bg-primary/20 text-primary'
|
|
787
|
-
: 'border-border bg-background hover:bg-accent text-muted-foreground'
|
|
788
|
-
)}
|
|
789
|
-
>
|
|
790
|
-
{t('dependenciesPage.filters.focusMode')}
|
|
791
|
-
</button>
|
|
792
|
-
)}
|
|
793
|
-
|
|
794
|
-
{hasFilters && (
|
|
795
|
-
<>
|
|
796
|
-
<span className="h-3 w-px bg-border" />
|
|
797
|
-
<button
|
|
798
|
-
onClick={clearFilters}
|
|
799
|
-
className="rounded border border-red-500/40 bg-red-500/10 px-2 py-1 font-medium text-red-400 hover:bg-red-500/20"
|
|
800
|
-
>
|
|
801
|
-
{t('dependenciesPage.filters.clear')}
|
|
802
|
-
</button>
|
|
803
|
-
</>
|
|
804
|
-
)}
|
|
805
|
-
</div>
|
|
806
|
-
|
|
807
|
-
{/* Main content */}
|
|
808
|
-
<div className="flex flex-1 gap-3 min-h-0">
|
|
809
|
-
{/* Graph */}
|
|
810
|
-
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-gray-50 dark:bg-[#080c14]">
|
|
811
|
-
{graph.nodes.length > 0 ? (
|
|
812
|
-
<ReactFlow
|
|
813
|
-
nodes={graph.nodes}
|
|
814
|
-
edges={graph.edges}
|
|
815
|
-
nodeTypes={nodeTypes}
|
|
816
|
-
onInit={handleInit}
|
|
817
|
-
className="h-full w-full"
|
|
818
|
-
fitView
|
|
819
|
-
proOptions={{ hideAttribution: true }}
|
|
820
|
-
nodesDraggable
|
|
821
|
-
nodesConnectable={false}
|
|
822
|
-
elementsSelectable
|
|
823
|
-
panOnScroll
|
|
824
|
-
panOnDrag
|
|
825
|
-
zoomOnScroll
|
|
826
|
-
zoomOnPinch
|
|
827
|
-
minZoom={0.05}
|
|
828
|
-
maxZoom={2}
|
|
829
|
-
onNodeClick={handleNodeClick}
|
|
830
|
-
onPaneClick={handlePaneClick}
|
|
831
|
-
>
|
|
832
|
-
<Background gap={20} size={1} color="rgba(100, 116, 139, 0.06)" />
|
|
833
|
-
<Controls showInteractive={false} className="!bg-background/90 !border-border !rounded-md" />
|
|
834
|
-
<MiniMap
|
|
835
|
-
nodeColor={(node) => {
|
|
836
|
-
const d = node.data as SpecNodeData;
|
|
837
|
-
return toneBgColors[d.tone] || '#6b7280';
|
|
838
|
-
}}
|
|
839
|
-
maskColor="rgba(128, 128, 128, 0.6)"
|
|
840
|
-
className="!bg-white/95 dark:!bg-background/95 !border-border !rounded-md"
|
|
841
|
-
style={{ width: 120, height: 80 }}
|
|
842
|
-
pannable
|
|
843
|
-
zoomable
|
|
844
|
-
/>
|
|
845
|
-
</ReactFlow>
|
|
846
|
-
) : (
|
|
847
|
-
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
848
|
-
<div className="text-center">
|
|
849
|
-
<p className="text-sm font-medium">{t('dependenciesPage.empty.title')}</p>
|
|
850
|
-
<p className="text-xs mt-1">
|
|
851
|
-
{showStandalone
|
|
852
|
-
? t('dependenciesPage.empty.filters')
|
|
853
|
-
: t('dependenciesPage.empty.standaloneHint')}
|
|
854
|
-
</p>
|
|
855
|
-
</div>
|
|
856
|
-
</div>
|
|
857
|
-
)}
|
|
858
|
-
</div>
|
|
859
|
-
|
|
860
|
-
{/* Right Sidebar */}
|
|
861
|
-
<SpecSidebar
|
|
862
|
-
focusedDetails={focusedNodeDetails}
|
|
863
|
-
onSelectSpec={setFocusedNodeId}
|
|
864
|
-
onOpenSpec={(num) => navigate(getSpecUrl(num))}
|
|
865
|
-
/>
|
|
866
|
-
</div>
|
|
867
|
-
|
|
868
|
-
{/* Legend */}
|
|
869
|
-
<div className="flex flex-wrap items-center gap-4 text-[10px] text-muted-foreground">
|
|
870
|
-
<span className="inline-flex items-center gap-1.5">
|
|
871
|
-
<span className="inline-block h-0.5 w-6 bg-amber-400 rounded" />
|
|
872
|
-
{t('dependenciesPage.legend.dependsOn')}
|
|
873
|
-
</span>
|
|
874
|
-
<span className="text-muted-foreground/50 ml-auto">
|
|
875
|
-
{t('dependenciesPage.legend.instructions')}
|
|
876
|
-
</span>
|
|
877
|
-
</div>
|
|
878
|
-
</div>
|
|
879
|
-
</div>
|
|
880
|
-
);
|
|
881
|
-
}
|