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

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-BH2F5WPN.js → chunk-2UCCTEIH.js} +1 -1
  21. package/dist/{chunk-RFIXKATC.js → chunk-4G7FSRPN.js} +35 -42
  22. package/dist/{chunk-TQLRG35T.js → chunk-AD7CL772.js} +78 -45
  23. package/dist/{chunk-NDJOAITI.js → chunk-BNSLGNQI.js} +9 -3
  24. package/dist/chunk-CA6WCQ2T.js +54 -0
  25. package/dist/chunk-CPW6G62V.js +17 -0
  26. package/dist/{chunk-MDWB5HLO.js → chunk-NKNOXQ62.js} +1 -1
  27. package/dist/{chunk-423S2KHN.js → chunk-OUEGYMBA.js} +4 -2
  28. package/dist/{chunk-3PKS2IIP.js → chunk-OX6TSVHY.js} +1 -1
  29. package/dist/{chunk-36RYTYT5.js → chunk-PD7KTSYN.js} +1 -1
  30. package/dist/chunk-S5GWAUG5.js +29 -0
  31. package/dist/{chunk-GYIVJXOJ.js → chunk-WADZVNCA.js} +1 -1
  32. package/dist/{chunk-6OC46WQJ.js → chunk-XRFL642Z.js} +66 -35
  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,784 @@
1
+ ---
2
+ import path from 'node:path';
3
+ import { ChevronDown, ChevronLeft, ChevronRight, Copy, EllipsisVertical, Pencil, Plus, Search, Trash2 } from 'lucide-react';
4
+ import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
5
+ import StudioNotice from '../components/StudioNotice.astro';
6
+ import { buildUrl } from '@utils/url-builder';
7
+ import { CANVAS_STATUSES, CANVAS_STATUS_LOOK, STATUS, type CanvasStatus } from '@eventcatalog/visualiser/studio';
8
+ import { MAX_TITLE_LENGTH } from '../limits';
9
+ import { MAX_NAME_LENGTH } from '../identity';
10
+ import { CANVASES_API_PATH } from '../api/canvases-api';
11
+ import { startStudio, type CanvasSummary } from '../server/runtime';
12
+ import { getSignedInUser, isSignedIn } from '../server/sign-in';
13
+
14
+ export const prerender = false;
15
+
16
+ // The canvases on this EventCatalog, listed like the catalog's other tables (filters are in the address, so they can
17
+ // be shared and work without JavaScript)
18
+ // Studio can't start when its storage can't be used (e.g. the database was updated by a newer EventCatalog): this
19
+ // page says why instead of listing anything
20
+ const started = await startStudio({ isSignedIn }).catch((error: Error) => error);
21
+ const unavailable = started instanceof Error ? started.message : undefined;
22
+ if (unavailable) Astro.response.status = 503;
23
+ const runtime = started instanceof Error ? undefined : started;
24
+ // Signed in (SSO): you're shown by who you signed in as, and asked for nothing
25
+ const signedInAs = getSignedInUser(Astro.locals);
26
+ const storage = runtime?.storage ?? { type: 'memory' as const };
27
+ // Where they're saved, as the catalog's folder sees it (e.g. canvases)
28
+ const savedIn = storage.location && path.relative(process.env.PROJECT_DIR || process.cwd(), storage.location);
29
+ const statusParam = Astro.url.searchParams.get('status');
30
+ const status = CANVAS_STATUSES.includes(statusParam as CanvasStatus) ? (statusParam as CanvasStatus) : undefined;
31
+ const search = Astro.url.searchParams.get('q')?.trim() ?? '';
32
+
33
+ // The ones changed most recently first (canvases started and left empty aren't listed)
34
+ const canvases = runtime?.listCanvases({ withPreview: true }) ?? [];
35
+ const counts = Object.fromEntries(
36
+ CANVAS_STATUSES.map((value) => [value, canvases.filter((canvas) => canvas.status === value).length])
37
+ ) as Record<CanvasStatus, number>;
38
+ const shown = canvases.filter(
39
+ (canvas) =>
40
+ (!status || canvas.status === status) &&
41
+ (!search || (canvas.title ?? 'Untitled canvas').toLowerCase().includes(search.toLowerCase()))
42
+ );
43
+
44
+ // A page of them at a time, like the catalog's other tables
45
+ const PAGE_SIZES = [10, 25, 50, 100];
46
+ const sizeParam = Number(Astro.url.searchParams.get('size'));
47
+ const pageSize = PAGE_SIZES.includes(sizeParam) ? sizeParam : PAGE_SIZES[0];
48
+ const pageCount = Math.max(1, Math.ceil(shown.length / pageSize));
49
+ const pageNumber = Math.min(Math.max(1, Math.floor(Number(Astro.url.searchParams.get('page')) || 1)), pageCount);
50
+ const onPage = shown.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
51
+ /** This list on another page (its filters and page size kept) */
52
+ const pageUrl = (page: number) => {
53
+ const params = new URLSearchParams();
54
+ if (search) params.set('q', search);
55
+ if (status) params.set('status', status);
56
+ if (pageSize !== PAGE_SIZES[0]) params.set('size', String(pageSize));
57
+ if (page > 1) params.set('page', String(page));
58
+ const query = params.toString();
59
+ return `${buildUrl('/studio')}${query ? `?${query}` : ''}`;
60
+ };
61
+
62
+ const relativeTime = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
63
+ const ago = (time: number) => {
64
+ const minutes = Math.round((time - Date.now()) / 60_000);
65
+ if (minutes === 0) return 'just now';
66
+ if (Math.abs(minutes) < 60) return relativeTime.format(minutes, 'minute');
67
+ const hours = Math.round(minutes / 60);
68
+ if (Math.abs(hours) < 24) return relativeTime.format(hours, 'hour');
69
+ const days = Math.round(hours / 24);
70
+ if (Math.abs(days) < 30) return relativeTime.format(days, 'day');
71
+ return new Date(time).toLocaleDateString('en', { day: 'numeric', month: 'short', year: 'numeric' });
72
+ };
73
+ const canvasesApiUrl = buildUrl(CANVASES_API_PATH, true);
74
+ // Opens the New canvas dialog (also from a canvas's New canvas, or a link)
75
+ const newCanvasUrl = `${buildUrl('/studio')}?new=1`;
76
+ const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`;
77
+
78
+ // Node colours in previews, like the visualiser's (by kind of node), from the theme's badge colours
79
+ const PREVIEW_COLOURS: Record<string, string> = {
80
+ service: 'pink',
81
+ event: 'orange',
82
+ command: 'blue',
83
+ query: 'green',
84
+ channel: 'slate',
85
+ data: 'indigo',
86
+ view: 'sky',
87
+ externalSystem: 'fuchsia',
88
+ agent: 'violet',
89
+ actor: 'sky',
90
+ system: 'violet',
91
+ 'context-domain': 'yellow',
92
+ note: 'yellow',
93
+ };
94
+ const colourOf = (type: string) => `rgb(var(--ec-badge-color-${PREVIEW_COLOURS[type] ?? 'gray'}-text))`;
95
+ // Room around the drawing, in canvas units relative to its size
96
+ const framed = (preview: NonNullable<CanvasSummary['preview']>) => {
97
+ const margin = Math.max(preview.width, preview.height) * 0.08;
98
+ return `${-margin} ${-margin} ${preview.width + margin * 2} ${preview.height + margin * 2}`;
99
+ };
100
+ const strokeOf = (preview: NonNullable<CanvasSummary['preview']>, ratio: number) =>
101
+ Math.max(preview.width, preview.height) / ratio;
102
+ ---
103
+
104
+ <VerticalSideBarLayout title="Studio" showNestedSideBar={false}>
105
+ {
106
+ unavailable ? (
107
+ <main
108
+ class="bg-[rgb(var(--ec-page-bg))]"
109
+ style="margin-left: calc(var(--ec-app-content-padding-left, 5rem) * -1); margin-right: calc(var(--ec-app-content-padding-right, 5rem) * -1); height: calc(100dvh - 60px);"
110
+ >
111
+ <StudioNotice title="Studio isn't available" message={unavailable} />
112
+ </main>
113
+ ) : (
114
+ <Fragment>
115
+ {/* The page's height, like Discover: the list scrolls between its header and its pages */}
116
+ <main
117
+ class="ml-0 flex flex-col bg-[rgb(var(--ec-page-bg))]"
118
+ style="margin-left: calc(var(--ec-app-content-padding-left, 5rem) * -1); margin-right: calc(var(--ec-app-content-padding-right, 5rem) * -1); height: calc(100dvh - 60px);"
119
+ >
120
+ <div class="flex flex-shrink-0 flex-col gap-4 px-6 py-5 md:flex-row md:items-end md:justify-between">
121
+ <div class="min-w-0">
122
+ <h1 class="text-2xl font-semibold text-[rgb(var(--ec-page-text))] md:text-4xl">Studio ({canvases.length})</h1>
123
+ <p class="max-w-3xl pt-2 text-base font-light text-[rgb(var(--ec-page-text-muted))]">
124
+ Design changes to your architecture on live canvases, with your team and AI agents.
125
+ </p>
126
+ </div>
127
+ <div class="flex shrink-0 flex-col items-start gap-3 md:items-end">
128
+ {/* You, by the name others see on canvases: who you signed in as, or the name kept in this browser (shown by
129
+ the script; its space is kept so nothing moves) */}
130
+ {signedInAs ? (
131
+ // Signed in: you're shown in the app's header already (still read by the script, for New canvas)
132
+ <div data-you data-signed-in-as={signedInAs.name} hidden />
133
+ ) : (
134
+ <div data-you class="invisible flex items-center gap-2">
135
+ <button
136
+ type="button"
137
+ title="Change your name"
138
+ class="group inline-flex max-w-48 items-center gap-2 rounded-lg border py-1 pl-1 pr-2.5 text-sm border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))]"
139
+ >
140
+ <span
141
+ data-you-avatar
142
+ class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
143
+ />
144
+ <span data-you-name class="truncate font-medium" />
145
+ <Pencil
146
+ size={12}
147
+ className="shrink-0 text-[rgb(var(--ec-icon-color))] group-hover:text-[rgb(var(--ec-page-text))]"
148
+ />
149
+ </button>
150
+ </div>
151
+ )}
152
+ <div class="flex items-center gap-2">
153
+ <form method="get" action={buildUrl('/studio')} class="flex items-center gap-2" role="search">
154
+ {pageSize !== PAGE_SIZES[0] && <input type="hidden" name="size" value={pageSize} />}
155
+ <label class="relative">
156
+ <span class="sr-only">Filter canvases</span>
157
+ <Search
158
+ size={14}
159
+ className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[rgb(var(--ec-icon-color))]"
160
+ />
161
+ <input
162
+ type="search"
163
+ name="q"
164
+ value={search}
165
+ placeholder="Filter..."
166
+ class="w-56 rounded-lg border py-1.5 pl-8 pr-3 text-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))]"
167
+ />
168
+ </label>
169
+ <label class="relative">
170
+ <span class="sr-only">Status</span>
171
+ {/* Our own arrow: the browser's sits against the edge, whatever the padding */}
172
+ <select
173
+ name="status"
174
+ onchange="this.form.requestSubmit()"
175
+ class="cursor-pointer appearance-none rounded-lg border py-1.5 pl-3 pr-9 text-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] focus:outline-none focus:border-[rgb(var(--ec-accent))]"
176
+ >
177
+ <option value="" selected={!status}>
178
+ All statuses
179
+ </option>
180
+ {CANVAS_STATUSES.map((value) => (
181
+ <option value={value} selected={status === value}>
182
+ {CANVAS_STATUS_LOOK[value].label} ({counts[value]})
183
+ </option>
184
+ ))}
185
+ </select>
186
+ <ChevronDown
187
+ size={14}
188
+ className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[rgb(var(--ec-icon-color))]"
189
+ />
190
+ </label>
191
+ </form>
192
+ <a
193
+ href={newCanvasUrl}
194
+ data-new-canvas
195
+ class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
196
+ >
197
+ <Plus size={14} />
198
+ New canvas
199
+ </a>
200
+ </div>
201
+ </div>
202
+ </div>
203
+
204
+ <div data-canvas-list class="min-h-0 flex-1 overflow-auto px-6 pb-5">
205
+ <div class="overflow-hidden rounded-xl border border-[rgb(var(--ec-page-border)/0.72)] bg-[rgb(var(--ec-dropdown-bg)/0.66)]">
206
+ <table class="min-w-full divide-y divide-[rgb(var(--ec-page-border)/0.62)]">
207
+ <thead class="sticky top-0 z-10 bg-[rgb(var(--ec-content-hover)/0.45)]">
208
+ <tr>
209
+ {['Canvas', 'Status', 'Contents', 'Comments', 'Created', 'Updated'].map((label) => (
210
+ <th
211
+ scope="col"
212
+ class="px-4 py-2.5 text-left text-[11px] font-medium uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))]"
213
+ >
214
+ {label}
215
+ </th>
216
+ ))}
217
+ <th scope="col" class="w-px px-4 py-2.5">
218
+ <span class="sr-only">Actions</span>
219
+ </th>
220
+ </tr>
221
+ </thead>
222
+ <tbody class="divide-y divide-[rgb(var(--ec-page-border)/0.5)]">
223
+ {onPage.map((canvas) => {
224
+ const look = CANVAS_STATUS_LOOK[canvas.status];
225
+ const preview = canvas.preview;
226
+ return (
227
+ <tr class="group relative transition-colors hover:bg-[rgb(var(--ec-content-hover)/0.38)]">
228
+ <td class="px-4 py-3 text-sm text-[rgb(var(--ec-page-text))]">
229
+ <a href={buildUrl(`/studio/${canvas.canvasId}`)} class="flex items-center gap-3 font-semibold">
230
+ {/* The whole row opens it */}
231
+ <span class="absolute inset-0" aria-hidden="true" />
232
+ <span class="flex h-9 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))]">
233
+ {preview && (
234
+ <svg
235
+ viewBox={framed(preview)}
236
+ preserveAspectRatio="xMidYMid meet"
237
+ class="h-full w-full"
238
+ aria-hidden="true"
239
+ >
240
+ {preview.lines.map((line) => (
241
+ <line
242
+ {...line}
243
+ stroke="rgb(var(--ec-page-text-muted))"
244
+ stroke-opacity="0.5"
245
+ stroke-width={strokeOf(preview, 120)}
246
+ />
247
+ ))}
248
+ {preview.shapes.map((shape) => (
249
+ <rect
250
+ x={shape.x}
251
+ y={shape.y}
252
+ width={shape.width}
253
+ height={shape.height}
254
+ rx={strokeOf(preview, 40)}
255
+ fill={shape.container ? 'none' : colourOf(shape.type)}
256
+ fill-opacity="0.35"
257
+ stroke={shape.container ? 'rgb(var(--ec-page-text-muted))' : colourOf(shape.type)}
258
+ stroke-opacity={shape.container ? 0.5 : 1}
259
+ stroke-width={strokeOf(preview, shape.container ? 160 : 100)}
260
+ />
261
+ ))}
262
+ </svg>
263
+ )}
264
+ </span>
265
+ <span class="truncate group-hover:text-[rgb(var(--ec-accent))]">
266
+ {canvas.title ?? 'Untitled canvas'}
267
+ </span>
268
+ </a>
269
+ </td>
270
+ <td class="px-4 py-3 text-sm">
271
+ <span class:list={['inline-flex rounded-full border px-2 py-px text-[11px] font-medium', look.pill]}>
272
+ {look.label}
273
+ </span>
274
+ </td>
275
+ <td class="px-4 py-3 text-sm text-[rgb(var(--ec-page-text-muted))]">
276
+ {plural(canvas.nodeCount, 'node')}, {plural(canvas.edgeCount, 'connection')}
277
+ </td>
278
+ <td class="px-4 py-3 text-sm text-[rgb(var(--ec-page-text-muted))]">
279
+ {canvas.openComments > 0 ? `${canvas.openComments} open` : '-'}
280
+ </td>
281
+ <td class="px-4 py-3 text-sm text-[rgb(var(--ec-page-text-muted))]">
282
+ {[canvas.createdAt && ago(canvas.createdAt), canvas.createdBy && `by ${canvas.createdBy}`]
283
+ .filter(Boolean)
284
+ .join(' ') || '-'}
285
+ </td>
286
+ <td class="px-4 py-3 text-sm text-[rgb(var(--ec-page-text-muted))]">
287
+ {canvas.updatedAt ? ago(canvas.updatedAt) : '-'}
288
+ </td>
289
+ <td class="px-4 py-3 text-right">
290
+ {/* Above the link that covers the row */}
291
+ <button
292
+ type="button"
293
+ data-canvas-menu={canvas.canvasId}
294
+ data-canvas-title={canvas.title ?? ''}
295
+ title="More"
296
+ aria-label={`More for ${canvas.title ?? 'Untitled canvas'}`}
297
+ aria-haspopup="menu"
298
+ aria-expanded="false"
299
+ class="relative z-[1] rounded-md p-1.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)] hover:text-[rgb(var(--ec-page-text))] aria-expanded:bg-[rgb(var(--ec-page-border)/0.5)]"
300
+ >
301
+ <EllipsisVertical size={16} />
302
+ </button>
303
+ </td>
304
+ </tr>
305
+ );
306
+ })}
307
+ {shown.length === 0 && (
308
+ <tr>
309
+ <td colspan="7" class="px-4 py-12 text-center">
310
+ <p class="text-sm font-medium text-[rgb(var(--ec-page-text-muted))]">
311
+ {canvases.length === 0 ? 'No canvases yet' : 'No canvases match'}
312
+ </p>
313
+ <p class="mt-1 text-xs text-[rgb(var(--ec-icon-color))]">
314
+ {canvases.length === 0 ? (
315
+ <a href={newCanvasUrl} data-new-canvas class="text-[rgb(var(--ec-accent))] hover:underline">
316
+ Start a canvas
317
+ </a>
318
+ ) : (
319
+ <a href={buildUrl('/studio')} class="text-[rgb(var(--ec-accent))] hover:underline">
320
+ Show all canvases
321
+ </a>
322
+ )}
323
+ </p>
324
+ </td>
325
+ </tr>
326
+ )}
327
+ </tbody>
328
+ </table>
329
+ </div>
330
+ </div>
331
+
332
+ <div class="flex flex-shrink-0 items-center justify-between gap-4 border-t border-[rgb(var(--ec-page-border))] px-6 py-3">
333
+ <div class="flex min-w-0 items-center gap-3 text-xs text-[rgb(var(--ec-page-text-muted))]">
334
+ {shown.length > 0 && (
335
+ <span class="shrink-0">
336
+ <span class="font-medium text-[rgb(var(--ec-page-text))]">{onPage.length}</span> of{' '}
337
+ <span class="font-medium text-[rgb(var(--ec-page-text))]">{shown.length}</span> results
338
+ </span>
339
+ )}
340
+ {shown.length > 0 && <span aria-hidden="true" class="h-3 w-px shrink-0 bg-[rgb(var(--ec-page-border))]" />}
341
+ <form method="get" action={buildUrl('/studio')} class="flex shrink-0 items-center gap-1.5">
342
+ {search && <input type="hidden" name="q" value={search} />}
343
+ {status && <input type="hidden" name="status" value={status} />}
344
+ <label for="studio-page-size">Per page</label>
345
+ <select
346
+ id="studio-page-size"
347
+ name="size"
348
+ onchange="this.form.requestSubmit()"
349
+ class="cursor-pointer rounded bg-transparent px-1 py-0.5 font-medium text-[rgb(var(--ec-page-text))] hover:bg-[rgb(var(--ec-content-hover))] focus:bg-[rgb(var(--ec-content-hover))] focus:outline-none"
350
+ >
351
+ {PAGE_SIZES.map((size) => (
352
+ <option value={size} selected={size === pageSize}>
353
+ {size}
354
+ </option>
355
+ ))}
356
+ </select>
357
+ </form>
358
+ <span aria-hidden="true" class="h-3 w-px shrink-0 bg-[rgb(var(--ec-page-border))]" />
359
+ <span class="truncate">
360
+ {storage.type === 'memory'
361
+ ? 'Kept in memory until EventCatalog restarts (set storage in eventcatalog.config.js to keep them)'
362
+ : `Saved in ${savedIn}`}
363
+ </span>
364
+ </div>
365
+ <nav class="flex shrink-0 items-center gap-1.5" aria-label="Pages">
366
+ {pageNumber > 1 ? (
367
+ <a
368
+ href={pageUrl(pageNumber - 1)}
369
+ title="Previous page"
370
+ class="p-1.5 text-[rgb(var(--ec-icon-color))] transition-colors hover:text-[rgb(var(--ec-page-text))]"
371
+ >
372
+ <ChevronLeft className="h-4 w-4" />
373
+ </a>
374
+ ) : (
375
+ <span aria-disabled="true" class="p-1.5 text-[rgb(var(--ec-icon-color))] opacity-30">
376
+ <ChevronLeft className="h-4 w-4" />
377
+ </span>
378
+ )}
379
+ <span class="min-w-[60px] text-center text-xs tabular-nums text-[rgb(var(--ec-page-text-muted))]">
380
+ <span class="font-medium text-[rgb(var(--ec-page-text))]">{pageNumber}</span> / {pageCount}
381
+ </span>
382
+ {pageNumber < pageCount ? (
383
+ <a
384
+ href={pageUrl(pageNumber + 1)}
385
+ title="Next page"
386
+ class="p-1.5 text-[rgb(var(--ec-icon-color))] transition-colors hover:text-[rgb(var(--ec-page-text))]"
387
+ >
388
+ <ChevronRight className="h-4 w-4" />
389
+ </a>
390
+ ) : (
391
+ <span aria-disabled="true" class="p-1.5 text-[rgb(var(--ec-icon-color))] opacity-30">
392
+ <ChevronRight className="h-4 w-4" />
393
+ </span>
394
+ )}
395
+ </nav>
396
+ </div>
397
+ </main>
398
+
399
+ {/* Naming a new canvas before it opens (created through the Studio API) */}
400
+ <dialog
401
+ id="new-canvas-dialog"
402
+ aria-labelledby="new-canvas-title"
403
+ class="m-auto w-full max-w-md rounded-xl border p-0 shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))] backdrop:bg-black/40"
404
+ >
405
+ <form data-api={canvasesApiUrl}>
406
+ <div class="border-b px-5 py-4 border-[rgb(var(--ec-page-border))]">
407
+ <h2 id="new-canvas-title" class="text-base font-semibold">
408
+ New canvas
409
+ </h2>
410
+ <p class="mt-0.5 text-sm text-[rgb(var(--ec-page-text-muted))]">
411
+ Name it for what you're designing, so your team can find it.
412
+ </p>
413
+ </div>
414
+ <div class="px-5 py-4">
415
+ <label for="new-canvas-name" class="block text-sm font-semibold">
416
+ Name
417
+ </label>
418
+ <input
419
+ id="new-canvas-name"
420
+ name="title"
421
+ maxlength={MAX_TITLE_LENGTH}
422
+ autocomplete="off"
423
+ placeholder="e.g. Payments redesign"
424
+ class="mt-2 w-full rounded-lg border px-3 py-2 text-sm shadow-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))] focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.25)]"
425
+ />
426
+ {/* Who's starting it: the name they use on canvases, if they've joined one in this browser */}
427
+ <input type="hidden" name="by" />
428
+ <p data-error hidden class:list={['mt-2 text-sm', STATUS.danger.text]} />
429
+ </div>
430
+ <div class="flex justify-end gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
431
+ <button
432
+ type="button"
433
+ data-close-dialog
434
+ class="rounded-lg border px-4 py-2 text-sm font-medium border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)]"
435
+ >
436
+ Cancel
437
+ </button>
438
+ <button
439
+ type="submit"
440
+ class="rounded-lg px-4 py-2 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))] disabled:opacity-60"
441
+ >
442
+ Create canvas
443
+ </button>
444
+ </div>
445
+ </form>
446
+ </dialog>
447
+
448
+ {/* A canvas's menu, opened from its row (one for the page, moved to the row it's opened from) */}
449
+ <div
450
+ id="canvas-row-menu"
451
+ role="menu"
452
+ hidden
453
+ data-api={canvasesApiUrl}
454
+ class="fixed z-50 min-w-44 rounded-xl border p-1 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
455
+ >
456
+ <button
457
+ type="button"
458
+ role="menuitem"
459
+ data-menu-copy
460
+ class="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs hover:bg-[rgb(var(--ec-page-border)/0.5)] disabled:opacity-60"
461
+ >
462
+ <Copy size={14} />
463
+ <span data-label>Make a copy</span>
464
+ </button>
465
+ <div class="my-1 h-px bg-[rgb(var(--ec-page-border))]" />
466
+ <button
467
+ type="button"
468
+ role="menuitem"
469
+ data-menu-delete
470
+ class:list={[
471
+ 'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs',
472
+ STATUS.danger.text,
473
+ STATUS.danger.hover,
474
+ ]}
475
+ >
476
+ <Trash2 size={14} />
477
+ Delete
478
+ </button>
479
+ <p data-error hidden class:list={['max-w-56 px-2.5 py-1.5 text-xs', STATUS.danger.text]} />
480
+ </div>
481
+
482
+ {/* Deleting a canvas, after asking (it's deleted for everyone, for good) */}
483
+ <dialog
484
+ id="delete-canvas-dialog"
485
+ role="alertdialog"
486
+ aria-labelledby="delete-canvas-title"
487
+ data-api={canvasesApiUrl}
488
+ class="m-auto w-full max-w-md rounded-xl border p-0 shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))] backdrop:bg-black/40"
489
+ >
490
+ <div class="space-y-2 px-5 py-4">
491
+ <h2 id="delete-canvas-title" class="text-base font-semibold">
492
+ Delete this canvas?
493
+ </h2>
494
+ <p class="text-sm text-[rgb(var(--ec-page-text-muted))]">
495
+ It's deleted for everyone, with its comments, and can't be brought back. Anyone who has it open is told.
496
+ </p>
497
+ <p data-error hidden class:list={['text-sm', STATUS.danger.text]} />
498
+ </div>
499
+ <div class="flex justify-end gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
500
+ <button
501
+ type="button"
502
+ data-close-dialog
503
+ class="rounded-lg border px-4 py-2 text-sm font-medium border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)]"
504
+ >
505
+ Cancel
506
+ </button>
507
+ <button
508
+ type="button"
509
+ data-confirm-delete
510
+ class:list={['rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60', STATUS.danger.button]}
511
+ >
512
+ Delete canvas
513
+ </button>
514
+ </div>
515
+ </dialog>
516
+
517
+ {/* Your name: asked for on your first visit, changed from the button by New canvas */}
518
+ <dialog
519
+ id="name-dialog"
520
+ aria-labelledby="name-dialog-title"
521
+ class="m-auto w-full max-w-md rounded-xl border p-0 shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))] backdrop:bg-black/40"
522
+ >
523
+ <form>
524
+ <div class="border-b px-5 py-4 border-[rgb(var(--ec-page-border))]">
525
+ <h2 id="name-dialog-title" class="text-base font-semibold" data-first-visit-text="Welcome to Studio">
526
+ Your name
527
+ </h2>
528
+ <p class="mt-0.5 text-sm text-[rgb(var(--ec-page-text-muted))]">
529
+ What should others call you? It's shown by your pointer and your comments on every canvas.
530
+ </p>
531
+ </div>
532
+ <div class="px-5 py-4">
533
+ <label for="your-name" class="block text-sm font-semibold">
534
+ Name
535
+ </label>
536
+ <input
537
+ id="your-name"
538
+ name="name"
539
+ required
540
+ maxlength={MAX_NAME_LENGTH}
541
+ autocomplete="name"
542
+ placeholder="e.g. Sam"
543
+ class="mt-2 w-full rounded-lg border px-3 py-2 text-sm shadow-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))] focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.25)]"
544
+ />
545
+ <p class="mt-2 text-xs text-[rgb(var(--ec-page-text-muted))]">Kept in this browser. Change it here any time.</p>
546
+ </div>
547
+ <div class="flex justify-end gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
548
+ <button
549
+ type="button"
550
+ data-close-dialog
551
+ class="rounded-lg border px-4 py-2 text-sm font-medium border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)]"
552
+ >
553
+ Cancel
554
+ </button>
555
+ <button
556
+ type="submit"
557
+ class="rounded-lg px-4 py-2 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
558
+ >
559
+ Save
560
+ </button>
561
+ </div>
562
+ </form>
563
+ </dialog>
564
+ </Fragment>
565
+ )
566
+ }
567
+ </VerticalSideBarLayout>
568
+
569
+ <script>
570
+ import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
571
+ import { copyCanvasThroughApi, createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
572
+
573
+ // On every visit to the page, including ones the app's client-side navigation swaps in (its scripts run only once)
574
+ const setUp = () => {
575
+ const dialog = document.querySelector<HTMLDialogElement>('#new-canvas-dialog');
576
+ const nameDialog = document.querySelector<HTMLDialogElement>('#name-dialog');
577
+ const you = document.querySelector<HTMLElement>('[data-you]');
578
+ if (!dialog || !nameDialog || !you || dialog.dataset.ready) return;
579
+ dialog.dataset.ready = 'true';
580
+
581
+ // Signed in (SSO): you're who you signed in as, so there's nothing to ask or change
582
+ const signedInAs = you.dataset.signedInAs;
583
+ // Kept here too, for browsers that block storage (asked once per visit)
584
+ let yourName = signedInAs ?? getStoredName();
585
+ const showYou = (name: string | null) => {
586
+ yourName = name;
587
+ you.classList.toggle('invisible', !name);
588
+ if (!name) return;
589
+ you.querySelector('[data-you-name]')!.textContent = name;
590
+ const avatar = you.querySelector<HTMLElement>('[data-you-avatar]')!;
591
+ avatar.textContent = name.slice(0, 2).toUpperCase();
592
+ avatar.style.backgroundColor = colorForName(name);
593
+ };
594
+ if (!signedInAs) {
595
+ showYou(getStoredName());
596
+ const stopFollowing = onStoredNameChange(showYou);
597
+ document.addEventListener('astro:before-swap', stopFollowing, { once: true });
598
+ }
599
+
600
+ // Your name: on a first visit it has to be given (no Cancel, Escape does nothing)
601
+ const nameInput = nameDialog.querySelector<HTMLInputElement>('#your-name')!;
602
+ const nameTitle = nameDialog.querySelector<HTMLElement>('#name-dialog-title')!;
603
+ const nameCancel = nameDialog.querySelector<HTMLButtonElement>('[data-close-dialog]')!;
604
+ const defaultTitle = nameTitle.textContent?.trim();
605
+ let onNamed: (() => void) | undefined;
606
+ const askName = (then?: () => void) => {
607
+ nameInput.value = yourName ?? '';
608
+ nameTitle.textContent = yourName ? defaultTitle : nameTitle.dataset.firstVisitText!;
609
+ nameCancel.hidden = !yourName;
610
+ onNamed = then;
611
+ nameDialog.showModal();
612
+ nameInput.select();
613
+ };
614
+ nameDialog.addEventListener('cancel', (event) => {
615
+ if (!yourName) event.preventDefault();
616
+ });
617
+ nameCancel.addEventListener('click', () => nameDialog.close());
618
+ nameDialog.querySelector('form')!.addEventListener('submit', (event) => {
619
+ event.preventDefault();
620
+ const name = nameInput.value.trim();
621
+ if (!name) return;
622
+ storeName(name);
623
+ showYou(name);
624
+ nameDialog.close();
625
+ onNamed?.();
626
+ });
627
+ you.querySelector('button')?.addEventListener('click', () => askName());
628
+
629
+ const open = () => {
630
+ const by = dialog.querySelector<HTMLInputElement>('input[name="by"]');
631
+ if (by) by.value = yourName ?? '';
632
+ dialog.querySelector<HTMLElement>('[data-error]')!.hidden = true;
633
+ dialog.showModal();
634
+ dialog.querySelector<HTMLInputElement>('#new-canvas-name')?.focus();
635
+ };
636
+ // Created through the Studio API, then opened
637
+ const newCanvasForm = dialog.querySelector<HTMLFormElement>('form')!;
638
+ newCanvasForm.addEventListener('submit', async (event) => {
639
+ event.preventDefault();
640
+ const form = new FormData(newCanvasForm);
641
+ const submit = newCanvasForm.querySelector<HTMLButtonElement>('[type="submit"]')!;
642
+ const error = newCanvasForm.querySelector<HTMLElement>('[data-error]')!;
643
+ submit.disabled = true;
644
+ error.hidden = true;
645
+ try {
646
+ const canvas = await createCanvasThroughApi(newCanvasForm.dataset.api!, {
647
+ title: String(form.get('title') ?? '').trim() || undefined,
648
+ createdBy: String(form.get('by') ?? '').trim() || undefined,
649
+ });
650
+ window.location.assign(canvas.url);
651
+ } catch (failure) {
652
+ error.textContent = (failure as Error).message;
653
+ error.hidden = false;
654
+ submit.disabled = false;
655
+ }
656
+ });
657
+
658
+ // A canvas's menu (from the ⋮ on its row): make a copy, or delete it
659
+ const rowMenu = document.querySelector<HTMLElement>('#canvas-row-menu')!;
660
+ const copyItem = rowMenu.querySelector<HTMLButtonElement>('[data-menu-copy]')!;
661
+ const copyLabel = copyItem.querySelector<HTMLElement>('[data-label]')!;
662
+ const menuError = rowMenu.querySelector<HTMLElement>('[data-error]')!;
663
+ let menuFor: HTMLButtonElement | undefined;
664
+ const closeMenu = () => {
665
+ rowMenu.hidden = true;
666
+ menuFor?.setAttribute('aria-expanded', 'false');
667
+ menuFor = undefined;
668
+ };
669
+ const openMenu = (button: HTMLButtonElement, fromKeyboard: boolean) => {
670
+ closeMenu();
671
+ menuFor = button;
672
+ button.setAttribute('aria-expanded', 'true');
673
+ copyItem.disabled = false;
674
+ copyLabel.textContent = 'Make a copy';
675
+ menuError.hidden = true;
676
+ rowMenu.hidden = false;
677
+ // Under the button, lined up with its right edge (above it if there's no room below)
678
+ const at = button.getBoundingClientRect();
679
+ const { width, height } = rowMenu.getBoundingClientRect();
680
+ const below = at.bottom + 4 + height <= window.innerHeight;
681
+ rowMenu.style.top = `${below ? at.bottom + 4 : at.top - 4 - height}px`;
682
+ rowMenu.style.left = `${Math.max(8, at.right - width)}px`;
683
+ // Opened with the keyboard: its first item, ready to choose
684
+ if (fromKeyboard) copyItem.focus();
685
+ };
686
+ document.querySelectorAll<HTMLButtonElement>('[data-canvas-menu]').forEach((button) =>
687
+ // A click with no pointer (detail 0) is the keyboard (Enter or Space)
688
+ button.addEventListener('click', (event) => (menuFor === button ? closeMenu() : openMenu(button, event.detail === 0)))
689
+ );
690
+ const onPointerDown = (event: PointerEvent) => {
691
+ const target = event.target as Node;
692
+ if (!rowMenu.hidden && !rowMenu.contains(target) && !menuFor?.contains(target)) closeMenu();
693
+ };
694
+ const onKeyDown = (event: KeyboardEvent) => {
695
+ if (event.key !== 'Escape' || rowMenu.hidden) return;
696
+ const button = menuFor;
697
+ closeMenu();
698
+ button?.focus();
699
+ };
700
+ window.addEventListener('pointerdown', onPointerDown);
701
+ window.addEventListener('keydown', onKeyDown);
702
+ // It's placed for where the row was
703
+ window.addEventListener('resize', closeMenu);
704
+ document.querySelector('[data-canvas-list]')?.addEventListener('scroll', closeMenu, { passive: true });
705
+ document.addEventListener(
706
+ 'astro:before-swap',
707
+ () => {
708
+ window.removeEventListener('pointerdown', onPointerDown);
709
+ window.removeEventListener('keydown', onKeyDown);
710
+ window.removeEventListener('resize', closeMenu);
711
+ },
712
+ { once: true }
713
+ );
714
+
715
+ // Making a copy: it's listed at the top (the canvas changed most recently)
716
+ copyItem.addEventListener('click', async () => {
717
+ const canvasId = menuFor?.dataset.canvasMenu;
718
+ if (!canvasId) return;
719
+ copyItem.disabled = true;
720
+ copyLabel.textContent = 'Copying…';
721
+ menuError.hidden = true;
722
+ try {
723
+ await copyCanvasThroughApi(rowMenu.dataset.api!, canvasId, { createdBy: yourName ?? undefined });
724
+ window.location.reload();
725
+ } catch (failure) {
726
+ menuError.textContent = (failure as Error).message;
727
+ menuError.hidden = false;
728
+ copyItem.disabled = false;
729
+ copyLabel.textContent = 'Make a copy';
730
+ }
731
+ });
732
+
733
+ // Deleting a canvas from the list, after asking
734
+ const deleteDialog = document.querySelector<HTMLDialogElement>('#delete-canvas-dialog')!;
735
+ const deleteTitle = deleteDialog.querySelector<HTMLElement>('#delete-canvas-title')!;
736
+ const deleteError = deleteDialog.querySelector<HTMLElement>('[data-error]')!;
737
+ const confirmDelete = deleteDialog.querySelector<HTMLButtonElement>('[data-confirm-delete]')!;
738
+ let deleting: string | undefined;
739
+ rowMenu.querySelector('[data-menu-delete]')!.addEventListener('click', () => {
740
+ deleting = menuFor?.dataset.canvasMenu;
741
+ const title = menuFor?.dataset.canvasTitle;
742
+ closeMenu();
743
+ deleteTitle.textContent = `Delete ${title ? `"${title}"` : 'this canvas'}?`;
744
+ deleteError.hidden = true;
745
+ confirmDelete.disabled = false;
746
+ deleteDialog.showModal();
747
+ });
748
+ deleteDialog.querySelector('[data-close-dialog]')!.addEventListener('click', () => deleteDialog.close());
749
+ confirmDelete.addEventListener('click', async () => {
750
+ if (!deleting) return;
751
+ confirmDelete.disabled = true;
752
+ deleteError.hidden = true;
753
+ try {
754
+ await deleteCanvasThroughApi(deleteDialog.dataset.api!, deleting);
755
+ window.location.reload();
756
+ } catch (failure) {
757
+ deleteError.textContent = (failure as Error).message;
758
+ deleteError.hidden = false;
759
+ confirmDelete.disabled = false;
760
+ }
761
+ });
762
+ // Starting a canvas says who started it: ask for a name first if there isn't one yet
763
+ const startCanvas = () => (yourName ? open() : askName(open));
764
+ // A plain button, so Enter in the name creates the canvas
765
+ dialog.querySelector('[data-close-dialog]')?.addEventListener('click', () => dialog.close());
766
+ document.querySelectorAll('[data-new-canvas]').forEach((link) =>
767
+ link.addEventListener('click', (event) => {
768
+ event.preventDefault();
769
+ startCanvas();
770
+ })
771
+ );
772
+ // Opened from a link (/studio?new=1), e.g. a canvas's New canvas
773
+ const url = new URL(window.location.href);
774
+ if (url.searchParams.has('new')) {
775
+ url.searchParams.delete('new');
776
+ history.replaceState(history.state, '', url);
777
+ startCanvas();
778
+ } else if (!yourName) {
779
+ askName();
780
+ }
781
+ };
782
+ document.addEventListener('astro:page-load', setUp);
783
+ setUp();
784
+ </script>