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,1814 @@
1
+ import {
2
+ type ColumnDef,
3
+ flexRender,
4
+ getCoreRowModel,
5
+ getSortedRowModel,
6
+ type SortingState,
7
+ useReactTable,
8
+ } from "@tanstack/react-table"
9
+ import {
10
+ NotionCustomBlock,
11
+ type NotionDataSource,
12
+ type NotionPagePropertyInputMap,
13
+ type NotionPagePropertyInputValue,
14
+ type NotionPropertySchema,
15
+ pages,
16
+ useDataSource,
17
+ useDataSourceDefinitions,
18
+ useTheme,
19
+ } from "ncblock"
20
+ import React from "react"
21
+ import ReactDOM from "react-dom/client"
22
+
23
+ import "./index.css"
24
+ import {
25
+ type ColumnConfig,
26
+ type ColumnKind,
27
+ compareValues,
28
+ formatDateValue,
29
+ formatNumberValue,
30
+ getColumnConfigs,
31
+ getColumnWidth,
32
+ getPrimaryColumnId,
33
+ getRelationDisplay,
34
+ getRowProperty,
35
+ getSearchText,
36
+ humanizeKey,
37
+ isDateValue,
38
+ isRelationArray,
39
+ isStringArray,
40
+ SAMPLE_ITEMS,
41
+ type TableRow,
42
+ } from "./table-model"
43
+
44
+ function TextValue(props: { value: string }) {
45
+ const { value } = props
46
+
47
+ if (value.length === 0) {
48
+ return <span className="text-(--muted)">&mdash;</span>
49
+ }
50
+
51
+ const linkClass =
52
+ "break-all underline decoration-(--line-strong) underline-offset-[3px] transition-colors hover:decoration-(--accent)"
53
+
54
+ if (/^https?:\/\//i.test(value)) {
55
+ return (
56
+ <a href={value} target="_blank" rel="noreferrer" className={linkClass}>
57
+ {value}
58
+ </a>
59
+ )
60
+ }
61
+
62
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
63
+ return (
64
+ <a href={`mailto:${value}`} className={linkClass}>
65
+ {value}
66
+ </a>
67
+ )
68
+ }
69
+
70
+ if (/^\+?[0-9().\s-]{7,}$/.test(value)) {
71
+ return (
72
+ <a href={`tel:${value.replace(/[^\d+]/g, "")}`} className={linkClass}>
73
+ {value}
74
+ </a>
75
+ )
76
+ }
77
+
78
+ return <span className="break-words whitespace-pre-wrap">{value}</span>
79
+ }
80
+
81
+ const EMPTY_VALUE = <span className="cell-body text-(--muted)">&mdash;</span>
82
+
83
+ const CHIP_CLASS =
84
+ "inline-flex items-center rounded-sm px-1.5 py-[2px] text-[11.5px] leading-4 text-(--foreground)"
85
+
86
+ type EditablePropertySchema = Extract<
87
+ NotionPropertySchema,
88
+ {
89
+ type:
90
+ | "title"
91
+ | "rich_text"
92
+ | "number"
93
+ | "checkbox"
94
+ | "url"
95
+ | "email"
96
+ | "phone_number"
97
+ | "select"
98
+ | "status"
99
+ | "multi_select"
100
+ | "date"
101
+ }
102
+ >
103
+
104
+ type UpdateStatus =
105
+ | { type: "idle" }
106
+ | { type: "saving" }
107
+ | { type: "success"; message: string }
108
+ | { type: "error"; message: string }
109
+
110
+ type EditingRow = {
111
+ row: TableRow
112
+ draftByKey: Record<string, string>
113
+ }
114
+
115
+ type WritableColumn = {
116
+ config: ColumnConfig
117
+ schema: EditablePropertySchema
118
+ }
119
+
120
+ function isEditablePropertySchema(
121
+ schema: NotionPropertySchema | undefined,
122
+ ): schema is EditablePropertySchema {
123
+ if (schema === undefined) {
124
+ return false
125
+ }
126
+
127
+ switch (schema.type) {
128
+ case "title":
129
+ case "rich_text":
130
+ case "number":
131
+ case "checkbox":
132
+ case "url":
133
+ case "email":
134
+ case "phone_number":
135
+ case "select":
136
+ case "status":
137
+ case "multi_select":
138
+ case "date":
139
+ return true
140
+ default:
141
+ return false
142
+ }
143
+ }
144
+
145
+ function getEditableSchemaForColumn(args: {
146
+ config: ColumnConfig
147
+ propertySchemasById: { [key: string]: NotionPropertySchema | undefined }
148
+ isPrimary: boolean
149
+ }): EditablePropertySchema | undefined {
150
+ const schema = args.propertySchemasById[args.config.key]
151
+ if (isEditablePropertySchema(schema)) {
152
+ return schema
153
+ }
154
+ if (schema !== undefined) {
155
+ return undefined
156
+ }
157
+ return inferEditableSchemaFromColumn({
158
+ config: args.config,
159
+ isPrimary: args.isPrimary,
160
+ })
161
+ }
162
+
163
+ function inferEditableSchemaFromColumn(args: {
164
+ config: ColumnConfig
165
+ isPrimary: boolean
166
+ }): EditablePropertySchema | undefined {
167
+ const { config, isPrimary } = args
168
+ const name = humanizeKey(config.key)
169
+ switch (config.kind) {
170
+ case "text":
171
+ return isPrimary ? { name, type: "title" } : { name, type: "rich_text" }
172
+ case "number":
173
+ return { name, type: "number" }
174
+ case "boolean":
175
+ return { name, type: "checkbox" }
176
+ case "date":
177
+ return { name, type: "date" }
178
+ case "list":
179
+ return { name, type: "multi_select", options: [] }
180
+ case "empty":
181
+ return isPrimary ? { name, type: "title" } : { name, type: "rich_text" }
182
+ default:
183
+ return undefined
184
+ }
185
+ }
186
+
187
+ function plainTextPropertyItem(content: string): Record<string, unknown> {
188
+ return {
189
+ type: "text",
190
+ text: { content },
191
+ }
192
+ }
193
+
194
+ function textDraftFromValue(value: unknown): string {
195
+ if (value === undefined || value === null) {
196
+ return ""
197
+ }
198
+ if (typeof value === "string") {
199
+ return value
200
+ }
201
+ if (typeof value === "number" || typeof value === "boolean") {
202
+ return String(value)
203
+ }
204
+ if (isStringArray(value)) {
205
+ return value.join(", ")
206
+ }
207
+ if (isDateValue(value)) {
208
+ return value.start_date
209
+ }
210
+ return getSearchText(value)
211
+ }
212
+
213
+ function makeDraftByKey(args: {
214
+ row: TableRow
215
+ writableColumns: WritableColumn[]
216
+ }): Record<string, string> {
217
+ const { row, writableColumns } = args
218
+ const draftByKey: Record<string, string> = {}
219
+ for (const { config } of writableColumns) {
220
+ draftByKey[config.key] = textDraftFromValue(getRowProperty(row, config.key))
221
+ }
222
+ return draftByKey
223
+ }
224
+
225
+ function propertyValueFromDraft(args: {
226
+ key: string
227
+ schema: EditablePropertySchema
228
+ draft: string
229
+ }): NotionPagePropertyInputValue {
230
+ const { key, schema, draft } = args
231
+ const trimmed = draft.trim()
232
+
233
+ switch (schema.type) {
234
+ case "title":
235
+ return { type: "title", title: [plainTextPropertyItem(draft)] }
236
+ case "rich_text":
237
+ return {
238
+ type: "rich_text",
239
+ rich_text: [plainTextPropertyItem(draft)],
240
+ }
241
+ case "number": {
242
+ if (trimmed.length === 0) {
243
+ return { type: "number", number: null }
244
+ }
245
+ const parsed = Number(trimmed)
246
+ if (!Number.isFinite(parsed)) {
247
+ throw new Error(`${humanizeKey(key)} must be a valid number.`)
248
+ }
249
+ return { type: "number", number: parsed }
250
+ }
251
+ case "checkbox":
252
+ return { type: "checkbox", checkbox: trimmed === "true" }
253
+ case "url":
254
+ return { type: "url", url: trimmed.length > 0 ? trimmed : null }
255
+ case "email":
256
+ return {
257
+ type: "email",
258
+ email: trimmed.length > 0 ? trimmed : null,
259
+ }
260
+ case "phone_number":
261
+ return {
262
+ type: "phone_number",
263
+ phone_number: trimmed.length > 0 ? trimmed : null,
264
+ }
265
+ case "select":
266
+ return {
267
+ type: "select",
268
+ select: trimmed.length > 0 ? { name: trimmed } : null,
269
+ }
270
+ case "status":
271
+ return {
272
+ type: "status",
273
+ status: trimmed.length > 0 ? { name: trimmed } : null,
274
+ }
275
+ case "multi_select":
276
+ return {
277
+ type: "multi_select",
278
+ multi_select: draft
279
+ .split(",")
280
+ .map(part => part.trim())
281
+ .filter(Boolean)
282
+ .map(name => ({ name })),
283
+ }
284
+ case "date":
285
+ return {
286
+ type: "date",
287
+ date: trimmed.length > 0 ? { start: trimmed } : null,
288
+ }
289
+ }
290
+ }
291
+
292
+ function emptyDraftByKey(
293
+ writableColumns: WritableColumn[],
294
+ ): Record<string, string> {
295
+ const draftByKey: Record<string, string> = {}
296
+ for (const { config, schema } of writableColumns) {
297
+ draftByKey[config.key] = schema.type === "checkbox" ? "false" : ""
298
+ }
299
+ return draftByKey
300
+ }
301
+
302
+ function PropertyValue(props: {
303
+ value: unknown
304
+ kind: ColumnKind
305
+ isPrimary: boolean
306
+ }) {
307
+ const { value, kind, isPrimary } = props
308
+
309
+ if (value === undefined || value === null) {
310
+ return EMPTY_VALUE
311
+ }
312
+
313
+ if (typeof value === "string") {
314
+ if (isPrimary) {
315
+ if (value.length === 0) {
316
+ return EMPTY_VALUE
317
+ }
318
+ return (
319
+ <span
320
+ className="ff-display cell-primary text-(--foreground)"
321
+ style={{ fontVariationSettings: '"SOFT" 50, "opsz" 72' }}
322
+ >
323
+ {value}
324
+ </span>
325
+ )
326
+ }
327
+
328
+ if (kind === "id") {
329
+ if (value.length === 0) {
330
+ return EMPTY_VALUE
331
+ }
332
+ return (
333
+ <span className="ff-mono cell-mono text-(--muted-strong)">{value}</span>
334
+ )
335
+ }
336
+
337
+ return (
338
+ <span className="cell-body text-(--foreground)">
339
+ <TextValue value={value} />
340
+ </span>
341
+ )
342
+ }
343
+
344
+ if (typeof value === "number") {
345
+ return (
346
+ <span className="ff-mono cell-body text-(--foreground)">
347
+ {formatNumberValue(value)}
348
+ </span>
349
+ )
350
+ }
351
+
352
+ if (typeof value === "boolean") {
353
+ return (
354
+ <span
355
+ className="inline-flex items-center gap-1.5"
356
+ aria-label={value ? "True" : "False"}
357
+ >
358
+ <span
359
+ aria-hidden="true"
360
+ className="h-1.5 w-1.5 rounded-full"
361
+ style={{
362
+ background: value ? "var(--accent)" : "var(--line-strong)",
363
+ }}
364
+ />
365
+ <span className="cell-body text-(--foreground)">
366
+ {value ? "Yes" : "No"}
367
+ </span>
368
+ </span>
369
+ )
370
+ }
371
+
372
+ if (isDateValue(value)) {
373
+ const formatted = formatDateValue(value)
374
+ return (
375
+ <div className="flex flex-col gap-0.5">
376
+ <span className="cell-body text-(--foreground)">
377
+ {formatted.primary}
378
+ </span>
379
+ {formatted.secondary ? (
380
+ <span className="ff-mono cell-fine text-(--muted)">
381
+ {formatted.secondary}
382
+ </span>
383
+ ) : null}
384
+ </div>
385
+ )
386
+ }
387
+
388
+ if (isStringArray(value)) {
389
+ if (value.length === 0) {
390
+ return EMPTY_VALUE
391
+ }
392
+
393
+ return (
394
+ <div className="flex flex-wrap gap-1">
395
+ {value.map(entry => (
396
+ <span
397
+ key={entry}
398
+ className={CHIP_CLASS}
399
+ style={{ background: "var(--chip-bg)" }}
400
+ >
401
+ {entry}
402
+ </span>
403
+ ))}
404
+ </div>
405
+ )
406
+ }
407
+
408
+ if (isRelationArray(value)) {
409
+ return (
410
+ <div className="flex flex-wrap gap-1">
411
+ {value.map(entry => {
412
+ const relation = getRelationDisplay(entry)
413
+ return (
414
+ <span
415
+ key={relation.id ?? relation.label}
416
+ title={
417
+ relation.id && relation.id !== relation.label
418
+ ? relation.id
419
+ : undefined
420
+ }
421
+ className={`${CHIP_CLASS} gap-1`}
422
+ style={{ background: "var(--chip-bg)" }}
423
+ >
424
+ <span
425
+ aria-hidden="true"
426
+ className="h-1 w-1 rounded-full bg-(--muted)"
427
+ />
428
+ {relation.label}
429
+ </span>
430
+ )
431
+ })}
432
+ </div>
433
+ )
434
+ }
435
+
436
+ if (Array.isArray(value) && value.length === 0) {
437
+ return EMPTY_VALUE
438
+ }
439
+
440
+ return (
441
+ <code className="ff-mono cell-fine break-all text-(--muted)">
442
+ {JSON.stringify(value)}
443
+ </code>
444
+ )
445
+ }
446
+
447
+ function EmptyState(props: {
448
+ title: string
449
+ description: string
450
+ action?: React.ReactNode
451
+ }) {
452
+ const { title, description, action } = props
453
+
454
+ return (
455
+ <div className="grid place-items-center px-6 py-16">
456
+ <div className="max-w-[420px] text-center">
457
+ <div
458
+ aria-hidden="true"
459
+ className="mx-auto mb-4 h-px w-10"
460
+ style={{ background: "var(--line-strong)" }}
461
+ />
462
+ <div className="ff-display text-xl leading-tight text-(--foreground)">
463
+ {title}
464
+ </div>
465
+ <p className="mt-2 text-xs leading-5 text-(--muted)">{description}</p>
466
+ {action ? <div className="mt-5">{action}</div> : null}
467
+ </div>
468
+ </div>
469
+ )
470
+ }
471
+
472
+ type RowEditorMode = { type: "update"; rowId: string } | { type: "create" }
473
+
474
+ function RowEditorPanel(props: {
475
+ mode: RowEditorMode
476
+ draftByKey: Record<string, string>
477
+ writableColumns: WritableColumn[]
478
+ status: UpdateStatus
479
+ onDraftChange: (key: string, value: string) => void
480
+ onCancel: () => void
481
+ onSubmit: () => void
482
+ }) {
483
+ const {
484
+ mode,
485
+ draftByKey,
486
+ writableColumns,
487
+ status,
488
+ onDraftChange,
489
+ onCancel,
490
+ onSubmit,
491
+ } = props
492
+
493
+ const isSaving = status.type === "saving"
494
+ const eyebrow = mode.type === "create" ? "New row" : "Update row"
495
+ const submitLabel =
496
+ mode.type === "create"
497
+ ? isSaving
498
+ ? "Creating..."
499
+ : "Create page"
500
+ : isSaving
501
+ ? "Updating..."
502
+ : "Update page"
503
+
504
+ return (
505
+ <form
506
+ onSubmit={event => {
507
+ event.preventDefault()
508
+ onSubmit()
509
+ }}
510
+ className="rounded-lg border border-(--line) bg-(--surface-bg) p-3 shadow-[0_10px_30px_-18px_rgb(0_0_0_/_0.25)]"
511
+ >
512
+ <div className="mb-3 flex flex-wrap items-start justify-between gap-2">
513
+ <div>
514
+ <div className="eyebrow">{eyebrow}</div>
515
+ {mode.type === "update" ? (
516
+ <div className="ff-mono mt-1 text-[11px] break-all text-(--muted)">
517
+ {mode.rowId}
518
+ </div>
519
+ ) : null}
520
+ </div>
521
+ <button
522
+ type="button"
523
+ onClick={onCancel}
524
+ disabled={isSaving}
525
+ className="text-xs text-(--muted) transition-colors hover:text-(--foreground) disabled:opacity-50"
526
+ >
527
+ Cancel
528
+ </button>
529
+ </div>
530
+
531
+ <div className="grid gap-3 md:grid-cols-2">
532
+ {writableColumns.map(({ config, schema }) => {
533
+ const value = draftByKey[config.key] ?? ""
534
+ const label = config.label
535
+ const baseInputClass =
536
+ "field-control min-h-9 w-full rounded-md border border-(--line) bg-(--surface-bg) px-2 text-sm text-(--foreground) outline-none hover:border-(--line-strong)"
537
+
538
+ return (
539
+ <label key={config.key} className="block min-w-0">
540
+ <span className="mb-1.5 flex items-baseline gap-2">
541
+ <span className="text-xs font-medium text-(--foreground)">
542
+ {label}
543
+ </span>
544
+ <span className="ff-mono text-[9.5px] tracking-widest text-(--muted) uppercase">
545
+ {schema.type}
546
+ </span>
547
+ </span>
548
+
549
+ {schema.type === "checkbox" ? (
550
+ <select
551
+ value={value === "true" ? "true" : "false"}
552
+ onChange={event =>
553
+ onDraftChange(config.key, event.target.value)
554
+ }
555
+ disabled={isSaving}
556
+ className={baseInputClass}
557
+ >
558
+ <option value="true">Checked</option>
559
+ <option value="false">Unchecked</option>
560
+ </select>
561
+ ) : schema.type === "select" || schema.type === "status" ? (
562
+ <select
563
+ value={value}
564
+ onChange={event =>
565
+ onDraftChange(config.key, event.target.value)
566
+ }
567
+ disabled={isSaving}
568
+ className={baseInputClass}
569
+ >
570
+ <option value="">Clear</option>
571
+ {schema.options.map(option => (
572
+ <option key={option.id} value={option.name}>
573
+ {option.name}
574
+ </option>
575
+ ))}
576
+ </select>
577
+ ) : (
578
+ <input
579
+ type={
580
+ schema.type === "number"
581
+ ? "number"
582
+ : schema.type === "date"
583
+ ? "date"
584
+ : "text"
585
+ }
586
+ value={value}
587
+ onChange={event =>
588
+ onDraftChange(config.key, event.target.value)
589
+ }
590
+ disabled={isSaving}
591
+ placeholder={
592
+ schema.type === "multi_select"
593
+ ? "Comma-separated option names"
594
+ : undefined
595
+ }
596
+ className={baseInputClass}
597
+ />
598
+ )}
599
+ </label>
600
+ )
601
+ })}
602
+ </div>
603
+
604
+ <div className="mt-3 flex flex-wrap items-center justify-between gap-2">
605
+ <div className="min-h-5 text-xs" aria-live="polite">
606
+ {status.type === "success" ? (
607
+ <span className="text-(--muted-strong)">{status.message}</span>
608
+ ) : status.type === "error" ? (
609
+ <span className="text-red-500">{status.message}</span>
610
+ ) : null}
611
+ </div>
612
+ <button
613
+ type="submit"
614
+ disabled={isSaving}
615
+ className="field-control min-h-9 rounded-md border border-(--line) bg-(--foreground) px-3 text-sm text-(--app-bg) transition-opacity disabled:cursor-progress disabled:opacity-70"
616
+ >
617
+ {submitLabel}
618
+ </button>
619
+ </div>
620
+ </form>
621
+ )
622
+ }
623
+
624
+ function GearIcon() {
625
+ return (
626
+ <svg
627
+ aria-hidden="true"
628
+ viewBox="0 0 16 16"
629
+ className="h-4 w-4"
630
+ fill="none"
631
+ stroke="currentColor"
632
+ strokeWidth="1.5"
633
+ strokeLinecap="round"
634
+ strokeLinejoin="round"
635
+ >
636
+ <circle cx="8" cy="8" r="2" />
637
+ <path d="M8 1.5v1.8M8 12.7v1.8M1.5 8h1.8M12.7 8h1.8M3.4 3.4l1.3 1.3M11.3 11.3l1.3 1.3M3.4 12.6l1.3-1.3M11.3 4.7l1.3-1.3" />
638
+ </svg>
639
+ )
640
+ }
641
+
642
+ function GripIcon() {
643
+ return (
644
+ <svg
645
+ aria-hidden="true"
646
+ viewBox="0 0 16 16"
647
+ className="h-3.5 w-3.5"
648
+ fill="currentColor"
649
+ >
650
+ <circle cx="6" cy="3.5" r="1" />
651
+ <circle cx="10" cy="3.5" r="1" />
652
+ <circle cx="6" cy="8" r="1" />
653
+ <circle cx="10" cy="8" r="1" />
654
+ <circle cx="6" cy="12.5" r="1" />
655
+ <circle cx="10" cy="12.5" r="1" />
656
+ </svg>
657
+ )
658
+ }
659
+
660
+ function ColumnSettings(props: {
661
+ allColumnConfigs: ColumnConfig[]
662
+ columnOrder: string[]
663
+ hiddenColumns: Set<string>
664
+ onColumnOrderChange: (next: string[]) => void
665
+ onHiddenColumnsChange: (next: Set<string>) => void
666
+ }) {
667
+ const {
668
+ allColumnConfigs,
669
+ columnOrder,
670
+ hiddenColumns,
671
+ onColumnOrderChange,
672
+ onHiddenColumnsChange,
673
+ } = props
674
+
675
+ const [isOpen, setIsOpen] = React.useState(false)
676
+ const [draggingKey, setDraggingKey] = React.useState<string | null>(null)
677
+ const [dragOverKey, setDragOverKey] = React.useState<string | null>(null)
678
+ const buttonRef = React.useRef<HTMLButtonElement>(null)
679
+ const panelRef = React.useRef<HTMLDivElement>(null)
680
+
681
+ const configByKey = React.useMemo(
682
+ () => new Map(allColumnConfigs.map(config => [config.key, config])),
683
+ [allColumnConfigs],
684
+ )
685
+
686
+ const effectiveOrder = React.useMemo(() => {
687
+ const known = new Set<string>()
688
+ const ordered: string[] = []
689
+ for (const key of columnOrder) {
690
+ if (configByKey.has(key) && !known.has(key)) {
691
+ known.add(key)
692
+ ordered.push(key)
693
+ }
694
+ }
695
+ for (const config of allColumnConfigs) {
696
+ if (!known.has(config.key)) {
697
+ known.add(config.key)
698
+ ordered.push(config.key)
699
+ }
700
+ }
701
+ return ordered
702
+ }, [allColumnConfigs, columnOrder, configByKey])
703
+
704
+ const visibleCount = effectiveOrder.filter(
705
+ key => !hiddenColumns.has(key),
706
+ ).length
707
+
708
+ React.useEffect(() => {
709
+ if (!isOpen) {
710
+ return
711
+ }
712
+ const handlePointer = (event: MouseEvent | TouchEvent) => {
713
+ const target = event.target
714
+ if (!(target instanceof Node)) {
715
+ return
716
+ }
717
+ if (panelRef.current?.contains(target)) {
718
+ return
719
+ }
720
+ if (buttonRef.current?.contains(target)) {
721
+ return
722
+ }
723
+ setIsOpen(false)
724
+ }
725
+ const handleKey = (event: KeyboardEvent) => {
726
+ if (event.key === "Escape") {
727
+ setIsOpen(false)
728
+ buttonRef.current?.focus()
729
+ }
730
+ }
731
+ document.addEventListener("mousedown", handlePointer)
732
+ document.addEventListener("touchstart", handlePointer, { passive: true })
733
+ document.addEventListener("keydown", handleKey)
734
+ return () => {
735
+ document.removeEventListener("mousedown", handlePointer)
736
+ document.removeEventListener("touchstart", handlePointer)
737
+ document.removeEventListener("keydown", handleKey)
738
+ }
739
+ }, [isOpen])
740
+
741
+ const moveKey = (fromKey: string, toKey: string, placeAfter = false) => {
742
+ if (fromKey === toKey) {
743
+ return
744
+ }
745
+ const next = effectiveOrder.slice()
746
+ const fromIndex = next.indexOf(fromKey)
747
+ if (fromIndex === -1) {
748
+ return
749
+ }
750
+ next.splice(fromIndex, 1)
751
+ let toIndex = next.indexOf(toKey)
752
+ if (toIndex === -1) {
753
+ return
754
+ }
755
+ if (placeAfter) {
756
+ toIndex += 1
757
+ }
758
+ next.splice(toIndex, 0, fromKey)
759
+ onColumnOrderChange(next)
760
+ }
761
+
762
+ const moveByStep = (key: string, direction: -1 | 1) => {
763
+ const index = effectiveOrder.indexOf(key)
764
+ const target = index + direction
765
+ if (index === -1 || target < 0 || target >= effectiveOrder.length) {
766
+ return
767
+ }
768
+ const next = effectiveOrder.slice()
769
+ next.splice(index, 1)
770
+ next.splice(target, 0, key)
771
+ onColumnOrderChange(next)
772
+ }
773
+
774
+ const toggleColumn = (key: string) => {
775
+ const next = new Set(hiddenColumns)
776
+ if (next.has(key)) {
777
+ next.delete(key)
778
+ } else {
779
+ next.add(key)
780
+ }
781
+ onHiddenColumnsChange(next)
782
+ }
783
+
784
+ const showAll = () => onHiddenColumnsChange(new Set())
785
+ const hideAll = () => onHiddenColumnsChange(new Set(effectiveOrder))
786
+ const resetOrder = () =>
787
+ onColumnOrderChange(allColumnConfigs.map(config => config.key))
788
+
789
+ return (
790
+ <div className="relative">
791
+ <button
792
+ ref={buttonRef}
793
+ type="button"
794
+ onClick={() => setIsOpen(open => !open)}
795
+ aria-label="Configure columns"
796
+ aria-expanded={isOpen}
797
+ aria-haspopup="dialog"
798
+ className="field-control inline-flex min-h-9 items-center gap-2 rounded-md border border-(--line) bg-(--surface-bg) px-2.5 text-sm text-(--foreground) hover:border-(--line-strong) hover:bg-(--chip-bg)"
799
+ >
800
+ <GearIcon />
801
+ <span className="ff-mono text-[11px] text-(--muted-strong)">
802
+ {String(visibleCount).padStart(2, "0")}
803
+ <span className="mx-0.5 text-(--line-strong)">/</span>
804
+ {String(effectiveOrder.length).padStart(2, "0")}
805
+ </span>
806
+ </button>
807
+
808
+ {isOpen ? (
809
+ <div
810
+ ref={panelRef}
811
+ role="dialog"
812
+ aria-label="Column settings"
813
+ className="absolute top-[calc(100%+6px)] right-0 z-40 w-[320px] overflow-hidden rounded-lg border border-(--line) bg-(--surface-bg) shadow-[0_10px_40px_-12px_rgb(0_0_0_/_0.18),0_2px_8px_-2px_rgb(0_0_0_/_0.06)]"
814
+ >
815
+ <div className="flex items-center justify-between border-b border-(--line) px-3 py-2.5">
816
+ <span className="eyebrow">Columns</span>
817
+ <div className="flex items-center gap-2">
818
+ <button
819
+ type="button"
820
+ onClick={showAll}
821
+ className="text-[11px] text-(--muted) transition-colors hover:text-(--foreground)"
822
+ >
823
+ Show all
824
+ </button>
825
+ <span className="text-[11px] text-(--line-strong)">·</span>
826
+ <button
827
+ type="button"
828
+ onClick={hideAll}
829
+ className="text-[11px] text-(--muted) transition-colors hover:text-(--foreground)"
830
+ >
831
+ Hide all
832
+ </button>
833
+ </div>
834
+ </div>
835
+
836
+ <ul
837
+ className="table-view-scroll max-h-[320px] overflow-auto py-1"
838
+ onDragOver={event => {
839
+ if (draggingKey) {
840
+ event.preventDefault()
841
+ }
842
+ }}
843
+ >
844
+ {effectiveOrder.map(key => {
845
+ const config = configByKey.get(key)
846
+ if (!config) {
847
+ return null
848
+ }
849
+ const isHidden = hiddenColumns.has(key)
850
+ const isDragging = draggingKey === key
851
+ const isDragTarget =
852
+ dragOverKey === key && draggingKey !== null && !isDragging
853
+ const label = config.label
854
+ return (
855
+ <li
856
+ key={key}
857
+ draggable
858
+ onDragStart={event => {
859
+ setDraggingKey(key)
860
+ event.dataTransfer.effectAllowed = "move"
861
+ event.dataTransfer.setData("text/plain", key)
862
+ }}
863
+ onDragEnd={() => {
864
+ setDraggingKey(null)
865
+ setDragOverKey(null)
866
+ }}
867
+ onDragOver={event => {
868
+ if (!draggingKey || draggingKey === key) {
869
+ return
870
+ }
871
+ event.preventDefault()
872
+ event.dataTransfer.dropEffect = "move"
873
+ setDragOverKey(key)
874
+ }}
875
+ onDragLeave={event => {
876
+ const related = event.relatedTarget
877
+ if (
878
+ related instanceof Node &&
879
+ event.currentTarget.contains(related)
880
+ ) {
881
+ return
882
+ }
883
+ if (dragOverKey === key) {
884
+ setDragOverKey(null)
885
+ }
886
+ }}
887
+ onDrop={event => {
888
+ event.preventDefault()
889
+ const source =
890
+ event.dataTransfer.getData("text/plain") || draggingKey
891
+ if (source) {
892
+ moveKey(source, key)
893
+ }
894
+ setDraggingKey(null)
895
+ setDragOverKey(null)
896
+ }}
897
+ className={`relative flex items-center gap-1.5 px-2 py-1 ${
898
+ isDragging ? "opacity-40" : ""
899
+ }`}
900
+ >
901
+ {isDragTarget ? (
902
+ <span
903
+ aria-hidden="true"
904
+ className="absolute top-0 right-2 left-2 h-px bg-(--foreground)"
905
+ />
906
+ ) : null}
907
+ <span
908
+ className="flex h-7 w-4 shrink-0 cursor-grab items-center justify-center text-(--muted) active:cursor-grabbing"
909
+ title="Drag to reorder"
910
+ aria-hidden="true"
911
+ >
912
+ <GripIcon />
913
+ </span>
914
+ <label className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-1">
915
+ <input
916
+ type="checkbox"
917
+ checked={!isHidden}
918
+ onChange={() => toggleColumn(key)}
919
+ aria-label={`Show ${label} column`}
920
+ className="h-3.5 w-3.5 shrink-0 accent-(--accent)"
921
+ />
922
+ <span
923
+ className={`min-w-0 flex-1 truncate text-sm ${isHidden ? "text-(--muted)" : "text-(--foreground)"}`}
924
+ >
925
+ {label}
926
+ </span>
927
+ <span className="ff-mono shrink-0 text-[9.5px] tracking-widest text-(--muted) uppercase">
928
+ {config.kind}
929
+ </span>
930
+ </label>
931
+ <div className="flex shrink-0 items-center gap-0.5">
932
+ <button
933
+ type="button"
934
+ onClick={() => moveByStep(key, -1)}
935
+ disabled={effectiveOrder.indexOf(key) === 0}
936
+ aria-label={`Move ${label} up`}
937
+ className="flex h-6 w-6 items-center justify-center rounded text-(--muted) hover:bg-(--chip-bg) hover:text-(--foreground) disabled:opacity-30 disabled:hover:bg-transparent"
938
+ >
939
+ <svg
940
+ aria-hidden="true"
941
+ viewBox="0 0 12 12"
942
+ className="h-3 w-3"
943
+ fill="none"
944
+ stroke="currentColor"
945
+ strokeWidth="1.75"
946
+ strokeLinecap="round"
947
+ strokeLinejoin="round"
948
+ >
949
+ <path d="m3 7 3-3 3 3" />
950
+ </svg>
951
+ </button>
952
+ <button
953
+ type="button"
954
+ onClick={() => moveByStep(key, 1)}
955
+ disabled={
956
+ effectiveOrder.indexOf(key) ===
957
+ effectiveOrder.length - 1
958
+ }
959
+ aria-label={`Move ${label} down`}
960
+ className="flex h-6 w-6 items-center justify-center rounded text-(--muted) hover:bg-(--chip-bg) hover:text-(--foreground) disabled:opacity-30 disabled:hover:bg-transparent"
961
+ >
962
+ <svg
963
+ aria-hidden="true"
964
+ viewBox="0 0 12 12"
965
+ className="h-3 w-3"
966
+ fill="none"
967
+ stroke="currentColor"
968
+ strokeWidth="1.75"
969
+ strokeLinecap="round"
970
+ strokeLinejoin="round"
971
+ >
972
+ <path d="m3 5 3 3 3-3" />
973
+ </svg>
974
+ </button>
975
+ </div>
976
+ </li>
977
+ )
978
+ })}
979
+ </ul>
980
+
981
+ <div className="flex items-center justify-between border-t border-(--line) bg-(--surface-sunken) px-3 py-1.5">
982
+ <span className="text-[11px] text-(--muted)">Drag to reorder</span>
983
+ <button
984
+ type="button"
985
+ onClick={resetOrder}
986
+ className="text-[11px] text-(--muted) transition-colors hover:text-(--foreground)"
987
+ >
988
+ Reset order
989
+ </button>
990
+ </div>
991
+ </div>
992
+ ) : null}
993
+ </div>
994
+ )
995
+ }
996
+
997
+ function DataWorkspace(props: {
998
+ items: TableRow[]
999
+ columnConfigs: ColumnConfig[]
1000
+ allColumnConfigs: ColumnConfig[]
1001
+ propertySchemasById: { [key: string]: NotionPropertySchema | undefined }
1002
+ columnOrder: string[]
1003
+ hiddenColumns: Set<string>
1004
+ onColumnOrderChange: (next: string[]) => void
1005
+ onHiddenColumnsChange: (next: Set<string>) => void
1006
+ activeDataSourceKey: string
1007
+ dataSources: NotionDataSource[]
1008
+ onSelectDataSource: (key: string) => void
1009
+ hasMore: boolean
1010
+ isLoading: boolean
1011
+ onFetchMore: () => void
1012
+ isUsingFallbackData: boolean
1013
+ isCollectionEmpty: boolean
1014
+ queryError?: string
1015
+ }) {
1016
+ const {
1017
+ items,
1018
+ columnConfigs,
1019
+ allColumnConfigs,
1020
+ propertySchemasById,
1021
+ columnOrder,
1022
+ hiddenColumns,
1023
+ onColumnOrderChange,
1024
+ onHiddenColumnsChange,
1025
+ activeDataSourceKey,
1026
+ dataSources,
1027
+ onSelectDataSource,
1028
+ hasMore,
1029
+ isLoading,
1030
+ onFetchMore,
1031
+ isUsingFallbackData,
1032
+ isCollectionEmpty,
1033
+ queryError,
1034
+ } = props
1035
+
1036
+ const [sorting, setSorting] = React.useState<SortingState>([])
1037
+ const [search, setSearch] = React.useState("")
1038
+ const [editingRow, setEditingRow] = React.useState<EditingRow | undefined>()
1039
+ const [updateStatus, setUpdateStatus] = React.useState<UpdateStatus>({
1040
+ type: "idle",
1041
+ })
1042
+ const [creatingDraft, setCreatingDraft] = React.useState<
1043
+ Record<string, string> | undefined
1044
+ >()
1045
+ const [createStatus, setCreateStatus] = React.useState<UpdateStatus>({
1046
+ type: "idle",
1047
+ })
1048
+ const searchInputRef = React.useRef<HTMLInputElement>(null)
1049
+
1050
+ React.useEffect(() => {
1051
+ const handleKey = (event: KeyboardEvent) => {
1052
+ if (
1053
+ event.key !== "/" ||
1054
+ event.defaultPrevented ||
1055
+ event.metaKey ||
1056
+ event.ctrlKey
1057
+ ) {
1058
+ return
1059
+ }
1060
+ const target = event.target as HTMLElement | null
1061
+ if (
1062
+ target &&
1063
+ (target.tagName === "INPUT" ||
1064
+ target.tagName === "TEXTAREA" ||
1065
+ target.isContentEditable)
1066
+ ) {
1067
+ return
1068
+ }
1069
+ event.preventDefault()
1070
+ searchInputRef.current?.focus()
1071
+ searchInputRef.current?.select()
1072
+ }
1073
+ window.addEventListener("keydown", handleKey)
1074
+ return () => window.removeEventListener("keydown", handleKey)
1075
+ }, [])
1076
+
1077
+ const primaryColumnId = React.useMemo(
1078
+ () => getPrimaryColumnId(columnConfigs),
1079
+ [columnConfigs],
1080
+ )
1081
+
1082
+ const writableColumns = React.useMemo(() => {
1083
+ const columns: WritableColumn[] = []
1084
+ for (const config of allColumnConfigs) {
1085
+ if (config.key === "id") {
1086
+ continue
1087
+ }
1088
+ const schema = getEditableSchemaForColumn({
1089
+ config,
1090
+ propertySchemasById,
1091
+ isPrimary: config.key === primaryColumnId,
1092
+ })
1093
+ if (schema) {
1094
+ columns.push({ config, schema })
1095
+ }
1096
+ }
1097
+ return columns
1098
+ }, [allColumnConfigs, propertySchemasById, primaryColumnId])
1099
+
1100
+ const openEditorForRow = React.useCallback(
1101
+ (row: TableRow) => {
1102
+ setCreatingDraft(undefined)
1103
+ setCreateStatus({ type: "idle" })
1104
+ setEditingRow({
1105
+ row,
1106
+ draftByKey: makeDraftByKey({ row, writableColumns }),
1107
+ })
1108
+ setUpdateStatus({ type: "idle" })
1109
+ },
1110
+ [writableColumns],
1111
+ )
1112
+
1113
+ const closeEditor = React.useCallback(() => {
1114
+ setEditingRow(undefined)
1115
+ setUpdateStatus({ type: "idle" })
1116
+ }, [])
1117
+
1118
+ const updateDraft = React.useCallback((key: string, value: string) => {
1119
+ setEditingRow(previous => {
1120
+ if (!previous) {
1121
+ return previous
1122
+ }
1123
+ return {
1124
+ ...previous,
1125
+ draftByKey: {
1126
+ ...previous.draftByKey,
1127
+ [key]: value,
1128
+ },
1129
+ }
1130
+ })
1131
+ }, [])
1132
+
1133
+ const buildPropertiesFromDraft = React.useCallback(
1134
+ (draftByKey: Record<string, string>): NotionPagePropertyInputMap => {
1135
+ const properties: NotionPagePropertyInputMap = {}
1136
+ for (const { config, schema } of writableColumns) {
1137
+ properties[config.key] = propertyValueFromDraft({
1138
+ key: config.key,
1139
+ schema,
1140
+ draft: draftByKey[config.key] ?? "",
1141
+ })
1142
+ }
1143
+ return properties
1144
+ },
1145
+ [writableColumns],
1146
+ )
1147
+
1148
+ const submitUpdate = React.useCallback(async () => {
1149
+ if (!editingRow) {
1150
+ return
1151
+ }
1152
+
1153
+ try {
1154
+ const properties = buildPropertiesFromDraft(editingRow.draftByKey)
1155
+ setUpdateStatus({ type: "saving" })
1156
+ const result = await editingRow.row.update({ properties })
1157
+ if (result.status === "success") {
1158
+ setUpdateStatus({
1159
+ type: "success",
1160
+ message: "Page updated.",
1161
+ })
1162
+ return
1163
+ }
1164
+ setUpdateStatus({ type: "error", message: result.error })
1165
+ } catch (error) {
1166
+ setUpdateStatus({
1167
+ type: "error",
1168
+ message: error instanceof Error ? error.message : "Update failed.",
1169
+ })
1170
+ }
1171
+ }, [editingRow, buildPropertiesFromDraft])
1172
+
1173
+ const openCreateRow = React.useCallback(() => {
1174
+ setEditingRow(undefined)
1175
+ setUpdateStatus({ type: "idle" })
1176
+ setCreatingDraft(emptyDraftByKey(writableColumns))
1177
+ setCreateStatus({ type: "idle" })
1178
+ }, [writableColumns])
1179
+
1180
+ const closeCreateRow = React.useCallback(() => {
1181
+ setCreatingDraft(undefined)
1182
+ setCreateStatus({ type: "idle" })
1183
+ }, [])
1184
+
1185
+ const updateCreateDraft = React.useCallback((key: string, value: string) => {
1186
+ setCreatingDraft(previous => {
1187
+ if (!previous) {
1188
+ return previous
1189
+ }
1190
+ return { ...previous, [key]: value }
1191
+ })
1192
+ }, [])
1193
+
1194
+ const submitCreate = React.useCallback(async () => {
1195
+ if (!creatingDraft) {
1196
+ return
1197
+ }
1198
+
1199
+ try {
1200
+ const properties = buildPropertiesFromDraft(creatingDraft)
1201
+ setCreateStatus({ type: "saving" })
1202
+ const result = await pages.create({
1203
+ parent: { type: "data_source_key", key: activeDataSourceKey },
1204
+ properties,
1205
+ })
1206
+ if (result.status === "success") {
1207
+ setCreateStatus({
1208
+ type: "success",
1209
+ message: `Page created (${result.page.id}).`,
1210
+ })
1211
+ setCreatingDraft(emptyDraftByKey(writableColumns))
1212
+ return
1213
+ }
1214
+ setCreateStatus({ type: "error", message: result.error })
1215
+ } catch (error) {
1216
+ setCreateStatus({
1217
+ type: "error",
1218
+ message: error instanceof Error ? error.message : "Create failed.",
1219
+ })
1220
+ }
1221
+ }, [
1222
+ creatingDraft,
1223
+ buildPropertiesFromDraft,
1224
+ activeDataSourceKey,
1225
+ writableColumns,
1226
+ ])
1227
+
1228
+ const filteredItems = React.useMemo(() => {
1229
+ const trimmed = search.trim().toLowerCase()
1230
+ if (trimmed.length === 0) {
1231
+ return items
1232
+ }
1233
+
1234
+ return items.filter(item =>
1235
+ columnConfigs.some(config => {
1236
+ const value =
1237
+ config.key === "id" ? item.id : getRowProperty(item, config.key)
1238
+ return getSearchText(value).toLowerCase().includes(trimmed)
1239
+ }),
1240
+ )
1241
+ }, [columnConfigs, items, search])
1242
+
1243
+ const columns = React.useMemo<ColumnDef<TableRow>[]>(
1244
+ () =>
1245
+ columnConfigs.map(config => ({
1246
+ id: config.key,
1247
+ accessorFn: row =>
1248
+ config.key === "id" ? row.id : getRowProperty(row, config.key),
1249
+ enableSorting: config.kind !== "empty",
1250
+ size: getColumnWidth(config.kind),
1251
+ meta: { kind: config.kind },
1252
+ sortingFn: (left, right) =>
1253
+ compareValues(
1254
+ config.key === "id"
1255
+ ? left.original.id
1256
+ : getRowProperty(left.original, config.key),
1257
+ config.key === "id"
1258
+ ? right.original.id
1259
+ : getRowProperty(right.original, config.key),
1260
+ ),
1261
+ header: ({ column }) => {
1262
+ const sortState = column.getIsSorted()
1263
+ const canSort = column.getCanSort()
1264
+ const isNumeric = config.kind === "number"
1265
+
1266
+ return (
1267
+ <button
1268
+ type="button"
1269
+ onClick={column.getToggleSortingHandler()}
1270
+ disabled={!canSort}
1271
+ title={canSort ? `Sort by ${config.label}` : undefined}
1272
+ aria-sort={
1273
+ sortState === "asc"
1274
+ ? "ascending"
1275
+ : sortState === "desc"
1276
+ ? "descending"
1277
+ : canSort
1278
+ ? "none"
1279
+ : undefined
1280
+ }
1281
+ className={`group/sort inline-flex items-center gap-1.5 disabled:cursor-default ${isNumeric ? "flex-row-reverse" : ""}`}
1282
+ >
1283
+ <span
1284
+ className={`eyebrow truncate ${sortState ? "text-(--foreground)" : ""}`}
1285
+ >
1286
+ {config.label}
1287
+ </span>
1288
+ {canSort ? (
1289
+ <span
1290
+ aria-hidden="true"
1291
+ data-state={sortState || "none"}
1292
+ className={`sort-indicator flex h-3 w-3 shrink-0 items-center justify-center ${
1293
+ sortState
1294
+ ? "text-(--accent) opacity-100"
1295
+ : "text-(--muted) opacity-0 group-hover/sort:opacity-70"
1296
+ }`}
1297
+ >
1298
+ <svg
1299
+ viewBox="0 0 12 12"
1300
+ className="h-3 w-3"
1301
+ fill="none"
1302
+ stroke="currentColor"
1303
+ strokeWidth="1.75"
1304
+ strokeLinecap="round"
1305
+ strokeLinejoin="round"
1306
+ >
1307
+ <path d="m3 7 3-3 3 3" />
1308
+ </svg>
1309
+ </span>
1310
+ ) : null}
1311
+ </button>
1312
+ )
1313
+ },
1314
+ cell: context => (
1315
+ <PropertyValue
1316
+ value={context.getValue()}
1317
+ kind={config.kind}
1318
+ isPrimary={config.key === primaryColumnId}
1319
+ />
1320
+ ),
1321
+ })),
1322
+ [columnConfigs, primaryColumnId],
1323
+ )
1324
+
1325
+ const table = useReactTable({
1326
+ data: filteredItems,
1327
+ columns,
1328
+ state: { sorting },
1329
+ onSortingChange: setSorting,
1330
+ getCoreRowModel: getCoreRowModel(),
1331
+ getSortedRowModel: getSortedRowModel(),
1332
+ })
1333
+
1334
+ const hasActiveSearch = search.trim().length > 0
1335
+ const noSearchResults = items.length > 0 && filteredItems.length === 0
1336
+
1337
+ const allColumnsHidden =
1338
+ columnConfigs.length === 0 && allColumnConfigs.length > 0
1339
+
1340
+ const totalCount = items.length
1341
+ const shownCount = filteredItems.length
1342
+ const canUpdateRows = !isUsingFallbackData && writableColumns.length > 0
1343
+
1344
+ return (
1345
+ <section className="flex flex-col gap-3">
1346
+ <div className="stage-toolbar flex flex-wrap items-center gap-2">
1347
+ <div className="field-control relative flex min-w-[220px] flex-1 items-center rounded-md border border-(--line) bg-(--surface-bg) hover:border-(--line-strong)">
1348
+ <svg
1349
+ aria-hidden="true"
1350
+ viewBox="0 0 20 20"
1351
+ className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-(--muted)"
1352
+ fill="none"
1353
+ stroke="currentColor"
1354
+ strokeWidth="1.75"
1355
+ strokeLinecap="round"
1356
+ strokeLinejoin="round"
1357
+ >
1358
+ <circle cx="9" cy="9" r="6" />
1359
+ <path d="m17 17-3.8-3.8" />
1360
+ </svg>
1361
+ <input
1362
+ ref={searchInputRef}
1363
+ type="text"
1364
+ value={search}
1365
+ onChange={event => setSearch(event.target.value)}
1366
+ onKeyDown={event => {
1367
+ if (event.key === "Escape" && search) {
1368
+ event.preventDefault()
1369
+ setSearch("")
1370
+ }
1371
+ }}
1372
+ placeholder="Search rows"
1373
+ aria-label="Search rows"
1374
+ className="min-h-9 w-full bg-transparent pr-20 pl-9 text-sm text-(--foreground) outline-none placeholder:text-(--muted)"
1375
+ />
1376
+ {hasActiveSearch ? (
1377
+ <button
1378
+ type="button"
1379
+ onClick={() => {
1380
+ setSearch("")
1381
+ searchInputRef.current?.focus()
1382
+ }}
1383
+ aria-label="Clear search"
1384
+ className="absolute top-1/2 right-2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-(--muted) transition-colors hover:bg-(--chip-bg) hover:text-(--foreground)"
1385
+ >
1386
+ <svg
1387
+ aria-hidden="true"
1388
+ viewBox="0 0 16 16"
1389
+ className="h-3 w-3"
1390
+ fill="none"
1391
+ stroke="currentColor"
1392
+ strokeWidth="1.75"
1393
+ strokeLinecap="round"
1394
+ >
1395
+ <path d="m4 4 8 8M12 4l-8 8" />
1396
+ </svg>
1397
+ </button>
1398
+ ) : (
1399
+ <span
1400
+ aria-hidden="true"
1401
+ className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2"
1402
+ >
1403
+ <span className="kbd">/</span>
1404
+ </span>
1405
+ )}
1406
+ </div>
1407
+
1408
+ <div
1409
+ className="ff-mono hidden items-baseline gap-1 px-1 text-[11px] text-(--muted) tabular-nums sm:inline-flex"
1410
+ aria-live="polite"
1411
+ >
1412
+ <span className="text-(--foreground)">
1413
+ {shownCount.toLocaleString()}
1414
+ </span>
1415
+ <span className="text-(--line-strong)">/</span>
1416
+ <span>{totalCount.toLocaleString()}</span>
1417
+ <span className="eyebrow ml-1.5">rows</span>
1418
+ </div>
1419
+
1420
+ {dataSources.length > 1 ? (
1421
+ <label className="block">
1422
+ <span className="sr-only">Choose data source</span>
1423
+ <select
1424
+ value={activeDataSourceKey}
1425
+ onChange={event => onSelectDataSource(event.target.value)}
1426
+ className="field-control min-h-9 w-full rounded-md border border-(--line) bg-(--surface-bg) px-2 text-sm text-(--foreground) outline-none hover:border-(--line-strong)"
1427
+ >
1428
+ {dataSources.map(dataSource => (
1429
+ <option key={dataSource.key} value={dataSource.key}>
1430
+ {dataSource.key}
1431
+ </option>
1432
+ ))}
1433
+ </select>
1434
+ </label>
1435
+ ) : null}
1436
+
1437
+ {hasMore ? (
1438
+ <button
1439
+ type="button"
1440
+ onClick={onFetchMore}
1441
+ disabled={isLoading}
1442
+ title={isLoading ? "Loading more rows" : "Fetch more rows"}
1443
+ aria-label={isLoading ? "Loading more rows" : "Fetch more rows"}
1444
+ className="field-control inline-flex h-9 w-9 items-center justify-center rounded-md border border-(--line) bg-(--surface-bg) text-(--muted-strong) transition-colors hover:bg-(--chip-bg) hover:text-(--foreground) disabled:cursor-progress disabled:opacity-80"
1445
+ >
1446
+ {isLoading ? (
1447
+ <svg
1448
+ aria-hidden="true"
1449
+ viewBox="0 0 16 16"
1450
+ className="h-3.5 w-3.5 animate-spin"
1451
+ fill="none"
1452
+ stroke="currentColor"
1453
+ strokeWidth="1.75"
1454
+ strokeLinecap="round"
1455
+ >
1456
+ <circle cx="8" cy="8" r="6" strokeOpacity="0.25" />
1457
+ <path d="M14 8a6 6 0 0 0-6-6" />
1458
+ </svg>
1459
+ ) : (
1460
+ <svg
1461
+ aria-hidden="true"
1462
+ viewBox="0 0 16 16"
1463
+ className="h-3.5 w-3.5"
1464
+ fill="none"
1465
+ stroke="currentColor"
1466
+ strokeWidth="1.75"
1467
+ strokeLinecap="round"
1468
+ strokeLinejoin="round"
1469
+ >
1470
+ <path d="M8 3v10M3 9l5 4 5-4" />
1471
+ </svg>
1472
+ )}
1473
+ </button>
1474
+ ) : null}
1475
+
1476
+ {canUpdateRows ? (
1477
+ <button
1478
+ type="button"
1479
+ onClick={openCreateRow}
1480
+ disabled={creatingDraft !== undefined}
1481
+ className="field-control inline-flex min-h-9 items-center gap-1.5 rounded-md border border-(--line) bg-(--surface-bg) px-2.5 text-sm text-(--foreground) hover:border-(--line-strong) hover:bg-(--chip-bg) disabled:cursor-default disabled:opacity-60"
1482
+ >
1483
+ <svg
1484
+ aria-hidden="true"
1485
+ viewBox="0 0 16 16"
1486
+ className="h-3.5 w-3.5"
1487
+ fill="none"
1488
+ stroke="currentColor"
1489
+ strokeWidth="1.75"
1490
+ strokeLinecap="round"
1491
+ >
1492
+ <path d="M8 3v10M3 8h10" />
1493
+ </svg>
1494
+ <span>New row</span>
1495
+ </button>
1496
+ ) : null}
1497
+
1498
+ <ColumnSettings
1499
+ allColumnConfigs={allColumnConfigs}
1500
+ columnOrder={columnOrder}
1501
+ hiddenColumns={hiddenColumns}
1502
+ onColumnOrderChange={onColumnOrderChange}
1503
+ onHiddenColumnsChange={onHiddenColumnsChange}
1504
+ />
1505
+ </div>
1506
+
1507
+ {canUpdateRows && creatingDraft ? (
1508
+ <RowEditorPanel
1509
+ mode={{ type: "create" }}
1510
+ draftByKey={creatingDraft}
1511
+ writableColumns={writableColumns}
1512
+ status={createStatus}
1513
+ onDraftChange={updateCreateDraft}
1514
+ onCancel={closeCreateRow}
1515
+ onSubmit={submitCreate}
1516
+ />
1517
+ ) : null}
1518
+
1519
+ {canUpdateRows && editingRow ? (
1520
+ <RowEditorPanel
1521
+ mode={{ type: "update", rowId: editingRow.row.id }}
1522
+ draftByKey={editingRow.draftByKey}
1523
+ writableColumns={writableColumns}
1524
+ status={updateStatus}
1525
+ onDraftChange={updateDraft}
1526
+ onCancel={closeEditor}
1527
+ onSubmit={submitUpdate}
1528
+ />
1529
+ ) : !isUsingFallbackData && writableColumns.length === 0 ? (
1530
+ <p className="flex items-baseline gap-2 text-xs text-(--muted)">
1531
+ <span className="eyebrow">Update</span>
1532
+ <span>
1533
+ No host-provided writable property schemas are available for this
1534
+ data source.
1535
+ </span>
1536
+ </p>
1537
+ ) : null}
1538
+
1539
+ {isUsingFallbackData ? (
1540
+ <p className="flex items-baseline gap-2 text-xs text-(--muted)">
1541
+ <span className="eyebrow">Preview</span>
1542
+ <span>
1543
+ Showing sample rows until a data source is mapped for &ldquo;
1544
+ <span className="ff-mono text-(--muted-strong)">
1545
+ {activeDataSourceKey}
1546
+ </span>
1547
+ &rdquo;.
1548
+ </span>
1549
+ </p>
1550
+ ) : null}
1551
+ {queryError ? (
1552
+ <p className="flex items-baseline gap-2 text-xs text-(--muted)">
1553
+ <span className="eyebrow">Note</span>
1554
+ <span>{queryError}</span>
1555
+ </p>
1556
+ ) : null}
1557
+
1558
+ {isLoading && items.length === 0 ? (
1559
+ <EmptyState
1560
+ title="Loading rows"
1561
+ description="Waiting for the mapped collection to return its first page."
1562
+ />
1563
+ ) : isCollectionEmpty ? (
1564
+ <EmptyState
1565
+ title="No rows yet"
1566
+ description="Add a row in Notion and it will appear here."
1567
+ />
1568
+ ) : allColumnsHidden ? (
1569
+ <EmptyState
1570
+ title="All columns hidden"
1571
+ description="Open the columns menu to show at least one."
1572
+ action={
1573
+ <button
1574
+ type="button"
1575
+ onClick={() => onHiddenColumnsChange(new Set())}
1576
+ className="field-control min-h-9 rounded-md border border-(--line) bg-(--surface-bg) px-3 text-sm text-(--foreground) transition-colors hover:bg-(--chip-bg)"
1577
+ >
1578
+ Show all columns
1579
+ </button>
1580
+ }
1581
+ />
1582
+ ) : noSearchResults ? (
1583
+ <EmptyState
1584
+ title="No matching rows"
1585
+ description={`No values matched "${search}".`}
1586
+ action={
1587
+ <button
1588
+ type="button"
1589
+ onClick={() => setSearch("")}
1590
+ className="field-control min-h-9 rounded-md border border-(--line) bg-(--surface-bg) px-3 text-sm text-(--foreground) transition-colors hover:bg-(--chip-bg)"
1591
+ >
1592
+ Clear search
1593
+ </button>
1594
+ }
1595
+ />
1596
+ ) : (
1597
+ <div className="stage-table table-view-scroll overflow-auto rounded-md border border-(--line) bg-(--surface-bg)">
1598
+ <table className="min-w-full border-separate border-spacing-0">
1599
+ <thead>
1600
+ {table.getHeaderGroups().map(headerGroup => (
1601
+ <tr key={headerGroup.id}>
1602
+ {headerGroup.headers.map(header => {
1603
+ const kind = (
1604
+ header.column.columnDef as { meta?: { kind: ColumnKind } }
1605
+ ).meta?.kind
1606
+ const isNumeric = kind === "number"
1607
+ return (
1608
+ <th
1609
+ key={header.id}
1610
+ scope="col"
1611
+ className={`table-head-cell sticky top-0 z-10 border-b border-(--line) px-3 py-2.5 align-middle ${isNumeric ? "text-right" : "text-left"}`}
1612
+ style={{
1613
+ width: header.getSize(),
1614
+ minWidth: header.getSize(),
1615
+ }}
1616
+ >
1617
+ {header.isPlaceholder
1618
+ ? null
1619
+ : flexRender(
1620
+ header.column.columnDef.header,
1621
+ header.getContext(),
1622
+ )}
1623
+ </th>
1624
+ )
1625
+ })}
1626
+ {canUpdateRows ? (
1627
+ <th
1628
+ scope="col"
1629
+ className="table-head-cell sticky top-0 z-10 w-[92px] border-b border-(--line) px-3 py-2.5 text-right align-middle"
1630
+ >
1631
+ <span className="eyebrow">Update</span>
1632
+ </th>
1633
+ ) : null}
1634
+ </tr>
1635
+ ))}
1636
+ </thead>
1637
+
1638
+ <tbody>
1639
+ {table.getRowModel().rows.map(row => (
1640
+ <tr key={row.id} className="data-row group/row">
1641
+ {row.getVisibleCells().map(cell => {
1642
+ const kind = (
1643
+ cell.column.columnDef as { meta?: { kind: ColumnKind } }
1644
+ ).meta?.kind
1645
+ const isNumeric = kind === "number"
1646
+ return (
1647
+ <td
1648
+ key={cell.id}
1649
+ className={`border-b border-(--line) px-3 py-2.5 align-middle ${isNumeric ? "text-right" : "text-left"}`}
1650
+ style={{
1651
+ width: cell.column.getSize(),
1652
+ minWidth: cell.column.getSize(),
1653
+ }}
1654
+ >
1655
+ {flexRender(
1656
+ cell.column.columnDef.cell,
1657
+ cell.getContext(),
1658
+ )}
1659
+ </td>
1660
+ )
1661
+ })}
1662
+ {canUpdateRows ? (
1663
+ <td className="w-[92px] border-b border-(--line) px-3 py-2.5 text-right align-middle">
1664
+ <button
1665
+ type="button"
1666
+ onClick={() => openEditorForRow(row.original)}
1667
+ className="field-control rounded-md border border-(--line) bg-(--surface-bg) px-2 py-1 text-xs text-(--foreground) opacity-80 transition-opacity hover:bg-(--chip-bg) hover:opacity-100"
1668
+ >
1669
+ Edit
1670
+ </button>
1671
+ </td>
1672
+ ) : null}
1673
+ </tr>
1674
+ ))}
1675
+ </tbody>
1676
+ </table>
1677
+ </div>
1678
+ )}
1679
+ </section>
1680
+ )
1681
+ }
1682
+
1683
+ function App() {
1684
+ const theme = useTheme()
1685
+ const dataSources = useDataSourceDefinitions()
1686
+ const [activeDataSourceKey, setActiveDataSourceKey] = React.useState(
1687
+ dataSources[0]?.key ?? "default",
1688
+ )
1689
+
1690
+ React.useEffect(() => {
1691
+ if (dataSources.length === 0) {
1692
+ setActiveDataSourceKey("default")
1693
+ return
1694
+ }
1695
+
1696
+ if (
1697
+ !dataSources.some(dataSource => dataSource.key === activeDataSourceKey)
1698
+ ) {
1699
+ setActiveDataSourceKey(dataSources[0].key)
1700
+ }
1701
+ }, [activeDataSourceKey, dataSources])
1702
+
1703
+ const queryKey = dataSources.length === 0 ? "default" : activeDataSourceKey
1704
+ const query = useDataSource(queryKey)
1705
+ const colorTheme = theme === "dark" ? "dark" : "light"
1706
+
1707
+ const mappedItems = query.items as TableRow[]
1708
+ const isUsingFallbackData = dataSources.length === 0
1709
+ const isCollectionEmpty =
1710
+ !isUsingFallbackData && !query.isLoading && mappedItems.length === 0
1711
+ const items = isUsingFallbackData ? SAMPLE_ITEMS : mappedItems
1712
+
1713
+ const allColumnConfigs = React.useMemo(
1714
+ () => getColumnConfigs(items, query.propertySchemasById),
1715
+ [items, query.propertySchemasById],
1716
+ )
1717
+
1718
+ const [columnOrder, setColumnOrder] = React.useState<string[]>([])
1719
+ const [hiddenColumns, setHiddenColumns] = React.useState<Set<string>>(
1720
+ () => new Set(),
1721
+ )
1722
+ const seededForKey = React.useRef<string | null>(null)
1723
+
1724
+ React.useEffect(() => {
1725
+ if (seededForKey.current === queryKey) {
1726
+ return
1727
+ }
1728
+ if (allColumnConfigs.length === 0) {
1729
+ return
1730
+ }
1731
+ seededForKey.current = queryKey
1732
+ setColumnOrder(allColumnConfigs.map(config => config.key))
1733
+ setHiddenColumns(new Set())
1734
+ }, [queryKey, allColumnConfigs])
1735
+
1736
+ React.useEffect(() => {
1737
+ setColumnOrder(previous => {
1738
+ if (previous.length === 0) {
1739
+ return previous
1740
+ }
1741
+ const known = new Set(previous)
1742
+ const additions = allColumnConfigs
1743
+ .map(config => config.key)
1744
+ .filter(key => !known.has(key))
1745
+ if (additions.length === 0) {
1746
+ return previous
1747
+ }
1748
+ return [...previous, ...additions]
1749
+ })
1750
+ }, [allColumnConfigs])
1751
+
1752
+ const visibleColumnConfigs = React.useMemo(() => {
1753
+ if (allColumnConfigs.length === 0) {
1754
+ return allColumnConfigs
1755
+ }
1756
+ const byKey = new Map(
1757
+ allColumnConfigs.map(config => [config.key, config] as const),
1758
+ )
1759
+ const seen = new Set<string>()
1760
+ const ordered: ColumnConfig[] = []
1761
+ for (const key of columnOrder) {
1762
+ const config = byKey.get(key)
1763
+ if (!config || seen.has(key)) {
1764
+ continue
1765
+ }
1766
+ seen.add(key)
1767
+ if (!hiddenColumns.has(key)) {
1768
+ ordered.push(config)
1769
+ }
1770
+ }
1771
+ for (const config of allColumnConfigs) {
1772
+ if (seen.has(config.key)) {
1773
+ continue
1774
+ }
1775
+ seen.add(config.key)
1776
+ if (!hiddenColumns.has(config.key)) {
1777
+ ordered.push(config)
1778
+ }
1779
+ }
1780
+ return ordered
1781
+ }, [allColumnConfigs, columnOrder, hiddenColumns])
1782
+
1783
+ return (
1784
+ <div data-theme={colorTheme} className="table-view-shell">
1785
+ <div className="p-4">
1786
+ <DataWorkspace
1787
+ items={items}
1788
+ columnConfigs={visibleColumnConfigs}
1789
+ allColumnConfigs={allColumnConfigs}
1790
+ propertySchemasById={query.propertySchemasById}
1791
+ columnOrder={columnOrder}
1792
+ hiddenColumns={hiddenColumns}
1793
+ onColumnOrderChange={setColumnOrder}
1794
+ onHiddenColumnsChange={setHiddenColumns}
1795
+ activeDataSourceKey={queryKey}
1796
+ dataSources={dataSources}
1797
+ onSelectDataSource={setActiveDataSourceKey}
1798
+ hasMore={query.hasMore}
1799
+ isLoading={query.isLoading}
1800
+ onFetchMore={query.fetchMore}
1801
+ isUsingFallbackData={isUsingFallbackData}
1802
+ isCollectionEmpty={isCollectionEmpty}
1803
+ queryError={query.error}
1804
+ />
1805
+ </div>
1806
+ </div>
1807
+ )
1808
+ }
1809
+
1810
+ ReactDOM.createRoot(document.getElementById("root")!).render(
1811
+ <NotionCustomBlock>
1812
+ <App />
1813
+ </NotionCustomBlock>,
1814
+ )