@leanspec/ui 0.2.14 → 0.2.15
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 +126 -0
- package/dist/assets/_baseUniq-B6x_7o5y.js +1 -0
- package/dist/assets/arc-DZ27bDb2.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-VTQAQir-.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-BeZAaaB1.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-BnT3bg74.js +10 -0
- package/dist/assets/channel-BSVY_tOy.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-qtS73lje.js +1 -0
- package/dist/assets/chunk-55IACEB6-B41Ne73X.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-CRL0j0p8.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-BRa_G3mf.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-D_AT_wL5.js +15 -0
- package/dist/assets/chunk-QN33PNHL-Q1Nos5j_.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-DflSXVVh.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-B0OC-s8d.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-Dn0xX9IG.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-Dn0xX9IG.js +1 -0
- package/dist/assets/clone-C-KMhWbr.js +1 -0
- package/dist/assets/core-DV6XEvTN.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-CboCNDKn.js +1 -0
- package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
- package/dist/assets/dagre-6UL2VRFP-DOonQ6kf.js +4 -0
- package/dist/assets/diagram-PSM6KHXK-DPYPbSse.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-DfTIvQXt.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-Dl0bD_cb.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-C36i3Lze.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-BskiGL1V.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-BvEghcko.js +267 -0
- package/dist/assets/gitGraphDiagram-NY62KEGX-BEkcYMS3.js +65 -0
- package/dist/assets/graph-DfQs0Ukg.js +1 -0
- package/dist/assets/index-BQDji5Db.js +389 -0
- package/dist/assets/index-BaBk6Eb5.css +1 -0
- package/dist/assets/infoDiagram-WHAUD3N6-BNQZZTcd.js +2 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-BmcOKIu0.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-etkUgKbz.js +89 -0
- package/dist/assets/katex-XbL3y5x-.js +261 -0
- package/dist/assets/layout-CyPK9cFq.js +1 -0
- package/dist/assets/min-D1_JVZu9.js +1 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-D-3bnFXY.js +68 -0
- package/dist/assets/pieDiagram-ADFJNKIX-SSpBbb1Z.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-kCW_e4Rj.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-B-hRBRHn.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-Bq18cS4Z.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-D6dOwWak.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-DRnWZawn.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-ortqHAq8.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-DLIDeF--.js +61 -0
- package/dist/assets/treemap-KMMF4GRG-D5oyLJbR.js +128 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-B_qUVnv4.js +7 -0
- package/{index.html → dist/index.html} +2 -1
- package/package.json +10 -1
- 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,592 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
3
|
-
import {
|
|
4
|
-
AlertTriangle,
|
|
5
|
-
RefreshCcw,
|
|
6
|
-
GitBranch,
|
|
7
|
-
Home,
|
|
8
|
-
Clock,
|
|
9
|
-
Maximize2,
|
|
10
|
-
Minimize2,
|
|
11
|
-
List as ListIcon,
|
|
12
|
-
ExternalLink
|
|
13
|
-
} from 'lucide-react';
|
|
14
|
-
import { useSpecDetailLayoutContext } from '../components/SpecDetailLayout.context';
|
|
15
|
-
import {
|
|
16
|
-
Button,
|
|
17
|
-
cn,
|
|
18
|
-
Dialog,
|
|
19
|
-
DialogContent,
|
|
20
|
-
DialogHeader,
|
|
21
|
-
DialogTitle,
|
|
22
|
-
DialogDescription,
|
|
23
|
-
SpecTimeline,
|
|
24
|
-
SpecDependencyGraph,
|
|
25
|
-
StatusBadge,
|
|
26
|
-
PriorityBadge,
|
|
27
|
-
type CompleteSpecRelationships
|
|
28
|
-
} from '@leanspec/ui-components';
|
|
29
|
-
import { APIError, api } from '../lib/api';
|
|
30
|
-
import { StatusEditor } from '../components/metadata-editors/StatusEditor';
|
|
31
|
-
import { PriorityEditor } from '../components/metadata-editors/PriorityEditor';
|
|
32
|
-
import { TagsEditor } from '../components/metadata-editors/TagsEditor';
|
|
33
|
-
import type { SubSpec } from '../types/api';
|
|
34
|
-
import { TableOfContents, TableOfContentsSidebar } from '../components/spec-detail/TableOfContents';
|
|
35
|
-
import { SpecDetailSkeleton } from '../components/shared/Skeletons';
|
|
36
|
-
import { EmptyState } from '../components/shared/EmptyState';
|
|
37
|
-
import { MarkdownRenderer } from '../components/spec-detail/MarkdownRenderer';
|
|
38
|
-
import { BackToTop } from '../components/shared/BackToTop';
|
|
39
|
-
import { useProject } from '../contexts';
|
|
40
|
-
import { useTranslation } from 'react-i18next';
|
|
41
|
-
import { formatDate, formatRelativeTime } from '../lib/date-utils';
|
|
42
|
-
import type { SpecDetail } from '../types/api';
|
|
43
|
-
import { PageTransition } from '../components/shared/PageTransition';
|
|
44
|
-
import { getSubSpecStyle, formatSubSpecName } from '../lib/sub-spec-utils';
|
|
45
|
-
import type { LucideIcon } from 'lucide-react';
|
|
46
|
-
|
|
47
|
-
// Sub-spec with frontend-assigned styling
|
|
48
|
-
interface EnrichedSubSpec extends SubSpec {
|
|
49
|
-
icon: LucideIcon;
|
|
50
|
-
color: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function SpecDetailPage() {
|
|
54
|
-
const { specName, projectId } = useParams<{ specName: string; projectId: string }>();
|
|
55
|
-
const [searchParams] = useSearchParams();
|
|
56
|
-
const navigate = useNavigate();
|
|
57
|
-
const basePath = `/projects/${projectId}`;
|
|
58
|
-
const { currentProject, loading: projectLoading } = useProject();
|
|
59
|
-
const { t, i18n } = useTranslation(['common', 'errors']);
|
|
60
|
-
const projectReady = !projectId || currentProject?.id === projectId;
|
|
61
|
-
const [spec, setSpec] = useState<SpecDetail | null>(null);
|
|
62
|
-
const [loading, setLoading] = useState(true);
|
|
63
|
-
const [error, setError] = useState<string | null>(null);
|
|
64
|
-
const currentSubSpec = searchParams.get('subspec');
|
|
65
|
-
const headerRef = useRef<HTMLElement>(null);
|
|
66
|
-
const [timelineDialogOpen, setTimelineDialogOpen] = useState(false);
|
|
67
|
-
const [dependenciesDialogOpen, setDependenciesDialogOpen] = useState(false);
|
|
68
|
-
const [dependencyGraphData, setDependencyGraphData] = useState<CompleteSpecRelationships | null>(null);
|
|
69
|
-
const [isFocusMode, setIsFocusMode] = useState(false);
|
|
70
|
-
const { setMobileOpen } = useSpecDetailLayoutContext();
|
|
71
|
-
|
|
72
|
-
const describeError = useCallback((err: unknown) => {
|
|
73
|
-
if (err instanceof APIError) {
|
|
74
|
-
switch (err.status) {
|
|
75
|
-
case 404:
|
|
76
|
-
return t('specNotFound', { ns: 'errors' });
|
|
77
|
-
case 400:
|
|
78
|
-
return t('invalidInput', { ns: 'errors' });
|
|
79
|
-
case 500:
|
|
80
|
-
return t('unknownError', { ns: 'errors' });
|
|
81
|
-
default:
|
|
82
|
-
return t('loadingError', { ns: 'errors' });
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (err instanceof Error && err.message.includes('Failed to fetch')) {
|
|
87
|
-
return t('networkError', { ns: 'errors' });
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return err instanceof Error ? err.message : t('unknownError', { ns: 'errors' });
|
|
91
|
-
}, [t]);
|
|
92
|
-
|
|
93
|
-
const loadSpec = useCallback(async () => {
|
|
94
|
-
if (!specName || !projectReady || projectLoading) return;
|
|
95
|
-
setLoading(true);
|
|
96
|
-
try {
|
|
97
|
-
const data = await api.getSpec(specName);
|
|
98
|
-
setSpec(data);
|
|
99
|
-
setError(null);
|
|
100
|
-
} catch (err) {
|
|
101
|
-
setError(describeError(err));
|
|
102
|
-
} finally {
|
|
103
|
-
setLoading(false);
|
|
104
|
-
}
|
|
105
|
-
}, [describeError, projectLoading, projectReady, specName]);
|
|
106
|
-
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
void loadSpec();
|
|
109
|
-
}, [loadSpec, projectReady]);
|
|
110
|
-
|
|
111
|
-
useEffect(() => {
|
|
112
|
-
if (dependenciesDialogOpen && !dependencyGraphData && spec) {
|
|
113
|
-
const loadGraph = async () => {
|
|
114
|
-
try {
|
|
115
|
-
// Fetch all specs to get details for dependencies
|
|
116
|
-
// In a real app, we should have a dedicated endpoint for this
|
|
117
|
-
const allSpecs = await api.getSpecs();
|
|
118
|
-
|
|
119
|
-
const findSpec = (idOrName: string) =>
|
|
120
|
-
allSpecs.find(s => s.id === idOrName || s.specName === idOrName);
|
|
121
|
-
|
|
122
|
-
const current = {
|
|
123
|
-
specName: spec.specName,
|
|
124
|
-
specNumber: spec.specNumber || undefined,
|
|
125
|
-
status: spec.status || undefined,
|
|
126
|
-
priority: spec.priority || undefined
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const dependsOn = (spec.dependsOn || []).map(id => {
|
|
130
|
-
const s = findSpec(id);
|
|
131
|
-
return {
|
|
132
|
-
specName: s?.specName || id,
|
|
133
|
-
specNumber: s?.specNumber || undefined,
|
|
134
|
-
title: s?.title || undefined,
|
|
135
|
-
status: s?.status || undefined,
|
|
136
|
-
priority: s?.priority || undefined
|
|
137
|
-
};
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const requiredBy = (spec.requiredBy || []).map(id => {
|
|
141
|
-
const s = findSpec(id);
|
|
142
|
-
return {
|
|
143
|
-
specName: s?.specName || id,
|
|
144
|
-
specNumber: s?.specNumber || undefined,
|
|
145
|
-
title: s?.title || undefined,
|
|
146
|
-
status: s?.status || undefined,
|
|
147
|
-
priority: s?.priority || undefined
|
|
148
|
-
};
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
setDependencyGraphData({ current, dependsOn, requiredBy });
|
|
152
|
-
} catch (err) {
|
|
153
|
-
console.error('Failed to load dependency graph data', err);
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
void loadGraph();
|
|
157
|
-
}
|
|
158
|
-
}, [dependenciesDialogOpen, dependencyGraphData, spec]);
|
|
159
|
-
|
|
160
|
-
const subSpecs: EnrichedSubSpec[] = useMemo(() => {
|
|
161
|
-
const raw = (spec?.subSpecs as unknown) ?? (spec?.metadata?.sub_specs as unknown);
|
|
162
|
-
if (!Array.isArray(raw)) return [];
|
|
163
|
-
return raw
|
|
164
|
-
.map((entry) => {
|
|
165
|
-
if (!entry || typeof entry !== 'object') return null;
|
|
166
|
-
const content = (entry as Record<string, unknown>).content;
|
|
167
|
-
if (typeof content !== 'string') return null;
|
|
168
|
-
|
|
169
|
-
const file = typeof (entry as Record<string, unknown>).file === 'string'
|
|
170
|
-
? (entry as Record<string, unknown>).file as string
|
|
171
|
-
: typeof (entry as Record<string, unknown>).name === 'string'
|
|
172
|
-
? (entry as Record<string, unknown>).name as string
|
|
173
|
-
: '';
|
|
174
|
-
|
|
175
|
-
// Use frontend styling logic based on filename
|
|
176
|
-
const style = getSubSpecStyle(file);
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
name: formatSubSpecName(file),
|
|
180
|
-
content,
|
|
181
|
-
file,
|
|
182
|
-
icon: style.icon,
|
|
183
|
-
color: style.color,
|
|
184
|
-
};
|
|
185
|
-
})
|
|
186
|
-
.filter(Boolean) as EnrichedSubSpec[];
|
|
187
|
-
}, [spec]);
|
|
188
|
-
|
|
189
|
-
const applySpecPatch = (updates: Partial<SpecDetail>) => {
|
|
190
|
-
setSpec((prev) => (prev ? { ...prev, ...updates } : prev));
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
// Handle sub-spec switching
|
|
194
|
-
const handleSubSpecSwitch = (file: string | null) => {
|
|
195
|
-
const newUrl = file
|
|
196
|
-
? `${basePath}/specs/${specName}?subspec=${file}`
|
|
197
|
-
: `${basePath}/specs/${specName}`;
|
|
198
|
-
navigate(newUrl);
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
// Get content to display (main or sub-spec)
|
|
202
|
-
let displayContent = spec?.content || spec?.contentMd || '';
|
|
203
|
-
if (currentSubSpec && spec && subSpecs.length > 0) {
|
|
204
|
-
const subSpecData = subSpecs.find(s => s.file === currentSubSpec);
|
|
205
|
-
if (subSpecData) {
|
|
206
|
-
displayContent = subSpecData.content;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Extract title
|
|
211
|
-
const displayTitle = spec?.title || spec?.specName || '';
|
|
212
|
-
const tags = useMemo(() => spec?.tags || [], [spec?.tags]);
|
|
213
|
-
const updatedRelative = spec?.updatedAt ? formatRelativeTime(spec.updatedAt, i18n.language) : null;
|
|
214
|
-
|
|
215
|
-
// Handle scroll padding for sticky header
|
|
216
|
-
useEffect(() => {
|
|
217
|
-
const updateScrollPadding = () => {
|
|
218
|
-
const navbarHeight = 56; // 3.5rem / top-14
|
|
219
|
-
let offset = 0;
|
|
220
|
-
|
|
221
|
-
// On large screens, the spec header is also sticky
|
|
222
|
-
if (window.innerWidth >= 1024 && headerRef.current) {
|
|
223
|
-
offset += headerRef.current.offsetHeight - navbarHeight;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const specDetailMain = document.querySelector<HTMLDivElement>('#spec-detail-main');
|
|
227
|
-
if (specDetailMain) {
|
|
228
|
-
specDetailMain.style.scrollPaddingTop = `${offset}px`;
|
|
229
|
-
}
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
updateScrollPadding();
|
|
233
|
-
window.addEventListener('resize', updateScrollPadding);
|
|
234
|
-
|
|
235
|
-
const observer = new ResizeObserver(updateScrollPadding);
|
|
236
|
-
if (headerRef.current) {
|
|
237
|
-
observer.observe(headerRef.current);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return () => {
|
|
241
|
-
window.removeEventListener('resize', updateScrollPadding);
|
|
242
|
-
observer.disconnect();
|
|
243
|
-
document.documentElement.style.scrollPaddingTop = '';
|
|
244
|
-
};
|
|
245
|
-
}, [spec, tags]);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (loading) {
|
|
249
|
-
return <SpecDetailSkeleton />;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (error || !spec) {
|
|
253
|
-
return (
|
|
254
|
-
<EmptyState
|
|
255
|
-
icon={AlertTriangle}
|
|
256
|
-
title={t('specDetail.state.unavailableTitle')}
|
|
257
|
-
description={error || t('specDetail.state.unavailableDescription')}
|
|
258
|
-
tone="error"
|
|
259
|
-
actions={(
|
|
260
|
-
<>
|
|
261
|
-
<Link to={`${basePath}/specs`} className="inline-flex">
|
|
262
|
-
<Button variant="outline" size="sm" className="gap-2">
|
|
263
|
-
{t('specDetail.links.backToSpecs')}
|
|
264
|
-
</Button>
|
|
265
|
-
</Link>
|
|
266
|
-
<Button variant="secondary" size="sm" className="gap-2" onClick={() => void loadSpec()}>
|
|
267
|
-
<RefreshCcw className="h-4 w-4" />
|
|
268
|
-
{t('actions.retry')}
|
|
269
|
-
</Button>
|
|
270
|
-
<a
|
|
271
|
-
href="https://github.com/codervisor/lean-spec/issues"
|
|
272
|
-
target="_blank"
|
|
273
|
-
rel="noreferrer"
|
|
274
|
-
className="inline-flex"
|
|
275
|
-
>
|
|
276
|
-
<Button variant="ghost" size="sm" className="gap-2">
|
|
277
|
-
{t('specDetail.links.reportIssue')}
|
|
278
|
-
</Button>
|
|
279
|
-
</a>
|
|
280
|
-
</>
|
|
281
|
-
)}
|
|
282
|
-
/>
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const dependsOn = spec.dependsOn || [];
|
|
287
|
-
const requiredBy = spec.requiredBy || [];
|
|
288
|
-
const hasRelationships = dependsOn.length > 0 || requiredBy.length > 0;
|
|
289
|
-
|
|
290
|
-
return (
|
|
291
|
-
<PageTransition className="w-full">
|
|
292
|
-
<div className="flex-1 min-w-0 overflow-y-auto h-[calc(100vh-3.5rem)]">
|
|
293
|
-
{/* Mobile Sidebar Toggle Button */}
|
|
294
|
-
<div className="lg:hidden sticky top-0 z-20 flex items-center justify-between bg-background/95 backdrop-blur border-b px-3 py-2">
|
|
295
|
-
<span className="text-sm font-semibold">{t('specsNavSidebar.title')}</span>
|
|
296
|
-
<Button size="sm" variant="outline" onClick={() => setMobileOpen(true)}>
|
|
297
|
-
{t('actions.openSidebar')}
|
|
298
|
-
</Button>
|
|
299
|
-
</div>
|
|
300
|
-
|
|
301
|
-
{/* Compact Header - sticky on desktop */}
|
|
302
|
-
<header ref={headerRef} className="lg:sticky lg:top-0 lg:z-20 border-b bg-card">
|
|
303
|
-
<div className={cn("px-3 sm:px-6", isFocusMode ? "py-1.5" : "py-2 sm:py-3")}>
|
|
304
|
-
{/* Focus mode: Single compact row */}
|
|
305
|
-
{isFocusMode ? (
|
|
306
|
-
<div className="flex items-center justify-between gap-3">
|
|
307
|
-
<div className="flex items-center gap-3 min-w-0">
|
|
308
|
-
<h1 className="text-base font-semibold tracking-tight truncate">
|
|
309
|
-
{spec.specNumber && (
|
|
310
|
-
<span className="text-muted-foreground">#{spec.specNumber.toString().padStart(3, '0')} </span>
|
|
311
|
-
)}
|
|
312
|
-
{displayTitle}
|
|
313
|
-
</h1>
|
|
314
|
-
<StatusBadge status={spec.status || 'planned'} />
|
|
315
|
-
<PriorityBadge priority={spec.priority || 'medium'} />
|
|
316
|
-
</div>
|
|
317
|
-
<Button
|
|
318
|
-
type="button"
|
|
319
|
-
variant="ghost"
|
|
320
|
-
size="sm"
|
|
321
|
-
onClick={() => setIsFocusMode(false)}
|
|
322
|
-
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground shrink-0"
|
|
323
|
-
title={t('specDetail.buttons.exitFocus')}
|
|
324
|
-
>
|
|
325
|
-
<Minimize2 className="h-4 w-4" />
|
|
326
|
-
</Button>
|
|
327
|
-
</div>
|
|
328
|
-
) : (
|
|
329
|
-
/* Normal mode: Full multi-line header */
|
|
330
|
-
<>
|
|
331
|
-
{/* Line 1: Spec number + H1 Title */}
|
|
332
|
-
<div className="flex items-start justify-between gap-2 mb-1.5 sm:mb-2">
|
|
333
|
-
<h1 className="text-lg sm:text-xl font-bold tracking-tight">
|
|
334
|
-
{spec.specNumber && (
|
|
335
|
-
<span className="text-muted-foreground">#{spec.specNumber.toString().padStart(3, '0')} </span>
|
|
336
|
-
)}
|
|
337
|
-
{displayTitle}
|
|
338
|
-
</h1>
|
|
339
|
-
|
|
340
|
-
{/* Mobile Specs List Toggle */}
|
|
341
|
-
<Button
|
|
342
|
-
variant="ghost"
|
|
343
|
-
size="icon"
|
|
344
|
-
className="lg:hidden h-8 w-8 -mr-2 shrink-0 text-muted-foreground"
|
|
345
|
-
onClick={() => setMobileOpen(true)}
|
|
346
|
-
>
|
|
347
|
-
<ListIcon className="h-5 w-5" />
|
|
348
|
-
<span className="sr-only">{t('specDetail.toggleSidebar')}</span>
|
|
349
|
-
</Button>
|
|
350
|
-
</div>
|
|
351
|
-
|
|
352
|
-
{/* Line 2: Status, Priority, Tags */}
|
|
353
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
354
|
-
<StatusEditor
|
|
355
|
-
specName={spec.specName}
|
|
356
|
-
value={spec.status}
|
|
357
|
-
onChange={(status) => applySpecPatch({ status })}
|
|
358
|
-
/>
|
|
359
|
-
<PriorityEditor
|
|
360
|
-
specName={spec.specName}
|
|
361
|
-
value={spec.priority}
|
|
362
|
-
onChange={(priority) => applySpecPatch({ priority })}
|
|
363
|
-
/>
|
|
364
|
-
|
|
365
|
-
<div className="h-4 w-px bg-border mx-1 hidden sm:block" />
|
|
366
|
-
<TagsEditor
|
|
367
|
-
specName={spec.specName}
|
|
368
|
-
value={tags}
|
|
369
|
-
onChange={(tags) => applySpecPatch({ tags })}
|
|
370
|
-
/>
|
|
371
|
-
</div>
|
|
372
|
-
|
|
373
|
-
{/* Line 3: Small metadata row */}
|
|
374
|
-
<div className="flex flex-wrap gap-2 sm:gap-4 text-xs text-muted-foreground mt-1.5 sm:mt-2">
|
|
375
|
-
<span className="hidden sm:inline">
|
|
376
|
-
{t('specDetail.metadata.created')}: {formatDate(spec.createdAt, i18n.language)}
|
|
377
|
-
</span>
|
|
378
|
-
<span className="hidden sm:inline">•</span>
|
|
379
|
-
<span>
|
|
380
|
-
{t('specDetail.metadata.updated')}: {formatDate(spec.updatedAt, i18n.language)}
|
|
381
|
-
{updatedRelative && (
|
|
382
|
-
<span className="ml-1 text-[11px] text-muted-foreground/80">({updatedRelative})</span>
|
|
383
|
-
)}
|
|
384
|
-
</span>
|
|
385
|
-
<span className="hidden sm:inline">•</span>
|
|
386
|
-
<span className="hidden md:inline">{t('specDetail.metadata.name')}: {spec.specName}</span>
|
|
387
|
-
{spec.metadata?.assignee ? (
|
|
388
|
-
<>
|
|
389
|
-
<span className="hidden sm:inline">•</span>
|
|
390
|
-
<span className="hidden sm:inline">{t('specDetail.metadata.assignee')}: {String(spec.metadata.assignee)}</span>
|
|
391
|
-
</>
|
|
392
|
-
) : null}
|
|
393
|
-
</div>
|
|
394
|
-
|
|
395
|
-
{/* Action buttons row */}
|
|
396
|
-
<div className="flex flex-wrap items-center gap-2 mt-2">
|
|
397
|
-
<Dialog open={timelineDialogOpen} onOpenChange={setTimelineDialogOpen}>
|
|
398
|
-
<Button
|
|
399
|
-
type="button"
|
|
400
|
-
variant="outline"
|
|
401
|
-
size="sm"
|
|
402
|
-
aria-haspopup="dialog"
|
|
403
|
-
aria-expanded={timelineDialogOpen}
|
|
404
|
-
onClick={() => setTimelineDialogOpen(true)}
|
|
405
|
-
className="h-8 rounded-full border px-3 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
406
|
-
>
|
|
407
|
-
<Clock className="mr-1.5 h-3.5 w-3.5" />
|
|
408
|
-
{t('specDetail.buttons.viewTimeline')}
|
|
409
|
-
<Maximize2 className="ml-1.5 h-3.5 w-3.5" />
|
|
410
|
-
</Button>
|
|
411
|
-
<DialogContent className="w-[min(900px,90vw)] max-w-3xl max-h-[90vh] overflow-y-auto">
|
|
412
|
-
<DialogHeader>
|
|
413
|
-
<DialogTitle>{t('specDetail.dialogs.timelineTitle')}</DialogTitle>
|
|
414
|
-
<DialogDescription>{t('specDetail.dialogs.timelineDescription')}</DialogDescription>
|
|
415
|
-
</DialogHeader>
|
|
416
|
-
<div className="rounded-xl border border-border bg-muted/30 p-4">
|
|
417
|
-
<SpecTimeline
|
|
418
|
-
createdAt={spec.createdAt}
|
|
419
|
-
updatedAt={spec.updatedAt}
|
|
420
|
-
completedAt={spec.completedAt}
|
|
421
|
-
status={spec.status || 'planned'}
|
|
422
|
-
labels={{
|
|
423
|
-
created: t('specTimeline.events.created'),
|
|
424
|
-
inProgress: t('specTimeline.events.inProgress'),
|
|
425
|
-
complete: t('specTimeline.events.complete'),
|
|
426
|
-
archived: t('specTimeline.events.archived'),
|
|
427
|
-
awaiting: t('specTimeline.state.awaiting'),
|
|
428
|
-
queued: t('specTimeline.state.queued'),
|
|
429
|
-
pending: t('specTimeline.state.pending'),
|
|
430
|
-
}}
|
|
431
|
-
language={i18n.language}
|
|
432
|
-
/>
|
|
433
|
-
</div>
|
|
434
|
-
</DialogContent>
|
|
435
|
-
</Dialog>
|
|
436
|
-
|
|
437
|
-
<Dialog open={dependenciesDialogOpen} onOpenChange={setDependenciesDialogOpen}>
|
|
438
|
-
<Button
|
|
439
|
-
type="button"
|
|
440
|
-
variant="outline"
|
|
441
|
-
size="sm"
|
|
442
|
-
aria-haspopup="dialog"
|
|
443
|
-
aria-expanded={dependenciesDialogOpen}
|
|
444
|
-
onClick={() => setDependenciesDialogOpen(true)}
|
|
445
|
-
disabled={!hasRelationships}
|
|
446
|
-
className={cn(
|
|
447
|
-
'h-8 rounded-full border px-3 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground',
|
|
448
|
-
!hasRelationships && 'cursor-not-allowed opacity-50'
|
|
449
|
-
)}
|
|
450
|
-
>
|
|
451
|
-
<GitBranch className="mr-1.5 h-3.5 w-3.5" />
|
|
452
|
-
{t('specDetail.buttons.viewDependencies')}
|
|
453
|
-
<Maximize2 className="ml-1.5 h-3.5 w-3.5" />
|
|
454
|
-
</Button>
|
|
455
|
-
<DialogContent className="flex h-[85vh] w-[min(1200px,95vw)] max-w-6xl flex-col gap-4 overflow-hidden">
|
|
456
|
-
<DialogHeader>
|
|
457
|
-
<DialogTitle>{t('specDetail.dialogs.dependenciesTitle')}</DialogTitle>
|
|
458
|
-
<DialogDescription className="flex flex-col gap-2">
|
|
459
|
-
<span>{t('specDetail.dialogs.dependenciesDescription')}</span>
|
|
460
|
-
<Link
|
|
461
|
-
to={projectId
|
|
462
|
-
? `/projects/${projectId}/dependencies?spec=${spec.specNumber || spec.id}`
|
|
463
|
-
: `/dependencies?spec=${spec.specNumber || spec.id}`
|
|
464
|
-
}
|
|
465
|
-
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline w-fit"
|
|
466
|
-
onClick={() => setDependenciesDialogOpen(false)}
|
|
467
|
-
>
|
|
468
|
-
<ExternalLink className="h-3 w-3" />
|
|
469
|
-
{t('specDetail.dialogs.dependenciesLink')}
|
|
470
|
-
</Link>
|
|
471
|
-
</DialogDescription>
|
|
472
|
-
</DialogHeader>
|
|
473
|
-
<div className="min-h-0 flex-1">
|
|
474
|
-
{dependencyGraphData && (
|
|
475
|
-
<SpecDependencyGraph
|
|
476
|
-
relationships={dependencyGraphData}
|
|
477
|
-
specNumber={spec.specNumber}
|
|
478
|
-
specTitle={displayTitle}
|
|
479
|
-
labels={{
|
|
480
|
-
title: t('dependencyGraph.header.title'),
|
|
481
|
-
subtitle: t('dependencyGraph.header.subtitle'),
|
|
482
|
-
badge: t('dependencyGraph.header.badge'),
|
|
483
|
-
currentBadge: t('dependencyGraph.badges.current'),
|
|
484
|
-
currentSubtitle: t('dependencyGraph.badges.currentSubtitle'),
|
|
485
|
-
dependsOnBadge: t('dependencyGraph.badges.dependsOn'),
|
|
486
|
-
dependsOnSubtitle: t('dependencyGraph.badges.dependsOnSubtitle'),
|
|
487
|
-
requiredByBadge: t('dependencyGraph.badges.requiredBy'),
|
|
488
|
-
requiredBySubtitle: t('dependencyGraph.badges.requiredBySubtitle'),
|
|
489
|
-
completedSubtitle: t('dependencyGraph.statusSubtitles.completed'),
|
|
490
|
-
inProgressSubtitle: t('dependencyGraph.statusSubtitles.inProgress'),
|
|
491
|
-
plannedBlockingSubtitle: t('dependencyGraph.statusSubtitles.plannedBlocking'),
|
|
492
|
-
plannedCanProceedSubtitle: t('dependencyGraph.statusSubtitles.plannedCanProceed'),
|
|
493
|
-
archivedSubtitle: t('dependencyGraph.statusSubtitles.archived'),
|
|
494
|
-
}}
|
|
495
|
-
onNodeClick={(specId) => {
|
|
496
|
-
const url = projectId
|
|
497
|
-
? `/projects/${projectId}/specs/${specId}`
|
|
498
|
-
: `/projects/default/specs/${specId}`;
|
|
499
|
-
navigate(url);
|
|
500
|
-
setDependenciesDialogOpen(false);
|
|
501
|
-
}}
|
|
502
|
-
/>
|
|
503
|
-
)}
|
|
504
|
-
</div>
|
|
505
|
-
</DialogContent>
|
|
506
|
-
</Dialog>
|
|
507
|
-
|
|
508
|
-
{/* Focus Mode Toggle */}
|
|
509
|
-
<Button
|
|
510
|
-
type="button"
|
|
511
|
-
variant="outline"
|
|
512
|
-
size="sm"
|
|
513
|
-
onClick={() => setIsFocusMode(true)}
|
|
514
|
-
className="hidden lg:inline-flex h-8 rounded-full border px-3 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
515
|
-
title={t('specDetail.buttons.focus')}
|
|
516
|
-
>
|
|
517
|
-
<Maximize2 className="mr-1.5 h-3.5 w-3.5" />
|
|
518
|
-
{t('specDetail.buttons.focus')}
|
|
519
|
-
</Button>
|
|
520
|
-
</div>
|
|
521
|
-
</>
|
|
522
|
-
)}
|
|
523
|
-
</div>
|
|
524
|
-
|
|
525
|
-
{/* Horizontal Tabs for Sub-specs */}
|
|
526
|
-
{subSpecs.length > 0 && (
|
|
527
|
-
<div className="border-t bg-muted/30">
|
|
528
|
-
<div className="px-3 sm:px-6 overflow-x-auto">
|
|
529
|
-
<div className="flex gap-1 py-2 min-w-max">
|
|
530
|
-
{/* Overview tab (README.md) */}
|
|
531
|
-
<button
|
|
532
|
-
onClick={() => handleSubSpecSwitch(null)}
|
|
533
|
-
className={`flex items-center gap-2 px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-md whitespace-nowrap transition-colors ${!currentSubSpec
|
|
534
|
-
? 'bg-background text-foreground shadow-sm'
|
|
535
|
-
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
536
|
-
}`}
|
|
537
|
-
>
|
|
538
|
-
<Home className="h-4 w-4" />
|
|
539
|
-
<span className="hidden sm:inline">{t('specDetail.tabs.overview')}</span>
|
|
540
|
-
</button>
|
|
541
|
-
|
|
542
|
-
{/* Sub-spec tabs */}
|
|
543
|
-
{subSpecs.map((subSpec) => {
|
|
544
|
-
const Icon = subSpec.icon;
|
|
545
|
-
return (
|
|
546
|
-
<button
|
|
547
|
-
key={subSpec.file}
|
|
548
|
-
onClick={() => handleSubSpecSwitch(subSpec.file)}
|
|
549
|
-
className={`flex items-center gap-2 px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-md whitespace-nowrap transition-colors ${currentSubSpec === subSpec.file
|
|
550
|
-
? 'bg-background text-foreground shadow-sm'
|
|
551
|
-
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
552
|
-
}`}
|
|
553
|
-
>
|
|
554
|
-
<Icon className={`h-4 w-4 ${subSpec.color}`} />
|
|
555
|
-
<span className="hidden sm:inline">{subSpec.name}</span>
|
|
556
|
-
</button>
|
|
557
|
-
);
|
|
558
|
-
})}
|
|
559
|
-
</div>
|
|
560
|
-
</div>
|
|
561
|
-
</div>
|
|
562
|
-
)}
|
|
563
|
-
</header>
|
|
564
|
-
|
|
565
|
-
{/* Main content with Sidebar */}
|
|
566
|
-
<div className="flex flex-col xl:flex-row xl:items-start">
|
|
567
|
-
<main className="flex-1 px-3 sm:px-6 py-3 sm:py-6 min-w-0">
|
|
568
|
-
<MarkdownRenderer content={displayContent} />
|
|
569
|
-
</main>
|
|
570
|
-
|
|
571
|
-
{/* Right Sidebar for TOC (Desktop only) */}
|
|
572
|
-
<aside
|
|
573
|
-
className={cn(
|
|
574
|
-
"hidden xl:block w-72 shrink-0 px-6 py-6 sticky overflow-y-auto scrollbar-auto-hide",
|
|
575
|
-
subSpecs.length > 0
|
|
576
|
-
? "top-[calc(16.375rem-3.5rem)] h-[calc(100vh-16.375rem)]"
|
|
577
|
-
: "top-[calc(13.125rem-3.5rem)] h-[calc(100vh-13.125rem)]"
|
|
578
|
-
)}
|
|
579
|
-
>
|
|
580
|
-
<TableOfContentsSidebar content={displayContent} />
|
|
581
|
-
</aside>
|
|
582
|
-
</div>
|
|
583
|
-
|
|
584
|
-
{/* Floating action buttons (Mobile/Tablet only) */}
|
|
585
|
-
<div className="xl:hidden">
|
|
586
|
-
<TableOfContents content={displayContent} />
|
|
587
|
-
</div>
|
|
588
|
-
<BackToTop />
|
|
589
|
-
</div>
|
|
590
|
-
</PageTransition>
|
|
591
|
-
);
|
|
592
|
-
}
|