@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,615 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
2
- import { Link, useLocation, useParams } from 'react-router-dom';
3
- import {
4
- Search,
5
- Filter,
6
- X,
7
- ChevronLeft,
8
- ChevronRight,
9
- Clock,
10
- PlayCircle,
11
- CheckCircle2,
12
- Archive,
13
- AlertCircle,
14
- ArrowUp,
15
- Minus,
16
- ArrowDown,
17
- Check,
18
- } from 'lucide-react';
19
- import {
20
- Button,
21
- Input,
22
- Popover,
23
- PopoverContent,
24
- PopoverTrigger,
25
- } from '@leanspec/ui-components';
26
- import {
27
- List,
28
- type ListImperativeAPI,
29
- } from 'react-window';
30
- import { StatusBadge, getStatusLabel } from './StatusBadge';
31
- import { PriorityBadge, getPriorityLabel } from './PriorityBadge';
32
- import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './Tooltip';
33
- import { api } from '../lib/api';
34
- import type { Spec } from '../types/api';
35
- import { cn } from '../lib/utils';
36
- import { formatRelativeTime } from '../lib/date-utils';
37
- import { useTranslation } from 'react-i18next';
38
- import { useProject } from '../contexts';
39
-
40
- const STORAGE_KEYS = {
41
- collapsed: 'specs-nav-sidebar-collapsed',
42
- scroll: 'specs-nav-sidebar-scroll-offset',
43
- statusFilter: 'specs-nav-sidebar-status-filter',
44
- priorityFilter: 'specs-nav-sidebar-priority-filter',
45
- tagFilter: 'specs-nav-sidebar-tag-filter',
46
- };
47
-
48
- interface SpecsNavSidebarProps {
49
- mobileOpen?: boolean;
50
- onMobileOpenChange?: (open: boolean) => void;
51
- }
52
-
53
- export function SpecsNavSidebar({ mobileOpen = false, onMobileOpenChange }: SpecsNavSidebarProps) {
54
- const location = useLocation();
55
- const { projectId } = useParams<{ projectId: string }>();
56
- const { currentProject } = useProject();
57
- const basePath = projectId ? `/projects/${projectId}` : '/projects/default';
58
- const [specs, setSpecs] = useState<Spec[]>([]);
59
- const [loading, setLoading] = useState(true);
60
- const [searchQuery, setSearchQuery] = useState('');
61
- const [statusFilter, setStatusFilter] = useState<string[]>(() => {
62
- if (typeof window === 'undefined') return [];
63
- const stored = sessionStorage.getItem(STORAGE_KEYS.statusFilter);
64
- return stored ? JSON.parse(stored) : [];
65
- });
66
- const [priorityFilter, setPriorityFilter] = useState<string[]>(() => {
67
- if (typeof window === 'undefined') return [];
68
- const stored = sessionStorage.getItem(STORAGE_KEYS.priorityFilter);
69
- return stored ? JSON.parse(stored) : [];
70
- });
71
- const [tagFilter, setTagFilter] = useState<string[]>(() => {
72
- if (typeof window === 'undefined') return [];
73
- const stored = sessionStorage.getItem(STORAGE_KEYS.tagFilter);
74
- return stored ? JSON.parse(stored) : [];
75
- });
76
- const [showFilters, setShowFilters] = useState(false);
77
- const [collapsed, setCollapsed] = useState<boolean>(() => {
78
- if (typeof window === 'undefined') return false;
79
- return localStorage.getItem(STORAGE_KEYS.collapsed) === 'true';
80
- });
81
- const [listHeight, setListHeight] = useState<number>(() => calculateListHeight());
82
- const [initialScrollOffset] = useState<number>(() => {
83
- if (typeof window === 'undefined') return 0;
84
- const stored = sessionStorage.getItem(STORAGE_KEYS.scroll);
85
- return stored ? parseFloat(stored) : 0;
86
- });
87
- const { t, i18n } = useTranslation('common');
88
-
89
- const listRef = useRef<ListImperativeAPI>(null);
90
- const mobileOpenRef = useRef(mobileOpen);
91
- const hasRestoredInitialScroll = useRef(false);
92
-
93
- const activeSpecId = useMemo(() => {
94
- const match = location.pathname.match(/\/specs\/(.+)$/);
95
- return match ? decodeURIComponent(match[1]) : '';
96
- }, [location.pathname]);
97
-
98
- const prevActiveSpecId = useRef(activeSpecId);
99
-
100
- useEffect(() => {
101
- // Wait for project to be available before loading specs
102
- if (!currentProject) {
103
- setLoading(true);
104
- return;
105
- }
106
-
107
- async function loadSpecs() {
108
- try {
109
- setLoading(true);
110
- const data = await api.getSpecs();
111
- setSpecs(data);
112
- } catch (err) {
113
- console.error('Failed to load specs for sidebar', err);
114
- } finally {
115
- setLoading(false);
116
- }
117
- }
118
- loadSpecs();
119
- }, [currentProject]);
120
-
121
- useEffect(() => {
122
- const handler = () => setListHeight(calculateListHeight());
123
- handler();
124
- window.addEventListener('resize', handler);
125
- return () => window.removeEventListener('resize', handler);
126
- }, []);
127
-
128
- useEffect(() => {
129
- document.documentElement.style.setProperty(
130
- '--specs-nav-sidebar-width',
131
- collapsed ? '0px' : '280px'
132
- );
133
- localStorage.setItem(STORAGE_KEYS.collapsed, String(collapsed));
134
- }, [collapsed]);
135
-
136
- useEffect(() => {
137
- mobileOpenRef.current = mobileOpen;
138
- }, [mobileOpen]);
139
-
140
- useEffect(() => {
141
- if (!mobileOpenRef.current) return;
142
- onMobileOpenChange?.(false);
143
- }, [location.pathname, onMobileOpenChange]);
144
-
145
- const filteredSpecs = useMemo(() => {
146
- let result = specs;
147
-
148
- if (searchQuery) {
149
- const query = searchQuery.toLowerCase();
150
- result = result.filter(
151
- (spec) =>
152
- spec.title?.toLowerCase().includes(query) ||
153
- spec.specName.toLowerCase().includes(query) ||
154
- spec.specNumber?.toString().includes(query)
155
- );
156
- }
157
-
158
- if (statusFilter.length > 0) {
159
- result = result.filter((spec) => spec.status && statusFilter.includes(spec.status));
160
- }
161
-
162
- if (priorityFilter.length > 0) {
163
- result = result.filter((spec) => spec.priority && priorityFilter.includes(spec.priority));
164
- }
165
-
166
- if (tagFilter.length > 0) {
167
- result = result.filter((spec) =>
168
- spec.tags?.some((tag: string) => tagFilter.includes(tag))
169
- );
170
- }
171
-
172
- return [...result].sort((a, b) => (b.specNumber || 0) - (a.specNumber || 0));
173
- }, [specs, searchQuery, statusFilter, priorityFilter, tagFilter]);
174
-
175
- const RowComponent = useCallback(
176
- (rowProps: { index: number; style: CSSProperties }) => {
177
- const { index, style } = rowProps;
178
- const spec = filteredSpecs[index];
179
- const isActive = spec?.specName === activeSpecId;
180
- const displayTitle = spec?.title || spec?.specName;
181
-
182
- if (!spec) {
183
- return <div style={style} />;
184
- }
185
-
186
- return (
187
- <div style={style} className="px-1">
188
- <div className="mb-0.5">
189
- <Tooltip>
190
- <TooltipTrigger asChild>
191
- <Link
192
- to={`${basePath}/specs/${spec.specName}`}
193
- onClick={() => onMobileOpenChange?.(false)}
194
- className={cn(
195
- 'flex flex-col gap-1 p-1.5 rounded-md text-sm transition-colors',
196
- isActive
197
- ? 'bg-accent text-accent-foreground font-medium'
198
- : 'hover:bg-accent/50'
199
- )}
200
- >
201
- <div className="flex items-center gap-1.5">
202
- {spec.specNumber && (
203
- <span className="text-xs font-mono text-muted-foreground shrink-0">
204
- #{spec.specNumber.toString().padStart(3, '0')}
205
- </span>
206
- )}
207
- <span className="truncate text-xs leading-relaxed">{displayTitle}</span>
208
- </div>
209
- <div className="flex items-center gap-1.5 flex-wrap">
210
- {spec.status && (
211
- <Tooltip>
212
- <TooltipTrigger asChild>
213
- <div>
214
- <StatusBadge status={spec.status} iconOnly className="text-[10px] scale-90" />
215
- </div>
216
- </TooltipTrigger>
217
- <TooltipContent side="right">
218
- {getStatusLabel(spec.status, t)}
219
- </TooltipContent>
220
- </Tooltip>
221
- )}
222
- {spec.priority && (
223
- <Tooltip>
224
- <TooltipTrigger asChild>
225
- <div>
226
- <PriorityBadge priority={spec.priority} iconOnly className="text-[10px] scale-90" />
227
- </div>
228
- </TooltipTrigger>
229
- <TooltipContent side="right">
230
- {getPriorityLabel(spec.priority, t)}
231
- </TooltipContent>
232
- </Tooltip>
233
- )}
234
- {spec.updatedAt && (
235
- <span className="text-[10px] text-muted-foreground">
236
- {formatRelativeTime(spec.updatedAt, i18n.language)}
237
- </span>
238
- )}
239
- </div>
240
- </Link>
241
- </TooltipTrigger>
242
- <TooltipContent side="right" className="max-w-[300px]">
243
- <div className="space-y-1">
244
- <div className="font-semibold">{displayTitle}</div>
245
- <div className="text-xs text-muted-foreground">{spec.specName}</div>
246
- </div>
247
- </TooltipContent>
248
- </Tooltip>
249
- </div>
250
- </div>
251
- );
252
- },
253
- [activeSpecId, basePath, filteredSpecs, i18n.language, onMobileOpenChange, t]
254
- );
255
-
256
- const allTags = useMemo(() => {
257
- const set = new Set<string>();
258
- specs.forEach((spec) => spec.tags?.forEach((tag: string) => set.add(tag)));
259
- return Array.from(set).sort();
260
- }, [specs]);
261
-
262
- const hasActiveFilters =
263
- statusFilter.length > 0 || priorityFilter.length > 0 || tagFilter.length > 0;
264
-
265
- // Persist filters to sessionStorage
266
- useEffect(() => {
267
- sessionStorage.setItem(STORAGE_KEYS.statusFilter, JSON.stringify(statusFilter));
268
- }, [statusFilter]);
269
-
270
- useEffect(() => {
271
- sessionStorage.setItem(STORAGE_KEYS.priorityFilter, JSON.stringify(priorityFilter));
272
- }, [priorityFilter]);
273
-
274
- useEffect(() => {
275
- sessionStorage.setItem(STORAGE_KEYS.tagFilter, JSON.stringify(tagFilter));
276
- }, [tagFilter]);
277
-
278
- // Restore initial scroll position only once on mount
279
- useEffect(() => {
280
- const el = listRef.current?.element;
281
- if (!el || hasRestoredInitialScroll.current) return;
282
-
283
- if (initialScrollOffset > 0) {
284
- el.scrollTop = initialScrollOffset;
285
- hasRestoredInitialScroll.current = true;
286
- }
287
- }, [initialScrollOffset, listHeight, showFilters, filteredSpecs.length]);
288
-
289
- // Scroll to active spec when it changes or on initial load (if no stored scroll offset)
290
- useEffect(() => {
291
- // Wait until the specs are loaded AND the list is actually rendered.
292
- if (loading) return;
293
- if (!activeSpecId || filteredSpecs.length === 0) return;
294
- if (!listRef.current) return;
295
-
296
- // Skip if active spec hasn't changed and we've already handled the initial scroll behavior.
297
- if (prevActiveSpecId.current === activeSpecId && hasRestoredInitialScroll.current) return;
298
-
299
- const targetIndex = filteredSpecs.findIndex((spec) => spec.specName === activeSpecId);
300
- if (targetIndex >= 0) {
301
- // Defer to the next frame so the list has committed layout.
302
- const raf = requestAnimationFrame(() => {
303
- listRef.current?.scrollToRow({ index: targetIndex, align: 'smart', behavior: 'smooth' });
304
- });
305
- hasRestoredInitialScroll.current = true;
306
- prevActiveSpecId.current = activeSpecId;
307
- return () => cancelAnimationFrame(raf);
308
- }
309
-
310
- // Active spec isn't currently visible (e.g., filtered out). Don't mark initial scroll
311
- // as handled so we can scroll once it becomes visible again.
312
- prevActiveSpecId.current = activeSpecId;
313
- }, [filteredSpecs, activeSpecId, initialScrollOffset, loading]);
314
-
315
- useEffect(() => {
316
- const el = listRef.current?.element;
317
- if (!el) return;
318
-
319
- const onScroll = () => {
320
- sessionStorage.setItem(STORAGE_KEYS.scroll, String(el.scrollTop));
321
- };
322
-
323
- el.addEventListener('scroll', onScroll, { passive: true });
324
- return () => el.removeEventListener('scroll', onScroll);
325
- }, []);
326
-
327
- const resetFilters = () => {
328
- setStatusFilter([]);
329
- setPriorityFilter([]);
330
- setTagFilter([]);
331
- };
332
-
333
- const toggleStatus = (status: string) => {
334
- setStatusFilter((prev) =>
335
- prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
336
- );
337
- };
338
-
339
- const togglePriority = (priority: string) => {
340
- setPriorityFilter((prev) =>
341
- prev.includes(priority) ? prev.filter((p) => p !== priority) : [...prev, priority]
342
- );
343
- };
344
-
345
- const toggleTag = (tag: string) => {
346
- setTagFilter((prev) =>
347
- prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
348
- );
349
- };
350
-
351
- const sidebarVisible = mobileOpen || !collapsed;
352
-
353
- return (
354
- <TooltipProvider delayDuration={700}>
355
- <div className="relative">
356
- {mobileOpen && (
357
- <div
358
- className="fixed inset-0 bg-black/40 z-40 lg:hidden"
359
- onClick={() => onMobileOpenChange?.(false)}
360
- />
361
- )}
362
-
363
- <aside
364
- className={cn(
365
- 'border-r bg-background flex flex-col overflow-hidden',
366
- mobileOpen
367
- ? 'fixed inset-y-0 left-0 z-50 w-[280px] shadow-xl'
368
- : 'hidden lg:flex lg:sticky lg:top-14 lg:h-[calc(100vh-3.5rem)]',
369
- collapsed && !mobileOpen ? 'lg:w-0 lg:border-r-0' : 'lg:w-[280px]'
370
- )}
371
- >
372
- <div className="p-3 border-b space-y-3">
373
- <div className="flex items-center justify-between">
374
- <h2 className="font-semibold text-sm">{t('specsNavSidebar.title')}</h2>
375
- <div className="flex items-center gap-1">
376
- <Button
377
- variant={showFilters || hasActiveFilters ? 'secondary' : 'ghost'}
378
- size="sm"
379
- className="h-7 w-7 p-0"
380
- onClick={() => setShowFilters((prev) => !prev)}
381
- title={showFilters ? t('specsNavSidebar.toggleFilters.hide') : t('specsNavSidebar.toggleFilters.show')}
382
- >
383
- <Filter className="h-4 w-4" />
384
- </Button>
385
- {onMobileOpenChange && (
386
- <Button
387
- variant="ghost"
388
- size="sm"
389
- className="h-7 w-7 p-0 lg:hidden"
390
- onClick={() => onMobileOpenChange(false)}
391
- title={t('actions.close')}
392
- >
393
- <X className="h-4 w-4" />
394
- </Button>
395
- )}
396
- <Button
397
- variant="ghost"
398
- size="sm"
399
- className="h-7 w-7 p-0 hidden lg:flex"
400
- onClick={() => setCollapsed(true)}
401
- title={t('specSidebar.collapse')}
402
- >
403
- <ChevronLeft className="h-4 w-4" />
404
- </Button>
405
- </div>
406
- </div>
407
-
408
- <div className="relative">
409
- <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
410
- <Input
411
- type="text"
412
- placeholder={t('specsNavSidebar.searchPlaceholder')}
413
- value={searchQuery}
414
- onChange={(e) => setSearchQuery(e.target.value)}
415
- className="pl-8 h-9"
416
- />
417
- </div>
418
-
419
- {showFilters && (
420
- <div className="space-y-2 pt-2 border-t">
421
- <div className="flex items-center justify-between">
422
- <span className="text-xs font-medium text-muted-foreground">{t('specsNavSidebar.filtersLabel')}</span>
423
- {hasActiveFilters && (
424
- <Button
425
- variant="ghost"
426
- size="sm"
427
- className="h-6 px-2 text-xs"
428
- onClick={resetFilters}
429
- >
430
- {t('specsNavSidebar.clearFilters')}
431
- </Button>
432
- )}
433
- </div>
434
-
435
- {/* Status Filter */}
436
- <Popover>
437
- <PopoverTrigger asChild>
438
- <Button
439
- variant="outline"
440
- size="sm"
441
- className="h-8 w-full justify-between text-xs font-normal"
442
- >
443
- <span className="truncate">
444
- {statusFilter.length === 0
445
- ? t('specsNavSidebar.select.status.all')
446
- : `${t('specsNavSidebar.status')}: ${statusFilter.length} ${t('specsNavSidebar.selected')}`}
447
- </span>
448
- <ChevronRight className="h-3 w-3 opacity-50" />
449
- </Button>
450
- </PopoverTrigger>
451
- <PopoverContent className="w-56 p-2" align="start">
452
- <div className="space-y-1">
453
- {(['planned', 'in-progress', 'complete', 'archived'] as const).map((status) => (
454
- <div
455
- key={status}
456
- className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
457
- onClick={() => toggleStatus(status)}
458
- >
459
- <div className="flex items-center justify-center w-4 h-4 border rounded">
460
- {statusFilter.includes(status) && (
461
- <Check className="h-3 w-3" />
462
- )}
463
- </div>
464
- <div className="flex items-center gap-2 flex-1">
465
- {status === 'planned' && <Clock className="h-4 w-4" />}
466
- {status === 'in-progress' && <PlayCircle className="h-4 w-4" />}
467
- {status === 'complete' && <CheckCircle2 className="h-4 w-4" />}
468
- {status === 'archived' && <Archive className="h-4 w-4" />}
469
- <span className="text-sm">
470
- {status === 'planned' && t('status.planned')}
471
- {status === 'in-progress' && t('status.inProgress')}
472
- {status === 'complete' && t('status.complete')}
473
- {status === 'archived' && t('status.archived')}
474
- </span>
475
- </div>
476
- </div>
477
- ))}
478
- </div>
479
- </PopoverContent>
480
- </Popover>
481
-
482
- {/* Priority Filter */}
483
- <Popover>
484
- <PopoverTrigger asChild>
485
- <Button
486
- variant="outline"
487
- size="sm"
488
- className="h-8 w-full justify-between text-xs font-normal"
489
- >
490
- <span className="truncate">
491
- {priorityFilter.length === 0
492
- ? t('specsNavSidebar.select.priority.all')
493
- : `${t('specsNavSidebar.priority')}: ${priorityFilter.length} ${t('specsNavSidebar.selected')}`}
494
- </span>
495
- <ChevronRight className="h-3 w-3 opacity-50" />
496
- </Button>
497
- </PopoverTrigger>
498
- <PopoverContent className="w-56 p-2" align="start">
499
- <div className="space-y-1">
500
- {(['low', 'medium', 'high', 'critical'] as const).map((priority) => (
501
- <div
502
- key={priority}
503
- className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
504
- onClick={() => togglePriority(priority)}
505
- >
506
- <div className="flex items-center justify-center w-4 h-4 border rounded">
507
- {priorityFilter.includes(priority) && (
508
- <Check className="h-3 w-3" />
509
- )}
510
- </div>
511
- <div className="flex items-center gap-2 flex-1">
512
- {priority === 'low' && <ArrowDown className="h-4 w-4" />}
513
- {priority === 'medium' && <Minus className="h-4 w-4" />}
514
- {priority === 'high' && <ArrowUp className="h-4 w-4" />}
515
- {priority === 'critical' && <AlertCircle className="h-4 w-4" />}
516
- <span className="text-sm">
517
- {priority === 'low' && t('priority.low')}
518
- {priority === 'medium' && t('priority.medium')}
519
- {priority === 'high' && t('priority.high')}
520
- {priority === 'critical' && t('priority.critical')}
521
- </span>
522
- </div>
523
- </div>
524
- ))}
525
- </div>
526
- </PopoverContent>
527
- </Popover>
528
-
529
- {/* Tag Filter */}
530
- {allTags.length > 0 && (
531
- <Popover>
532
- <PopoverTrigger asChild>
533
- <Button
534
- variant="outline"
535
- size="sm"
536
- className="h-8 w-full justify-between text-xs font-normal"
537
- >
538
- <span className="truncate">
539
- {tagFilter.length === 0
540
- ? t('specsNavSidebar.select.tag.all')
541
- : `${t('specsNavSidebar.tags')}: ${tagFilter.length} ${t('specsNavSidebar.selected')}`}
542
- </span>
543
- <ChevronRight className="h-3 w-3 opacity-50" />
544
- </Button>
545
- </PopoverTrigger>
546
- <PopoverContent className="w-56 p-2 max-h-64 overflow-y-auto" align="start">
547
- <div className="space-y-1">
548
- {allTags.map((tag) => (
549
- <div
550
- key={tag}
551
- className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
552
- onClick={() => toggleTag(tag)}
553
- >
554
- <div className="flex items-center justify-center w-4 h-4 border rounded">
555
- {tagFilter.includes(tag) && (
556
- <Check className="h-3 w-3" />
557
- )}
558
- </div>
559
- <span className="text-sm flex-1">{tag}</span>
560
- </div>
561
- ))}
562
- </div>
563
- </PopoverContent>
564
- </Popover>
565
- )}
566
- </div>
567
- )}
568
- </div>
569
-
570
- <div className="flex-1 overflow-hidden">
571
- {loading ? (
572
- <div className="text-center py-8 text-sm text-muted-foreground">
573
- {t('actions.loading')}
574
- </div>
575
- ) : filteredSpecs.length === 0 ? (
576
- <div className="text-center py-8 text-sm text-muted-foreground">
577
- {t('specsNavSidebar.noResults')}
578
- </div>
579
- ) : (
580
- <List<Record<string, never>>
581
- listRef={listRef}
582
- defaultHeight={listHeight}
583
- rowCount={filteredSpecs.length}
584
- rowHeight={76}
585
- overscanCount={6}
586
- rowComponent={RowComponent}
587
- rowProps={{}}
588
- style={{ height: listHeight, width: '100%' }}
589
- />
590
- )}
591
- </div>
592
- </aside>
593
-
594
- {!sidebarVisible && (
595
- <Button
596
- variant="ghost"
597
- size="sm"
598
- className="hidden lg:flex h-10 w-5 p-0 absolute z-50 top-2 left-0 bg-background border border-l-0 rounded-r-md rounded-l-none shadow-md hover:w-6 hover:bg-accent transition-all items-center justify-center"
599
- onClick={() => setCollapsed(false)}
600
- title={t('specSidebar.expand')}
601
- >
602
- <ChevronRight className="h-4 w-4" />
603
- </Button>
604
- )}
605
- </div>
606
- </TooltipProvider>
607
- );
608
- }
609
-
610
- function calculateListHeight() {
611
- if (typeof window === 'undefined') return 600;
612
- const headerHeight = 56; // top navigation bar
613
- const controlsHeight = 100;
614
- return window.innerHeight - headerHeight - controlsHeight;
615
- }
@@ -1,59 +0,0 @@
1
- import { Clock, PlayCircle, CheckCircle2, Archive } from 'lucide-react';
2
- import { Badge } from '@leanspec/ui-components';
3
- import { cn } from '../lib/utils';
4
- import { useTranslation } from 'react-i18next';
5
-
6
- interface StatusBadgeProps {
7
- status: string;
8
- className?: string;
9
- iconOnly?: boolean;
10
- }
11
-
12
- const statusConfig: Record<string, { icon: typeof Clock; labelKey: `status.${string}`; className: string }> = {
13
- 'planned': {
14
- icon: Clock,
15
- labelKey: 'status.planned',
16
- className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
17
- },
18
- 'in-progress': {
19
- icon: PlayCircle,
20
- labelKey: 'status.inProgress',
21
- className: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400'
22
- },
23
- 'complete': {
24
- icon: CheckCircle2,
25
- labelKey: 'status.complete',
26
- className: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
27
- },
28
- 'archived': {
29
- icon: Archive,
30
- labelKey: 'status.archived',
31
- className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
32
- }
33
- };
34
-
35
- export function getStatusLabel(status: string, t: (key: string) => string) {
36
- const config = statusConfig[status] || statusConfig['planned'];
37
- return t(config.labelKey);
38
- }
39
-
40
- export function StatusBadge({ status, className, iconOnly = false }: StatusBadgeProps) {
41
- const config = statusConfig[status] || statusConfig['planned'];
42
- const Icon = config.icon;
43
- const { t } = useTranslation('common');
44
-
45
- return (
46
- <Badge
47
- variant="outline"
48
- className={cn(
49
- 'flex items-center w-fit h-5 px-2 py-0.5 text-xs font-medium border-transparent',
50
- !iconOnly && 'gap-1.5',
51
- config.className,
52
- className,
53
- )}
54
- >
55
- <Icon className="h-3.5 w-3.5" />
56
- {!iconOnly && t(config.labelKey)}
57
- </Badge>
58
- );
59
- }