@eventcatalog/core 5.0.0-beta.3 → 5.0.0-beta.30

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 (277) hide show
  1. package/LICENSE +234 -134
  2. package/README.md +5 -5
  3. package/dist/analytics/analytics.cjs +18 -3
  4. package/dist/analytics/analytics.d.cts +1 -1
  5. package/dist/analytics/analytics.d.ts +1 -1
  6. package/dist/analytics/analytics.js +3 -2
  7. package/dist/analytics/log-build.cjs +160 -77
  8. package/dist/analytics/log-build.d.cts +2 -2
  9. package/dist/analytics/log-build.d.ts +2 -2
  10. package/dist/analytics/log-build.js +7 -5
  11. package/dist/analytics/studio-usage.cjs +90 -0
  12. package/dist/analytics/studio-usage.d.cts +5 -0
  13. package/dist/analytics/studio-usage.d.ts +5 -0
  14. package/dist/analytics/studio-usage.js +10 -0
  15. package/dist/analytics/telemetry.cjs +42 -0
  16. package/dist/analytics/telemetry.d.cts +20 -0
  17. package/dist/analytics/telemetry.d.ts +20 -0
  18. package/dist/analytics/telemetry.js +8 -0
  19. package/dist/catalog-runtime.cjs +1 -1
  20. package/dist/{chunk-RFIXKATC.js → chunk-4G7FSRPN.js} +35 -42
  21. package/dist/{chunk-6OC46WQJ.js → chunk-55QETQZ3.js} +66 -35
  22. package/dist/{chunk-TQLRG35T.js → chunk-AD7CL772.js} +78 -45
  23. package/dist/chunk-CA6WCQ2T.js +54 -0
  24. package/dist/chunk-CPW6G62V.js +17 -0
  25. package/dist/{chunk-423S2KHN.js → chunk-FLAZ2JVN.js} +4 -2
  26. package/dist/{chunk-BH2F5WPN.js → chunk-GQI5UYXZ.js} +1 -1
  27. package/dist/{chunk-GYIVJXOJ.js → chunk-NA7OTBWW.js} +1 -1
  28. package/dist/{chunk-MDWB5HLO.js → chunk-NKNOXQ62.js} +1 -1
  29. package/dist/{chunk-3PKS2IIP.js → chunk-OX6TSVHY.js} +1 -1
  30. package/dist/{chunk-36RYTYT5.js → chunk-PD7KTSYN.js} +1 -1
  31. package/dist/chunk-S5GWAUG5.js +29 -0
  32. package/dist/{chunk-NDJOAITI.js → chunk-T3OROMAU.js} +9 -3
  33. package/dist/constants.cjs +1 -1
  34. package/dist/constants.js +1 -1
  35. package/dist/eventcatalog-config-file-utils.cjs +34 -41
  36. package/dist/eventcatalog-config-file-utils.d.cts +56 -58
  37. package/dist/eventcatalog-config-file-utils.d.ts +56 -58
  38. package/dist/eventcatalog-config-file-utils.js +1 -1
  39. package/dist/eventcatalog.cjs +531 -356
  40. package/dist/eventcatalog.config.d.cts +59 -1
  41. package/dist/eventcatalog.config.d.ts +59 -1
  42. package/dist/eventcatalog.js +63 -25
  43. package/dist/features.cjs +34 -41
  44. package/dist/features.js +2 -2
  45. package/dist/federation/federate.cjs +37 -44
  46. package/dist/federation/federate.js +2 -2
  47. package/dist/generate.cjs +37 -42
  48. package/dist/generate.js +4 -4
  49. package/dist/resolve-catalog-dependencies.cjs +34 -41
  50. package/dist/resolve-catalog-dependencies.js +2 -2
  51. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  52. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  53. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  54. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  55. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  56. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  57. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  58. package/dist/runtime-dependencies/htmlparser2.mjs +3 -0
  59. package/dist/runtime-dependencies/manifest.json +13 -1
  60. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  61. package/dist/runtime-dependencies/react-syntax-highlighter__dist__cjs__create-element.js.mjs +3 -0
  62. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  63. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  64. package/dist/runtime-dependencies/yjs.mjs +3 -0
  65. package/dist/utils/child-process-signals.cjs +53 -0
  66. package/dist/utils/child-process-signals.d.cts +19 -0
  67. package/dist/utils/child-process-signals.d.ts +19 -0
  68. package/dist/utils/child-process-signals.js +6 -0
  69. package/dist/utils/cli-logger.cjs +1 -1
  70. package/dist/utils/cli-logger.js +2 -2
  71. package/dist/utils/license-status.cjs +78 -45
  72. package/dist/utils/license-status.d.cts +15 -5
  73. package/dist/utils/license-status.d.ts +15 -5
  74. package/dist/utils/license-status.js +1 -1
  75. package/eventcatalog/astro.config.mjs +23 -1
  76. package/eventcatalog/integrations/config-loader.mjs +56 -58
  77. package/eventcatalog/integrations/skip-startup-reload.mjs +31 -0
  78. package/eventcatalog/integrations/stable-dependency-order.mjs +29 -0
  79. package/eventcatalog/integrations/storage.mjs +20 -0
  80. package/eventcatalog/integrations/studio-server.mjs +32 -0
  81. package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +39 -0
  82. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  83. package/eventcatalog/src/components/Header.astro +3 -3
  84. package/eventcatalog/src/components/Homepage/CatalogDashboard.astro +342 -0
  85. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +60 -4
  86. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  87. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  88. package/eventcatalog/src/components/SchemaExplorer/ApiContentViewer.tsx +2 -0
  89. package/eventcatalog/src/components/SchemaExplorer/ExamplesViewer.tsx +2 -0
  90. package/eventcatalog/src/components/SchemaExplorer/SchemaCodeModal.tsx +2 -0
  91. package/eventcatalog/src/components/SchemaExplorer/SchemaContentViewer.tsx +2 -0
  92. package/eventcatalog/src/components/Search/SearchDataLoader.astro +16 -28
  93. package/eventcatalog/src/components/Search/SearchModal.tsx +2 -10
  94. package/eventcatalog/src/components/Search/search-utils.ts +26 -0
  95. package/eventcatalog/src/components/Seo.astro +5 -1
  96. package/eventcatalog/src/components/Settings/AssistantSettingsForm.tsx +53 -48
  97. package/eventcatalog/src/components/Settings/GeneralSettingsForm.tsx +183 -178
  98. package/eventcatalog/src/components/Settings/LicenseSettings.tsx +140 -31
  99. package/eventcatalog/src/components/Settings/LlmAccessSettingsForm.tsx +40 -35
  100. package/eventcatalog/src/components/Settings/LogoUpload.tsx +1 -1
  101. package/eventcatalog/src/components/Settings/McpSettingsForm.tsx +9 -8
  102. package/eventcatalog/src/components/Settings/Row.tsx +7 -4
  103. package/eventcatalog/src/components/Settings/SettingsShared.tsx +3 -3
  104. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +53 -83
  105. package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +2 -0
  106. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +131 -0
  107. package/eventcatalog/src/components/SideNav/SideNav.astro +9 -1
  108. package/eventcatalog/src/components/TrialBadge.astro +20 -9
  109. package/eventcatalog/src/content.config.ts +19 -2
  110. package/eventcatalog/src/features/auth/error.astro +4 -2
  111. package/eventcatalog/src/features/auth/login.astro +2 -1
  112. package/eventcatalog/src/features/auth/middleware/middleware-auth.ts +10 -0
  113. package/eventcatalog/src/features/auth/unauthorized.astro +1 -1
  114. package/eventcatalog/src/features/fields/pages/fields.astro +1 -1
  115. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +34 -1
  116. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  117. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +47 -0
  118. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +24 -0
  119. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +278 -0
  120. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +272 -0
  121. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +65 -65
  122. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  123. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  124. package/eventcatalog/src/features/mcp/mcp-server.ts +36 -5
  125. package/eventcatalog/src/features/server/start.ts +25 -0
  126. package/eventcatalog/src/features/storage/database.ts +88 -0
  127. package/eventcatalog/src/features/storage/migrations.ts +85 -0
  128. package/eventcatalog/src/features/storage/sql/database.sql +5 -0
  129. package/eventcatalog/src/features/storage/sql/migrations/0001-studio-canvases.sql +12 -0
  130. package/eventcatalog/src/features/storage/sql/migrations.sql +15 -0
  131. package/eventcatalog/src/features/storage/sql.ts +28 -0
  132. package/eventcatalog/src/features/studio/agent-choreography.ts +384 -0
  133. package/eventcatalog/src/features/studio/agents.ts +28 -0
  134. package/eventcatalog/src/features/studio/api/canvases-api.ts +219 -0
  135. package/eventcatalog/src/features/studio/api/canvases.ts +25 -0
  136. package/eventcatalog/src/features/studio/api/client.ts +40 -0
  137. package/eventcatalog/src/features/studio/canvas-actions.ts +540 -0
  138. package/eventcatalog/src/features/studio/canvas-preview.ts +52 -0
  139. package/eventcatalog/src/features/studio/catalog-resources.ts +155 -0
  140. package/eventcatalog/src/features/studio/catalog.ts +173 -0
  141. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +518 -0
  142. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +118 -0
  143. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +434 -0
  144. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +143 -0
  145. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +65 -0
  146. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  147. package/eventcatalog/src/features/studio/components/JoinForm.tsx +109 -0
  148. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +206 -0
  149. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  150. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  151. package/eventcatalog/src/features/studio/components/Presence.tsx +293 -0
  152. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +396 -0
  153. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  154. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +2091 -0
  155. package/eventcatalog/src/features/studio/components/StudioNotice.astro +24 -0
  156. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  157. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  158. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +63 -0
  159. package/eventcatalog/src/features/studio/hooks/presence-store.ts +119 -0
  160. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +353 -0
  161. package/eventcatalog/src/features/studio/hooks/use-comments.ts +54 -0
  162. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +109 -0
  163. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +782 -0
  164. package/eventcatalog/src/features/studio/identity.ts +59 -0
  165. package/eventcatalog/src/features/studio/limits.ts +2 -0
  166. package/eventcatalog/src/features/studio/pages/[id].astro +69 -0
  167. package/eventcatalog/src/features/studio/pages/index.astro +784 -0
  168. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  169. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +495 -0
  170. package/eventcatalog/src/features/studio/server/runtime.ts +498 -0
  171. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  172. package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
  173. package/eventcatalog/src/features/studio/server/storage.ts +90 -0
  174. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  175. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  176. package/eventcatalog/src/layouts/BaseLayout.astro +4 -4
  177. package/eventcatalog/src/layouts/SettingsLayout.astro +2 -2
  178. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +13 -5
  179. package/eventcatalog/src/pages/_index.astro +5 -342
  180. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +2 -3
  181. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +0 -7
  182. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +3 -1
  183. package/eventcatalog/src/pages/diagrams/[id]/[version]/_index.data.ts +0 -7
  184. package/eventcatalog/src/pages/diagrams/[id]/[version]/embed.astro +1 -0
  185. package/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro +1 -0
  186. package/eventcatalog/src/pages/directory/[type]/_index.data.ts +5 -7
  187. package/eventcatalog/src/pages/directory/[type]/index.astro +3 -1
  188. package/eventcatalog/src/pages/discover/[type]/_index.data.ts +5 -7
  189. package/eventcatalog/src/pages/discover/[type]/index.astro +3 -1
  190. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/_index.data.ts +0 -7
  191. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +1 -0
  192. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/_index.data.ts +0 -7
  193. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +1 -0
  194. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts +0 -7
  195. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/asyncapi/[filename].astro +3 -1
  196. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/asyncapi/_[filename].data.ts +0 -7
  197. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +0 -7
  198. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro +1 -0
  199. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/examples/[...filename].astro +1 -0
  200. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/examples/_[...filename].data.ts +0 -4
  201. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/field-lineage/_index.data.ts +0 -7
  202. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/field-lineage/index.astro +1 -0
  203. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +3 -1
  204. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/_[filename].data.ts +0 -7
  205. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +1 -0
  206. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +2 -3
  207. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +0 -7
  208. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +1 -0
  209. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/spec/[filename].astro +1 -0
  210. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/spec/_[filename].data.ts +0 -7
  211. package/eventcatalog/src/pages/docs/[type]/[id]/_index.data.ts +0 -7
  212. package/eventcatalog/src/pages/docs/[type]/[id]/index.astro +2 -1
  213. package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/_index.data.ts +0 -7
  214. package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/index.astro +1 -0
  215. package/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts +0 -7
  216. package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +1 -0
  217. package/eventcatalog/src/pages/docs/custom/[...path]/_index.data.ts +0 -7
  218. package/eventcatalog/src/pages/docs/custom/[...path]/index.astro +1 -0
  219. package/eventcatalog/src/pages/docs/services/[id]/[docType]/[docId]/index.astro +1 -1
  220. package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +1 -0
  221. package/eventcatalog/src/pages/docs/services/[id]/asyncapi/[filename].astro +1 -1
  222. package/eventcatalog/src/pages/docs/services/[id]/changelog/index.astro +1 -1
  223. package/eventcatalog/src/pages/docs/services/[id]/graphql/[filename].astro +1 -1
  224. package/eventcatalog/src/pages/docs/services/[id]/spec/[filename].astro +1 -1
  225. package/eventcatalog/src/pages/docs/teams/[id]/_index.data.ts +0 -7
  226. package/eventcatalog/src/pages/docs/teams/[id]/index.astro +1 -0
  227. package/eventcatalog/src/pages/docs/users/[id]/_index.data.ts +0 -7
  228. package/eventcatalog/src/pages/docs/users/[id]/index.astro +1 -0
  229. package/eventcatalog/src/pages/index.astro +4 -2
  230. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts +0 -7
  231. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +1 -0
  232. package/eventcatalog/src/pages/schemas/explorer/_index.data.ts +0 -7
  233. package/eventcatalog/src/pages/schemas/explorer/index.astro +4 -2
  234. package/eventcatalog/src/pages/settings/license.astro +9 -1
  235. package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/_index.data.ts +0 -7
  236. package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/index.astro +1 -0
  237. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/_index.data.ts +0 -7
  238. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/data/_index.data.ts +0 -7
  239. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/data/index.astro +1 -0
  240. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/entity-map/_index.data.ts +0 -7
  241. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/entity-map/index.astro +1 -0
  242. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/index.astro +1 -0
  243. package/eventcatalog/src/pages/visualiser/[type]/[id]/_index.data.ts +38 -0
  244. package/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro +18 -41
  245. package/eventcatalog/src/pages/visualiser/designs/[id]/_index.data.ts +0 -7
  246. package/eventcatalog/src/pages/visualiser/designs/[id]/index.astro +2 -1
  247. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/_index.data.ts +0 -7
  248. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/index.astro +1 -0
  249. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/index.astro +1 -0
  250. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/_index.data.ts +0 -7
  251. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/index.astro +1 -0
  252. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/_index.data.ts +0 -7
  253. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/index.astro +1 -0
  254. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/index.astro +1 -0
  255. package/eventcatalog/src/types/react-syntax-highlighter.d.ts +10 -0
  256. package/eventcatalog/src/utils/collections/util.ts +8 -4
  257. package/eventcatalog/src/utils/feature.ts +8 -0
  258. package/eventcatalog/src/utils/git.ts +22 -5
  259. package/eventcatalog/src/utils/homepage-dashboard.ts +143 -0
  260. package/eventcatalog/src/utils/license.ts +1 -1
  261. package/eventcatalog/src/utils/link-validation.ts +25 -19
  262. package/eventcatalog/src/utils/node-graphs/domain-entity-map.ts +3 -1
  263. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  264. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  265. package/eventcatalog/src/utils/node-graphs/field-node-graph.ts +4 -2
  266. package/eventcatalog/src/utils/node-graphs/layout-node-graph.ts +20 -1
  267. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  268. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  269. package/eventcatalog/src/utils/page-loaders/hybrid-page.ts +4 -15
  270. package/eventcatalog/src/utils/page-title.ts +15 -0
  271. package/eventcatalog/src/utils/syntax-highlighter-renderer.ts +64 -0
  272. package/eventcatalog/src/utils/trial.ts +14 -1
  273. package/package.json +19 -13
  274. package/dist/runtime-dependencies/shelljs.mjs +0 -3
  275. package/eventcatalog/public/studio-bg.png +0 -0
  276. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  277. package/eventcatalog/src/pages/studio.astro +0 -316
@@ -0,0 +1,518 @@
1
+ import { memo, useEffect, useRef, useState, type ReactNode } from 'react';
2
+ import {
3
+ CircleHelp,
4
+ Download,
5
+ FolderOpen,
6
+ ImageDown,
7
+ Link2,
8
+ MoreVertical,
9
+ Pencil,
10
+ Plug,
11
+ Plus,
12
+ Share2,
13
+ Trash2,
14
+ Users,
15
+ } from 'lucide-react';
16
+ import {
17
+ DIAGRAM_MENU,
18
+ DIAGRAM_MENU_BUTTON_ICON,
19
+ DIAGRAM_MENU_ITEM,
20
+ DIAGRAM_MENU_ITEM_ICON,
21
+ DIAGRAM_MENU_SEPARATOR,
22
+ DIAGRAM_MENU_TITLE,
23
+ } from '@eventcatalog/visualiser';
24
+ import { usePeople, type Peer, type PresenceStore } from '../hooks/presence-store';
25
+ import { MAX_NAME_LENGTH, PERSON_COLORS } from '../identity';
26
+ import type { WebMcpStatus } from '../hooks/use-canvas-webmcp';
27
+ import type { Status, Transport } from '../hooks/use-studio-flow';
28
+ import { type CanvasStatus, type StatusChange, STATUS, Picture, AgentMark } from '@eventcatalog/visualiser/studio';
29
+ import CanvasStatusMenu from './CanvasStatusMenu';
30
+
31
+ /** The dot showing whether the canvas is connected */
32
+ const CONNECTION_DOT: Record<Status, string> = {
33
+ connected: STATUS.success.dot,
34
+ connecting: STATUS.warning.dot,
35
+ disconnected: STATUS.danger.dot,
36
+ };
37
+
38
+ /**
39
+ * The canvas's header, as Studio on the web's top bar: on the left the canvas (its title, its status and a menu), on
40
+ * the right who's here, connecting an agent and sharing, each a small card over the canvas. Its menus look like the
41
+ * visualiser's (`DIAGRAM_MENU_*`).
42
+ */
43
+
44
+ const barClass =
45
+ 'pointer-events-auto flex h-[38px] items-center rounded-[9px] border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-sm';
46
+ // The header lets the pointer through to the canvas around its bars, so its menus take it back
47
+ const popoverClass = 'pointer-events-auto absolute top-full mt-2';
48
+ const menuClass = `${popoverClass} ${DIAGRAM_MENU}`;
49
+ const menuItemClass = `${DIAGRAM_MENU_ITEM} w-full text-left`;
50
+ const MAX_AVATARS = 4;
51
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
52
+ const MOD = isMac ? 'Cmd' : 'Ctrl';
53
+ const itemClass =
54
+ 'flex h-[34px] w-full cursor-pointer items-center gap-3 rounded-[7px] px-2 text-left text-sm outline-none hover:bg-[rgb(var(--ec-content-hover))]';
55
+ const itemIconClass = 'flex flex-none [&_svg]:h-[17px] [&_svg]:w-[17px] [&_svg]:stroke-[1.75]';
56
+
57
+ /** Closes a popover on outside clicks and Escape */
58
+ function usePopover() {
59
+ const [open, setOpen] = useState(false);
60
+ const ref = useRef<HTMLDivElement>(null);
61
+ useEffect(() => {
62
+ if (!open) return;
63
+ const onPointerDown = (event: PointerEvent) => {
64
+ if (!ref.current?.contains(event.target as globalThis.Node)) setOpen(false);
65
+ };
66
+ const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && setOpen(false);
67
+ window.addEventListener('pointerdown', onPointerDown);
68
+ window.addEventListener('keydown', onKeyDown);
69
+ return () => {
70
+ window.removeEventListener('pointerdown', onPointerDown);
71
+ window.removeEventListener('keydown', onKeyDown);
72
+ };
73
+ }, [open]);
74
+ return { open, setOpen, ref };
75
+ }
76
+
77
+ function Avatar({ peer, size = 28, children }: { peer: Peer; size?: number; children?: ReactNode }) {
78
+ return (
79
+ <span
80
+ className="relative flex items-center justify-center rounded-full text-[11px] font-semibold text-white ring-2 ring-[rgb(var(--ec-card-bg))]"
81
+ style={{ backgroundColor: peer.color, width: size, height: size }}
82
+ >
83
+ {children ?? (peer.agent ? <AgentMark name={peer.name} size={14} /> : peer.name.slice(0, 2).toUpperCase())}
84
+ {!children && <Picture src={peer.picture} />}
85
+ </span>
86
+ );
87
+ }
88
+
89
+ function CanvasMenu({
90
+ title,
91
+ status,
92
+ transport,
93
+ onRetitle,
94
+ shareUrl,
95
+ newCanvasUrl,
96
+ onConnectAgent,
97
+ webMcp,
98
+ agentsHere,
99
+ onDelete,
100
+ onOpenFile,
101
+ onSaveToFile,
102
+ onExportImage,
103
+ onLiveCollaboration,
104
+ onHelp,
105
+ children,
106
+ }: {
107
+ /** Opens a .eventcatalog file as a new canvas (not offered when not given, e.g. inside a chat) */
108
+ onOpenFile?: () => void;
109
+ onSaveToFile: () => void;
110
+ onExportImage: () => void;
111
+ onLiveCollaboration: () => void;
112
+ onHelp: () => void;
113
+ title?: string;
114
+ status: Status;
115
+ transport: Transport;
116
+ onRetitle: (title: string) => void;
117
+ shareUrl?: string;
118
+ newCanvasUrl?: string;
119
+ onConnectAgent: () => void;
120
+ webMcp: WebMcpStatus;
121
+ /** An AI agent is on the canvas */
122
+ agentsHere: boolean;
123
+ /** Asks to delete the canvas (not offered when not given, e.g. inside a chat) */
124
+ onDelete?: () => void;
125
+ /** Shown after the title (the canvas's status) */
126
+ children?: ReactNode;
127
+ }) {
128
+ const menu = usePopover();
129
+ const titleInput = useRef<HTMLInputElement>(null);
130
+ const [draft, setDraft] = useState<string | null>(null);
131
+ const statusLabel = {
132
+ connected: transport === 'tools' ? 'Connected through the chat' : 'Connected',
133
+ connecting: 'Connecting…',
134
+ disconnected: 'Offline, reconnecting…',
135
+ }[status];
136
+ const statusColor = CONNECTION_DOT[status];
137
+ /** A menu item, as Studio on the web's: its icon, what it does, and its shortcut (or something else on the right) */
138
+ const item = (icon: ReactNode, label: string, onSelect: () => void, shortcut?: ReactNode, className = '') => (
139
+ <button
140
+ type="button"
141
+ className={`${itemClass} ${className}`}
142
+ onClick={() => {
143
+ menu.setOpen(false);
144
+ onSelect();
145
+ }}
146
+ >
147
+ <span className={itemIconClass}>{icon}</span>
148
+ <span className="min-w-0 flex-1 truncate">{label}</span>
149
+ {typeof shortcut === 'string' ? (
150
+ <span className="flex-none text-xs font-normal text-[rgb(var(--ec-page-text-muted))]">{shortcut}</span>
151
+ ) : (
152
+ shortcut
153
+ )}
154
+ </button>
155
+ );
156
+
157
+ return (
158
+ <div ref={menu.ref} className="relative">
159
+ <div className={`${barClass} gap-1.5 pl-3 pr-1`}>
160
+ <span title={statusLabel} className={`h-2 w-2 shrink-0 rounded-full ${statusColor}`} />
161
+ <input
162
+ ref={titleInput}
163
+ aria-label="Canvas title"
164
+ value={draft ?? title ?? ''}
165
+ placeholder="Untitled canvas"
166
+ onChange={(e) => setDraft(e.target.value)}
167
+ onBlur={() => {
168
+ if (draft !== null && draft.trim() !== (title ?? '')) onRetitle(draft.trim());
169
+ setDraft(null);
170
+ }}
171
+ onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
172
+ size={Math.max(12, (draft ?? title ?? 'Untitled canvas').length)}
173
+ className={`${DIAGRAM_MENU_TITLE} max-w-64 truncate rounded-md bg-transparent px-1.5 py-1 placeholder:text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-border)/0.4)] focus:bg-[rgb(var(--ec-input-bg))] focus:outline-none`}
174
+ />
175
+ {children}
176
+ <button
177
+ aria-label="Canvas menu"
178
+ onClick={() => menu.setOpen((open) => !open)}
179
+ className="group rounded-md p-1 hover:bg-[rgb(var(--ec-accent-subtle))]"
180
+ >
181
+ <MoreVertical className={DIAGRAM_MENU_BUTTON_ICON} />
182
+ </button>
183
+ </div>
184
+ {menu.open && (
185
+ <div
186
+ className={`${popoverClass} left-0 z-50 w-72 rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] p-1.5 text-[rgb(var(--ec-page-text))] shadow-xl`}
187
+ >
188
+ {/* As Studio on the web's menu: files and images, people and agents, help */}
189
+ {onOpenFile && item(<FolderOpen />, 'Open…', onOpenFile, `${MOD}+O`)}
190
+ {item(<Download />, 'Save to file…', onSaveToFile, `${MOD}+S`)}
191
+ {item(<ImageDown />, 'Export image…', onExportImage, `${MOD}+Shift+E`)}
192
+ {item(<Users />, 'Live collaboration…', onLiveCollaboration)}
193
+ {item(
194
+ <Plug />,
195
+ agentsHere ? 'AI agent connected' : 'Connect your AI agent…',
196
+ onConnectAgent,
197
+ webMcp.state === 'on' || agentsHere ? (
198
+ <span className={`h-1.5 w-1.5 rounded-full ${STATUS.success.dot}`} />
199
+ ) : undefined
200
+ )}
201
+ {item(<CircleHelp />, 'Help', onHelp, '?')}
202
+ <div className="my-1.5 h-px bg-[rgb(var(--ec-page-border))]" />
203
+ {item(<Pencil />, 'Rename canvas', () => {
204
+ titleInput.current?.focus();
205
+ titleInput.current?.select();
206
+ })}
207
+ {item(<Link2 />, 'Copy link', () => void navigator.clipboard.writeText(shareUrl ?? window.location.href))}
208
+ {newCanvasUrl && (
209
+ <a className={itemClass} href={newCanvasUrl}>
210
+ <span className={itemIconClass}>
211
+ <Plus />
212
+ </span>
213
+ <span className="min-w-0 flex-1 truncate">New canvas</span>
214
+ </a>
215
+ )}
216
+ {onDelete && (
217
+ <>
218
+ <div className="my-1.5 h-px bg-[rgb(var(--ec-page-border))]" />
219
+ {item(<Trash2 />, 'Delete canvas', onDelete, undefined, `${STATUS.danger.text} ${STATUS.danger.hover}`)}
220
+ </>
221
+ )}
222
+ <div className="my-1.5 h-px bg-[rgb(var(--ec-page-border))]" />
223
+ <p className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
224
+ <span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
225
+ {statusLabel}
226
+ </p>
227
+ </div>
228
+ )}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ const initialsOf = (name: string) => {
234
+ const words = name.trim().split(/\s+/).filter(Boolean);
235
+ return (words.length > 1 ? words.map((word) => word[0]).join('') : name.trim()).slice(0, 2).toUpperCase();
236
+ };
237
+
238
+ /**
239
+ * Who you are here, top right (as Studio on the web's): your avatar, opening your name and colour to change. They're
240
+ * on your pointer and comments. Signed in, your name and picture are the ones you signed in with.
241
+ */
242
+ function ProfileMenu({
243
+ me,
244
+ onRename,
245
+ onRecolor,
246
+ }: {
247
+ me: { name: string; color: string; picture?: string; agent?: boolean };
248
+ /** Not given when you're signed in */
249
+ onRename?: (name: string) => void;
250
+ onRecolor: (color: string) => void;
251
+ }) {
252
+ const popover = usePopover();
253
+ const [draft, setDraft] = useState(me.name);
254
+ useEffect(() => setDraft(me.name), [me.name]);
255
+ const save = () => {
256
+ const next = draft.trim();
257
+ if (!next) return setDraft(me.name);
258
+ if (next !== me.name) onRename?.(next);
259
+ };
260
+ const avatar = (size: string, text: string) => (
261
+ <span
262
+ className={`relative flex ${size} flex-none items-center justify-center rounded-full text-[10px] font-semibold text-white`}
263
+ style={{ background: me.color }}
264
+ >
265
+ {initialsOf(text)}
266
+ <Picture src={me.picture} />
267
+ </span>
268
+ );
269
+ // Here as an AI agent (e.g. a chat's view, as the chat's agent): who you are is its logo, not yours to change
270
+ if (me.agent)
271
+ return (
272
+ <span title={`You're here as ${me.name} (AI agent)`} className={`${barClass} pointer-events-auto w-[38px] justify-center`}>
273
+ <span className="flex h-6 w-6 items-center justify-center rounded-full text-white" style={{ background: me.color }}>
274
+ <AgentMark name={me.name} size={14} />
275
+ </span>
276
+ </span>
277
+ );
278
+ return (
279
+ <div ref={popover.ref} className="pointer-events-auto relative">
280
+ <button
281
+ type="button"
282
+ title={onRename ? `${me.name}: change your name and colour` : `${me.name}: change your colour`}
283
+ onClick={() => popover.setOpen((open) => !open)}
284
+ className={`${barClass} w-[38px] justify-center hover:bg-[rgb(var(--ec-content-hover))]`}
285
+ >
286
+ {avatar('h-6 w-6', me.name)}
287
+ </button>
288
+ {popover.open && (
289
+ <div
290
+ className={`${popoverClass} right-0 z-[70] w-72 rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] p-4 text-[rgb(var(--ec-page-text))] shadow-xl`}
291
+ >
292
+ <div className="flex items-center gap-3">
293
+ {avatar('h-9 w-9 text-xs', draft || me.name)}
294
+ <div className="min-w-0">
295
+ <p className="truncate text-sm font-semibold">{draft || me.name}</p>
296
+ <p className="text-xs text-[rgb(var(--ec-page-text-muted))]">On your pointer and comments</p>
297
+ </div>
298
+ </div>
299
+ {onRename && (
300
+ <label className="mt-4 block text-xs font-medium text-[rgb(var(--ec-page-text-muted))]">
301
+ Your name
302
+ <input
303
+ value={draft}
304
+ maxLength={MAX_NAME_LENGTH}
305
+ onChange={(event) => setDraft(event.target.value)}
306
+ onBlur={save}
307
+ onKeyDown={(event) => event.key === 'Enter' && event.currentTarget.blur()}
308
+ className="mt-1.5 block h-9 w-full rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg))] px-3 text-sm text-[rgb(var(--ec-page-text))] outline-none focus:border-[rgb(var(--ec-accent))]"
309
+ />
310
+ </label>
311
+ )}
312
+ <div className="mt-3 text-xs font-medium text-[rgb(var(--ec-page-text-muted))]">
313
+ Your colour
314
+ <div role="radiogroup" aria-label="Your colour" className="mt-2 flex gap-2">
315
+ {PERSON_COLORS.map((color) => (
316
+ <button
317
+ key={color}
318
+ type="button"
319
+ role="radio"
320
+ aria-checked={color === me.color}
321
+ aria-label={color}
322
+ onClick={() => onRecolor(color)}
323
+ className={`h-6 w-6 rounded-full transition-transform hover:scale-110 ${
324
+ color === me.color
325
+ ? 'ring-2 ring-[rgb(var(--ec-page-text))] ring-offset-2 ring-offset-[rgb(var(--ec-card-bg))]'
326
+ : ''
327
+ }`}
328
+ style={{ background: color }}
329
+ />
330
+ ))}
331
+ </div>
332
+ </div>
333
+ </div>
334
+ )}
335
+ </div>
336
+ );
337
+ }
338
+
339
+ /** Who's here: click someone to jump to them; everyone past the first few is in a list */
340
+ /**
341
+ * Who else is here. An agent is shown once, however many places it's in (e.g. its MCP tools and its chat's canvas
342
+ * view), and not at all when it's you.
343
+ */
344
+ const othersHere = (peers: Peer[], clientId: number | null, me: { name: string; agent?: boolean }) => {
345
+ const agentsSeen = new Set(me.agent ? [me.name] : []);
346
+ return peers.filter((peer) => {
347
+ if (peer.clientId === clientId) return false;
348
+ if (!peer.agent) return true;
349
+ if (agentsSeen.has(peer.name)) return false;
350
+ agentsSeen.add(peer.name);
351
+ return true;
352
+ });
353
+ };
354
+
355
+ function People({
356
+ peers,
357
+ clientId,
358
+ me,
359
+ onJumpTo,
360
+ }: {
361
+ peers: Peer[];
362
+ clientId: number | null;
363
+ me: { name: string; agent?: boolean };
364
+ onJumpTo: (peer: Peer) => void;
365
+ }) {
366
+ const overflow = usePopover();
367
+ const others = othersHere(peers, clientId, me);
368
+ const shown = others.slice(0, MAX_AVATARS);
369
+ const hidden = others.slice(MAX_AVATARS);
370
+ const describe = (peer: Peer) => `${peer.name}${peer.agent ? ` (AI agent)${peer.activity ? `: ${peer.activity}` : ''}` : ''}`;
371
+
372
+ return (
373
+ <div className="flex items-center gap-1.5">
374
+ <div className="flex -space-x-1.5">
375
+ {shown.map((peer) => (
376
+ <button
377
+ key={peer.clientId}
378
+ title={`${describe(peer)}. Click to jump to them`}
379
+ onClick={() => onJumpTo(peer)}
380
+ className="block rounded-full transition-transform hover:z-10 hover:scale-110"
381
+ >
382
+ <Avatar peer={peer} />
383
+ </button>
384
+ ))}
385
+ {hidden.length > 0 && (
386
+ <div ref={overflow.ref} className="relative">
387
+ <button
388
+ title={`${hidden.length} more`}
389
+ onClick={() => overflow.setOpen((open) => !open)}
390
+ className="flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold ring-2 ring-[rgb(var(--ec-card-bg))] bg-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
391
+ >
392
+ +{hidden.length}
393
+ </button>
394
+ {overflow.open && (
395
+ <div className={`${menuClass} right-0`}>
396
+ {hidden.map((peer) => (
397
+ <button
398
+ key={peer.clientId}
399
+ className={menuItemClass}
400
+ onClick={() => {
401
+ onJumpTo(peer);
402
+ overflow.setOpen(false);
403
+ }}
404
+ >
405
+ <Avatar peer={peer} size={20} />
406
+ {describe(peer)}
407
+ </button>
408
+ ))}
409
+ </div>
410
+ )}
411
+ </div>
412
+ )}
413
+ </div>
414
+ </div>
415
+ );
416
+ }
417
+
418
+ export default memo(function CanvasHeader({
419
+ title,
420
+ onRetitle,
421
+ status,
422
+ transport,
423
+ presence,
424
+ clientId,
425
+ onJumpTo,
426
+ me,
427
+ onRename,
428
+ onRecolor,
429
+ onConnectAgent,
430
+ webMcp,
431
+ shareUrl,
432
+ newCanvasUrl,
433
+ onShare,
434
+ onOpenFile,
435
+ onSaveToFile,
436
+ onExportImage,
437
+ onLiveCollaboration,
438
+ onHelp,
439
+ canvasStatus,
440
+ statusHistory,
441
+ onStatusChange,
442
+ onDelete,
443
+ }: {
444
+ title?: string;
445
+ onRetitle: (title: string) => void;
446
+ canvasStatus: CanvasStatus;
447
+ statusHistory: StatusChange[];
448
+ onStatusChange: (status: CanvasStatus, note?: string) => void;
449
+ status: Status;
450
+ transport: Transport;
451
+ presence: PresenceStore | null;
452
+ clientId: number | null;
453
+ onJumpTo: (peer: Peer) => void;
454
+ /** You: your name, colour and (signed in) picture */
455
+ me: { name: string; color: string; picture?: string; agent?: boolean };
456
+ /** Not given when you're signed in */
457
+ onRename?: (name: string) => void;
458
+ onRecolor: (color: string) => void;
459
+ onConnectAgent: () => void;
460
+ webMcp: WebMcpStatus;
461
+ shareUrl?: string;
462
+ newCanvasUrl?: string;
463
+ /** Open the share dialog */
464
+ onShare: () => void;
465
+ /** Opens a .eventcatalog file as a new canvas (not given where it can't, e.g. inside a chat) */
466
+ onOpenFile?: () => void;
467
+ onSaveToFile: () => void;
468
+ onExportImage: () => void;
469
+ onLiveCollaboration: () => void;
470
+ onHelp: () => void;
471
+ /** Ask to delete the canvas (not given where it can't be, e.g. inside a chat) */
472
+ onDelete?: () => void;
473
+ }) {
474
+ const peers = usePeople(presence);
475
+ const agentsHere = peers.some((peer) => peer.agent);
476
+ // Who else is here (you're your own avatar, next to it)
477
+ const showPeople = othersHere(peers, clientId, me).length > 0;
478
+
479
+ return (
480
+ <div className="pointer-events-none absolute left-3 right-3 top-3 z-10 flex items-start justify-between gap-3">
481
+ <CanvasMenu
482
+ title={title}
483
+ status={status}
484
+ transport={transport}
485
+ onRetitle={onRetitle}
486
+ shareUrl={shareUrl}
487
+ newCanvasUrl={newCanvasUrl}
488
+ onConnectAgent={onConnectAgent}
489
+ webMcp={webMcp}
490
+ agentsHere={agentsHere}
491
+ onDelete={onDelete}
492
+ onOpenFile={onOpenFile}
493
+ onSaveToFile={onSaveToFile}
494
+ onExportImage={onExportImage}
495
+ onLiveCollaboration={onLiveCollaboration}
496
+ onHelp={onHelp}
497
+ >
498
+ <CanvasStatusMenu status={canvasStatus} history={statusHistory} onChange={onStatusChange} />
499
+ </CanvasMenu>
500
+
501
+ <div className="flex items-center gap-2">
502
+ {showPeople && (
503
+ <div className={`${barClass} px-1.5`}>
504
+ <People peers={peers} clientId={clientId} me={me} onJumpTo={onJumpTo} />
505
+ </div>
506
+ )}
507
+ <ProfileMenu me={me} onRename={onRename} onRecolor={onRecolor} />
508
+ <button
509
+ onClick={onShare}
510
+ className="pointer-events-auto inline-flex h-[38px] items-center gap-2 rounded-[9px] bg-[rgb(var(--ec-page-text))] px-4 text-sm font-semibold text-[rgb(var(--ec-page-bg))] shadow-sm transition-colors hover:bg-[rgb(var(--ec-page-text)/0.85)]"
511
+ >
512
+ <Share2 size={16} aria-hidden />
513
+ Share
514
+ </button>
515
+ </div>
516
+ </div>
517
+ );
518
+ });
@@ -0,0 +1,118 @@
1
+ import { memo, useEffect, useRef, useState } from 'react';
2
+ import { Check, ChevronDown } from 'lucide-react';
3
+ import { DIAGRAM_MENU, DIAGRAM_MENU_ITEM, DIAGRAM_MENU_SEPARATOR } from '@eventcatalog/visualiser';
4
+ import {
5
+ CANVAS_STATUSES,
6
+ type CanvasStatus,
7
+ type StatusChange,
8
+ timeAgo,
9
+ CANVAS_STATUS_LOOK,
10
+ } from '@eventcatalog/visualiser/studio';
11
+
12
+ /** Status changes shown in the menu, newest first */
13
+ const HISTORY_SHOWN = 8;
14
+
15
+ /**
16
+ * The canvas's status (draft, proposed, accepted, rejected) as a pill beside its title. It opens a menu to change it,
17
+ * with a note on why, and who changed it before.
18
+ */
19
+ export default memo(function CanvasStatusMenu({
20
+ status,
21
+ history,
22
+ onChange,
23
+ }: {
24
+ status: CanvasStatus;
25
+ history: StatusChange[];
26
+ onChange: (status: CanvasStatus, note?: string) => void;
27
+ }) {
28
+ const [open, setOpen] = useState(false);
29
+ const [note, setNote] = useState('');
30
+ const ref = useRef<HTMLDivElement>(null);
31
+ const look = CANVAS_STATUS_LOOK[status];
32
+
33
+ useEffect(() => {
34
+ if (!open) return;
35
+ const onPointerDown = (event: PointerEvent) => {
36
+ if (!ref.current?.contains(event.target as globalThis.Node)) setOpen(false);
37
+ };
38
+ const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && setOpen(false);
39
+ window.addEventListener('pointerdown', onPointerDown);
40
+ window.addEventListener('keydown', onKeyDown);
41
+ return () => {
42
+ window.removeEventListener('pointerdown', onPointerDown);
43
+ window.removeEventListener('keydown', onKeyDown);
44
+ };
45
+ }, [open]);
46
+
47
+ const choose = (next: CanvasStatus) => {
48
+ if (next !== status) onChange(next, note);
49
+ setNote('');
50
+ setOpen(false);
51
+ };
52
+ const recent = history.slice(-HISTORY_SHOWN).reverse();
53
+
54
+ return (
55
+ <div ref={ref} className="relative">
56
+ <button
57
+ onClick={() => setOpen((current) => !current)}
58
+ aria-label={`Status: ${look.label}`}
59
+ aria-expanded={open}
60
+ className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${look.pill}`}
61
+ >
62
+ {look.label}
63
+ <ChevronDown size={12} />
64
+ </button>
65
+ {open && (
66
+ <div className={`absolute left-0 top-full mt-2 w-72 ${DIAGRAM_MENU}`}>
67
+ <p className="px-3 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
68
+ Status
69
+ </p>
70
+ {CANVAS_STATUSES.map((value) => {
71
+ const option = CANVAS_STATUS_LOOK[value];
72
+ return (
73
+ <button key={value} onClick={() => choose(value)} className={`${DIAGRAM_MENU_ITEM} w-full text-left`}>
74
+ <span className={`h-2 w-2 shrink-0 rounded-full ${option.dot}`} />
75
+ <span className="min-w-0 flex-1">
76
+ <span className="block text-xs font-medium">{option.label}</span>
77
+ <span className="block text-[11px] text-[rgb(var(--ec-page-text-muted))]">{option.description}</span>
78
+ </span>
79
+ {value === status && <Check size={14} className="shrink-0 text-[rgb(var(--ec-accent))]" />}
80
+ </button>
81
+ );
82
+ })}
83
+ <div className="px-3 py-2">
84
+ <input
85
+ value={note}
86
+ onChange={(event) => setNote(event.target.value)}
87
+ placeholder="Add a note (optional), then pick a status"
88
+ aria-label="Note for the status change"
89
+ className="w-full rounded-md border px-2 py-1.5 text-xs 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))]"
90
+ />
91
+ </div>
92
+ {recent.length > 0 && (
93
+ <>
94
+ <div className={DIAGRAM_MENU_SEPARATOR} />
95
+ <p className="px-3 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
96
+ History
97
+ </p>
98
+ <ul className="max-h-48 space-y-1.5 overflow-y-auto px-3 pb-2">
99
+ {recent.map((change) => (
100
+ <li key={`${change.at}-${change.status}`} className="text-[11px]">
101
+ <span className="flex items-center gap-1.5">
102
+ <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${CANVAS_STATUS_LOOK[change.status].dot}`} />
103
+ <span className="font-medium">{CANVAS_STATUS_LOOK[change.status].label}</span>
104
+ <span className="text-[rgb(var(--ec-page-text-muted))]">
105
+ by {change.by.name} · {timeAgo(change.at)}
106
+ </span>
107
+ </span>
108
+ {change.note && <span className="block pl-3 text-[rgb(var(--ec-page-text-muted))]">{change.note}</span>}
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ </>
113
+ )}
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ });