@leanspec/ui 0.2.13 → 0.2.15-dev.21022397862
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 +13 -3
- package/eslint.config.js +0 -23
- 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
package/src/pages/SpecsPage.tsx
DELETED
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|
2
|
-
import { LayoutGrid, List, AlertCircle, FileQuestion, FilterX, RefreshCcw } from 'lucide-react';
|
|
3
|
-
import { Button, Card, CardContent } from '@leanspec/ui-components';
|
|
4
|
-
import { useParams, useSearchParams } from 'react-router-dom';
|
|
5
|
-
import { api } from '../lib/api';
|
|
6
|
-
import type { Spec } from '../types/api';
|
|
7
|
-
import { BoardView } from '../components/specs/BoardView';
|
|
8
|
-
import { ListView } from '../components/specs/ListView';
|
|
9
|
-
import { SpecsFilters } from '../components/specs/SpecsFilters';
|
|
10
|
-
import { cn } from '../lib/utils';
|
|
11
|
-
import { SpecListSkeleton } from '../components/shared/Skeletons';
|
|
12
|
-
import { PageHeader } from '../components/shared/PageHeader';
|
|
13
|
-
import { EmptyState } from '../components/shared/EmptyState';
|
|
14
|
-
import { useProject } from '../contexts';
|
|
15
|
-
import { useTranslation } from 'react-i18next';
|
|
16
|
-
|
|
17
|
-
type ViewMode = 'list' | 'board';
|
|
18
|
-
type SpecStatus = 'planned' | 'in-progress' | 'complete' | 'archived';
|
|
19
|
-
type SortOption = 'id-desc' | 'id-asc' | 'updated-desc' | 'title-asc';
|
|
20
|
-
|
|
21
|
-
export function SpecsPage() {
|
|
22
|
-
const [specs, setSpecs] = useState<Spec[]>([]);
|
|
23
|
-
const { projectId } = useParams<{ projectId: string }>();
|
|
24
|
-
const basePath = projectId ? `/projects/${projectId}` : '/projects/default';
|
|
25
|
-
const { currentProject, loading: projectLoading } = useProject();
|
|
26
|
-
const projectReady = !projectId || currentProject?.id === projectId;
|
|
27
|
-
const { t } = useTranslation('common');
|
|
28
|
-
const [loading, setLoading] = useState(true);
|
|
29
|
-
const [error, setError] = useState<string | null>(null);
|
|
30
|
-
|
|
31
|
-
// Filters
|
|
32
|
-
const [searchQuery, setSearchQuery] = useState('');
|
|
33
|
-
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
34
|
-
const [priorityFilter, setPriorityFilter] = useState<string>('all');
|
|
35
|
-
const [tagFilter, setTagFilter] = useState<string>('all');
|
|
36
|
-
const [sortBy, setSortBy] = useState<SortOption>('id-desc');
|
|
37
|
-
|
|
38
|
-
const [searchParams] = useSearchParams();
|
|
39
|
-
const initializedFromQuery = useRef(false);
|
|
40
|
-
|
|
41
|
-
// View State
|
|
42
|
-
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
|
43
|
-
const saved = localStorage.getItem('specs-view-mode');
|
|
44
|
-
return (saved === 'board' || saved === 'list') ? saved : 'list';
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
const loadSpecs = useCallback(async () => {
|
|
48
|
-
if (!projectReady || projectLoading) return;
|
|
49
|
-
try {
|
|
50
|
-
setLoading(true);
|
|
51
|
-
const data = await api.getSpecs();
|
|
52
|
-
setSpecs(data);
|
|
53
|
-
setError(null);
|
|
54
|
-
} catch (err: unknown) {
|
|
55
|
-
console.error('Failed to load specs', err);
|
|
56
|
-
setError(t('specsPage.state.errorDescription'));
|
|
57
|
-
} finally {
|
|
58
|
-
setLoading(false);
|
|
59
|
-
}
|
|
60
|
-
}, [projectLoading, projectReady, t]);
|
|
61
|
-
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
void loadSpecs();
|
|
64
|
-
}, [loadSpecs]);
|
|
65
|
-
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
if (initializedFromQuery.current) return;
|
|
68
|
-
const initialTag = searchParams.get('tag');
|
|
69
|
-
const initialQuery = searchParams.get('q');
|
|
70
|
-
if (initialTag) setTagFilter(initialTag);
|
|
71
|
-
if (initialQuery) setSearchQuery(initialQuery);
|
|
72
|
-
initializedFromQuery.current = true;
|
|
73
|
-
}, [searchParams]);
|
|
74
|
-
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
localStorage.setItem('specs-view-mode', viewMode);
|
|
77
|
-
}, [viewMode]);
|
|
78
|
-
|
|
79
|
-
const handleStatusChange = useCallback(async (spec: Spec, newStatus: SpecStatus) => {
|
|
80
|
-
// Optimistic update
|
|
81
|
-
setSpecs(prev => prev.map(s =>
|
|
82
|
-
s.specName === spec.specName ? { ...s, status: newStatus } : s
|
|
83
|
-
));
|
|
84
|
-
|
|
85
|
-
try {
|
|
86
|
-
await api.updateSpec(spec.specName, { status: newStatus });
|
|
87
|
-
} catch (err) {
|
|
88
|
-
// Revert on error
|
|
89
|
-
setSpecs(prev => prev.map(s =>
|
|
90
|
-
s.specName === spec.specName ? { ...s, status: spec.status } : s
|
|
91
|
-
));
|
|
92
|
-
console.error('Failed to update status:', err);
|
|
93
|
-
}
|
|
94
|
-
}, []);
|
|
95
|
-
|
|
96
|
-
// Get unique values for filters
|
|
97
|
-
const uniqueStatuses = useMemo(() => {
|
|
98
|
-
const statuses = specs.map((s) => s.status).filter((s): s is SpecStatus => Boolean(s));
|
|
99
|
-
const uniqueSet = Array.from(new Set(statuses));
|
|
100
|
-
// Sort by defined order: planned -> in-progress -> complete -> archived
|
|
101
|
-
const statusOrder: Record<SpecStatus, number> = {
|
|
102
|
-
'planned': 1,
|
|
103
|
-
'in-progress': 2,
|
|
104
|
-
'complete': 3,
|
|
105
|
-
'archived': 4,
|
|
106
|
-
};
|
|
107
|
-
return uniqueSet.sort((a, b) => statusOrder[a] - statusOrder[b]);
|
|
108
|
-
}, [specs]);
|
|
109
|
-
const uniquePriorities = useMemo(() => {
|
|
110
|
-
const uniqueSet = Array.from(new Set(specs.map(s => s.priority).filter(Boolean) as string[]));
|
|
111
|
-
// Sort by defined order: critical -> high -> medium -> low
|
|
112
|
-
const priorityOrder: Record<string, number> = {
|
|
113
|
-
'critical': 1,
|
|
114
|
-
'high': 2,
|
|
115
|
-
'medium': 3,
|
|
116
|
-
'low': 4,
|
|
117
|
-
};
|
|
118
|
-
return uniqueSet.sort((a, b) => (priorityOrder[a] || 999) - (priorityOrder[b] || 999));
|
|
119
|
-
}, [specs]);
|
|
120
|
-
const uniqueTags = useMemo(() => {
|
|
121
|
-
const uniqueSet = Array.from(new Set(specs.flatMap(s => s.tags || [])));
|
|
122
|
-
// Sort alphabetically ascending
|
|
123
|
-
return uniqueSet.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
124
|
-
}, [specs]);
|
|
125
|
-
|
|
126
|
-
const handleClearFilters = useCallback(() => {
|
|
127
|
-
setSearchQuery('');
|
|
128
|
-
setStatusFilter('all');
|
|
129
|
-
setPriorityFilter('all');
|
|
130
|
-
setTagFilter('all');
|
|
131
|
-
}, []);
|
|
132
|
-
|
|
133
|
-
// Filter specs based on search and filters
|
|
134
|
-
const filteredSpecs = useMemo(() => {
|
|
135
|
-
const filtered = specs.filter(spec => {
|
|
136
|
-
if (searchQuery) {
|
|
137
|
-
const query = searchQuery.toLowerCase();
|
|
138
|
-
const matchesSearch =
|
|
139
|
-
spec.specName.toLowerCase().includes(query) ||
|
|
140
|
-
(spec.title ? spec.title.toLowerCase().includes(query) : false) ||
|
|
141
|
-
spec.tags?.some((tag: string) => tag.toLowerCase().includes(query));
|
|
142
|
-
if (!matchesSearch) return false;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (statusFilter !== 'all' && spec.status !== statusFilter) {
|
|
146
|
-
return false;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (priorityFilter !== 'all' && spec.priority !== priorityFilter) {
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (tagFilter !== 'all' && !spec.tags?.includes(tagFilter)) {
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return true;
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
const sorted = [...filtered];
|
|
161
|
-
switch (sortBy) {
|
|
162
|
-
case 'id-asc':
|
|
163
|
-
sorted.sort((a, b) => (a.specNumber || 0) - (b.specNumber || 0));
|
|
164
|
-
break;
|
|
165
|
-
case 'updated-desc':
|
|
166
|
-
sorted.sort((a, b) => {
|
|
167
|
-
if (!a.updatedAt) return 1;
|
|
168
|
-
if (!b.updatedAt) return -1;
|
|
169
|
-
const aTime = new Date(a.updatedAt).getTime();
|
|
170
|
-
const bTime = new Date(b.updatedAt).getTime();
|
|
171
|
-
return bTime - aTime;
|
|
172
|
-
});
|
|
173
|
-
break;
|
|
174
|
-
case 'title-asc':
|
|
175
|
-
sorted.sort((a, b) => {
|
|
176
|
-
const titleA = (a.title || a.specName).toLowerCase();
|
|
177
|
-
const titleB = (b.title || b.specName).toLowerCase();
|
|
178
|
-
return titleA.localeCompare(titleB);
|
|
179
|
-
});
|
|
180
|
-
break;
|
|
181
|
-
case 'id-desc':
|
|
182
|
-
default:
|
|
183
|
-
sorted.sort((a, b) => (b.specNumber || 0) - (a.specNumber || 0));
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return sorted;
|
|
188
|
-
}, [priorityFilter, searchQuery, sortBy, specs, statusFilter, tagFilter]);
|
|
189
|
-
|
|
190
|
-
if (loading) {
|
|
191
|
-
return (
|
|
192
|
-
<div className="p-4 sm:p-6">
|
|
193
|
-
<SpecListSkeleton />
|
|
194
|
-
</div>
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (error) {
|
|
199
|
-
return (
|
|
200
|
-
<div className="p-4 sm:p-6">
|
|
201
|
-
<Card>
|
|
202
|
-
<CardContent className="py-10 text-center space-y-3">
|
|
203
|
-
<div className="flex justify-center">
|
|
204
|
-
<AlertCircle className="h-6 w-6 text-destructive" />
|
|
205
|
-
</div>
|
|
206
|
-
<div className="text-lg font-semibold">{t('specsPage.state.errorTitle')}</div>
|
|
207
|
-
<p className="text-sm text-muted-foreground">{error || t('specsPage.state.errorDescription')}</p>
|
|
208
|
-
<Button variant="secondary" size="sm" onClick={loadSpecs} className="mt-2">
|
|
209
|
-
{t('actions.retry')}
|
|
210
|
-
</Button>
|
|
211
|
-
</CardContent>
|
|
212
|
-
</Card>
|
|
213
|
-
</div>
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return (
|
|
218
|
-
<div className="h-[calc(100vh-3.5rem)] flex flex-col gap-4 p-4 sm:p-6 max-w-7xl mx-auto w-full">
|
|
219
|
-
<div className="flex flex-col gap-4 sticky top-14 bg-background mt-0 py-2 z-10">
|
|
220
|
-
<PageHeader
|
|
221
|
-
title={t('specsPage.title')}
|
|
222
|
-
description={t('specsPage.description')}
|
|
223
|
-
actions={(
|
|
224
|
-
<div className="flex items-center gap-1 bg-secondary/50 p-1 rounded-lg border">
|
|
225
|
-
<Button
|
|
226
|
-
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
|
227
|
-
size="sm"
|
|
228
|
-
onClick={() => setViewMode('list')}
|
|
229
|
-
className={cn(
|
|
230
|
-
"h-8",
|
|
231
|
-
viewMode === 'list' && "bg-background shadow-sm"
|
|
232
|
-
)}
|
|
233
|
-
title={t('specsPage.views.listTooltip')}
|
|
234
|
-
>
|
|
235
|
-
<List className="w-4 h-4 mr-1.5" />
|
|
236
|
-
{t('specsPage.views.list')}
|
|
237
|
-
</Button>
|
|
238
|
-
<Button
|
|
239
|
-
variant={viewMode === 'board' ? 'secondary' : 'ghost'}
|
|
240
|
-
size="sm"
|
|
241
|
-
onClick={() => setViewMode('board')}
|
|
242
|
-
className={cn(
|
|
243
|
-
"h-8",
|
|
244
|
-
viewMode === 'board' && "bg-background shadow-sm"
|
|
245
|
-
)}
|
|
246
|
-
title={t('specsPage.views.boardTooltip')}
|
|
247
|
-
>
|
|
248
|
-
<LayoutGrid className="w-4 h-4 mr-1.5" />
|
|
249
|
-
{t('specsPage.views.board')}
|
|
250
|
-
</Button>
|
|
251
|
-
</div>
|
|
252
|
-
)}
|
|
253
|
-
/>
|
|
254
|
-
|
|
255
|
-
<p className="text-sm text-muted-foreground">{t('specsPage.count', { count: filteredSpecs.length })}</p>
|
|
256
|
-
|
|
257
|
-
<SpecsFilters
|
|
258
|
-
searchQuery={searchQuery}
|
|
259
|
-
onSearchChange={setSearchQuery}
|
|
260
|
-
statusFilter={statusFilter}
|
|
261
|
-
onStatusFilterChange={setStatusFilter}
|
|
262
|
-
priorityFilter={priorityFilter}
|
|
263
|
-
onPriorityFilterChange={setPriorityFilter}
|
|
264
|
-
tagFilter={tagFilter}
|
|
265
|
-
onTagFilterChange={setTagFilter}
|
|
266
|
-
sortBy={sortBy}
|
|
267
|
-
onSortByChange={(value) => setSortBy(value as SortOption)}
|
|
268
|
-
uniqueStatuses={uniqueStatuses}
|
|
269
|
-
uniquePriorities={uniquePriorities}
|
|
270
|
-
uniqueTags={uniqueTags}
|
|
271
|
-
onClearFilters={handleClearFilters}
|
|
272
|
-
totalSpecs={specs.length}
|
|
273
|
-
filteredCount={filteredSpecs.length}
|
|
274
|
-
/>
|
|
275
|
-
</div>
|
|
276
|
-
|
|
277
|
-
<div className="flex-1 min-h-0">
|
|
278
|
-
{specs.length === 0 ? (
|
|
279
|
-
<EmptyState
|
|
280
|
-
icon={FileQuestion}
|
|
281
|
-
title={t('specsPage.state.noSpecsTitle')}
|
|
282
|
-
description={t('specsPage.state.noSpecsDescription')}
|
|
283
|
-
actions={(
|
|
284
|
-
<Button variant="secondary" size="sm" onClick={loadSpecs}>
|
|
285
|
-
<RefreshCcw className="h-4 w-4 mr-2" />
|
|
286
|
-
{t('specsPage.buttons.refreshList')}
|
|
287
|
-
</Button>
|
|
288
|
-
)}
|
|
289
|
-
/>
|
|
290
|
-
) : filteredSpecs.length === 0 ? (
|
|
291
|
-
<EmptyState
|
|
292
|
-
icon={FilterX}
|
|
293
|
-
title={t('specsPage.state.noFiltersTitle')}
|
|
294
|
-
description={t('specsPage.state.noFiltersDescription')}
|
|
295
|
-
actions={(
|
|
296
|
-
<div className="flex gap-2 flex-wrap justify-center">
|
|
297
|
-
<Button variant="outline" size="sm" onClick={handleClearFilters}>
|
|
298
|
-
{t('specsNavSidebar.clearFilters')}
|
|
299
|
-
</Button>
|
|
300
|
-
<Button variant="secondary" size="sm" onClick={loadSpecs}>
|
|
301
|
-
<RefreshCcw className="h-4 w-4 mr-2" />
|
|
302
|
-
{t('specsPage.buttons.reloadData')}
|
|
303
|
-
</Button>
|
|
304
|
-
</div>
|
|
305
|
-
)}
|
|
306
|
-
/>
|
|
307
|
-
) : viewMode === 'list' ? (
|
|
308
|
-
<ListView specs={filteredSpecs} basePath={basePath} />
|
|
309
|
-
) : (
|
|
310
|
-
<BoardView
|
|
311
|
-
specs={filteredSpecs}
|
|
312
|
-
onStatusChange={handleStatusChange}
|
|
313
|
-
basePath={basePath}
|
|
314
|
-
/>
|
|
315
|
-
)}
|
|
316
|
-
</div>
|
|
317
|
-
</div>
|
|
318
|
-
);
|
|
319
|
-
}
|
package/src/pages/StatsPage.tsx
DELETED
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
BarChart,
|
|
4
|
-
Bar,
|
|
5
|
-
PieChart,
|
|
6
|
-
Pie,
|
|
7
|
-
Cell,
|
|
8
|
-
XAxis,
|
|
9
|
-
YAxis,
|
|
10
|
-
CartesianGrid,
|
|
11
|
-
Tooltip,
|
|
12
|
-
ResponsiveContainer,
|
|
13
|
-
Legend
|
|
14
|
-
} from 'recharts';
|
|
15
|
-
import { AlertCircle, FileText, Clock, PlayCircle, CheckCircle2, TrendingUp } from 'lucide-react';
|
|
16
|
-
import { Button, Card, CardContent, CardHeader, CardTitle } from '@leanspec/ui-components';
|
|
17
|
-
import { Link } from 'react-router-dom';
|
|
18
|
-
import { StatCard } from '../components/dashboard/StatCard';
|
|
19
|
-
import { api } from '../lib/api';
|
|
20
|
-
import type { Stats, Spec } from '../types/api';
|
|
21
|
-
import { StatsSkeleton } from '../components/shared/Skeletons';
|
|
22
|
-
import { PageHeader } from '../components/shared/PageHeader';
|
|
23
|
-
import { useTranslation } from 'react-i18next';
|
|
24
|
-
import { useProject } from '../contexts';
|
|
25
|
-
|
|
26
|
-
const STATUS_COLORS = {
|
|
27
|
-
planned: '#3B82F6',
|
|
28
|
-
'in-progress': '#F59E0B',
|
|
29
|
-
complete: '#10B981',
|
|
30
|
-
archived: '#6B7280',
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const PRIORITY_COLORS = {
|
|
34
|
-
critical: '#EF4444',
|
|
35
|
-
high: '#F59E0B',
|
|
36
|
-
medium: '#3B82F6',
|
|
37
|
-
low: '#6B7280',
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export function StatsPage() {
|
|
41
|
-
const [stats, setStats] = useState<Stats | null>(null);
|
|
42
|
-
const [specs, setSpecs] = useState<Spec[]>([]);
|
|
43
|
-
const [loading, setLoading] = useState(true);
|
|
44
|
-
const [error, setError] = useState<string | null>(null);
|
|
45
|
-
const { t, i18n } = useTranslation('common');
|
|
46
|
-
const { currentProject, loading: projectLoading } = useProject();
|
|
47
|
-
|
|
48
|
-
const loadStats = useCallback(async () => {
|
|
49
|
-
if (projectLoading || !currentProject) return;
|
|
50
|
-
try {
|
|
51
|
-
setLoading(true);
|
|
52
|
-
const [statsData, specsData] = await Promise.all([api.getStats(), api.getSpecs()]);
|
|
53
|
-
setStats(statsData);
|
|
54
|
-
setSpecs(Array.isArray(specsData) ? specsData : []);
|
|
55
|
-
setError(null);
|
|
56
|
-
} catch (err) {
|
|
57
|
-
console.error('Failed to load statistics', err);
|
|
58
|
-
setError(t('statsPage.state.errorDescription'));
|
|
59
|
-
} finally {
|
|
60
|
-
setLoading(false);
|
|
61
|
-
}
|
|
62
|
-
}, [currentProject, projectLoading, t]);
|
|
63
|
-
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
void loadStats();
|
|
66
|
-
}, [loadStats]);
|
|
67
|
-
|
|
68
|
-
// Prepare data for charts - must be before any conditional returns
|
|
69
|
-
const statusCounts = useMemo(() => stats?.specsByStatus.reduce<Record<string, number>>((acc: Record<string, number>, entry: { status: string; count: number }) => {
|
|
70
|
-
acc[entry.status] = entry.count;
|
|
71
|
-
return acc;
|
|
72
|
-
}, {}) || {}, [stats?.specsByStatus]);
|
|
73
|
-
|
|
74
|
-
const statusData = useMemo(() => stats?.specsByStatus.map(({ status, count }: { status: string; count: number }) => ({
|
|
75
|
-
name: t(`status.${status}`, { defaultValue: status }),
|
|
76
|
-
value: count,
|
|
77
|
-
fill: STATUS_COLORS[status as keyof typeof STATUS_COLORS] || '#6B7280',
|
|
78
|
-
})) || [], [stats?.specsByStatus, t]);
|
|
79
|
-
|
|
80
|
-
const priorityData = useMemo(() => (stats?.specsByPriority || []).map(({ priority, count }: { priority: string; count: number }) => ({
|
|
81
|
-
name: t(`priority.${priority}`, { defaultValue: priority }),
|
|
82
|
-
value: count,
|
|
83
|
-
fill: PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#6B7280',
|
|
84
|
-
})), [stats?.specsByPriority, t]);
|
|
85
|
-
|
|
86
|
-
const topTags = useMemo(() => {
|
|
87
|
-
const tagFrequency = specs.reduce<Record<string, number>>((acc, spec) => {
|
|
88
|
-
(spec.tags || []).forEach((tag: string) => {
|
|
89
|
-
acc[tag] = (acc[tag] || 0) + 1;
|
|
90
|
-
});
|
|
91
|
-
return acc;
|
|
92
|
-
}, {});
|
|
93
|
-
|
|
94
|
-
return Object.entries(tagFrequency)
|
|
95
|
-
.sort(([, a], [, b]) => b - a)
|
|
96
|
-
.slice(0, 10)
|
|
97
|
-
.map(([tag, count]) => ({ tag, count }));
|
|
98
|
-
}, [specs]);
|
|
99
|
-
|
|
100
|
-
const trendData = useMemo(() => {
|
|
101
|
-
const monthFormatter = new Intl.DateTimeFormat(i18n.language, { year: 'numeric', month: 'short' });
|
|
102
|
-
const monthly = specs
|
|
103
|
-
.filter((spec) => spec.createdAt)
|
|
104
|
-
.reduce<Record<string, number>>((acc, spec) => {
|
|
105
|
-
try {
|
|
106
|
-
const date = typeof spec.createdAt === 'string'
|
|
107
|
-
? new Date(spec.createdAt)
|
|
108
|
-
: spec.createdAt;
|
|
109
|
-
|
|
110
|
-
if (date instanceof Date && !isNaN(date.getTime())) {
|
|
111
|
-
const month = monthFormatter.format(date);
|
|
112
|
-
acc[month] = (acc[month] || 0) + 1;
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
// Skip invalid dates
|
|
116
|
-
}
|
|
117
|
-
return acc;
|
|
118
|
-
}, {});
|
|
119
|
-
|
|
120
|
-
return Object.entries(monthly)
|
|
121
|
-
.slice(-6)
|
|
122
|
-
.map(([month, count]) => ({ month, count }));
|
|
123
|
-
}, [i18n.language, specs]);
|
|
124
|
-
|
|
125
|
-
const completionRate = stats?.completionRate.toFixed(1) || '0.0';
|
|
126
|
-
|
|
127
|
-
if (projectLoading || loading) {
|
|
128
|
-
return <StatsSkeleton />;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (!currentProject) {
|
|
132
|
-
return (
|
|
133
|
-
<Card>
|
|
134
|
-
<CardContent className="py-10 text-center space-y-3">
|
|
135
|
-
<div className="text-lg font-semibold">{t('statsPage.state.noProjectTitle', { defaultValue: 'No project selected' })}</div>
|
|
136
|
-
<p className="text-sm text-muted-foreground">
|
|
137
|
-
{t('statsPage.state.noProjectDescription', { defaultValue: 'Select or create a project to view statistics.' })}
|
|
138
|
-
</p>
|
|
139
|
-
<Link to="/projects" className="inline-flex">
|
|
140
|
-
<Button variant="secondary" size="sm">{t('projectsPage.title', { defaultValue: 'Projects' })}</Button>
|
|
141
|
-
</Link>
|
|
142
|
-
</CardContent>
|
|
143
|
-
</Card>
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (error || !stats) {
|
|
148
|
-
return (
|
|
149
|
-
<Card>
|
|
150
|
-
<CardContent className="py-10 text-center space-y-3">
|
|
151
|
-
<div className="flex justify-center">
|
|
152
|
-
<AlertCircle className="h-6 w-6 text-destructive" />
|
|
153
|
-
</div>
|
|
154
|
-
<div className="text-lg font-semibold">{t('statsPage.state.errorTitle')}</div>
|
|
155
|
-
<p className="text-sm text-muted-foreground">{error || t('statsPage.state.unknownError')}</p>
|
|
156
|
-
<Button variant="secondary" size="sm" onClick={loadStats} className="mt-2">
|
|
157
|
-
{t('actions.retry')}
|
|
158
|
-
</Button>
|
|
159
|
-
</CardContent>
|
|
160
|
-
</Card>
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return (
|
|
165
|
-
<div className="space-y-6 p-6">
|
|
166
|
-
<PageHeader
|
|
167
|
-
title={t('statsPage.title')}
|
|
168
|
-
description={t('statsPage.description')}
|
|
169
|
-
/>
|
|
170
|
-
|
|
171
|
-
{/* Summary Cards */}
|
|
172
|
-
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
|
173
|
-
<StatCard
|
|
174
|
-
title={t('statsPage.cards.total.title')}
|
|
175
|
-
value={stats.totalSpecs}
|
|
176
|
-
icon={FileText}
|
|
177
|
-
iconColor="text-blue-600"
|
|
178
|
-
gradientFrom="from-blue-500/10"
|
|
179
|
-
/>
|
|
180
|
-
<StatCard
|
|
181
|
-
title={t('statsPage.cards.planned.title')}
|
|
182
|
-
value={statusCounts.planned || 0}
|
|
183
|
-
icon={Clock}
|
|
184
|
-
iconColor="text-purple-600"
|
|
185
|
-
gradientFrom="from-purple-500/10"
|
|
186
|
-
/>
|
|
187
|
-
<StatCard
|
|
188
|
-
title={t('statsPage.cards.inProgress.title')}
|
|
189
|
-
value={statusCounts['in-progress'] || 0}
|
|
190
|
-
icon={PlayCircle}
|
|
191
|
-
iconColor="text-orange-600"
|
|
192
|
-
gradientFrom="from-orange-500/10"
|
|
193
|
-
/>
|
|
194
|
-
<StatCard
|
|
195
|
-
title={t('statsPage.cards.completed.title')}
|
|
196
|
-
value={statusCounts.complete || 0}
|
|
197
|
-
icon={CheckCircle2}
|
|
198
|
-
iconColor="text-green-600"
|
|
199
|
-
gradientFrom="from-green-500/10"
|
|
200
|
-
subtext={
|
|
201
|
-
<span className="flex items-center gap-1">
|
|
202
|
-
<TrendingUp className="h-3 w-3" />
|
|
203
|
-
{completionRate}% {t('statsPage.cards.completed.subtitle')}
|
|
204
|
-
</span>
|
|
205
|
-
}
|
|
206
|
-
/>
|
|
207
|
-
</div>
|
|
208
|
-
|
|
209
|
-
{/* Charts */}
|
|
210
|
-
<div className="grid gap-6 md:grid-cols-2">
|
|
211
|
-
{/* Status Distribution - Pie Chart */}
|
|
212
|
-
<Card>
|
|
213
|
-
<CardHeader>
|
|
214
|
-
<CardTitle>{t('statsPage.charts.status.title')}</CardTitle>
|
|
215
|
-
</CardHeader>
|
|
216
|
-
<CardContent>
|
|
217
|
-
<ResponsiveContainer width="100%" height={300}>
|
|
218
|
-
<PieChart>
|
|
219
|
-
<Pie
|
|
220
|
-
data={statusData}
|
|
221
|
-
cx="50%"
|
|
222
|
-
cy="50%"
|
|
223
|
-
labelLine={false}
|
|
224
|
-
label={({ name, value }) => t('statsPage.charts.label', { name, value })}
|
|
225
|
-
outerRadius={80}
|
|
226
|
-
dataKey="value"
|
|
227
|
-
>
|
|
228
|
-
{statusData.map((entry: { name: string; value: number; fill: string }, index: number) => (
|
|
229
|
-
<Cell key={`cell-${index}`} fill={entry.fill} />
|
|
230
|
-
))}
|
|
231
|
-
</Pie>
|
|
232
|
-
<Tooltip />
|
|
233
|
-
<Legend />
|
|
234
|
-
</PieChart>
|
|
235
|
-
</ResponsiveContainer>
|
|
236
|
-
</CardContent>
|
|
237
|
-
</Card>
|
|
238
|
-
|
|
239
|
-
{/* Priority Distribution - Bar Chart */}
|
|
240
|
-
<Card>
|
|
241
|
-
<CardHeader>
|
|
242
|
-
<CardTitle>{t('statsPage.charts.priority.title')}</CardTitle>
|
|
243
|
-
</CardHeader>
|
|
244
|
-
<CardContent>
|
|
245
|
-
<ResponsiveContainer width="100%" height={300}>
|
|
246
|
-
<BarChart data={priorityData}>
|
|
247
|
-
<CartesianGrid strokeDasharray="3 3" />
|
|
248
|
-
<XAxis dataKey="name" />
|
|
249
|
-
<YAxis />
|
|
250
|
-
<Tooltip />
|
|
251
|
-
<Legend />
|
|
252
|
-
<Bar dataKey="value" fill="#3B82F6" />
|
|
253
|
-
</BarChart>
|
|
254
|
-
</ResponsiveContainer>
|
|
255
|
-
</CardContent>
|
|
256
|
-
</Card>
|
|
257
|
-
</div>
|
|
258
|
-
|
|
259
|
-
<div className="grid gap-6 md:grid-cols-2">
|
|
260
|
-
{trendData.length > 0 && (
|
|
261
|
-
<Card>
|
|
262
|
-
<CardHeader>
|
|
263
|
-
<CardTitle>{t('statsPage.charts.creation.title')}</CardTitle>
|
|
264
|
-
</CardHeader>
|
|
265
|
-
<CardContent>
|
|
266
|
-
<ResponsiveContainer width="100%" height={300}>
|
|
267
|
-
<BarChart data={trendData}>
|
|
268
|
-
<CartesianGrid strokeDasharray="3 3" />
|
|
269
|
-
<XAxis dataKey="month" />
|
|
270
|
-
<YAxis allowDecimals={false} />
|
|
271
|
-
<Tooltip />
|
|
272
|
-
<Bar dataKey="count" fill="#3B82F6" />
|
|
273
|
-
</BarChart>
|
|
274
|
-
</ResponsiveContainer>
|
|
275
|
-
</CardContent>
|
|
276
|
-
</Card>
|
|
277
|
-
)}
|
|
278
|
-
|
|
279
|
-
{topTags.length > 0 && (
|
|
280
|
-
<Card>
|
|
281
|
-
<CardHeader>
|
|
282
|
-
<CardTitle>{t('statsPage.charts.topTags.title')}</CardTitle>
|
|
283
|
-
</CardHeader>
|
|
284
|
-
<CardContent>
|
|
285
|
-
<div className="space-y-3">
|
|
286
|
-
{topTags.map(({ tag, count }) => (
|
|
287
|
-
<div key={tag} className="flex items-center justify-between gap-3">
|
|
288
|
-
<span className="text-sm font-medium truncate">{tag}</span>
|
|
289
|
-
<div className="flex items-center gap-2 w-40">
|
|
290
|
-
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
|
291
|
-
<div
|
|
292
|
-
className="h-full bg-primary"
|
|
293
|
-
style={{ width: `${(count / topTags[0].count) * 100}%` }}
|
|
294
|
-
/>
|
|
295
|
-
</div>
|
|
296
|
-
<span className="text-xs text-muted-foreground w-6 text-right">{count}</span>
|
|
297
|
-
</div>
|
|
298
|
-
</div>
|
|
299
|
-
))}
|
|
300
|
-
</div>
|
|
301
|
-
</CardContent>
|
|
302
|
-
</Card>
|
|
303
|
-
)}
|
|
304
|
-
</div>
|
|
305
|
-
</div>
|
|
306
|
-
);
|
|
307
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { RouteObject } from 'react-router-dom';
|
|
2
|
-
|
|
3
|
-
import { SpecDetailLayout } from '../components/SpecDetailLayout';
|
|
4
|
-
import { ContextPage } from '../pages/ContextPage';
|
|
5
|
-
import { DashboardPage } from '../pages/DashboardPage';
|
|
6
|
-
import { DependenciesPage } from '../pages/DependenciesPage';
|
|
7
|
-
import { SpecDetailPage } from '../pages/SpecDetailPage';
|
|
8
|
-
import { SpecsPage } from '../pages/SpecsPage';
|
|
9
|
-
import { StatsPage } from '../pages/StatsPage';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Shared project-scoped route definitions.
|
|
13
|
-
*
|
|
14
|
-
* Both the web app (@leanspec/ui) and desktop app (@leanspec/desktop)
|
|
15
|
-
* compose these under their own top-level layouts and router types
|
|
16
|
-
* (browser vs hash).
|
|
17
|
-
*/
|
|
18
|
-
export function createProjectRoutes(): RouteObject[] {
|
|
19
|
-
return [
|
|
20
|
-
{ index: true, element: <DashboardPage /> },
|
|
21
|
-
{
|
|
22
|
-
path: 'specs',
|
|
23
|
-
children: [
|
|
24
|
-
{ index: true, element: <SpecsPage /> },
|
|
25
|
-
{
|
|
26
|
-
element: <SpecDetailLayout />,
|
|
27
|
-
children: [{ path: ':specName', element: <SpecDetailPage /> }],
|
|
28
|
-
},
|
|
29
|
-
],
|
|
30
|
-
},
|
|
31
|
-
{ path: 'stats', element: <StatsPage /> },
|
|
32
|
-
{ path: 'dependencies', element: <DependenciesPage /> },
|
|
33
|
-
{ path: 'dependencies/:specName', element: <DependenciesPage /> },
|
|
34
|
-
{ path: 'context', element: <ContextPage /> },
|
|
35
|
-
];
|
|
36
|
-
}
|
package/src/router.tsx
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { createBrowserRouter } from 'react-router-dom';
|
|
2
|
-
import { Layout } from './components/Layout';
|
|
3
|
-
import { ProjectsPage } from './pages/ProjectsPage';
|
|
4
|
-
import { RootRedirect } from './components/RootRedirect';
|
|
5
|
-
import { createProjectRoutes } from './router/projectRoutes';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Router configuration for @leanspec/ui (Vite SPA).
|
|
9
|
-
*
|
|
10
|
-
* Layout hierarchy:
|
|
11
|
-
* - Layout: Navigation + MainSidebar (for all project routes including projects list)
|
|
12
|
-
*
|
|
13
|
-
* This nested layout approach ensures:
|
|
14
|
-
* 1. Navigation bar is always present across all pages
|
|
15
|
-
* 2. MainSidebar is always visible for consistent navigation
|
|
16
|
-
* 3. SpecDetailLayout provides SpecsNavSidebar + outlet context
|
|
17
|
-
*/
|
|
18
|
-
export const router = createBrowserRouter([
|
|
19
|
-
{
|
|
20
|
-
path: '/',
|
|
21
|
-
element: <RootRedirect />,
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
path: '/projects',
|
|
25
|
-
element: <Layout />,
|
|
26
|
-
children: [{ index: true, element: <ProjectsPage /> }],
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
path: '/projects/:projectId',
|
|
30
|
-
element: <Layout />,
|
|
31
|
-
children: createProjectRoutes(),
|
|
32
|
-
},
|
|
33
|
-
]);
|