@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,8 +1,561 @@
1
1
  ---
2
+ import path from 'node:path';
3
+ import { ChevronDown, ChevronLeft, ChevronRight, Pencil, Plus, Search } from 'lucide-react';
4
+ import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
2
5
  import { buildUrl } from '@utils/url-builder';
6
+ import { CANVAS_STATUSES, type CanvasStatus } from '../canvas-doc';
7
+ import { CANVAS_STATUS_LOOK } from '../components/status';
8
+ import { startStudio, type CanvasSummary } from '../server/runtime';
9
+ import { getSignedInUser, isSignedIn } from '../server/sign-in';
3
10
 
4
11
  export const prerender = false;
5
12
 
6
- // EventCatalog Studio: each visit starts a new canvas
7
- return Astro.redirect(buildUrl(`/studio/${crypto.randomUUID()}`));
13
+ // The canvases on this EventCatalog, listed like the catalog's other tables (filters are in the address, so they can
14
+ // be shared and work without JavaScript)
15
+ const runtime = await startStudio({ isSignedIn });
16
+ // Signed in (SSO): you're shown by who you signed in as, and asked for nothing
17
+ const signedInAs = getSignedInUser(Astro.locals);
18
+ const storage = runtime?.storage ?? { type: 'memory' as const };
19
+ // Where they're saved, as the catalog's folder sees it (e.g. canvases)
20
+ const savedIn = storage.location && path.relative(process.env.PROJECT_DIR || process.cwd(), storage.location);
21
+ const statusParam = Astro.url.searchParams.get('status');
22
+ const status = CANVAS_STATUSES.includes(statusParam as CanvasStatus) ? (statusParam as CanvasStatus) : undefined;
23
+ const search = Astro.url.searchParams.get('q')?.trim() ?? '';
24
+
25
+ // Canvases started and left empty (opening a new one to look around) aren't listed
26
+ const isBlank = (canvas: CanvasSummary) =>
27
+ canvas.nodeCount === 0 && canvas.openComments === 0 && !canvas.title && canvas.people.length === 0;
28
+ const canvases = (runtime?.listCanvases({ withPreview: true }) ?? [])
29
+ .filter((canvas) => !isBlank(canvas))
30
+ // The ones changed most recently first
31
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
32
+ const counts = Object.fromEntries(
33
+ CANVAS_STATUSES.map((value) => [value, canvases.filter((canvas) => canvas.status === value).length])
34
+ ) as Record<CanvasStatus, number>;
35
+ const shown = canvases.filter(
36
+ (canvas) =>
37
+ (!status || canvas.status === status) &&
38
+ (!search || (canvas.title ?? 'Untitled canvas').toLowerCase().includes(search.toLowerCase()))
39
+ );
40
+
41
+ // A page of them at a time, like the catalog's other tables
42
+ const PAGE_SIZES = [10, 25, 50, 100];
43
+ const sizeParam = Number(Astro.url.searchParams.get('size'));
44
+ const pageSize = PAGE_SIZES.includes(sizeParam) ? sizeParam : PAGE_SIZES[0];
45
+ const pageCount = Math.max(1, Math.ceil(shown.length / pageSize));
46
+ const pageNumber = Math.min(Math.max(1, Math.floor(Number(Astro.url.searchParams.get('page')) || 1)), pageCount);
47
+ const onPage = shown.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
48
+ /** This list on another page (its filters and page size kept) */
49
+ const pageUrl = (page: number) => {
50
+ const params = new URLSearchParams();
51
+ if (search) params.set('q', search);
52
+ if (status) params.set('status', status);
53
+ if (pageSize !== PAGE_SIZES[0]) params.set('size', String(pageSize));
54
+ if (page > 1) params.set('page', String(page));
55
+ const query = params.toString();
56
+ return `${buildUrl('/studio')}${query ? `?${query}` : ''}`;
57
+ };
58
+
59
+ const relativeTime = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
60
+ const ago = (time: number) => {
61
+ const minutes = Math.round((time - Date.now()) / 60_000);
62
+ if (minutes === 0) return 'just now';
63
+ if (Math.abs(minutes) < 60) return relativeTime.format(minutes, 'minute');
64
+ const hours = Math.round(minutes / 60);
65
+ if (Math.abs(hours) < 24) return relativeTime.format(hours, 'hour');
66
+ const days = Math.round(hours / 24);
67
+ if (Math.abs(days) < 30) return relativeTime.format(days, 'day');
68
+ return new Date(time).toLocaleDateString('en', { day: 'numeric', month: 'short', year: 'numeric' });
69
+ };
70
+ const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`;
71
+
72
+ // Node colours in previews, like the visualiser's (by kind of node), from the theme's badge colours
73
+ const PREVIEW_COLOURS: Record<string, string> = {
74
+ service: 'pink',
75
+ event: 'orange',
76
+ command: 'blue',
77
+ query: 'green',
78
+ channel: 'slate',
79
+ data: 'indigo',
80
+ view: 'sky',
81
+ externalSystem: 'fuchsia',
82
+ agent: 'violet',
83
+ actor: 'sky',
84
+ system: 'violet',
85
+ 'context-domain': 'yellow',
86
+ note: 'yellow',
87
+ };
88
+ const colourOf = (type: string) => `rgb(var(--ec-badge-color-${PREVIEW_COLOURS[type] ?? 'gray'}-text))`;
89
+ // Room around the drawing, in canvas units relative to its size
90
+ const framed = (preview: NonNullable<CanvasSummary['preview']>) => {
91
+ const margin = Math.max(preview.width, preview.height) * 0.08;
92
+ return `${-margin} ${-margin} ${preview.width + margin * 2} ${preview.height + margin * 2}`;
93
+ };
94
+ const strokeOf = (preview: NonNullable<CanvasSummary['preview']>, ratio: number) =>
95
+ Math.max(preview.width, preview.height) / ratio;
8
96
  ---
97
+
98
+ <VerticalSideBarLayout title="Studio - EventCatalog" showNestedSideBar={false}>
99
+ {/* The page's height, like Discover: the list scrolls between its header and its pages */}
100
+ <main
101
+ class="ml-0 flex flex-col bg-[rgb(var(--ec-page-bg))]"
102
+ style="margin-left: calc(var(--ec-app-content-padding-left, 5rem) * -1); margin-right: calc(var(--ec-app-content-padding-right, 5rem) * -1); height: calc(100dvh - 60px);"
103
+ >
104
+ <div class="flex flex-shrink-0 flex-col gap-4 px-6 py-5 md:flex-row md:items-end md:justify-between">
105
+ <div class="min-w-0">
106
+ <h1 class="text-2xl font-semibold text-[rgb(var(--ec-page-text))] md:text-4xl">Studio ({canvases.length})</h1>
107
+ <p class="max-w-3xl pt-2 text-base font-light text-[rgb(var(--ec-page-text-muted))]">
108
+ Design changes to your architecture on live canvases, with your team and AI agents.
109
+ </p>
110
+ </div>
111
+ <div class="flex shrink-0 flex-col items-start gap-3 md:items-end">
112
+ {
113
+ /* You, by the name others see on canvases: who you signed in as, or the name kept in this browser (shown by
114
+ the script; its space is kept so nothing moves) */
115
+ }
116
+ {
117
+ signedInAs ? (
118
+ // Signed in: you're shown in the app's header already (still read by the script, for New canvas)
119
+ <div data-you data-signed-in-as={signedInAs.name} hidden />
120
+ ) : (
121
+ <div data-you class="invisible flex items-center gap-2">
122
+ <button
123
+ type="button"
124
+ title="Change your name"
125
+ class="group inline-flex max-w-48 items-center gap-2 rounded-lg border py-1 pl-1 pr-2.5 text-sm border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))]"
126
+ >
127
+ <span
128
+ data-you-avatar
129
+ class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
130
+ />
131
+ <span data-you-name class="truncate font-medium" />
132
+ <Pencil
133
+ size={12}
134
+ className="shrink-0 text-[rgb(var(--ec-icon-color))] group-hover:text-[rgb(var(--ec-page-text))]"
135
+ />
136
+ </button>
137
+ </div>
138
+ )
139
+ }
140
+ <div class="flex items-center gap-2">
141
+ <form method="get" action={buildUrl('/studio')} class="flex items-center gap-2" role="search">
142
+ {pageSize !== PAGE_SIZES[0] && <input type="hidden" name="size" value={pageSize} />}
143
+ <label class="relative">
144
+ <span class="sr-only">Filter canvases</span>
145
+ <Search
146
+ size={14}
147
+ className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[rgb(var(--ec-icon-color))]"
148
+ />
149
+ <input
150
+ type="search"
151
+ name="q"
152
+ value={search}
153
+ placeholder="Filter..."
154
+ class="w-56 rounded-lg border py-1.5 pl-8 pr-3 text-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))]"
155
+ />
156
+ </label>
157
+ <label class="relative">
158
+ <span class="sr-only">Status</span>
159
+ {/* Our own arrow: the browser's sits against the edge, whatever the padding */}
160
+ <select
161
+ name="status"
162
+ onchange="this.form.requestSubmit()"
163
+ class="cursor-pointer appearance-none rounded-lg border py-1.5 pl-3 pr-9 text-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] focus:outline-none focus:border-[rgb(var(--ec-accent))]"
164
+ >
165
+ <option value="" selected={!status}>All statuses</option>
166
+ {
167
+ CANVAS_STATUSES.map((value) => (
168
+ <option value={value} selected={status === value}>
169
+ {CANVAS_STATUS_LOOK[value].label} ({counts[value]})
170
+ </option>
171
+ ))
172
+ }
173
+ </select>
174
+ <ChevronDown
175
+ size={14}
176
+ className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[rgb(var(--ec-icon-color))]"
177
+ />
178
+ </label>
179
+ </form>
180
+ <a
181
+ href={buildUrl('/studio/new')}
182
+ data-new-canvas
183
+ class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
184
+ >
185
+ <Plus size={14} />
186
+ New canvas
187
+ </a>
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ <div class="min-h-0 flex-1 overflow-auto px-6 pb-5">
193
+ <div
194
+ class="overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border)/0.72)] bg-[rgb(var(--ec-dropdown-bg)/0.66)]"
195
+ >
196
+ <table class="min-w-full divide-y divide-[rgb(var(--ec-page-border)/0.62)]">
197
+ <thead class="sticky top-0 z-10 bg-[rgb(var(--ec-content-hover)/0.45)]">
198
+ <tr>
199
+ {
200
+ ['Canvas', 'Status', 'Contents', 'Comments', 'Created', 'Updated'].map((label) => (
201
+ <th
202
+ scope="col"
203
+ class="px-4 py-2.5 text-left text-[11px] font-medium uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))]"
204
+ >
205
+ {label}
206
+ </th>
207
+ ))
208
+ }
209
+ </tr>
210
+ </thead>
211
+ <tbody class="divide-y divide-[rgb(var(--ec-page-border)/0.5)]">
212
+ {
213
+ onPage.map((canvas) => {
214
+ const look = CANVAS_STATUS_LOOK[canvas.status];
215
+ const preview = canvas.preview;
216
+ return (
217
+ <tr class="group relative transition-colors hover:bg-[rgb(var(--ec-content-hover)/0.38)]">
218
+ <td class="px-4 py-3 text-sm text-[rgb(var(--ec-page-text))]">
219
+ <a href={buildUrl(`/studio/${canvas.canvasId}`)} class="flex items-center gap-3 font-semibold">
220
+ {/* The whole row opens it */}
221
+ <span class="absolute inset-0" aria-hidden="true" />
222
+ <span class="flex h-9 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))]">
223
+ {preview && (
224
+ <svg
225
+ viewBox={framed(preview)}
226
+ preserveAspectRatio="xMidYMid meet"
227
+ class="h-full w-full"
228
+ aria-hidden="true"
229
+ >
230
+ {preview.lines.map((line) => (
231
+ <line
232
+ {...line}
233
+ stroke="rgb(var(--ec-page-text-muted))"
234
+ stroke-opacity="0.5"
235
+ stroke-width={strokeOf(preview, 120)}
236
+ />
237
+ ))}
238
+ {preview.shapes.map((shape) => (
239
+ <rect
240
+ x={shape.x}
241
+ y={shape.y}
242
+ width={shape.width}
243
+ height={shape.height}
244
+ rx={strokeOf(preview, 40)}
245
+ fill={shape.container ? 'none' : colourOf(shape.type)}
246
+ fill-opacity="0.35"
247
+ stroke={shape.container ? 'rgb(var(--ec-page-text-muted))' : colourOf(shape.type)}
248
+ stroke-opacity={shape.container ? 0.5 : 1}
249
+ stroke-width={strokeOf(preview, shape.container ? 160 : 100)}
250
+ />
251
+ ))}
252
+ </svg>
253
+ )}
254
+ </span>
255
+ <span class="truncate group-hover:text-[rgb(var(--ec-accent))]">{canvas.title ?? 'Untitled canvas'}</span>
256
+ </a>
257
+ </td>
258
+ <td class="px-4 py-3 text-sm">
259
+ <span class:list={['inline-flex rounded-full border px-2 py-px text-[11px] font-medium', look.pill]}>
260
+ {look.label}
261
+ </span>
262
+ </td>
263
+ <td class="px-4 py-3 text-sm text-[rgb(var(--ec-page-text-muted))]">
264
+ {plural(canvas.nodeCount, 'node')}, {plural(canvas.edgeCount, 'connection')}
265
+ </td>
266
+ <td class="px-4 py-3 text-sm text-[rgb(var(--ec-page-text-muted))]">
267
+ {canvas.openComments > 0 ? `${canvas.openComments} open` : '-'}
268
+ </td>
269
+ <td class="px-4 py-3 text-sm text-[rgb(var(--ec-page-text-muted))]">
270
+ {[canvas.createdAt && ago(canvas.createdAt), canvas.createdBy && `by ${canvas.createdBy}`]
271
+ .filter(Boolean)
272
+ .join(' ') || '-'}
273
+ </td>
274
+ <td class="px-4 py-3 text-sm text-[rgb(var(--ec-page-text-muted))]">
275
+ {canvas.updatedAt ? ago(canvas.updatedAt) : '-'}
276
+ </td>
277
+ </tr>
278
+ );
279
+ })
280
+ }
281
+ {
282
+ shown.length === 0 && (
283
+ <tr>
284
+ <td colspan="6" class="px-4 py-12 text-center">
285
+ <p class="text-sm font-medium text-[rgb(var(--ec-page-text-muted))]">
286
+ {canvases.length === 0 ? 'No canvases yet' : 'No canvases match'}
287
+ </p>
288
+ <p class="mt-1 text-xs text-[rgb(var(--ec-icon-color))]">
289
+ {canvases.length === 0 ? (
290
+ <a href={buildUrl('/studio/new')} data-new-canvas class="text-[rgb(var(--ec-accent))] hover:underline">
291
+ Start a canvas
292
+ </a>
293
+ ) : (
294
+ <a href={buildUrl('/studio')} class="text-[rgb(var(--ec-accent))] hover:underline">
295
+ Show all canvases
296
+ </a>
297
+ )}
298
+ </p>
299
+ </td>
300
+ </tr>
301
+ )
302
+ }
303
+ </tbody>
304
+ </table>
305
+ </div>
306
+ </div>
307
+
308
+ <div class="flex flex-shrink-0 items-center justify-between gap-4 border-t border-[rgb(var(--ec-page-border))] px-6 py-3">
309
+ <div class="flex min-w-0 items-center gap-3 text-xs text-[rgb(var(--ec-page-text-muted))]">
310
+ {
311
+ shown.length > 0 && (
312
+ <span class="shrink-0">
313
+ <span class="font-medium text-[rgb(var(--ec-page-text))]">{onPage.length}</span> of{' '}
314
+ <span class="font-medium text-[rgb(var(--ec-page-text))]">{shown.length}</span> results
315
+ </span>
316
+ )
317
+ }
318
+ {shown.length > 0 && <span aria-hidden="true" class="h-3 w-px shrink-0 bg-[rgb(var(--ec-page-border))]" />}
319
+ <form method="get" action={buildUrl('/studio')} class="flex shrink-0 items-center gap-1.5">
320
+ {search && <input type="hidden" name="q" value={search} />}
321
+ {status && <input type="hidden" name="status" value={status} />}
322
+ <label for="studio-page-size">Per page</label>
323
+ <select
324
+ id="studio-page-size"
325
+ name="size"
326
+ onchange="this.form.requestSubmit()"
327
+ class="cursor-pointer rounded bg-transparent px-1 py-0.5 font-medium text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))] focus:bg-[rgb(var(--ec-content-hover))] focus:outline-none"
328
+ >
329
+ {
330
+ PAGE_SIZES.map((size) => (
331
+ <option value={size} selected={size === pageSize}>
332
+ {size}
333
+ </option>
334
+ ))
335
+ }
336
+ </select>
337
+ </form>
338
+ <span aria-hidden="true" class="h-3 w-px shrink-0 bg-[rgb(var(--ec-page-border))]"></span>
339
+ <span class="truncate">
340
+ {
341
+ storage.type === 'memory'
342
+ ? 'Kept in memory until EventCatalog restarts (set studio.storage in eventcatalog.config.js to keep them)'
343
+ : `Saved in ${savedIn}`
344
+ }
345
+ </span>
346
+ </div>
347
+ <nav class="flex shrink-0 items-center gap-1.5" aria-label="Pages">
348
+ {
349
+ pageNumber > 1 ? (
350
+ <a
351
+ href={pageUrl(pageNumber - 1)}
352
+ title="Previous page"
353
+ class="p-1.5 text-[rgb(var(--ec-icon-color))] transition-colors hover:text-[rgb(var(--ec-page-text))]"
354
+ >
355
+ <ChevronLeft className="h-4 w-4" />
356
+ </a>
357
+ ) : (
358
+ <span aria-disabled="true" class="p-1.5 text-[rgb(var(--ec-icon-color))] opacity-30">
359
+ <ChevronLeft className="h-4 w-4" />
360
+ </span>
361
+ )
362
+ }
363
+ <span class="min-w-[60px] text-center text-xs tabular-nums text-[rgb(var(--ec-page-text-muted))]">
364
+ <span class="font-medium text-[rgb(var(--ec-page-text))]">{pageNumber}</span> / {pageCount}
365
+ </span>
366
+ {
367
+ pageNumber < pageCount ? (
368
+ <a
369
+ href={pageUrl(pageNumber + 1)}
370
+ title="Next page"
371
+ class="p-1.5 text-[rgb(var(--ec-icon-color))] transition-colors hover:text-[rgb(var(--ec-page-text))]"
372
+ >
373
+ <ChevronRight className="h-4 w-4" />
374
+ </a>
375
+ ) : (
376
+ <span aria-disabled="true" class="p-1.5 text-[rgb(var(--ec-icon-color))] opacity-30">
377
+ <ChevronRight className="h-4 w-4" />
378
+ </span>
379
+ )
380
+ }
381
+ </nav>
382
+ </div>
383
+ </main>
384
+
385
+ {/* Naming a new canvas before it opens (New canvas links work without it: they start an untitled one) */}
386
+ <dialog
387
+ id="new-canvas-dialog"
388
+ aria-labelledby="new-canvas-title"
389
+ class="m-auto w-full max-w-md rounded-xl border p-0 shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))] backdrop:bg-black/40"
390
+ >
391
+ <form method="post" action={buildUrl('/studio/new')}>
392
+ <div class="border-b px-5 py-4 border-[rgb(var(--ec-page-border))]">
393
+ <h2 id="new-canvas-title" class="text-base font-semibold">New canvas</h2>
394
+ <p class="mt-0.5 text-sm text-[rgb(var(--ec-page-text-muted))]">
395
+ Name it for what you're designing, so your team can find it.
396
+ </p>
397
+ </div>
398
+ <div class="px-5 py-4">
399
+ <label for="new-canvas-name" class="block text-sm font-semibold">Name</label>
400
+ <input
401
+ id="new-canvas-name"
402
+ name="title"
403
+ maxlength="200"
404
+ autocomplete="off"
405
+ placeholder="e.g. Payments redesign"
406
+ class="mt-2 w-full rounded-lg border px-3 py-2 text-sm shadow-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))] focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.25)]"
407
+ />
408
+ {/* Who's starting it: the name they use on canvases, if they've joined one in this browser */}
409
+ <input type="hidden" name="by" />
410
+ </div>
411
+ <div class="flex justify-end gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
412
+ <button
413
+ type="button"
414
+ data-close-dialog
415
+ class="rounded-lg border px-4 py-2 text-sm font-medium border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)]"
416
+ >
417
+ Cancel
418
+ </button>
419
+ <button
420
+ type="submit"
421
+ class="rounded-lg px-4 py-2 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
422
+ >
423
+ Create canvas
424
+ </button>
425
+ </div>
426
+ </form>
427
+ </dialog>
428
+
429
+ {/* Your name: asked for on your first visit, changed from the button by New canvas */}
430
+ <dialog
431
+ id="name-dialog"
432
+ aria-labelledby="name-dialog-title"
433
+ class="m-auto w-full max-w-md rounded-xl border p-0 shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))] backdrop:bg-black/40"
434
+ >
435
+ <form>
436
+ <div class="border-b px-5 py-4 border-[rgb(var(--ec-page-border))]">
437
+ <h2 id="name-dialog-title" class="text-base font-semibold" data-first-visit-text="Welcome to Studio">Your name</h2>
438
+ <p class="mt-0.5 text-sm text-[rgb(var(--ec-page-text-muted))]">
439
+ What should others call you? It's shown by your pointer and your comments on every canvas.
440
+ </p>
441
+ </div>
442
+ <div class="px-5 py-4">
443
+ <label for="your-name" class="block text-sm font-semibold">Name</label>
444
+ <input
445
+ id="your-name"
446
+ name="name"
447
+ required
448
+ maxlength="60"
449
+ autocomplete="name"
450
+ placeholder="e.g. Sam"
451
+ class="mt-2 w-full rounded-lg border px-3 py-2 text-sm shadow-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))] focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.25)]"
452
+ />
453
+ <p class="mt-2 text-xs text-[rgb(var(--ec-page-text-muted))]">Kept in this browser. Change it here any time.</p>
454
+ </div>
455
+ <div class="flex justify-end gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
456
+ <button
457
+ type="button"
458
+ data-close-dialog
459
+ class="rounded-lg border px-4 py-2 text-sm font-medium border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)]"
460
+ >
461
+ Cancel
462
+ </button>
463
+ <button
464
+ type="submit"
465
+ class="rounded-lg px-4 py-2 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
466
+ >
467
+ Save
468
+ </button>
469
+ </div>
470
+ </form>
471
+ </dialog>
472
+ </VerticalSideBarLayout>
473
+
474
+ <script>
475
+ import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
476
+
477
+ // On every visit to the page, including ones the app's client-side navigation swaps in (its scripts run only once)
478
+ const setUp = () => {
479
+ const dialog = document.querySelector<HTMLDialogElement>('#new-canvas-dialog');
480
+ const nameDialog = document.querySelector<HTMLDialogElement>('#name-dialog');
481
+ const you = document.querySelector<HTMLElement>('[data-you]');
482
+ if (!dialog || !nameDialog || !you || dialog.dataset.ready) return;
483
+ dialog.dataset.ready = 'true';
484
+
485
+ // Signed in (SSO): you're who you signed in as, so there's nothing to ask or change
486
+ const signedInAs = you.dataset.signedInAs;
487
+ // Kept here too, for browsers that block storage (asked once per visit)
488
+ let yourName = signedInAs ?? getStoredName();
489
+ const showYou = (name: string | null) => {
490
+ yourName = name;
491
+ you.classList.toggle('invisible', !name);
492
+ if (!name) return;
493
+ you.querySelector('[data-you-name]')!.textContent = name;
494
+ const avatar = you.querySelector<HTMLElement>('[data-you-avatar]')!;
495
+ avatar.textContent = name.slice(0, 2).toUpperCase();
496
+ avatar.style.backgroundColor = colorForName(name);
497
+ };
498
+ if (!signedInAs) {
499
+ showYou(getStoredName());
500
+ const stopFollowing = onStoredNameChange(showYou);
501
+ document.addEventListener('astro:before-swap', stopFollowing, { once: true });
502
+ }
503
+
504
+ // Your name: on a first visit it has to be given (no Cancel, Escape does nothing)
505
+ const nameInput = nameDialog.querySelector<HTMLInputElement>('#your-name')!;
506
+ const nameTitle = nameDialog.querySelector<HTMLElement>('#name-dialog-title')!;
507
+ const nameCancel = nameDialog.querySelector<HTMLButtonElement>('[data-close-dialog]')!;
508
+ const defaultTitle = nameTitle.textContent?.trim();
509
+ let onNamed: (() => void) | undefined;
510
+ const askName = (then?: () => void) => {
511
+ nameInput.value = yourName ?? '';
512
+ nameTitle.textContent = yourName ? defaultTitle : nameTitle.dataset.firstVisitText!;
513
+ nameCancel.hidden = !yourName;
514
+ onNamed = then;
515
+ nameDialog.showModal();
516
+ nameInput.select();
517
+ };
518
+ nameDialog.addEventListener('cancel', (event) => {
519
+ if (!yourName) event.preventDefault();
520
+ });
521
+ nameCancel.addEventListener('click', () => nameDialog.close());
522
+ nameDialog.querySelector('form')!.addEventListener('submit', (event) => {
523
+ event.preventDefault();
524
+ const name = nameInput.value.trim();
525
+ if (!name) return;
526
+ storeName(name);
527
+ showYou(name);
528
+ nameDialog.close();
529
+ onNamed?.();
530
+ });
531
+ you.querySelector('button')?.addEventListener('click', () => askName());
532
+
533
+ const open = () => {
534
+ const by = dialog.querySelector<HTMLInputElement>('input[name="by"]');
535
+ if (by) by.value = yourName ?? '';
536
+ dialog.showModal();
537
+ dialog.querySelector<HTMLInputElement>('#new-canvas-name')?.focus();
538
+ };
539
+ // Starting a canvas says who started it: ask for a name first if there isn't one yet
540
+ const startCanvas = () => (yourName ? open() : askName(open));
541
+ // A plain button, so Enter in the name creates the canvas
542
+ dialog.querySelector('[data-close-dialog]')?.addEventListener('click', () => dialog.close());
543
+ document.querySelectorAll('[data-new-canvas]').forEach((link) =>
544
+ link.addEventListener('click', (event) => {
545
+ event.preventDefault();
546
+ startCanvas();
547
+ })
548
+ );
549
+ // Opened from a canvas's New canvas (/studio?new=1)
550
+ const url = new URL(window.location.href);
551
+ if (url.searchParams.has('new')) {
552
+ url.searchParams.delete('new');
553
+ history.replaceState(history.state, '', url);
554
+ startCanvas();
555
+ } else if (!yourName) {
556
+ askName();
557
+ }
558
+ };
559
+ document.addEventListener('astro:page-load', setUp);
560
+ setUp();
561
+ </script>
@@ -0,0 +1,24 @@
1
+ ---
2
+ import { buildUrl } from '@utils/url-builder';
3
+ import { startStudio } from '../server/runtime';
4
+ import { getSignedInUser, isSignedIn } from '../server/sign-in';
5
+
6
+ export const prerender = false;
7
+
8
+ // A new canvas. From the New canvas dialog (a POST) it's started with its name and who started it (whoever's signed in,
9
+ // or the name they gave); a plain link starts an untitled one, created when its page connects.
10
+ const MAX_TITLE_LENGTH = 200;
11
+
12
+ if (Astro.request.method === 'POST') {
13
+ const form = await Astro.request.formData();
14
+ const text = (field: string) =>
15
+ String(form.get(field) ?? '')
16
+ .trim()
17
+ .slice(0, MAX_TITLE_LENGTH) || undefined;
18
+ const createdBy = getSignedInUser(Astro.locals)?.name ?? text('by');
19
+ const canvasId = await (await startStudio({ isSignedIn })).createCanvas({ title: text('title'), createdBy });
20
+ return Astro.redirect(buildUrl(`/studio/${canvasId}`), 303);
21
+ }
22
+
23
+ return Astro.redirect(buildUrl(`/studio/${crypto.randomUUID()}`));
24
+ ---
@@ -0,0 +1,15 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { startStudio } from '../server/runtime';
3
+ import { isSignedIn } from '../server/sign-in';
4
+
5
+ /**
6
+ * Starts Studio (its storage, and the collaboration connection) without opening a page: `eventcatalog start` calls it
7
+ * once the server listens, so tabs reconnecting after a restart find it ready. It's under /_eventcatalog, which sign-in
8
+ * lets through (the server calls it without a session), and says nothing about the canvases.
9
+ */
10
+ export const GET: APIRoute = async () => {
11
+ await startStudio({ isSignedIn });
12
+ return new Response(null, { status: 204 });
13
+ };
14
+
15
+ export const prerender = false;