@eventcatalog/core 5.0.0-beta.7 → 5.0.0-beta.8

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 (85) 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 +3 -1
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/{chunk-BOKAG5XC.js → chunk-4W55ZOOS.js} +2 -2
  6. package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
  7. package/dist/{chunk-3VBB2ZMP.js → chunk-C6T2AAFR.js} +1 -1
  8. package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
  9. package/dist/{chunk-S7ODHVII.js → chunk-PU25TOOJ.js} +1 -1
  10. package/dist/{chunk-FARBIZ7Y.js → chunk-RDIZJ7UT.js} +1 -1
  11. package/dist/{chunk-SJJKWZYZ.js → chunk-T4G3VVZ4.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/eventcatalog.cjs +138 -90
  15. package/dist/eventcatalog.config.d.cts +47 -0
  16. package/dist/eventcatalog.config.d.ts +47 -0
  17. package/dist/eventcatalog.js +30 -11
  18. package/dist/features.cjs +8 -2
  19. package/dist/features.d.cts +3 -1
  20. package/dist/features.d.ts +3 -1
  21. package/dist/features.js +5 -3
  22. package/dist/generate.cjs +1 -1
  23. package/dist/generate.js +3 -3
  24. package/dist/utils/cli-logger.cjs +1 -1
  25. package/dist/utils/cli-logger.js +2 -2
  26. package/dist/utils/license-status.cjs +70 -44
  27. package/dist/utils/license-status.d.cts +14 -5
  28. package/dist/utils/license-status.d.ts +14 -5
  29. package/dist/utils/license-status.js +1 -1
  30. package/eventcatalog/integrations/studio-server.mjs +15 -3
  31. package/eventcatalog/src/components/Header.astro +3 -3
  32. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
  33. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
  34. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
  35. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
  36. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  37. package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
  38. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
  39. package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
  40. package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
  41. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  42. package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
  43. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  44. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
  45. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
  46. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  47. package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
  48. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
  49. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  50. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
  51. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  52. package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
  53. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
  54. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  55. package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
  56. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
  57. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  58. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
  59. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
  60. package/eventcatalog/src/features/studio/components/icons.ts +6 -1
  61. package/eventcatalog/src/features/studio/components/status.ts +30 -1
  62. package/eventcatalog/src/features/studio/edges.ts +2 -0
  63. package/eventcatalog/src/features/studio/grouping.ts +95 -2
  64. package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
  65. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
  66. package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
  67. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
  68. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
  69. package/eventcatalog/src/features/studio/identity.ts +35 -0
  70. package/eventcatalog/src/features/studio/layout.ts +7 -4
  71. package/eventcatalog/src/features/studio/levels.ts +138 -72
  72. package/eventcatalog/src/features/studio/node-types.ts +35 -3
  73. package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
  74. package/eventcatalog/src/features/studio/pages/index.astro +555 -2
  75. package/eventcatalog/src/features/studio/pages/new.astro +24 -0
  76. package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
  77. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
  78. package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
  79. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  80. package/eventcatalog/src/features/studio/server/storage.ts +110 -0
  81. package/eventcatalog/src/features/studio/studio.css +184 -0
  82. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
  83. package/eventcatalog/src/utils/feature.ts +7 -3
  84. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  85. package/package.json +4 -4
@@ -1,5 +1,5 @@
1
- import { useEffect, useState, type ReactNode } from 'react';
2
- import { Bot, Check, Copy, X } from 'lucide-react';
1
+ import { useEffect, useState } from 'react';
2
+ import { Bot, Check, ChevronDown, Copy, X } from 'lucide-react';
3
3
  import type { WebMcpStatus } from '../hooks/use-canvas-webmcp';
4
4
  import { STATUS } from './status';
5
5
 
@@ -71,12 +71,12 @@ Connect with the first of these you can use:
71
71
  3. WebMCP local relay: if you run on my computer (e.g. Claude Code, Cursor) and can't reach the MCP server, add the MCP server \`${RELAY_COMMAND}\`. It gives you the tools of my open canvas tab.
72
72
 
73
73
  Once connected:
74
- - Read the canvas first (getCanvas / get_canvas) and tell me who else is on it.
75
- - Ask me what we are designing, then work on the canvas as we talk: add catalog resources (find them with getResources / search_catalog), add new components for things that don't exist yet, and connect them. Leave out positions so new nodes go next to what they connect to; only re-lay the whole canvas (layoutCanvas / layout_canvas) when I ask.
76
- - Leave questions and suggestions as comments on the canvas, and reply to comments people leave for you.
74
+ - Read the canvas first (getCanvas / get_canvas) and tell me here who else is on it.
75
+ - Ask me here, in our conversation, what we are designing, then work on the canvas as we talk: add catalog resources (find them with getResources / search_catalog), add new components for things that don't exist yet, and connect them. Leave out positions so new nodes go next to what they connect to; only re-lay the whole canvas (layoutCanvas / layout_canvas) when I ask.
76
+ - Talk to me here, not in canvas comments. Only comment on the canvas when I ask you to (e.g. to review it), and reply to comments people leave for you.
77
77
  - Pass agentName with your name on MCP tools, so people on the canvas can see who you are.`;
78
78
 
79
- function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) {
79
+ function CopyButton({ text, label = 'Copy', large = false }: { text: string; label?: string; large?: boolean }) {
80
80
  const [copied, setCopied] = useState(false);
81
81
  return (
82
82
  <button
@@ -85,31 +85,56 @@ function CopyButton({ text, label = 'Copy' }: { text: string; label?: string })
85
85
  setCopied(true);
86
86
  setTimeout(() => setCopied(false), 1500);
87
87
  }}
88
- className="flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))]"
88
+ className={`flex shrink-0 items-center justify-center gap-1.5 rounded-lg font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))] ${
89
+ large ? 'w-full px-4 py-2.5 text-sm' : 'px-2.5 py-1.5 text-xs'
90
+ }`}
89
91
  >
90
- {copied ? <Check size={14} /> : <Copy size={14} />}
92
+ {copied ? <Check size={large ? 16 : 14} /> : <Copy size={large ? 16 : 14} />}
91
93
  {copied ? 'Copied' : label}
92
94
  </button>
93
95
  );
94
96
  }
95
97
 
96
- function Detail({ title, value, children }: { title: string; value?: string; children: ReactNode }) {
98
+ /** Something to copy (an address or a command), with its copy button */
99
+ function CopyField({ value }: { value: string }) {
97
100
  return (
98
- <div className="space-y-1.5">
99
- <h3 className="text-xs font-semibold">{title}</h3>
100
- <p className="text-xs text-[rgb(var(--ec-page-text-muted))]">{children}</p>
101
- {value && (
102
- <div className="flex items-center gap-2">
103
- <code className="min-w-0 flex-1 truncate rounded-md border px-2 py-1.5 text-xs bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))]">
104
- {value}
105
- </code>
106
- <CopyButton text={value} />
107
- </div>
108
- )}
101
+ <div className="flex items-center gap-2">
102
+ <code className="min-w-0 flex-1 truncate rounded-md border px-2 py-1.5 text-xs bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))]">
103
+ {value}
104
+ </code>
105
+ <CopyButton text={value} />
109
106
  </div>
110
107
  );
111
108
  }
112
109
 
110
+ /** Turns something on for this tab, or says it's on */
111
+ function TabSwitch({ on, onTurnOn, label }: { on: boolean; onTurnOn: () => void; label: string }) {
112
+ return on ? (
113
+ <p className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap text-xs font-medium ${STATUS.success.text}`}>
114
+ <span className={`h-2 w-2 rounded-full ${STATUS.success.dot}`} />
115
+ On for this tab
116
+ </p>
117
+ ) : (
118
+ <button
119
+ onClick={onTurnOn}
120
+ className="shrink-0 whitespace-nowrap rounded-lg border px-2.5 py-1.5 text-xs font-medium border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)]"
121
+ >
122
+ {label}
123
+ </button>
124
+ );
125
+ }
126
+
127
+ type Way = 'chat' | 'code' | 'browser';
128
+ const WAYS: { id: Way; label: string; hint: string }[] = [
129
+ { id: 'chat', label: 'Chat app', hint: 'Claude, ChatGPT' },
130
+ { id: 'code', label: 'Coding agent', hint: 'Claude Code, Cursor, Codex' },
131
+ { id: 'browser', label: 'This browser', hint: 'WebMCP' },
132
+ ];
133
+
134
+ /**
135
+ * Bringing an AI agent onto the canvas: copy the prompt and give it to the agent (it works out how to connect).
136
+ * The prompt is there to read, and how each kind of agent connects is one step each, for setting it up by hand.
137
+ */
113
138
  export default function ConnectAgentDialog({
114
139
  link,
115
140
  webMcp,
@@ -127,6 +152,8 @@ export default function ConnectAgentDialog({
127
152
  }) {
128
153
  const prompt = buildAgentPrompt(link);
129
154
  const [relayOn, setRelayOn] = useState(isRelayEnabled);
155
+ const [showPrompt, setShowPrompt] = useState(false);
156
+ const [way, setWay] = useState<Way>('chat');
130
157
 
131
158
  useEffect(() => {
132
159
  const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && onClose();
@@ -134,82 +161,119 @@ export default function ConnectAgentDialog({
134
161
  return () => window.removeEventListener('keydown', onKeyDown);
135
162
  }, [onClose]);
136
163
 
164
+ const turnOnRelay = () => {
165
+ if (!relayScriptPath) return;
166
+ enableRelay(relayScriptPath);
167
+ setRelayOn(true);
168
+ // The relay hands this tab's WebMCP tools to desktop agents
169
+ onEnableWebMcp();
170
+ };
171
+
137
172
  return (
138
173
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
139
174
  <div
140
175
  role="dialog"
141
176
  aria-label="Connect your agent"
142
177
  onClick={(event) => event.stopPropagation()}
143
- className="flex max-h-full w-full max-w-2xl flex-col rounded-xl border shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
178
+ className="flex max-h-full w-full max-w-md flex-col rounded-xl border shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
144
179
  >
145
- <div className="flex items-start justify-between gap-4 border-b p-5 border-[rgb(var(--ec-page-border))]">
146
- <div className="space-y-1">
147
- <h2 className="flex items-center gap-2 text-base font-semibold">
180
+ <header className="flex items-start justify-between gap-3 px-5 pb-3 pt-5">
181
+ <div className="flex items-center gap-3">
182
+ <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))]">
148
183
  <Bot size={18} />
149
- Connect your agent
150
- </h2>
151
- <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
152
- Give this prompt to your agent (Claude, ChatGPT, Codex, Cursor...). It joins this canvas and works on it with you,
153
- live, using whatever it can connect with.
154
- </p>
184
+ </span>
185
+ <div>
186
+ <h2 className="text-base font-semibold">Bring in an AI agent</h2>
187
+ <p className="text-xs text-[rgb(var(--ec-page-text-muted))]">It joins this canvas and designs with you, live.</p>
188
+ </div>
155
189
  </div>
156
190
  <button
157
191
  onClick={onClose}
158
192
  aria-label="Close"
159
- className="rounded p-1 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
193
+ className="rounded-md p-1 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
160
194
  >
161
195
  <X size={16} />
162
196
  </button>
163
- </div>
197
+ </header>
164
198
 
165
- <div className="min-h-0 space-y-5 overflow-y-auto p-5">
166
- <div className="space-y-2">
167
- <div className="flex items-center justify-between">
168
- <h3 className="text-xs font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">Prompt</h3>
169
- <CopyButton text={prompt} label="Copy prompt" />
170
- </div>
171
- <pre className="max-h-64 overflow-y-auto whitespace-pre-wrap rounded-lg border p-3 font-mono text-xs leading-relaxed bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))]">
172
- {prompt}
173
- </pre>
174
- </div>
199
+ <div className="min-h-0 space-y-5 overflow-y-auto px-5 pb-5">
200
+ {/* The one thing most people need: the prompt */}
201
+ <section className="space-y-2">
202
+ <CopyButton text={prompt} label="Copy prompt" large />
203
+ <p className="text-center text-xs text-[rgb(var(--ec-page-text-muted))]">
204
+ Paste it into your agent. It works out how to connect.{' '}
205
+ <button
206
+ onClick={() => setShowPrompt((shown) => !shown)}
207
+ aria-expanded={showPrompt}
208
+ className="inline-flex items-center gap-0.5 font-medium text-[rgb(var(--ec-accent))] hover:underline"
209
+ >
210
+ {showPrompt ? 'Hide prompt' : 'Show prompt'}
211
+ <ChevronDown size={12} className={`transition-transform ${showPrompt ? 'rotate-180' : ''}`} />
212
+ </button>
213
+ </p>
214
+ {showPrompt && (
215
+ <pre className="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-lg border p-3 font-mono text-[11px] leading-relaxed bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))]">
216
+ {prompt}
217
+ </pre>
218
+ )}
219
+ </section>
175
220
 
176
- <div className="space-y-4 border-t pt-4 border-[rgb(var(--ec-page-border))]">
177
- <Detail title="MCP server" value={link.mcpUrl}>
178
- For agents that can add an MCP server. Chats that show MCP Apps (ChatGPT, Claude) can open the canvas right in the
179
- chat. Agents running in the cloud need this EventCatalog on a public URL.
180
- </Detail>
181
- <Detail title="WebMCP (this tab)">
182
- {webMcp.state === 'on'
183
- ? `On: this tab offers ${webMcp.tools} canvas tools to agents in your browser (${webMcp.source === 'native' ? "your browser's WebMCP" : 'WebMCP polyfill, e.g. for the MCP-B extension'}).`
184
- : 'Off: this tab is not offering tools to agents in your browser (e.g. through the MCP-B extension).'}{' '}
185
- {webMcp.state === 'off' && (
186
- <button onClick={onEnableWebMcp} className="font-medium text-[rgb(var(--ec-accent))] hover:underline">
187
- Turn on for this tab
221
+ {/* Or set it up by hand, for the kind of agent you use */}
222
+ <section className="space-y-3 border-t pt-4 border-[rgb(var(--ec-page-border))]">
223
+ <h3 className="text-xs font-semibold">Or connect it yourself</h3>
224
+ <div role="tablist" className="grid grid-cols-3 gap-1 rounded-lg p-1 bg-[rgb(var(--ec-page-border)/0.5)]">
225
+ {WAYS.map(({ id, label, hint }) => (
226
+ <button
227
+ key={id}
228
+ role="tab"
229
+ aria-selected={way === id}
230
+ onClick={() => setWay(id)}
231
+ className={`rounded-md px-2 py-1.5 text-center ${
232
+ way === id ? 'bg-[rgb(var(--ec-card-bg))] shadow-sm' : 'text-[rgb(var(--ec-page-text-muted))]'
233
+ }`}
234
+ >
235
+ <span className="block text-xs font-medium">{label}</span>
236
+ <span className="block truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">{hint}</span>
188
237
  </button>
189
- )}
190
- </Detail>
191
- {relayScriptPath && (
192
- <Detail title="WebMCP local relay" value={RELAY_COMMAND}>
193
- For agents on your computer (Claude Code, Cursor...): add this as an MCP server, and turn the relay on for this
194
- tab. They get the tools of this tab while it stays open.{' '}
195
- {relayOn ? (
196
- <span className={`font-medium ${STATUS.success.text}`}>The relay is on for this tab.</span>
197
- ) : (
198
- <button
199
- onClick={() => {
200
- enableRelay(relayScriptPath);
201
- setRelayOn(true);
202
- // The relay hands this tab's WebMCP tools to desktop agents
203
- onEnableWebMcp();
204
- }}
205
- className="font-medium text-[rgb(var(--ec-accent))] hover:underline"
206
- >
207
- Turn on for this tab
208
- </button>
238
+ ))}
239
+ </div>
240
+
241
+ {way === 'chat' && (
242
+ <div className="space-y-2">
243
+ <p className="text-xs text-[rgb(var(--ec-page-text-muted))]">
244
+ Add this MCP server to your chat app. Chats that show MCP Apps open the canvas right in the chat.
245
+ </p>
246
+ <CopyField value={link.mcpUrl} />
247
+ </div>
248
+ )}
249
+ {way === 'code' && (
250
+ <div className="space-y-2">
251
+ <p className="text-xs text-[rgb(var(--ec-page-text-muted))]">
252
+ Add EventCatalog's MCP server, e.g. in Claude Code:
253
+ </p>
254
+ <CopyField value={`claude mcp add --transport http eventcatalog ${link.mcpUrl}`} />
255
+ {relayScriptPath && (
256
+ <div className="flex items-center justify-between gap-3 pt-1">
257
+ <p className="text-[11px] text-[rgb(var(--ec-page-text-muted))]">
258
+ Can't reach this server? Use this tab through the local relay: add <code>{RELAY_COMMAND}</code> as an MCP
259
+ server.
260
+ </p>
261
+ <TabSwitch on={relayOn} onTurnOn={turnOnRelay} label="Turn on relay" />
262
+ </div>
209
263
  )}
210
- </Detail>
264
+ </div>
211
265
  )}
212
- </div>
266
+ {way === 'browser' && (
267
+ <div className="flex items-center justify-between gap-3">
268
+ <p className="text-xs text-[rgb(var(--ec-page-text-muted))]">
269
+ {webMcp.state === 'on'
270
+ ? `This tab offers ${webMcp.tools} canvas tools to agents in your browser (like the MCP-B extension).`
271
+ : 'Let agents in your browser (like the MCP-B extension) use this canvas through WebMCP.'}
272
+ </p>
273
+ <TabSwitch on={webMcp.state === 'on'} onTurnOn={onEnableWebMcp} label="Turn on" />
274
+ </div>
275
+ )}
276
+ </section>
213
277
  </div>
214
278
  </div>
215
279
  </div>
@@ -0,0 +1,144 @@
1
+ import { useEffect, useState, type ReactElement } from 'react';
2
+ import { X } from 'lucide-react';
3
+ import type { ConnectionGroup, ConnectionGroupId } from '../canvas-actions';
4
+ import type { CatalogResource } from '../catalog-resources';
5
+ import { getNodeLabel } from '../node-types';
6
+ import { CardPreview } from './ContainerChooser';
7
+
8
+ /**
9
+ * Asked when a catalog service or message is dropped on the canvas (and connects to things that aren't on it yet):
10
+ * just it, or it with its connections (a service's messages and data stores, the services that send and receive a
11
+ * message), picked by group. Styled like the domain and system chooser.
12
+ */
13
+
14
+ export type ConnectionsChoice = { as: 'card' } | { as: 'connected'; include: CatalogResource[] };
15
+
16
+ // It in the middle, what comes in before it and what goes out after it
17
+ const ConnectedPreview = () => (
18
+ <svg viewBox="0 0 64 36" className="h-9 w-16" aria-hidden>
19
+ <rect x="1" y="7" width="10" height="8" rx="1.5" fill="currentColor" fillOpacity="0.4" />
20
+ <rect x="1" y="21" width="10" height="8" rx="1.5" fill="currentColor" fillOpacity="0.4" />
21
+ <path d="M11 11h8M11 25h8" stroke="currentColor" strokeOpacity="0.5" strokeWidth="1.5" />
22
+ <rect
23
+ x="19"
24
+ y="8"
25
+ width="26"
26
+ height="20"
27
+ rx="4"
28
+ fill="currentColor"
29
+ fillOpacity="0.1"
30
+ stroke="currentColor"
31
+ strokeOpacity="0.5"
32
+ strokeWidth="1.5"
33
+ />
34
+ <rect x="23" y="13" width="13" height="3" rx="1.5" fill="currentColor" fillOpacity="0.6" />
35
+ <rect x="23" y="19" width="17" height="2" rx="1" fill="currentColor" fillOpacity="0.35" />
36
+ <path d="M45 18h8" stroke="currentColor" strokeOpacity="0.5" strokeWidth="1.5" />
37
+ <rect x="53" y="14" width="10" height="8" rx="1.5" fill="currentColor" fillOpacity="0.4" />
38
+ </svg>
39
+ );
40
+
41
+ /** What "with its connections" brings, by what's dropped */
42
+ const CONNECTED_HINTS: Record<string, string> = {
43
+ services: 'What it sends, receives and stores, around it',
44
+ };
45
+ const MESSAGE_HINT = 'The services that send and receive it, around it';
46
+
47
+ export default function ConnectionsChooser({
48
+ resource,
49
+ groups,
50
+ position,
51
+ onChoose,
52
+ onCancel,
53
+ }: {
54
+ resource: CatalogResource;
55
+ /** What it connects to that isn't on the canvas yet */
56
+ groups: ConnectionGroup[];
57
+ /** Where to show it (screen coordinates) */
58
+ position: { x: number; y: number };
59
+ onChoose: (choice: ConnectionsChoice) => void;
60
+ onCancel: () => void;
61
+ }) {
62
+ const noun = getNodeLabel(resource.node.type).toLowerCase();
63
+ const [included, setIncluded] = useState<Set<ConnectionGroupId>>(() => new Set(groups.map((group) => group.id)));
64
+ const toggle = (id: ConnectionGroupId) =>
65
+ setIncluded((current) => {
66
+ const next = new Set(current);
67
+ if (next.has(id)) next.delete(id);
68
+ else next.add(id);
69
+ return next;
70
+ });
71
+ const include = groups.flatMap((group) => (included.has(group.id) ? group.resources : []));
72
+ const options: { as: ConnectionsChoice['as']; label: string; hint: string; Preview: () => ReactElement }[] = [
73
+ { as: 'card', label: `Just the ${noun}`, hint: 'Connected to what is already on the canvas', Preview: CardPreview },
74
+ {
75
+ as: 'connected',
76
+ label: 'With its connections',
77
+ hint: CONNECTED_HINTS[resource.collection] ?? MESSAGE_HINT,
78
+ Preview: ConnectedPreview,
79
+ },
80
+ ];
81
+
82
+ useEffect(() => {
83
+ const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && onCancel();
84
+ window.addEventListener('keydown', onKeyDown);
85
+ return () => window.removeEventListener('keydown', onKeyDown);
86
+ }, [onCancel]);
87
+
88
+ // Kept on screen, next to where it was dropped
89
+ const left = Math.min(Math.max(position.x - 160, 12), window.innerWidth - 332);
90
+ const top = Math.min(Math.max(position.y - 20, 12), window.innerHeight - 300);
91
+
92
+ return (
93
+ <div className="fixed inset-0 z-50" onPointerDown={onCancel}>
94
+ <div
95
+ role="dialog"
96
+ aria-label={`Add ${resource.name}`}
97
+ onPointerDown={(event) => event.stopPropagation()}
98
+ className="absolute w-80 rounded-lg border p-1.5 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
99
+ style={{ left, top }}
100
+ >
101
+ <div className="flex items-start justify-between gap-2 px-2 pb-1.5 pt-1">
102
+ <div>
103
+ <p className="text-[10px] font-semibold uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))]">
104
+ Add this {noun}
105
+ </p>
106
+ <p className="truncate text-xs font-semibold">{resource.name}</p>
107
+ </div>
108
+ <button
109
+ onClick={onCancel}
110
+ aria-label="Cancel"
111
+ className="rounded p-0.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
112
+ >
113
+ <X size={14} />
114
+ </button>
115
+ </div>
116
+
117
+ <div className="grid grid-cols-2 gap-1.5 px-0.5 pb-0.5">
118
+ {options.map(({ as, label, hint, Preview }) => (
119
+ <button
120
+ key={as}
121
+ autoFocus={as === 'connected'}
122
+ disabled={as === 'connected' && include.length === 0}
123
+ onClick={() => onChoose(as === 'card' ? { as } : { as, include })}
124
+ className="flex flex-col items-center gap-1 rounded-md border px-2 pb-1.5 pt-2 text-xs outline-none transition-colors border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text-muted))] hover:border-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent)/0.08)] hover:text-[rgb(var(--ec-accent))] focus-visible:border-[rgb(var(--ec-accent))] focus-visible:text-[rgb(var(--ec-accent))] disabled:cursor-not-allowed disabled:opacity-50"
125
+ >
126
+ <Preview />
127
+ <span className="font-semibold">{label}</span>
128
+ <span className="text-center text-[10px] leading-tight text-[rgb(var(--ec-page-text-muted))]">{hint}</span>
129
+ </button>
130
+ ))}
131
+ </div>
132
+
133
+ <div className="mt-1 space-y-0.5 px-2 pb-1">
134
+ {groups.map((group) => (
135
+ <label key={group.id} className="flex items-center gap-2 py-0.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
136
+ <input type="checkbox" checked={included.has(group.id)} onChange={() => toggle(group.id)} />
137
+ {group.label} ({group.resources.length})
138
+ </label>
139
+ ))}
140
+ </div>
141
+ </div>
142
+ </div>
143
+ );
144
+ }
@@ -10,7 +10,7 @@ import type { CatalogResource } from '../catalog-resources';
10
10
  export type ContainerChoice = { as: 'card' } | { as: 'container'; withContents: boolean };
11
11
 
12
12
  // Small pictures of the two ways a domain or system can be on the canvas
13
- const CardPreview = () => (
13
+ export const CardPreview = () => (
14
14
  <svg viewBox="0 0 64 36" className="h-9 w-16" aria-hidden>
15
15
  <rect
16
16
  x="18"
@@ -0,0 +1,115 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from 'react';
2
+ import { ReactFlow, ReactFlowProvider, useStoreApi, type Node, type XYPosition } from '@xyflow/react';
3
+ import { getNodeSize, isGroupType } from '../node-types';
4
+ import { nodeTypes } from './canvas-nodes';
5
+
6
+ /**
7
+ * Dragging something from the left panel onto the canvas shows the node it becomes, under the pointer, at the
8
+ * canvas's zoom and where it will land (centred on the pointer, as it's dropped). Drawn here rather than as the
9
+ * browser's drag image, which browsers shrink, fade and put a backing behind.
10
+ */
11
+
12
+ /** What's dragged: the node it becomes */
13
+ export type DragPreview = { type: string; data: Record<string, unknown> };
14
+ /** Starts dragging `value` as `dragType`, showing `preview` as what's dragged */
15
+ export type StartDrag = (dragType: string, value: string, preview: DragPreview) => (event: DragEvent) => void;
16
+
17
+ // Hides the browser's own drag image (a transparent pixel)
18
+ const NO_DRAG_IMAGE =
19
+ typeof Image === 'undefined'
20
+ ? undefined
21
+ : Object.assign(new Image(), { src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' });
22
+
23
+ // Room around the node for the badge on its top edge
24
+ const MARGIN = 16;
25
+ const PRO_OPTIONS = { hideAttribution: true };
26
+
27
+ type Ghost = { preview: DragPreview; zoom: number; at: XYPosition };
28
+
29
+ /** The ghost of what's being dragged, and how to start dragging something with one */
30
+ export function useDragGhost() {
31
+ const store = useStoreApi();
32
+ const [ghost, setGhost] = useState<Ghost | null>(null);
33
+
34
+ const startDrag = useCallback<StartDrag>(
35
+ (dragType, value, preview) => (event) => {
36
+ event.dataTransfer.setData(dragType, value);
37
+ event.dataTransfer.effectAllowed = 'move';
38
+ if (NO_DRAG_IMAGE) event.dataTransfer.setDragImage(NO_DRAG_IMAGE, 0, 0);
39
+ setGhost({ preview, zoom: store.getState().transform[2], at: { x: event.clientX, y: event.clientY } });
40
+ },
41
+ [store]
42
+ );
43
+
44
+ const element = ghost && <DragGhost ghost={ghost} onEnd={() => setGhost(null)} />;
45
+ return { startDrag, ghost: element };
46
+ }
47
+
48
+ function DragGhost({ ghost, onEnd }: { ghost: Ghost; onEnd: () => void }) {
49
+ const ref = useRef<HTMLDivElement>(null);
50
+ const { preview, zoom } = ghost;
51
+ const container = isGroupType(preview.type);
52
+ // The size it's added at (containers are sized; cards are drawn at their own size inside this)
53
+ const size = getNodeSize(preview.type);
54
+ // The node is placed with its middle where it's dropped, at the size it's added at
55
+ const offset = { x: (size.width / 2) * zoom + MARGIN, y: (size.height / 2) * zoom + MARGIN };
56
+
57
+ // Follows the pointer (as the browser reports it while dragging) without re-rendering, and goes when it's dropped
58
+ useEffect(() => {
59
+ const move = (x: number, y: number) => {
60
+ if (ref.current) ref.current.style.transform = `translate(${x - offset.x}px, ${y - offset.y}px)`;
61
+ };
62
+ move(ghost.at.x, ghost.at.y);
63
+ const onDragOver = (event: globalThis.DragEvent) => {
64
+ if (event.clientX || event.clientY) move(event.clientX, event.clientY);
65
+ };
66
+ document.addEventListener('dragover', onDragOver);
67
+ document.addEventListener('drop', onEnd);
68
+ document.addEventListener('dragend', onEnd);
69
+ return () => {
70
+ document.removeEventListener('dragover', onDragOver);
71
+ document.removeEventListener('drop', onEnd);
72
+ document.removeEventListener('dragend', onEnd);
73
+ };
74
+ }, [ghost]);
75
+
76
+ const nodes = useMemo<Node[]>(
77
+ () => [
78
+ {
79
+ id: 'drag-ghost',
80
+ type: preview.type,
81
+ position: { x: 0, y: 0 },
82
+ data: preview.data,
83
+ ...(container && size),
84
+ },
85
+ ],
86
+ [preview, container, size]
87
+ );
88
+ const viewport = useMemo(() => ({ x: MARGIN, y: MARGIN, zoom }), [zoom]);
89
+
90
+ return (
91
+ <div
92
+ ref={ref}
93
+ aria-hidden
94
+ // Sized here: the visualiser's styles make its class fill the window
95
+ className="studio-drag-preview eventcatalog-visualizer pointer-events-none fixed left-0 top-0 z-[100]"
96
+ style={{ width: size.width * zoom + MARGIN * 2, height: size.height * zoom + MARGIN * 2 }}
97
+ >
98
+ <ReactFlowProvider>
99
+ <ReactFlow
100
+ nodes={nodes}
101
+ nodeTypes={nodeTypes}
102
+ defaultViewport={viewport}
103
+ proOptions={PRO_OPTIONS}
104
+ nodesDraggable={false}
105
+ nodesConnectable={false}
106
+ elementsSelectable={false}
107
+ panOnDrag={false}
108
+ zoomOnScroll={false}
109
+ zoomOnPinch={false}
110
+ preventScrolling={false}
111
+ />
112
+ </ReactFlowProvider>
113
+ </div>
114
+ );
115
+ }
@@ -1,29 +1,5 @@
1
1
  import { useState } from 'react';
2
2
 
3
- const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
4
- const NAME_KEY = 'eventcatalog-studio-name';
5
-
6
- export const getStoredName = () => {
7
- try {
8
- return localStorage.getItem(NAME_KEY);
9
- } catch {
10
- return null;
11
- }
12
- };
13
-
14
- export const storeName = (name: string) => {
15
- try {
16
- localStorage.setItem(NAME_KEY, name);
17
- } catch {}
18
- };
19
-
20
- // Same name, same colour across reloads
21
- export const colorForName = (name: string) => {
22
- let hash = 0;
23
- for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
24
- return COLORS[Math.abs(hash) % COLORS.length];
25
- };
26
-
27
3
  export default function JoinForm({ onJoin }: { onJoin: (name: string) => void }) {
28
4
  const [draft, setDraft] = useState('');
29
5