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

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 (84) 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/{chunk-SHHO3JJI.js → chunk-3VBB2ZMP.js} +1 -1
  6. package/dist/{chunk-EBDTVZDW.js → chunk-BOKAG5XC.js} +4 -4
  7. package/dist/{chunk-XQXMSQA5.js → chunk-FARBIZ7Y.js} +1 -1
  8. package/dist/{chunk-43AEF3JE.js → chunk-S7ODHVII.js} +1 -1
  9. package/dist/{chunk-YYMBYOQR.js → chunk-SJJKWZYZ.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +11 -11
  14. package/dist/federation/federate.js +4 -4
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  18. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  19. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  20. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  21. package/dist/runtime-dependencies/manifest.json +8 -0
  22. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  23. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  24. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  25. package/dist/runtime-dependencies/yjs.mjs +3 -0
  26. package/dist/utils/cli-logger.cjs +1 -1
  27. package/dist/utils/cli-logger.js +2 -2
  28. package/eventcatalog/astro.config.mjs +2 -0
  29. package/eventcatalog/integrations/studio-server.mjs +17 -0
  30. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
  31. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
  32. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  33. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
  34. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
  35. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
  36. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  37. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  38. package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
  39. package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
  40. package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
  41. package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
  42. package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
  43. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  44. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
  45. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
  46. package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
  47. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
  48. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  49. package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
  50. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
  51. package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
  52. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
  53. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
  54. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
  55. package/eventcatalog/src/features/studio/components/icons.ts +48 -0
  56. package/eventcatalog/src/features/studio/components/status.ts +22 -0
  57. package/eventcatalog/src/features/studio/edges.ts +54 -0
  58. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  59. package/eventcatalog/src/features/studio/grouping.ts +133 -0
  60. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  61. package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
  62. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
  63. package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
  64. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
  65. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
  66. package/eventcatalog/src/features/studio/layout.ts +39 -0
  67. package/eventcatalog/src/features/studio/levels.ts +216 -0
  68. package/eventcatalog/src/features/studio/node-types.ts +173 -0
  69. package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
  70. package/eventcatalog/src/features/studio/pages/index.astro +8 -0
  71. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  72. package/eventcatalog/src/features/studio/placement.ts +197 -0
  73. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
  74. package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
  75. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  76. package/eventcatalog/src/features/studio/studio.css +89 -0
  77. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  78. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
  79. package/eventcatalog/src/utils/feature.ts +4 -0
  80. package/package.json +11 -4
  81. package/eventcatalog/public/studio-bg.png +0 -0
  82. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  83. package/eventcatalog/src/pages/studio.astro +0 -316
  84. package/dist/{chunk-ZEY2XUJN.js → chunk-AYNZXUB5.js} +7 -7
@@ -0,0 +1,145 @@
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
+ 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
+ }
@@ -0,0 +1,58 @@
1
+ import { useState } from 'react';
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
+ export default function JoinForm({ onJoin }: { onJoin: (name: string) => void }) {
28
+ const [draft, setDraft] = useState('');
29
+
30
+ return (
31
+ <form
32
+ onSubmit={(e) => {
33
+ e.preventDefault();
34
+ if (draft.trim()) onJoin(draft.trim());
35
+ }}
36
+ className="mx-auto mt-20 max-w-sm space-y-4 rounded-lg border p-6 bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
37
+ >
38
+ <div>
39
+ <h1 className="text-lg font-semibold">Join this canvas</h1>
40
+ <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">What should others call you?</p>
41
+ </div>
42
+ <input
43
+ autoFocus
44
+ value={draft}
45
+ onChange={(e) => setDraft(e.target.value)}
46
+ placeholder="Your name"
47
+ className="w-full rounded-md px-3 py-2 border bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))]"
48
+ />
49
+ <button
50
+ type="submit"
51
+ disabled={!draft.trim()}
52
+ className="w-full rounded-md px-3 py-2 text-sm disabled:opacity-50 bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))]"
53
+ >
54
+ Join
55
+ </button>
56
+ </form>
57
+ );
58
+ }
@@ -0,0 +1,218 @@
1
+ import { memo, useMemo, useState, type DragEvent } from 'react';
2
+ import { Check, Plus, Search } from 'lucide-react';
3
+ import type { CatalogResource } from '../catalog-resources';
4
+ import { NODE_CATEGORIES, nodeDefinitions } from '../node-types';
5
+ import type { Thread } from '../hooks/use-comments';
6
+ import { CommentList } from './Comments';
7
+ import { CATALOG_GROUPS, NODE_ICONS } from './icons';
8
+ import { STATUS } from './status';
9
+
10
+ /** What's dragged from the left panel onto the canvas: a component's type, or a catalog resource's key */
11
+ export const COMPONENT_DRAG_TYPE = 'application/eventcatalog-studio';
12
+ export const CATALOG_DRAG_TYPE = 'application/eventcatalog-studio-resource';
13
+
14
+ /** A catalog group shows this many resources until it's expanded, so big catalogs stay quick */
15
+ const GROUP_PREVIEW_SIZE = 50;
16
+
17
+ const itemClass =
18
+ 'group flex cursor-grab items-center justify-between gap-2 rounded-md border px-2.5 py-2 bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))] hover:border-[rgb(var(--ec-accent))]';
19
+ const addButtonClass =
20
+ 'shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-accent))]';
21
+ const headingClass = 'px-1 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]';
22
+
23
+ const startDrag = (type: string, value: string) => (event: DragEvent) => {
24
+ event.dataTransfer.setData(type, value);
25
+ event.dataTransfer.effectAllowed = 'move';
26
+ };
27
+
28
+ type Tab = 'components' | 'catalog' | 'comments';
29
+
30
+ /** Components and catalog resources to drag onto the canvas, and the comments on it */
31
+ export default memo(function LeftPanel({
32
+ resources,
33
+ keysOnCanvas,
34
+ onAddComponent,
35
+ onAddResource,
36
+ threads,
37
+ showResolved,
38
+ onToggleResolved,
39
+ onOpenThread,
40
+ }: {
41
+ resources: CatalogResource[];
42
+ /** The catalog resources on the canvas */
43
+ keysOnCanvas: Set<string>;
44
+ onAddComponent: (type: string) => void;
45
+ onAddResource: (key: string) => void;
46
+ threads: Thread[];
47
+ showResolved: boolean;
48
+ onToggleResolved: () => void;
49
+ onOpenThread: (thread: Thread) => void;
50
+ }) {
51
+ const [tab, setTab] = useState<Tab>('components');
52
+ const openThreads = threads.filter((thread) => !thread.resolved).length;
53
+ const tabClass = (active: boolean) =>
54
+ `flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-1.5 py-1 text-xs font-medium ${
55
+ active ? 'bg-[rgb(var(--ec-page-bg))] shadow-sm' : 'text-[rgb(var(--ec-page-text-muted))]'
56
+ }`;
57
+
58
+ return (
59
+ <aside className="flex w-72 shrink-0 flex-col border-r bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
60
+ <div className="m-3 flex gap-1 rounded-lg p-1 bg-[rgb(var(--ec-page-border)/0.5)]">
61
+ <button className={tabClass(tab === 'components')} onClick={() => setTab('components')}>
62
+ Components
63
+ </button>
64
+ <button className={tabClass(tab === 'catalog')} onClick={() => setTab('catalog')}>
65
+ Catalog
66
+ </button>
67
+ <button className={tabClass(tab === 'comments')} onClick={() => setTab('comments')}>
68
+ Comments
69
+ {openThreads > 0 && (
70
+ <span className="rounded-full px-1.5 text-[10px] leading-4 bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))]">
71
+ {openThreads}
72
+ </span>
73
+ )}
74
+ </button>
75
+ </div>
76
+ {tab === 'components' && <ComponentList onAdd={onAddComponent} />}
77
+ {tab === 'catalog' && <CatalogList resources={resources} keysOnCanvas={keysOnCanvas} onAdd={onAddResource} />}
78
+ {tab === 'comments' && (
79
+ <CommentList threads={threads} showResolved={showResolved} onToggleResolved={onToggleResolved} onOpen={onOpenThread} />
80
+ )}
81
+ </aside>
82
+ );
83
+ });
84
+
85
+ const listedDefinitions = NODE_CATEGORIES.map((category) => ({
86
+ ...category,
87
+ definitions: nodeDefinitions.filter((definition) => definition.category === category.id && !definition.unlisted),
88
+ }));
89
+
90
+ const ComponentList = memo(function ComponentList({ onAdd }: { onAdd: (type: string) => void }) {
91
+ return (
92
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-3 pb-3">
93
+ {listedDefinitions.map((category) => (
94
+ <section key={category.id} className="space-y-1.5">
95
+ <h3 className={headingClass}>{category.label}</h3>
96
+ {category.definitions.map(({ type, label }) => {
97
+ const { icon: Icon, className } = NODE_ICONS[type];
98
+ return (
99
+ <div key={type} draggable onDragStart={startDrag(COMPONENT_DRAG_TYPE, type)} className={itemClass}>
100
+ <span className="flex items-center gap-2 text-xs font-medium">
101
+ <Icon size={16} className={className} />
102
+ {label}
103
+ </span>
104
+ <button onClick={() => onAdd(type)} title={`Add ${label}`} className={addButtonClass}>
105
+ <Plus size={14} />
106
+ </button>
107
+ </div>
108
+ );
109
+ })}
110
+ </section>
111
+ ))}
112
+ </div>
113
+ );
114
+ });
115
+
116
+ const CatalogList = memo(function CatalogList({
117
+ resources,
118
+ keysOnCanvas,
119
+ onAdd,
120
+ }: {
121
+ resources: CatalogResource[];
122
+ keysOnCanvas: Set<string>;
123
+ onAdd: (key: string) => void;
124
+ }) {
125
+ const [search, setSearch] = useState('');
126
+ const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
127
+ const groups = useMemo(() => {
128
+ const term = search.trim().toLowerCase();
129
+ const matches = term
130
+ ? resources.filter((resource) => resource.name.toLowerCase().includes(term) || resource.id.toLowerCase().includes(term))
131
+ : resources;
132
+ return CATALOG_GROUPS.map((group) => ({
133
+ ...group,
134
+ items: matches.filter((resource) => resource.collection === group.collection),
135
+ })).filter((group) => group.items.length > 0);
136
+ }, [resources, search]);
137
+
138
+ return (
139
+ <div className="flex min-h-0 flex-1 flex-col">
140
+ <div className="relative mx-3 mb-2">
141
+ <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[rgb(var(--ec-icon-color))]" />
142
+ <input
143
+ value={search}
144
+ onChange={(e) => setSearch(e.target.value)}
145
+ placeholder="Search the catalog"
146
+ className="w-full rounded-md border py-1.5 pl-8 pr-2 text-xs bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))]"
147
+ />
148
+ </div>
149
+ <div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-3 pb-3">
150
+ {groups.length === 0 && (
151
+ <p className="py-6 text-center text-xs text-[rgb(var(--ec-page-text-muted))]">No resources found.</p>
152
+ )}
153
+ {groups.map(({ collection, label, icon, className, items }) => {
154
+ const shown = expanded.has(collection) ? items : items.slice(0, GROUP_PREVIEW_SIZE);
155
+ return (
156
+ <section key={collection} className="space-y-1.5">
157
+ <h3 className={headingClass}>
158
+ {label} <span className="font-normal">({items.length})</span>
159
+ </h3>
160
+ {shown.map((resource) => (
161
+ <CatalogItem
162
+ key={resource.key}
163
+ resource={resource}
164
+ icon={icon}
165
+ iconClassName={className}
166
+ onCanvas={keysOnCanvas.has(resource.key)}
167
+ onAdd={onAdd}
168
+ />
169
+ ))}
170
+ {shown.length < items.length && (
171
+ <button
172
+ onClick={() => setExpanded((current) => new Set(current).add(collection))}
173
+ className="w-full rounded-md px-2 py-1.5 text-xs font-medium text-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent-subtle))]"
174
+ >
175
+ Show all {items.length} {label.toLowerCase()}
176
+ </button>
177
+ )}
178
+ </section>
179
+ );
180
+ })}
181
+ </div>
182
+ </div>
183
+ );
184
+ });
185
+
186
+ const CatalogItem = memo(function CatalogItem({
187
+ resource,
188
+ icon: Icon,
189
+ iconClassName,
190
+ onCanvas,
191
+ onAdd,
192
+ }: {
193
+ resource: CatalogResource;
194
+ icon: (typeof CATALOG_GROUPS)[number]['icon'];
195
+ iconClassName: string;
196
+ onCanvas: boolean;
197
+ onAdd: (key: string) => void;
198
+ }) {
199
+ return (
200
+ <div draggable onDragStart={startDrag(CATALOG_DRAG_TYPE, resource.key)} title={resource.summary} className={itemClass}>
201
+ <span className="flex min-w-0 items-center gap-2 text-xs font-medium">
202
+ <Icon size={16} className={`shrink-0 ${iconClassName}`} />
203
+ <span className="truncate">{resource.name}</span>
204
+ <span className="shrink-0 text-[10px] text-[rgb(var(--ec-page-text-muted))]">v{resource.version}</span>
205
+ </span>
206
+ <span className="flex shrink-0 items-center gap-1">
207
+ {onCanvas && (
208
+ <span title="On the canvas">
209
+ <Check size={14} className={STATUS.success.text} />
210
+ </span>
211
+ )}
212
+ <button onClick={() => onAdd(resource.key)} title={`Add ${resource.name}`} className={addButtonClass}>
213
+ <Plus size={14} />
214
+ </button>
215
+ </span>
216
+ </div>
217
+ );
218
+ });