@eventcatalog/core 2.65.0 → 3.0.0-beta.0
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/README.md +1 -26
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-NK6OYMRD.js → chunk-JB4YT5JY.js} +1 -1
- package/dist/{chunk-BMDTX5IN.js → chunk-TQ4HZREX.js} +1 -1
- package/dist/{chunk-IJRFYF4B.js → chunk-X4W4YC3U.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -21
- package/dist/eventcatalog.config.d.cts +10 -0
- package/dist/eventcatalog.config.d.ts +10 -0
- package/dist/eventcatalog.js +3 -20
- package/eventcatalog/src/components/CopyAsMarkdown.tsx +19 -1
- package/eventcatalog/src/components/FavoriteButton.tsx +54 -0
- package/eventcatalog/src/components/Grids/DomainGrid.tsx +386 -362
- package/eventcatalog/src/components/Grids/MessageGrid.tsx +166 -518
- package/eventcatalog/src/components/Header.astro +48 -23
- package/eventcatalog/src/components/Lists/VersionList.astro +2 -2
- package/eventcatalog/src/components/MDX/Design/Design.astro +4 -1
- package/eventcatalog/src/components/MDX/Flow/Flow.astro +2 -1
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +3 -3
- package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +8 -2
- package/eventcatalog/src/components/SchemaExplorer/SchemaPageViewer.tsx +37 -0
- package/eventcatalog/src/components/Search/Search.astro +48 -28
- package/eventcatalog/src/components/Search/SearchModal.tsx +393 -702
- package/eventcatalog/src/components/SideNav/NestedSideBar/SearchBar.tsx +298 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/builders/container.ts +66 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/builders/domain.ts +101 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/builders/flow.ts +29 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/builders/message.ts +84 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/builders/service.ts +147 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/builders/shared.ts +146 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +1073 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/sidebar-builder.ts +365 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +90 -0
- package/eventcatalog/src/components/SideNav/SideNav.astro +18 -28
- package/eventcatalog/src/content.config.ts +2 -0
- package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +10 -4
- package/eventcatalog/src/enterprise/eventcatalog-chat/pages/chat/index.astro +3 -3
- package/eventcatalog/src/layouts/DirectoryLayout.astro +2 -2
- package/eventcatalog/src/layouts/DiscoverLayout.astro +3 -3
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +85 -63
- package/eventcatalog/src/layouts/VisualiserLayout.astro +3 -3
- package/eventcatalog/src/pages/_index.astro +530 -110
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +64 -0
- package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +29 -0
- package/eventcatalog/src/pages/directory/[type]/_index.data.ts +4 -4
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts +1 -4
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +3 -3
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro +1 -5
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +362 -190
- package/eventcatalog/src/pages/docs/[type]/[id]/[version].md.ts +1 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/index.astro +4 -4
- package/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts +1 -4
- package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +3 -27
- package/eventcatalog/src/pages/docs/teams/[id]/_index.data.ts +2 -2
- package/eventcatalog/src/pages/docs/users/[id]/_index.data.ts +2 -2
- package/eventcatalog/src/pages/index.astro +14 -5
- package/eventcatalog/src/pages/nav-index.json.ts +30 -0
- package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts +77 -0
- package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +90 -0
- package/eventcatalog/src/pages/schemas/{index.astro → explorer/index.astro} +3 -3
- package/eventcatalog/src/pages/studio.astro +3 -3
- package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/_index.data.ts +4 -3
- package/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro +2 -2
- package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/_index.data.ts +4 -3
- package/eventcatalog/src/stores/favorites-store.ts +83 -0
- package/eventcatalog/src/stores/sidebar-store.ts +8 -0
- package/eventcatalog/src/utils/collections/changelogs.ts +7 -4
- package/eventcatalog/src/utils/{channels.ts → collections/channels.ts} +81 -31
- package/eventcatalog/src/utils/collections/commands.ts +134 -0
- package/eventcatalog/src/utils/collections/containers.ts +44 -33
- package/eventcatalog/src/utils/collections/domains.ts +204 -62
- package/eventcatalog/src/utils/{entities.ts → collections/entities.ts} +44 -24
- package/eventcatalog/src/utils/collections/events.ts +136 -0
- package/eventcatalog/src/utils/collections/flows.ts +59 -25
- package/eventcatalog/src/utils/{messages.ts → collections/messages.ts} +13 -4
- package/eventcatalog/src/utils/{queries.ts → collections/queries.ts} +49 -28
- package/eventcatalog/src/utils/collections/services.ts +100 -68
- package/eventcatalog/src/utils/collections/teams.ts +94 -0
- package/eventcatalog/src/utils/collections/users.ts +122 -0
- package/eventcatalog/src/utils/collections/util.ts +57 -1
- package/eventcatalog/src/utils/feature.ts +3 -1
- package/eventcatalog/src/utils/{collections/file-diffs.ts → file-diffs.ts} +1 -1
- package/eventcatalog/src/utils/node-graphs/container-node-graph.ts +2 -0
- package/eventcatalog/src/utils/node-graphs/domain-entity-map.ts +16 -6
- package/eventcatalog/src/utils/node-graphs/domains-canvas.ts +14 -10
- package/eventcatalog/src/utils/node-graphs/domains-node-graph.ts +36 -64
- package/eventcatalog/src/utils/node-graphs/flows-node-graph.ts +23 -19
- package/eventcatalog/src/utils/node-graphs/message-node-graph.ts +36 -49
- package/eventcatalog/src/utils/node-graphs/services-node-graph.ts +22 -18
- package/eventcatalog/src/utils/page-loaders/page-data-loader.ts +4 -4
- package/eventcatalog/tailwind.config.mjs +14 -0
- package/eventcatalog/tsconfig.json +2 -1
- package/package.json +7 -4
- package/eventcatalog/public/logo_old.png +0 -0
- package/eventcatalog/src/components/DiscoverInsight.astro +0 -61
- package/eventcatalog/src/components/Grids/ServiceGrid.tsx +0 -534
- package/eventcatalog/src/components/Lists/CustomSideBarSectionList.astro +0 -55
- package/eventcatalog/src/components/Lists/ProtocolList.tsx +0 -74
- package/eventcatalog/src/components/Lists/RepositoryList.astro +0 -37
- package/eventcatalog/src/components/Lists/SpecificationsList.astro +0 -67
- package/eventcatalog/src/components/SideBars/ChannelSideBar.astro +0 -204
- package/eventcatalog/src/components/SideBars/ContainerSideBar.astro +0 -180
- package/eventcatalog/src/components/SideBars/DomainSideBar.astro +0 -273
- package/eventcatalog/src/components/SideBars/EntitySideBar.astro +0 -139
- package/eventcatalog/src/components/SideBars/FlowSideBar.astro +0 -128
- package/eventcatalog/src/components/SideBars/MessageSideBar.astro +0 -248
- package/eventcatalog/src/components/SideBars/ServiceSideBar.astro +0 -294
- package/eventcatalog/src/components/SideNav/ListViewSideBar/components/CollapsibleGroup.tsx +0 -46
- package/eventcatalog/src/components/SideNav/ListViewSideBar/components/MessageList.tsx +0 -78
- package/eventcatalog/src/components/SideNav/ListViewSideBar/components/SpecificationList.tsx +0 -83
- package/eventcatalog/src/components/SideNav/ListViewSideBar/index.tsx +0 -1250
- package/eventcatalog/src/components/SideNav/ListViewSideBar/types.ts +0 -91
- package/eventcatalog/src/components/SideNav/ListViewSideBar/utils.ts +0 -201
- package/eventcatalog/src/components/SideNav/TreeView/getTreeView.ts +0 -190
- package/eventcatalog/src/components/SideNav/TreeView/index.tsx +0 -94
- package/eventcatalog/src/components/TreeView/index.tsx +0 -328
- package/eventcatalog/src/components/TreeView/styles.module.css +0 -264
- package/eventcatalog/src/components/TreeView/useSlots.ts +0 -95
- package/eventcatalog/src/pages/architecture/[type]/index.astro +0 -14
- package/eventcatalog/src/pages/architecture/architecture.astro +0 -101
- package/eventcatalog/src/pages/architecture/docs/[type]/index.astro +0 -14
- package/eventcatalog/src/utils/commands.ts +0 -112
- package/eventcatalog/src/utils/events.ts +0 -108
- package/eventcatalog/src/utils/generators/index.ts +0 -10
- package/eventcatalog/src/utils/teams.ts +0 -72
- package/eventcatalog/src/utils/users.ts +0 -72
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
4
|
+
import * as LucideIcons from 'lucide-react';
|
|
5
|
+
import { ChevronRight, ChevronLeft, ChevronDown, Home, Star } from 'lucide-react';
|
|
6
|
+
import type { NavigationData, NavNode, ChildRef } from './sidebar-builder';
|
|
7
|
+
import SearchBar from './SearchBar';
|
|
8
|
+
import { saveState, loadState, saveCollapsedSections, loadCollapsedSections } from './storage';
|
|
9
|
+
import { useStore } from '@nanostores/react';
|
|
10
|
+
import { sidebarStore } from '@stores/sidebar-store';
|
|
11
|
+
import { favoritesStore, toggleFavorite as toggleFavoriteAction, type FavoriteItem } from '@stores/favorites-store';
|
|
12
|
+
|
|
13
|
+
const cn = (...classes: (string | false | undefined)[]) => classes.filter(Boolean).join(' ');
|
|
14
|
+
|
|
15
|
+
// ============================================
|
|
16
|
+
// Badge color mapping
|
|
17
|
+
// ============================================
|
|
18
|
+
|
|
19
|
+
const getBadgeClasses = (badge: string): string => {
|
|
20
|
+
const badgeColors: Record<string, string> = {
|
|
21
|
+
domain: 'bg-blue-100 text-blue-700',
|
|
22
|
+
service: 'bg-green-100 text-green-700',
|
|
23
|
+
event: 'bg-amber-100 text-amber-700',
|
|
24
|
+
command: 'bg-pink-100 text-pink-700',
|
|
25
|
+
query: 'bg-purple-100 text-purple-700',
|
|
26
|
+
message: 'bg-indigo-100 text-indigo-700',
|
|
27
|
+
design: 'bg-teal-100 text-teal-700',
|
|
28
|
+
};
|
|
29
|
+
return badgeColors[badge.toLowerCase()] || 'bg-gray-100 text-gray-600';
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ============================================
|
|
33
|
+
// Component
|
|
34
|
+
// ============================================
|
|
35
|
+
|
|
36
|
+
type NavigationLevel = {
|
|
37
|
+
key: string | null; // The key of the node that was drilled into (null for root)
|
|
38
|
+
entries: ChildRef[];
|
|
39
|
+
title: string;
|
|
40
|
+
badge?: string; // Category badge (e.g., "Domain", "Service")
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default function NestedSideBar() {
|
|
44
|
+
const data = useStore(sidebarStore);
|
|
45
|
+
|
|
46
|
+
// Guard against undefined data (e.g., during hydration)
|
|
47
|
+
const roots = data?.roots ?? [];
|
|
48
|
+
const nodes = data?.nodes ?? {};
|
|
49
|
+
|
|
50
|
+
const [navigationStack, setNavigationStack] = useState<NavigationLevel[]>([
|
|
51
|
+
{ key: null, entries: roots, title: 'Documentation' },
|
|
52
|
+
]);
|
|
53
|
+
const [animationKey, setAnimationKey] = useState(0);
|
|
54
|
+
const [slideDirection, setSlideDirection] = useState<'forward' | 'backward' | null>(null);
|
|
55
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
56
|
+
const [currentPath, setCurrentPath] = useState<string>('');
|
|
57
|
+
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
|
|
58
|
+
const [showPathPreview, setShowPathPreview] = useState(false);
|
|
59
|
+
const [showFullPath, setShowFullPath] = useState(false);
|
|
60
|
+
const favorites = useStore(favoritesStore);
|
|
61
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
62
|
+
|
|
63
|
+
// Build a lookup map for faster URL navigation
|
|
64
|
+
// Map format: "type:id" -> "nodeKey"
|
|
65
|
+
const nodeLookup = useMemo(() => {
|
|
66
|
+
const lookup = new Map<string, string>();
|
|
67
|
+
|
|
68
|
+
Object.keys(nodes).forEach((key) => {
|
|
69
|
+
// Key formats:
|
|
70
|
+
// - "type:id:version" (e.g., "service:OrdersService:0.0.3")
|
|
71
|
+
// - "type:id" (e.g., "service:OrdersService", "user:john", "team:backend")
|
|
72
|
+
// - "list:name" (e.g., "list:domains") - skip these
|
|
73
|
+
const parts = key.split(':');
|
|
74
|
+
|
|
75
|
+
// Skip list items
|
|
76
|
+
if (parts[0] === 'list') return;
|
|
77
|
+
|
|
78
|
+
if (parts.length >= 2) {
|
|
79
|
+
// Store as "type:id"
|
|
80
|
+
const type = parts[0];
|
|
81
|
+
const id = parts[1];
|
|
82
|
+
lookup.set(`${type}:${id}`, key);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return lookup;
|
|
87
|
+
}, [nodes]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Toggle section collapse state
|
|
91
|
+
*/
|
|
92
|
+
const toggleSectionCollapse = (sectionId: string) => {
|
|
93
|
+
setCollapsedSections((prev) => {
|
|
94
|
+
const next = new Set(prev);
|
|
95
|
+
if (next.has(sectionId)) {
|
|
96
|
+
next.delete(sectionId);
|
|
97
|
+
} else {
|
|
98
|
+
next.add(sectionId);
|
|
99
|
+
}
|
|
100
|
+
// Save to localStorage
|
|
101
|
+
saveCollapsedSections(next);
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Load collapsed sections from localStorage on mount
|
|
108
|
+
*/
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
const saved = loadCollapsedSections();
|
|
111
|
+
if (saved.size > 0) {
|
|
112
|
+
setCollapsedSections(saved);
|
|
113
|
+
}
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Populate the store with the data when the component mounts or data changes
|
|
118
|
+
*/
|
|
119
|
+
// useEffect(() => {
|
|
120
|
+
// if (data) {
|
|
121
|
+
// setSidebarData(data);
|
|
122
|
+
// }
|
|
123
|
+
// }, [data]);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a child reference to a NavNode
|
|
127
|
+
*/
|
|
128
|
+
const resolveRef = useCallback(
|
|
129
|
+
(ref: ChildRef): NavNode | null => {
|
|
130
|
+
if (typeof ref === 'string') {
|
|
131
|
+
return nodes[ref] ?? null;
|
|
132
|
+
}
|
|
133
|
+
return ref;
|
|
134
|
+
},
|
|
135
|
+
[nodes]
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if a node is visible (default: true)
|
|
140
|
+
*/
|
|
141
|
+
const isVisible = useCallback((node: NavNode | null): boolean => {
|
|
142
|
+
if (!node) return false;
|
|
143
|
+
return node.visible !== false;
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build navigation stack from a path of keys
|
|
148
|
+
*/
|
|
149
|
+
const buildStackFromPath = useCallback(
|
|
150
|
+
(path: string[]): NavigationLevel[] => {
|
|
151
|
+
const stack: NavigationLevel[] = [{ key: null, entries: roots, title: 'Documentation' }];
|
|
152
|
+
|
|
153
|
+
for (const key of path) {
|
|
154
|
+
const node = nodes[key];
|
|
155
|
+
if (node && node.pages) {
|
|
156
|
+
stack.push({
|
|
157
|
+
key,
|
|
158
|
+
entries: node.pages,
|
|
159
|
+
title: node.title,
|
|
160
|
+
badge: node.badge,
|
|
161
|
+
});
|
|
162
|
+
} else {
|
|
163
|
+
// Path is invalid (node doesn't exist or has no children), stop here
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return stack;
|
|
169
|
+
},
|
|
170
|
+
[roots, nodes]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get current path from navigation stack
|
|
175
|
+
*/
|
|
176
|
+
const getCurrentPath = useCallback((): string[] => {
|
|
177
|
+
return navigationStack.filter((level) => level.key !== null).map((level) => level.key as string);
|
|
178
|
+
}, [navigationStack]);
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Find a node key by matching URL patterns
|
|
182
|
+
*/
|
|
183
|
+
const findNodeKeyByUrl = useCallback(
|
|
184
|
+
(url: string): string | null => {
|
|
185
|
+
// URL patterns to match resources with version
|
|
186
|
+
const urlPatternsWithVersion = [
|
|
187
|
+
// Domains
|
|
188
|
+
{ pattern: /^\/docs\/domains\/([^/]+)\/([^/]+)/, type: 'domain' },
|
|
189
|
+
{ pattern: /^\/visualiser\/domains\/([^/]+)\/([^/]+)/, type: 'domain' },
|
|
190
|
+
{ pattern: /^\/architecture\/domains\/([^/]+)\/([^/]+)/, type: 'domain' },
|
|
191
|
+
// Services
|
|
192
|
+
{ pattern: /^\/docs\/services\/([^/]+)\/([^/]+)/, type: 'service' },
|
|
193
|
+
{ pattern: /^\/architecture\/services\/([^/]+)\/([^/]+)/, type: 'service' },
|
|
194
|
+
{ pattern: /^\/visualiser\/services\/([^/]+)\/([^/]+)/, type: 'service' },
|
|
195
|
+
// Messages (events, commands, queries) - note: keys use singular form
|
|
196
|
+
{ pattern: /^\/docs\/events\/([^/]+)\/([^/]+)/, type: 'event' },
|
|
197
|
+
{ pattern: /^\/docs\/commands\/([^/]+)\/([^/]+)/, type: 'command' },
|
|
198
|
+
{ pattern: /^\/docs\/queries\/([^/]+)\/([^/]+)/, type: 'query' },
|
|
199
|
+
{ pattern: /^\/visualiser\/messages\/([^/]+)\/([^/]+)/, type: 'message' },
|
|
200
|
+
{ pattern: /^\/visualiser\/events\/([^/]+)\/([^/]+)/, type: 'event' },
|
|
201
|
+
{ pattern: /^\/visualiser\/commands\/([^/]+)\/([^/]+)/, type: 'command' },
|
|
202
|
+
{ pattern: /^\/visualiser\/queries\/([^/]+)\/([^/]+)/, type: 'query' },
|
|
203
|
+
// Containers
|
|
204
|
+
{ pattern: /^\/docs\/containers\/([^/]+)\/([^/]+)/, type: 'container' },
|
|
205
|
+
{ pattern: /^\/visualiser\/containers\/([^/]+)\/([^/]+)/, type: 'container' },
|
|
206
|
+
// Flows
|
|
207
|
+
{ pattern: /^\/docs\/flows\/([^/]+)\/([^/]+)/, type: 'flow' },
|
|
208
|
+
{ pattern: /^\/visualiser\/flows\/([^/]+)\/([^/]+)/, type: 'flow' },
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
// URL patterns without version (language pages, etc)
|
|
212
|
+
const urlPatternsWithoutVersion = [{ pattern: /^\/docs\/domains\/([^/]+)\/language/, type: 'domain' }];
|
|
213
|
+
|
|
214
|
+
// First try to match patterns with version
|
|
215
|
+
for (const { pattern, type } of urlPatternsWithVersion) {
|
|
216
|
+
const match = url.match(pattern);
|
|
217
|
+
if (match) {
|
|
218
|
+
const id = match[1];
|
|
219
|
+
const version = match[2];
|
|
220
|
+
|
|
221
|
+
// First try with version
|
|
222
|
+
const keyWithVersion = `${type}:${id}:${version}`;
|
|
223
|
+
if (nodes[keyWithVersion]) {
|
|
224
|
+
return keyWithVersion;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Fallback to lookup without version (for latest)
|
|
228
|
+
const foundNodeKey = nodeLookup.get(`${type}:${id}`);
|
|
229
|
+
if (foundNodeKey) return foundNodeKey;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Then try patterns without version
|
|
234
|
+
for (const { pattern, type } of urlPatternsWithoutVersion) {
|
|
235
|
+
const match = url.match(pattern);
|
|
236
|
+
if (match) {
|
|
237
|
+
const id = match[1];
|
|
238
|
+
const foundNodeKey = nodeLookup.get(`${type}:${id}`);
|
|
239
|
+
if (foundNodeKey) return foundNodeKey;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return null;
|
|
244
|
+
},
|
|
245
|
+
[nodeLookup, nodes]
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Try to connect a target node to the current stack (drill down, move up, or validate leaf)
|
|
250
|
+
*/
|
|
251
|
+
const tryConnectStack = useCallback(
|
|
252
|
+
(targetKey: string, currentStack: NavigationLevel[]): NavigationLevel[] | null => {
|
|
253
|
+
const targetNode = nodes[targetKey];
|
|
254
|
+
if (!targetNode) return null;
|
|
255
|
+
|
|
256
|
+
// 1. Check if we are already at this level (or above)
|
|
257
|
+
const existingLevelIndex = currentStack.findIndex((level) => level.key === targetKey);
|
|
258
|
+
if (existingLevelIndex !== -1) {
|
|
259
|
+
// Truncate stack to this level
|
|
260
|
+
return currentStack.slice(0, existingLevelIndex + 1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 2. Check if it's a child of the current last level
|
|
264
|
+
const lastLevel = currentStack[currentStack.length - 1];
|
|
265
|
+
const lastNode = lastLevel.key ? nodes[lastLevel.key] : null;
|
|
266
|
+
|
|
267
|
+
// If root level (key=null), we check against roots
|
|
268
|
+
const parentChildren = lastLevel.key === null ? roots : lastNode?.pages;
|
|
269
|
+
|
|
270
|
+
if (parentChildren) {
|
|
271
|
+
const isChild = parentChildren.some((ref) => {
|
|
272
|
+
if (typeof ref === 'string') return ref === targetKey;
|
|
273
|
+
// Inline nodes don't have global keys usually
|
|
274
|
+
return false;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (isChild) {
|
|
278
|
+
// If it has children, we drill down
|
|
279
|
+
if (targetNode.pages && targetNode.pages.length > 0) {
|
|
280
|
+
return [
|
|
281
|
+
...currentStack,
|
|
282
|
+
{ key: targetKey, entries: targetNode.pages, title: targetNode.title, badge: targetNode.badge },
|
|
283
|
+
];
|
|
284
|
+
}
|
|
285
|
+
// If it's a leaf, the stack is valid as is
|
|
286
|
+
return currentStack;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return null;
|
|
291
|
+
},
|
|
292
|
+
[nodes, roots]
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Find a node by matching URL patterns and navigate to it
|
|
297
|
+
*/
|
|
298
|
+
const findAndNavigateToUrl = useCallback(
|
|
299
|
+
(url: string) => {
|
|
300
|
+
const foundNodeKey = findNodeKeyByUrl(url);
|
|
301
|
+
|
|
302
|
+
if (foundNodeKey) {
|
|
303
|
+
// Try to connect to current stack first
|
|
304
|
+
const connectedStack = tryConnectStack(foundNodeKey, navigationStack);
|
|
305
|
+
|
|
306
|
+
if (connectedStack) {
|
|
307
|
+
setNavigationStack(connectedStack);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const foundNode = nodes[foundNodeKey];
|
|
312
|
+
if (foundNode && foundNode.pages && foundNode.pages.length > 0) {
|
|
313
|
+
// Fallback: Flattened navigation
|
|
314
|
+
setNavigationStack([
|
|
315
|
+
{ key: null, entries: roots, title: 'Documentation' },
|
|
316
|
+
{ key: foundNodeKey, entries: foundNode.pages, title: foundNode.title, badge: foundNode.badge },
|
|
317
|
+
]);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
} else if (url === '/' || url === '') {
|
|
321
|
+
// Reset to root if we are on homepage
|
|
322
|
+
if (navigationStack.length > 1) {
|
|
323
|
+
setSlideDirection('backward');
|
|
324
|
+
setAnimationKey((prev) => prev + 1);
|
|
325
|
+
}
|
|
326
|
+
setNavigationStack([{ key: null, entries: roots, title: 'Documentation' }]);
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
},
|
|
331
|
+
[findNodeKeyByUrl, tryConnectStack, navigationStack, nodes, roots]
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Restore state from localStorage on mount, or navigate to URL
|
|
336
|
+
*/
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (!data || roots.length === 0) return;
|
|
339
|
+
if (isInitialized) return;
|
|
340
|
+
|
|
341
|
+
const currentUrl = window.location.pathname;
|
|
342
|
+
|
|
343
|
+
// Force root navigation on homepage
|
|
344
|
+
if (currentUrl === '/' || currentUrl === '') {
|
|
345
|
+
setNavigationStack([{ key: null, entries: roots, title: 'Documentation' }]);
|
|
346
|
+
setIsInitialized(true);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const savedState = loadState();
|
|
351
|
+
const targetKey = findNodeKeyByUrl(currentUrl);
|
|
352
|
+
|
|
353
|
+
let finalStack: NavigationLevel[] | null = null;
|
|
354
|
+
|
|
355
|
+
// 1. Try to restore saved state + connect to target
|
|
356
|
+
if (savedState && savedState.path.length > 0) {
|
|
357
|
+
const restoredStack = buildStackFromPath(savedState.path);
|
|
358
|
+
|
|
359
|
+
if (targetKey) {
|
|
360
|
+
// Try to connect restored stack to target
|
|
361
|
+
const connectedStack = tryConnectStack(targetKey, restoredStack);
|
|
362
|
+
if (connectedStack) {
|
|
363
|
+
finalStack = connectedStack;
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
// No target from URL, just restore saved state
|
|
367
|
+
finalStack = restoredStack;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 2. If no valid stack from step 1, try just the target (flattened)
|
|
372
|
+
if (!finalStack && targetKey) {
|
|
373
|
+
const targetNode = nodes[targetKey];
|
|
374
|
+
if (targetNode && targetNode.pages && targetNode.pages.length > 0) {
|
|
375
|
+
finalStack = [
|
|
376
|
+
{ key: null, entries: roots, title: 'Documentation' },
|
|
377
|
+
{ key: targetKey, entries: targetNode.pages, title: targetNode.title, badge: targetNode.badge },
|
|
378
|
+
];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 3. Fallback to root
|
|
383
|
+
if (!finalStack) {
|
|
384
|
+
setNavigationStack([{ key: null, entries: roots, title: 'Documentation' }]);
|
|
385
|
+
} else {
|
|
386
|
+
setNavigationStack(finalStack);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
setIsInitialized(true);
|
|
390
|
+
}, [data, roots, buildStackFromPath, isInitialized, findNodeKeyByUrl, tryConnectStack, nodes]);
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Save state whenever navigation changes
|
|
394
|
+
*/
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
if (!isInitialized) return;
|
|
397
|
+
|
|
398
|
+
const path = getCurrentPath();
|
|
399
|
+
saveState({
|
|
400
|
+
path,
|
|
401
|
+
currentUrl: window.location.pathname,
|
|
402
|
+
});
|
|
403
|
+
}, [navigationStack, isInitialized, getCurrentPath]);
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Track current URL for highlighting active item and auto-navigation
|
|
407
|
+
*/
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
// Set initial path
|
|
410
|
+
setCurrentPath(window.location.pathname);
|
|
411
|
+
|
|
412
|
+
// Listen for URL changes (for client-side navigation)
|
|
413
|
+
const handleUrlChange = () => {
|
|
414
|
+
const newPath = window.location.pathname;
|
|
415
|
+
setCurrentPath(newPath);
|
|
416
|
+
|
|
417
|
+
// Try to auto-navigate to the new URL's resource
|
|
418
|
+
if (isInitialized) {
|
|
419
|
+
findAndNavigateToUrl(newPath);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
window.addEventListener('popstate', handleUrlChange);
|
|
424
|
+
|
|
425
|
+
// Also listen for click events on links to catch client-side navigation
|
|
426
|
+
const handleClick = (e: MouseEvent) => {
|
|
427
|
+
const target = e.target as HTMLElement;
|
|
428
|
+
const anchor = target.closest('a');
|
|
429
|
+
if (anchor && anchor.href && anchor.href.startsWith(window.location.origin)) {
|
|
430
|
+
// Delay to let the navigation happen first
|
|
431
|
+
setTimeout(() => {
|
|
432
|
+
const newPath = window.location.pathname;
|
|
433
|
+
if (newPath !== currentPath) {
|
|
434
|
+
setCurrentPath(newPath);
|
|
435
|
+
if (isInitialized) {
|
|
436
|
+
findAndNavigateToUrl(newPath);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}, 100);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
document.addEventListener('click', handleClick);
|
|
444
|
+
|
|
445
|
+
return () => {
|
|
446
|
+
window.removeEventListener('popstate', handleUrlChange);
|
|
447
|
+
document.removeEventListener('click', handleClick);
|
|
448
|
+
};
|
|
449
|
+
}, [isInitialized, findAndNavigateToUrl, currentPath]);
|
|
450
|
+
|
|
451
|
+
// Show loading state if no data yet
|
|
452
|
+
if (!data || roots.length === 0) {
|
|
453
|
+
return (
|
|
454
|
+
<aside className="w-[315px] h-screen flex flex-col bg-gray-50 border-r border-gray-200">
|
|
455
|
+
<div className="px-3 py-2 bg-white border-b border-gray-200">
|
|
456
|
+
<span className="text-sm font-semibold text-gray-900">Loading...</span>
|
|
457
|
+
</div>
|
|
458
|
+
</aside>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const currentLevel = navigationStack[navigationStack.length - 1];
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Check if a node is a group
|
|
466
|
+
*/
|
|
467
|
+
const isGroup = (node: NavNode): boolean => node.type === 'group';
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Check if a node has children
|
|
471
|
+
*/
|
|
472
|
+
const hasChildren = (node: NavNode): boolean => {
|
|
473
|
+
return (node.pages?.length ?? 0) > 0;
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Check if a section has any visible children
|
|
478
|
+
*/
|
|
479
|
+
const hasVisibleChildren = (node: NavNode): boolean => {
|
|
480
|
+
if (!node.pages) return false;
|
|
481
|
+
return node.pages.some((childRef) => {
|
|
482
|
+
const child = resolveRef(childRef);
|
|
483
|
+
return isVisible(child);
|
|
484
|
+
});
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Handle drilling down into an item with children
|
|
489
|
+
*/
|
|
490
|
+
const handleDrillDown = (node: NavNode, nodeKey: string | null) => {
|
|
491
|
+
if (node.pages && node.pages.length > 0) {
|
|
492
|
+
setSlideDirection('forward');
|
|
493
|
+
setAnimationKey((prev) => prev + 1);
|
|
494
|
+
const newStack = [...navigationStack, { key: nodeKey, entries: node.pages, title: node.title, badge: node.badge }];
|
|
495
|
+
setNavigationStack(newStack);
|
|
496
|
+
// Reset hover states to prevent showing path preview immediately after navigation
|
|
497
|
+
setShowPathPreview(false);
|
|
498
|
+
setShowFullPath(false);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Navigate back one level
|
|
504
|
+
*/
|
|
505
|
+
const navigateBack = () => {
|
|
506
|
+
if (navigationStack.length > 1) {
|
|
507
|
+
setSlideDirection('backward');
|
|
508
|
+
setAnimationKey((prev) => prev + 1);
|
|
509
|
+
setNavigationStack(navigationStack.slice(0, -1));
|
|
510
|
+
// Reset hover states
|
|
511
|
+
setShowPathPreview(false);
|
|
512
|
+
setShowFullPath(false);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Navigate to a specific level in the stack
|
|
518
|
+
*/
|
|
519
|
+
const navigateToLevel = (levelIndex: number) => {
|
|
520
|
+
if (levelIndex < navigationStack.length - 1) {
|
|
521
|
+
setSlideDirection('backward');
|
|
522
|
+
setAnimationKey((prev) => prev + 1);
|
|
523
|
+
setNavigationStack(navigationStack.slice(0, levelIndex + 1));
|
|
524
|
+
setShowPathPreview(false);
|
|
525
|
+
setShowFullPath(false);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Check if a node is favorited
|
|
531
|
+
*/
|
|
532
|
+
const isFavorited = useCallback(
|
|
533
|
+
(nodeKey: string | null): boolean => {
|
|
534
|
+
if (!nodeKey) return false;
|
|
535
|
+
return favorites.some((fav) => fav.nodeKey === nodeKey);
|
|
536
|
+
},
|
|
537
|
+
[favorites]
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Toggle favorite status for a node
|
|
542
|
+
*/
|
|
543
|
+
const toggleFavorite = (nodeKey: string | null, node: NavNode) => {
|
|
544
|
+
if (!nodeKey) return;
|
|
545
|
+
|
|
546
|
+
const favoriteItem: FavoriteItem = {
|
|
547
|
+
nodeKey,
|
|
548
|
+
path: getCurrentPath(),
|
|
549
|
+
title: node.title,
|
|
550
|
+
badge: node.badge,
|
|
551
|
+
href: node.href,
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
toggleFavoriteAction(favoriteItem);
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Navigate to a favorited item
|
|
559
|
+
*/
|
|
560
|
+
const navigateToFavorite = (favorite: FavoriteItem) => {
|
|
561
|
+
// If it has an href and no children, just navigate to the URL
|
|
562
|
+
const node = nodes[favorite.nodeKey];
|
|
563
|
+
if (favorite.href && (!node?.pages || node.pages.length === 0)) {
|
|
564
|
+
window.location.href = favorite.href;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Build the stack to this favorite
|
|
569
|
+
const stack = buildStackFromPath(favorite.path);
|
|
570
|
+
|
|
571
|
+
// If the node has children, add it to the stack
|
|
572
|
+
if (node && node.pages && node.pages.length > 0) {
|
|
573
|
+
stack.push({
|
|
574
|
+
key: favorite.nodeKey,
|
|
575
|
+
entries: node.pages,
|
|
576
|
+
title: node.title,
|
|
577
|
+
badge: node.badge,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
setSlideDirection('forward');
|
|
582
|
+
setAnimationKey((prev) => prev + 1);
|
|
583
|
+
setNavigationStack(stack);
|
|
584
|
+
// Reset hover states
|
|
585
|
+
setShowPathPreview(false);
|
|
586
|
+
setShowFullPath(false);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const isTopLevel = navigationStack.length === 1;
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Navigate to a search result
|
|
593
|
+
*/
|
|
594
|
+
const navigateToSearchResult = (nodeKey: string, node: NavNode) => {
|
|
595
|
+
// If it's a leaf node with href, navigate directly
|
|
596
|
+
if (node.href && (!node.pages || node.pages.length === 0)) {
|
|
597
|
+
window.location.href = node.href;
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// If it has children, drill down to it
|
|
602
|
+
if (node.pages && node.pages.length > 0) {
|
|
603
|
+
setSlideDirection('forward');
|
|
604
|
+
setAnimationKey((prev) => prev + 1);
|
|
605
|
+
setNavigationStack([
|
|
606
|
+
{ key: null, entries: roots, title: 'Documentation' },
|
|
607
|
+
{ key: nodeKey, entries: node.pages, title: node.title, badge: node.badge },
|
|
608
|
+
]);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
setIsSearching(false);
|
|
612
|
+
// Reset hover states
|
|
613
|
+
setShowPathPreview(false);
|
|
614
|
+
setShowFullPath(false);
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Render a list of child refs (resolving keys as needed)
|
|
619
|
+
*/
|
|
620
|
+
const renderEntries = (refs: ChildRef[]) => {
|
|
621
|
+
const result: React.ReactNode[] = [];
|
|
622
|
+
let currentItemGroup: { node: NavNode; key: string | null }[] = [];
|
|
623
|
+
|
|
624
|
+
const flushItemGroup = () => {
|
|
625
|
+
if (currentItemGroup.length > 0) {
|
|
626
|
+
result.push(
|
|
627
|
+
<div key={`items-${result.length}`} className="flex flex-col gap-0.5 mb-1.5">
|
|
628
|
+
{currentItemGroup.map((item, idx) => renderItem(item.node, item.key, idx))}
|
|
629
|
+
</div>
|
|
630
|
+
);
|
|
631
|
+
currentItemGroup = [];
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
refs.forEach((ref, index) => {
|
|
636
|
+
const node = resolveRef(ref);
|
|
637
|
+
if (!node) return;
|
|
638
|
+
|
|
639
|
+
// Skip invisible nodes
|
|
640
|
+
if (!isVisible(node)) return;
|
|
641
|
+
|
|
642
|
+
// Track the key if this is a reference
|
|
643
|
+
const nodeKey = typeof ref === 'string' ? ref : null;
|
|
644
|
+
|
|
645
|
+
if (isGroup(node)) {
|
|
646
|
+
// Skip groups with no visible children
|
|
647
|
+
if (!hasVisibleChildren(node)) return;
|
|
648
|
+
|
|
649
|
+
flushItemGroup();
|
|
650
|
+
result.push(renderGroup(node, nodeKey, index));
|
|
651
|
+
} else {
|
|
652
|
+
currentItemGroup.push({ node, key: nodeKey });
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
flushItemGroup();
|
|
657
|
+
return result;
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Render a group with its children
|
|
662
|
+
*/
|
|
663
|
+
const renderGroup = (group: NavNode, groupKey: string | null, index: number) => {
|
|
664
|
+
// Get optional icon for group
|
|
665
|
+
const GroupIcon = group.icon ? (LucideIcons as unknown as Record<string, LucideIcons.LucideIcon>)[group.icon] : null;
|
|
666
|
+
|
|
667
|
+
// Get visible children
|
|
668
|
+
const visibleChildren =
|
|
669
|
+
group.pages?.filter((childRef) => {
|
|
670
|
+
const child = resolveRef(childRef);
|
|
671
|
+
return child && isVisible(child);
|
|
672
|
+
}) ?? [];
|
|
673
|
+
|
|
674
|
+
const groupId = groupKey || `group-${index}`;
|
|
675
|
+
const isCollapsed = collapsedSections.has(groupId);
|
|
676
|
+
const canCollapse = visibleChildren.length > 3;
|
|
677
|
+
|
|
678
|
+
const headerContent = (
|
|
679
|
+
<>
|
|
680
|
+
<div className="flex items-center">
|
|
681
|
+
{GroupIcon && (
|
|
682
|
+
<span className="mr-2 text-gray-900">
|
|
683
|
+
<GroupIcon className="w-3.5 h-3.5" />
|
|
684
|
+
</span>
|
|
685
|
+
)}
|
|
686
|
+
<span className="text-sm text-black font-semibold">{group.title}</span>
|
|
687
|
+
</div>
|
|
688
|
+
{canCollapse && <ChevronDown className={cn('w-4 h-4 text-gray-400 transition-transform', isCollapsed && '-rotate-90')} />}
|
|
689
|
+
</>
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
return (
|
|
693
|
+
<div key={`group-${groupKey || index}`} className="mb-4 last:mb-2">
|
|
694
|
+
{canCollapse ? (
|
|
695
|
+
<button
|
|
696
|
+
onClick={() => toggleSectionCollapse(groupId)}
|
|
697
|
+
className="flex items-center justify-between w-full px-2 py-1.5 pb-1.5 hover:bg-gray-100 rounded transition-colors cursor-pointer"
|
|
698
|
+
>
|
|
699
|
+
{headerContent}
|
|
700
|
+
</button>
|
|
701
|
+
) : (
|
|
702
|
+
<div className="flex items-center justify-between px-2 py-1.5 pb-1.5">{headerContent}</div>
|
|
703
|
+
)}
|
|
704
|
+
{!isCollapsed && (
|
|
705
|
+
<div className="flex flex-col gap-0.5 border-l ml-3.5 border-gray-100">
|
|
706
|
+
{visibleChildren.map((childRef, childIndex) => {
|
|
707
|
+
const child = resolveRef(childRef);
|
|
708
|
+
if (!child) return null;
|
|
709
|
+
|
|
710
|
+
const childKey = typeof childRef === 'string' ? childRef : null;
|
|
711
|
+
|
|
712
|
+
if (isGroup(child)) {
|
|
713
|
+
// Skip nested groups with no visible children
|
|
714
|
+
if (!hasVisibleChildren(child)) return null;
|
|
715
|
+
|
|
716
|
+
return (
|
|
717
|
+
<div key={`nested-group-${childKey || childIndex}`} className="ml-3 mt-1.5 pl-3 border-l border-gray-200">
|
|
718
|
+
{renderGroup(child, childKey, childIndex)}
|
|
719
|
+
</div>
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
return renderItem(child, childKey, childIndex);
|
|
723
|
+
})}
|
|
724
|
+
</div>
|
|
725
|
+
)}
|
|
726
|
+
</div>
|
|
727
|
+
);
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Render a single item
|
|
732
|
+
*/
|
|
733
|
+
const renderItem = (item: NavNode, itemKey: string | null, index: number) => {
|
|
734
|
+
const itemHasChildren = hasChildren(item);
|
|
735
|
+
const isActive = item.href && currentPath === item.href;
|
|
736
|
+
const isFav = isFavorited(itemKey);
|
|
737
|
+
const canFavorite = itemKey !== null; // Only items with keys can be favorited
|
|
738
|
+
|
|
739
|
+
// Get icon component from lucide-react
|
|
740
|
+
const IconComponent = item.icon ? (LucideIcons as unknown as Record<string, LucideIcons.LucideIcon>)[item.icon] : null;
|
|
741
|
+
|
|
742
|
+
const handleStarClick = (e: React.MouseEvent) => {
|
|
743
|
+
e.preventDefault();
|
|
744
|
+
e.stopPropagation();
|
|
745
|
+
toggleFavorite(itemKey, item);
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const content = (
|
|
749
|
+
<>
|
|
750
|
+
<div className="flex items-center gap-2.5 min-w-0 flex-1 ">
|
|
751
|
+
{IconComponent && (
|
|
752
|
+
<span
|
|
753
|
+
className={cn(
|
|
754
|
+
'flex items-center justify-center w-5 h-5 flex-shrink-0',
|
|
755
|
+
isActive ? 'text-purple-600' : 'text-gray-500'
|
|
756
|
+
)}
|
|
757
|
+
>
|
|
758
|
+
<IconComponent className="w-4 h-4" />
|
|
759
|
+
</span>
|
|
760
|
+
)}
|
|
761
|
+
<span
|
|
762
|
+
className={cn(
|
|
763
|
+
'text-[13px] truncate',
|
|
764
|
+
isActive ? 'text-purple-600 font-medium' : 'text-gray-600 group-hover:text-gray-900'
|
|
765
|
+
)}
|
|
766
|
+
>
|
|
767
|
+
{item.title}
|
|
768
|
+
</span>
|
|
769
|
+
</div>
|
|
770
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
771
|
+
{canFavorite && (
|
|
772
|
+
<button
|
|
773
|
+
onClick={handleStarClick}
|
|
774
|
+
className={cn(
|
|
775
|
+
'flex items-center justify-center w-5 h-5 rounded transition-colors',
|
|
776
|
+
isFav
|
|
777
|
+
? 'text-amber-400 hover:text-amber-500'
|
|
778
|
+
: 'text-gray-300 opacity-0 group-hover:opacity-100 hover:text-amber-400'
|
|
779
|
+
)}
|
|
780
|
+
>
|
|
781
|
+
<Star className={cn('w-3.5 h-3.5', isFav && 'fill-current')} />
|
|
782
|
+
</button>
|
|
783
|
+
)}
|
|
784
|
+
{itemHasChildren && (
|
|
785
|
+
<span className="flex items-center justify-center w-5 h-5 text-gray-400 group-hover:text-purple-500 group-hover:translate-x-0.5 transition-transform">
|
|
786
|
+
<ChevronRight className="w-4 h-4" />
|
|
787
|
+
</span>
|
|
788
|
+
)}
|
|
789
|
+
</div>
|
|
790
|
+
</>
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
const baseClasses =
|
|
794
|
+
'group flex items-center justify-between w-full px-3 py-1 rounded-lg cursor-pointer text-left transition-colors hover:bg-gray-100 active:bg-gray-200';
|
|
795
|
+
const parentClasses = itemHasChildren ? 'font-medium' : '';
|
|
796
|
+
const activeClasses = isActive ? 'bg-purple-100 hover:bg-purple-100 border-l-4 border-purple-600 rounded-l-none' : '';
|
|
797
|
+
|
|
798
|
+
// Leaf item with href → render as link
|
|
799
|
+
if (item.href && !itemHasChildren) {
|
|
800
|
+
return (
|
|
801
|
+
<a
|
|
802
|
+
key={`item-${itemKey || index}`}
|
|
803
|
+
href={item.href}
|
|
804
|
+
target={item.external ? '_blank' : undefined}
|
|
805
|
+
className={cn(baseClasses, parentClasses, activeClasses)}
|
|
806
|
+
>
|
|
807
|
+
{content}
|
|
808
|
+
</a>
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Item with children → render as button for drill-down
|
|
813
|
+
return (
|
|
814
|
+
<button
|
|
815
|
+
key={`item-${itemKey || index}`}
|
|
816
|
+
onClick={() => handleDrillDown(item, itemKey)}
|
|
817
|
+
className={cn(baseClasses, parentClasses)}
|
|
818
|
+
>
|
|
819
|
+
{content}
|
|
820
|
+
</button>
|
|
821
|
+
);
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
// Animation classes
|
|
825
|
+
const getAnimationClass = () => {
|
|
826
|
+
if (slideDirection === 'forward') return 'animate-slide-in-right';
|
|
827
|
+
if (slideDirection === 'backward') return 'animate-slide-in-left';
|
|
828
|
+
return '';
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
return (
|
|
832
|
+
<aside className="w-[315px] h-full flex flex-col font-sans">
|
|
833
|
+
{/* Search */}
|
|
834
|
+
<SearchBar nodes={nodes} onSelectResult={navigateToSearchResult} onSearchChange={setIsSearching} />
|
|
835
|
+
|
|
836
|
+
{/* Back Navigation and Nav Content - hidden when showing search results */}
|
|
837
|
+
{!isSearching && (
|
|
838
|
+
<>
|
|
839
|
+
{!isTopLevel && (
|
|
840
|
+
<div
|
|
841
|
+
className="px-3 py-2 bg-white border-b border-gray-200 sticky top-0 z-10"
|
|
842
|
+
onMouseEnter={() => !isTopLevel && setShowPathPreview(true)}
|
|
843
|
+
onMouseLeave={() => {
|
|
844
|
+
setShowPathPreview(false);
|
|
845
|
+
setShowFullPath(false);
|
|
846
|
+
}}
|
|
847
|
+
>
|
|
848
|
+
<button
|
|
849
|
+
onClick={navigateBack}
|
|
850
|
+
disabled={isTopLevel}
|
|
851
|
+
className={cn(
|
|
852
|
+
'flex items-center gap-2 w-full px-2 py-1.5 -mx-2 rounded-md transition-colors',
|
|
853
|
+
!isTopLevel && 'hover:bg-gray-100 cursor-pointer',
|
|
854
|
+
isTopLevel && 'cursor-default'
|
|
855
|
+
)}
|
|
856
|
+
>
|
|
857
|
+
<span
|
|
858
|
+
className={cn(
|
|
859
|
+
'flex items-center justify-center w-5 h-5 text-gray-500 transition-all',
|
|
860
|
+
isTopLevel && 'opacity-0',
|
|
861
|
+
!isTopLevel && 'group-hover:-translate-x-0.5'
|
|
862
|
+
)}
|
|
863
|
+
>
|
|
864
|
+
<ChevronLeft className="w-4 h-4" />
|
|
865
|
+
</span>
|
|
866
|
+
<span className="text-sm font-semibold text-gray-900 truncate">{currentLevel.title}</span>
|
|
867
|
+
{currentLevel.badge && (
|
|
868
|
+
<span
|
|
869
|
+
className={cn(
|
|
870
|
+
'ml-auto px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wide rounded',
|
|
871
|
+
getBadgeClasses(currentLevel.badge)
|
|
872
|
+
)}
|
|
873
|
+
>
|
|
874
|
+
{currentLevel.badge}
|
|
875
|
+
</span>
|
|
876
|
+
)}
|
|
877
|
+
</button>
|
|
878
|
+
|
|
879
|
+
{/* Path Preview Dropdown */}
|
|
880
|
+
{showPathPreview && navigationStack.length > 1 && (
|
|
881
|
+
<div className="absolute left-0 right-0 top-full bg-white border-b border-gray-200 shadow-lg z-20">
|
|
882
|
+
<div className="px-3 py-2">
|
|
883
|
+
<div className="text-[10px] font-medium text-gray-400 uppercase tracking-wide mb-2">Navigation Path</div>
|
|
884
|
+
<div className="flex flex-col gap-0.5">
|
|
885
|
+
{(() => {
|
|
886
|
+
const SHOW_FIRST = 2; // Show first N items
|
|
887
|
+
const SHOW_LAST = 2; // Show last N items (including current)
|
|
888
|
+
const totalItems = navigationStack.length;
|
|
889
|
+
const hiddenCount = totalItems - SHOW_FIRST - SHOW_LAST;
|
|
890
|
+
const shouldTruncate = hiddenCount > 0 && !showFullPath;
|
|
891
|
+
|
|
892
|
+
const renderPathItem = (level: NavigationLevel, index: number, displayIndex: number) => {
|
|
893
|
+
const isCurrentLevel = index === navigationStack.length - 1;
|
|
894
|
+
return (
|
|
895
|
+
<button
|
|
896
|
+
key={`path-${index}`}
|
|
897
|
+
onClick={() => navigateToLevel(index)}
|
|
898
|
+
disabled={isCurrentLevel}
|
|
899
|
+
className={cn(
|
|
900
|
+
'flex items-center gap-2 px-2 py-1.5 rounded text-left transition-colors',
|
|
901
|
+
!isCurrentLevel && 'hover:bg-gray-100 cursor-pointer',
|
|
902
|
+
isCurrentLevel && 'bg-purple-50 cursor-default'
|
|
903
|
+
)}
|
|
904
|
+
style={{ paddingLeft: `${displayIndex * 12 + 8}px` }}
|
|
905
|
+
>
|
|
906
|
+
{index === 0 ? (
|
|
907
|
+
<Home className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
|
908
|
+
) : (
|
|
909
|
+
<ChevronRight className="w-3.5 h-3.5 text-gray-300 flex-shrink-0" />
|
|
910
|
+
)}
|
|
911
|
+
<span
|
|
912
|
+
className={cn(
|
|
913
|
+
'text-sm truncate',
|
|
914
|
+
isCurrentLevel ? 'font-medium text-purple-700' : 'text-gray-600'
|
|
915
|
+
)}
|
|
916
|
+
>
|
|
917
|
+
{level.title}
|
|
918
|
+
</span>
|
|
919
|
+
{level.badge && (
|
|
920
|
+
<span
|
|
921
|
+
className={cn(
|
|
922
|
+
'ml-auto px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wide rounded flex-shrink-0',
|
|
923
|
+
getBadgeClasses(level.badge)
|
|
924
|
+
)}
|
|
925
|
+
>
|
|
926
|
+
{level.badge}
|
|
927
|
+
</span>
|
|
928
|
+
)}
|
|
929
|
+
</button>
|
|
930
|
+
);
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
if (shouldTruncate) {
|
|
934
|
+
return (
|
|
935
|
+
<>
|
|
936
|
+
{/* First N items */}
|
|
937
|
+
{navigationStack.slice(0, SHOW_FIRST).map((level, index) => renderPathItem(level, index, index))}
|
|
938
|
+
|
|
939
|
+
{/* Collapsed middle section */}
|
|
940
|
+
<button
|
|
941
|
+
onClick={(e) => {
|
|
942
|
+
e.stopPropagation();
|
|
943
|
+
setShowFullPath(true);
|
|
944
|
+
}}
|
|
945
|
+
className="flex items-center gap-2 px-2 py-1.5 rounded text-left transition-colors hover:bg-gray-100 cursor-pointer"
|
|
946
|
+
style={{ paddingLeft: `${SHOW_FIRST * 12 + 8}px` }}
|
|
947
|
+
>
|
|
948
|
+
<span className="flex items-center justify-center w-3.5 h-3.5 text-gray-400">
|
|
949
|
+
<span className="text-xs">•••</span>
|
|
950
|
+
</span>
|
|
951
|
+
<span className="text-sm text-gray-500">
|
|
952
|
+
{hiddenCount} more level{hiddenCount > 1 ? 's' : ''}
|
|
953
|
+
</span>
|
|
954
|
+
<ChevronDown className="w-3.5 h-3.5 text-gray-400 ml-auto" />
|
|
955
|
+
</button>
|
|
956
|
+
|
|
957
|
+
{/* Last N items */}
|
|
958
|
+
{navigationStack.slice(-SHOW_LAST).map((level, sliceIndex) => {
|
|
959
|
+
const actualIndex = totalItems - SHOW_LAST + sliceIndex;
|
|
960
|
+
return renderPathItem(level, actualIndex, SHOW_FIRST + 1 + sliceIndex);
|
|
961
|
+
})}
|
|
962
|
+
</>
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Show full path
|
|
967
|
+
return navigationStack.map((level, index) => renderPathItem(level, index, index));
|
|
968
|
+
})()}
|
|
969
|
+
</div>
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
)}
|
|
973
|
+
</div>
|
|
974
|
+
)}
|
|
975
|
+
|
|
976
|
+
{/* Navigation Content */}
|
|
977
|
+
<nav
|
|
978
|
+
key={animationKey}
|
|
979
|
+
className={cn('flex-1 overflow-y-auto overflow-x-hidden p-3', getAnimationClass())}
|
|
980
|
+
style={{
|
|
981
|
+
scrollbarWidth: 'thin',
|
|
982
|
+
scrollbarColor: '#e5e7eb transparent',
|
|
983
|
+
}}
|
|
984
|
+
>
|
|
985
|
+
{/* Favorites Section */}
|
|
986
|
+
{favorites.length > 0 && isTopLevel && (
|
|
987
|
+
<div className="mb-6">
|
|
988
|
+
<div className="flex items-center px-2 py-2 pb-2">
|
|
989
|
+
<Star className="w-3.5 h-3.5 mr-2 text-amber-400 fill-current" />
|
|
990
|
+
<span className="text-sm text-black font-semibold">Favorites</span>
|
|
991
|
+
</div>
|
|
992
|
+
<div className="flex flex-col gap-0.5 border-l ml-3.5 border-amber-200">
|
|
993
|
+
{favorites.map((fav, index) => {
|
|
994
|
+
const node = nodes[fav.nodeKey];
|
|
995
|
+
const isActive = fav.href && currentPath === fav.href;
|
|
996
|
+
|
|
997
|
+
return (
|
|
998
|
+
<button
|
|
999
|
+
key={`fav-${index}`}
|
|
1000
|
+
onClick={() => navigateToFavorite(fav)}
|
|
1001
|
+
className={cn(
|
|
1002
|
+
'group flex items-center justify-between w-full px-3 py-1.5 rounded-lg cursor-pointer text-left transition-colors hover:bg-amber-50 active:bg-amber-100',
|
|
1003
|
+
isActive && 'bg-purple-100 hover:bg-purple-100 border-l-4 border-purple-600 rounded-l-none'
|
|
1004
|
+
)}
|
|
1005
|
+
>
|
|
1006
|
+
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
|
1007
|
+
<span
|
|
1008
|
+
className={cn(
|
|
1009
|
+
'text-[14px] truncate',
|
|
1010
|
+
isActive ? 'text-purple-600 font-medium' : 'text-gray-600 group-hover:text-gray-900'
|
|
1011
|
+
)}
|
|
1012
|
+
>
|
|
1013
|
+
{fav.title}
|
|
1014
|
+
</span>
|
|
1015
|
+
</div>
|
|
1016
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
1017
|
+
{fav.badge && (
|
|
1018
|
+
<span
|
|
1019
|
+
className={cn(
|
|
1020
|
+
'px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wide rounded',
|
|
1021
|
+
getBadgeClasses(fav.badge)
|
|
1022
|
+
)}
|
|
1023
|
+
>
|
|
1024
|
+
{fav.badge}
|
|
1025
|
+
</span>
|
|
1026
|
+
)}
|
|
1027
|
+
<button
|
|
1028
|
+
onClick={(e) => {
|
|
1029
|
+
e.stopPropagation();
|
|
1030
|
+
if (node) toggleFavorite(fav.nodeKey, node);
|
|
1031
|
+
}}
|
|
1032
|
+
className="flex items-center justify-center w-5 h-5 text-amber-400 hover:text-amber-500 rounded transition-colors"
|
|
1033
|
+
>
|
|
1034
|
+
<Star className="w-3.5 h-3.5 fill-current" />
|
|
1035
|
+
</button>
|
|
1036
|
+
{node?.pages && node.pages.length > 0 && (
|
|
1037
|
+
<span className="flex items-center justify-center w-5 h-5 text-gray-400 group-hover:text-purple-500">
|
|
1038
|
+
<ChevronRight className="w-4 h-4" />
|
|
1039
|
+
</span>
|
|
1040
|
+
)}
|
|
1041
|
+
</div>
|
|
1042
|
+
</button>
|
|
1043
|
+
);
|
|
1044
|
+
})}
|
|
1045
|
+
</div>
|
|
1046
|
+
</div>
|
|
1047
|
+
)}
|
|
1048
|
+
|
|
1049
|
+
{renderEntries(currentLevel.entries)}
|
|
1050
|
+
</nav>
|
|
1051
|
+
</>
|
|
1052
|
+
)}
|
|
1053
|
+
|
|
1054
|
+
{/* Animation keyframes */}
|
|
1055
|
+
<style>{`
|
|
1056
|
+
@keyframes slideInRight {
|
|
1057
|
+
from { opacity: 0; transform: translateX(40px); }
|
|
1058
|
+
to { opacity: 1; transform: translateX(0); }
|
|
1059
|
+
}
|
|
1060
|
+
@keyframes slideInLeft {
|
|
1061
|
+
from { opacity: 0; transform: translateX(-40px); }
|
|
1062
|
+
to { opacity: 1; transform: translateX(0); }
|
|
1063
|
+
}
|
|
1064
|
+
.animate-slide-in-right {
|
|
1065
|
+
animation: slideInRight 200ms ease-out forwards;
|
|
1066
|
+
}
|
|
1067
|
+
.animate-slide-in-left {
|
|
1068
|
+
animation: slideInLeft 200ms ease-out forwards;
|
|
1069
|
+
}
|
|
1070
|
+
`}</style>
|
|
1071
|
+
</aside>
|
|
1072
|
+
);
|
|
1073
|
+
}
|