@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.
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-K7EPHS7S.js → chunk-G7GG3HEB.js} +1 -1
- package/dist/{chunk-DGE5ITBR.js → chunk-K44BXVHU.js} +1 -1
- package/dist/{chunk-IJKRHWWO.js → chunk-LXOS3MXQ.js} +1 -1
- package/dist/{chunk-QWSUFHCT.js → chunk-VUBZ6A7B.js} +1 -1
- package/dist/{chunk-AL67CV2N.js → chunk-WVKLG26T.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +2 -1
- package/dist/eventcatalog.js +6 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/integrations/eventcatalog-features.ts +13 -0
- package/eventcatalog/src/components/MDX/Design/Design.astro +10 -2
- package/eventcatalog/src/components/MDX/EntityMap/EntityMap.astro +10 -2
- package/eventcatalog/src/components/MDX/Flow/Flow.astro +10 -2
- package/eventcatalog/src/components/MDX/NodeGraph/Edges/AnimatedMessageEdge.tsx +13 -0
- package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModeContent.tsx +294 -0
- package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModeNodeActions.tsx +92 -0
- package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/FocusModePlaceholder.tsx +26 -0
- package/eventcatalog/src/components/MDX/NodeGraph/FocusMode/utils.ts +163 -0
- package/eventcatalog/src/components/MDX/NodeGraph/FocusModeModal.tsx +99 -0
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +10 -2
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.tsx +166 -43
- package/eventcatalog/src/components/MDX/NodeGraph/Nodes/Entity.tsx +4 -1
- package/eventcatalog/src/components/MDX/NodeGraph/Nodes/MessageContextMenu.tsx +4 -1
- package/eventcatalog/src/components/MDX/NodeGraph/Nodes/Service.tsx +4 -1
- package/eventcatalog/src/components/MDX/NodeGraph/VisualizerDropdownContent.tsx +91 -2
- package/eventcatalog/src/enterprise/visualizer-layout/reset.ts +45 -0
- package/eventcatalog/src/enterprise/visualizer-layout/save.ts +57 -0
- package/eventcatalog/src/layouts/Footer.astro +4 -1
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +3 -1
- package/eventcatalog/src/utils/feature.ts +2 -0
- package/eventcatalog/src/utils/node-graphs/layout-persistence.ts +81 -0
- package/eventcatalog/tailwind.config.mjs +10 -0
- 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
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
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
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
[
|
|
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={
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
<
|
|
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';
|