@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.
Files changed (130) hide show
  1. package/README.md +1 -26
  2. package/dist/analytics/analytics.cjs +1 -1
  3. package/dist/analytics/analytics.js +2 -2
  4. package/dist/analytics/log-build.cjs +1 -1
  5. package/dist/analytics/log-build.js +3 -3
  6. package/dist/{chunk-NK6OYMRD.js → chunk-JB4YT5JY.js} +1 -1
  7. package/dist/{chunk-BMDTX5IN.js → chunk-TQ4HZREX.js} +1 -1
  8. package/dist/{chunk-IJRFYF4B.js → chunk-X4W4YC3U.js} +1 -1
  9. package/dist/constants.cjs +1 -1
  10. package/dist/constants.js +1 -1
  11. package/dist/eventcatalog.cjs +1 -21
  12. package/dist/eventcatalog.config.d.cts +10 -0
  13. package/dist/eventcatalog.config.d.ts +10 -0
  14. package/dist/eventcatalog.js +3 -20
  15. package/eventcatalog/src/components/CopyAsMarkdown.tsx +19 -1
  16. package/eventcatalog/src/components/FavoriteButton.tsx +54 -0
  17. package/eventcatalog/src/components/Grids/DomainGrid.tsx +386 -362
  18. package/eventcatalog/src/components/Grids/MessageGrid.tsx +166 -518
  19. package/eventcatalog/src/components/Header.astro +48 -23
  20. package/eventcatalog/src/components/Lists/VersionList.astro +2 -2
  21. package/eventcatalog/src/components/MDX/Design/Design.astro +4 -1
  22. package/eventcatalog/src/components/MDX/Flow/Flow.astro +2 -1
  23. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +3 -3
  24. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +8 -2
  25. package/eventcatalog/src/components/SchemaExplorer/SchemaPageViewer.tsx +37 -0
  26. package/eventcatalog/src/components/Search/Search.astro +48 -28
  27. package/eventcatalog/src/components/Search/SearchModal.tsx +393 -702
  28. package/eventcatalog/src/components/SideNav/NestedSideBar/SearchBar.tsx +298 -0
  29. package/eventcatalog/src/components/SideNav/NestedSideBar/builders/container.ts +66 -0
  30. package/eventcatalog/src/components/SideNav/NestedSideBar/builders/domain.ts +101 -0
  31. package/eventcatalog/src/components/SideNav/NestedSideBar/builders/flow.ts +29 -0
  32. package/eventcatalog/src/components/SideNav/NestedSideBar/builders/message.ts +84 -0
  33. package/eventcatalog/src/components/SideNav/NestedSideBar/builders/service.ts +147 -0
  34. package/eventcatalog/src/components/SideNav/NestedSideBar/builders/shared.ts +146 -0
  35. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +1073 -0
  36. package/eventcatalog/src/components/SideNav/NestedSideBar/sidebar-builder.ts +365 -0
  37. package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +90 -0
  38. package/eventcatalog/src/components/SideNav/SideNav.astro +18 -28
  39. package/eventcatalog/src/content.config.ts +2 -0
  40. package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +10 -4
  41. package/eventcatalog/src/enterprise/eventcatalog-chat/pages/chat/index.astro +3 -3
  42. package/eventcatalog/src/layouts/DirectoryLayout.astro +2 -2
  43. package/eventcatalog/src/layouts/DiscoverLayout.astro +3 -3
  44. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +85 -63
  45. package/eventcatalog/src/layouts/VisualiserLayout.astro +3 -3
  46. package/eventcatalog/src/pages/_index.astro +530 -110
  47. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +64 -0
  48. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +29 -0
  49. package/eventcatalog/src/pages/directory/[type]/_index.data.ts +4 -4
  50. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts +1 -4
  51. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +3 -3
  52. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro +1 -5
  53. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +362 -190
  54. package/eventcatalog/src/pages/docs/[type]/[id]/[version].md.ts +1 -1
  55. package/eventcatalog/src/pages/docs/[type]/[id]/index.astro +4 -4
  56. package/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts +1 -4
  57. package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +3 -27
  58. package/eventcatalog/src/pages/docs/teams/[id]/_index.data.ts +2 -2
  59. package/eventcatalog/src/pages/docs/users/[id]/_index.data.ts +2 -2
  60. package/eventcatalog/src/pages/index.astro +14 -5
  61. package/eventcatalog/src/pages/nav-index.json.ts +30 -0
  62. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts +77 -0
  63. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +90 -0
  64. package/eventcatalog/src/pages/schemas/{index.astro → explorer/index.astro} +3 -3
  65. package/eventcatalog/src/pages/studio.astro +3 -3
  66. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/_index.data.ts +4 -3
  67. package/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro +2 -2
  68. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/_index.data.ts +4 -3
  69. package/eventcatalog/src/stores/favorites-store.ts +83 -0
  70. package/eventcatalog/src/stores/sidebar-store.ts +8 -0
  71. package/eventcatalog/src/utils/collections/changelogs.ts +7 -4
  72. package/eventcatalog/src/utils/{channels.ts → collections/channels.ts} +81 -31
  73. package/eventcatalog/src/utils/collections/commands.ts +134 -0
  74. package/eventcatalog/src/utils/collections/containers.ts +44 -33
  75. package/eventcatalog/src/utils/collections/domains.ts +204 -62
  76. package/eventcatalog/src/utils/{entities.ts → collections/entities.ts} +44 -24
  77. package/eventcatalog/src/utils/collections/events.ts +136 -0
  78. package/eventcatalog/src/utils/collections/flows.ts +59 -25
  79. package/eventcatalog/src/utils/{messages.ts → collections/messages.ts} +13 -4
  80. package/eventcatalog/src/utils/{queries.ts → collections/queries.ts} +49 -28
  81. package/eventcatalog/src/utils/collections/services.ts +100 -68
  82. package/eventcatalog/src/utils/collections/teams.ts +94 -0
  83. package/eventcatalog/src/utils/collections/users.ts +122 -0
  84. package/eventcatalog/src/utils/collections/util.ts +57 -1
  85. package/eventcatalog/src/utils/feature.ts +3 -1
  86. package/eventcatalog/src/utils/{collections/file-diffs.ts → file-diffs.ts} +1 -1
  87. package/eventcatalog/src/utils/node-graphs/container-node-graph.ts +2 -0
  88. package/eventcatalog/src/utils/node-graphs/domain-entity-map.ts +16 -6
  89. package/eventcatalog/src/utils/node-graphs/domains-canvas.ts +14 -10
  90. package/eventcatalog/src/utils/node-graphs/domains-node-graph.ts +36 -64
  91. package/eventcatalog/src/utils/node-graphs/flows-node-graph.ts +23 -19
  92. package/eventcatalog/src/utils/node-graphs/message-node-graph.ts +36 -49
  93. package/eventcatalog/src/utils/node-graphs/services-node-graph.ts +22 -18
  94. package/eventcatalog/src/utils/page-loaders/page-data-loader.ts +4 -4
  95. package/eventcatalog/tailwind.config.mjs +14 -0
  96. package/eventcatalog/tsconfig.json +2 -1
  97. package/package.json +7 -4
  98. package/eventcatalog/public/logo_old.png +0 -0
  99. package/eventcatalog/src/components/DiscoverInsight.astro +0 -61
  100. package/eventcatalog/src/components/Grids/ServiceGrid.tsx +0 -534
  101. package/eventcatalog/src/components/Lists/CustomSideBarSectionList.astro +0 -55
  102. package/eventcatalog/src/components/Lists/ProtocolList.tsx +0 -74
  103. package/eventcatalog/src/components/Lists/RepositoryList.astro +0 -37
  104. package/eventcatalog/src/components/Lists/SpecificationsList.astro +0 -67
  105. package/eventcatalog/src/components/SideBars/ChannelSideBar.astro +0 -204
  106. package/eventcatalog/src/components/SideBars/ContainerSideBar.astro +0 -180
  107. package/eventcatalog/src/components/SideBars/DomainSideBar.astro +0 -273
  108. package/eventcatalog/src/components/SideBars/EntitySideBar.astro +0 -139
  109. package/eventcatalog/src/components/SideBars/FlowSideBar.astro +0 -128
  110. package/eventcatalog/src/components/SideBars/MessageSideBar.astro +0 -248
  111. package/eventcatalog/src/components/SideBars/ServiceSideBar.astro +0 -294
  112. package/eventcatalog/src/components/SideNav/ListViewSideBar/components/CollapsibleGroup.tsx +0 -46
  113. package/eventcatalog/src/components/SideNav/ListViewSideBar/components/MessageList.tsx +0 -78
  114. package/eventcatalog/src/components/SideNav/ListViewSideBar/components/SpecificationList.tsx +0 -83
  115. package/eventcatalog/src/components/SideNav/ListViewSideBar/index.tsx +0 -1250
  116. package/eventcatalog/src/components/SideNav/ListViewSideBar/types.ts +0 -91
  117. package/eventcatalog/src/components/SideNav/ListViewSideBar/utils.ts +0 -201
  118. package/eventcatalog/src/components/SideNav/TreeView/getTreeView.ts +0 -190
  119. package/eventcatalog/src/components/SideNav/TreeView/index.tsx +0 -94
  120. package/eventcatalog/src/components/TreeView/index.tsx +0 -328
  121. package/eventcatalog/src/components/TreeView/styles.module.css +0 -264
  122. package/eventcatalog/src/components/TreeView/useSlots.ts +0 -95
  123. package/eventcatalog/src/pages/architecture/[type]/index.astro +0 -14
  124. package/eventcatalog/src/pages/architecture/architecture.astro +0 -101
  125. package/eventcatalog/src/pages/architecture/docs/[type]/index.astro +0 -14
  126. package/eventcatalog/src/utils/commands.ts +0 -112
  127. package/eventcatalog/src/utils/events.ts +0 -108
  128. package/eventcatalog/src/utils/generators/index.ts +0 -10
  129. package/eventcatalog/src/utils/teams.ts +0 -72
  130. 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
+ }