@eventcatalog/core 3.10.2 → 3.12.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 (41) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-K7EPHS7S.js → chunk-G7GG3HEB.js} +1 -1
  6. package/dist/{chunk-DGE5ITBR.js → chunk-K44BXVHU.js} +1 -1
  7. package/dist/{chunk-IJKRHWWO.js → chunk-LXOS3MXQ.js} +1 -1
  8. package/dist/{chunk-QWSUFHCT.js → chunk-VUBZ6A7B.js} +1 -1
  9. package/dist/{chunk-AL67CV2N.js → chunk-WVKLG26T.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +2 -1
  13. package/dist/eventcatalog.js +6 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/integrations/eventcatalog-features.ts +13 -0
  19. package/eventcatalog/src/components/MDX/Design/Design.astro +10 -2
  20. package/eventcatalog/src/components/MDX/EntityMap/EntityMap.astro +10 -2
  21. package/eventcatalog/src/components/MDX/Flow/Flow.astro +10 -2
  22. package/eventcatalog/src/components/MDX/NodeGraph/Edges/AnimatedMessageEdge.tsx +13 -0
  23. package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModeContent.tsx +294 -0
  24. package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModeNodeActions.tsx +92 -0
  25. package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModePlaceholder.tsx +26 -0
  26. package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/utils.ts +163 -0
  27. package/eventcatalog/src/components/MDX/NodeGraph/FocusModeModal.tsx +99 -0
  28. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +10 -2
  29. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.tsx +166 -43
  30. package/eventcatalog/src/components/MDX/NodeGraph/Nodes/Entity.tsx +4 -1
  31. package/eventcatalog/src/components/MDX/NodeGraph/Nodes/MessageContextMenu.tsx +4 -1
  32. package/eventcatalog/src/components/MDX/NodeGraph/Nodes/Service.tsx +4 -1
  33. package/eventcatalog/src/components/MDX/NodeGraph/VisualizerDropdownContent.tsx +91 -2
  34. package/eventcatalog/src/enterprise/visualizer-layout/reset.ts +45 -0
  35. package/eventcatalog/src/enterprise/visualizer-layout/save.ts +57 -0
  36. package/eventcatalog/src/layouts/Footer.astro +4 -1
  37. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +3 -1
  38. package/eventcatalog/src/utils/feature.ts +2 -0
  39. package/eventcatalog/src/utils/node-graphs/layout-persistence.ts +81 -0
  40. package/eventcatalog/tailwind.config.mjs +10 -0
  41. package/package.json +1 -1
@@ -12,6 +12,7 @@ import {
12
12
  useEdgesState,
13
13
  type Edge,
14
14
  type Node,
15
+ type NodeChange,
15
16
  useReactFlow,
16
17
  getNodesBounds,
17
18
  getViewportForBounds,
@@ -53,11 +54,15 @@ import { useEventCatalogVisualiser } from 'src/hooks/eventcatalog-visualizer';
53
54
  import VisualiserSearch, { type VisualiserSearchRef } from './VisualiserSearch';
54
55
  import StepWalkthrough from './StepWalkthrough';
55
56
  import StudioModal from './StudioModal';
57
+ import FocusModeModal from './FocusModeModal';
56
58
  import MermaidView from './MermaidView';
57
59
  import VisualizerDropdownContent from './VisualizerDropdownContent';
58
60
  import { convertToMermaid } from '@utils/node-graphs/export-mermaid';
59
61
  import { copyToClipboard } from '@utils/clipboard';
60
62
 
63
+ // Minimum pixel change to detect layout modifications (avoids floating point comparison issues)
64
+ const POSITION_CHANGE_THRESHOLD = 1;
65
+
61
66
  interface Props {
62
67
  nodes: any;
63
68
  edges: any;
@@ -78,6 +83,8 @@ interface Props {
78
83
  setIsStudioModalOpen?: (isOpen: boolean) => void;
79
84
  isChatEnabled?: boolean;
80
85
  maxTextSize?: number;
86
+ isDevMode?: boolean;
87
+ resourceKey?: string;
81
88
  }
82
89
 
83
90
  const getVisualiserUrlForCollection = (collectionItem: CollectionEntry<CollectionTypes>) => {
@@ -101,6 +108,8 @@ const NodeGraphBuilder = ({
101
108
  setIsStudioModalOpen = () => {},
102
109
  isChatEnabled = false,
103
110
  maxTextSize,
111
+ isDevMode = false,
112
+ resourceKey,
104
113
  }: Props) => {
105
114
  const nodeTypes = useMemo(
106
115
  () =>
@@ -153,7 +162,12 @@ const NodeGraphBuilder = ({
153
162
  const [shareUrlCopySuccess, setShareUrlCopySuccess] = useState(false);
154
163
  const [isMermaidView, setIsMermaidView] = useState(false);
155
164
  const [showMinimap, setShowMinimap] = useState(false);
165
+ const [hasLayoutChanges, setHasLayoutChanges] = useState(false);
166
+ const [isSavingLayout, setIsSavingLayout] = useState(false);
167
+ const initialPositionsRef = useRef<Record<string, { x: number; y: number }>>({});
156
168
  // const [isStudioModalOpen, setIsStudioModalOpen] = useState(false);
169
+ const [focusModeOpen, setFocusModeOpen] = useState(false);
170
+ const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
157
171
 
158
172
  // Check if there are channels to determine if we need the visualizer functionality
159
173
  const hasChannels = useMemo(() => initialNodes.some((node: any) => node.type === 'channels'), [initialNodes]);
@@ -169,6 +183,49 @@ const NodeGraphBuilder = ({
169
183
  const reactFlowWrapperRef = useRef<HTMLDivElement>(null);
170
184
  const scrollableContainerRef = useRef<HTMLElement | null>(null);
171
185
 
186
+ // Store initial node positions for change detection (dev mode only)
187
+ useEffect(() => {
188
+ if (isDevMode && initialNodes.length > 0) {
189
+ const positions: Record<string, { x: number; y: number }> = {};
190
+ initialNodes.forEach((node: Node) => {
191
+ positions[node.id] = { x: node.position.x, y: node.position.y };
192
+ });
193
+ initialPositionsRef.current = positions;
194
+ }
195
+ }, [isDevMode, initialNodes]);
196
+
197
+ // Detect layout changes by comparing current positions to initial positions
198
+ const checkForLayoutChanges = useCallback(() => {
199
+ if (!isDevMode) return;
200
+ const initial = initialPositionsRef.current;
201
+ if (Object.keys(initial).length === 0) return;
202
+
203
+ const hasChanges = nodes.some((node) => {
204
+ const initialPos = initial[node.id];
205
+ return (
206
+ initialPos &&
207
+ (Math.abs(node.position.x - initialPos.x) > POSITION_CHANGE_THRESHOLD ||
208
+ Math.abs(node.position.y - initialPos.y) > POSITION_CHANGE_THRESHOLD)
209
+ );
210
+ });
211
+
212
+ setHasLayoutChanges(hasChanges);
213
+ }, [isDevMode, nodes]);
214
+
215
+ // Wrap onNodesChange to detect layout changes after node drag
216
+ const handleNodesChange = useCallback(
217
+ (changes: NodeChange[]) => {
218
+ onNodesChange(changes);
219
+ // Check for position changes after drag ends
220
+ const hasDragEnd = changes.some((change) => change.type === 'position' && !change.dragging);
221
+ if (hasDragEnd) {
222
+ // Use setTimeout to ensure state is updated
223
+ setTimeout(checkForLayoutChanges, 0);
224
+ }
225
+ },
226
+ [onNodesChange, checkForLayoutChanges]
227
+ );
228
+
172
229
  const resetNodesAndEdges = useCallback(() => {
173
230
  setNodes((nds) =>
174
231
  nds.map((node) => {
@@ -197,50 +254,16 @@ const NodeGraphBuilder = ({
197
254
  return;
198
255
  }
199
256
 
200
- resetNodesAndEdges();
201
-
202
- const connectedNodeIds = new Set<string>();
203
- connectedNodeIds.add(node.id);
204
-
205
- const updatedEdges = edges.map((edge) => {
206
- if (edge.source === node.id || edge.target === node.id) {
207
- connectedNodeIds.add(edge.source);
208
- connectedNodeIds.add(edge.target);
209
- return {
210
- ...edge,
211
- data: { ...edge.data, opacity: 1, animated: animateMessages },
212
- style: { ...edge.style, opacity: 1 },
213
- labelStyle: { ...edge.labelStyle, opacity: 1 },
214
- animated: true,
215
- };
216
- }
217
- return {
218
- ...edge,
219
- data: { ...edge.data, opacity: 0.1, animated: animateMessages },
220
- style: { ...edge.style, opacity: 0.1 },
221
- labelStyle: { ...edge.labelStyle, opacity: 0.1 },
222
- animated: animateMessages,
223
- };
224
- });
225
-
226
- const updatedNodes = nodes.map((n) => {
227
- if (connectedNodeIds.has(n.id)) {
228
- return { ...n, style: { ...n.style, opacity: 1 } };
229
- }
230
- return { ...n, style: { ...n.style, opacity: 0.1 } };
231
- });
232
-
233
- setNodes(updatedNodes);
234
- setEdges(updatedEdges);
257
+ // Disable focus mode for flow and entity visualizations
258
+ const isFlow = edges.some((edge: Edge) => edge.type === 'flow-edge');
259
+ const isEntityVisualizer = nodes.some((n: Node) => n.type === 'entities');
260
+ if (isFlow || isEntityVisualizer) return;
235
261
 
236
- // Fit the clicked node and its connected nodes into view
237
- fitView({
238
- padding: 0.2,
239
- duration: 800,
240
- nodes: updatedNodes.filter((n) => connectedNodeIds.has(n.id)),
241
- });
262
+ // Open focus mode modal
263
+ setFocusedNodeId(node.id);
264
+ setFocusModeOpen(true);
242
265
  },
243
- [nodes, edges, setNodes, setEdges, resetNodesAndEdges, fitView]
266
+ [linksToVisualiser, edges, nodes]
244
267
  );
245
268
 
246
269
  const toggleAnimateMessages = () => {
@@ -397,6 +420,63 @@ const NodeGraphBuilder = ({
397
420
  window.dispatchEvent(new CustomEvent('eventcatalog:open-chat'));
398
421
  }, []);
399
422
 
423
+ // Layout persistence handlers (dev mode only)
424
+ const handleSaveLayout = useCallback(async (): Promise<boolean> => {
425
+ if (!resourceKey) return false;
426
+
427
+ const positions: Record<string, { x: number; y: number }> = {};
428
+ nodes.forEach((node) => {
429
+ positions[node.id] = {
430
+ x: node.position.x,
431
+ y: node.position.y,
432
+ };
433
+ });
434
+
435
+ try {
436
+ const response = await fetch('/api/dev/visualizer-layout/save', {
437
+ method: 'POST',
438
+ headers: { 'Content-Type': 'application/json' },
439
+ body: JSON.stringify({ resourceKey, positions }),
440
+ });
441
+ const result = await response.json();
442
+ return result.success === true;
443
+ } catch {
444
+ return false;
445
+ }
446
+ }, [nodes, resourceKey]);
447
+
448
+ const handleResetLayout = useCallback(async (): Promise<boolean> => {
449
+ if (!resourceKey) return false;
450
+
451
+ try {
452
+ const response = await fetch('/api/dev/visualizer-layout/reset', {
453
+ method: 'POST',
454
+ headers: { 'Content-Type': 'application/json' },
455
+ body: JSON.stringify({ resourceKey }),
456
+ });
457
+ const result = await response.json();
458
+ return result.success === true;
459
+ } catch {
460
+ return false;
461
+ }
462
+ }, [resourceKey]);
463
+
464
+ // Quick save handler for the change detection UI
465
+ const handleQuickSaveLayout = useCallback(async () => {
466
+ setIsSavingLayout(true);
467
+ const success = await handleSaveLayout();
468
+ setIsSavingLayout(false);
469
+ if (success) {
470
+ // Update initial positions to current positions after save
471
+ const positions: Record<string, { x: number; y: number }> = {};
472
+ nodes.forEach((node) => {
473
+ positions[node.id] = { x: node.position.x, y: node.position.y };
474
+ });
475
+ initialPositionsRef.current = positions;
476
+ setHasLayoutChanges(false);
477
+ }
478
+ }, [handleSaveLayout, nodes]);
479
+
400
480
  const handleCopyArchitectureCode = useCallback(async () => {
401
481
  await copyToClipboard(mermaidCode);
402
482
  }, [mermaidCode]);
@@ -692,6 +772,9 @@ const NodeGraphBuilder = ({
692
772
  setIsShareModalOpen={setIsShareModalOpen}
693
773
  toggleFullScreen={toggleFullScreen}
694
774
  openStudioModal={openStudioModal}
775
+ isDevMode={isDevMode}
776
+ onSaveLayout={handleSaveLayout}
777
+ onResetLayout={handleResetLayout}
695
778
  />
696
779
  </DropdownMenu.Content>
697
780
  </DropdownMenu.Portal>
@@ -720,7 +803,7 @@ const NodeGraphBuilder = ({
720
803
  nodes={nodes}
721
804
  edges={edges}
722
805
  fitView
723
- onNodesChange={onNodesChange}
806
+ onNodesChange={handleNodesChange}
724
807
  onEdgesChange={onEdgesChange}
725
808
  connectionLineType={ConnectionLineType.SmoothStep}
726
809
  nodeOrigin={[0.1, 0.1]}
@@ -772,6 +855,9 @@ const NodeGraphBuilder = ({
772
855
  setIsShareModalOpen={setIsShareModalOpen}
773
856
  toggleFullScreen={toggleFullScreen}
774
857
  openStudioModal={openStudioModal}
858
+ isDevMode={isDevMode}
859
+ onSaveLayout={handleSaveLayout}
860
+ onResetLayout={handleResetLayout}
775
861
  />
776
862
  </DropdownMenu.Content>
777
863
  </DropdownMenu.Portal>
@@ -845,6 +931,28 @@ const NodeGraphBuilder = ({
845
931
  />
846
932
  </Panel>
847
933
  )}
934
+ {/* Dev Mode: Layout change indicator */}
935
+ {isDevMode && hasLayoutChanges && (
936
+ <Panel
937
+ position="bottom-left"
938
+ style={
939
+ isFlowVisualization && showFlowWalkthrough
940
+ ? { marginBottom: '20px', marginLeft: '410px' }
941
+ : { marginLeft: '60px' }
942
+ }
943
+ >
944
+ <div className="bg-[rgb(var(--ec-card-bg))] border border-[rgb(var(--ec-page-border))] rounded-lg shadow-md px-3 py-2 flex items-center gap-3">
945
+ <span className="text-xs text-[rgb(var(--ec-page-text-muted))]">Layout changed</span>
946
+ <button
947
+ onClick={handleQuickSaveLayout}
948
+ disabled={isSavingLayout}
949
+ className="text-xs font-medium text-[rgb(var(--ec-accent-text))] bg-[rgb(var(--ec-accent-subtle))] hover:bg-[rgb(var(--ec-accent-subtle)/0.7)] px-2 py-1 rounded transition-colors disabled:opacity-50"
950
+ >
951
+ {isSavingLayout ? 'Saving...' : 'Save'}
952
+ </button>
953
+ </div>
954
+ </Panel>
955
+ )}
848
956
  {includeKey && (
849
957
  <Panel position="bottom-right" style={showMinimap ? { marginRight: '230px' } : undefined}>
850
958
  <div className=" bg-white font-light px-4 text-[12px] shadow-md py-1 rounded-md">
@@ -868,6 +976,15 @@ const NodeGraphBuilder = ({
868
976
  </ReactFlow>
869
977
  )}
870
978
  <StudioModal isOpen={isStudioModalOpen || false} onClose={() => setIsStudioModalOpen(false)} />
979
+ <FocusModeModal
980
+ isOpen={focusModeOpen}
981
+ onClose={() => setFocusModeOpen(false)}
982
+ initialNodeId={focusedNodeId}
983
+ nodes={nodes}
984
+ edges={edges}
985
+ nodeTypes={nodeTypes}
986
+ edgeTypes={edgeTypes}
987
+ />
871
988
 
872
989
  {/* Share Link Modal */}
873
990
  {isShareModalOpen && (
@@ -952,6 +1069,8 @@ interface NodeGraphProps {
952
1069
  designId?: string;
953
1070
  isChatEnabled?: boolean;
954
1071
  maxTextSize?: number;
1072
+ isDevMode?: boolean;
1073
+ resourceKey?: string;
955
1074
  }
956
1075
 
957
1076
  const NodeGraph = ({
@@ -974,6 +1093,8 @@ const NodeGraph = ({
974
1093
  designId,
975
1094
  isChatEnabled = false,
976
1095
  maxTextSize,
1096
+ isDevMode = false,
1097
+ resourceKey,
977
1098
  }: NodeGraphProps) => {
978
1099
  const [elem, setElem] = useState(null);
979
1100
  const [showFooter, setShowFooter] = useState(true);
@@ -1021,6 +1142,8 @@ const NodeGraph = ({
1021
1142
  setIsStudioModalOpen={setIsStudioModalOpen}
1022
1143
  isChatEnabled={isChatEnabled}
1023
1144
  maxTextSize={maxTextSize}
1145
+ isDevMode={isDevMode}
1146
+ resourceKey={resourceKey}
1024
1147
  />
1025
1148
 
1026
1149
  {showFooter && (
@@ -137,7 +137,10 @@ export default function EntityNode({ data, sourcePosition, targetPosition }: any
137
137
  </div>
138
138
  </ContextMenu.Trigger>
139
139
  <ContextMenu.Portal>
140
- <ContextMenu.Content className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200">
140
+ <ContextMenu.Content
141
+ className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200"
142
+ onClick={(e) => e.stopPropagation()}
143
+ >
141
144
  <ContextMenu.Item
142
145
  asChild
143
146
  className="text-sm px-2 py-1.5 outline-none cursor-pointer hover:bg-orange-100 rounded-sm flex items-center"
@@ -22,7 +22,10 @@ export default function MessageContextMenu(data: Data) {
22
22
  <ContextMenu.Root>
23
23
  <ContextMenu.Trigger>{children}</ContextMenu.Trigger>
24
24
  <ContextMenu.Portal>
25
- <ContextMenu.Content className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200">
25
+ <ContextMenu.Content
26
+ className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200"
27
+ onClick={(e) => e.stopPropagation()}
28
+ >
26
29
  <ContextMenu.Item
27
30
  asChild
28
31
  className="text-sm px-2 py-1.5 outline-none cursor-pointer hover:bg-orange-100 rounded-sm flex items-center"
@@ -57,7 +57,10 @@ export default function ServiceNode(props: ServiceNode) {
57
57
  </div>
58
58
  </ContextMenu.Trigger>
59
59
  <ContextMenu.Portal>
60
- <ContextMenu.Content className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200">
60
+ <ContextMenu.Content
61
+ className="min-w-[220px] bg-white rounded-md p-1 shadow-md border border-gray-200"
62
+ onClick={(e) => e.stopPropagation()}
63
+ >
61
64
  <ContextMenu.Item
62
65
  asChild
63
66
  className="text-sm px-2 py-1.5 outline-none cursor-pointer hover:bg-orange-100 rounded-sm flex items-center"
@@ -1,6 +1,20 @@
1
- import React, { type RefObject } from 'react';
1
+ import React, { type RefObject, useState } from 'react';
2
2
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
3
- import { Code, Share2, Search, Grid3x3, Maximize2, Map, Sparkles, Zap, EyeOff, ExternalLink } from 'lucide-react';
3
+ import {
4
+ Code,
5
+ Share2,
6
+ Search,
7
+ Grid3x3,
8
+ Maximize2,
9
+ Map,
10
+ Sparkles,
11
+ Zap,
12
+ EyeOff,
13
+ ExternalLink,
14
+ Save,
15
+ RotateCcw,
16
+ Loader2,
17
+ } from 'lucide-react';
4
18
  import { DocumentArrowDownIcon, PresentationChartLineIcon } from '@heroicons/react/24/outline';
5
19
  import type { VisualiserSearchRef } from './VisualiserSearch';
6
20
 
@@ -23,6 +37,9 @@ interface VisualizerDropdownContentProps {
23
37
  setIsShareModalOpen: (value: boolean) => void;
24
38
  toggleFullScreen: () => void;
25
39
  openStudioModal: () => void;
40
+ isDevMode?: boolean;
41
+ onSaveLayout?: () => Promise<boolean>;
42
+ onResetLayout?: () => Promise<boolean>;
26
43
  }
27
44
 
28
45
  const VisualizerDropdownContent: React.FC<VisualizerDropdownContentProps> = ({
@@ -44,7 +61,33 @@ const VisualizerDropdownContent: React.FC<VisualizerDropdownContentProps> = ({
44
61
  setIsShareModalOpen,
45
62
  toggleFullScreen,
46
63
  openStudioModal,
64
+ isDevMode = false,
65
+ onSaveLayout,
66
+ onResetLayout,
47
67
  }) => {
68
+ const [layoutStatus, setLayoutStatus] = useState<'idle' | 'saving' | 'resetting'>('idle');
69
+
70
+ const handleSaveLayout = async () => {
71
+ if (!onSaveLayout) return;
72
+ setLayoutStatus('saving');
73
+ await onSaveLayout();
74
+ setLayoutStatus('idle');
75
+ };
76
+
77
+ const handleResetLayout = async () => {
78
+ if (!onResetLayout) return;
79
+ if (!window.confirm('Reset layout to auto-positioning? This will delete your saved layout.')) {
80
+ return;
81
+ }
82
+ setLayoutStatus('resetting');
83
+ const success = await onResetLayout();
84
+ if (success) {
85
+ window.location.reload();
86
+ } else {
87
+ setLayoutStatus('idle');
88
+ }
89
+ };
90
+
48
91
  return (
49
92
  <>
50
93
  {/* Canvas Settings Submenu */}
@@ -157,6 +200,52 @@ const VisualizerDropdownContent: React.FC<VisualizerDropdownContentProps> = ({
157
200
  </DropdownMenu.Portal>
158
201
  </DropdownMenu.Sub>
159
202
 
203
+ {/* Dev Mode: Layout Submenu */}
204
+ {isDevMode && onSaveLayout && (
205
+ <DropdownMenu.Sub>
206
+ <DropdownMenu.SubTrigger className="flex items-center px-3 py-2 text-xs text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-accent-subtle)/0.3)] cursor-pointer transition-colors gap-2 outline-none">
207
+ <Save className="w-3.5 h-3.5 text-[rgb(var(--ec-page-text-muted))] flex-shrink-0" />
208
+ <span className="flex-1 font-normal">Layout</span>
209
+ <span className="text-[10px] text-amber-600 font-medium">DEV</span>
210
+ <svg className="w-3 h-3 text-[rgb(var(--ec-page-text-muted))]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
211
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
212
+ </svg>
213
+ </DropdownMenu.SubTrigger>
214
+ <DropdownMenu.Portal>
215
+ <DropdownMenu.SubContent
216
+ className="min-w-[180px] bg-[rgb(var(--ec-card-bg))] rounded-lg shadow-xl border border-[rgb(var(--ec-page-border))] py-1.5 z-[60]"
217
+ sideOffset={8}
218
+ alignOffset={-8}
219
+ >
220
+ <DropdownMenu.Item
221
+ onClick={handleSaveLayout}
222
+ disabled={layoutStatus !== 'idle'}
223
+ className="px-3 py-2 text-xs text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-accent-subtle)/0.3)] cursor-pointer flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
224
+ >
225
+ {layoutStatus === 'saving' ? (
226
+ <Loader2 className="w-3.5 h-3.5 text-[rgb(var(--ec-page-text-muted))] flex-shrink-0 animate-spin" />
227
+ ) : (
228
+ <Save className="w-3.5 h-3.5 text-[rgb(var(--ec-page-text-muted))] flex-shrink-0" />
229
+ )}
230
+ <span className="flex-1 font-normal">{layoutStatus === 'saving' ? 'Saving...' : 'Save Layout'}</span>
231
+ </DropdownMenu.Item>
232
+ <DropdownMenu.Item
233
+ onClick={handleResetLayout}
234
+ disabled={layoutStatus !== 'idle'}
235
+ className="px-3 py-2 text-xs text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-accent-subtle)/0.3)] cursor-pointer flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
236
+ >
237
+ {layoutStatus === 'resetting' ? (
238
+ <Loader2 className="w-3.5 h-3.5 text-[rgb(var(--ec-page-text-muted))] flex-shrink-0 animate-spin" />
239
+ ) : (
240
+ <RotateCcw className="w-3.5 h-3.5 text-[rgb(var(--ec-page-text-muted))] flex-shrink-0" />
241
+ )}
242
+ <span className="flex-1 font-normal">{layoutStatus === 'resetting' ? 'Resetting...' : 'Reset Layout'}</span>
243
+ </DropdownMenu.Item>
244
+ </DropdownMenu.SubContent>
245
+ </DropdownMenu.Portal>
246
+ </DropdownMenu.Sub>
247
+ )}
248
+
160
249
  {/* Ask AI */}
161
250
  {isChatEnabled && (
162
251
  <>
@@ -0,0 +1,45 @@
1
+ import type { APIRoute } from 'astro';
2
+ import fs from 'node:fs';
3
+ import { getLayoutFilePath } from '@utils/node-graphs/layout-persistence';
4
+
5
+ export const POST: APIRoute = async ({ request }) => {
6
+ try {
7
+ const body = await request.json();
8
+ const { resourceKey } = body;
9
+
10
+ // Validate input
11
+ if (!resourceKey) {
12
+ return new Response(JSON.stringify({ error: 'Missing required field: resourceKey' }), {
13
+ status: 400,
14
+ headers: { 'Content-Type': 'application/json' },
15
+ });
16
+ }
17
+
18
+ const filePath = getLayoutFilePath(resourceKey);
19
+
20
+ // Check if file exists
21
+ try {
22
+ await fs.promises.access(filePath);
23
+ } catch {
24
+ // File doesn't exist, nothing to delete
25
+ return new Response(JSON.stringify({ success: true, message: 'No saved layout to reset' }), {
26
+ headers: { 'Content-Type': 'application/json' },
27
+ });
28
+ }
29
+
30
+ // Delete the layout file
31
+ await fs.promises.unlink(filePath);
32
+
33
+ return new Response(JSON.stringify({ success: true, message: 'Layout reset successfully' }), {
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ } catch (error) {
37
+ const message = error instanceof Error ? error.message : 'Unknown error';
38
+ return new Response(JSON.stringify({ error: `Failed to reset layout: ${message}` }), {
39
+ status: 500,
40
+ headers: { 'Content-Type': 'application/json' },
41
+ });
42
+ }
43
+ };
44
+
45
+ export const prerender = false;
@@ -0,0 +1,57 @@
1
+ import type { APIRoute } from 'astro';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getLayoutFilePath, type SavedLayout } from '@utils/node-graphs/layout-persistence';
5
+
6
+ export const POST: APIRoute = async ({ request }) => {
7
+ try {
8
+ const body = await request.json();
9
+ const { resourceKey, positions } = body;
10
+
11
+ // Validate input
12
+ if (!resourceKey || !positions) {
13
+ return new Response(JSON.stringify({ error: 'Missing required fields: resourceKey and positions' }), {
14
+ status: 400,
15
+ headers: { 'Content-Type': 'application/json' },
16
+ });
17
+ }
18
+
19
+ // Validate resourceKey doesn't contain path traversal
20
+ if (resourceKey.includes('..') || resourceKey.includes('~')) {
21
+ return new Response(JSON.stringify({ error: 'Invalid resourceKey' }), {
22
+ status: 400,
23
+ headers: { 'Content-Type': 'application/json' },
24
+ });
25
+ }
26
+
27
+ // Build file path from resource key using shared utility
28
+ const filePath = getLayoutFilePath(resourceKey);
29
+
30
+ // Create directory structure if needed
31
+ const fileDir = path.dirname(filePath);
32
+ await fs.promises.mkdir(fileDir, { recursive: true });
33
+
34
+ // Build layout data
35
+ const layoutData: SavedLayout = {
36
+ version: 1,
37
+ savedAt: new Date().toISOString(),
38
+ resourceKey,
39
+ positions,
40
+ };
41
+
42
+ // Write file with pretty formatting
43
+ await fs.promises.writeFile(filePath, JSON.stringify(layoutData, null, 2));
44
+
45
+ return new Response(JSON.stringify({ success: true, filePath }), {
46
+ headers: { 'Content-Type': 'application/json' },
47
+ });
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : 'Unknown error';
50
+ return new Response(JSON.stringify({ error: `Failed to save layout: ${message}` }), {
51
+ status: 500,
52
+ headers: { 'Content-Type': 'application/json' },
53
+ });
54
+ }
55
+ };
56
+
57
+ export const prerender = false;
@@ -4,7 +4,10 @@ import { showEventCatalogBranding } from '@utils/feature';
4
4
  const { className } = Astro.props;
5
5
  ---
6
6
 
7
- <footer class={`relative py-4 space-y-8 border-t border-[rgb(var(--ec-page-border))] ${className}`}>
7
+ <footer
8
+ transition:persist="site-footer"
9
+ class={`relative py-4 space-y-8 border-t border-[rgb(var(--ec-page-border))] ${className}`}
10
+ >
8
11
  {
9
12
  showEventCatalogBranding() && (
10
13
  <div class="flex justify-between items-center py-8 text-[rgb(var(--ec-page-text-muted))] text-sm font-light">
@@ -277,7 +277,9 @@ const canPageBeEmbedded = isEmbedEnabled();
277
277
  {/* Load search data even when sidebar is hidden */}
278
278
  <SearchDataLoader />
279
279
  <main id="eventcatalog-application" class="relative">
280
- <Header />
280
+ <div transition:persist="site-header">
281
+ <Header />
282
+ </div>
281
283
  <div class="flex">
282
284
  <aside class="flex" id="eventcatalog-vertical-nav">
283
285
  <div
@@ -71,3 +71,5 @@ export const isCustomStylesEnabled = () => {
71
71
  export const isDiagramComparisonEnabled = () => isEventCatalogScaleEnabled();
72
72
 
73
73
  export const isEventCatalogMCPEnabled = () => isEventCatalogScaleEnabled() && isSSR();
74
+
75
+ export const isDevMode = () => process.env.EVENTCATALOG_DEV_MODE === 'true';