@leanspec/ui 0.2.14 → 0.2.15-dev.21025278490
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/leanspec-ui.js +191 -0
- package/dist/assets/_baseUniq-CRqreL7N.js +1 -0
- package/dist/assets/arc-DMhx9AJT.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-DM0L0YzO.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-DHQXDHsD.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-0L7o2gpH.js +10 -0
- package/dist/assets/channel-2tOl0nAZ.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-CwFT-Uaj.js +1 -0
- package/dist/assets/chunk-55IACEB6-CjvuUHHG.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-BRJBysMK.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-BnNEeoaA.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-BK2l30pm.js +15 -0
- package/dist/assets/chunk-QN33PNHL-BN_cZkCU.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-Brc3Yrub.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-D2zzpLfO.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BB9CSNmS.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BB9CSNmS.js +1 -0
- package/dist/assets/clone-BjxVFtyI.js +1 -0
- package/dist/assets/core-DV6XEvTN.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-CLJgM3XR.js +1 -0
- package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
- package/dist/assets/dagre-6UL2VRFP-_IFvBJKJ.js +4 -0
- package/dist/assets/diagram-PSM6KHXK--83HIYSQ.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-6jAWnCnZ.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-D5pwHvjZ.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-B4FV3mTd.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-mtD2kF4M.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-BKALgqTK.js +267 -0
- package/dist/assets/gitGraphDiagram-NY62KEGX-Bd7r0pAf.js +65 -0
- package/dist/assets/graph-B2rEI7cK.js +1 -0
- package/dist/assets/index-Bekv_o1t.css +1 -0
- package/dist/assets/index-DSRxU-E5.js +389 -0
- package/dist/assets/infoDiagram-WHAUD3N6--nJOBKqh.js +2 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-BzGutKN3.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-DyQO17vq.js +89 -0
- package/dist/assets/katex-XbL3y5x-.js +261 -0
- package/dist/assets/layout-iCSHU015.js +1 -0
- package/dist/assets/min-BK_AIJdo.js +1 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BZMj_6zo.js +68 -0
- package/dist/assets/pieDiagram-ADFJNKIX-CkAGsq9p.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-CWa93px1.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-CufFVR8c.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-BEPgVgU4.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-BkdBWhel.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-D5T73yx0.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-9hJWG2n6.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-CX7kTdU2.js +61 -0
- package/dist/assets/treemap-KMMF4GRG-ftWCQ9lJ.js +128 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-Ngrels4n.js +7 -0
- package/{index.html → dist/index.html} +2 -1
- package/package.json +12 -2
- package/eslint.config.js +0 -23
- package/package.json.backup +0 -83
- package/postcss.config.js +0 -6
- package/src/App.css +0 -42
- package/src/App.tsx +0 -17
- package/src/assets/react.svg +0 -1
- package/src/components/LanguageSwitcher.tsx +0 -67
- package/src/components/Layout.tsx +0 -88
- package/src/components/MainSidebar.tsx +0 -163
- package/src/components/MermaidDiagram.tsx +0 -85
- package/src/components/MinimalLayout.tsx +0 -51
- package/src/components/Navigation.tsx +0 -254
- package/src/components/PriorityBadge.tsx +0 -59
- package/src/components/ProjectSwitcher.tsx +0 -222
- package/src/components/QuickSearch.tsx +0 -225
- package/src/components/RootRedirect.tsx +0 -40
- package/src/components/SpecDetailLayout.context.ts +0 -10
- package/src/components/SpecDetailLayout.tsx +0 -14
- package/src/components/SpecsNavSidebar.tsx +0 -615
- package/src/components/StatusBadge.tsx +0 -59
- package/src/components/ThemeToggle.tsx +0 -25
- package/src/components/Tooltip.tsx +0 -29
- package/src/components/context/ContextClient.tsx +0 -471
- package/src/components/context/ContextFileDetail.tsx +0 -163
- package/src/components/dashboard/ActivityItem.tsx +0 -36
- package/src/components/dashboard/DashboardClient.tsx +0 -218
- package/src/components/dashboard/SpecListItem.tsx +0 -58
- package/src/components/dashboard/StatCard.tsx +0 -52
- package/src/components/dependencies/SpecNode.tsx +0 -128
- package/src/components/dependencies/SpecSidebar.tsx +0 -256
- package/src/components/dependencies/constants.ts +0 -25
- package/src/components/dependencies/types.ts +0 -38
- package/src/components/dependencies/utils.ts +0 -261
- package/src/components/metadata-editors/PriorityEditor.tsx +0 -89
- package/src/components/metadata-editors/StatusEditor.tsx +0 -85
- package/src/components/metadata-editors/TagsEditor.tsx +0 -207
- package/src/components/projects/CreateProjectDialog.tsx +0 -162
- package/src/components/projects/DirectoryPicker.tsx +0 -182
- package/src/components/shared/BackToTop.tsx +0 -39
- package/src/components/shared/ColorPicker.tsx +0 -68
- package/src/components/shared/EmptyState.tsx +0 -35
- package/src/components/shared/ErrorBoundary.tsx +0 -79
- package/src/components/shared/PageHeader.tsx +0 -23
- package/src/components/shared/PageTransition.tsx +0 -40
- package/src/components/shared/ProjectAvatar.tsx +0 -107
- package/src/components/shared/Skeletons.tsx +0 -184
- package/src/components/spec-detail/EditableMetadata.tsx +0 -129
- package/src/components/spec-detail/MarkdownRenderer.tsx +0 -47
- package/src/components/spec-detail/TableOfContents.tsx +0 -150
- package/src/components/specs/BoardView.tsx +0 -204
- package/src/components/specs/ListView.tsx +0 -62
- package/src/components/specs/SpecsFilters.tsx +0 -190
- package/src/contexts/KeyboardShortcutsContext.tsx +0 -95
- package/src/contexts/LayoutContext.tsx +0 -45
- package/src/contexts/ProjectContext.tsx +0 -163
- package/src/contexts/ThemeContext.tsx +0 -90
- package/src/contexts/index.ts +0 -7
- package/src/hooks/useKeyboardShortcuts.ts +0 -87
- package/src/index.css +0 -624
- package/src/lib/api.ts +0 -72
- package/src/lib/backend-adapter.ts +0 -382
- package/src/lib/date-utils.ts +0 -122
- package/src/lib/i18n.test.ts +0 -57
- package/src/lib/i18n.ts +0 -51
- package/src/lib/markdown-utils.ts +0 -38
- package/src/lib/sub-spec-utils.ts +0 -166
- package/src/lib/utils.ts +0 -6
- package/src/locales/en/common.json +0 -660
- package/src/locales/en/errors.json +0 -20
- package/src/locales/en/help.json +0 -8
- package/src/locales/zh-CN/common.json +0 -660
- package/src/locales/zh-CN/errors.json +0 -20
- package/src/locales/zh-CN/help.json +0 -8
- package/src/main.tsx +0 -12
- package/src/pages/ContextPage.tsx +0 -111
- package/src/pages/DashboardPage.tsx +0 -97
- package/src/pages/DependenciesPage.tsx +0 -881
- package/src/pages/ProjectsPage.tsx +0 -432
- package/src/pages/SpecDetailPage.tsx +0 -592
- package/src/pages/SpecsPage.tsx +0 -319
- package/src/pages/StatsPage.tsx +0 -307
- package/src/router/projectRoutes.tsx +0 -36
- package/src/router.tsx +0 -33
- package/src/test/setup.ts +0 -39
- package/src/types/api.ts +0 -185
- package/tailwind.config.ts +0 -57
- package/tsconfig.app.json +0 -29
- package/tsconfig.json +0 -7
- package/tsconfig.node.json +0 -26
- package/tsconfig.tsbuildinfo +0 -1
- package/vite.config.ts +0 -27
- package/vitest.config.ts +0 -18
- /package/{public → dist}/favicon.ico +0 -0
- /package/{public → dist}/github-mark-white.svg +0 -0
- /package/{public → dist}/github-mark.svg +0 -0
- /package/{public → dist}/logo-dark-bg.svg +0 -0
- /package/{public → dist}/logo-with-bg.svg +0 -0
- /package/{public → dist}/logo.svg +0 -0
- /package/{public → dist}/vite.svg +0 -0
|
@@ -1,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
|
-
}
|