create-ncblock 0.0.1

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 (238) hide show
  1. package/README.md +49 -0
  2. package/bin/cli.js +33 -0
  3. package/package.json +25 -0
  4. package/scripts/init.ts +527 -0
  5. package/scripts/scaffold-assets/AGENTS.md +65 -0
  6. package/scripts/utils/templates.ts +293 -0
  7. package/sdk-version.json +1 -0
  8. package/templates/debug/README.md +36 -0
  9. package/templates/debug/_gitignore +2 -0
  10. package/templates/debug/custom_blocks.json +9 -0
  11. package/templates/debug/dist/assets/index-Cet2SsjS.css +2 -0
  12. package/templates/debug/dist/assets/index-DAzv_fuh.js +9 -0
  13. package/templates/debug/dist/custom_blocks.json +9 -0
  14. package/templates/debug/dist/index.html +16 -0
  15. package/templates/debug/index.html +15 -0
  16. package/templates/debug/node_modules/.bin/browserslist +21 -0
  17. package/templates/debug/node_modules/.bin/esbuild +21 -0
  18. package/templates/debug/node_modules/.bin/jiti +21 -0
  19. package/templates/debug/node_modules/.bin/rollup +21 -0
  20. package/templates/debug/node_modules/.bin/tsc +21 -0
  21. package/templates/debug/node_modules/.bin/tsserver +21 -0
  22. package/templates/debug/node_modules/.bin/tsx +21 -0
  23. package/templates/debug/node_modules/.bin/vite +21 -0
  24. package/templates/debug/node_modules/.vite/deps/_metadata.json +50 -0
  25. package/templates/debug/node_modules/.vite/deps/package.json +3 -0
  26. package/templates/debug/node_modules/.vite/deps/react-CsV5wVHy.js +770 -0
  27. package/templates/debug/node_modules/.vite/deps/react-CsV5wVHy.js.map +1 -0
  28. package/templates/debug/node_modules/.vite/deps/react-dom.js +185 -0
  29. package/templates/debug/node_modules/.vite/deps/react-dom.js.map +1 -0
  30. package/templates/debug/node_modules/.vite/deps/react-dom_client.js +14384 -0
  31. package/templates/debug/node_modules/.vite/deps/react-dom_client.js.map +1 -0
  32. package/templates/debug/node_modules/.vite/deps/react.js +2 -0
  33. package/templates/debug/node_modules/.vite/deps/react_jsx-dev-runtime.js +204 -0
  34. package/templates/debug/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -0
  35. package/templates/debug/node_modules/.vite/deps/react_jsx-runtime.js +208 -0
  36. package/templates/debug/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -0
  37. package/templates/debug/node_modules/.vite/deps/valibot.js +6623 -0
  38. package/templates/debug/node_modules/.vite/deps/valibot.js.map +1 -0
  39. package/templates/debug/node_modules/.vite-temp/vite.config.ts.timestamp-1778623720803-0bcf523a67aa8.mjs +15 -0
  40. package/templates/debug/package.json +30 -0
  41. package/templates/debug/src/index.css +62 -0
  42. package/templates/debug/src/index.tsx +1963 -0
  43. package/templates/debug/tsconfig.json +17 -0
  44. package/templates/debug/vite.config.ts +8 -0
  45. package/templates/empty/README.md +10 -0
  46. package/templates/empty/_gitignore +2 -0
  47. package/templates/empty/custom_blocks.json +12 -0
  48. package/templates/empty/dist/assets/index-CodJADav.js +9 -0
  49. package/templates/empty/dist/custom_blocks.json +12 -0
  50. package/templates/empty/dist/index.html +15 -0
  51. package/templates/empty/index.html +15 -0
  52. package/templates/empty/node_modules/.bin/esbuild +21 -0
  53. package/templates/empty/node_modules/.bin/jiti +21 -0
  54. package/templates/empty/node_modules/.bin/tsc +21 -0
  55. package/templates/empty/node_modules/.bin/tsserver +21 -0
  56. package/templates/empty/node_modules/.bin/tsx +21 -0
  57. package/templates/empty/node_modules/.bin/vite +21 -0
  58. package/templates/empty/node_modules/.vite/deps/_metadata.json +50 -0
  59. package/templates/empty/node_modules/.vite/deps/package.json +3 -0
  60. package/templates/empty/node_modules/.vite/deps/react-CsV5wVHy.js +770 -0
  61. package/templates/empty/node_modules/.vite/deps/react-CsV5wVHy.js.map +1 -0
  62. package/templates/empty/node_modules/.vite/deps/react-dom.js +185 -0
  63. package/templates/empty/node_modules/.vite/deps/react-dom.js.map +1 -0
  64. package/templates/empty/node_modules/.vite/deps/react-dom_client.js +14384 -0
  65. package/templates/empty/node_modules/.vite/deps/react-dom_client.js.map +1 -0
  66. package/templates/empty/node_modules/.vite/deps/react.js +2 -0
  67. package/templates/empty/node_modules/.vite/deps/react_jsx-dev-runtime.js +204 -0
  68. package/templates/empty/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -0
  69. package/templates/empty/node_modules/.vite/deps/react_jsx-runtime.js +208 -0
  70. package/templates/empty/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -0
  71. package/templates/empty/node_modules/.vite/deps/valibot.js +6623 -0
  72. package/templates/empty/node_modules/.vite/deps/valibot.js.map +1 -0
  73. package/templates/empty/package.json +28 -0
  74. package/templates/empty/src/index.tsx +12 -0
  75. package/templates/empty/tsconfig.json +17 -0
  76. package/templates/empty/vite.config.ts +7 -0
  77. package/templates/gantt-chart/node_modules/.bin/tsc +21 -0
  78. package/templates/gantt-chart/node_modules/.bin/tsserver +21 -0
  79. package/templates/gantt-chart/node_modules/.bin/vite +21 -0
  80. package/templates/gantt-chart/node_modules/.vite/deps/_metadata.json +50 -0
  81. package/templates/gantt-chart/node_modules/.vite/deps/package.json +3 -0
  82. package/templates/gantt-chart/node_modules/.vite/deps/react-CsV5wVHy.js +770 -0
  83. package/templates/gantt-chart/node_modules/.vite/deps/react-CsV5wVHy.js.map +1 -0
  84. package/templates/gantt-chart/node_modules/.vite/deps/react-dom.js +185 -0
  85. package/templates/gantt-chart/node_modules/.vite/deps/react-dom.js.map +1 -0
  86. package/templates/gantt-chart/node_modules/.vite/deps/react-dom_client.js +14384 -0
  87. package/templates/gantt-chart/node_modules/.vite/deps/react-dom_client.js.map +1 -0
  88. package/templates/gantt-chart/node_modules/.vite/deps/react.js +2 -0
  89. package/templates/gantt-chart/node_modules/.vite/deps/react_jsx-dev-runtime.js +204 -0
  90. package/templates/gantt-chart/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -0
  91. package/templates/gantt-chart/node_modules/.vite/deps/react_jsx-runtime.js +208 -0
  92. package/templates/gantt-chart/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -0
  93. package/templates/gantt-chart/node_modules/.vite/deps/valibot.js +6623 -0
  94. package/templates/gantt-chart/node_modules/.vite/deps/valibot.js.map +1 -0
  95. package/templates/hello-world/node_modules/.bin/tsc +21 -0
  96. package/templates/hello-world/node_modules/.bin/tsserver +21 -0
  97. package/templates/hello-world/node_modules/.bin/vite +21 -0
  98. package/templates/hello-world/node_modules/.vite/deps/_metadata.json +50 -0
  99. package/templates/hello-world/node_modules/.vite/deps/package.json +3 -0
  100. package/templates/hello-world/node_modules/.vite/deps/react-CsV5wVHy.js +770 -0
  101. package/templates/hello-world/node_modules/.vite/deps/react-CsV5wVHy.js.map +1 -0
  102. package/templates/hello-world/node_modules/.vite/deps/react-dom.js +185 -0
  103. package/templates/hello-world/node_modules/.vite/deps/react-dom.js.map +1 -0
  104. package/templates/hello-world/node_modules/.vite/deps/react-dom_client.js +14384 -0
  105. package/templates/hello-world/node_modules/.vite/deps/react-dom_client.js.map +1 -0
  106. package/templates/hello-world/node_modules/.vite/deps/react.js +2 -0
  107. package/templates/hello-world/node_modules/.vite/deps/react_jsx-dev-runtime.js +204 -0
  108. package/templates/hello-world/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -0
  109. package/templates/hello-world/node_modules/.vite/deps/react_jsx-runtime.js +208 -0
  110. package/templates/hello-world/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -0
  111. package/templates/hello-world/node_modules/.vite/deps/valibot.js +6623 -0
  112. package/templates/hello-world/node_modules/.vite/deps/valibot.js.map +1 -0
  113. package/templates/interactive-resize/node_modules/.bin/tsc +21 -0
  114. package/templates/interactive-resize/node_modules/.bin/tsserver +21 -0
  115. package/templates/interactive-resize/node_modules/.bin/vite +21 -0
  116. package/templates/interactive-resize/node_modules/.vite/deps/_metadata.json +50 -0
  117. package/templates/interactive-resize/node_modules/.vite/deps/package.json +3 -0
  118. package/templates/interactive-resize/node_modules/.vite/deps/react-CsV5wVHy.js +770 -0
  119. package/templates/interactive-resize/node_modules/.vite/deps/react-CsV5wVHy.js.map +1 -0
  120. package/templates/interactive-resize/node_modules/.vite/deps/react-dom.js +185 -0
  121. package/templates/interactive-resize/node_modules/.vite/deps/react-dom.js.map +1 -0
  122. package/templates/interactive-resize/node_modules/.vite/deps/react-dom_client.js +14384 -0
  123. package/templates/interactive-resize/node_modules/.vite/deps/react-dom_client.js.map +1 -0
  124. package/templates/interactive-resize/node_modules/.vite/deps/react.js +2 -0
  125. package/templates/interactive-resize/node_modules/.vite/deps/react_jsx-dev-runtime.js +204 -0
  126. package/templates/interactive-resize/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -0
  127. package/templates/interactive-resize/node_modules/.vite/deps/react_jsx-runtime.js +208 -0
  128. package/templates/interactive-resize/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -0
  129. package/templates/interactive-resize/node_modules/.vite/deps/valibot.js +6623 -0
  130. package/templates/interactive-resize/node_modules/.vite/deps/valibot.js.map +1 -0
  131. package/templates/org-chart/node_modules/.bin/tsc +21 -0
  132. package/templates/org-chart/node_modules/.bin/tsserver +21 -0
  133. package/templates/org-chart/node_modules/.bin/vite +21 -0
  134. package/templates/org-chart/node_modules/.vite/deps/_metadata.json +50 -0
  135. package/templates/org-chart/node_modules/.vite/deps/package.json +3 -0
  136. package/templates/org-chart/node_modules/.vite/deps/react-CsV5wVHy.js +770 -0
  137. package/templates/org-chart/node_modules/.vite/deps/react-CsV5wVHy.js.map +1 -0
  138. package/templates/org-chart/node_modules/.vite/deps/react-dom.js +185 -0
  139. package/templates/org-chart/node_modules/.vite/deps/react-dom.js.map +1 -0
  140. package/templates/org-chart/node_modules/.vite/deps/react-dom_client.js +14384 -0
  141. package/templates/org-chart/node_modules/.vite/deps/react-dom_client.js.map +1 -0
  142. package/templates/org-chart/node_modules/.vite/deps/react.js +2 -0
  143. package/templates/org-chart/node_modules/.vite/deps/react_jsx-dev-runtime.js +204 -0
  144. package/templates/org-chart/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -0
  145. package/templates/org-chart/node_modules/.vite/deps/react_jsx-runtime.js +208 -0
  146. package/templates/org-chart/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -0
  147. package/templates/org-chart/node_modules/.vite/deps/valibot.js +6623 -0
  148. package/templates/org-chart/node_modules/.vite/deps/valibot.js.map +1 -0
  149. package/templates/radar-chart/README.md +55 -0
  150. package/templates/radar-chart/_gitignore +2 -0
  151. package/templates/radar-chart/custom_blocks.json +34 -0
  152. package/templates/radar-chart/dist/assets/index-DOf05oXg.css +2 -0
  153. package/templates/radar-chart/dist/assets/index-DWpNd1qt.js +9 -0
  154. package/templates/radar-chart/dist/custom_blocks.json +34 -0
  155. package/templates/radar-chart/dist/index.html +16 -0
  156. package/templates/radar-chart/index.html +15 -0
  157. package/templates/radar-chart/node_modules/.bin/esbuild +21 -0
  158. package/templates/radar-chart/node_modules/.bin/jiti +21 -0
  159. package/templates/radar-chart/node_modules/.bin/tsc +21 -0
  160. package/templates/radar-chart/node_modules/.bin/tsserver +21 -0
  161. package/templates/radar-chart/node_modules/.bin/tsx +21 -0
  162. package/templates/radar-chart/node_modules/.bin/vite +21 -0
  163. package/templates/radar-chart/node_modules/.vite/deps/_metadata.json +50 -0
  164. package/templates/radar-chart/node_modules/.vite/deps/package.json +3 -0
  165. package/templates/radar-chart/node_modules/.vite/deps/react-CsV5wVHy.js +770 -0
  166. package/templates/radar-chart/node_modules/.vite/deps/react-CsV5wVHy.js.map +1 -0
  167. package/templates/radar-chart/node_modules/.vite/deps/react-dom.js +185 -0
  168. package/templates/radar-chart/node_modules/.vite/deps/react-dom.js.map +1 -0
  169. package/templates/radar-chart/node_modules/.vite/deps/react-dom_client.js +14384 -0
  170. package/templates/radar-chart/node_modules/.vite/deps/react-dom_client.js.map +1 -0
  171. package/templates/radar-chart/node_modules/.vite/deps/react.js +2 -0
  172. package/templates/radar-chart/node_modules/.vite/deps/react_jsx-dev-runtime.js +204 -0
  173. package/templates/radar-chart/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -0
  174. package/templates/radar-chart/node_modules/.vite/deps/react_jsx-runtime.js +208 -0
  175. package/templates/radar-chart/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -0
  176. package/templates/radar-chart/node_modules/.vite/deps/valibot.js +6623 -0
  177. package/templates/radar-chart/node_modules/.vite/deps/valibot.js.map +1 -0
  178. package/templates/radar-chart/package.json +30 -0
  179. package/templates/radar-chart/src/index.css +44 -0
  180. package/templates/radar-chart/src/index.tsx +531 -0
  181. package/templates/radar-chart/tsconfig.json +17 -0
  182. package/templates/radar-chart/vite.config.ts +8 -0
  183. package/templates/table-view/README.md +43 -0
  184. package/templates/table-view/_gitignore +2 -0
  185. package/templates/table-view/custom_blocks.json +9 -0
  186. package/templates/table-view/dist/assets/index-Bd8u_e4X.js +12 -0
  187. package/templates/table-view/dist/assets/index-BkZn3aQZ.css +1 -0
  188. package/templates/table-view/dist/custom_blocks.json +9 -0
  189. package/templates/table-view/dist/index.html +16 -0
  190. package/templates/table-view/index.html +15 -0
  191. package/templates/table-view/node_modules/.bin/esbuild +21 -0
  192. package/templates/table-view/node_modules/.bin/jiti +21 -0
  193. package/templates/table-view/node_modules/.bin/rollup +21 -0
  194. package/templates/table-view/node_modules/.bin/tsc +21 -0
  195. package/templates/table-view/node_modules/.bin/tsserver +21 -0
  196. package/templates/table-view/node_modules/.bin/tsx +21 -0
  197. package/templates/table-view/node_modules/.bin/vite +21 -0
  198. package/templates/table-view/node_modules/.vite/deps/@tanstack_react-table.js +2809 -0
  199. package/templates/table-view/node_modules/.vite/deps/@tanstack_react-table.js.map +1 -0
  200. package/templates/table-view/node_modules/.vite/deps/_metadata.json +56 -0
  201. package/templates/table-view/node_modules/.vite/deps/package.json +3 -0
  202. package/templates/table-view/node_modules/.vite/deps/react-D5jdVkJj.js +790 -0
  203. package/templates/table-view/node_modules/.vite/deps/react-D5jdVkJj.js.map +1 -0
  204. package/templates/table-view/node_modules/.vite/deps/react-dom.js +185 -0
  205. package/templates/table-view/node_modules/.vite/deps/react-dom.js.map +1 -0
  206. package/templates/table-view/node_modules/.vite/deps/react-dom_client.js +14384 -0
  207. package/templates/table-view/node_modules/.vite/deps/react-dom_client.js.map +1 -0
  208. package/templates/table-view/node_modules/.vite/deps/react.js +2 -0
  209. package/templates/table-view/node_modules/.vite/deps/react_jsx-dev-runtime.js +204 -0
  210. package/templates/table-view/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -0
  211. package/templates/table-view/node_modules/.vite/deps/react_jsx-runtime.js +208 -0
  212. package/templates/table-view/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -0
  213. package/templates/table-view/node_modules/.vite/deps/valibot.js +6623 -0
  214. package/templates/table-view/node_modules/.vite/deps/valibot.js.map +1 -0
  215. package/templates/table-view/package.json +31 -0
  216. package/templates/table-view/src/index.css +256 -0
  217. package/templates/table-view/src/index.tsx +1814 -0
  218. package/templates/table-view/src/table-model.ts +663 -0
  219. package/templates/table-view/tsconfig.json +17 -0
  220. package/templates/table-view/vite.config.ts +8 -0
  221. package/templates/us-heatmap/node_modules/.bin/tsc +21 -0
  222. package/templates/us-heatmap/node_modules/.bin/tsserver +21 -0
  223. package/templates/us-heatmap/node_modules/.bin/vite +21 -0
  224. package/templates/us-heatmap/node_modules/.vite/deps/_metadata.json +50 -0
  225. package/templates/us-heatmap/node_modules/.vite/deps/package.json +3 -0
  226. package/templates/us-heatmap/node_modules/.vite/deps/react-CsV5wVHy.js +770 -0
  227. package/templates/us-heatmap/node_modules/.vite/deps/react-CsV5wVHy.js.map +1 -0
  228. package/templates/us-heatmap/node_modules/.vite/deps/react-dom.js +185 -0
  229. package/templates/us-heatmap/node_modules/.vite/deps/react-dom.js.map +1 -0
  230. package/templates/us-heatmap/node_modules/.vite/deps/react-dom_client.js +14384 -0
  231. package/templates/us-heatmap/node_modules/.vite/deps/react-dom_client.js.map +1 -0
  232. package/templates/us-heatmap/node_modules/.vite/deps/react.js +2 -0
  233. package/templates/us-heatmap/node_modules/.vite/deps/react_jsx-dev-runtime.js +204 -0
  234. package/templates/us-heatmap/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -0
  235. package/templates/us-heatmap/node_modules/.vite/deps/react_jsx-runtime.js +208 -0
  236. package/templates/us-heatmap/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -0
  237. package/templates/us-heatmap/node_modules/.vite/deps/valibot.js +6623 -0
  238. package/templates/us-heatmap/node_modules/.vite/deps/valibot.js.map +1 -0
@@ -0,0 +1,1963 @@
1
+ import {
2
+ type NotionCreatePagePosition,
3
+ NotionCustomBlock,
4
+ type NotionCustomBlockContext,
5
+ type NotionDataSource,
6
+ type NotionDataSourceId,
7
+ type NotionPage,
8
+ type NotionPageId,
9
+ pages,
10
+ useCustomBlockContext,
11
+ useDataSourceDefinitions,
12
+ useTheme,
13
+ } from "ncblock"
14
+ import React, {
15
+ Component,
16
+ useCallback,
17
+ useEffect,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ } from "react"
22
+ import ReactDOM from "react-dom/client"
23
+
24
+ import "./index.css"
25
+
26
+ type LogEntry = {
27
+ id: number
28
+ time: string
29
+ direction: "sent" | "received"
30
+ type: string
31
+ data: unknown
32
+ }
33
+
34
+ type ThemeOverride = "host" | "light" | "dark"
35
+ type TrackedPage = {
36
+ page: NotionPage
37
+ draftTitle: string
38
+ draftIcon: string
39
+ }
40
+
41
+ const cardClass =
42
+ "mb-3 rounded-lg border border-(--border) bg-(--card-bg) px-4 py-3"
43
+ const labelClass =
44
+ "text-[10px] font-semibold uppercase tracking-[0.06em] text-(--muted)"
45
+ const metaTextClass =
46
+ "font-mono text-[11px] font-normal normal-case tracking-normal text-(--muted)"
47
+
48
+ function App() {
49
+ const ctx = useCustomBlockContext()
50
+ const hostThemeValue = useTheme()
51
+ const dataSources = useDataSourceDefinitions()
52
+ const [isPaused, setIsPaused] = useState(false)
53
+ const { log, initData, send, clearLog } = useMessageLog(isPaused)
54
+ const [themeOverride, setThemeOverride] = useState<ThemeOverride>("host")
55
+ const inIframe = window.parent !== window
56
+ const hostTheme = hostThemeValue === "dark" ? "dark" : "light"
57
+ const theme = themeOverride === "host" ? hostTheme : themeOverride
58
+ const { toast, copy } = useCopyToast()
59
+ const viewport = useViewportInfo()
60
+ const readyAt = useReadyTimestamp(true)
61
+ const elapsedSeconds = useElapsedSeconds(readyAt)
62
+
63
+ const status = !inIframe
64
+ ? { label: "Standalone", color: "var(--status-standalone)" }
65
+ : {
66
+ label: `Connected${
67
+ elapsedSeconds !== null ? ` · ${formatElapsed(elapsedSeconds)}` : ""
68
+ }`,
69
+ color: "var(--status-connected)",
70
+ }
71
+
72
+ const themeVars = {
73
+ "--status-color": status.color,
74
+ } as React.CSSProperties
75
+
76
+ const contextValue = {
77
+ theme: hostThemeValue,
78
+ customBlockId: ctx?.customBlockId,
79
+ parent: ctx?.parent,
80
+ page: ctx?.page,
81
+ dataSources,
82
+ }
83
+
84
+ const metadataRows: Array<[string, string]> = [
85
+ ["origin", window.location.origin],
86
+ ["url", window.location.href],
87
+ ["sandbox", inIframe ? "iframe" : "standalone"],
88
+ [
89
+ "theme",
90
+ `${hostThemeValue ?? "unknown"}${themeOverride !== "host" ? ` (override: ${themeOverride})` : ""}`,
91
+ ],
92
+ ["bridgeProtocolVersion", getBridgeProtocolVersion(initData) ?? "\u2014"],
93
+ ["viewport", `${viewport.innerWidth} \u00d7 ${viewport.innerHeight}`],
94
+ ["contentHeight", `${viewport.scrollHeight}px`],
95
+ ["devicePixelRatio", String(viewport.devicePixelRatio)],
96
+ ]
97
+
98
+ return (
99
+ <div
100
+ data-theme={theme}
101
+ className="bg-(--app-bg) p-8 text-(--foreground)"
102
+ style={themeVars}
103
+ >
104
+ <div className="mb-6 flex flex-wrap items-center justify-between gap-3">
105
+ <h1 className="text-[32px] font-bold leading-tight tracking-[-0.03em]">
106
+ Debug
107
+ </h1>
108
+ <div className="flex items-center gap-3">
109
+ <div className="flex items-center gap-1.5 leading-none">
110
+ <span
111
+ className="inline-block h-2 w-2 rounded-full bg-(--status-color)"
112
+ aria-hidden="true"
113
+ />
114
+ <span className="text-[13px] font-medium leading-none text-(--status-color)">
115
+ {status.label}
116
+ </span>
117
+ </div>
118
+ <ThemeToggle value={themeOverride} onChange={setThemeOverride} />
119
+ </div>
120
+ </div>
121
+
122
+ {/* Connection metadata */}
123
+ <CollapsibleCard
124
+ label="Metadata"
125
+ headerExtra={
126
+ <span className={metaTextClass}>{metadataRows.length} fields</span>
127
+ }
128
+ headerActions={
129
+ <IconButton
130
+ label="Copy metadata JSON"
131
+ onClick={() =>
132
+ copy(serializeForCopy(Object.fromEntries(metadataRows)))
133
+ }
134
+ >
135
+ <CopyIcon />
136
+ </IconButton>
137
+ }
138
+ >
139
+ <div className="rounded-lg border border-(--border) bg-(--app-bg) p-1">
140
+ {metadataRows.map(([key, val]) => (
141
+ <MetadataRow key={key} label={key} value={val} onCopy={copy} />
142
+ ))}
143
+ </div>
144
+ </CollapsibleCard>
145
+
146
+ {/* Context state */}
147
+ <CollapsibleCard
148
+ label="Init payload"
149
+ headerExtra={
150
+ <span className={metaTextClass}>
151
+ {Object.keys(contextValue).length} keys
152
+ </span>
153
+ }
154
+ headerActions={
155
+ <IconButton
156
+ label="Copy context JSON"
157
+ onClick={() => copy(serializeForCopy(contextValue))}
158
+ >
159
+ <CopyIcon />
160
+ </IconButton>
161
+ }
162
+ >
163
+ <JsonPanel
164
+ value={contextValue}
165
+ onCopy={copy}
166
+ defaultExpandedDepth={2}
167
+ />
168
+ </CollapsibleCard>
169
+
170
+ {/* Create page */}
171
+ <CreatePageCard context={ctx} dataSources={dataSources} />
172
+
173
+ {/* Send message */}
174
+ <SendMessageCard onSend={send} />
175
+
176
+ {/* Events log */}
177
+ <EventsLog
178
+ log={log}
179
+ onCopy={copy}
180
+ onClear={clearLog}
181
+ isPaused={isPaused}
182
+ onPauseChange={setIsPaused}
183
+ />
184
+
185
+ {/* Copy toast */}
186
+ {toast !== null && (
187
+ <div className="fixed bottom-5 left-1/2 z-1000 max-w-[80%] -translate-x-1/2 overflow-hidden text-ellipsis whitespace-nowrap rounded-lg border border-(--border) bg-(--toast-bg) px-3.5 py-1.5 font-mono text-xs text-(--toast-fg) shadow-(--toast-shadow)">
188
+ Copied: {toast}
189
+ </div>
190
+ )}
191
+ </div>
192
+ )
193
+ }
194
+
195
+ // --- Hooks ---
196
+
197
+ function useMessageLog(isPaused: boolean) {
198
+ const [log, setLog] = useState<LogEntry[]>([])
199
+ const [initData, setInitData] = useState<unknown>(null)
200
+ const idRef = useRef(0)
201
+ const pausedRef = useRef(isPaused)
202
+ pausedRef.current = isPaused
203
+ const initSeenRef = useRef(false)
204
+
205
+ useEffect(() => {
206
+ const inIframe = window.parent !== window
207
+ const handler = (event: MessageEvent) => {
208
+ if (!inIframe || event.source !== window.parent) {
209
+ return
210
+ }
211
+ const d = event.data
212
+ if (
213
+ !d ||
214
+ typeof d !== "object" ||
215
+ typeof (d as { type?: unknown }).type !== "string"
216
+ ) {
217
+ return
218
+ }
219
+ const type = (d as { type: string }).type
220
+ if (type === "init" && !initSeenRef.current) {
221
+ initSeenRef.current = true
222
+ setInitData(d)
223
+ }
224
+ if (pausedRef.current) {
225
+ return
226
+ }
227
+ setLog(prev => [
228
+ ...prev,
229
+ {
230
+ id: idRef.current++,
231
+ time: formatTime(new Date()),
232
+ direction: "received",
233
+ type,
234
+ data: d,
235
+ },
236
+ ])
237
+ }
238
+ window.addEventListener("message", handler)
239
+ return () => {
240
+ window.removeEventListener("message", handler)
241
+ }
242
+ }, [])
243
+
244
+ const send = useCallback((message: Record<string, unknown>) => {
245
+ window.parent.postMessage(message, "*")
246
+ const type = typeof message.type === "string" ? message.type : "unknown"
247
+ setLog(prev => [
248
+ ...prev,
249
+ {
250
+ id: idRef.current++,
251
+ time: formatTime(new Date()),
252
+ direction: "sent",
253
+ type,
254
+ data: message,
255
+ },
256
+ ])
257
+ }, [])
258
+
259
+ const clearLog = useCallback(() => setLog([]), [])
260
+
261
+ return { log, initData, send, clearLog }
262
+ }
263
+
264
+ function useReadyTimestamp(isReady: boolean): number | null {
265
+ const [readyAt, setReadyAt] = useState<number | null>(null)
266
+ useEffect(() => {
267
+ if (isReady && readyAt === null) {
268
+ setReadyAt(Date.now())
269
+ }
270
+ }, [isReady, readyAt])
271
+ return readyAt
272
+ }
273
+
274
+ function useCopyToast() {
275
+ const [toast, setToast] = useState<string | null>(null)
276
+
277
+ useEffect(() => {
278
+ if (toast === null) {
279
+ return
280
+ }
281
+ const t = setTimeout(() => setToast(null), 1200)
282
+ return () => clearTimeout(t)
283
+ }, [toast])
284
+
285
+ const copy = (text: string) => {
286
+ // navigator.clipboard.writeText is blocked inside sandboxed iframes,
287
+ // so fall back to execCommand("copy") with a temporary textarea.
288
+ try {
289
+ const textarea = document.createElement("textarea")
290
+ textarea.value = text
291
+ textarea.style.position = "fixed"
292
+ textarea.style.opacity = "0"
293
+ document.body.appendChild(textarea)
294
+ textarea.select()
295
+ document.execCommand("copy")
296
+ document.body.removeChild(textarea)
297
+ } catch {
298
+ // Silently fail if even execCommand is blocked
299
+ }
300
+ setToast(text.length > 40 ? text.slice(0, 40) + "\u2026" : text)
301
+ }
302
+
303
+ return { toast, copy }
304
+ }
305
+
306
+ function useElapsedSeconds(from: number | null) {
307
+ const [now, setNow] = useState(() => Date.now())
308
+ useEffect(() => {
309
+ if (from === null) {
310
+ return
311
+ }
312
+ setNow(Date.now())
313
+ const id = setInterval(() => setNow(Date.now()), 1000)
314
+ return () => clearInterval(id)
315
+ }, [from])
316
+ if (from === null) {
317
+ return null
318
+ }
319
+ return Math.max(0, Math.floor((now - from) / 1000))
320
+ }
321
+
322
+ function useViewportInfo() {
323
+ const [info, setInfo] = useState(() => captureViewportInfo())
324
+ useEffect(() => {
325
+ const onChange = () => setInfo(captureViewportInfo())
326
+ window.addEventListener("resize", onChange)
327
+ const root = document.getElementById("root")
328
+ let ro: ResizeObserver | null = null
329
+ if (root && typeof ResizeObserver !== "undefined") {
330
+ ro = new ResizeObserver(onChange)
331
+ ro.observe(root)
332
+ }
333
+ return () => {
334
+ window.removeEventListener("resize", onChange)
335
+ ro?.disconnect()
336
+ }
337
+ }, [])
338
+ return info
339
+ }
340
+
341
+ function captureViewportInfo() {
342
+ const root = document.getElementById("root")
343
+ return {
344
+ innerWidth: window.innerWidth,
345
+ innerHeight: window.innerHeight,
346
+ scrollHeight: root?.scrollHeight ?? document.documentElement.scrollHeight,
347
+ devicePixelRatio: window.devicePixelRatio,
348
+ }
349
+ }
350
+
351
+ function useOutsideClick(
352
+ ref: React.RefObject<HTMLElement | null>,
353
+ open: boolean,
354
+ onClose: () => void,
355
+ ) {
356
+ useEffect(() => {
357
+ if (!open) {
358
+ return
359
+ }
360
+ const onDoc = (event: MouseEvent) => {
361
+ if (ref.current && !ref.current.contains(event.target as Node)) {
362
+ onClose()
363
+ }
364
+ }
365
+ document.addEventListener("mousedown", onDoc)
366
+ return () => document.removeEventListener("mousedown", onDoc)
367
+ }, [open, onClose, ref])
368
+ }
369
+
370
+ // --- Components ---
371
+
372
+ function CollapsibleCard({
373
+ label,
374
+ headerActions,
375
+ headerExtra,
376
+ defaultOpen = true,
377
+ children,
378
+ }: {
379
+ label: string
380
+ headerActions?: React.ReactNode
381
+ headerExtra?: React.ReactNode
382
+ defaultOpen?: boolean
383
+ children: React.ReactNode
384
+ }) {
385
+ const [open, setOpen] = useState(defaultOpen)
386
+ return (
387
+ <div className={cardClass}>
388
+ <div className={`-mx-1 flex items-center gap-2 ${open ? "mb-2.5" : ""}`}>
389
+ <button
390
+ type="button"
391
+ aria-expanded={open}
392
+ onClick={() => setOpen(o => !o)}
393
+ className="group/trigger flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1 text-left outline-none transition-colors duration-150 hover:bg-(--hover-bg) focus-visible:ring-1 focus-visible:ring-(--button-bg)"
394
+ >
395
+ <ChevronIcon isOpen={open} />
396
+ <span
397
+ className={`${labelClass} transition-opacity duration-150 group-hover/trigger:text-(--foreground)`}
398
+ >
399
+ {label}
400
+ </span>
401
+ {headerExtra && (
402
+ <span className="flex min-w-0 items-center gap-1.5 truncate">
403
+ {headerExtra}
404
+ </span>
405
+ )}
406
+ </button>
407
+ {headerActions && (
408
+ <div
409
+ className="flex shrink-0 items-center gap-0.5"
410
+ onClick={e => e.stopPropagation()}
411
+ >
412
+ {headerActions}
413
+ </div>
414
+ )}
415
+ </div>
416
+ {open && children}
417
+ </div>
418
+ )
419
+ }
420
+
421
+ function MetadataRow({
422
+ label,
423
+ value,
424
+ onCopy,
425
+ }: {
426
+ label: string
427
+ value: string
428
+ onCopy: (text: string) => void
429
+ }) {
430
+ return (
431
+ <div className="group flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-(--hover-bg)">
432
+ <span className="w-3.5 shrink-0" />
433
+ <span className="shrink-0 font-mono text-[12px] text-(--muted)">
434
+ {label}
435
+ </span>
436
+ <span className="shrink-0 font-mono text-[12px] opacity-60">:</span>
437
+ <span className="min-w-0 flex-1 break-all font-mono text-[12px] leading-5 text-(--foreground)">
438
+ {value}
439
+ </span>
440
+ <div className="shrink-0 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100">
441
+ <CopyValueButton
442
+ label={`Copy ${label}`}
443
+ onClick={() => onCopy(value)}
444
+ />
445
+ </div>
446
+ </div>
447
+ )
448
+ }
449
+
450
+ function ThemeToggle({
451
+ value,
452
+ onChange,
453
+ }: {
454
+ value: ThemeOverride
455
+ onChange: (value: ThemeOverride) => void
456
+ }) {
457
+ const options: { id: ThemeOverride; label: string }[] = [
458
+ { id: "host", label: "Host" },
459
+ { id: "light", label: "Light" },
460
+ { id: "dark", label: "Dark" },
461
+ ]
462
+ return (
463
+ <div
464
+ role="group"
465
+ aria-label="Theme override"
466
+ className="inline-flex rounded-md border border-(--border) p-0.5 text-[11px]"
467
+ >
468
+ {options.map(option => {
469
+ const active = option.id === value
470
+ return (
471
+ <button
472
+ key={option.id}
473
+ type="button"
474
+ onClick={() => onChange(option.id)}
475
+ className={`rounded-[4px] px-2 py-0.5 font-medium outline-none transition-colors ${
476
+ active
477
+ ? "bg-(--button-bg) text-(--button-fg)"
478
+ : "text-(--muted) hover:bg-(--hover-bg) hover:text-(--foreground)"
479
+ }`}
480
+ >
481
+ {option.label}
482
+ </button>
483
+ )
484
+ })}
485
+ </div>
486
+ )
487
+ }
488
+
489
+ function JsonPanel({
490
+ value,
491
+ onCopy,
492
+ defaultExpandedDepth = 2,
493
+ embedded = false,
494
+ }: {
495
+ value: unknown
496
+ onCopy: (text: string) => void
497
+ defaultExpandedDepth?: number
498
+ embedded?: boolean
499
+ }) {
500
+ const expandable = isExpandableJsonValue(value)
501
+ const entries = expandable
502
+ ? Array.isArray(value)
503
+ ? value.map((item, index) => [String(index), item] as const)
504
+ : Object.entries(value)
505
+ : []
506
+
507
+ const containerClass = embedded
508
+ ? ""
509
+ : "max-h-[360px] overflow-auto rounded-lg border border-(--border) bg-(--app-bg) p-1"
510
+
511
+ return (
512
+ <div className={containerClass}>
513
+ {expandable ? (
514
+ entries.length === 0 ? (
515
+ <div className="px-2 py-1.5 font-mono text-[11px] italic text-(--muted)">
516
+ Empty {Array.isArray(value) ? "array" : "object"}
517
+ </div>
518
+ ) : (
519
+ <div>
520
+ {entries.map(([childKey, childValue]) => (
521
+ <JsonNode
522
+ key={childKey}
523
+ name={childKey}
524
+ value={childValue}
525
+ path={childKey}
526
+ depth={0}
527
+ onCopy={onCopy}
528
+ defaultExpandedDepth={defaultExpandedDepth}
529
+ />
530
+ ))}
531
+ </div>
532
+ )
533
+ ) : (
534
+ <JsonNode
535
+ name="value"
536
+ value={value}
537
+ path="value"
538
+ depth={0}
539
+ onCopy={onCopy}
540
+ defaultExpandedDepth={defaultExpandedDepth}
541
+ />
542
+ )}
543
+ </div>
544
+ )
545
+ }
546
+
547
+ function JsonNode({
548
+ name,
549
+ value,
550
+ path,
551
+ depth,
552
+ onCopy,
553
+ defaultExpandedDepth,
554
+ }: {
555
+ name: string
556
+ value: unknown
557
+ path: string
558
+ depth: number
559
+ onCopy: (text: string) => void
560
+ defaultExpandedDepth: number
561
+ }) {
562
+ const expandable = isExpandableJsonValue(value)
563
+ const [isOpen, setIsOpen] = useState(depth < defaultExpandedDepth)
564
+
565
+ if (!expandable) {
566
+ return (
567
+ <div className="group flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-(--hover-bg)">
568
+ <span className="w-3.5 shrink-0" />
569
+ <span className="shrink-0 font-mono text-[12px] text-(--muted)">
570
+ {name}
571
+ </span>
572
+ <span className="shrink-0 font-mono text-[12px] opacity-60">:</span>
573
+ <span className="min-w-0 flex-1 break-all font-mono text-[12px] leading-5 text-(--foreground)">
574
+ {formatPrimitiveValue(value)}
575
+ </span>
576
+ <div className="shrink-0 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100">
577
+ <CopyValueButton
578
+ label={`Copy ${name}`}
579
+ onClick={() => onCopy(serializeForCopy(value))}
580
+ />
581
+ </div>
582
+ </div>
583
+ )
584
+ }
585
+
586
+ const entries = Array.isArray(value)
587
+ ? value.map((item, index) => [String(index), item] as const)
588
+ : Object.entries(value)
589
+ const showCopyButton = depth > 0
590
+
591
+ return (
592
+ <div>
593
+ <div className="group flex items-stretch gap-1 rounded-md hover:bg-(--hover-bg)">
594
+ <button
595
+ type="button"
596
+ aria-expanded={isOpen}
597
+ onClick={() => setIsOpen(open => !open)}
598
+ className="flex min-h-7 min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1 text-left outline-none focus-visible:ring-1 focus-visible:ring-(--button-bg)"
599
+ >
600
+ <ChevronIcon isOpen={isOpen} />
601
+ <span className="shrink-0 font-mono text-[12px] text-(--muted)">
602
+ {name}
603
+ </span>
604
+ <span className="shrink-0 rounded-full bg-(--chip-bg) px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.04em] text-(--muted)">
605
+ {getJsonTypeLabel(value)}
606
+ </span>
607
+ <span className="min-w-0 truncate font-mono text-[11px] text-(--muted) opacity-70">
608
+ {formatInlinePreview(value)}
609
+ </span>
610
+ </button>
611
+ {showCopyButton && (
612
+ <div className="flex shrink-0 items-center pr-1 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100">
613
+ <CopyValueButton
614
+ label={`Copy ${name}`}
615
+ onClick={() => onCopy(serializeForCopy(value))}
616
+ />
617
+ </div>
618
+ )}
619
+ </div>
620
+ {isOpen && (
621
+ <div className="ml-[9px] border-l border-(--border-subtle) pl-1">
622
+ {entries.length === 0 ? (
623
+ <div className="px-2.5 py-1 font-mono text-[11px] italic text-(--muted)">
624
+ Empty {Array.isArray(value) ? "array" : "object"}
625
+ </div>
626
+ ) : (
627
+ <div>
628
+ {entries.map(([childKey, childValue]) => (
629
+ <JsonNode
630
+ key={`${path}.${childKey}`}
631
+ name={childKey}
632
+ value={childValue}
633
+ path={`${path}.${childKey}`}
634
+ depth={depth + 1}
635
+ onCopy={onCopy}
636
+ defaultExpandedDepth={defaultExpandedDepth}
637
+ />
638
+ ))}
639
+ </div>
640
+ )}
641
+ </div>
642
+ )}
643
+ </div>
644
+ )
645
+ }
646
+
647
+ function EventsLog({
648
+ log,
649
+ onCopy,
650
+ onClear,
651
+ isPaused,
652
+ onPauseChange,
653
+ }: {
654
+ log: LogEntry[]
655
+ onCopy: (text: string) => void
656
+ onClear: () => void
657
+ isPaused: boolean
658
+ onPauseChange: (paused: boolean) => void
659
+ }) {
660
+ const [excludedDirections, setExcludedDirections] = useState<
661
+ Set<"sent" | "received">
662
+ >(() => new Set())
663
+ const [excludedTypes, setExcludedTypes] = useState<Set<string>>(
664
+ () => new Set(),
665
+ )
666
+ const [expanded, setExpanded] = useState<Set<number>>(() => new Set())
667
+ const [filterOpen, setFilterOpen] = useState(false)
668
+
669
+ const availableTypes = useMemo(() => {
670
+ const set = new Set<string>()
671
+ for (const entry of log) {
672
+ set.add(entry.type)
673
+ }
674
+ return [...set].sort()
675
+ }, [log])
676
+
677
+ const filtered = useMemo(
678
+ () =>
679
+ log.filter(
680
+ entry =>
681
+ !excludedDirections.has(entry.direction) &&
682
+ !excludedTypes.has(entry.type),
683
+ ),
684
+ [log, excludedDirections, excludedTypes],
685
+ )
686
+
687
+ const sentCount = useMemo(
688
+ () => log.filter(entry => entry.direction === "sent").length,
689
+ [log],
690
+ )
691
+ const receivedCount = log.length - sentCount
692
+ const hasFilter = excludedDirections.size > 0 || excludedTypes.size > 0
693
+
694
+ const toggleExpanded = useCallback((id: number) => {
695
+ setExpanded(prev => {
696
+ const next = new Set(prev)
697
+ if (next.has(id)) {
698
+ next.delete(id)
699
+ } else {
700
+ next.add(id)
701
+ }
702
+ return next
703
+ })
704
+ }, [])
705
+
706
+ const clearFilters = useCallback(() => {
707
+ setExcludedDirections(new Set())
708
+ setExcludedTypes(new Set())
709
+ }, [])
710
+
711
+ return (
712
+ <CollapsibleCard
713
+ label="Events"
714
+ headerExtra={
715
+ <>
716
+ <span className={metaTextClass}>
717
+ <span className="text-(--sent-color)">
718
+ {"\u2191"} {sentCount}
719
+ </span>
720
+ <span className="mx-1 opacity-40">·</span>
721
+ <span className="text-(--received-color)">
722
+ {"\u2193"} {receivedCount}
723
+ </span>
724
+ {hasFilter && log.length !== filtered.length && (
725
+ <span className="ml-2 opacity-60">({filtered.length} shown)</span>
726
+ )}
727
+ </span>
728
+ {isPaused && (
729
+ <span className="rounded-full bg-(--chip-bg) px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.04em] text-(--status-waiting)">
730
+ Paused
731
+ </span>
732
+ )}
733
+ </>
734
+ }
735
+ headerActions={
736
+ <>
737
+ <FilterMenu
738
+ open={filterOpen}
739
+ onOpenChange={setFilterOpen}
740
+ availableTypes={availableTypes}
741
+ excludedDirections={excludedDirections}
742
+ excludedTypes={excludedTypes}
743
+ onExcludedDirectionsChange={setExcludedDirections}
744
+ onExcludedTypesChange={setExcludedTypes}
745
+ onClearFilters={clearFilters}
746
+ active={hasFilter}
747
+ />
748
+ <IconButton
749
+ label={isPaused ? "Resume logging" : "Pause logging"}
750
+ onClick={() => onPauseChange(!isPaused)}
751
+ >
752
+ {isPaused ? <PlayIcon /> : <PauseIcon />}
753
+ </IconButton>
754
+ <IconButton label="Clear events" onClick={onClear}>
755
+ <TrashIcon />
756
+ </IconButton>
757
+ </>
758
+ }
759
+ >
760
+ <div className="max-h-[400px] overflow-auto rounded-lg border border-(--border) bg-(--app-bg) p-1 font-mono">
761
+ {filtered.length === 0 && (
762
+ <div className="px-2 py-1.5 text-[11px] italic text-(--muted)">
763
+ {log.length === 0
764
+ ? "No events yet"
765
+ : "No events match the current filters"}
766
+ </div>
767
+ )}
768
+ {[...filtered].reverse().map(entry => {
769
+ const isOpen = expanded.has(entry.id)
770
+ return (
771
+ <div key={entry.id} className="group">
772
+ <div
773
+ onClick={() => toggleExpanded(entry.id)}
774
+ className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-(--hover-bg)"
775
+ >
776
+ <ChevronIcon isOpen={isOpen} />
777
+ <span
778
+ className={`shrink-0 text-[13px] font-bold leading-4 ${
779
+ entry.direction === "sent"
780
+ ? "text-(--sent-color)"
781
+ : "text-(--received-color)"
782
+ }`}
783
+ >
784
+ {entry.direction === "sent" ? "\u2191" : "\u2193"}
785
+ </span>
786
+ <span className="shrink-0 text-[11px] opacity-35">
787
+ {entry.time}
788
+ </span>
789
+ <span className="shrink-0 rounded-full bg-(--chip-bg) px-1.5 py-0.5 text-[10px] text-(--muted)">
790
+ {entry.type}
791
+ </span>
792
+ <span className="min-w-0 flex-1 truncate text-[11px] text-(--muted) opacity-80">
793
+ {formatInlinePreview(entry.data)}
794
+ </span>
795
+ <div
796
+ className="shrink-0 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100"
797
+ onClick={e => e.stopPropagation()}
798
+ >
799
+ <CopyValueButton
800
+ label={`Copy ${entry.type} event`}
801
+ onClick={() => onCopy(serializeForCopy(entry.data))}
802
+ />
803
+ </div>
804
+ </div>
805
+ {isOpen && (
806
+ <div className="mb-1 ml-[9px] border-l border-(--border-subtle) pl-1">
807
+ <JsonPanel
808
+ value={entry.data}
809
+ onCopy={onCopy}
810
+ defaultExpandedDepth={3}
811
+ embedded
812
+ />
813
+ </div>
814
+ )}
815
+ </div>
816
+ )
817
+ })}
818
+ </div>
819
+ </CollapsibleCard>
820
+ )
821
+ }
822
+
823
+ function FilterMenu({
824
+ open,
825
+ onOpenChange,
826
+ availableTypes,
827
+ excludedDirections,
828
+ excludedTypes,
829
+ onExcludedDirectionsChange,
830
+ onExcludedTypesChange,
831
+ onClearFilters,
832
+ active,
833
+ }: {
834
+ open: boolean
835
+ onOpenChange: (open: boolean) => void
836
+ availableTypes: string[]
837
+ excludedDirections: Set<"sent" | "received">
838
+ excludedTypes: Set<string>
839
+ onExcludedDirectionsChange: (value: Set<"sent" | "received">) => void
840
+ onExcludedTypesChange: (value: Set<string>) => void
841
+ onClearFilters: () => void
842
+ active: boolean
843
+ }) {
844
+ const ref = useRef<HTMLDivElement>(null)
845
+ useOutsideClick(ref, open, () => onOpenChange(false))
846
+
847
+ const directions: { id: "sent" | "received"; label: string }[] = [
848
+ { id: "sent", label: "Sent" },
849
+ { id: "received", label: "Received" },
850
+ ]
851
+
852
+ return (
853
+ <div ref={ref} className="relative">
854
+ <IconButton label="Filter events" onClick={() => onOpenChange(!open)}>
855
+ <FilterIcon active={active} />
856
+ </IconButton>
857
+ {open && (
858
+ <div className="absolute right-0 top-full z-10 mt-1 w-56 rounded-lg border border-(--border) bg-(--card-bg) p-2 shadow-(--toast-shadow)">
859
+ <div className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.05em] opacity-40">
860
+ Direction
861
+ </div>
862
+ {directions.map(direction => (
863
+ <FilterCheckbox
864
+ key={direction.id}
865
+ label={direction.label}
866
+ checked={!excludedDirections.has(direction.id)}
867
+ onChange={checked => {
868
+ const next = new Set(excludedDirections)
869
+ if (checked) {
870
+ next.delete(direction.id)
871
+ } else {
872
+ next.add(direction.id)
873
+ }
874
+ onExcludedDirectionsChange(next)
875
+ }}
876
+ />
877
+ ))}
878
+ <div className="mt-2 mb-1 px-1 text-[10px] font-semibold uppercase tracking-[0.05em] opacity-40">
879
+ Types
880
+ </div>
881
+ {availableTypes.length === 0 ? (
882
+ <div className="px-1 py-1 font-mono text-[11px] italic text-(--muted)">
883
+ No types yet
884
+ </div>
885
+ ) : (
886
+ availableTypes.map(type => (
887
+ <FilterCheckbox
888
+ key={type}
889
+ label={type}
890
+ checked={!excludedTypes.has(type)}
891
+ onChange={checked => {
892
+ const next = new Set(excludedTypes)
893
+ if (checked) {
894
+ next.delete(type)
895
+ } else {
896
+ next.add(type)
897
+ }
898
+ onExcludedTypesChange(next)
899
+ }}
900
+ />
901
+ ))
902
+ )}
903
+ {active && (
904
+ <button
905
+ type="button"
906
+ onClick={onClearFilters}
907
+ className="mt-1 w-full rounded-md px-2 py-1 text-left font-mono text-[11px] text-(--muted) transition-colors hover:bg-(--hover-bg) hover:text-(--foreground)"
908
+ >
909
+ Clear filters
910
+ </button>
911
+ )}
912
+ </div>
913
+ )}
914
+ </div>
915
+ )
916
+ }
917
+
918
+ function FilterCheckbox({
919
+ label,
920
+ checked,
921
+ onChange,
922
+ }: {
923
+ label: string
924
+ checked: boolean
925
+ onChange: (checked: boolean) => void
926
+ }) {
927
+ return (
928
+ <label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-1 font-mono text-[12px] hover:bg-(--hover-bg)">
929
+ <input
930
+ type="checkbox"
931
+ checked={checked}
932
+ onChange={event => onChange(event.target.checked)}
933
+ className="h-3.5 w-3.5 cursor-pointer"
934
+ style={{ accentColor: "var(--button-bg)" }}
935
+ />
936
+ <span className="min-w-0 flex-1 truncate">{label}</span>
937
+ </label>
938
+ )
939
+ }
940
+
941
+ function IconButton({
942
+ label,
943
+ onClick,
944
+ children,
945
+ }: {
946
+ label: string
947
+ onClick: () => void
948
+ children: React.ReactNode
949
+ }) {
950
+ return (
951
+ <button
952
+ type="button"
953
+ aria-label={label}
954
+ title={label}
955
+ onClick={onClick}
956
+ className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-(--foreground) opacity-55 outline-none transition-[opacity,background-color] duration-150 ease-out hover:bg-(--hover-bg) hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1 focus-visible:ring-(--button-bg)"
957
+ >
958
+ {children}
959
+ </button>
960
+ )
961
+ }
962
+
963
+ function CopyValueButton({
964
+ label,
965
+ onClick,
966
+ }: {
967
+ label: string
968
+ onClick: () => void
969
+ }) {
970
+ return (
971
+ <IconButton label={label} onClick={onClick}>
972
+ <CopyIcon />
973
+ </IconButton>
974
+ )
975
+ }
976
+
977
+ function ChevronIcon({ isOpen }: { isOpen: boolean }) {
978
+ return (
979
+ <svg
980
+ aria-hidden="true"
981
+ viewBox="0 0 16 16"
982
+ className={`h-3.5 w-3.5 shrink-0 text-(--muted) transition-transform duration-150 ease-out ${
983
+ isOpen ? "rotate-90" : ""
984
+ }`}
985
+ fill="none"
986
+ stroke="currentColor"
987
+ strokeWidth="1.75"
988
+ strokeLinecap="round"
989
+ strokeLinejoin="round"
990
+ >
991
+ <path d="M6 4l4 4-4 4" />
992
+ </svg>
993
+ )
994
+ }
995
+
996
+ function CopyIcon() {
997
+ return (
998
+ <svg
999
+ aria-hidden="true"
1000
+ viewBox="0 0 16 16"
1001
+ className="h-3.5 w-3.5"
1002
+ fill="none"
1003
+ stroke="currentColor"
1004
+ strokeWidth="1.5"
1005
+ strokeLinecap="round"
1006
+ strokeLinejoin="round"
1007
+ >
1008
+ <rect x="6" y="6" width="8" height="8" rx="1.5" />
1009
+ <path d="M3 11V3.5C3 3.22 3.22 3 3.5 3H10" />
1010
+ </svg>
1011
+ )
1012
+ }
1013
+
1014
+ function PauseIcon() {
1015
+ return (
1016
+ <svg
1017
+ aria-hidden="true"
1018
+ viewBox="0 0 16 16"
1019
+ className="h-3.5 w-3.5"
1020
+ fill="currentColor"
1021
+ >
1022
+ <rect x="4.5" y="3.5" width="2.5" height="9" rx="0.75" />
1023
+ <rect x="9" y="3.5" width="2.5" height="9" rx="0.75" />
1024
+ </svg>
1025
+ )
1026
+ }
1027
+
1028
+ function PlayIcon() {
1029
+ return (
1030
+ <svg
1031
+ aria-hidden="true"
1032
+ viewBox="0 0 16 16"
1033
+ className="h-3.5 w-3.5"
1034
+ fill="currentColor"
1035
+ >
1036
+ <path d="M5 3.5v9l7-4.5-7-4.5z" />
1037
+ </svg>
1038
+ )
1039
+ }
1040
+
1041
+ function TrashIcon() {
1042
+ return (
1043
+ <svg
1044
+ aria-hidden="true"
1045
+ viewBox="0 0 16 16"
1046
+ className="h-3.5 w-3.5"
1047
+ fill="none"
1048
+ stroke="currentColor"
1049
+ strokeWidth="1.5"
1050
+ strokeLinecap="round"
1051
+ strokeLinejoin="round"
1052
+ >
1053
+ <path d="M3 4.5h10" />
1054
+ <path d="M6.5 4.5V3.25a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 .75.75V4.5" />
1055
+ <path d="M5 4.5v8a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-8" />
1056
+ <path d="M7 7v4M9 7v4" />
1057
+ </svg>
1058
+ )
1059
+ }
1060
+
1061
+ function FilterIcon({ active }: { active: boolean }) {
1062
+ return (
1063
+ <svg
1064
+ aria-hidden="true"
1065
+ viewBox="0 0 16 16"
1066
+ className="h-3.5 w-3.5"
1067
+ fill="none"
1068
+ stroke="currentColor"
1069
+ strokeWidth="1.5"
1070
+ strokeLinecap="round"
1071
+ strokeLinejoin="round"
1072
+ >
1073
+ <path d="M2.5 3.5h11l-4 5.5v3.5l-3 1V9L2.5 3.5z" />
1074
+ {active && (
1075
+ <circle
1076
+ cx="13"
1077
+ cy="3"
1078
+ r="2"
1079
+ fill="var(--status-waiting)"
1080
+ stroke="var(--card-bg)"
1081
+ strokeWidth="1"
1082
+ />
1083
+ )}
1084
+ </svg>
1085
+ )
1086
+ }
1087
+
1088
+ function HistoryIcon() {
1089
+ return (
1090
+ <svg
1091
+ aria-hidden="true"
1092
+ viewBox="0 0 16 16"
1093
+ className="h-3.5 w-3.5"
1094
+ fill="none"
1095
+ stroke="currentColor"
1096
+ strokeWidth="1.5"
1097
+ strokeLinecap="round"
1098
+ strokeLinejoin="round"
1099
+ >
1100
+ <path d="M2.5 8a5.5 5.5 0 1 0 1.7-3.97" />
1101
+ <path d="M2.5 3v3h3" />
1102
+ <path d="M8 5v3.5l2 1.25" />
1103
+ </svg>
1104
+ )
1105
+ }
1106
+
1107
+ /** Single-line JSON with spaces after colons and commas, but strings preserved intact. */
1108
+ function prettyJson(value: unknown): string {
1109
+ try {
1110
+ return JSON.stringify(value, null, 2).replace(/\n\s*/g, " ")
1111
+ } catch {
1112
+ return String(value)
1113
+ }
1114
+ }
1115
+
1116
+ function serializeForCopy(value: unknown) {
1117
+ try {
1118
+ return JSON.stringify(value, null, 2) ?? String(value)
1119
+ } catch {
1120
+ return String(value)
1121
+ }
1122
+ }
1123
+
1124
+ function isExpandableJsonValue(
1125
+ value: unknown,
1126
+ ): value is Record<string, unknown> | unknown[] {
1127
+ return value !== null && typeof value === "object"
1128
+ }
1129
+
1130
+ function getJsonTypeLabel(value: unknown) {
1131
+ if (Array.isArray(value)) {
1132
+ return `array ${value.length}`
1133
+ }
1134
+ if (value === null) {
1135
+ return "null"
1136
+ }
1137
+ if (typeof value === "object") {
1138
+ return `object ${Object.keys(value).length}`
1139
+ }
1140
+ return typeof value
1141
+ }
1142
+
1143
+ function formatPrimitiveValue(value: unknown) {
1144
+ if (typeof value === "string") {
1145
+ return JSON.stringify(value)
1146
+ }
1147
+ if (value === undefined) {
1148
+ return "undefined"
1149
+ }
1150
+ return String(value)
1151
+ }
1152
+
1153
+ function formatInlinePreview(value: unknown) {
1154
+ if (Array.isArray(value)) {
1155
+ if (value.length === 0) {
1156
+ return "[]"
1157
+ }
1158
+ const preview = value
1159
+ .slice(0, 3)
1160
+ .map(item => summarizePreviewToken(item))
1161
+ .join(", ")
1162
+ return value.length > 3 ? `${preview}, +${value.length - 3} more` : preview
1163
+ }
1164
+
1165
+ if (value !== null && typeof value === "object") {
1166
+ const keys = Object.keys(value)
1167
+ if (keys.length === 0) {
1168
+ return "{}"
1169
+ }
1170
+ const preview = keys.slice(0, 4).join(", ")
1171
+ return keys.length > 4 ? `${preview}, +${keys.length - 4} more` : preview
1172
+ }
1173
+
1174
+ return formatPrimitiveValue(value)
1175
+ }
1176
+
1177
+ function summarizePreviewToken(value: unknown) {
1178
+ if (Array.isArray(value)) {
1179
+ return `[${value.length}]`
1180
+ }
1181
+ if (value !== null && typeof value === "object") {
1182
+ return `{${Object.keys(value).length}}`
1183
+ }
1184
+ if (typeof value === "string") {
1185
+ return value.length > 18 ? `${value.slice(0, 18)}\u2026` : value
1186
+ }
1187
+ return String(value)
1188
+ }
1189
+
1190
+ function formatElapsed(seconds: number) {
1191
+ if (seconds < 60) {
1192
+ return `${seconds}s ago`
1193
+ }
1194
+ if (seconds < 3600) {
1195
+ const minutes = Math.floor(seconds / 60)
1196
+ return `${minutes}m ago`
1197
+ }
1198
+ const hours = Math.floor(seconds / 3600)
1199
+ return `${hours}h ago`
1200
+ }
1201
+
1202
+ function getBridgeProtocolVersion(initData: unknown): string | null {
1203
+ if (!initData || typeof initData !== "object") {
1204
+ return null
1205
+ }
1206
+ const v = (initData as { bridgeProtocolVersion?: unknown })
1207
+ .bridgeProtocolVersion
1208
+ if (typeof v === "number") {
1209
+ return String(v)
1210
+ }
1211
+ if (typeof v === "string") {
1212
+ return v
1213
+ }
1214
+ return null
1215
+ }
1216
+
1217
+ function formatTime(date: Date) {
1218
+ return date.toISOString().slice(11, 23)
1219
+ }
1220
+
1221
+ function getContentHeight() {
1222
+ const root = document.getElementById("root")
1223
+ return Math.ceil(root?.scrollHeight ?? document.body.scrollHeight)
1224
+ }
1225
+
1226
+ const PRESETS: {
1227
+ label: string
1228
+ createMessage: () => Record<string, unknown>
1229
+ }[] = [
1230
+ {
1231
+ label: "ready",
1232
+ createMessage: () => ({
1233
+ type: "ready",
1234
+ bridgeProtocolVersion: 1,
1235
+ manifest: null,
1236
+ }),
1237
+ },
1238
+ {
1239
+ label: "resize 900",
1240
+ createMessage: () => ({ type: "resize", height: 900 }),
1241
+ },
1242
+ {
1243
+ label: "resize: contentHeight",
1244
+ createMessage: () => ({ type: "resize", height: getContentHeight() }),
1245
+ },
1246
+ { label: "ping", createMessage: () => ({ type: "ping" }) },
1247
+ {
1248
+ label: "queryDataSource",
1249
+ createMessage: () => ({
1250
+ type: "queryDataSource",
1251
+ requestId: "debug-query",
1252
+ key: "default",
1253
+ limit: 20,
1254
+ }),
1255
+ },
1256
+ ]
1257
+
1258
+ type SendMessageCardProps = {
1259
+ onSend: (message: Record<string, unknown>) => void
1260
+ }
1261
+
1262
+ function SendMessageCard({ onSend }: SendMessageCardProps) {
1263
+ const [input, setInput] = useState('{ "type": "ready" }')
1264
+ const [history, setHistory] = useState<string[]>([])
1265
+ const [historyOpen, setHistoryOpen] = useState(false)
1266
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
1267
+ const historyRef = useRef<HTMLDivElement>(null)
1268
+
1269
+ const parsed = useMemo<
1270
+ | { ok: true; value: Record<string, unknown> }
1271
+ | { ok: false; error: string | null }
1272
+ >(() => {
1273
+ const trimmed = input.trim()
1274
+ if (!trimmed) {
1275
+ return { ok: false, error: null }
1276
+ }
1277
+ try {
1278
+ const value = JSON.parse(trimmed)
1279
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1280
+ return { ok: false, error: "Must be a JSON object" }
1281
+ }
1282
+ return { ok: true, value: value as Record<string, unknown> }
1283
+ } catch {
1284
+ return { ok: false, error: "Invalid JSON" }
1285
+ }
1286
+ }, [input])
1287
+
1288
+ useEffect(() => {
1289
+ const textarea = textareaRef.current
1290
+ if (!textarea) {
1291
+ return
1292
+ }
1293
+ textarea.style.height = "auto"
1294
+ textarea.style.height = `${Math.min(Math.max(textarea.scrollHeight, 32), 200)}px`
1295
+ }, [input])
1296
+
1297
+ useOutsideClick(historyRef, historyOpen, () => setHistoryOpen(false))
1298
+
1299
+ const handleSend = () => {
1300
+ if (!parsed.ok) {
1301
+ return
1302
+ }
1303
+ onSend(parsed.value)
1304
+ setHistory(prev => {
1305
+ if (prev[0] === input) {
1306
+ return prev
1307
+ }
1308
+ return [input, ...prev.filter(entry => entry !== input)].slice(0, 10)
1309
+ })
1310
+ }
1311
+
1312
+ return (
1313
+ <CollapsibleCard label="Send message">
1314
+ <div className="mb-2 flex flex-wrap gap-1.5">
1315
+ {PRESETS.map(preset => (
1316
+ <button
1317
+ key={preset.label}
1318
+ type="button"
1319
+ className="rounded-md border border-(--border) bg-transparent px-2 py-1 font-mono text-[11px] text-(--muted) transition-colors hover:border-(--foreground)/20 hover:bg-(--hover-bg) hover:text-(--foreground)"
1320
+ onClick={() => {
1321
+ const message = preset.createMessage()
1322
+ setInput(prettyJson(message))
1323
+ onSend(message)
1324
+ }}
1325
+ >
1326
+ {preset.label}
1327
+ </button>
1328
+ ))}
1329
+ </div>
1330
+ <textarea
1331
+ ref={textareaRef}
1332
+ value={input}
1333
+ onChange={event => setInput(event.target.value)}
1334
+ onKeyDown={event => {
1335
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
1336
+ event.preventDefault()
1337
+ handleSend()
1338
+ }
1339
+ }}
1340
+ rows={1}
1341
+ spellCheck={false}
1342
+ style={{ scrollbarWidth: "none" }}
1343
+ className={`block w-full resize-none rounded-md border bg-(--app-bg) px-2.5 py-1.5 font-mono text-[11px] leading-5 outline-none transition-colors [&::-webkit-scrollbar]:hidden focus:border-(--foreground) ${
1344
+ parsed.ok || parsed.error === null
1345
+ ? "border-(--border)"
1346
+ : "border-(--error-color)"
1347
+ }`}
1348
+ placeholder='{ "type": "..." }'
1349
+ />
1350
+ <div className="mt-2 flex items-center justify-between gap-2">
1351
+ <div className="flex min-w-0 items-center gap-2 font-mono text-[10px]">
1352
+ <span className="shrink-0 text-(--muted) opacity-70">
1353
+ {"\u2318\u23ce to send"}
1354
+ </span>
1355
+ {parsed.ok === false && parsed.error !== null && (
1356
+ <span className="min-w-0 truncate text-(--error-color)">
1357
+ {parsed.error}
1358
+ </span>
1359
+ )}
1360
+ </div>
1361
+ <div className="flex shrink-0 items-center gap-1">
1362
+ <div ref={historyRef} className="relative">
1363
+ <IconButton
1364
+ label="Recent sent messages"
1365
+ onClick={() => setHistoryOpen(open => !open)}
1366
+ >
1367
+ <HistoryIcon />
1368
+ </IconButton>
1369
+ {historyOpen && (
1370
+ <div className="absolute right-0 top-full z-10 mt-1 w-72 rounded-lg border border-(--border) bg-(--card-bg) p-1 shadow-(--toast-shadow)">
1371
+ {history.length === 0 ? (
1372
+ <div className="px-2 py-1.5 font-mono text-[11px] italic text-(--muted)">
1373
+ No sent messages yet
1374
+ </div>
1375
+ ) : (
1376
+ history.map((item, index) => (
1377
+ <button
1378
+ key={`${index}-${item}`}
1379
+ type="button"
1380
+ onClick={() => {
1381
+ setInput(item)
1382
+ setHistoryOpen(false)
1383
+ textareaRef.current?.focus()
1384
+ }}
1385
+ className="block w-full truncate rounded-md px-2 py-1 text-left font-mono text-[11px] text-(--foreground) hover:bg-(--hover-bg)"
1386
+ >
1387
+ {item}
1388
+ </button>
1389
+ ))
1390
+ )}
1391
+ </div>
1392
+ )}
1393
+ </div>
1394
+ <button
1395
+ type="button"
1396
+ disabled={!parsed.ok}
1397
+ onClick={handleSend}
1398
+ className="rounded-md border-0 bg-(--button-bg) px-3 py-1.5 font-mono text-[11px] font-semibold text-(--button-fg) outline-none transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
1399
+ >
1400
+ Send
1401
+ </button>
1402
+ </div>
1403
+ </div>
1404
+ </CollapsibleCard>
1405
+ )
1406
+ }
1407
+
1408
+ type ErrorBoundaryProps = {
1409
+ children: React.ReactNode
1410
+ }
1411
+
1412
+ type ErrorBoundaryState = {
1413
+ error: Error | null
1414
+ }
1415
+
1416
+ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
1417
+ state: ErrorBoundaryState = { error: null }
1418
+
1419
+ static getDerivedStateFromError(error: Error) {
1420
+ return { error }
1421
+ }
1422
+
1423
+ render() {
1424
+ if (this.state.error) {
1425
+ return (
1426
+ <div className="px-10 py-8 text-(--error-color)">
1427
+ <div className="mb-3 text-[28px] font-bold tracking-[-0.02em]">
1428
+ Error
1429
+ </div>
1430
+ <div className="break-all whitespace-pre-wrap rounded-lg border border-(--error-border) bg-(--error-surface) px-5 py-3 font-mono text-xs">
1431
+ {this.state.error.message}
1432
+ {this.state.error.stack && (
1433
+ <div className="mt-2 opacity-60">{this.state.error.stack}</div>
1434
+ )}
1435
+ </div>
1436
+ </div>
1437
+ )
1438
+ }
1439
+ return this.props.children
1440
+ }
1441
+ }
1442
+
1443
+ type CreatePageCardProps = {
1444
+ context: NotionCustomBlockContext | undefined
1445
+ dataSources: NotionDataSource[]
1446
+ }
1447
+
1448
+ type CreatePagePreset = {
1449
+ label: string
1450
+ description: string
1451
+ position?: NotionCreatePagePosition
1452
+ }
1453
+
1454
+ function presetPositions(
1455
+ customBlockId: string | undefined,
1456
+ ): CreatePagePreset[] {
1457
+ const presets: CreatePagePreset[] = [
1458
+ {
1459
+ label: "Top of page",
1460
+ description: "Append to the top of the page",
1461
+ position: { type: "start" },
1462
+ },
1463
+ {
1464
+ label: "Bottom of page",
1465
+ description: "Append to the bottom of the page",
1466
+ position: { type: "end" },
1467
+ },
1468
+ ]
1469
+ if (customBlockId !== undefined) {
1470
+ presets.push(
1471
+ {
1472
+ label: "Above this block",
1473
+ description: "Directly above the custom block",
1474
+ position: { type: "before", blockId: customBlockId },
1475
+ },
1476
+ {
1477
+ label: "Below this block",
1478
+ description: "Directly below the custom block",
1479
+ position: { type: "after", blockId: customBlockId },
1480
+ },
1481
+ )
1482
+ }
1483
+ return presets
1484
+ }
1485
+
1486
+ function getPageTitle(page: NotionPage): string {
1487
+ const titleValue = page.properties.title
1488
+ if (titleValue === undefined || !("title" in titleValue)) {
1489
+ return ""
1490
+ }
1491
+ const richText = titleValue.title
1492
+ if (!Array.isArray(richText)) {
1493
+ return ""
1494
+ }
1495
+ return richText
1496
+ .map(part => {
1497
+ if (typeof part.plain_text === "string") {
1498
+ return part.plain_text
1499
+ }
1500
+ if (
1501
+ part.type !== "text" ||
1502
+ part.text === null ||
1503
+ typeof part.text !== "object" ||
1504
+ !("content" in part.text)
1505
+ ) {
1506
+ return ""
1507
+ }
1508
+ return typeof part.text.content === "string" ? part.text.content : ""
1509
+ })
1510
+ .join("")
1511
+ }
1512
+
1513
+ function getPageIconValue(page: NotionPage): string {
1514
+ const icon = page.icon
1515
+ if (!icon) {
1516
+ return ""
1517
+ }
1518
+ switch (icon.type) {
1519
+ case "emoji":
1520
+ return icon.emoji
1521
+ case "external":
1522
+ return icon.external.url
1523
+ case "file":
1524
+ return icon.file.url
1525
+ case "custom_emoji":
1526
+ return icon.custom_emoji.url ?? icon.custom_emoji.id
1527
+ }
1528
+ }
1529
+
1530
+ function trackPage(prev: TrackedPage[], page: NotionPage): TrackedPage[] {
1531
+ const nextEntry: TrackedPage = {
1532
+ page,
1533
+ draftTitle: getPageTitle(page),
1534
+ draftIcon: getPageIconValue(page),
1535
+ }
1536
+ return [nextEntry, ...prev.filter(entry => entry.page.id !== page.id)]
1537
+ }
1538
+
1539
+ function pageIconFromInput(raw: string): NotionPage["icon"] | undefined {
1540
+ const value = raw.trim()
1541
+ if (value === "") {
1542
+ return undefined
1543
+ }
1544
+ if (
1545
+ value.startsWith("http://") ||
1546
+ value.startsWith("https://") ||
1547
+ value.startsWith("/") ||
1548
+ value.includes("://")
1549
+ ) {
1550
+ return {
1551
+ type: "external",
1552
+ external: { url: value },
1553
+ }
1554
+ }
1555
+ return {
1556
+ type: "emoji",
1557
+ emoji: value,
1558
+ }
1559
+ }
1560
+
1561
+ /**
1562
+ * Buttons that exercise `sdk.pages.create` at each of the supported positions. Every preset creates
1563
+ * a page whose `parent.page_id` is the nearest page ancestor. `position` demonstrates append,
1564
+ * prepend, and inserting directly above or below the custom block via `context.customBlockId`.
1565
+ *
1566
+ * Two extra affordances exercise the data-source-backed parents:
1567
+ * - A free-form input accepts a data source ID and uses `parent: { type: "data_source_id", ... }`
1568
+ * directly.
1569
+ * - A button per known data-source key uses `parent: { type: "data_source_key", key }`, letting the
1570
+ * SDK resolve the key to the configured data source ID locally before sending the request to the
1571
+ * Notion host.
1572
+ */
1573
+ function CreatePageCard({ context, dataSources }: CreatePageCardProps) {
1574
+ const [status, setStatus] = useState<string | null>(null)
1575
+ const [busy, setBusy] = useState(false)
1576
+ const [dataSourceId, setDataSourceId] = useState("")
1577
+ const [pageId, setPageId] = useState("")
1578
+ const [trackedPages, setTrackedPages] = useState<TrackedPage[]>([])
1579
+ const presets = presetPositions(context?.customBlockId)
1580
+ const disabled = context === undefined || busy
1581
+ const trimmedDataSourceId = dataSourceId.trim()
1582
+ const dataSourceDisabled = disabled || trimmedDataSourceId === ""
1583
+ const trimmedPageId = pageId.trim()
1584
+ const fetchDisabled = busy || trimmedPageId === ""
1585
+
1586
+ useEffect(() => {
1587
+ if (context?.page.id && pageId === "") {
1588
+ setPageId(context.page.id)
1589
+ }
1590
+ }, [context?.page.id, pageId])
1591
+
1592
+ const handleCreate = async (preset: CreatePagePreset) => {
1593
+ if (context === undefined || busy) {
1594
+ return
1595
+ }
1596
+ setBusy(true)
1597
+ setStatus(`Creating (${preset.label})\u2026`)
1598
+ const title = `New page (${preset.label.toLowerCase()}) ${new Date().toLocaleTimeString()}`
1599
+ const result = await pages.create({
1600
+ parent: { type: "page_id", page_id: context.page.id },
1601
+ properties: {
1602
+ title: {
1603
+ id: "title",
1604
+ type: "title",
1605
+ title: [{ type: "text", text: { content: title } }],
1606
+ },
1607
+ },
1608
+ ...(preset.position !== undefined ? { position: preset.position } : {}),
1609
+ })
1610
+ if (result.status === "success") {
1611
+ setTrackedPages(prev => trackPage(prev, result.page))
1612
+ setStatus(`\u2713 ${preset.label}: ${result.page.id}`)
1613
+ } else {
1614
+ setStatus(`\u2717 ${preset.label}: ${result.error}`)
1615
+ }
1616
+ setBusy(false)
1617
+ }
1618
+
1619
+ const handleCreateInDataSource = async () => {
1620
+ if (context === undefined || busy || trimmedDataSourceId === "") {
1621
+ return
1622
+ }
1623
+ setBusy(true)
1624
+ setStatus("Creating (data source)\u2026")
1625
+ const title = `New row ${new Date().toLocaleTimeString()}`
1626
+ const result = await pages.create({
1627
+ parent: {
1628
+ type: "data_source_id",
1629
+ data_source_id: trimmedDataSourceId as NotionDataSourceId,
1630
+ },
1631
+ properties: {
1632
+ title: {
1633
+ id: "title",
1634
+ type: "title",
1635
+ title: [{ type: "text", text: { content: title } }],
1636
+ },
1637
+ },
1638
+ })
1639
+ if (result.status === "success") {
1640
+ setTrackedPages(prev => trackPage(prev, result.page))
1641
+ setStatus(`\u2713 Data source: ${result.page.id}`)
1642
+ } else {
1643
+ setStatus(`\u2717 Data source: ${result.error}`)
1644
+ }
1645
+ setBusy(false)
1646
+ }
1647
+
1648
+ const handleCreateForKey = async (key: string) => {
1649
+ if (context === undefined || busy) {
1650
+ return
1651
+ }
1652
+ setBusy(true)
1653
+ setStatus(`Creating (key: ${key})\u2026`)
1654
+ const title = `New row (${key}) ${new Date().toLocaleTimeString()}`
1655
+ const result = await pages.create({
1656
+ parent: { type: "data_source_key", key },
1657
+ properties: {
1658
+ title: {
1659
+ id: "title",
1660
+ type: "title",
1661
+ title: [{ type: "text", text: { content: title } }],
1662
+ },
1663
+ },
1664
+ })
1665
+ if (result.status === "success") {
1666
+ setTrackedPages(prev => trackPage(prev, result.page))
1667
+ setStatus(`\u2713 Key "${key}": ${result.page.id}`)
1668
+ } else {
1669
+ setStatus(`\u2717 Key "${key}": ${result.error}`)
1670
+ }
1671
+ setBusy(false)
1672
+ }
1673
+
1674
+ const handleFetchPage = async () => {
1675
+ if (busy || trimmedPageId === "") {
1676
+ return
1677
+ }
1678
+ setBusy(true)
1679
+ setStatus(`Fetching ${trimmedPageId}\u2026`)
1680
+ const result = await pages.get(trimmedPageId as NotionPageId)
1681
+ if (result.status === "success") {
1682
+ setTrackedPages(prev => trackPage(prev, result.page))
1683
+ setStatus(`\u2713 Loaded ${result.page.id}`)
1684
+ } else {
1685
+ setStatus(`\u2717 Fetch failed: ${result.error}`)
1686
+ }
1687
+ setBusy(false)
1688
+ }
1689
+
1690
+ const handleRefreshPage = async (trackedPageId: string) => {
1691
+ if (busy) {
1692
+ return
1693
+ }
1694
+ setBusy(true)
1695
+ setStatus(`Refreshing ${trackedPageId}\u2026`)
1696
+ const result = await pages.get(trackedPageId as NotionPageId)
1697
+ if (result.status === "success") {
1698
+ setTrackedPages(prev => trackPage(prev, result.page))
1699
+ setStatus(`\u2713 Refreshed ${result.page.id}`)
1700
+ } else {
1701
+ setStatus(`\u2717 Refresh failed: ${result.error}`)
1702
+ }
1703
+ setBusy(false)
1704
+ }
1705
+
1706
+ const handleSavePage = async (trackedPage: TrackedPage) => {
1707
+ if (busy) {
1708
+ return
1709
+ }
1710
+
1711
+ const nextTitle = trackedPage.draftTitle
1712
+ const nextIcon = trackedPage.draftIcon.trim()
1713
+ const currentTitle = getPageTitle(trackedPage.page)
1714
+ const currentIcon = getPageIconValue(trackedPage.page)
1715
+ const titleChanged = nextTitle !== currentTitle
1716
+ const iconChanged = nextIcon !== currentIcon
1717
+ const iconPatch =
1718
+ iconChanged && nextIcon !== "" ? pageIconFromInput(nextIcon) : undefined
1719
+
1720
+ if (!titleChanged && !iconChanged) {
1721
+ setStatus(`No changes for ${trackedPage.page.id}`)
1722
+ return
1723
+ }
1724
+ if (iconChanged && nextIcon === "" && !titleChanged) {
1725
+ setStatus("Clearing icons is not supported yet")
1726
+ return
1727
+ }
1728
+
1729
+ setBusy(true)
1730
+ setStatus(`Saving ${trackedPage.page.id}\u2026`)
1731
+ const result = await pages.update({
1732
+ pageId: trackedPage.page.id,
1733
+ ...(titleChanged
1734
+ ? {
1735
+ properties: {
1736
+ title: {
1737
+ id: "title",
1738
+ type: "title",
1739
+ title: [{ type: "text", text: { content: nextTitle } }],
1740
+ },
1741
+ },
1742
+ }
1743
+ : {}),
1744
+ ...(iconPatch !== undefined ? { icon: iconPatch } : {}),
1745
+ })
1746
+ if (result.status === "success") {
1747
+ setTrackedPages(prev => trackPage(prev, result.page))
1748
+ setStatus(`\u2713 Saved ${result.page.id}`)
1749
+ } else {
1750
+ setStatus(`\u2717 Save failed: ${result.error}`)
1751
+ }
1752
+ setBusy(false)
1753
+ }
1754
+
1755
+ const handleDeletePage = async (trackedPageId: string) => {
1756
+ if (busy) {
1757
+ return
1758
+ }
1759
+ setBusy(true)
1760
+ setStatus(`Deleting ${trackedPageId}\u2026`)
1761
+ const result = await pages.delete(trackedPageId as NotionPageId)
1762
+ if (result.status === "success") {
1763
+ setTrackedPages(prev => trackPage(prev, result.page))
1764
+ setStatus(`\u2713 Deleted ${result.page.id}`)
1765
+ } else {
1766
+ setStatus(`\u2717 Delete failed: ${result.error}`)
1767
+ }
1768
+ setBusy(false)
1769
+ }
1770
+
1771
+ return (
1772
+ <CollapsibleCard label="Page API debugger">
1773
+ <div className="mb-2 flex flex-wrap gap-1.5">
1774
+ {presets.map(preset => (
1775
+ <button
1776
+ key={preset.label}
1777
+ type="button"
1778
+ disabled={disabled}
1779
+ title={preset.description}
1780
+ className="rounded-md border border-(--border) bg-transparent px-2 py-1 font-mono text-[11px] text-(--muted) transition-colors hover:border-(--foreground)/20 hover:bg-(--hover-bg) hover:text-(--foreground) disabled:cursor-not-allowed disabled:opacity-50"
1781
+ onClick={() => {
1782
+ void handleCreate(preset)
1783
+ }}
1784
+ >
1785
+ {preset.label}
1786
+ </button>
1787
+ ))}
1788
+ </div>
1789
+ <div className="mb-2 flex flex-wrap items-center gap-1.5">
1790
+ <input
1791
+ type="text"
1792
+ value={dataSourceId}
1793
+ onChange={event => setDataSourceId(event.target.value)}
1794
+ placeholder="data_source_id"
1795
+ spellCheck={false}
1796
+ className="min-w-0 flex-1 rounded-md border border-(--border) bg-(--app-bg) px-2 py-1 font-mono text-[11px] outline-none transition-colors focus:border-(--foreground)"
1797
+ />
1798
+ <button
1799
+ type="button"
1800
+ disabled={dataSourceDisabled}
1801
+ title="Create a page as a row of the data source with the given ID"
1802
+ className="rounded-md border border-(--border) bg-transparent px-2 py-1 font-mono text-[11px] text-(--muted) transition-colors hover:border-(--foreground)/20 hover:bg-(--hover-bg) hover:text-(--foreground) disabled:cursor-not-allowed disabled:opacity-50"
1803
+ onClick={() => {
1804
+ void handleCreateInDataSource()
1805
+ }}
1806
+ >
1807
+ Create in data source
1808
+ </button>
1809
+ </div>
1810
+ {dataSources.length > 0 && (
1811
+ <div className="mb-2 flex flex-wrap items-center gap-1.5">
1812
+ <span className="font-mono text-[11px] text-(--muted)">
1813
+ by data source key:
1814
+ </span>
1815
+ {dataSources.map(source => (
1816
+ <button
1817
+ key={source.key}
1818
+ type="button"
1819
+ disabled={disabled}
1820
+ title={
1821
+ source.collectionPointer !== undefined
1822
+ ? `Create a page in data source ${source.collectionPointer.id} via key "${source.key}"`
1823
+ : `Key "${source.key}" has no collection pointer yet; request will resolve with an error`
1824
+ }
1825
+ className="rounded-md border border-(--border) bg-transparent px-2 py-1 font-mono text-[11px] text-(--muted) transition-colors hover:border-(--foreground)/20 hover:bg-(--hover-bg) hover:text-(--foreground) disabled:cursor-not-allowed disabled:opacity-50"
1826
+ onClick={() => {
1827
+ void handleCreateForKey(source.key)
1828
+ }}
1829
+ >
1830
+ {source.key}
1831
+ </button>
1832
+ ))}
1833
+ </div>
1834
+ )}
1835
+ <div className="mb-3 flex flex-wrap items-center gap-1.5 border-t border-(--border) pt-3">
1836
+ <input
1837
+ type="text"
1838
+ value={pageId}
1839
+ onChange={event => setPageId(event.target.value)}
1840
+ placeholder={context?.page.id ?? "page_id"}
1841
+ spellCheck={false}
1842
+ className="min-w-0 flex-1 rounded-md border border-(--border) bg-(--app-bg) px-2 py-1 font-mono text-[11px] outline-none transition-colors focus:border-(--foreground)"
1843
+ />
1844
+ <button
1845
+ type="button"
1846
+ disabled={fetchDisabled}
1847
+ className="rounded-md border border-(--border) bg-transparent px-2 py-1 font-mono text-[11px] text-(--muted) transition-colors hover:border-(--foreground)/20 hover:bg-(--hover-bg) hover:text-(--foreground) disabled:cursor-not-allowed disabled:opacity-50"
1848
+ onClick={() => {
1849
+ void handleFetchPage()
1850
+ }}
1851
+ >
1852
+ Get page
1853
+ </button>
1854
+ </div>
1855
+ {trackedPages.length > 0 && (
1856
+ <div className="mb-2 space-y-2">
1857
+ {trackedPages.map(trackedPage => {
1858
+ const archived = trackedPage.page.in_trash === true
1859
+ return (
1860
+ <div
1861
+ key={trackedPage.page.id}
1862
+ className={`rounded-lg border border-(--border) px-3 py-2 ${
1863
+ archived ? "bg-(--hover-bg)" : "bg-(--app-bg)"
1864
+ }`}
1865
+ >
1866
+ <div className="mb-2 flex items-center justify-between gap-2">
1867
+ <div className="min-w-0">
1868
+ <div className="truncate font-mono text-[11px] text-(--foreground)">
1869
+ {trackedPage.page.id}
1870
+ </div>
1871
+ <div className="font-mono text-[10px] text-(--muted)">
1872
+ {trackedPage.page.parent.type}
1873
+ {archived ? " · archived" : ""}
1874
+ </div>
1875
+ </div>
1876
+ <div className="flex items-center gap-1.5">
1877
+ <button
1878
+ type="button"
1879
+ disabled={busy}
1880
+ className="rounded-md border border-(--border) bg-transparent px-2 py-1 font-mono text-[11px] text-(--muted) transition-colors hover:border-(--foreground)/20 hover:bg-(--hover-bg) hover:text-(--foreground) disabled:cursor-not-allowed disabled:opacity-50"
1881
+ onClick={() => {
1882
+ void handleRefreshPage(trackedPage.page.id)
1883
+ }}
1884
+ >
1885
+ Refresh
1886
+ </button>
1887
+ <button
1888
+ type="button"
1889
+ disabled={busy}
1890
+ className="rounded-md border border-(--border) bg-transparent px-2 py-1 font-mono text-[11px] text-(--muted) transition-colors hover:border-(--foreground)/20 hover:bg-(--hover-bg) hover:text-(--foreground) disabled:cursor-not-allowed disabled:opacity-50"
1891
+ onClick={() => {
1892
+ void handleSavePage(trackedPage)
1893
+ }}
1894
+ >
1895
+ Save
1896
+ </button>
1897
+ <button
1898
+ type="button"
1899
+ disabled={busy || archived}
1900
+ className="rounded-md border border-(--border) bg-transparent px-2 py-1 font-mono text-[11px] text-(--muted) transition-colors hover:border-(--foreground)/20 hover:bg-(--hover-bg) hover:text-(--foreground) disabled:cursor-not-allowed disabled:opacity-50"
1901
+ onClick={() => {
1902
+ void handleDeletePage(trackedPage.page.id)
1903
+ }}
1904
+ >
1905
+ Delete
1906
+ </button>
1907
+ </div>
1908
+ </div>
1909
+ <div className="grid gap-2 md:grid-cols-2">
1910
+ <input
1911
+ type="text"
1912
+ value={trackedPage.draftTitle}
1913
+ onChange={event => {
1914
+ const nextTitle = event.target.value
1915
+ setTrackedPages(prev =>
1916
+ prev.map(entry =>
1917
+ entry.page.id === trackedPage.page.id
1918
+ ? { ...entry, draftTitle: nextTitle }
1919
+ : entry,
1920
+ ),
1921
+ )
1922
+ }}
1923
+ placeholder="Page title"
1924
+ className="rounded-md border border-(--border) bg-(--app-bg) px-2 py-1 font-mono text-[11px] outline-none transition-colors focus:border-(--foreground)"
1925
+ />
1926
+ <input
1927
+ type="text"
1928
+ value={trackedPage.draftIcon}
1929
+ onChange={event => {
1930
+ const nextIcon = event.target.value
1931
+ setTrackedPages(prev =>
1932
+ prev.map(entry =>
1933
+ entry.page.id === trackedPage.page.id
1934
+ ? { ...entry, draftIcon: nextIcon }
1935
+ : entry,
1936
+ ),
1937
+ )
1938
+ }}
1939
+ placeholder="Emoji or URL icon"
1940
+ className="rounded-md border border-(--border) bg-(--app-bg) px-2 py-1 font-mono text-[11px] outline-none transition-colors focus:border-(--foreground)"
1941
+ />
1942
+ </div>
1943
+ </div>
1944
+ )
1945
+ })}
1946
+ </div>
1947
+ )}
1948
+ {status !== null && (
1949
+ <div className="font-mono text-[11px] text-(--muted)">{status}</div>
1950
+ )}
1951
+ </CollapsibleCard>
1952
+ )
1953
+ }
1954
+
1955
+ // --- Mount ---
1956
+
1957
+ ReactDOM.createRoot(document.getElementById("root")!).render(
1958
+ <ErrorBoundary>
1959
+ <NotionCustomBlock>
1960
+ <App />
1961
+ </NotionCustomBlock>
1962
+ </ErrorBoundary>,
1963
+ )