@notis_ai/cli 0.2.13 → 0.2.15

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 (295) hide show
  1. package/README.md +125 -21
  2. package/dist/agent-hooks/notis-agent-hook.mjs +19750 -0
  3. package/{skills → dist/base-skills}/notis-apps/SKILL.md +93 -58
  4. package/{skills → dist/base-skills}/notis-cli/SKILL.md +9 -9
  5. package/dist/base-skills/notis-query/SKILL.md +705 -0
  6. package/dist/skill-sync/index.js +1528 -0
  7. package/dist/skill-sync/index.js.map +7 -0
  8. package/package.json +5 -2
  9. package/skills/notis-apps/cli.md +44 -18
  10. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  11. package/skills/notis-onboarding/BRIEF.md +16 -0
  12. package/src/agent-hook-entry.js +5 -0
  13. package/src/cli.js +23 -14
  14. package/src/command-specs/agents.js +392 -0
  15. package/src/command-specs/apps.js +1090 -418
  16. package/src/command-specs/auth.js +63 -10
  17. package/src/command-specs/handover.js +374 -0
  18. package/src/command-specs/index.js +9 -0
  19. package/src/command-specs/onboarding.js +69 -5
  20. package/src/command-specs/skills.js +56 -0
  21. package/src/command-specs/tools.js +6 -0
  22. package/src/runtime/agent-memory-state.js +126 -0
  23. package/src/runtime/agent-setup.js +383 -0
  24. package/src/runtime/app-dev-consumers.js +154 -0
  25. package/src/runtime/app-dev-host-lock.js +80 -0
  26. package/src/runtime/app-dev-roots.js +284 -0
  27. package/src/runtime/app-dev-server.js +229 -30
  28. package/src/runtime/app-dev-sessions.js +105 -145
  29. package/src/runtime/app-platform.js +1499 -161
  30. package/src/runtime/app-registry-scaffolds.js +367 -0
  31. package/src/runtime/base-skills.d.ts +20 -0
  32. package/src/runtime/base-skills.js +167 -0
  33. package/src/runtime/delegated-context.js +68 -0
  34. package/src/runtime/git.js +233 -0
  35. package/src/runtime/login-listener.js +15 -0
  36. package/src/runtime/oauth.js +1506 -141
  37. package/src/runtime/output.js +7 -0
  38. package/src/runtime/ports.js +16 -0
  39. package/src/runtime/profiles.js +0 -5
  40. package/src/runtime/skill-sync/cloud-client.ts +96 -0
  41. package/src/runtime/skill-sync/index.ts +644 -0
  42. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  43. package/src/runtime/skill-sync/symlink-manager.ts +383 -0
  44. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  45. package/src/runtime/skill-sync/types.ts +103 -0
  46. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  47. package/src/runtime/store-screenshot.js +6 -1
  48. package/src/runtime/sync-skills.d.ts +37 -0
  49. package/src/runtime/sync-skills.js +215 -0
  50. package/src/runtime/transport.js +19 -2
  51. package/template/.harness/index.html.tmpl +116 -47
  52. package/template/app/layout.tsx +5 -1
  53. package/template/notis.config.ts +1 -0
  54. package/template/packages/sdk/package.json +2 -1
  55. package/template/packages/sdk/src/components/DocumentEditor.tsx +13 -3
  56. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  57. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +37 -119
  58. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
  59. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
  60. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
  61. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  62. package/template/packages/sdk/src/config.ts +71 -0
  63. package/template/packages/sdk/src/documents.ts +28 -1
  64. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  65. package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  66. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +709 -0
  67. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  68. package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
  69. package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
  70. package/template/packages/sdk/src/hooks/useTool.ts +5 -4
  71. package/template/packages/sdk/src/index.ts +64 -2
  72. package/template/packages/sdk/src/interactions/actions.ts +46 -0
  73. package/template/packages/sdk/src/interactions/shortcuts.tsx +628 -0
  74. package/template/packages/sdk/src/interactions.ts +41 -0
  75. package/template/packages/sdk/src/provider.tsx +2 -1
  76. package/template/packages/sdk/src/runtime.ts +219 -3
  77. package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
  78. package/dist/scaffolds/notis-database/app/globals.css +0 -44
  79. package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
  80. package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
  81. package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
  82. package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
  83. package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
  84. package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
  85. package/dist/scaffolds/notis-database/components.json +0 -20
  86. package/dist/scaffolds/notis-database/index.html +0 -12
  87. package/dist/scaffolds/notis-database/lib/types.ts +0 -132
  88. package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
  89. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  90. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  91. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  92. package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
  93. package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
  94. package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
  95. package/dist/scaffolds/notis-database/notis.config.ts +0 -73
  96. package/dist/scaffolds/notis-database/package-lock.json +0 -3935
  97. package/dist/scaffolds/notis-database/package.json +0 -32
  98. package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
  99. package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  100. package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
  101. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  102. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  103. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  104. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -196
  105. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -229
  106. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
  107. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  108. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
  109. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
  110. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  111. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
  112. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  113. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
  114. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
  115. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  116. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  117. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -83
  118. package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
  119. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -220
  120. package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
  121. package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
  122. package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
  123. package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
  124. package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
  125. package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
  126. package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
  127. package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
  128. package/dist/scaffolds/notis-database/tsconfig.json +0 -23
  129. package/dist/scaffolds/notis-database/vite.config.ts +0 -22
  130. package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
  131. package/dist/scaffolds/notis-journal/app/globals.css +0 -37
  132. package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
  133. package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
  134. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
  135. package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
  136. package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
  137. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
  138. package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
  139. package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
  140. package/dist/scaffolds/notis-journal/components.json +0 -20
  141. package/dist/scaffolds/notis-journal/index.html +0 -12
  142. package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
  143. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  144. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  145. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  146. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  147. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  148. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  149. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
  150. package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
  151. package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
  152. package/dist/scaffolds/notis-journal/package.json +0 -34
  153. package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
  154. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  155. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
  156. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  157. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  158. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  159. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -196
  160. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -229
  161. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
  162. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  163. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +0 -78
  164. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +0 -121
  165. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  166. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
  167. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  168. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
  169. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
  170. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  171. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  172. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -83
  173. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
  174. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -220
  175. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
  176. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
  177. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
  178. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
  179. package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
  180. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
  181. package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
  182. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -197
  183. package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
  184. package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
  185. package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
  186. package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
  187. package/dist/scaffolds/notis-notes/app/globals.css +0 -3
  188. package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
  189. package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
  190. package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
  191. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
  192. package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
  193. package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
  194. package/dist/scaffolds/notis-notes/components.json +0 -20
  195. package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
  196. package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
  197. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  198. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  199. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  200. package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
  201. package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
  202. package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
  203. package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
  204. package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
  205. package/dist/scaffolds/notis-notes/package.json +0 -35
  206. package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
  207. package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  208. package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
  209. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  210. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  211. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  212. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -196
  213. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -229
  214. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
  215. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  216. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
  217. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
  218. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  219. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
  220. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  221. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
  222. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
  223. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  224. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  225. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -83
  226. package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
  227. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -220
  228. package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
  229. package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
  230. package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
  231. package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
  232. package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
  233. package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
  234. package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
  235. package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
  236. package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
  237. package/dist/scaffolds/notis-random/README.md +0 -33
  238. package/dist/scaffolds/notis-random/app/globals.css +0 -11
  239. package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
  240. package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
  241. package/dist/scaffolds/notis-random/app/page.tsx +0 -289
  242. package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
  243. package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
  244. package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
  245. package/dist/scaffolds/notis-random/components.json +0 -20
  246. package/dist/scaffolds/notis-random/index.html +0 -12
  247. package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
  248. package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
  249. package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
  250. package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
  251. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  252. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  253. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  254. package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
  255. package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
  256. package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
  257. package/dist/scaffolds/notis-random/notis.config.ts +0 -86
  258. package/dist/scaffolds/notis-random/package-lock.json +0 -4513
  259. package/dist/scaffolds/notis-random/package.json +0 -36
  260. package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
  261. package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  262. package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
  263. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  264. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  265. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  266. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -196
  267. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -229
  268. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
  269. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  270. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
  271. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
  272. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  273. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
  274. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  275. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
  276. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
  277. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  278. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  279. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -83
  280. package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
  281. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -220
  282. package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
  283. package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
  284. package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
  285. package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
  286. package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
  287. package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
  288. package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
  289. package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
  290. package/dist/scaffolds/notis-random/tsconfig.json +0 -23
  291. package/dist/scaffolds/notis-random/vite.config.ts +0 -11
  292. package/dist/scaffolds.json +0 -49
  293. package/template/metadata/screenshot-1.png +0 -0
  294. package/template/metadata/screenshot-2.png +0 -0
  295. package/template/metadata/screenshot-3.png +0 -0
@@ -1,1943 +0,0 @@
1
- 'use client';
2
-
3
- import { useEffect, useMemo, useRef, useState } from 'react';
4
- import {
5
- MultiSelectActionBar,
6
- MultiSelectCheckbox,
7
- MultiSelectDragOverlay,
8
- useMultiSelect,
9
- useBackend,
10
- useDatabaseSchema,
11
- useDocuments,
12
- useNotis,
13
- useNotisNavigation,
14
- useTopBarSearch,
15
- useUpsertDocument,
16
- asRecord,
17
- getDocumentPreview,
18
- getRelationIds,
19
- isPresentString,
20
- optionalString,
21
- type DatabaseProperty,
22
- type DocumentRecord,
23
- type MultiSelectController,
24
- type UpsertDocumentArgs,
25
- } from '@notis/sdk';
26
- import { ArrowUpRightIcon as ArrowUpRight, CubeIcon as Boxes, BookOpenIcon as BookOpen, BookOpenTextIcon as BookOpenText, CalendarIcon as CalendarDays, CaretDownIcon as ChevronDown, CaretLeftIcon as ChevronLeft, CaretRightIcon as ChevronRight, FileTextIcon as FileText, FolderIcon as Folder, FolderMinusIcon as FolderMinus, FolderOpenIcon as FolderOpen, FolderPlusIcon as FolderPlus, FoldersIcon as Folders, SquaresFourIcon as LayoutGrid, CircleNotchIcon as Loader2, MagnifyingGlassIcon as Search, NotePencilIcon as NotebookPen, PencilIcon as Pencil, PlusIcon as Plus, NoteIcon as StickyNote, TableIcon as Table2, TrashIcon as Trash, XIcon as X, type Icon } from '@phosphor-icons/react';
27
-
28
- import {
29
- PHOSPHOR_ICONS,
30
- PHOSPHOR_ICON_ALIASES,
31
- PHOSPHOR_ICON_NAMES,
32
- } from './phosphor-icons';
33
- import { Button } from '@/components/ui/button';
34
- import { cn } from '@/lib/utils';
35
- import {
36
- VISIBLE_PROPERTIES_PROPERTY,
37
- resolveVisibleColumns,
38
- } from '@/lib/visible-properties';
39
-
40
- type TabKey = 'gallery' | 'table' | 'calendar';
41
-
42
- type TabConfig = {
43
- key: TabKey;
44
- label: string;
45
- icon: Icon;
46
- };
47
-
48
- type FolderOption = {
49
- id: string;
50
- title: string;
51
- parentId: string | null;
52
- pathLabel: string;
53
- };
54
-
55
- type CollectionItemContext = {
56
- id: string;
57
- title: string;
58
- icon?: string | null;
59
- properties: Record<string, unknown>;
60
- };
61
-
62
- const NOTE_DATABASE_SLUG = 'notes';
63
- const FOLDER_DATABASE_SLUG = 'note_folders';
64
- const DEFAULT_NOTE_TITLE = 'Untitled note';
65
- const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
66
-
67
- // Sentinel for the built-in "Created" date, sourced from each document's
68
- // created_at timestamp rather than a user-defined date property.
69
- const CREATED_DATE_FIELD = '__created__';
70
- const CREATED_DATE_LABEL = 'Created';
71
-
72
- type CalendarDateField = {
73
- value: string;
74
- label: string;
75
- };
76
-
77
- const TABS: TabConfig[] = [
78
- { key: 'gallery', label: 'Gallery', icon: LayoutGrid },
79
- { key: 'table', label: 'Table', icon: Table2 },
80
- { key: 'calendar', label: 'Calendar', icon: CalendarDays },
81
- ];
82
-
83
- type IconName = string;
84
-
85
- const PHOSPHOR_ICON_PREFIX = 'phosphor:';
86
-
87
- const SUGGESTED_FOLDER_ICON_NAMES: IconName[] = [
88
- 'folder',
89
- 'folder-open',
90
- 'folder-kanban',
91
- 'archive',
92
- 'bookmark',
93
- 'book-open',
94
- 'briefcase',
95
- 'calendar',
96
- 'file-text',
97
- 'flag',
98
- 'house',
99
- 'lightbulb',
100
- 'note-pencil',
101
- 'palette',
102
- 'rocket',
103
- 'magnifying-glass',
104
- 'sparkle',
105
- 'tag',
106
- ];
107
-
108
- function parsePhosphorIconName(icon: string | null | undefined): IconName | null {
109
- if (typeof icon !== 'string' || !icon.startsWith(PHOSPHOR_ICON_PREFIX)) {
110
- return null;
111
- }
112
- const name = icon.slice(PHOSPHOR_ICON_PREFIX.length).trim().replace(/_/g, '-').toLowerCase();
113
- return name && /^[a-z][a-z0-9-]*$/.test(name) ? name : null;
114
- }
115
-
116
- function getPhosphorIconComponent(icon: string | null | undefined): Icon | null {
117
- const name = parsePhosphorIconName(icon);
118
- return name ? getPhosphorIconComponentByName(name) : null;
119
- }
120
-
121
- function getPhosphorIconComponentByName(name: string): Icon | null {
122
- return PHOSPHOR_ICONS[name] ?? PHOSPHOR_ICONS[PHOSPHOR_ICON_ALIASES[name] ?? ''] ?? null;
123
- }
124
-
125
- const ALL_PHOSPHOR_ICON_NAMES = [...PHOSPHOR_ICON_NAMES].sort((a, b) => a.localeCompare(b));
126
-
127
- function normalizeCollectionItem(value: unknown): CollectionItemContext | null {
128
- const record = asRecord(value);
129
- const id = optionalString(record?.id);
130
- if (!record || !id) return null;
131
- return {
132
- id,
133
- title: optionalString(record.title) ?? 'Untitled',
134
- icon: optionalString(record.icon),
135
- properties: asRecord(record.properties) ?? {},
136
- };
137
- }
138
-
139
- function pluralize(count: number, singular: string, plural = `${singular}s`): string {
140
- return `${count} ${count === 1 ? singular : plural}`;
141
- }
142
-
143
- function getDateKey(value: unknown): string | null {
144
- if (!isPresentString(value)) return null;
145
- const match = value.match(/^(\d{4}-\d{2}-\d{2})/);
146
- return match ? match[1] : null;
147
- }
148
-
149
- function getDateInputValue(value: unknown): string {
150
- return getDateKey(value) ?? '';
151
- }
152
-
153
- function getNoteDateValue(note: DocumentRecord, field: string): unknown {
154
- if (field === CREATED_DATE_FIELD) return note.createdAt;
155
- return note.properties[field];
156
- }
157
-
158
- function getFolderTitle(document: DocumentRecord): string {
159
- const explicit = document.properties.Name;
160
- if (isPresentString(explicit)) return explicit;
161
- return document.title || 'Untitled folder';
162
- }
163
-
164
- function getNoteTitle(document: DocumentRecord): string {
165
- return isPresentString(document.title) ? document.title : DEFAULT_NOTE_TITLE;
166
- }
167
-
168
- function getNotePreviewText(document: DocumentRecord): string {
169
- return getDocumentPreview(document);
170
- }
171
-
172
- function buildFolderOptions(documents: DocumentRecord[]): FolderOption[] {
173
- const byId = new Map(documents.map((d) => [d.id, d]));
174
-
175
- function buildPath(id: string, seen: Set<string> = new Set()): string {
176
- const folder = byId.get(id);
177
- if (!folder) return 'Untitled folder';
178
- const title = getFolderTitle(folder);
179
- if (seen.has(id)) return title;
180
- seen.add(id);
181
- const parentId = getRelationIds(folder.properties.Parent)[0] ?? null;
182
- if (!parentId || !byId.has(parentId)) return title;
183
- return `${buildPath(parentId, seen)} / ${title}`;
184
- }
185
-
186
- return documents
187
- .map((d) => ({
188
- id: d.id,
189
- title: getFolderTitle(d),
190
- parentId: getRelationIds(d.properties.Parent)[0] ?? null,
191
- pathLabel: buildPath(d.id),
192
- }))
193
- .sort((a, b) => a.pathLabel.localeCompare(b.pathLabel));
194
- }
195
-
196
- function getCoverUrl(document: DocumentRecord): string | null {
197
- if (isPresentString(document.cover)) {
198
- return document.cover.trim();
199
- }
200
- return null;
201
- }
202
-
203
- function NoteIcon({
204
- icon,
205
- className,
206
- fallbackClassName,
207
- }: {
208
- icon: string | null | undefined;
209
- className?: string;
210
- fallbackClassName?: string;
211
- }) {
212
- const Icon = getPhosphorIconComponent(icon);
213
- if (Icon) {
214
- return <Icon className={cn('h-3.5 w-3.5', className)} />;
215
- }
216
- if (typeof icon === 'string' && /^https?:\/\//i.test(icon.trim())) {
217
- return (
218
- <img
219
- src={icon.trim()}
220
- alt=""
221
- className={cn('h-3.5 w-3.5 rounded-sm object-cover', className)}
222
- />
223
- );
224
- }
225
- return <FileText className={cn('h-3.5 w-3.5', className, fallbackClassName)} />;
226
- }
227
-
228
- function formatDateLabel(value: unknown): string {
229
- const key = getDateKey(value);
230
- if (!key) return 'No date';
231
- const date = new Date(`${key}T12:00:00`);
232
- return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date);
233
- }
234
-
235
- function formatPropertyValue(
236
- value: unknown,
237
- property: DatabaseProperty | null,
238
- folderNameById: Map<string, string>,
239
- ): string {
240
- if (property?.type === 'relation') {
241
- const ids = getRelationIds(value);
242
- if (!ids.length) return 'None';
243
- return ids.map((id) => folderNameById.get(id) ?? id).join(', ');
244
- }
245
- if (property?.type === 'date') return formatDateLabel(value);
246
- if (Array.isArray(value)) return value.length ? value.map((v) => String(v)).join(', ') : 'None';
247
- if (typeof value === 'boolean') return value ? 'Yes' : 'No';
248
- if (typeof value === 'number') return String(value);
249
- if (isPresentString(value)) return value;
250
- return 'None';
251
- }
252
-
253
- function formatMonthLabel(month: Date): string {
254
- return new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(month);
255
- }
256
-
257
- function startOfCalendarGrid(month: Date): Date {
258
- const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
259
- const mondayIndex = (first.getDay() + 6) % 7;
260
- const start = new Date(first);
261
- start.setDate(first.getDate() - mondayIndex);
262
- return start;
263
- }
264
-
265
- function buildCalendarDays(month: Date): Date[] {
266
- const start = startOfCalendarGrid(month);
267
- return Array.from({ length: 42 }, (_, i) => {
268
- const d = new Date(start);
269
- d.setDate(start.getDate() + i);
270
- return d;
271
- });
272
- }
273
-
274
- function isSameMonth(a: Date, b: Date): boolean {
275
- return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
276
- }
277
-
278
- function isToday(day: Date): boolean {
279
- const t = new Date();
280
- return day.getFullYear() === t.getFullYear() && day.getMonth() === t.getMonth() && day.getDate() === t.getDate();
281
- }
282
-
283
- function getStatusLabel(value: unknown): string | null {
284
- return isPresentString(value) ? value : null;
285
- }
286
-
287
- type StatusTone = 'active' | 'review' | 'done' | 'blocked' | 'idea' | 'neutral';
288
-
289
- function getStatusTone(status: string | null): StatusTone {
290
- if (!status) return 'neutral';
291
- const s = status.toLowerCase();
292
- if (s.includes('done') || s.includes('complete') || s.includes('ship')) return 'done';
293
- if (s.includes('progress') || s.includes('active') || s.includes('draft')) return 'active';
294
- if (s.includes('review') || s.includes('wait') || s.includes('hold')) return 'review';
295
- if (s.includes('block') || s.includes('stuck')) return 'blocked';
296
- if (s.includes('idea') || s.includes('backlog')) return 'idea';
297
- return 'neutral';
298
- }
299
-
300
- const statusPillClasses: Record<StatusTone, string> = {
301
- active: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
302
- review: 'bg-blue-500/10 text-blue-700 dark:text-blue-400',
303
- done: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
304
- blocked: 'bg-red-500/10 text-red-700 dark:text-red-400',
305
- idea: 'bg-muted text-muted-foreground',
306
- neutral: 'bg-muted text-muted-foreground',
307
- };
308
-
309
- const statusDotClasses: Record<StatusTone, string> = {
310
- active: 'bg-amber-500',
311
- review: 'bg-blue-500',
312
- done: 'bg-emerald-500',
313
- blocked: 'bg-red-500',
314
- idea: 'bg-stone-400',
315
- neutral: 'bg-stone-400',
316
- };
317
-
318
- const statusBarClasses: Record<StatusTone, string> = {
319
- active: 'border-l-amber-500',
320
- review: 'border-l-blue-500',
321
- done: 'border-l-emerald-500',
322
- blocked: 'border-l-red-500',
323
- idea: 'border-l-stone-400',
324
- neutral: 'border-l-stone-400',
325
- };
326
-
327
- function StatusPill({ status }: { status: string | null }) {
328
- if (!status) return null;
329
- const tone = getStatusTone(status);
330
- return (
331
- <span
332
- className={cn(
333
- 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-tight',
334
- statusPillClasses[tone],
335
- )}
336
- >
337
- <span className={cn('h-1.5 w-1.5 rounded-full', statusDotClasses[tone])} />
338
- {status}
339
- </span>
340
- );
341
- }
342
-
343
- function Eyebrow({ children, className }: { children: React.ReactNode; className?: string }) {
344
- return (
345
- <span
346
- className={cn(
347
- 'font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground',
348
- className,
349
- )}
350
- >
351
- {children}
352
- </span>
353
- );
354
- }
355
-
356
- function EmptyState({
357
- icon: Icon,
358
- title,
359
- description,
360
- action,
361
- }: {
362
- icon: Icon;
363
- title: string;
364
- description: string;
365
- action?: React.ReactNode;
366
- }) {
367
- return (
368
- <div className="flex flex-col items-center justify-center px-6 py-16 text-center">
369
- <Icon className="mb-4 h-10 w-10 stroke-[1.5] text-muted-foreground/30" />
370
- <p className="text-sm font-semibold text-foreground">{title}</p>
371
- <p className="mt-1 max-w-sm text-[13px] text-muted-foreground">{description}</p>
372
- {action ? <div className="mt-5">{action}</div> : null}
373
- </div>
374
- );
375
- }
376
-
377
- function LoadingState() {
378
- return (
379
- <div className="flex min-h-[360px] items-center justify-center gap-2 text-sm text-muted-foreground">
380
- <Loader2 className="h-4 w-4 animate-spin" />
381
- Loading notes…
382
- </div>
383
- );
384
- }
385
-
386
- function PageIcon({ icon }: { icon: string | null | undefined }) {
387
- const Icon = getPhosphorIconComponent(icon);
388
- if (Icon) {
389
- return <Icon className="h-7 w-7 stroke-[1.5] text-foreground" />;
390
- }
391
- if (typeof icon === 'string' && /^https?:\/\//i.test(icon.trim())) {
392
- return (
393
- <img
394
- src={icon.trim()}
395
- alt=""
396
- className="h-7 w-7 rounded-md object-cover"
397
- />
398
- );
399
- }
400
- return <Boxes className="h-7 w-7 stroke-[1.5] text-foreground" />;
401
- }
402
-
403
- function FolderIconPicker({
404
- displayIcon,
405
- storedIcon,
406
- disabled,
407
- onChange,
408
- onRemove,
409
- }: {
410
- displayIcon: string | null;
411
- storedIcon: string | null;
412
- disabled?: boolean;
413
- onChange: (icon: string) => void;
414
- onRemove: () => void;
415
- }) {
416
- const [open, setOpen] = useState(false);
417
- const [query, setQuery] = useState('');
418
- const containerRef = useRef<HTMLDivElement>(null);
419
- const selectedName = parsePhosphorIconName(storedIcon);
420
- const filteredIcons = useMemo(() => {
421
- const normalizedQuery = query.trim().toLowerCase();
422
- if (!normalizedQuery) return SUGGESTED_FOLDER_ICON_NAMES;
423
- return ALL_PHOSPHOR_ICON_NAMES
424
- .filter((name) => name.replace(/-/g, ' ').includes(normalizedQuery))
425
- .slice(0, 96);
426
- }, [query]);
427
-
428
- useEffect(() => {
429
- if (!open) return;
430
-
431
- function handlePointerDown(event: Event) {
432
- const target = event.target;
433
- if (target instanceof Node && containerRef.current?.contains(target)) {
434
- return;
435
- }
436
- setOpen(false);
437
- }
438
-
439
- const root = containerRef.current?.getRootNode();
440
- const eventTarget =
441
- root instanceof ShadowRoot || root instanceof Document
442
- ? root
443
- : document;
444
- eventTarget.addEventListener('pointerdown', handlePointerDown);
445
- return () => eventTarget.removeEventListener('pointerdown', handlePointerDown);
446
- }, [open]);
447
-
448
- return (
449
- <div ref={containerRef} className="relative w-fit">
450
- <button
451
- type="button"
452
- aria-label="Edit folder icon"
453
- disabled={disabled}
454
- onClick={() => setOpen((value) => !value)}
455
- className="group/icon inline-flex size-10 items-center justify-center rounded-lg text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-60"
456
- >
457
- <PageIcon icon={displayIcon} />
458
- </button>
459
- {open ? (
460
- <div className="absolute left-0 top-12 z-20 w-[320px] rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl">
461
- <div className="mb-2 flex items-center gap-2">
462
- <input
463
- autoFocus
464
- value={query}
465
- onChange={(event) => setQuery(event.target.value)}
466
- placeholder="Search icons"
467
- className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
468
- />
469
- <button
470
- type="button"
471
- aria-label="Close icon picker"
472
- onClick={() => setOpen(false)}
473
- className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
474
- >
475
- <X className="h-3.5 w-3.5" />
476
- </button>
477
- </div>
478
- <div className="grid max-h-[220px] grid-cols-8 gap-1 overflow-y-auto pr-1">
479
- {filteredIcons.map((name) => {
480
- const Icon = getPhosphorIconComponentByName(name);
481
- if (!Icon) return null;
482
-
483
- return (
484
- <button
485
- key={name}
486
- type="button"
487
- title={name}
488
- aria-label={`Use ${name} icon`}
489
- onPointerDown={(event) => {
490
- event.preventDefault();
491
- event.stopPropagation();
492
- onChange(`${PHOSPHOR_ICON_PREFIX}${name}`);
493
- setOpen(false);
494
- }}
495
- className={cn(
496
- 'inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
497
- selectedName === name && 'bg-muted text-foreground ring-1 ring-border',
498
- )}
499
- >
500
- <Icon className="h-4 w-4" />
501
- </button>
502
- );
503
- })}
504
- </div>
505
- {filteredIcons.length === 0 ? (
506
- <p className="py-6 text-center text-sm text-muted-foreground">No icons found.</p>
507
- ) : null}
508
- <div className="mt-3 border-t border-border pt-2">
509
- <button
510
- type="button"
511
- disabled={!storedIcon}
512
- onPointerDown={(event) => {
513
- event.preventDefault();
514
- event.stopPropagation();
515
- if (!storedIcon) return;
516
- onRemove();
517
- setOpen(false);
518
- }}
519
- className="inline-flex h-8 w-full items-center justify-center rounded-md text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
520
- >
521
- Remove custom icon
522
- </button>
523
- </div>
524
- </div>
525
- ) : null}
526
- </div>
527
- );
528
- }
529
-
530
- function ViewPill({
531
- config,
532
- active,
533
- onSelect,
534
- }: {
535
- config: TabConfig;
536
- active: boolean;
537
- onSelect: () => void;
538
- }) {
539
- const Icon = config.icon;
540
- return (
541
- <button
542
- type="button"
543
- data-view={config.key}
544
- onClick={onSelect}
545
- className={cn(
546
- 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[12.5px] font-medium transition-colors',
547
- active
548
- ? 'bg-muted text-foreground'
549
- : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
550
- )}
551
- >
552
- <Icon className="h-3.5 w-3.5" />
553
- {config.label}
554
- </button>
555
- );
556
- }
557
-
558
- function PageHeader({
559
- title,
560
- icon,
561
- storedIcon = null,
562
- iconEditable = false,
563
- iconSaving = false,
564
- titleEditable = false,
565
- titleEditing = false,
566
- titleDraft = '',
567
- titleSaving = false,
568
- activeTab,
569
- onTabChange,
570
- onIconChange,
571
- onIconRemove,
572
- onStartTitleEdit,
573
- onTitleDraftChange,
574
- onSubmitTitleEdit,
575
- onCancelTitleEdit,
576
- onCreateNote,
577
- creatingNote = false,
578
- }: {
579
- title: string;
580
- icon: string | null;
581
- storedIcon?: string | null;
582
- iconEditable?: boolean;
583
- iconSaving?: boolean;
584
- titleEditable?: boolean;
585
- titleEditing?: boolean;
586
- titleDraft?: string;
587
- titleSaving?: boolean;
588
- activeTab: TabKey;
589
- onTabChange: (tab: TabKey) => void;
590
- onIconChange?: (icon: string) => void;
591
- onIconRemove?: () => void;
592
- onStartTitleEdit?: () => void;
593
- onTitleDraftChange?: (title: string) => void;
594
- onSubmitTitleEdit?: () => void;
595
- onCancelTitleEdit?: () => void;
596
- onCreateNote?: () => void;
597
- creatingNote?: boolean;
598
- }) {
599
- return (
600
- <div className="flex flex-col gap-3 px-6 pt-6">
601
- {iconEditable ? (
602
- <FolderIconPicker
603
- displayIcon={icon}
604
- storedIcon={storedIcon}
605
- disabled={iconSaving}
606
- onChange={(nextIcon) => onIconChange?.(nextIcon)}
607
- onRemove={() => onIconRemove?.()}
608
- />
609
- ) : (
610
- <div className="flex size-10 items-center justify-center">
611
- <PageIcon icon={icon} />
612
- </div>
613
- )}
614
- {titleEditing ? (
615
- <input
616
- autoFocus
617
- value={titleDraft}
618
- onChange={(event) => onTitleDraftChange?.(event.target.value)}
619
- onKeyDown={(event) => {
620
- if (event.key === 'Enter') {
621
- event.preventDefault();
622
- onSubmitTitleEdit?.();
623
- }
624
- if (event.key === 'Escape') {
625
- event.preventDefault();
626
- onCancelTitleEdit?.();
627
- }
628
- }}
629
- onBlur={() => onSubmitTitleEdit?.()}
630
- className="h-10 w-full max-w-xl rounded-md border border-border bg-background px-2 text-3xl font-bold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
631
- />
632
- ) : (
633
- <div className="flex min-w-0 items-center gap-2">
634
- <h1
635
- className={cn(
636
- 'min-w-0 truncate text-3xl font-bold tracking-tight text-foreground',
637
- titleEditable && 'cursor-text',
638
- )}
639
- onDoubleClick={titleEditable ? onStartTitleEdit : undefined}
640
- >
641
- {title}
642
- </h1>
643
- {titleEditable ? (
644
- <button
645
- type="button"
646
- aria-label="Rename folder"
647
- onClick={onStartTitleEdit}
648
- disabled={titleSaving}
649
- className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
650
- >
651
- {titleSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
652
- </button>
653
- ) : null}
654
- </div>
655
- )}
656
- <div className="flex flex-wrap items-center gap-2 border-b border-border pb-2">
657
- <div className="flex items-center gap-1">
658
- {TABS.map((tab) => (
659
- <ViewPill
660
- key={tab.key}
661
- config={tab}
662
- active={activeTab === tab.key}
663
- onSelect={() => onTabChange(tab.key)}
664
- />
665
- ))}
666
- </div>
667
- <div className="ml-auto flex items-center gap-0.5">
668
- <button
669
- type="button"
670
- onClick={onCreateNote}
671
- disabled={!onCreateNote || creatingNote}
672
- className="ml-1 inline-flex h-7 items-center gap-1 rounded-md bg-primary px-2.5 text-[12.5px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-60"
673
- >
674
- {creatingNote ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
675
- {creatingNote ? 'Creating…' : 'New'}
676
- </button>
677
- </div>
678
- </div>
679
- </div>
680
- );
681
- }
682
-
683
- export default function NotesPage() {
684
- const { app, route, collectionItem: rawCollectionItem } = useNotis();
685
- const navigation = useNotisNavigation();
686
- const { request } = useBackend();
687
- const { upsert: upsertNoteDocument } = useUpsertDocument(NOTE_DATABASE_SLUG);
688
-
689
- const [activeTab, setActiveTab] = useState<TabKey>('gallery');
690
- const [activeDateProperty, setActiveDateProperty] = useState(CREATED_DATE_FIELD);
691
- const [visibleMonth, setVisibleMonth] = useState(() => new Date());
692
- const [creatingDocument, setCreatingDocument] = useState(false);
693
- const [savingNoteId, setSavingNoteId] = useState<string | null>(null);
694
- const [savingFolderTitle, setSavingFolderTitle] = useState(false);
695
- const [savingFolderIcon, setSavingFolderIcon] = useState(false);
696
- const [errorMessage, setErrorMessage] = useState<string | null>(null);
697
- const [searchQuery, setSearchQuery] = useState('');
698
- const [folderTitleDraft, setFolderTitleDraft] = useState('');
699
- const [editingFolderTitle, setEditingFolderTitle] = useState(false);
700
- const [folderTitleOverride, setFolderTitleOverride] = useState<{ id: string; title: string } | null>(null);
701
- const [folderIconOverride, setFolderIconOverride] = useState<{ id: string; icon: string | null } | null>(null);
702
- // Which bulk folder action is awaiting a target-folder pick, if any.
703
- const [pendingFolderAction, setPendingFolderAction] = useState<'move' | 'add' | null>(null);
704
- const [bulkRunning, setBulkRunning] = useState(false);
705
- const folderRenameSubmittingRef = useRef(false);
706
-
707
- const collectionItem = normalizeCollectionItem(rawCollectionItem);
708
- const noteSchema = useDatabaseSchema(NOTE_DATABASE_SLUG);
709
- const noteProperties = noteSchema.properties;
710
- const titleProperty = noteProperties.find((p) => p.type === 'title');
711
- const titlePropertyName = titleProperty?.name ?? 'Title';
712
- const folderPropertyName = noteProperties.find((p) => p.type === 'relation')?.name ?? 'Folder';
713
- const statusPropertyName =
714
- noteProperties.find((p) => p.type === 'status' || p.name === 'Status')?.name ?? null;
715
- const dateProperties = noteProperties.filter((p) => p.type === 'date');
716
- const metadataProperties = noteProperties.filter((p) => p.name !== titlePropertyName);
717
-
718
- const activeFolderId = collectionItem?.id ?? null;
719
- const activeFolderTitle =
720
- activeFolderId && folderTitleOverride?.id === activeFolderId
721
- ? folderTitleOverride.title
722
- : collectionItem?.title ?? null;
723
- const activeFolderIcon =
724
- activeFolderId && folderIconOverride?.id === activeFolderId
725
- ? folderIconOverride.icon
726
- : collectionItem?.icon ?? null;
727
-
728
- const notesFilter = activeFolderId
729
- ? {
730
- filters: [
731
- {
732
- property: folderPropertyName,
733
- operator: 'contains',
734
- type: 'relation',
735
- value: activeFolderId,
736
- },
737
- ],
738
- }
739
- : undefined;
740
-
741
- const notesQuery = useDocuments(NOTE_DATABASE_SLUG, {
742
- filter: notesFilter,
743
- pageSize: 250,
744
- fetchAll: true,
745
- });
746
- const foldersQuery = useDocuments(FOLDER_DATABASE_SLUG, {
747
- pageSize: 500,
748
- fetchAll: true,
749
- });
750
-
751
- const { setLoading: setSearchLoading } = useTopBarSearch({
752
- value: searchQuery,
753
- onChange: setSearchQuery,
754
- placeholder: 'Search notes…',
755
- onSubmit: notesQuery.refetch,
756
- });
757
-
758
- useEffect(() => {
759
- setSearchLoading(notesQuery.loading);
760
- }, [notesQuery.loading, setSearchLoading]);
761
-
762
- const trimmedQuery = searchQuery.trim().toLowerCase();
763
- const allNotes = notesQuery.documents;
764
- const notes = trimmedQuery
765
- ? allNotes.filter((note) => {
766
- if (getNoteTitle(note).toLowerCase().includes(trimmedQuery)) return true;
767
- if (isPresentString(note.plainText) && note.plainText.toLowerCase().includes(trimmedQuery)) return true;
768
- if (isPresentString(note.contentMarkdown) && note.contentMarkdown.toLowerCase().includes(trimmedQuery)) return true;
769
- return false;
770
- })
771
- : allNotes;
772
- const folders = foldersQuery.documents;
773
- const folderOptions = buildFolderOptions(folders);
774
- const folderNameById = new Map(folderOptions.map((f) => [f.id, f.title]));
775
-
776
- // Which columns the table shows is per-folder VIEW config: it never hides a
777
- // property from query and never changes what a note is. The active folder's
778
- // choice wins; with no folder selected the union across folders is shown.
779
- const { columns: visibleColumns } = resolveVisibleColumns({
780
- metadataProperties,
781
- activeFolderSelection: collectionItem?.properties?.[VISIBLE_PROPERTIES_PROPERTY],
782
- activeFolderSelected: Boolean(collectionItem),
783
- allFolderSelections: folders.map(
784
- (folder) => (folder.properties as Record<string, unknown> | undefined)?.[VISIBLE_PROPERTIES_PROPERTY],
785
- ),
786
- });
787
-
788
- // The calendar always offers a built-in "Created" field (sourced from each
789
- // note's created_at) plus any user-defined date properties on the database.
790
- const calendarDateFields: CalendarDateField[] = [
791
- { value: CREATED_DATE_FIELD, label: CREATED_DATE_LABEL },
792
- ...dateProperties.map((p) => ({ value: p.name, label: p.name })),
793
- ];
794
- const activeCalendarField = calendarDateFields.some((f) => f.value === activeDateProperty)
795
- ? activeDateProperty
796
- : CREATED_DATE_FIELD;
797
-
798
- const notesByDay = new Map<string, DocumentRecord[]>();
799
- for (const note of notes) {
800
- const key = getDateKey(getNoteDateValue(note, activeCalendarField));
801
- if (!key) continue;
802
- const bucket = notesByDay.get(key) ?? [];
803
- bucket.push(note);
804
- notesByDay.set(key, bucket);
805
- }
806
- const scheduledNotesCount = notes.filter((n) =>
807
- Boolean(getDateKey(getNoteDateValue(n, activeCalendarField))),
808
- ).length;
809
-
810
- const monthDays = buildCalendarDays(visibleMonth);
811
- const isLoading = notesQuery.loading || foldersQuery.loading || noteSchema.loading;
812
- const currentFolderLabel = activeFolderTitle ?? 'All notes';
813
- const topLevelError = errorMessage || notesQuery.error?.message || foldersQuery.error?.message || noteSchema.error?.message;
814
- const pageTitle = activeFolderTitle || route?.name || app?.name || 'Notes';
815
- const pageIcon = activeFolderIcon || route?.icon || null;
816
-
817
- useEffect(() => {
818
- setEditingFolderTitle(false);
819
- setFolderTitleDraft('');
820
- folderRenameSubmittingRef.current = false;
821
- setFolderIconOverride(null);
822
- }, [activeFolderId]);
823
-
824
- useEffect(() => {
825
- const valid =
826
- activeDateProperty === CREATED_DATE_FIELD ||
827
- dateProperties.some((p) => p.name === activeDateProperty);
828
- if (!valid) setActiveDateProperty(CREATED_DATE_FIELD);
829
- }, [activeDateProperty, dateProperties]);
830
-
831
- async function openNote(document: DocumentRecord) {
832
- navigation.toDocument(document.id, document.title);
833
- }
834
-
835
- async function createDocument() {
836
- setCreatingDocument(true);
837
- setErrorMessage(null);
838
- try {
839
- const document = await upsertNoteDocument({
840
- title: DEFAULT_NOTE_TITLE,
841
- properties: activeFolderId ? { [folderPropertyName]: [activeFolderId] } : undefined,
842
- });
843
- navigation.toDocument(document.id, document.title);
844
- } catch (error) {
845
- setErrorMessage(error instanceof Error ? error.message : 'Failed to create note');
846
- } finally {
847
- setCreatingDocument(false);
848
- }
849
- }
850
-
851
- async function saveProperties(documentId: string, properties: Record<string, unknown>) {
852
- setSavingNoteId(documentId);
853
- setErrorMessage(null);
854
- try {
855
- await upsertNoteDocument({ documentId, properties });
856
- notesQuery.refetch();
857
- } catch (error) {
858
- setErrorMessage(error instanceof Error ? error.message : 'Failed to save note');
859
- } finally {
860
- setSavingNoteId(null);
861
- }
862
- }
863
-
864
- async function saveTitle(documentId: string, nextTitle: string) {
865
- const normalized = nextTitle.trim() || DEFAULT_NOTE_TITLE;
866
- setSavingNoteId(documentId);
867
- setErrorMessage(null);
868
- try {
869
- await upsertNoteDocument({ documentId, title: normalized });
870
- notesQuery.refetch();
871
- } catch (error) {
872
- setErrorMessage(error instanceof Error ? error.message : 'Failed to rename note');
873
- } finally {
874
- setSavingNoteId(null);
875
- }
876
- }
877
-
878
- function startFolderTitleEdit() {
879
- if (!activeFolderId) return;
880
- setFolderTitleDraft(activeFolderTitle ?? '');
881
- setEditingFolderTitle(true);
882
- }
883
-
884
- function cancelFolderTitleEdit() {
885
- setEditingFolderTitle(false);
886
- setFolderTitleDraft('');
887
- folderRenameSubmittingRef.current = false;
888
- }
889
-
890
- async function submitFolderTitleEdit() {
891
- if (folderRenameSubmittingRef.current) return;
892
- if (!activeFolderId || !app?.id || !route?.slug) {
893
- cancelFolderTitleEdit();
894
- return;
895
- }
896
-
897
- const previousTitle = activeFolderTitle ?? collectionItem?.title ?? '';
898
- const nextTitle = folderTitleDraft.trim();
899
- if (!nextTitle || nextTitle === previousTitle) {
900
- cancelFolderTitleEdit();
901
- return;
902
- }
903
-
904
- folderRenameSubmittingRef.current = true;
905
- setEditingFolderTitle(false);
906
- setFolderTitleOverride({ id: activeFolderId, title: nextTitle });
907
- setSavingFolderTitle(true);
908
- setErrorMessage(null);
909
- try {
910
- const response = await request('/portal_views/collection_tree/rename', {
911
- method: 'POST',
912
- body: {
913
- app_id: app.id,
914
- route_slug: route.slug,
915
- item_id: activeFolderId,
916
- title: nextTitle,
917
- },
918
- }) as { item?: { title?: unknown } };
919
- const savedTitle = typeof response.item?.title === 'string' && response.item.title.trim()
920
- ? response.item.title
921
- : nextTitle;
922
- setFolderTitleOverride({ id: activeFolderId, title: savedTitle });
923
- foldersQuery.refetch();
924
- } catch (error) {
925
- setFolderTitleOverride(previousTitle ? { id: activeFolderId, title: previousTitle } : null);
926
- setErrorMessage(error instanceof Error ? error.message : 'Failed to rename folder');
927
- } finally {
928
- setSavingFolderTitle(false);
929
- setFolderTitleDraft('');
930
- folderRenameSubmittingRef.current = false;
931
- }
932
- }
933
-
934
- async function saveFolderIcon(nextIcon: string | null) {
935
- if (!activeFolderId || !app?.id || !route?.slug) return;
936
- if (nextIcon === activeFolderIcon) return;
937
-
938
- const previousIcon = activeFolderIcon;
939
- setFolderIconOverride({ id: activeFolderId, icon: nextIcon });
940
- setSavingFolderIcon(true);
941
- setErrorMessage(null);
942
- try {
943
- const response = await request('/portal_views/collection_tree/icon', {
944
- method: 'POST',
945
- body: {
946
- app_id: app.id,
947
- route_slug: route.slug,
948
- item_id: activeFolderId,
949
- icon: nextIcon,
950
- },
951
- }) as { item?: { icon?: unknown } };
952
- const savedIcon = typeof response.item?.icon === 'string' ? response.item.icon : null;
953
- setFolderIconOverride({ id: activeFolderId, icon: savedIcon });
954
- foldersQuery.refetch();
955
- } catch (error) {
956
- setFolderIconOverride({ id: activeFolderId, icon: previousIcon });
957
- setErrorMessage(error instanceof Error ? error.message : 'Failed to update folder icon');
958
- } finally {
959
- setSavingFolderIcon(false);
960
- }
961
- }
962
-
963
- // Holds both table rows and gallery cards so Shift+Arrow can scroll the head
964
- // into view regardless of the active view.
965
- const rowRefs = useRef(new Map<string, HTMLElement>());
966
- const selectionEnabled = activeTab !== 'calendar';
967
- const multiSelect = useMultiSelect<DocumentRecord>({
968
- items: notes,
969
- getId: (note) => note.id,
970
- bindKeyboardShortcuts: selectionEnabled,
971
- enableDragSelect: selectionEnabled,
972
- onHeadChange: (id) => {
973
- if (!id) return;
974
- rowRefs.current.get(id)?.scrollIntoView({ block: 'nearest' });
975
- },
976
- });
977
-
978
- // Clear the selection when the visible set changes out from under it (folder
979
- // switch) or when entering a view that can't act on a selection (calendar),
980
- // so bulk actions never apply to off-screen notes.
981
- const clearSelection = multiSelect.clear;
982
- useEffect(() => {
983
- clearSelection();
984
- }, [activeFolderId, clearSelection]);
985
- useEffect(() => {
986
- if (!selectionEnabled) clearSelection();
987
- }, [selectionEnabled, clearSelection]);
988
-
989
- // Runs `apply` for each selected note in parallel, then clears + refetches.
990
- // `apply` receives the full note so handlers can read its current properties.
991
- async function runBulk(
992
- apply: (note: DocumentRecord) => UpsertDocumentArgs,
993
- failureMessage: string,
994
- ) {
995
- const selected = multiSelect.getSelectedItems();
996
- if (selected.length === 0) return;
997
- setBulkRunning(true);
998
- setErrorMessage(null);
999
- try {
1000
- await Promise.all(selected.map((note) => upsertNoteDocument(apply(note))));
1001
- multiSelect.clear();
1002
- notesQuery.refetch();
1003
- } catch (error) {
1004
- setErrorMessage(error instanceof Error ? error.message : failureMessage);
1005
- } finally {
1006
- setBulkRunning(false);
1007
- }
1008
- }
1009
-
1010
- function bulkClearFolder() {
1011
- return runBulk(
1012
- (note) => ({ documentId: note.id, properties: { [folderPropertyName]: [] } }),
1013
- 'Failed to update notes',
1014
- );
1015
- }
1016
-
1017
- function bulkDelete() {
1018
- return runBulk(
1019
- (note) => ({ documentId: note.id, operation: 'archive' }),
1020
- 'Failed to delete notes',
1021
- );
1022
- }
1023
-
1024
- function bulkMoveToFolder(folderId: string) {
1025
- return runBulk(
1026
- (note) => ({ documentId: note.id, properties: { [folderPropertyName]: [folderId] } }),
1027
- 'Failed to move notes',
1028
- );
1029
- }
1030
-
1031
- function bulkAddToFolder(folderId: string) {
1032
- return runBulk(
1033
- (note) => ({
1034
- documentId: note.id,
1035
- properties: {
1036
- [folderPropertyName]: Array.from(
1037
- new Set([...getRelationIds(note.properties[folderPropertyName]), folderId]),
1038
- ),
1039
- },
1040
- }),
1041
- 'Failed to add notes to folder',
1042
- );
1043
- }
1044
-
1045
- async function handleFolderPick(folderId: string) {
1046
- const action = pendingFolderAction;
1047
- setPendingFolderAction(null);
1048
- if (!action) return;
1049
- if (action === 'move') await bulkMoveToFolder(folderId);
1050
- else await bulkAddToFolder(folderId);
1051
- }
1052
-
1053
- return (
1054
- <main data-store-screenshot="notes" className="flex min-h-screen flex-col bg-background">
1055
- <PageHeader
1056
- title={pageTitle}
1057
- icon={pageIcon}
1058
- storedIcon={activeFolderIcon}
1059
- iconEditable={Boolean(activeFolderId)}
1060
- iconSaving={savingFolderIcon}
1061
- onIconChange={(nextIcon) => void saveFolderIcon(nextIcon)}
1062
- onIconRemove={() => void saveFolderIcon(null)}
1063
- titleEditable={Boolean(activeFolderId)}
1064
- titleEditing={editingFolderTitle}
1065
- titleDraft={folderTitleDraft}
1066
- titleSaving={savingFolderTitle}
1067
- onStartTitleEdit={startFolderTitleEdit}
1068
- onTitleDraftChange={setFolderTitleDraft}
1069
- onSubmitTitleEdit={() => void submitFolderTitleEdit()}
1070
- onCancelTitleEdit={cancelFolderTitleEdit}
1071
- activeTab={activeTab}
1072
- onTabChange={setActiveTab}
1073
- onCreateNote={() => void createDocument()}
1074
- creatingNote={creatingDocument}
1075
- />
1076
-
1077
- {topLevelError ? (
1078
- <div className="mx-6 mt-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
1079
- {topLevelError}
1080
- </div>
1081
- ) : null}
1082
-
1083
- {isLoading ? <LoadingState /> : null}
1084
-
1085
- {!isLoading && activeTab === 'gallery' ? (
1086
- <GalleryBody
1087
- notes={notes}
1088
- statusPropertyName={statusPropertyName}
1089
- onOpenNote={openNote}
1090
- onCreateDocument={createDocument}
1091
- creatingDocument={creatingDocument}
1092
- currentFolderLabel={currentFolderLabel}
1093
- hasCollectionItem={Boolean(activeFolderId)}
1094
- multiSelect={multiSelect}
1095
- rowRefs={rowRefs.current}
1096
- />
1097
- ) : null}
1098
-
1099
- {!isLoading && activeTab === 'table' ? (
1100
- <TableBody
1101
- notes={notes}
1102
- metadataProperties={metadataProperties}
1103
- visibleColumns={visibleColumns}
1104
- folderOptions={folderOptions}
1105
- folderNameById={folderNameById}
1106
- folderPropertyName={folderPropertyName}
1107
- savingNoteId={savingNoteId}
1108
- onOpen={openNote}
1109
- onSaveTitle={saveTitle}
1110
- onSaveProperties={saveProperties}
1111
- currentFolderLabel={currentFolderLabel}
1112
- hasCollectionItem={Boolean(activeFolderId)}
1113
- multiSelect={multiSelect}
1114
- rowRefs={rowRefs.current}
1115
- />
1116
- ) : null}
1117
-
1118
- {!isLoading && activeTab === 'calendar' ? (
1119
- <CalendarBody
1120
- monthDays={monthDays}
1121
- visibleMonth={visibleMonth}
1122
- setVisibleMonth={setVisibleMonth}
1123
- notesByDay={notesByDay}
1124
- statusPropertyName={statusPropertyName}
1125
- calendarDateFields={calendarDateFields}
1126
- activeDateProperty={activeCalendarField}
1127
- setActiveDateProperty={setActiveDateProperty}
1128
- scheduledNotesCount={scheduledNotesCount}
1129
- onOpen={openNote}
1130
- />
1131
- ) : null}
1132
-
1133
- {selectionEnabled ? (
1134
- <>
1135
- <MultiSelectDragOverlay rect={multiSelect.dragRect} />
1136
- {pendingFolderAction ? (
1137
- <BulkFolderPicker
1138
- mode={pendingFolderAction}
1139
- folderOptions={folderOptions}
1140
- busy={bulkRunning}
1141
- onPick={(folderId) => void handleFolderPick(folderId)}
1142
- onClose={() => setPendingFolderAction(null)}
1143
- />
1144
- ) : null}
1145
- <MultiSelectActionBar
1146
- selectedCount={multiSelect.selectedCount}
1147
- itemLabel={{ singular: 'note', plural: 'notes' }}
1148
- actions={[
1149
- {
1150
- id: 'move-folder',
1151
- label: 'Move to folder',
1152
- shortcut: 'M',
1153
- icon: <FolderOpen className="h-3.5 w-3.5" />,
1154
- onRun: () => setPendingFolderAction('move'),
1155
- },
1156
- {
1157
- id: 'add-folder',
1158
- label: 'Add to folder',
1159
- shortcut: 'F',
1160
- icon: <FolderPlus className="h-3.5 w-3.5" />,
1161
- onRun: () => setPendingFolderAction('add'),
1162
- },
1163
- {
1164
- id: 'clear-folder',
1165
- label: 'Move out of folder',
1166
- icon: <FolderMinus className="h-3.5 w-3.5" />,
1167
- onRun: bulkClearFolder,
1168
- },
1169
- {
1170
- id: 'delete',
1171
- label: 'Delete',
1172
- shortcut: '#',
1173
- destructive: true,
1174
- icon: <Trash className="h-3.5 w-3.5" />,
1175
- onRun: bulkDelete,
1176
- },
1177
- ]}
1178
- />
1179
- </>
1180
- ) : null}
1181
- </main>
1182
- );
1183
- }
1184
-
1185
- /* -------------------------------------------------------------------------- */
1186
- /* Gallery view */
1187
- /* -------------------------------------------------------------------------- */
1188
-
1189
- function GalleryBody({
1190
- notes,
1191
- statusPropertyName,
1192
- onOpenNote,
1193
- onCreateDocument,
1194
- creatingDocument,
1195
- currentFolderLabel,
1196
- hasCollectionItem,
1197
- multiSelect,
1198
- rowRefs,
1199
- }: {
1200
- notes: DocumentRecord[];
1201
- statusPropertyName: string | null;
1202
- onOpenNote: (doc: DocumentRecord) => void;
1203
- onCreateDocument: () => Promise<void>;
1204
- creatingDocument: boolean;
1205
- currentFolderLabel: string;
1206
- hasCollectionItem: boolean;
1207
- multiSelect: MultiSelectController<DocumentRecord>;
1208
- rowRefs: Map<string, HTMLElement>;
1209
- }) {
1210
- if (!notes.length) {
1211
- return (
1212
- <EmptyState
1213
- icon={LayoutGrid}
1214
- title={hasCollectionItem ? 'No notes in this folder' : 'No notes yet'}
1215
- description={
1216
- hasCollectionItem
1217
- ? `Create the first note in ${currentFolderLabel}.`
1218
- : 'Folders live in the sidebar. Create a note here to get started.'
1219
- }
1220
- action={
1221
- <Button
1222
- size="sm"
1223
- onClick={() => void onCreateDocument()}
1224
- disabled={creatingDocument}
1225
- className="gap-1.5"
1226
- >
1227
- <Plus className="h-3.5 w-3.5" />
1228
- {creatingDocument ? 'Creating…' : 'New note'}
1229
- </Button>
1230
- }
1231
- />
1232
- );
1233
- }
1234
-
1235
- return (
1236
- <div
1237
- {...multiSelect.getContainerProps()}
1238
- className="grid grid-cols-1 gap-4 px-4 py-4 sm:grid-cols-2 sm:px-6 lg:grid-cols-3 xl:grid-cols-4"
1239
- >
1240
- {notes.map((note) => (
1241
- <NoteCard
1242
- key={note.id}
1243
- note={note}
1244
- statusPropertyName={statusPropertyName}
1245
- onOpen={() => onOpenNote(note)}
1246
- isSelected={multiSelect.isSelected(note.id)}
1247
- itemProps={multiSelect.getItemProps(note.id)}
1248
- checkboxProps={multiSelect.getCheckboxProps(note.id)}
1249
- cardRef={(node) => {
1250
- if (node) {
1251
- rowRefs.set(note.id, node);
1252
- } else {
1253
- rowRefs.delete(note.id);
1254
- }
1255
- }}
1256
- />
1257
- ))}
1258
- </div>
1259
- );
1260
- }
1261
-
1262
- function NoteCard({
1263
- note,
1264
- statusPropertyName,
1265
- onOpen,
1266
- isSelected,
1267
- itemProps,
1268
- checkboxProps,
1269
- cardRef,
1270
- }: {
1271
- note: DocumentRecord;
1272
- statusPropertyName: string | null;
1273
- onOpen: () => void;
1274
- isSelected: boolean;
1275
- itemProps: { 'data-notis-row-id': string; onMouseDown: (event: React.MouseEvent) => void };
1276
- checkboxProps: { isSelected: boolean; onClick: (event: React.MouseEvent) => void };
1277
- cardRef: (node: HTMLDivElement | null) => void;
1278
- }) {
1279
- const status = statusPropertyName ? getStatusLabel(note.properties[statusPropertyName]) : null;
1280
- const coverUrl = getCoverUrl(note);
1281
- const title = getNoteTitle(note);
1282
- const previewText = getNotePreviewText(note);
1283
-
1284
- // A plain div (no `role="button"`) so the SDK's drag-select can arm from a
1285
- // card body; keyboard access is preserved via tabIndex + Enter.
1286
- return (
1287
- <div
1288
- {...itemProps}
1289
- ref={cardRef}
1290
- tabIndex={0}
1291
- onClick={onOpen}
1292
- onKeyDown={(e) => {
1293
- if (e.key === 'Enter') {
1294
- e.preventDefault();
1295
- onOpen();
1296
- }
1297
- }}
1298
- className={cn(
1299
- 'group relative flex h-full w-full cursor-pointer flex-col overflow-hidden rounded-xl border bg-card text-left',
1300
- 'transition-colors hover:border-foreground/20 hover:shadow-sm',
1301
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
1302
- isSelected ? 'border-primary ring-2 ring-primary' : 'border-border',
1303
- )}
1304
- >
1305
- <div className="absolute left-2 top-2 z-10">
1306
- <MultiSelectCheckbox
1307
- {...checkboxProps}
1308
- alwaysVisible={isSelected}
1309
- ariaLabel={isSelected ? 'Deselect note' : 'Select note'}
1310
- className={cn(
1311
- 'transition-opacity',
1312
- !isSelected && 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
1313
- )}
1314
- />
1315
- </div>
1316
- <div className="relative aspect-[16/10] w-full overflow-hidden">
1317
- {coverUrl ? (
1318
- // eslint-disable-next-line @next/next/no-img-element
1319
- <img src={coverUrl} alt="" className="h-full w-full object-cover" />
1320
- ) : (
1321
- <div className="flex h-full w-full bg-card px-5 py-5 text-foreground sm:px-6">
1322
- <p className="line-clamp-5 text-xl font-semibold leading-snug text-foreground/75 sm:text-2xl">
1323
- {previewText}
1324
- </p>
1325
- </div>
1326
- )}
1327
- {status ? (
1328
- <div className="absolute right-2 top-2">
1329
- <StatusPill status={status} />
1330
- </div>
1331
- ) : null}
1332
- </div>
1333
- <div className="flex w-full items-center gap-1.5 border-t border-border px-3 py-2">
1334
- <NoteIcon icon={note.icon} className="shrink-0 text-muted-foreground" />
1335
- <span className="line-clamp-1 text-[12.5px] text-foreground">
1336
- {title}
1337
- </span>
1338
- </div>
1339
- </div>
1340
- );
1341
- }
1342
-
1343
- /* -------------------------------------------------------------------------- */
1344
- /* Bulk folder picker (Move to / Add to folder) */
1345
- /* -------------------------------------------------------------------------- */
1346
-
1347
- function BulkFolderPicker({
1348
- mode,
1349
- folderOptions,
1350
- busy,
1351
- onPick,
1352
- onClose,
1353
- }: {
1354
- mode: 'move' | 'add';
1355
- folderOptions: FolderOption[];
1356
- busy: boolean;
1357
- onPick: (folderId: string) => void;
1358
- onClose: () => void;
1359
- }) {
1360
- const [query, setQuery] = useState('');
1361
- const containerRef = useRef<HTMLDivElement>(null);
1362
- const normalizedQuery = query.trim().toLowerCase();
1363
- const filtered = normalizedQuery
1364
- ? folderOptions.filter((f) => f.pathLabel.toLowerCase().includes(normalizedQuery))
1365
- : folderOptions;
1366
-
1367
- useEffect(() => {
1368
- function handlePointerDown(event: Event) {
1369
- const target = event.target;
1370
- if (target instanceof Node && containerRef.current?.contains(target)) return;
1371
- onClose();
1372
- }
1373
- const root = containerRef.current?.getRootNode();
1374
- const eventTarget =
1375
- root instanceof ShadowRoot || root instanceof Document ? root : document;
1376
- eventTarget.addEventListener('pointerdown', handlePointerDown);
1377
- return () => eventTarget.removeEventListener('pointerdown', handlePointerDown);
1378
- }, [onClose]);
1379
-
1380
- return (
1381
- <div
1382
- ref={containerRef}
1383
- role="dialog"
1384
- aria-label={mode === 'move' ? 'Move notes to folder' : 'Add notes to folder'}
1385
- className="fixed bottom-16 left-1/2 z-[70] w-[320px] -translate-x-1/2 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl"
1386
- >
1387
- <div className="mb-2 flex items-center justify-between">
1388
- <span className="text-[12px] font-semibold text-foreground">
1389
- {mode === 'move' ? 'Move to folder' : 'Add to folder'}
1390
- </span>
1391
- <button
1392
- type="button"
1393
- aria-label="Close"
1394
- onClick={onClose}
1395
- className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
1396
- >
1397
- <X className="h-3.5 w-3.5" />
1398
- </button>
1399
- </div>
1400
- <div className="mb-2 flex items-center gap-2 rounded-md border border-border bg-background px-2">
1401
- <Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1402
- <input
1403
- autoFocus
1404
- value={query}
1405
- onChange={(event) => setQuery(event.target.value)}
1406
- placeholder="Search folders"
1407
- className="h-8 min-w-0 flex-1 bg-transparent text-sm outline-none"
1408
- />
1409
- </div>
1410
- <div className="max-h-[220px] overflow-y-auto">
1411
- {filtered.length ? (
1412
- filtered.map((folder) => (
1413
- <button
1414
- key={folder.id}
1415
- type="button"
1416
- disabled={busy}
1417
- onClick={() => onPick(folder.id)}
1418
- className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] text-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
1419
- >
1420
- <Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1421
- <span className="truncate">{folder.pathLabel}</span>
1422
- </button>
1423
- ))
1424
- ) : (
1425
- <p className="py-6 text-center text-sm text-muted-foreground">No folders found.</p>
1426
- )}
1427
- </div>
1428
- </div>
1429
- );
1430
- }
1431
-
1432
- /* -------------------------------------------------------------------------- */
1433
- /* Table view */
1434
- /* -------------------------------------------------------------------------- */
1435
-
1436
- function TableBody({
1437
- notes,
1438
- metadataProperties,
1439
- visibleColumns,
1440
- folderOptions,
1441
- folderNameById,
1442
- folderPropertyName,
1443
- savingNoteId,
1444
- onOpen,
1445
- onSaveTitle,
1446
- onSaveProperties,
1447
- currentFolderLabel,
1448
- hasCollectionItem,
1449
- multiSelect,
1450
- rowRefs,
1451
- }: {
1452
- notes: DocumentRecord[];
1453
- metadataProperties: DatabaseProperty[];
1454
- visibleColumns?: DatabaseProperty[];
1455
- folderOptions: FolderOption[];
1456
- folderNameById: Map<string, string>;
1457
- folderPropertyName: string;
1458
- savingNoteId: string | null;
1459
- onOpen: (doc: DocumentRecord) => void;
1460
- onSaveTitle: (id: string, title: string) => void;
1461
- onSaveProperties: (id: string, props: Record<string, unknown>) => void;
1462
- currentFolderLabel: string;
1463
- hasCollectionItem: boolean;
1464
- multiSelect: MultiSelectController<DocumentRecord>;
1465
- rowRefs: Map<string, HTMLElement>;
1466
- }) {
1467
- const columns = visibleColumns ?? metadataProperties.slice(0, 6);
1468
- const allSelected =
1469
- notes.length > 0 && notes.every((note) => multiSelect.isSelected(note.id));
1470
- const handleSelectAllToggle = () => {
1471
- if (allSelected) {
1472
- multiSelect.clear();
1473
- return;
1474
- }
1475
- multiSelect.select(notes.map((note) => note.id));
1476
- };
1477
-
1478
- return (
1479
- <div className="flex flex-1 flex-col gap-4 px-6 py-5">
1480
- {!notes.length ? (
1481
- <EmptyState
1482
- icon={Table2}
1483
- title={hasCollectionItem ? 'No rows in this folder' : 'No rows yet'}
1484
- description={
1485
- hasCollectionItem
1486
- ? `Create a note in ${currentFolderLabel} to start editing metadata here.`
1487
- : 'Notes become rows here. Create one to begin editing metadata inline.'
1488
- }
1489
- />
1490
- ) : (
1491
- <div className="overflow-hidden rounded-lg border border-border bg-card">
1492
- <div {...multiSelect.getContainerProps()} className="overflow-x-auto">
1493
- <table className="w-full min-w-[1040px] border-collapse text-[13px]">
1494
- <thead>
1495
- <tr className="border-b border-border bg-muted/40">
1496
- <th className="w-10 px-3 py-2.5">
1497
- <MultiSelectCheckbox
1498
- isSelected={allSelected}
1499
- onClick={(e) => {
1500
- e.stopPropagation();
1501
- handleSelectAllToggle();
1502
- }}
1503
- alwaysVisible
1504
- ariaLabel={allSelected ? 'Deselect all notes' : 'Select all notes'}
1505
- />
1506
- </th>
1507
- <th className="px-3 py-2.5 text-left">
1508
- <div className="flex items-center gap-1.5">
1509
- <FileText className="h-3 w-3 text-muted-foreground" />
1510
- <Eyebrow>Note</Eyebrow>
1511
- </div>
1512
- </th>
1513
- {columns.map((p) => (
1514
- <th key={p.name} className="border-l border-border px-3 py-2.5 text-left">
1515
- <Eyebrow>{p.name}</Eyebrow>
1516
- </th>
1517
- ))}
1518
- <th className="w-10 border-l border-border" />
1519
- </tr>
1520
- </thead>
1521
- <tbody>
1522
- {notes.map((note) => (
1523
- <TableRow
1524
- key={note.id}
1525
- note={note}
1526
- columns={columns}
1527
- folderOptions={folderOptions}
1528
- folderNameById={folderNameById}
1529
- folderPropertyName={folderPropertyName}
1530
- saving={savingNoteId === note.id}
1531
- onOpen={() => onOpen(note)}
1532
- onSaveTitle={(title) => onSaveTitle(note.id, title)}
1533
- onSaveProperties={(props) => onSaveProperties(note.id, props)}
1534
- isSelected={multiSelect.isSelected(note.id)}
1535
- onCheckboxClick={multiSelect.onCheckboxClick(note.id)}
1536
- onRowMouseDown={multiSelect.onRowMouseDown(note.id)}
1537
- rowProps={multiSelect.getRowProps(note.id)}
1538
- rowRef={(node) => {
1539
- if (node) {
1540
- rowRefs.set(note.id, node);
1541
- } else {
1542
- rowRefs.delete(note.id);
1543
- }
1544
- }}
1545
- />
1546
- ))}
1547
- </tbody>
1548
- </table>
1549
- </div>
1550
- </div>
1551
- )}
1552
- </div>
1553
- );
1554
- }
1555
-
1556
- function TableRow({
1557
- note,
1558
- columns,
1559
- folderOptions,
1560
- folderNameById,
1561
- folderPropertyName,
1562
- saving,
1563
- onOpen,
1564
- onSaveTitle,
1565
- onSaveProperties,
1566
- isSelected,
1567
- onCheckboxClick,
1568
- onRowMouseDown,
1569
- rowProps,
1570
- rowRef,
1571
- }: {
1572
- note: DocumentRecord;
1573
- columns: DatabaseProperty[];
1574
- folderOptions: FolderOption[];
1575
- folderNameById: Map<string, string>;
1576
- folderPropertyName: string;
1577
- saving: boolean;
1578
- onOpen: () => void;
1579
- onSaveTitle: (title: string) => void;
1580
- onSaveProperties: (props: Record<string, unknown>) => void;
1581
- isSelected: boolean;
1582
- onCheckboxClick: (event: React.MouseEvent) => void;
1583
- onRowMouseDown: (event: React.MouseEvent) => void;
1584
- rowProps: Record<string, string>;
1585
- rowRef: (node: HTMLTableRowElement | null) => void;
1586
- }) {
1587
- return (
1588
- <tr
1589
- {...rowProps}
1590
- ref={rowRef}
1591
- className={cn(
1592
- 'group border-b border-border last:border-b-0 transition-colors',
1593
- saving ? 'bg-muted/20' : 'hover:bg-muted/30',
1594
- isSelected && 'bg-primary/5 hover:bg-primary/10',
1595
- )}
1596
- onClick={onOpen}
1597
- onMouseDown={onRowMouseDown}
1598
- onKeyDown={(e) => {
1599
- if (e.key === 'Enter') {
1600
- e.preventDefault();
1601
- onOpen();
1602
- }
1603
- }}
1604
- tabIndex={0}
1605
- >
1606
- <td className="px-3 py-2">
1607
- <MultiSelectCheckbox
1608
- isSelected={isSelected}
1609
- onClick={onCheckboxClick}
1610
- alwaysVisible={isSelected}
1611
- className={cn(
1612
- 'transition-opacity',
1613
- !isSelected && 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
1614
- )}
1615
- />
1616
- </td>
1617
- <td className="px-3 py-2">
1618
- <div className="flex items-center gap-2">
1619
- <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md bg-muted">
1620
- <FileText className="h-3 w-3 text-muted-foreground" />
1621
- </div>
1622
- <input
1623
- type="text"
1624
- defaultValue={getNoteTitle(note)}
1625
- key={`${note.id}:title:${getNoteTitle(note)}`}
1626
- onClick={(e) => e.stopPropagation()}
1627
- onKeyDown={(e) => {
1628
- if (e.key === 'Enter') e.currentTarget.blur();
1629
- }}
1630
- onBlur={(e) => onSaveTitle(e.target.value)}
1631
- className="w-full rounded-md border border-transparent bg-transparent px-1.5 py-1 text-[13px] font-medium text-foreground outline-none transition-colors focus:border-border focus:bg-background"
1632
- />
1633
- <ArrowUpRight className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-60" />
1634
- </div>
1635
- </td>
1636
- {columns.map((prop) => (
1637
- <td key={prop.name} className="border-l border-border px-3 py-2 align-middle">
1638
- <TableCell
1639
- note={note}
1640
- property={prop}
1641
- folderOptions={folderOptions}
1642
- folderNameById={folderNameById}
1643
- folderPropertyName={folderPropertyName}
1644
- onSaveProperties={onSaveProperties}
1645
- />
1646
- </td>
1647
- ))}
1648
- <td className="w-10 border-l border-border" />
1649
- </tr>
1650
- );
1651
- }
1652
-
1653
- function TableCell({
1654
- note,
1655
- property,
1656
- folderOptions,
1657
- folderNameById,
1658
- folderPropertyName,
1659
- onSaveProperties,
1660
- }: {
1661
- note: DocumentRecord;
1662
- property: DatabaseProperty;
1663
- folderOptions: FolderOption[];
1664
- folderNameById: Map<string, string>;
1665
- folderPropertyName: string;
1666
- onSaveProperties: (props: Record<string, unknown>) => void;
1667
- }) {
1668
- const value = note.properties[property.name];
1669
- const selectClass =
1670
- 'w-full appearance-none rounded-md border border-transparent bg-transparent px-1.5 py-1 text-[12px] text-foreground outline-none transition-colors focus:border-border focus:bg-background';
1671
-
1672
- if (property.type === 'status' || property.type === 'select') {
1673
- const label = isPresentString(value) ? value : '';
1674
- return (
1675
- <div className="relative inline-flex items-center" onClick={(e) => e.stopPropagation()}>
1676
- {label ? (
1677
- <StatusPill status={label} />
1678
- ) : (
1679
- <span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">None</span>
1680
- )}
1681
- <select
1682
- aria-label={property.name}
1683
- className="absolute inset-0 cursor-pointer opacity-0"
1684
- value={label}
1685
- onChange={(e) => onSaveProperties({ [property.name]: e.target.value || null })}
1686
- >
1687
- <option value="">None</option>
1688
- {(property.options ?? []).map((opt) => (
1689
- <option key={opt.id ?? opt.name} value={opt.name}>
1690
- {opt.name}
1691
- </option>
1692
- ))}
1693
- </select>
1694
- </div>
1695
- );
1696
- }
1697
-
1698
- if (property.type === 'date') {
1699
- return (
1700
- <input
1701
- type="date"
1702
- value={getDateInputValue(value)}
1703
- onClick={(e) => e.stopPropagation()}
1704
- onChange={(e) => onSaveProperties({ [property.name]: e.target.value || null })}
1705
- className={cn(selectClass, 'text-[12px]')}
1706
- />
1707
- );
1708
- }
1709
-
1710
- if (property.type === 'checkbox') {
1711
- return (
1712
- <label
1713
- className="inline-flex items-center"
1714
- onClick={(e) => e.stopPropagation()}
1715
- >
1716
- <input
1717
- type="checkbox"
1718
- checked={Boolean(value)}
1719
- onChange={(e) => onSaveProperties({ [property.name]: e.target.checked })}
1720
- className="h-3.5 w-3.5 rounded border-border"
1721
- />
1722
- </label>
1723
- );
1724
- }
1725
-
1726
- if (property.type === 'relation' && property.name === folderPropertyName) {
1727
- const currentId = getRelationIds(value)[0] ?? '';
1728
- return (
1729
- <select
1730
- className={cn(selectClass, 'text-[12px]')}
1731
- value={currentId}
1732
- onClick={(e) => e.stopPropagation()}
1733
- onChange={(e) => onSaveProperties({ [property.name]: e.target.value ? [e.target.value] : [] })}
1734
- >
1735
- <option value="">No folder</option>
1736
- {folderOptions.map((f) => (
1737
- <option key={f.id} value={f.id}>
1738
- {f.pathLabel}
1739
- </option>
1740
- ))}
1741
- </select>
1742
- );
1743
- }
1744
-
1745
- if (property.type === 'number') {
1746
- return (
1747
- <input
1748
- type="number"
1749
- defaultValue={typeof value === 'number' ? String(value) : ''}
1750
- inputMode="decimal"
1751
- key={`${note.id}:${property.name}:${String(value ?? '')}`}
1752
- onClick={(e) => e.stopPropagation()}
1753
- onKeyDown={(e) => {
1754
- if (e.key === 'Enter') e.currentTarget.blur();
1755
- }}
1756
- onBlur={(e) => {
1757
- const v = e.target.value.trim();
1758
- onSaveProperties({ [property.name]: v ? Number(v) : null });
1759
- }}
1760
- className={cn(selectClass, 'font-mono text-[12px]')}
1761
- />
1762
- );
1763
- }
1764
-
1765
- if (property.type === 'rich_text') {
1766
- return (
1767
- <input
1768
- type="text"
1769
- defaultValue={isPresentString(value) ? value : ''}
1770
- key={`${note.id}:${property.name}:${String(value ?? '')}`}
1771
- onClick={(e) => e.stopPropagation()}
1772
- onKeyDown={(e) => {
1773
- if (e.key === 'Enter') e.currentTarget.blur();
1774
- }}
1775
- onBlur={(e) => {
1776
- const v = e.target.value.trim();
1777
- onSaveProperties({ [property.name]: v || null });
1778
- }}
1779
- className={cn(selectClass, 'text-[12px]')}
1780
- />
1781
- );
1782
- }
1783
-
1784
- return (
1785
- <span className="px-1.5 text-[12px] text-muted-foreground">
1786
- {formatPropertyValue(value, property, folderNameById)}
1787
- </span>
1788
- );
1789
- }
1790
-
1791
- /* -------------------------------------------------------------------------- */
1792
- /* Calendar view */
1793
- /* -------------------------------------------------------------------------- */
1794
-
1795
- function CalendarBody({
1796
- monthDays,
1797
- visibleMonth,
1798
- setVisibleMonth,
1799
- notesByDay,
1800
- statusPropertyName,
1801
- calendarDateFields,
1802
- activeDateProperty,
1803
- setActiveDateProperty,
1804
- scheduledNotesCount,
1805
- onOpen,
1806
- }: {
1807
- monthDays: Date[];
1808
- visibleMonth: Date;
1809
- setVisibleMonth: (d: Date) => void;
1810
- notesByDay: Map<string, DocumentRecord[]>;
1811
- statusPropertyName: string | null;
1812
- calendarDateFields: CalendarDateField[];
1813
- activeDateProperty: string;
1814
- setActiveDateProperty: (v: string) => void;
1815
- scheduledNotesCount: number;
1816
- onOpen: (doc: DocumentRecord) => void;
1817
- }) {
1818
- return (
1819
- <div className="flex flex-1 flex-col gap-4 px-6 py-5">
1820
- <div className="flex flex-wrap items-center gap-2">
1821
- <div className="inline-flex items-center gap-0 rounded-lg border border-border bg-background p-0.5">
1822
- <Button
1823
- variant="ghost"
1824
- size="icon"
1825
- className="h-7 w-7"
1826
- onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1))}
1827
- >
1828
- <ChevronLeft className="h-3.5 w-3.5" />
1829
- </Button>
1830
- <div className="px-2 text-[13px] font-semibold tracking-tight text-foreground">
1831
- {formatMonthLabel(visibleMonth)}
1832
- </div>
1833
- <Button
1834
- variant="ghost"
1835
- size="icon"
1836
- className="h-7 w-7"
1837
- onClick={() => setVisibleMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1))}
1838
- >
1839
- <ChevronRight className="h-3.5 w-3.5" />
1840
- </Button>
1841
- </div>
1842
- <Button variant="outline" size="sm" className="h-8 text-[12px]" onClick={() => setVisibleMonth(new Date())}>
1843
- Today
1844
- </Button>
1845
-
1846
- <div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-[11px]">
1847
- <CalendarDays className="h-3 w-3 text-muted-foreground" />
1848
- <span className="text-muted-foreground">Date field</span>
1849
- <select
1850
- className="appearance-none bg-transparent pr-3 font-semibold text-foreground outline-none"
1851
- value={activeDateProperty}
1852
- onChange={(e) => setActiveDateProperty(e.target.value)}
1853
- >
1854
- {calendarDateFields.map((field) => (
1855
- <option key={field.value} value={field.value}>
1856
- {field.label}
1857
- </option>
1858
- ))}
1859
- </select>
1860
- <ChevronDown className="h-3 w-3 text-muted-foreground" />
1861
- </div>
1862
-
1863
- <div className="flex-1" />
1864
- <Eyebrow>{pluralize(scheduledNotesCount, 'note')} scheduled</Eyebrow>
1865
- </div>
1866
-
1867
- <div className="overflow-hidden rounded-lg border border-border bg-card">
1868
- <div className="grid grid-cols-7 border-b border-border bg-muted/40">
1869
- {WEEKDAY_LABELS.map((label, i) => (
1870
- <div
1871
- key={label}
1872
- className={cn(
1873
- 'px-3 py-2 font-mono text-[10px] font-medium uppercase tracking-[0.14em]',
1874
- i >= 5 ? 'text-muted-foreground/60' : 'text-muted-foreground',
1875
- )}
1876
- >
1877
- {label}
1878
- </div>
1879
- ))}
1880
- </div>
1881
- <div className="grid grid-cols-7">
1882
- {monthDays.map((day) => {
1883
- const key = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`;
1884
- const dayNotes = notesByDay.get(key) ?? [];
1885
- const inMonth = isSameMonth(day, visibleMonth);
1886
- const today = isToday(day);
1887
- return (
1888
- <div
1889
- key={key}
1890
- className={cn(
1891
- 'flex min-h-[110px] flex-col gap-1.5 border-b border-r border-border px-2 py-2',
1892
- !inMonth && 'bg-muted/30',
1893
- )}
1894
- >
1895
- <div className="flex items-center justify-between">
1896
- {today ? (
1897
- <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-foreground text-[11px] font-semibold text-background">
1898
- {day.getDate()}
1899
- </span>
1900
- ) : (
1901
- <span
1902
- className={cn(
1903
- 'text-[12px] font-medium',
1904
- inMonth ? 'text-foreground/80' : 'text-muted-foreground/50',
1905
- )}
1906
- >
1907
- {day.getDate()}
1908
- </span>
1909
- )}
1910
- {today ? <Eyebrow className="text-foreground">Today</Eyebrow> : null}
1911
- </div>
1912
- <div className="flex flex-col gap-1">
1913
- {dayNotes.slice(0, 3).map((note) => {
1914
- const status = statusPropertyName
1915
- ? getStatusLabel(note.properties[statusPropertyName])
1916
- : null;
1917
- const tone = getStatusTone(status);
1918
- return (
1919
- <button
1920
- key={note.id}
1921
- type="button"
1922
- onClick={() => onOpen(note)}
1923
- className={cn(
1924
- 'line-clamp-1 rounded-md border border-l-2 border-border bg-background px-2 py-1 text-left text-[11px] font-medium text-foreground transition-colors hover:bg-muted/50',
1925
- statusBarClasses[tone],
1926
- )}
1927
- >
1928
- {getNoteTitle(note)}
1929
- </button>
1930
- );
1931
- })}
1932
- {dayNotes.length > 3 ? (
1933
- <span className="px-1 text-[10px] text-muted-foreground">+{dayNotes.length - 3} more</span>
1934
- ) : null}
1935
- </div>
1936
- </div>
1937
- );
1938
- })}
1939
- </div>
1940
- </div>
1941
- </div>
1942
- );
1943
- }