@jant/core 0.3.27 → 0.3.28

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 (313) hide show
  1. package/dist/client/client.css +1 -0
  2. package/dist/client/client.js +31561 -0
  3. package/dist/index.js +15209 -15
  4. package/package.json +21 -15
  5. package/src/__tests__/helpers/app.ts +19 -3
  6. package/src/__tests__/helpers/db.ts +44 -0
  7. package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
  8. package/src/app.tsx +111 -174
  9. package/src/client.ts +13 -0
  10. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  11. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  12. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  13. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  14. package/src/db/schema.ts +24 -4
  15. package/src/i18n/locales/en.po +810 -385
  16. package/src/i18n/locales/en.ts +1 -1
  17. package/src/i18n/locales/zh-Hans.po +733 -522
  18. package/src/i18n/locales/zh-Hans.ts +1 -1
  19. package/src/i18n/locales/zh-Hant.po +733 -522
  20. package/src/i18n/locales/zh-Hant.ts +1 -1
  21. package/src/i18n/middleware.ts +7 -11
  22. package/src/index.ts +1 -1
  23. package/src/lib/__tests__/icons.test.ts +178 -0
  24. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  25. package/src/lib/__tests__/schemas.test.ts +12 -6
  26. package/src/lib/__tests__/theme.test.ts +62 -0
  27. package/src/lib/__tests__/timezones.test.ts +1 -1
  28. package/src/lib/__tests__/url.test.ts +12 -0
  29. package/src/lib/__tests__/view.test.ts +1 -5
  30. package/src/lib/avatar-upload.ts +18 -10
  31. package/src/lib/collection-form-bridge.ts +52 -0
  32. package/src/lib/collections-reorder.ts +28 -0
  33. package/src/lib/compose-bridge.ts +251 -0
  34. package/src/lib/errors.ts +116 -0
  35. package/src/lib/excerpt.ts +1 -1
  36. package/src/lib/favicon.ts +3 -5
  37. package/src/lib/html.ts +22 -0
  38. package/src/lib/icon-catalog.ts +181 -0
  39. package/src/lib/icons.ts +202 -0
  40. package/src/lib/navigation.ts +18 -33
  41. package/src/lib/pagination.ts +3 -2
  42. package/src/lib/post-form-bridge.ts +136 -0
  43. package/src/lib/render.tsx +11 -4
  44. package/src/lib/resolve-config.ts +157 -0
  45. package/src/lib/schemas.ts +76 -12
  46. package/src/lib/settings-bridge.ts +139 -0
  47. package/src/lib/storage.ts +37 -16
  48. package/src/lib/theme.ts +5 -7
  49. package/src/lib/timeline.ts +4 -8
  50. package/src/lib/toast.ts +134 -0
  51. package/src/lib/upload.ts +71 -0
  52. package/src/lib/url.ts +9 -1
  53. package/src/lib/version.ts +16 -0
  54. package/src/lib/view.ts +9 -10
  55. package/src/middleware/__tests__/auth.test.ts +6 -28
  56. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  57. package/src/middleware/auth.ts +6 -12
  58. package/src/middleware/config.ts +51 -0
  59. package/src/middleware/error-handler.ts +56 -0
  60. package/src/middleware/onboarding.ts +1 -1
  61. package/src/preset.css +6 -0
  62. package/src/routes/__tests__/compose.test.ts +104 -17
  63. package/src/routes/api/__tests__/collections.test.ts +93 -2
  64. package/src/routes/api/__tests__/posts.test.ts +2 -1
  65. package/src/routes/api/__tests__/settings.test.ts +1 -1
  66. package/src/routes/api/collections.ts +64 -68
  67. package/src/routes/api/nav-items.ts +21 -59
  68. package/src/routes/api/pages.ts +18 -46
  69. package/src/routes/api/posts.ts +64 -86
  70. package/src/routes/api/search.ts +6 -4
  71. package/src/routes/api/settings.ts +8 -24
  72. package/src/routes/api/upload.ts +55 -53
  73. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  74. package/src/routes/auth/reset.tsx +17 -66
  75. package/src/routes/auth/setup.tsx +67 -11
  76. package/src/routes/auth/signin.tsx +44 -8
  77. package/src/routes/compose.tsx +194 -0
  78. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  79. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  80. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  81. package/src/routes/dash/appearance.tsx +173 -0
  82. package/src/routes/dash/collections.tsx +80 -14
  83. package/src/routes/dash/index.tsx +12 -14
  84. package/src/routes/dash/media.tsx +46 -49
  85. package/src/routes/dash/pages.tsx +85 -37
  86. package/src/routes/dash/posts.tsx +60 -23
  87. package/src/routes/dash/redirects.tsx +43 -33
  88. package/src/routes/dash/settings.tsx +234 -214
  89. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  90. package/src/routes/feed/rss.ts +11 -16
  91. package/src/routes/feed/sitemap.ts +15 -9
  92. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  93. package/src/routes/pages/archive.tsx +2 -2
  94. package/src/routes/pages/collection.tsx +76 -9
  95. package/src/routes/pages/collections.tsx +3 -1
  96. package/src/routes/pages/featured.tsx +2 -2
  97. package/src/routes/pages/home.tsx +3 -3
  98. package/src/routes/pages/latest.tsx +2 -2
  99. package/src/routes/pages/page.tsx +2 -2
  100. package/src/routes/pages/post.tsx +2 -2
  101. package/src/routes/pages/search.tsx +2 -2
  102. package/src/services/__tests__/collection.test.ts +324 -34
  103. package/src/services/__tests__/media.test.ts +1 -1
  104. package/src/services/__tests__/page.test.ts +116 -1
  105. package/src/services/auth.ts +88 -0
  106. package/src/services/collection.ts +169 -30
  107. package/src/services/index.ts +8 -3
  108. package/src/services/media.ts +39 -12
  109. package/src/services/navigation.ts +17 -5
  110. package/src/services/page.ts +24 -4
  111. package/src/services/post.ts +87 -19
  112. package/src/services/search.ts +0 -1
  113. package/src/services/settings.ts +21 -13
  114. package/src/style.css +3 -0
  115. package/src/styles/components.css +42 -1
  116. package/src/styles/tokens.css +4 -0
  117. package/src/styles/ui.css +902 -73
  118. package/src/types/app-context.ts +25 -0
  119. package/src/types/bindings.ts +1 -0
  120. package/src/types/config.ts +60 -23
  121. package/src/types/entities.ts +12 -2
  122. package/src/types/lingui-react-macro.d.ts +3 -3
  123. package/src/types/operations.ts +2 -4
  124. package/src/types/views.ts +1 -3
  125. package/src/ui/__tests__/font-themes.test.ts +27 -8
  126. package/src/ui/color-themes.ts +1 -1
  127. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  128. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  129. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  130. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  131. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  132. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  133. package/src/ui/components/collection-types.ts +45 -0
  134. package/src/ui/components/compose-types.ts +75 -0
  135. package/src/ui/components/jant-collection-form.ts +512 -0
  136. package/src/ui/components/jant-compose-dialog.ts +494 -0
  137. package/src/ui/components/jant-compose-editor.ts +799 -0
  138. package/src/ui/components/jant-post-form.ts +290 -0
  139. package/src/ui/components/jant-settings-avatar.ts +231 -0
  140. package/src/ui/components/jant-settings-general.ts +436 -0
  141. package/src/ui/components/post-form-template.ts +260 -0
  142. package/src/ui/components/post-form-types.ts +87 -0
  143. package/src/ui/components/settings-types.ts +62 -0
  144. package/src/ui/compose/ComposeDialog.tsx +141 -385
  145. package/src/ui/compose/ComposePrompt.tsx +3 -3
  146. package/src/ui/dash/PostList.tsx +55 -61
  147. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  148. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  149. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  150. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  151. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  152. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  153. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  154. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  155. package/src/ui/dash/index.ts +1 -1
  156. package/src/ui/dash/posts/PostForm.tsx +248 -0
  157. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  158. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  159. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  160. package/src/ui/font-themes.ts +115 -32
  161. package/src/ui/layouts/BaseLayout.tsx +49 -19
  162. package/src/ui/layouts/DashLayout.tsx +14 -9
  163. package/src/ui/layouts/SiteLayout.tsx +38 -23
  164. package/src/ui/pages/CollectionPage.tsx +12 -2
  165. package/src/ui/pages/CollectionsPage.tsx +27 -27
  166. package/src/ui/pages/HomePage.tsx +15 -6
  167. package/src/ui/pages/SearchPage.tsx +1 -2
  168. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  169. package/src/ui/shared/Pagination.tsx +2 -2
  170. package/dist/app.js +0 -267
  171. package/dist/auth.js +0 -39
  172. package/dist/client.js +0 -13
  173. package/dist/db/index.js +0 -10
  174. package/dist/db/schema.js +0 -224
  175. package/dist/i18n/Trans.js +0 -24
  176. package/dist/i18n/context.js +0 -58
  177. package/dist/i18n/detect.js +0 -26
  178. package/dist/i18n/i18n.js +0 -49
  179. package/dist/i18n/index.js +0 -44
  180. package/dist/i18n/locales/en.js +0 -1
  181. package/dist/i18n/locales/zh-Hans.js +0 -1
  182. package/dist/i18n/locales/zh-Hant.js +0 -1
  183. package/dist/i18n/locales.js +0 -13
  184. package/dist/i18n/middleware.js +0 -30
  185. package/dist/lib/avatar-upload.js +0 -134
  186. package/dist/lib/config.js +0 -143
  187. package/dist/lib/constants.js +0 -50
  188. package/dist/lib/excerpt.js +0 -76
  189. package/dist/lib/favicon.js +0 -102
  190. package/dist/lib/feed.js +0 -123
  191. package/dist/lib/image-processor.js +0 -187
  192. package/dist/lib/image.js +0 -97
  193. package/dist/lib/index.js +0 -7
  194. package/dist/lib/markdown.js +0 -83
  195. package/dist/lib/media-helpers.js +0 -49
  196. package/dist/lib/media-upload.js +0 -104
  197. package/dist/lib/nav-reorder.js +0 -27
  198. package/dist/lib/navigation.js +0 -79
  199. package/dist/lib/pagination.js +0 -44
  200. package/dist/lib/render.js +0 -53
  201. package/dist/lib/schemas.js +0 -174
  202. package/dist/lib/sqid.js +0 -72
  203. package/dist/lib/sse.js +0 -218
  204. package/dist/lib/storage.js +0 -164
  205. package/dist/lib/theme.js +0 -65
  206. package/dist/lib/time.js +0 -159
  207. package/dist/lib/timeline.js +0 -95
  208. package/dist/lib/timezones.js +0 -388
  209. package/dist/lib/url.js +0 -89
  210. package/dist/lib/view.js +0 -217
  211. package/dist/middleware/auth.js +0 -52
  212. package/dist/middleware/onboarding.js +0 -41
  213. package/dist/routes/api/collections.js +0 -124
  214. package/dist/routes/api/nav-items.js +0 -104
  215. package/dist/routes/api/pages.js +0 -91
  216. package/dist/routes/api/posts.js +0 -218
  217. package/dist/routes/api/search.js +0 -48
  218. package/dist/routes/api/settings.js +0 -68
  219. package/dist/routes/api/upload.js +0 -246
  220. package/dist/routes/auth/reset.js +0 -221
  221. package/dist/routes/auth/setup.js +0 -194
  222. package/dist/routes/auth/signin.js +0 -176
  223. package/dist/routes/compose.js +0 -48
  224. package/dist/routes/dash/collections.js +0 -115
  225. package/dist/routes/dash/index.js +0 -118
  226. package/dist/routes/dash/media.js +0 -106
  227. package/dist/routes/dash/pages.js +0 -294
  228. package/dist/routes/dash/posts.js +0 -244
  229. package/dist/routes/dash/redirects.js +0 -257
  230. package/dist/routes/dash/settings.js +0 -379
  231. package/dist/routes/feed/rss.js +0 -62
  232. package/dist/routes/feed/sitemap.js +0 -49
  233. package/dist/routes/pages/archive.js +0 -62
  234. package/dist/routes/pages/collection.js +0 -34
  235. package/dist/routes/pages/collections.js +0 -28
  236. package/dist/routes/pages/featured.js +0 -36
  237. package/dist/routes/pages/home.js +0 -64
  238. package/dist/routes/pages/latest.js +0 -45
  239. package/dist/routes/pages/page.js +0 -68
  240. package/dist/routes/pages/post.js +0 -44
  241. package/dist/routes/pages/search.js +0 -54
  242. package/dist/services/collection.js +0 -109
  243. package/dist/services/index.js +0 -24
  244. package/dist/services/media.js +0 -117
  245. package/dist/services/navigation.js +0 -91
  246. package/dist/services/page.js +0 -84
  247. package/dist/services/post.js +0 -229
  248. package/dist/services/redirect.js +0 -48
  249. package/dist/services/search.js +0 -67
  250. package/dist/services/settings.js +0 -68
  251. package/dist/types/bindings.js +0 -3
  252. package/dist/types/config.js +0 -147
  253. package/dist/types/constants.js +0 -27
  254. package/dist/types/entities.js +0 -3
  255. package/dist/types/lingui-react-macro.d.js +0 -9
  256. package/dist/types/operations.js +0 -3
  257. package/dist/types/props.js +0 -3
  258. package/dist/types/sortablejs.d.js +0 -5
  259. package/dist/types/views.js +0 -5
  260. package/dist/types.js +0 -11
  261. package/dist/ui/color-themes.js +0 -268
  262. package/dist/ui/compose/ComposeDialog.js +0 -467
  263. package/dist/ui/compose/ComposePrompt.js +0 -55
  264. package/dist/ui/dash/ActionButtons.js +0 -46
  265. package/dist/ui/dash/CrudPageHeader.js +0 -22
  266. package/dist/ui/dash/DangerZone.js +0 -36
  267. package/dist/ui/dash/FormatBadge.js +0 -27
  268. package/dist/ui/dash/ListItemRow.js +0 -21
  269. package/dist/ui/dash/PageForm.js +0 -195
  270. package/dist/ui/dash/PostForm.js +0 -395
  271. package/dist/ui/dash/PostList.js +0 -83
  272. package/dist/ui/dash/StatusBadge.js +0 -46
  273. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  274. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  275. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  276. package/dist/ui/dash/index.js +0 -10
  277. package/dist/ui/dash/media/MediaListContent.js +0 -166
  278. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  279. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  280. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  281. package/dist/ui/dash/settings/AccountContent.js +0 -209
  282. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  283. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  284. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  285. package/dist/ui/feed/LinkCard.js +0 -72
  286. package/dist/ui/feed/NoteCard.js +0 -58
  287. package/dist/ui/feed/QuoteCard.js +0 -63
  288. package/dist/ui/feed/ThreadPreview.js +0 -48
  289. package/dist/ui/feed/TimelineFeed.js +0 -41
  290. package/dist/ui/feed/TimelineItem.js +0 -27
  291. package/dist/ui/font-themes.js +0 -36
  292. package/dist/ui/layouts/BaseLayout.js +0 -153
  293. package/dist/ui/layouts/DashLayout.js +0 -141
  294. package/dist/ui/layouts/SiteLayout.js +0 -169
  295. package/dist/ui/pages/ArchivePage.js +0 -143
  296. package/dist/ui/pages/CollectionPage.js +0 -70
  297. package/dist/ui/pages/CollectionsPage.js +0 -76
  298. package/dist/ui/pages/FeaturedPage.js +0 -24
  299. package/dist/ui/pages/HomePage.js +0 -24
  300. package/dist/ui/pages/PostPage.js +0 -55
  301. package/dist/ui/pages/SearchPage.js +0 -122
  302. package/dist/ui/pages/SinglePage.js +0 -23
  303. package/dist/ui/shared/EmptyState.js +0 -27
  304. package/dist/ui/shared/MediaGallery.js +0 -35
  305. package/dist/ui/shared/Pagination.js +0 -195
  306. package/dist/ui/shared/ThreadView.js +0 -108
  307. package/dist/ui/shared/index.js +0 -5
  308. package/dist/vendor/datastar.js +0 -1606
  309. package/src/lib/__tests__/config.test.ts +0 -192
  310. package/src/lib/config.ts +0 -167
  311. package/src/routes/compose.ts +0 -63
  312. package/src/ui/dash/PostForm.tsx +0 -360
  313. package/src/ui/dash/settings/AppearanceContent.tsx +0 -254
@@ -18,7 +18,7 @@ describe("Compose Routes", () => {
18
18
  expect(res.headers.get("Location")).toBe("/signin");
19
19
  });
20
20
 
21
- it("creates a note post and returns redirect", async () => {
21
+ it("creates a note post and returns timeline card via SSE", async () => {
22
22
  const { app, services } = createTestApp({ authenticated: true });
23
23
  app.route("/compose", composeRoutes);
24
24
 
@@ -29,7 +29,13 @@ describe("Compose Routes", () => {
29
29
  });
30
30
 
31
31
  expect(res.status).toBe(200);
32
- expect(res.headers.get("Content-Type")).toBe("text/html");
32
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
33
+
34
+ const text = await res.text();
35
+ // SSE prepends the card to the timeline
36
+ expect(text).toContain("datastar-patch-elements");
37
+ expect(text).toContain('data-format="note"');
38
+ expect(text).toContain("selector #timeline-items");
33
39
 
34
40
  // Verify post was created
35
41
  const posts = await services.posts.list();
@@ -54,6 +60,10 @@ describe("Compose Routes", () => {
54
60
  });
55
61
 
56
62
  expect(res.status).toBe(200);
63
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
64
+
65
+ const text = await res.text();
66
+ expect(text).toContain('data-format="link"');
57
67
 
58
68
  const posts = await services.posts.list();
59
69
  expect(posts).toHaveLength(1);
@@ -77,6 +87,10 @@ describe("Compose Routes", () => {
77
87
  });
78
88
 
79
89
  expect(res.status).toBe(200);
90
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
91
+
92
+ const text = await res.text();
93
+ expect(text).toContain('data-format="quote"');
80
94
 
81
95
  const posts = await services.posts.list();
82
96
  expect(posts).toHaveLength(1);
@@ -84,7 +98,7 @@ describe("Compose Routes", () => {
84
98
  expect(posts[0].quoteText).toBe("The original quote");
85
99
  });
86
100
 
87
- it("creates a draft when status is draft", async () => {
101
+ it("creates a draft and closes dialog with toast", async () => {
88
102
  const { app, services } = createTestApp({ authenticated: true });
89
103
  app.route("/compose", composeRoutes);
90
104
 
@@ -99,6 +113,13 @@ describe("Compose Routes", () => {
99
113
  });
100
114
 
101
115
  expect(res.status).toBe(200);
116
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
117
+
118
+ const text = await res.text();
119
+ // Should close dialog and show toast, not prepend to timeline
120
+ expect(text).toContain("compose-dialog");
121
+ expect(text).toContain("Draft saved");
122
+ expect(text).not.toContain("selector #timeline-items");
102
123
 
103
124
  const posts = await services.posts.list({ includeDrafts: true });
104
125
  expect(posts).toHaveLength(1);
@@ -158,27 +179,20 @@ describe("Compose Routes", () => {
158
179
  expect(attachments[0].id).toBe(media.id);
159
180
  });
160
181
 
161
- it("sets featured and pinned flags", async () => {
162
- const { app, services } = createTestApp({ authenticated: true });
182
+ it("resets compose signals after publishing", async () => {
183
+ const { app } = createTestApp({ authenticated: true });
163
184
  app.route("/compose", composeRoutes);
164
185
 
165
186
  const res = await app.request("/compose", {
166
187
  method: "POST",
167
188
  headers: { "Content-Type": "application/json" },
168
- body: JSON.stringify({
169
- format: "note",
170
- body: "Featured and pinned",
171
- featured: true,
172
- pinned: true,
173
- }),
189
+ body: JSON.stringify({ format: "note", body: "Hello" }),
174
190
  });
175
191
 
176
- expect(res.status).toBe(200);
177
-
178
- const posts = await services.posts.list();
179
- expect(posts).toHaveLength(1);
180
- expect(posts[0].featured).toBe(1);
181
- expect(posts[0].pinned).toBe(1);
192
+ const text = await res.text();
193
+ // SSE should include signal reset
194
+ expect(text).toContain("datastar-patch-signals");
195
+ expect(text).toContain('"_composeLoading":false');
182
196
  });
183
197
 
184
198
  it("returns error when format is missing", async () => {
@@ -196,4 +210,77 @@ describe("Compose Routes", () => {
196
210
  expect(text).toContain("toast-error");
197
211
  });
198
212
  });
213
+
214
+ describe("POST /compose (JSON mode)", () => {
215
+ it("returns JSON for published note", async () => {
216
+ const { app, services } = createTestApp({ authenticated: true });
217
+ app.route("/compose", composeRoutes);
218
+
219
+ const res = await app.request("/compose", {
220
+ method: "POST",
221
+ headers: {
222
+ "Content-Type": "application/json",
223
+ Accept: "application/json",
224
+ },
225
+ body: JSON.stringify({ format: "note", body: "Hello JSON" }),
226
+ });
227
+
228
+ expect(res.status).toBe(200);
229
+ expect(res.headers.get("Content-Type")).toContain("application/json");
230
+
231
+ const data = await res.json();
232
+ expect(data.status).toBe("published");
233
+ expect(data.cardHtml).toContain('data-format="note"');
234
+
235
+ const posts = await services.posts.list();
236
+ expect(posts).toHaveLength(1);
237
+ expect(posts[0].body).toBe("Hello JSON");
238
+ });
239
+
240
+ it("returns JSON for draft", async () => {
241
+ const { app, services } = createTestApp({ authenticated: true });
242
+ app.route("/compose", composeRoutes);
243
+
244
+ const res = await app.request("/compose", {
245
+ method: "POST",
246
+ headers: {
247
+ "Content-Type": "application/json",
248
+ Accept: "application/json",
249
+ },
250
+ body: JSON.stringify({
251
+ format: "note",
252
+ body: "Draft JSON",
253
+ status: "draft",
254
+ }),
255
+ });
256
+
257
+ expect(res.status).toBe(200);
258
+ const data = await res.json();
259
+ expect(data.status).toBe("draft");
260
+ expect(data.toast).toBe("Draft saved.");
261
+
262
+ const posts = await services.posts.list({ includeDrafts: true });
263
+ expect(posts).toHaveLength(1);
264
+ expect(posts[0].status).toBe("draft");
265
+ });
266
+
267
+ it("returns JSON error for invalid input", async () => {
268
+ const { app } = createTestApp({ authenticated: true });
269
+ app.route("/compose", composeRoutes);
270
+
271
+ const res = await app.request("/compose", {
272
+ method: "POST",
273
+ headers: {
274
+ "Content-Type": "application/json",
275
+ Accept: "application/json",
276
+ },
277
+ body: JSON.stringify({ format: "invalid", body: "Hello" }),
278
+ });
279
+
280
+ expect(res.status).toBe(422);
281
+ const data = await res.json();
282
+ expect(data.status).toBe("error");
283
+ expect(data.error).toBeDefined();
284
+ });
285
+ });
199
286
  });
@@ -23,11 +23,11 @@ describe("Collections API Routes", () => {
23
23
  slug: "tech",
24
24
  title: "Tech",
25
25
  });
26
- await services.posts.create({
26
+ const post = await services.posts.create({
27
27
  format: "note",
28
28
  body: "tech post",
29
- collectionId: col.id,
30
29
  });
30
+ await services.collections.addPost(col.id, post.id);
31
31
 
32
32
  const res = await app.request("/api/collections");
33
33
  const body = await res.json();
@@ -246,4 +246,95 @@ describe("Collections API Routes", () => {
246
246
  expect(res.status).toBe(404);
247
247
  });
248
248
  });
249
+
250
+ describe("POST /api/collections/:id/posts", () => {
251
+ it("adds a post to a collection", async () => {
252
+ const { app, services } = createTestApp({ authenticated: true });
253
+ app.route("/api/collections", collectionsApiRoutes);
254
+
255
+ const col = await services.collections.create({
256
+ slug: "tech",
257
+ title: "Tech",
258
+ });
259
+ const post = await services.posts.create({
260
+ format: "note",
261
+ body: "test",
262
+ });
263
+
264
+ const res = await app.request(`/api/collections/${col.id}/posts`, {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: JSON.stringify({ postId: post.id }),
268
+ });
269
+
270
+ expect(res.status).toBe(201);
271
+
272
+ const postIds = await services.collections.getPostIds(col.id);
273
+ expect(postIds).toContain(post.id);
274
+ });
275
+
276
+ it("returns 404 for non-existent collection", async () => {
277
+ const { app, services } = createTestApp({ authenticated: true });
278
+ app.route("/api/collections", collectionsApiRoutes);
279
+
280
+ const post = await services.posts.create({
281
+ format: "note",
282
+ body: "test",
283
+ });
284
+
285
+ const res = await app.request("/api/collections/9999/posts", {
286
+ method: "POST",
287
+ headers: { "Content-Type": "application/json" },
288
+ body: JSON.stringify({ postId: post.id }),
289
+ });
290
+
291
+ expect(res.status).toBe(404);
292
+ });
293
+
294
+ it("returns 401 when not authenticated", async () => {
295
+ const { app, services } = createTestApp({ authenticated: false });
296
+ app.route("/api/collections", collectionsApiRoutes);
297
+
298
+ const col = await services.collections.create({
299
+ slug: "tech",
300
+ title: "Tech",
301
+ });
302
+
303
+ const res = await app.request(`/api/collections/${col.id}/posts`, {
304
+ method: "POST",
305
+ headers: { "Content-Type": "application/json" },
306
+ body: JSON.stringify({ postId: 1 }),
307
+ });
308
+
309
+ expect(res.status).toBe(401);
310
+ });
311
+ });
312
+
313
+ describe("DELETE /api/collections/:id/posts/:postId", () => {
314
+ it("removes a post from a collection", async () => {
315
+ const { app, services } = createTestApp({ authenticated: true });
316
+ app.route("/api/collections", collectionsApiRoutes);
317
+
318
+ const col = await services.collections.create({
319
+ slug: "tech",
320
+ title: "Tech",
321
+ });
322
+ const post = await services.posts.create({
323
+ format: "note",
324
+ body: "test",
325
+ });
326
+
327
+ await services.collections.addPost(col.id, post.id);
328
+
329
+ const res = await app.request(
330
+ `/api/collections/${col.id}/posts/${post.id}`,
331
+ { method: "DELETE" },
332
+ );
333
+
334
+ expect(res.status).toBe(200);
335
+
336
+ const postIds = await services.collections.getPostIds(col.id);
337
+ expect(postIds).not.toContain(post.id);
338
+ });
339
+ });
249
340
  });
@@ -275,7 +275,8 @@ describe("Posts API Routes", () => {
275
275
 
276
276
  expect(res.status).toBe(400);
277
277
  const body = await res.json();
278
- expect(body.error).toBe("Validation failed");
278
+ expect(body.error).toContain("Invalid");
279
+ expect(body.code).toBe("VALIDATION_ERROR");
279
280
  });
280
281
 
281
282
  it("returns 400 for missing required fields", async () => {
@@ -94,7 +94,7 @@ describe("Settings API Routes", () => {
94
94
 
95
95
  expect(res.status).toBe(400);
96
96
  const body = await res.json();
97
- expect(body.rejectedKeys).toContain("AUTH_SECRET");
97
+ expect(body.details.rejectedKeys).toContain("AUTH_SECRET");
98
98
  });
99
99
 
100
100
  it("partially applies when mixing editable and env-only keys", async () => {
@@ -3,40 +3,37 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
+ import { z } from "zod";
6
7
  import type { Bindings, SortOrder } from "../../types.js";
7
- import type { AppVariables } from "../../app.js";
8
+ import type { AppVariables } from "../../types/app-context.js";
8
9
  import { requireAuthApi } from "../../middleware/auth.js";
9
- import { z } from "zod";
10
- import { SORT_ORDERS } from "../../types.js";
10
+ import {
11
+ CreateCollectionSchema,
12
+ SortOrderSchema,
13
+ parseValidated,
14
+ } from "../../lib/schemas.js";
15
+ import { assertFound, parseIntParam, NotFoundError } from "../../lib/errors.js";
11
16
 
12
17
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
18
 
14
19
  export const collectionsApiRoutes = new Hono<Env>();
15
20
 
16
- const SortOrderSchema = z.enum(SORT_ORDERS);
17
-
18
- const CreateCollectionSchema = z.object({
19
- slug: z.string().min(1),
20
- title: z.string().min(1),
21
- description: z.string().optional(),
22
- icon: z.string().optional(),
23
- sortOrder: SortOrderSchema.optional(),
24
- position: z.number().int().min(0).optional(),
25
- showDivider: z.boolean().optional(),
26
- });
27
-
28
- const UpdateCollectionSchema = z.object({
29
- slug: z.string().min(1).optional(),
30
- title: z.string().min(1).optional(),
21
+ // API update schema extends shared schema with nullable fields for explicit clearing
22
+ const UpdateCollectionSchema = CreateCollectionSchema.partial().extend({
31
23
  description: z.string().nullable().optional(),
32
24
  icon: z.string().nullable().optional(),
33
25
  sortOrder: SortOrderSchema.optional(),
34
26
  position: z.number().int().min(0).optional(),
35
- showDivider: z.boolean().optional(),
36
27
  });
37
28
 
38
- const ReorderSchema = z.object({
39
- ids: z.array(z.number().int().positive()),
29
+ // Route-specific schemas (not shared domain schemas)
30
+ const CollectionReorderSchema = z.object({
31
+ ids: z.array(z.number().int().positive()).optional(),
32
+ items: z.array(z.string().regex(/^[cd]-\d+$/)).optional(),
33
+ });
34
+
35
+ const PostAssignSchema = z.object({
36
+ postId: z.number().int().positive(),
40
37
  });
41
38
 
42
39
  // List collections (includes post counts)
@@ -54,45 +51,30 @@ collectionsApiRoutes.get("/", async (c) => {
54
51
 
55
52
  // Get single collection
56
53
  collectionsApiRoutes.get("/:id", async (c) => {
57
- const id = parseInt(c.req.param("id"), 10);
58
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
59
-
60
- const collection = await c.var.services.collections.getById(id);
61
- if (!collection) return c.json({ error: "Not found" }, 404);
62
-
54
+ const id = parseIntParam(c.req.param("id"));
55
+ const collection = assertFound(
56
+ await c.var.services.collections.getById(id),
57
+ "Collection",
58
+ );
63
59
  return c.json(collection);
64
60
  });
65
61
 
66
62
  // Reorder collections (requires auth) — must be before /:id
67
63
  collectionsApiRoutes.put("/reorder", requireAuthApi(), async (c) => {
68
- const rawBody = await c.req.json();
69
-
70
- const parseResult = ReorderSchema.safeParse(rawBody);
71
- if (!parseResult.success) {
72
- return c.json(
73
- { error: "Validation failed", details: parseResult.error.flatten() },
74
- 400,
75
- );
76
- }
64
+ const body = parseValidated(CollectionReorderSchema, await c.req.json());
77
65
 
78
- await c.var.services.collections.reorder(parseResult.data.ids);
66
+ if (body.items) {
67
+ await c.var.services.collections.reorderAll(body.items);
68
+ } else if (body.ids) {
69
+ await c.var.services.collections.reorder(body.ids);
70
+ }
79
71
  const collections = await c.var.services.collections.list();
80
72
  return c.json({ collections });
81
73
  });
82
74
 
83
75
  // Create collection (requires auth)
84
76
  collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
85
- const rawBody = await c.req.json();
86
-
87
- const parseResult = CreateCollectionSchema.safeParse(rawBody);
88
- if (!parseResult.success) {
89
- return c.json(
90
- { error: "Validation failed", details: parseResult.error.flatten() },
91
- 400,
92
- );
93
- }
94
-
95
- const body = parseResult.data;
77
+ const body = parseValidated(CreateCollectionSchema, await c.req.json());
96
78
 
97
79
  const collection = await c.var.services.collections.create({
98
80
  slug: body.slug,
@@ -101,7 +83,6 @@ collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
101
83
  icon: body.icon,
102
84
  sortOrder: body.sortOrder as SortOrder | undefined,
103
85
  position: body.position,
104
- showDivider: body.showDivider,
105
86
  });
106
87
 
107
88
  return c.json(collection, 201);
@@ -109,35 +90,50 @@ collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
109
90
 
110
91
  // Update collection (requires auth)
111
92
  collectionsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
112
- const id = parseInt(c.req.param("id"), 10);
113
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
93
+ const id = parseIntParam(c.req.param("id"));
94
+ const body = parseValidated(UpdateCollectionSchema, await c.req.json());
114
95
 
115
- const rawBody = await c.req.json();
116
-
117
- const parseResult = UpdateCollectionSchema.safeParse(rawBody);
118
- if (!parseResult.success) {
119
- return c.json(
120
- { error: "Validation failed", details: parseResult.error.flatten() },
121
- 400,
122
- );
123
- }
124
-
125
- const collection = await c.var.services.collections.update(
126
- id,
127
- parseResult.data,
96
+ const collection = assertFound(
97
+ await c.var.services.collections.update(id, body),
98
+ "Collection",
128
99
  );
129
- if (!collection) return c.json({ error: "Not found" }, 404);
130
100
 
131
101
  return c.json(collection);
132
102
  });
133
103
 
134
104
  // Delete collection (requires auth)
135
105
  collectionsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
136
- const id = parseInt(c.req.param("id"), 10);
137
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
106
+ const id = parseIntParam(c.req.param("id"));
138
107
 
139
108
  const success = await c.var.services.collections.delete(id);
140
- if (!success) return c.json({ error: "Not found" }, 404);
109
+ if (!success) throw new NotFoundError("Collection");
141
110
 
142
111
  return c.json({ success: true });
143
112
  });
113
+
114
+ // Add a post to a collection (requires auth)
115
+ collectionsApiRoutes.post("/:id/posts", requireAuthApi(), async (c) => {
116
+ const id = parseIntParam(c.req.param("id"));
117
+ assertFound(await c.var.services.collections.getById(id), "Collection");
118
+
119
+ const body = parseValidated(PostAssignSchema, await c.req.json());
120
+ assertFound(await c.var.services.posts.getById(body.postId), "Post");
121
+
122
+ await c.var.services.collections.addPost(id, body.postId);
123
+
124
+ return c.json({ success: true }, 201);
125
+ });
126
+
127
+ // Remove a post from a collection (requires auth)
128
+ collectionsApiRoutes.delete(
129
+ "/:id/posts/:postId",
130
+ requireAuthApi(),
131
+ async (c) => {
132
+ const id = parseIntParam(c.req.param("id"));
133
+ const postId = parseIntParam(c.req.param("postId"));
134
+
135
+ await c.var.services.collections.removePost(id, postId);
136
+
137
+ return c.json({ success: true });
138
+ },
139
+ );
@@ -3,35 +3,24 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
+ import { z } from "zod";
6
7
  import type { Bindings, NavItemType } from "../../types.js";
7
- import type { AppVariables } from "../../app.js";
8
+ import type { AppVariables } from "../../types/app-context.js";
8
9
  import { requireAuthApi } from "../../middleware/auth.js";
9
- import { z } from "zod";
10
+ import {
11
+ CreateNavItemSchema,
12
+ ReorderSchema,
13
+ parseValidated,
14
+ } from "../../lib/schemas.js";
15
+ import { assertFound, parseIntParam, NotFoundError } from "../../lib/errors.js";
10
16
 
11
17
  type Env = { Bindings: Bindings; Variables: AppVariables };
12
18
 
13
19
  export const navItemsApiRoutes = new Hono<Env>();
14
20
 
15
- const NavItemTypeSchema = z.enum(["link", "page"]);
16
-
17
- const CreateNavItemSchema = z.object({
18
- type: NavItemTypeSchema,
19
- label: z.string().min(1),
20
- url: z.string().min(1),
21
- pageId: z.number().int().positive().optional(),
22
- position: z.number().int().min(0).optional(),
23
- });
24
-
25
- const UpdateNavItemSchema = z.object({
26
- type: NavItemTypeSchema.optional(),
27
- label: z.string().min(1).optional(),
28
- url: z.string().min(1).optional(),
21
+ // API update schema extends shared schema with nullable pageId for explicit clearing
22
+ const UpdateNavItemSchema = CreateNavItemSchema.partial().extend({
29
23
  pageId: z.number().int().positive().nullable().optional(),
30
- position: z.number().int().min(0).optional(),
31
- });
32
-
33
- const ReorderSchema = z.object({
34
- ids: z.array(z.number().int().positive()),
35
24
  });
36
25
 
37
26
  // List nav items
@@ -42,34 +31,16 @@ navItemsApiRoutes.get("/", async (c) => {
42
31
 
43
32
  // Reorder nav items (requires auth) — must be before /:id
44
33
  navItemsApiRoutes.put("/reorder", requireAuthApi(), async (c) => {
45
- const rawBody = await c.req.json();
46
-
47
- const parseResult = ReorderSchema.safeParse(rawBody);
48
- if (!parseResult.success) {
49
- return c.json(
50
- { error: "Validation failed", details: parseResult.error.flatten() },
51
- 400,
52
- );
53
- }
34
+ const body = parseValidated(ReorderSchema, await c.req.json());
54
35
 
55
- await c.var.services.navItems.reorder(parseResult.data.ids);
36
+ await c.var.services.navItems.reorder(body.ids);
56
37
  const items = await c.var.services.navItems.list();
57
38
  return c.json({ navItems: items });
58
39
  });
59
40
 
60
41
  // Create nav item (requires auth)
61
42
  navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
62
- const rawBody = await c.req.json();
63
-
64
- const parseResult = CreateNavItemSchema.safeParse(rawBody);
65
- if (!parseResult.success) {
66
- return c.json(
67
- { error: "Validation failed", details: parseResult.error.flatten() },
68
- 400,
69
- );
70
- }
71
-
72
- const body = parseResult.data;
43
+ const body = parseValidated(CreateNavItemSchema, await c.req.json());
73
44
 
74
45
  const item = await c.var.services.navItems.create({
75
46
  type: body.type as NavItemType,
@@ -84,32 +55,23 @@ navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
84
55
 
85
56
  // Update nav item (requires auth)
86
57
  navItemsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
87
- const id = parseInt(c.req.param("id"), 10);
88
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
89
-
90
- const rawBody = await c.req.json();
91
-
92
- const parseResult = UpdateNavItemSchema.safeParse(rawBody);
93
- if (!parseResult.success) {
94
- return c.json(
95
- { error: "Validation failed", details: parseResult.error.flatten() },
96
- 400,
97
- );
98
- }
58
+ const id = parseIntParam(c.req.param("id"));
59
+ const body = parseValidated(UpdateNavItemSchema, await c.req.json());
99
60
 
100
- const item = await c.var.services.navItems.update(id, parseResult.data);
101
- if (!item) return c.json({ error: "Not found" }, 404);
61
+ const item = assertFound(
62
+ await c.var.services.navItems.update(id, body),
63
+ "Nav item",
64
+ );
102
65
 
103
66
  return c.json(item);
104
67
  });
105
68
 
106
69
  // Delete nav item (requires auth)
107
70
  navItemsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
108
- const id = parseInt(c.req.param("id"), 10);
109
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
71
+ const id = parseIntParam(c.req.param("id"));
110
72
 
111
73
  const success = await c.var.services.navItems.delete(id);
112
- if (!success) return c.json({ error: "Not found" }, 404);
74
+ if (!success) throw new NotFoundError("Nav item");
113
75
 
114
76
  return c.json({ success: true });
115
77
  });