@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.
Files changed (150) hide show
  1. package/bin/leanspec-ui.js +191 -0
  2. package/dist/assets/_baseUniq-CRqreL7N.js +1 -0
  3. package/dist/assets/arc-DMhx9AJT.js +1 -0
  4. package/dist/assets/architectureDiagram-VXUJARFQ-DM0L0YzO.js +36 -0
  5. package/dist/assets/blockDiagram-VD42YOAC-DHQXDHsD.js +122 -0
  6. package/dist/assets/c4Diagram-YG6GDRKO-0L7o2gpH.js +10 -0
  7. package/dist/assets/channel-2tOl0nAZ.js +1 -0
  8. package/dist/assets/chunk-4BX2VUAB-CwFT-Uaj.js +1 -0
  9. package/dist/assets/chunk-55IACEB6-CjvuUHHG.js +1 -0
  10. package/dist/assets/chunk-B4BG7PRW-BRJBysMK.js +165 -0
  11. package/dist/assets/chunk-DI55MBZ5-BnNEeoaA.js +220 -0
  12. package/dist/assets/chunk-FMBD7UC4-BK2l30pm.js +15 -0
  13. package/dist/assets/chunk-QN33PNHL-BN_cZkCU.js +1 -0
  14. package/dist/assets/chunk-QZHKN3VN-Brc3Yrub.js +1 -0
  15. package/dist/assets/chunk-TZMSLE5B-D2zzpLfO.js +1 -0
  16. package/dist/assets/classDiagram-2ON5EDUG-BB9CSNmS.js +1 -0
  17. package/dist/assets/classDiagram-v2-WZHVMYZB-BB9CSNmS.js +1 -0
  18. package/dist/assets/clone-BjxVFtyI.js +1 -0
  19. package/dist/assets/core-DV6XEvTN.js +1 -0
  20. package/dist/assets/cose-bilkent-S5V4N54A-CLJgM3XR.js +1 -0
  21. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  22. package/dist/assets/dagre-6UL2VRFP-_IFvBJKJ.js +4 -0
  23. package/dist/assets/diagram-PSM6KHXK--83HIYSQ.js +24 -0
  24. package/dist/assets/diagram-QEK2KX5R-6jAWnCnZ.js +43 -0
  25. package/dist/assets/diagram-S2PKOQOG-D5pwHvjZ.js +24 -0
  26. package/dist/assets/erDiagram-Q2GNP2WA-B4FV3mTd.js +60 -0
  27. package/dist/assets/flowDiagram-NV44I4VS-mtD2kF4M.js +162 -0
  28. package/dist/assets/ganttDiagram-JELNMOA3-BKALgqTK.js +267 -0
  29. package/dist/assets/gitGraphDiagram-NY62KEGX-Bd7r0pAf.js +65 -0
  30. package/dist/assets/graph-B2rEI7cK.js +1 -0
  31. package/dist/assets/index-Bekv_o1t.css +1 -0
  32. package/dist/assets/index-DSRxU-E5.js +389 -0
  33. package/dist/assets/infoDiagram-WHAUD3N6--nJOBKqh.js +2 -0
  34. package/dist/assets/journeyDiagram-XKPGCS4Q-BzGutKN3.js +139 -0
  35. package/dist/assets/kanban-definition-3W4ZIXB7-DyQO17vq.js +89 -0
  36. package/dist/assets/katex-XbL3y5x-.js +261 -0
  37. package/dist/assets/layout-iCSHU015.js +1 -0
  38. package/dist/assets/min-BK_AIJdo.js +1 -0
  39. package/dist/assets/mindmap-definition-VGOIOE7T-BZMj_6zo.js +68 -0
  40. package/dist/assets/pieDiagram-ADFJNKIX-CkAGsq9p.js +30 -0
  41. package/dist/assets/quadrantDiagram-AYHSOK5B-CWa93px1.js +7 -0
  42. package/dist/assets/requirementDiagram-UZGBJVZJ-CufFVR8c.js +64 -0
  43. package/dist/assets/sankeyDiagram-TZEHDZUN-BEPgVgU4.js +10 -0
  44. package/dist/assets/sequenceDiagram-WL72ISMW-BkdBWhel.js +145 -0
  45. package/dist/assets/stateDiagram-FKZM4ZOC-D5T73yx0.js +1 -0
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-9hJWG2n6.js +1 -0
  47. package/dist/assets/timeline-definition-IT6M3QCI-CX7kTdU2.js +61 -0
  48. package/dist/assets/treemap-KMMF4GRG-ftWCQ9lJ.js +128 -0
  49. package/dist/assets/xychartDiagram-PRI3JC2R-Ngrels4n.js +7 -0
  50. package/{index.html → dist/index.html} +2 -1
  51. package/package.json +12 -2
  52. package/eslint.config.js +0 -23
  53. package/package.json.backup +0 -83
  54. package/postcss.config.js +0 -6
  55. package/src/App.css +0 -42
  56. package/src/App.tsx +0 -17
  57. package/src/assets/react.svg +0 -1
  58. package/src/components/LanguageSwitcher.tsx +0 -67
  59. package/src/components/Layout.tsx +0 -88
  60. package/src/components/MainSidebar.tsx +0 -163
  61. package/src/components/MermaidDiagram.tsx +0 -85
  62. package/src/components/MinimalLayout.tsx +0 -51
  63. package/src/components/Navigation.tsx +0 -254
  64. package/src/components/PriorityBadge.tsx +0 -59
  65. package/src/components/ProjectSwitcher.tsx +0 -222
  66. package/src/components/QuickSearch.tsx +0 -225
  67. package/src/components/RootRedirect.tsx +0 -40
  68. package/src/components/SpecDetailLayout.context.ts +0 -10
  69. package/src/components/SpecDetailLayout.tsx +0 -14
  70. package/src/components/SpecsNavSidebar.tsx +0 -615
  71. package/src/components/StatusBadge.tsx +0 -59
  72. package/src/components/ThemeToggle.tsx +0 -25
  73. package/src/components/Tooltip.tsx +0 -29
  74. package/src/components/context/ContextClient.tsx +0 -471
  75. package/src/components/context/ContextFileDetail.tsx +0 -163
  76. package/src/components/dashboard/ActivityItem.tsx +0 -36
  77. package/src/components/dashboard/DashboardClient.tsx +0 -218
  78. package/src/components/dashboard/SpecListItem.tsx +0 -58
  79. package/src/components/dashboard/StatCard.tsx +0 -52
  80. package/src/components/dependencies/SpecNode.tsx +0 -128
  81. package/src/components/dependencies/SpecSidebar.tsx +0 -256
  82. package/src/components/dependencies/constants.ts +0 -25
  83. package/src/components/dependencies/types.ts +0 -38
  84. package/src/components/dependencies/utils.ts +0 -261
  85. package/src/components/metadata-editors/PriorityEditor.tsx +0 -89
  86. package/src/components/metadata-editors/StatusEditor.tsx +0 -85
  87. package/src/components/metadata-editors/TagsEditor.tsx +0 -207
  88. package/src/components/projects/CreateProjectDialog.tsx +0 -162
  89. package/src/components/projects/DirectoryPicker.tsx +0 -182
  90. package/src/components/shared/BackToTop.tsx +0 -39
  91. package/src/components/shared/ColorPicker.tsx +0 -68
  92. package/src/components/shared/EmptyState.tsx +0 -35
  93. package/src/components/shared/ErrorBoundary.tsx +0 -79
  94. package/src/components/shared/PageHeader.tsx +0 -23
  95. package/src/components/shared/PageTransition.tsx +0 -40
  96. package/src/components/shared/ProjectAvatar.tsx +0 -107
  97. package/src/components/shared/Skeletons.tsx +0 -184
  98. package/src/components/spec-detail/EditableMetadata.tsx +0 -129
  99. package/src/components/spec-detail/MarkdownRenderer.tsx +0 -47
  100. package/src/components/spec-detail/TableOfContents.tsx +0 -150
  101. package/src/components/specs/BoardView.tsx +0 -204
  102. package/src/components/specs/ListView.tsx +0 -62
  103. package/src/components/specs/SpecsFilters.tsx +0 -190
  104. package/src/contexts/KeyboardShortcutsContext.tsx +0 -95
  105. package/src/contexts/LayoutContext.tsx +0 -45
  106. package/src/contexts/ProjectContext.tsx +0 -163
  107. package/src/contexts/ThemeContext.tsx +0 -90
  108. package/src/contexts/index.ts +0 -7
  109. package/src/hooks/useKeyboardShortcuts.ts +0 -87
  110. package/src/index.css +0 -624
  111. package/src/lib/api.ts +0 -72
  112. package/src/lib/backend-adapter.ts +0 -382
  113. package/src/lib/date-utils.ts +0 -122
  114. package/src/lib/i18n.test.ts +0 -57
  115. package/src/lib/i18n.ts +0 -51
  116. package/src/lib/markdown-utils.ts +0 -38
  117. package/src/lib/sub-spec-utils.ts +0 -166
  118. package/src/lib/utils.ts +0 -6
  119. package/src/locales/en/common.json +0 -660
  120. package/src/locales/en/errors.json +0 -20
  121. package/src/locales/en/help.json +0 -8
  122. package/src/locales/zh-CN/common.json +0 -660
  123. package/src/locales/zh-CN/errors.json +0 -20
  124. package/src/locales/zh-CN/help.json +0 -8
  125. package/src/main.tsx +0 -12
  126. package/src/pages/ContextPage.tsx +0 -111
  127. package/src/pages/DashboardPage.tsx +0 -97
  128. package/src/pages/DependenciesPage.tsx +0 -881
  129. package/src/pages/ProjectsPage.tsx +0 -432
  130. package/src/pages/SpecDetailPage.tsx +0 -592
  131. package/src/pages/SpecsPage.tsx +0 -319
  132. package/src/pages/StatsPage.tsx +0 -307
  133. package/src/router/projectRoutes.tsx +0 -36
  134. package/src/router.tsx +0 -33
  135. package/src/test/setup.ts +0 -39
  136. package/src/types/api.ts +0 -185
  137. package/tailwind.config.ts +0 -57
  138. package/tsconfig.app.json +0 -29
  139. package/tsconfig.json +0 -7
  140. package/tsconfig.node.json +0 -26
  141. package/tsconfig.tsbuildinfo +0 -1
  142. package/vite.config.ts +0 -27
  143. package/vitest.config.ts +0 -18
  144. /package/{public → dist}/favicon.ico +0 -0
  145. /package/{public → dist}/github-mark-white.svg +0 -0
  146. /package/{public → dist}/github-mark.svg +0 -0
  147. /package/{public → dist}/logo-dark-bg.svg +0 -0
  148. /package/{public → dist}/logo-with-bg.svg +0 -0
  149. /package/{public → dist}/logo.svg +0 -0
  150. /package/{public → dist}/vite.svg +0 -0
@@ -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
- }
@@ -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
- ]);