@leanspec/ui 0.2.3 → 0.2.4
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/dist/standalone/packages/web/.next/BUILD_ID +1 -1
- package/dist/standalone/packages/web/.next/build-manifest.json +2 -2
- package/dist/standalone/packages/web/.next/prerender-manifest.json +3 -3
- package/dist/standalone/packages/web/.next/server/app/_global-error.html +2 -2
- package/dist/standalone/packages/web/.next/server/app/_global-error.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/_not-found.html +2 -2
- package/dist/standalone/packages/web/.next/server/app/_not-found.rsc +2 -2
- package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/projects/[id]/specs/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/projects/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/revalidate/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/specs/[id]/dependency-graph/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/specs/[id]/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/specs/[id]/subspecs/[file]/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/api/stats/route.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/board/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/board.html +1 -1
- package/dist/standalone/packages/web/.next/server/app/board.rsc +2 -2
- package/dist/standalone/packages/web/.next/server/app/board.segments/_full.segment.rsc +2 -2
- package/dist/standalone/packages/web/.next/server/app/board.segments/_index.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/board.segments/_tree.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/board.segments/board/__PAGE__.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/board.segments/board.segment.rsc +1 -1
- package/dist/standalone/packages/web/.next/server/app/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/specs/[id]/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/specs/[id]/page_client-reference-manifest.js +1 -1
- package/dist/standalone/packages/web/.next/server/app/specs/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/app/stats/page.js.nft.json +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__2e0f9179._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__577d6d08._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__e54bc4b8._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/[root-of-the-server]__f8978f3e._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/ssr/[root-of-the-server]__be46bb7c._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/ssr/_7dedc302._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/ssr/_ad71cd8c._.js +1 -1
- package/dist/standalone/packages/web/.next/server/chunks/ssr/_c5a5c652._.js +1 -1
- package/dist/standalone/packages/web/.next/server/pages/404.html +2 -2
- package/dist/standalone/packages/web/.next/server/pages/500.html +2 -2
- package/dist/standalone/packages/web/.next/server/server-reference-manifest.js +1 -1
- package/dist/standalone/packages/web/.next/server/server-reference-manifest.json +1 -1
- package/dist/{static/chunks/0de258404bcae76f.js → standalone/packages/web/.next/static/chunks/8864b47e107cbe63.js} +1 -1
- package/dist/{static/chunks/09ff02250dd56621.js → standalone/packages/web/.next/static/chunks/a2889ecda42c83e7.js} +1 -1
- package/dist/standalone/packages/web/.next/static/chunks/c22619397bb8368e.js +1 -0
- package/dist/standalone/packages/web/components.json +20 -0
- package/dist/standalone/packages/web/drizzle/0000_reflective_thena.sql +59 -0
- package/dist/standalone/packages/web/drizzle/0001_fresh_carmella_unuscione.sql +1 -0
- package/dist/standalone/packages/web/drizzle/meta/0000_snapshot.json +427 -0
- package/dist/standalone/packages/web/drizzle/meta/0001_snapshot.json +436 -0
- package/dist/standalone/packages/web/drizzle/meta/_journal.json +20 -0
- package/dist/standalone/packages/web/drizzle.config.ts +10 -0
- package/dist/standalone/packages/web/eslint.config.mjs +18 -0
- package/dist/standalone/packages/web/next.config.ts +7 -0
- package/dist/standalone/packages/web/package.json +1 -1
- package/dist/standalone/packages/web/postcss.config.mjs +8 -0
- package/dist/standalone/packages/web/src/app/api/projects/[id]/specs/route.ts +23 -0
- package/dist/standalone/packages/web/src/app/api/projects/route.ts +19 -0
- package/dist/standalone/packages/web/src/app/api/revalidate/route.ts +63 -0
- package/dist/standalone/packages/web/src/app/api/specs/[id]/dependency-graph/route.test.ts +51 -0
- package/dist/standalone/packages/web/src/app/api/specs/[id]/dependency-graph/route.ts +171 -0
- package/dist/standalone/packages/web/src/app/api/specs/[id]/route.ts +36 -0
- package/dist/standalone/packages/web/src/app/api/specs/[id]/subspecs/[file]/route.ts +46 -0
- package/dist/standalone/packages/web/src/app/api/stats/route.ts +19 -0
- package/dist/standalone/packages/web/src/app/board/board-client.tsx +162 -0
- package/dist/standalone/packages/web/src/app/board/loading.tsx +43 -0
- package/dist/standalone/packages/web/src/app/board/page.tsx +18 -0
- package/dist/standalone/packages/web/src/app/dashboard-client.tsx +364 -0
- package/dist/standalone/packages/web/src/app/error.tsx +43 -0
- package/dist/standalone/packages/web/src/app/globals.css +531 -0
- package/dist/standalone/packages/web/src/app/home-client.tsx +277 -0
- package/dist/standalone/packages/web/src/app/layout.tsx +70 -0
- package/dist/standalone/packages/web/src/app/loading.tsx +87 -0
- package/dist/standalone/packages/web/src/app/not-found.tsx +27 -0
- package/dist/standalone/packages/web/src/app/page.tsx +18 -0
- package/dist/standalone/packages/web/src/app/specs/[id]/loading.tsx +5 -0
- package/dist/standalone/packages/web/src/app/specs/[id]/page.tsx +43 -0
- package/dist/standalone/packages/web/src/app/specs/page.tsx +18 -0
- package/dist/standalone/packages/web/src/app/specs/specs-client.tsx +425 -0
- package/dist/standalone/packages/web/src/app/stats/page.tsx +18 -0
- package/dist/standalone/packages/web/src/app/stats/stats-client.tsx +283 -0
- package/dist/standalone/packages/web/src/components/back-to-top.tsx +46 -0
- package/dist/standalone/packages/web/src/components/empty-state.tsx +52 -0
- package/dist/standalone/packages/web/src/components/main-sidebar.tsx +175 -0
- package/dist/standalone/packages/web/src/components/markdown-link.test.ts +96 -0
- package/dist/standalone/packages/web/src/components/markdown-link.tsx +95 -0
- package/dist/standalone/packages/web/src/components/navigation.tsx +210 -0
- package/dist/standalone/packages/web/src/components/priority-badge.tsx +53 -0
- package/dist/standalone/packages/web/src/components/quick-search.tsx +180 -0
- package/dist/standalone/packages/web/src/components/skeletons.tsx +119 -0
- package/dist/standalone/packages/web/src/components/spec-dependency-graph.tsx +369 -0
- package/dist/standalone/packages/web/src/components/spec-detail-client.tsx +372 -0
- package/dist/standalone/packages/web/src/components/spec-detail-loading-shell.tsx +42 -0
- package/dist/standalone/packages/web/src/components/spec-detail-wrapper.tsx +70 -0
- package/dist/standalone/packages/web/src/components/spec-metadata.tsx +136 -0
- package/dist/standalone/packages/web/src/components/spec-sidebar.tsx +127 -0
- package/dist/standalone/packages/web/src/components/spec-timeline.tsx +186 -0
- package/dist/standalone/packages/web/src/components/specs-nav-sidebar.tsx +561 -0
- package/dist/standalone/packages/web/src/components/status-badge.tsx +53 -0
- package/dist/standalone/packages/web/src/components/sub-spec-tabs.tsx +143 -0
- package/dist/standalone/packages/web/src/components/table-of-contents.tsx +130 -0
- package/dist/standalone/packages/web/src/components/theme-provider.tsx +11 -0
- package/dist/standalone/packages/web/src/components/theme-toggle.tsx +37 -0
- package/dist/standalone/packages/web/src/components/ui/avatar.tsx +50 -0
- package/dist/standalone/packages/web/src/components/ui/badge.tsx +36 -0
- package/dist/standalone/packages/web/src/components/ui/breadcrumb.tsx +110 -0
- package/dist/standalone/packages/web/src/components/ui/button.tsx +57 -0
- package/dist/standalone/packages/web/src/components/ui/card.tsx +76 -0
- package/dist/standalone/packages/web/src/components/ui/command.tsx +153 -0
- package/dist/standalone/packages/web/src/components/ui/dialog.tsx +122 -0
- package/dist/standalone/packages/web/src/components/ui/input.tsx +24 -0
- package/dist/standalone/packages/web/src/components/ui/select.tsx +159 -0
- package/dist/standalone/packages/web/src/components/ui/separator.tsx +31 -0
- package/dist/standalone/packages/web/src/components/ui/skeleton.tsx +15 -0
- package/dist/standalone/packages/web/src/components/ui/tabs.tsx +55 -0
- package/dist/standalone/packages/web/src/components/ui/toast.tsx +30 -0
- package/dist/standalone/packages/web/src/components/ui/tooltip.tsx +32 -0
- package/dist/standalone/packages/web/src/lib/date-utils.ts +76 -0
- package/dist/standalone/packages/web/src/lib/db/index.ts +42 -0
- package/dist/standalone/packages/web/src/lib/db/migrate.ts +18 -0
- package/dist/standalone/packages/web/src/lib/db/queries.ts +114 -0
- package/dist/standalone/packages/web/src/lib/db/schema.ts +123 -0
- package/dist/standalone/packages/web/src/lib/db/seed.ts +156 -0
- package/dist/standalone/packages/web/src/lib/db/service-queries.ts +216 -0
- package/dist/standalone/packages/web/src/lib/dependency-graph.ts +105 -0
- package/dist/standalone/packages/web/src/lib/specs/service.ts +120 -0
- package/dist/standalone/packages/web/src/lib/specs/sources/database-source.ts +94 -0
- package/dist/standalone/packages/web/src/lib/specs/sources/filesystem-source.ts +249 -0
- package/dist/standalone/packages/web/src/lib/specs/types.ts +55 -0
- package/dist/standalone/packages/web/src/lib/stores/specs-sidebar-store.ts +152 -0
- package/dist/standalone/packages/web/src/lib/sub-specs.ts +171 -0
- package/dist/standalone/packages/web/src/lib/utils.ts +17 -0
- package/dist/standalone/packages/web/src/types/specs.ts +18 -0
- package/dist/standalone/packages/web/tailwind.config.ts +58 -0
- package/dist/standalone/packages/web/tsconfig.json +34 -0
- package/dist/standalone/packages/web/vitest.config.ts +14 -0
- package/dist/standalone/specs/100-release-process-typecheck-failure/README.md +266 -0
- package/dist/standalone/specs/101-sidebar-scroll-position-drift/README.md +100 -0
- package/package.json +5 -3
- package/dist/BUILD_ID +0 -1
- package/dist/static/chunks/a3e649fcddd3d715.js +0 -1
- /package/dist/{static/Z_BxkB0KJViCNbULN1S5p → standalone/packages/web/.next/static/DDHAmoL_2Poji-whvFjry}/_buildManifest.js +0 -0
- /package/dist/{static/Z_BxkB0KJViCNbULN1S5p → standalone/packages/web/.next/static/DDHAmoL_2Poji-whvFjry}/_clientMiddlewareManifest.json +0 -0
- /package/dist/{static/Z_BxkB0KJViCNbULN1S5p → standalone/packages/web/.next/static/DDHAmoL_2Poji-whvFjry}/_ssgManifest.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/0c19c69aa7625475.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/116800b03245a1e5.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/19e80edf527aef5c.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/2ece90370908f56c.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/36fd2dddb486f6bc.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/5c2072ad938de8ed.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/6577fe797a336bab.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/6a05a93ec8fa7b83.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/7f732ea69e643219.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/a02c1f50ff00204f.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/a45464b9776dd88e.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/a6dad97d9634a72d.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/ae04dcd433be6dab.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/b20313408e970968.css +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/c46095e1a421d93f.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/c48dd4c72d7c5ef4.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/c557ac675be79771.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/dca0c854c59234cd.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/df1731c03abf1aee.css +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/dfd41488ad062cd5.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/ebd89051637b9a47.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/f3ec9fd77a8618b1.js +0 -0
- /package/dist/{static → standalone/packages/web/.next/static}/chunks/turbopack-7450632b40b2e378.js +0 -0
- /package/dist/{public → standalone/packages/web/public}/f864aa7e7061c0600e35cf3d879b27cf.txt +0 -0
- /package/dist/{public → standalone/packages/web/public}/favicon.ico +0 -0
- /package/dist/{public → standalone/packages/web/public}/file.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/github-mark-white.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/github-mark.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/globe.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/icon.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/logo-dark-bg.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/logo-with-bg.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/logo.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/next.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/vercel.svg +0 -0
- /package/dist/{public → standalone/packages/web/public}/window.svg +0 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { List, type ListImperativeAPI } from 'react-window';
|
|
6
|
+
import {
|
|
7
|
+
Search,
|
|
8
|
+
ChevronLeft,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
X,
|
|
11
|
+
List as ListIconLucide,
|
|
12
|
+
Filter,
|
|
13
|
+
} from 'lucide-react';
|
|
14
|
+
import { Input } from '@/components/ui/input';
|
|
15
|
+
import { Button } from '@/components/ui/button';
|
|
16
|
+
import { StatusBadge, getStatusLabel } from '@/components/status-badge';
|
|
17
|
+
import {
|
|
18
|
+
Tooltip,
|
|
19
|
+
TooltipContent,
|
|
20
|
+
TooltipProvider,
|
|
21
|
+
TooltipTrigger,
|
|
22
|
+
} from '@/components/ui/tooltip';
|
|
23
|
+
import {
|
|
24
|
+
Select,
|
|
25
|
+
SelectContent,
|
|
26
|
+
SelectItem,
|
|
27
|
+
SelectTrigger,
|
|
28
|
+
SelectValue,
|
|
29
|
+
} from '@/components/ui/select';
|
|
30
|
+
import { cn } from '@/lib/utils';
|
|
31
|
+
import { extractH1Title } from '@/lib/utils';
|
|
32
|
+
import { PriorityBadge, getPriorityLabel } from './priority-badge';
|
|
33
|
+
import { formatRelativeTime } from '@/lib/date-utils';
|
|
34
|
+
import {
|
|
35
|
+
useSpecsSidebarSpecs,
|
|
36
|
+
useSpecsSidebarActiveSpec,
|
|
37
|
+
getSidebarScrollTop,
|
|
38
|
+
updateSidebarScrollTop,
|
|
39
|
+
} from '@/lib/stores/specs-sidebar-store';
|
|
40
|
+
|
|
41
|
+
const useIsomorphicLayoutEffect = typeof window !== 'undefined'
|
|
42
|
+
? React.useLayoutEffect
|
|
43
|
+
: React.useEffect;
|
|
44
|
+
import type { SidebarSpec } from '@/types/specs';
|
|
45
|
+
|
|
46
|
+
interface SpecsNavSidebarProps {
|
|
47
|
+
initialSpecs?: SidebarSpec[];
|
|
48
|
+
currentSpecId?: string;
|
|
49
|
+
currentSubSpec?: string;
|
|
50
|
+
onSpecHover?: (specId: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function SpecsNavSidebar({ initialSpecs = [], currentSpecId, currentSubSpec, onSpecHover }: SpecsNavSidebarProps) {
|
|
54
|
+
const [searchQuery, setSearchQuery] = React.useState('');
|
|
55
|
+
const [statusFilter, setStatusFilter] = React.useState<string>('all');
|
|
56
|
+
const [priorityFilter, setPriorityFilter] = React.useState<string>('all');
|
|
57
|
+
const [tagFilter, setTagFilter] = React.useState<string>('all');
|
|
58
|
+
const [showFilters, setShowFilters] = React.useState(false);
|
|
59
|
+
const [isCollapsed, setIsCollapsed] = React.useState(() => {
|
|
60
|
+
if (typeof window !== 'undefined') {
|
|
61
|
+
const saved = localStorage.getItem('specs-nav-sidebar-collapsed');
|
|
62
|
+
return saved === 'true';
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
66
|
+
const [mobileOpen, setMobileOpen] = React.useState(false);
|
|
67
|
+
const [mounted, setMounted] = React.useState(false);
|
|
68
|
+
const activeItemRef = React.useRef<HTMLAnchorElement>(null);
|
|
69
|
+
const scrollFrameRef = React.useRef<number | null>(null);
|
|
70
|
+
const restoredScrollRef = React.useRef(false);
|
|
71
|
+
const listRef = React.useRef<ListImperativeAPI>(null);
|
|
72
|
+
const savedScrollRef = React.useRef(0);
|
|
73
|
+
const anchoredActiveSpecRef = React.useRef(false);
|
|
74
|
+
const initialRenderRef = React.useRef(true);
|
|
75
|
+
|
|
76
|
+
// Use selector hooks to avoid unnecessary re-renders from unrelated state changes
|
|
77
|
+
const sidebarSpecs = useSpecsSidebarSpecs();
|
|
78
|
+
const sidebarActiveSpecId = useSpecsSidebarActiveSpec();
|
|
79
|
+
|
|
80
|
+
// Memoize specs to prevent unnecessary recalculations downstream
|
|
81
|
+
const cachedSpecs = React.useMemo(
|
|
82
|
+
() => (sidebarSpecs.length > 0 ? sidebarSpecs : initialSpecs),
|
|
83
|
+
[sidebarSpecs, initialSpecs]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const resolvedCurrentSpecId = currentSpecId || sidebarActiveSpecId || '';
|
|
87
|
+
|
|
88
|
+
React.useEffect(() => {
|
|
89
|
+
setMounted(true);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
// Update CSS variable for page width calculations and persist to localStorage
|
|
93
|
+
React.useEffect(() => {
|
|
94
|
+
document.documentElement.style.setProperty(
|
|
95
|
+
'--specs-nav-sidebar-width',
|
|
96
|
+
isCollapsed ? '0px' : '280px'
|
|
97
|
+
);
|
|
98
|
+
localStorage.setItem('specs-nav-sidebar-collapsed', String(isCollapsed));
|
|
99
|
+
}, [isCollapsed]);
|
|
100
|
+
|
|
101
|
+
// Close mobile menu on route change
|
|
102
|
+
React.useEffect(() => {
|
|
103
|
+
setMobileOpen(false);
|
|
104
|
+
}, [resolvedCurrentSpecId, currentSubSpec]);
|
|
105
|
+
|
|
106
|
+
const filteredSpecs = React.useMemo(() => {
|
|
107
|
+
let specs = cachedSpecs;
|
|
108
|
+
|
|
109
|
+
// Apply search query
|
|
110
|
+
if (searchQuery) {
|
|
111
|
+
const query = searchQuery.toLowerCase();
|
|
112
|
+
specs = specs.filter(
|
|
113
|
+
(spec) =>
|
|
114
|
+
spec.title?.toLowerCase().includes(query) ||
|
|
115
|
+
spec.specName.toLowerCase().includes(query) ||
|
|
116
|
+
spec.specNumber?.toString().includes(query)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Apply status filter
|
|
121
|
+
if (statusFilter !== 'all') {
|
|
122
|
+
specs = specs.filter((spec) => spec.status === statusFilter);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Apply priority filter
|
|
126
|
+
if (priorityFilter !== 'all') {
|
|
127
|
+
specs = specs.filter((spec) => spec.priority === priorityFilter);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Apply tag filter
|
|
131
|
+
if (tagFilter !== 'all') {
|
|
132
|
+
specs = specs.filter((spec) => spec.tags?.includes(tagFilter));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.debug(specs);
|
|
136
|
+
return specs;
|
|
137
|
+
}, [cachedSpecs, searchQuery, statusFilter, priorityFilter, tagFilter]);
|
|
138
|
+
|
|
139
|
+
// Get all unique tags from all specs
|
|
140
|
+
const allTags = React.useMemo(() => {
|
|
141
|
+
const tagsSet = new Set<string>();
|
|
142
|
+
cachedSpecs.forEach((spec) => {
|
|
143
|
+
spec.tags?.forEach((tag) => tagsSet.add(tag));
|
|
144
|
+
});
|
|
145
|
+
return Array.from(tagsSet).sort();
|
|
146
|
+
}, [cachedSpecs]);
|
|
147
|
+
|
|
148
|
+
// Check if any filters are active
|
|
149
|
+
const hasActiveFilters = statusFilter !== 'all' || priorityFilter !== 'all' || tagFilter !== 'all';
|
|
150
|
+
|
|
151
|
+
// Reset all filters
|
|
152
|
+
const resetFilters = () => {
|
|
153
|
+
setStatusFilter('all');
|
|
154
|
+
setPriorityFilter('all');
|
|
155
|
+
setTagFilter('all');
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Sort specs by number descending (newest first)
|
|
159
|
+
const sortedSpecs = React.useMemo(() => {
|
|
160
|
+
return [...filteredSpecs].sort((a, b) => (b.specNumber || 0) - (a.specNumber || 0));
|
|
161
|
+
}, [filteredSpecs]);
|
|
162
|
+
|
|
163
|
+
// Persist and restore sidebar scroll position without triggering component re-renders
|
|
164
|
+
useIsomorphicLayoutEffect(() => {
|
|
165
|
+
if (typeof window === 'undefined') {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let rafId: number | null = null;
|
|
170
|
+
let cleanup: (() => void) | null = null;
|
|
171
|
+
|
|
172
|
+
savedScrollRef.current = getSidebarScrollTop();
|
|
173
|
+
anchoredActiveSpecRef.current = savedScrollRef.current > 0;
|
|
174
|
+
|
|
175
|
+
const setupScrollPersistence = () => {
|
|
176
|
+
const listElement = listRef.current?.element;
|
|
177
|
+
|
|
178
|
+
if (!listElement) {
|
|
179
|
+
rafId = window.requestAnimationFrame(setupScrollPersistence);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!restoredScrollRef.current) {
|
|
184
|
+
if (savedScrollRef.current > 0) {
|
|
185
|
+
listElement.scrollTop = savedScrollRef.current;
|
|
186
|
+
}
|
|
187
|
+
restoredScrollRef.current = true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const handleScroll = () => {
|
|
191
|
+
if (scrollFrameRef.current !== null) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
|
195
|
+
scrollFrameRef.current = null;
|
|
196
|
+
savedScrollRef.current = listElement.scrollTop;
|
|
197
|
+
updateSidebarScrollTop(listElement.scrollTop);
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
listElement.addEventListener('scroll', handleScroll, { passive: true });
|
|
202
|
+
cleanup = () => {
|
|
203
|
+
listElement.removeEventListener('scroll', handleScroll);
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
setupScrollPersistence();
|
|
208
|
+
|
|
209
|
+
return () => {
|
|
210
|
+
if (cleanup) {
|
|
211
|
+
cleanup();
|
|
212
|
+
}
|
|
213
|
+
if (rafId !== null) {
|
|
214
|
+
window.cancelAnimationFrame(rafId);
|
|
215
|
+
}
|
|
216
|
+
if (scrollFrameRef.current !== null) {
|
|
217
|
+
window.cancelAnimationFrame(scrollFrameRef.current);
|
|
218
|
+
scrollFrameRef.current = null;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
// Ensure the active spec is visible when first loading without a stored scroll position
|
|
224
|
+
useIsomorphicLayoutEffect(() => {
|
|
225
|
+
if (typeof window === 'undefined') {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!initialRenderRef.current) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
initialRenderRef.current = false;
|
|
234
|
+
|
|
235
|
+
if (anchoredActiveSpecRef.current) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!resolvedCurrentSpecId) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let rafId: number | null = null;
|
|
244
|
+
|
|
245
|
+
const tryAnchorScroll = () => {
|
|
246
|
+
if (anchoredActiveSpecRef.current) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!restoredScrollRef.current || !listRef.current) {
|
|
251
|
+
rafId = window.requestAnimationFrame(tryAnchorScroll);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const targetIndex = sortedSpecs.findIndex((spec) => spec.id === resolvedCurrentSpecId);
|
|
256
|
+
if (targetIndex === -1) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
anchoredActiveSpecRef.current = true;
|
|
261
|
+
listRef.current.scrollToRow({ index: targetIndex, align: 'center' });
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
tryAnchorScroll();
|
|
265
|
+
|
|
266
|
+
return () => {
|
|
267
|
+
if (rafId !== null) {
|
|
268
|
+
window.cancelAnimationFrame(rafId);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}, [sortedSpecs, resolvedCurrentSpecId]);
|
|
272
|
+
|
|
273
|
+
// Expose function for mobile toggle
|
|
274
|
+
React.useEffect(() => {
|
|
275
|
+
if (typeof window === 'undefined') {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
window.toggleSpecsSidebar = () => setMobileOpen(prev => !prev);
|
|
280
|
+
return () => {
|
|
281
|
+
window.toggleSpecsSidebar = undefined;
|
|
282
|
+
};
|
|
283
|
+
}, []);
|
|
284
|
+
|
|
285
|
+
// Virtual list row renderer (rowProps will be passed by react-window)
|
|
286
|
+
const RowComponent = React.useCallback((rowProps: { index: number; style: React.CSSProperties }) => {
|
|
287
|
+
const { index, style } = rowProps;
|
|
288
|
+
const spec = sortedSpecs[index];
|
|
289
|
+
const isCurrentSpec = spec.id === resolvedCurrentSpecId;
|
|
290
|
+
|
|
291
|
+
// Extract H1 title, fallback to title or name
|
|
292
|
+
const h1Title = spec.contentMd ? extractH1Title(spec.contentMd) : null;
|
|
293
|
+
const displayTitle = h1Title || spec.title || spec.specName;
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div style={style} className="px-1">
|
|
297
|
+
<div className="mb-0.5">
|
|
298
|
+
{/* Main spec item */}
|
|
299
|
+
<div className="flex items-center gap-0.5">
|
|
300
|
+
<Tooltip>
|
|
301
|
+
<TooltipTrigger asChild>
|
|
302
|
+
<Link
|
|
303
|
+
ref={isCurrentSpec && !currentSubSpec ? activeItemRef : null}
|
|
304
|
+
href={`/specs/${spec.specNumber || spec.id}`}
|
|
305
|
+
onMouseEnter={() => onSpecHover?.(spec.specNumber?.toString() || spec.id)}
|
|
306
|
+
className={cn(
|
|
307
|
+
'w-full flex flex-col gap-1 p-1.5 rounded-md text-sm transition-colors',
|
|
308
|
+
isCurrentSpec
|
|
309
|
+
? 'bg-accent text-accent-foreground font-medium'
|
|
310
|
+
: 'hover:bg-accent/50',
|
|
311
|
+
)}
|
|
312
|
+
>
|
|
313
|
+
<div className="flex items-center gap-1.5">
|
|
314
|
+
{spec.specNumber && (
|
|
315
|
+
<span className="text-xs font-mono text-muted-foreground shrink-0">
|
|
316
|
+
#{spec.specNumber.toString().padStart(3, '0')}
|
|
317
|
+
</span>
|
|
318
|
+
)}
|
|
319
|
+
<span className="truncate text-xs leading-relaxed">{displayTitle}</span>
|
|
320
|
+
</div>
|
|
321
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
322
|
+
{spec.status && (
|
|
323
|
+
<Tooltip>
|
|
324
|
+
<TooltipTrigger asChild>
|
|
325
|
+
<div>
|
|
326
|
+
<StatusBadge status={spec.status} iconOnly className="text-[10px] scale-90" />
|
|
327
|
+
</div>
|
|
328
|
+
</TooltipTrigger>
|
|
329
|
+
<TooltipContent side="right">
|
|
330
|
+
{getStatusLabel(spec.status)}
|
|
331
|
+
</TooltipContent>
|
|
332
|
+
</Tooltip>
|
|
333
|
+
)}
|
|
334
|
+
{spec.priority && (
|
|
335
|
+
<Tooltip>
|
|
336
|
+
<TooltipTrigger asChild>
|
|
337
|
+
<div>
|
|
338
|
+
<PriorityBadge priority={spec.priority} iconOnly className="text-[10px] scale-90" />
|
|
339
|
+
</div>
|
|
340
|
+
</TooltipTrigger>
|
|
341
|
+
<TooltipContent side="right">
|
|
342
|
+
{getPriorityLabel(spec.priority)}
|
|
343
|
+
</TooltipContent>
|
|
344
|
+
</Tooltip>
|
|
345
|
+
)}
|
|
346
|
+
{typeof spec.subSpecsCount === 'number' && spec.subSpecsCount > 0 && (
|
|
347
|
+
<span className="text-[10px] text-muted-foreground">
|
|
348
|
+
+{spec.subSpecsCount} files
|
|
349
|
+
</span>
|
|
350
|
+
)}
|
|
351
|
+
{spec.updatedAt && (
|
|
352
|
+
<span className="text-[10px] text-muted-foreground">
|
|
353
|
+
{formatRelativeTime(spec.updatedAt)}
|
|
354
|
+
</span>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
</Link>
|
|
358
|
+
</TooltipTrigger>
|
|
359
|
+
<TooltipContent side="right" className="max-w-[300px]">
|
|
360
|
+
<div className="space-y-1">
|
|
361
|
+
<div className="font-semibold">{displayTitle}</div>
|
|
362
|
+
<div className="text-xs text-muted-foreground">{spec.specName}</div>
|
|
363
|
+
</div>
|
|
364
|
+
</TooltipContent>
|
|
365
|
+
</Tooltip>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}, [sortedSpecs, resolvedCurrentSpecId, currentSubSpec, onSpecHover]);
|
|
371
|
+
|
|
372
|
+
// Calculate list height
|
|
373
|
+
const listHeight = React.useMemo(() => {
|
|
374
|
+
// Get viewport height minus header (3.5rem = 56px) and search/filter area (~180px)
|
|
375
|
+
if (typeof window !== 'undefined') {
|
|
376
|
+
return window.innerHeight - 56 - 180;
|
|
377
|
+
}
|
|
378
|
+
return 600; // fallback
|
|
379
|
+
}, []);
|
|
380
|
+
|
|
381
|
+
// Memoized List component to prevent unnecessary re-renders
|
|
382
|
+
const MemoizedList = React.useMemo(() => {
|
|
383
|
+
return React.memo(List<Record<string, never>>);
|
|
384
|
+
}, []);
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<TooltipProvider delayDuration={700}>
|
|
388
|
+
{/* Mobile overlay backdrop */}
|
|
389
|
+
{mobileOpen && (
|
|
390
|
+
<div
|
|
391
|
+
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
|
392
|
+
onClick={() => setMobileOpen(false)}
|
|
393
|
+
/>
|
|
394
|
+
)}
|
|
395
|
+
|
|
396
|
+
<div className="relative flex-shrink-0">
|
|
397
|
+
<aside className={cn(
|
|
398
|
+
"sticky top-14 h-[calc(100vh-3.5rem)] border-r border-border bg-background flex flex-col overflow-hidden transition-all duration-300",
|
|
399
|
+
// Desktop behavior
|
|
400
|
+
"hidden lg:flex",
|
|
401
|
+
mounted && isCollapsed ? "lg:w-0 lg:border-r-0" : "lg:w-[280px]",
|
|
402
|
+
// Mobile behavior - show as overlay when open
|
|
403
|
+
mobileOpen && "fixed left-0 top-14 z-50 flex w-[280px]"
|
|
404
|
+
)}>
|
|
405
|
+
<div className="p-4 border-b border-border">
|
|
406
|
+
<div className="flex items-center justify-between mb-3">
|
|
407
|
+
<h2 className="font-semibold text-sm">Specifications</h2>
|
|
408
|
+
<div className="flex items-center gap-1">
|
|
409
|
+
{/* Filter toggle button */}
|
|
410
|
+
<Tooltip>
|
|
411
|
+
<TooltipTrigger asChild>
|
|
412
|
+
<Button
|
|
413
|
+
variant={showFilters || hasActiveFilters ? "default" : "ghost"}
|
|
414
|
+
size="sm"
|
|
415
|
+
onClick={() => setShowFilters(!showFilters)}
|
|
416
|
+
className="h-6 w-6 p-0"
|
|
417
|
+
>
|
|
418
|
+
<Filter className="h-4 w-4" />
|
|
419
|
+
</Button>
|
|
420
|
+
</TooltipTrigger>
|
|
421
|
+
<TooltipContent side="bottom">
|
|
422
|
+
{showFilters ? 'Hide filters' : 'Show filters'}
|
|
423
|
+
</TooltipContent>
|
|
424
|
+
</Tooltip>
|
|
425
|
+
{/* Mobile close button */}
|
|
426
|
+
<Button
|
|
427
|
+
variant="ghost"
|
|
428
|
+
size="sm"
|
|
429
|
+
onClick={() => setMobileOpen(false)}
|
|
430
|
+
className="h-6 w-6 p-0 lg:hidden"
|
|
431
|
+
>
|
|
432
|
+
<X className="h-4 w-4" />
|
|
433
|
+
</Button>
|
|
434
|
+
{/* Desktop collapse button */}
|
|
435
|
+
<Button
|
|
436
|
+
variant="ghost"
|
|
437
|
+
size="sm"
|
|
438
|
+
onClick={() => setIsCollapsed(true)}
|
|
439
|
+
className="h-6 w-6 p-0 hidden lg:flex lg:items-center lg:justify-center"
|
|
440
|
+
>
|
|
441
|
+
<ChevronLeft className="h-4 w-4" />
|
|
442
|
+
</Button>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
{/* Filter controls */}
|
|
447
|
+
{showFilters && (
|
|
448
|
+
<div className="space-y-2 mb-3 pb-3 border-b border-border">
|
|
449
|
+
<div className="flex items-center justify-between">
|
|
450
|
+
<span className="text-xs font-medium text-muted-foreground">Filters</span>
|
|
451
|
+
{hasActiveFilters && (
|
|
452
|
+
<Button
|
|
453
|
+
variant="ghost"
|
|
454
|
+
size="sm"
|
|
455
|
+
onClick={resetFilters}
|
|
456
|
+
className="h-5 text-xs px-2 py-0"
|
|
457
|
+
>
|
|
458
|
+
Clear
|
|
459
|
+
</Button>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
|
|
463
|
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
464
|
+
<SelectTrigger className="h-8 text-xs">
|
|
465
|
+
<SelectValue placeholder="Status" />
|
|
466
|
+
</SelectTrigger>
|
|
467
|
+
<SelectContent>
|
|
468
|
+
<SelectItem value="all">All Status</SelectItem>
|
|
469
|
+
<SelectItem value="planned">Planned</SelectItem>
|
|
470
|
+
<SelectItem value="in-progress">In Progress</SelectItem>
|
|
471
|
+
<SelectItem value="complete">Complete</SelectItem>
|
|
472
|
+
<SelectItem value="archived">Archived</SelectItem>
|
|
473
|
+
</SelectContent>
|
|
474
|
+
</Select>
|
|
475
|
+
|
|
476
|
+
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
|
477
|
+
<SelectTrigger className="h-8 text-xs">
|
|
478
|
+
<SelectValue placeholder="Priority" />
|
|
479
|
+
</SelectTrigger>
|
|
480
|
+
<SelectContent>
|
|
481
|
+
<SelectItem value="all">All Priority</SelectItem>
|
|
482
|
+
<SelectItem value="low">Low</SelectItem>
|
|
483
|
+
<SelectItem value="medium">Medium</SelectItem>
|
|
484
|
+
<SelectItem value="high">High</SelectItem>
|
|
485
|
+
<SelectItem value="critical">Critical</SelectItem>
|
|
486
|
+
</SelectContent>
|
|
487
|
+
</Select>
|
|
488
|
+
|
|
489
|
+
{allTags.length > 0 && (
|
|
490
|
+
<Select value={tagFilter} onValueChange={setTagFilter}>
|
|
491
|
+
<SelectTrigger className="h-8 text-xs">
|
|
492
|
+
<SelectValue placeholder="Tag" />
|
|
493
|
+
</SelectTrigger>
|
|
494
|
+
<SelectContent>
|
|
495
|
+
<SelectItem value="all">All Tags</SelectItem>
|
|
496
|
+
{allTags.map((tag) => (
|
|
497
|
+
<SelectItem key={tag} value={tag}>
|
|
498
|
+
{tag}
|
|
499
|
+
</SelectItem>
|
|
500
|
+
))}
|
|
501
|
+
</SelectContent>
|
|
502
|
+
</Select>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
505
|
+
)}
|
|
506
|
+
|
|
507
|
+
<div className="relative">
|
|
508
|
+
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
509
|
+
<Input
|
|
510
|
+
placeholder="Search specs..."
|
|
511
|
+
value={searchQuery}
|
|
512
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
513
|
+
className="pl-8 h-9 border-border"
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<div className="flex-1 overflow-hidden">
|
|
519
|
+
{sortedSpecs.length === 0 ? (
|
|
520
|
+
<div className="text-center py-8 text-sm text-muted-foreground">
|
|
521
|
+
No specs found
|
|
522
|
+
</div>
|
|
523
|
+
) : (
|
|
524
|
+
<MemoizedList
|
|
525
|
+
listRef={listRef}
|
|
526
|
+
defaultHeight={listHeight}
|
|
527
|
+
rowCount={sortedSpecs.length}
|
|
528
|
+
rowHeight={72}
|
|
529
|
+
overscanCount={5}
|
|
530
|
+
rowComponent={RowComponent}
|
|
531
|
+
rowProps={{}}
|
|
532
|
+
/>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
</aside>
|
|
536
|
+
|
|
537
|
+
{/* Floating toggle button when collapsed (desktop only) */}
|
|
538
|
+
{mounted && isCollapsed && (
|
|
539
|
+
<Button
|
|
540
|
+
variant="ghost"
|
|
541
|
+
size="sm"
|
|
542
|
+
onClick={() => setIsCollapsed(false)}
|
|
543
|
+
className="hidden lg:items-center lg:justify-center lg:flex h-6 w-6 p-0 fixed z-50 top-20 -translate-y-1 -translate-x-1/2 left-[calc(var(--main-sidebar-width,240px))] bg-background border"
|
|
544
|
+
>
|
|
545
|
+
<ChevronRight className="h-4 w-4" />
|
|
546
|
+
</Button>
|
|
547
|
+
)}
|
|
548
|
+
|
|
549
|
+
{/* Mobile floating toggle button - matches BackToTop/TOC style */}
|
|
550
|
+
<Button
|
|
551
|
+
onClick={() => setMobileOpen(true)}
|
|
552
|
+
size="icon"
|
|
553
|
+
className="lg:hidden fixed bottom-6 left-6 h-12 w-12 rounded-full shadow-lg z-40 hover:scale-110 transition-transform"
|
|
554
|
+
aria-label="Show specifications list"
|
|
555
|
+
>
|
|
556
|
+
<ListIconLucide className="h-5 w-5" />
|
|
557
|
+
</Button>
|
|
558
|
+
</div>
|
|
559
|
+
</TooltipProvider>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status badge component with icons
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Clock, PlayCircle, CheckCircle2, Archive } from 'lucide-react';
|
|
6
|
+
import { Badge } from '@/components/ui/badge';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface StatusBadgeProps {
|
|
10
|
+
status: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
iconOnly?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const statusConfig = {
|
|
16
|
+
'planned': {
|
|
17
|
+
icon: Clock,
|
|
18
|
+
label: 'Planned',
|
|
19
|
+
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
|
|
20
|
+
},
|
|
21
|
+
'in-progress': {
|
|
22
|
+
icon: PlayCircle,
|
|
23
|
+
label: 'In Progress',
|
|
24
|
+
className: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400'
|
|
25
|
+
},
|
|
26
|
+
'complete': {
|
|
27
|
+
icon: CheckCircle2,
|
|
28
|
+
label: 'Complete',
|
|
29
|
+
className: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
|
30
|
+
},
|
|
31
|
+
'archived': {
|
|
32
|
+
icon: Archive,
|
|
33
|
+
label: 'Archived',
|
|
34
|
+
className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function StatusBadge({ status, className, iconOnly = false }: StatusBadgeProps) {
|
|
39
|
+
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.planned;
|
|
40
|
+
const Icon = config.icon;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Badge className={cn('flex items-center w-fit', !iconOnly && 'gap-1.5', config.className, className)}>
|
|
44
|
+
<Icon className="h-3.5 w-3.5" />
|
|
45
|
+
{!iconOnly && config.label}
|
|
46
|
+
</Badge>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getStatusLabel(status: string): string {
|
|
51
|
+
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.planned;
|
|
52
|
+
return config.label;
|
|
53
|
+
}
|