@eventcatalog/core 2.65.1 → 3.0.0-beta.1

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