@eventcatalog/core 5.0.0-beta.12 → 5.0.0-beta.13

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 (82) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/catalog-runtime.cjs +1 -1
  6. package/dist/{chunk-FY6MSP6J.js → chunk-BO2HHVZ3.js} +1 -1
  7. package/dist/{chunk-CX2VB7AI.js → chunk-JPT3QC5V.js} +1 -1
  8. package/dist/{chunk-BWC4YKW3.js → chunk-PLTLW45Q.js} +5 -5
  9. package/dist/{chunk-TSY5CC27.js → chunk-T75DOVFP.js} +1 -1
  10. package/dist/{chunk-QB7X34OD.js → chunk-XA5W6DXX.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +2 -2
  14. package/dist/eventcatalog.js +14 -14
  15. package/dist/federation/federate.js +3 -3
  16. package/dist/generate.cjs +1 -1
  17. package/dist/generate.js +3 -3
  18. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  19. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  20. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  21. package/dist/runtime-dependencies/manifest.json +3 -0
  22. package/dist/utils/cli-logger.cjs +1 -1
  23. package/dist/utils/cli-logger.js +2 -2
  24. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +11 -3
  25. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  26. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  27. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  28. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +133 -71
  29. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +66 -66
  30. package/eventcatalog/src/features/studio/agent-choreography.ts +5 -4
  31. package/eventcatalog/src/features/studio/agents.ts +28 -0
  32. package/eventcatalog/src/features/studio/api/canvases-api.ts +1 -1
  33. package/eventcatalog/src/features/studio/canvas-actions.ts +22 -14
  34. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  35. package/eventcatalog/src/features/studio/catalog.ts +1 -4
  36. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +246 -115
  37. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +7 -3
  38. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  39. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  40. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +1 -1
  41. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  42. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +102 -241
  43. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  44. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +1 -1
  45. package/eventcatalog/src/features/studio/components/Presence.tsx +6 -8
  46. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  47. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  48. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1005 -354
  49. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  50. package/eventcatalog/src/features/studio/from-visualiser.ts +1 -1
  51. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  52. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  53. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  54. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +162 -23
  55. package/eventcatalog/src/features/studio/identity.ts +23 -1
  56. package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
  57. package/eventcatalog/src/features/studio/pages/index.astro +1 -2
  58. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +7 -22
  59. package/eventcatalog/src/features/studio/server/runtime.ts +4 -5
  60. package/package.json +3 -3
  61. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -569
  62. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  63. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -119
  64. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -777
  65. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  66. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  67. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  68. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  69. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -271
  70. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  71. package/eventcatalog/src/features/studio/components/status.ts +0 -53
  72. package/eventcatalog/src/features/studio/edges.ts +0 -56
  73. package/eventcatalog/src/features/studio/flow-state.ts +0 -121
  74. package/eventcatalog/src/features/studio/grouping.ts +0 -230
  75. package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +0 -20
  76. package/eventcatalog/src/features/studio/layout.ts +0 -56
  77. package/eventcatalog/src/features/studio/level-layout.ts +0 -145
  78. package/eventcatalog/src/features/studio/levels.ts +0 -337
  79. package/eventcatalog/src/features/studio/node-types.ts +0 -245
  80. package/eventcatalog/src/features/studio/placement.ts +0 -197
  81. package/eventcatalog/src/features/studio/studio.css +0 -273
  82. package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
@@ -1,145 +0,0 @@
1
- import { useEffect, useState, type ReactElement } from 'react';
2
- import { X } from 'lucide-react';
3
- import type { CatalogResource } from '../catalog-resources';
4
-
5
- /**
6
- * Asked when a domain or system is dropped on the canvas: is it a card (connected to what it contains), or a
7
- * container that what it contains sits inside? Styled like the visualiser's group-by picker (boxes or lanes).
8
- */
9
-
10
- export type ContainerChoice = { as: 'card' } | { as: 'container'; withContents: boolean };
11
-
12
- // Small pictures of the two ways a domain or system can be on the canvas
13
- export const CardPreview = () => (
14
- <svg viewBox="0 0 64 36" className="h-9 w-16" aria-hidden>
15
- <rect
16
- x="18"
17
- y="8"
18
- width="28"
19
- height="20"
20
- rx="4"
21
- fill="currentColor"
22
- fillOpacity="0.1"
23
- stroke="currentColor"
24
- strokeOpacity="0.5"
25
- strokeWidth="1.5"
26
- />
27
- <rect x="23" y="13" width="14" height="3" rx="1.5" fill="currentColor" fillOpacity="0.6" />
28
- <rect x="23" y="19" width="18" height="2" rx="1" fill="currentColor" fillOpacity="0.35" />
29
- <path d="M46 18h12" stroke="currentColor" strokeOpacity="0.5" strokeWidth="1.5" strokeDasharray="2 2" />
30
- <rect x="57" y="14" width="6" height="8" rx="1.5" fill="currentColor" fillOpacity="0.4" />
31
- </svg>
32
- );
33
-
34
- const ContainerPreview = () => (
35
- <svg viewBox="0 0 64 36" className="h-9 w-16" aria-hidden>
36
- <rect
37
- x="4"
38
- y="2"
39
- width="56"
40
- height="32"
41
- rx="4"
42
- fill="currentColor"
43
- fillOpacity="0.08"
44
- stroke="currentColor"
45
- strokeOpacity="0.5"
46
- strokeWidth="1.5"
47
- />
48
- <path d="M4 10h56" stroke="currentColor" strokeOpacity="0.35" strokeWidth="1" />
49
- <rect x="8" y="4.5" width="14" height="3" rx="1.5" fill="currentColor" fillOpacity="0.6" />
50
- <rect x="10" y="15" width="18" height="12" rx="2" fill="currentColor" fillOpacity="0.5" />
51
- <rect x="36" y="15" width="18" height="12" rx="2" fill="currentColor" fillOpacity="0.5" />
52
- </svg>
53
- );
54
-
55
- const OPTIONS: { as: ContainerChoice['as']; label: string; hint: string; Preview: () => ReactElement }[] = [
56
- { as: 'card', label: 'Card', hint: 'Connected to what it contains', Preview: CardPreview },
57
- { as: 'container', label: 'Container', hint: 'What it contains sits inside it', Preview: ContainerPreview },
58
- ];
59
-
60
- const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`;
61
-
62
- export default function ContainerChooser({
63
- resource,
64
- contents,
65
- position,
66
- onChoose,
67
- onCancel,
68
- }: {
69
- resource: CatalogResource;
70
- /** What it contains in the catalog that isn't on the canvas yet */
71
- contents: { systems: number; services: number };
72
- /** Where to show it (screen coordinates) */
73
- position: { x: number; y: number };
74
- onChoose: (choice: ContainerChoice) => void;
75
- onCancel: () => void;
76
- }) {
77
- const [withContents, setWithContents] = useState(true);
78
- const noun = resource.collection === 'domains' ? 'domain' : 'system';
79
- const contentsLabel = [
80
- contents.systems > 0 && plural(contents.systems, 'system'),
81
- contents.services > 0 && plural(contents.services, 'service'),
82
- ]
83
- .filter(Boolean)
84
- .join(' and ');
85
-
86
- useEffect(() => {
87
- const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && onCancel();
88
- window.addEventListener('keydown', onKeyDown);
89
- return () => window.removeEventListener('keydown', onKeyDown);
90
- }, [onCancel]);
91
-
92
- // Kept on screen, next to where it was dropped
93
- const left = Math.min(Math.max(position.x - 160, 12), window.innerWidth - 332);
94
- const top = Math.min(Math.max(position.y - 20, 12), window.innerHeight - 260);
95
-
96
- return (
97
- <div className="fixed inset-0 z-50" onPointerDown={onCancel}>
98
- <div
99
- role="dialog"
100
- aria-label={`Add ${resource.name} as`}
101
- onPointerDown={(event) => event.stopPropagation()}
102
- 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))]"
103
- style={{ left, top }}
104
- >
105
- <div className="flex items-start justify-between gap-2 px-2 pb-1.5 pt-1">
106
- <div>
107
- <p className="text-[10px] font-semibold uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))]">
108
- Add this {noun} as
109
- </p>
110
- <p className="truncate text-xs font-semibold">{resource.name}</p>
111
- </div>
112
- <button
113
- onClick={onCancel}
114
- aria-label="Cancel"
115
- className="rounded p-0.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
116
- >
117
- <X size={14} />
118
- </button>
119
- </div>
120
-
121
- <div className="grid grid-cols-2 gap-1.5 px-0.5 pb-0.5">
122
- {OPTIONS.map(({ as, label, hint, Preview }) => (
123
- <button
124
- key={as}
125
- autoFocus={as === 'container'}
126
- onClick={() => onChoose(as === 'card' ? { as } : { as, withContents: withContents && !!contentsLabel })}
127
- 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))]"
128
- >
129
- <Preview />
130
- <span className="font-semibold">{label}</span>
131
- <span className="text-center text-[10px] leading-tight text-[rgb(var(--ec-page-text-muted))]">{hint}</span>
132
- </button>
133
- ))}
134
- </div>
135
-
136
- {contentsLabel && (
137
- <label className="mt-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
138
- <input type="checkbox" checked={withContents} onChange={(event) => setWithContents(event.target.checked)} />
139
- As a container, bring its {contentsLabel} inside, with their messages and data stores
140
- </label>
141
- )}
142
- </div>
143
- </div>
144
- );
145
- }
@@ -1,115 +0,0 @@
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,21 +0,0 @@
1
- import { useState } from 'react';
2
-
3
- /**
4
- * Someone's picture from their sign-in provider (SSO), loaded from there. By default it covers their avatar's
5
- * initials, which show again if it doesn't load (blocked, expired, or no longer there).
6
- */
7
- export default function Picture({ src, className = 'absolute inset-0 h-full w-full' }: { src?: string; className?: string }) {
8
- const [failed, setFailed] = useState<string>();
9
- if (!src || failed === src) return null;
10
- return (
11
- <img
12
- src={src}
13
- alt=""
14
- draggable={false}
15
- // Some providers (e.g. Google) refuse pictures loaded with a referrer from another site
16
- referrerPolicy="no-referrer"
17
- onError={() => setFailed(src)}
18
- className={`rounded-full object-cover ${className}`}
19
- />
20
- );
21
- }
@@ -1,212 +0,0 @@
1
- import { memo, useState, type FormEvent, type ReactNode } from 'react';
2
- import type { Node } from '@xyflow/react';
3
- import { Box, ExternalLink, Library, Trash2, X } from 'lucide-react';
4
- import { getCatalogLink } from '../catalog';
5
- import { getNodeLabel, getResource, isVersioned, updateResource } from '../node-types';
6
- import type { UpdateNodeData } from './canvas-nodes';
7
- import { NODE_ICONS } from './icons';
8
- import { STATUS } from './status';
9
-
10
- const inputClass =
11
- '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)] read-only:cursor-default read-only:opacity-70 read-only:shadow-none read-only:focus:ring-0 read-only:focus:border-[rgb(var(--ec-input-border))]';
12
- const buttonClass = 'rounded-lg px-4 py-2 text-sm font-medium transition-colors';
13
-
14
- // Versions are usually semantic versions (1.0.0), but any text is allowed
15
- const LOOKS_LIKE_A_VERSION = /^v?\d+(\.\d+){0,2}([-+][\w.-]+)?$/;
16
-
17
- /** How to name each type, shown under the name */
18
- const NAME_HINTS: Record<string, string> = {
19
- event: 'The name of the event. Use a verb-noun format in the past tense (e.g., OrderPlaced).',
20
- command: 'The name of the command. Use an instruction (e.g., PlaceOrder).',
21
- query: 'The name of the query. Ask for something (e.g., GetOrder).',
22
- service: 'The name of the service (e.g., Orders Service).',
23
- channel: 'The name of the channel (e.g., orders.events).',
24
- };
25
-
26
- type Field = 'name' | 'version' | 'summary';
27
-
28
- /**
29
- * The details of the node being edited: its name, version and summary, saved together with Save changes (one
30
- * change for everyone, and one step to undo). Catalog resources are read only: they're changed in the catalog.
31
- * Notes and text are edited on the canvas.
32
- */
33
- export default memo(function PropertiesPanel({
34
- node,
35
- onChange,
36
- onDelete,
37
- onClose,
38
- }: {
39
- /** What's shown of the node (not its position, so dragging it doesn't re-render the panel) */
40
- node: Pick<Node, 'id' | 'type' | 'data'>;
41
- onChange: UpdateNodeData;
42
- onDelete: (ids: string[]) => void;
43
- onClose: () => void;
44
- }) {
45
- const resource = getResource(node.type, node.data);
46
- const link = getCatalogLink(node);
47
- const label = getNodeLabel(node.type);
48
- const noun = label.toLowerCase();
49
- const { icon: Icon, className: iconClassName } = NODE_ICONS[node.type ?? ''] ?? { icon: Box, className: '' };
50
- const versioned = isVersioned(node.type);
51
-
52
- // What's been changed here and not saved. Fields not changed show the node as it is, so changes others make while
53
- // it's open show up.
54
- const [draft, setDraft] = useState<Partial<Record<Field, string>>>({});
55
- const saved: Record<Field, string> = {
56
- name: String(resource.name ?? ''),
57
- version: String(link?.version ?? resource.version ?? ''),
58
- summary: String(resource.summary ?? ''),
59
- };
60
- const value = (field: Field) => draft[field] ?? saved[field];
61
- const edit = (field: Field) => (event: { target: { value: string } }) =>
62
- setDraft((current) => ({ ...current, [field]: event.target.value }));
63
-
64
- const changes = Object.entries(draft).filter(([field, text]) => text !== saved[field as Field]);
65
- const missing = !value('name').trim() || (versioned && !value('version').trim());
66
- const canSave = !link && changes.length > 0 && !missing;
67
- const version = value('version');
68
-
69
- const save = (event?: FormEvent) => {
70
- event?.preventDefault();
71
- if (!canSave) return;
72
- onChange(node.id, (data) => updateResource(node.type, data, Object.fromEntries(changes)));
73
- setDraft({});
74
- onClose();
75
- };
76
-
77
- return (
78
- <aside className="flex w-[22rem] shrink-0 flex-col border-l bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
79
- <header className="flex items-center gap-3 border-b px-5 py-4 border-[rgb(var(--ec-page-border))]">
80
- <Icon size={22} className={`shrink-0 ${iconClassName}`} />
81
- <h2 className="min-w-0 flex-1 truncate text-lg font-semibold">{label}</h2>
82
- <button
83
- type="button"
84
- onClick={onClose}
85
- title="Close"
86
- aria-label="Close"
87
- className="shrink-0 rounded-md p-1 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)] hover:text-[rgb(var(--ec-icon-hover))]"
88
- >
89
- <X size={18} />
90
- </button>
91
- </header>
92
-
93
- <form
94
- onSubmit={save}
95
- // ⌘/Ctrl + Enter saves from any field (Enter alone adds a line to the summary)
96
- onKeyDown={(event) => (event.metaKey || event.ctrlKey) && event.key === 'Enter' && save(event)}
97
- className="flex min-h-0 flex-1 flex-col"
98
- >
99
- <div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-5">
100
- {link && (
101
- <div className="space-y-2 rounded-lg border p-3 text-xs bg-[rgb(var(--ec-accent-subtle)/0.5)] border-[rgb(var(--ec-accent)/0.25)]">
102
- <p className="flex items-center gap-1.5 font-semibold text-[rgb(var(--ec-page-text))]">
103
- <Library size={14} className="text-[rgb(var(--ec-accent))]" />
104
- From your catalog
105
- </p>
106
- <p className="text-[rgb(var(--ec-page-text-muted))]">
107
- Its name, version and summary come from the catalog: change them there and they're kept in sync here.
108
- </p>
109
- <a
110
- href={link.url}
111
- target="_blank"
112
- rel="noreferrer"
113
- className="inline-flex items-center gap-1 font-medium text-[rgb(var(--ec-accent))] hover:underline"
114
- >
115
- Open in catalog <ExternalLink size={12} />
116
- </a>
117
- </div>
118
- )}
119
-
120
- <FormField label="Name" hint={NAME_HINTS[node.type ?? ''] ?? `The name of the ${noun}.`} required={!link}>
121
- <input
122
- autoFocus={!link}
123
- value={value('name')}
124
- readOnly={!!link}
125
- onChange={edit('name')}
126
- placeholder={`Name this ${noun}`}
127
- className={inputClass}
128
- />
129
- </FormField>
130
- {versioned && (
131
- <FormField label="Version" hint="The version number (e.g., 1.0.0)." required={!link}>
132
- <input
133
- value={version}
134
- readOnly={!!link}
135
- onChange={edit('version')}
136
- placeholder="0.0.1"
137
- spellCheck={false}
138
- className={inputClass}
139
- />
140
- {!link && version && !LOOKS_LIKE_A_VERSION.test(version) && (
141
- <p className={`text-xs ${STATUS.warning.text}`}>Versions are usually like 1.0.0</p>
142
- )}
143
- </FormField>
144
- )}
145
- <FormField label="Summary" hint={`A brief summary of the ${noun}.`}>
146
- <textarea
147
- rows={5}
148
- value={value('summary')}
149
- readOnly={!!link}
150
- onChange={edit('summary')}
151
- placeholder={`What does this ${noun} do?`}
152
- className={`${inputClass} resize-y`}
153
- />
154
- </FormField>
155
- </div>
156
-
157
- <footer className="flex items-center gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
158
- <button
159
- type="button"
160
- onClick={() => onDelete([node.id])}
161
- title={`Delete ${noun}`}
162
- aria-label={`Delete ${noun}`}
163
- className={`mr-auto rounded-lg p-2 ${STATUS.danger.text} ${STATUS.danger.hover}`}
164
- >
165
- <Trash2 size={16} />
166
- </button>
167
- <button
168
- type="button"
169
- onClick={onClose}
170
- className={`${buttonClass} border border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)]`}
171
- >
172
- {link ? 'Close' : 'Cancel'}
173
- </button>
174
- {!link && (
175
- <button
176
- type="submit"
177
- disabled={!canSave}
178
- className={`${buttonClass} bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))] disabled:cursor-not-allowed disabled:opacity-50`}
179
- >
180
- Save changes
181
- </button>
182
- )}
183
- </footer>
184
- </form>
185
- </aside>
186
- );
187
- });
188
-
189
- function FormField({
190
- label,
191
- hint,
192
- required = false,
193
- children,
194
- }: {
195
- label: string;
196
- hint: string;
197
- required?: boolean;
198
- children: ReactNode;
199
- }) {
200
- return (
201
- <label className="block space-y-2">
202
- <span className="block">
203
- <span className="block text-sm font-semibold text-[rgb(var(--ec-page-text))]">
204
- {label}
205
- {required && <span className={`ml-0.5 ${STATUS.danger.text}`}>*</span>}
206
- </span>
207
- <span className="mt-0.5 block text-xs text-[rgb(var(--ec-page-text-muted))]">{hint}</span>
208
- </span>
209
- {children}
210
- </label>
211
- );
212
- }