@jant/core 0.3.26 → 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 (314) 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 +112 -173
  9. package/src/auth.ts +4 -1
  10. package/src/client.ts +13 -0
  11. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  12. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  13. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  14. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  15. package/src/db/schema.ts +24 -4
  16. package/src/i18n/locales/en.po +810 -385
  17. package/src/i18n/locales/en.ts +1 -1
  18. package/src/i18n/locales/zh-Hans.po +733 -522
  19. package/src/i18n/locales/zh-Hans.ts +1 -1
  20. package/src/i18n/locales/zh-Hant.po +733 -522
  21. package/src/i18n/locales/zh-Hant.ts +1 -1
  22. package/src/i18n/middleware.ts +7 -11
  23. package/src/index.ts +1 -1
  24. package/src/lib/__tests__/icons.test.ts +178 -0
  25. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  26. package/src/lib/__tests__/schemas.test.ts +12 -6
  27. package/src/lib/__tests__/theme.test.ts +62 -0
  28. package/src/lib/__tests__/timezones.test.ts +1 -1
  29. package/src/lib/__tests__/url.test.ts +12 -0
  30. package/src/lib/__tests__/view.test.ts +1 -5
  31. package/src/lib/avatar-upload.ts +18 -10
  32. package/src/lib/collection-form-bridge.ts +52 -0
  33. package/src/lib/collections-reorder.ts +28 -0
  34. package/src/lib/compose-bridge.ts +251 -0
  35. package/src/lib/errors.ts +116 -0
  36. package/src/lib/excerpt.ts +1 -1
  37. package/src/lib/favicon.ts +3 -5
  38. package/src/lib/html.ts +22 -0
  39. package/src/lib/icon-catalog.ts +181 -0
  40. package/src/lib/icons.ts +202 -0
  41. package/src/lib/navigation.ts +18 -33
  42. package/src/lib/pagination.ts +3 -2
  43. package/src/lib/post-form-bridge.ts +136 -0
  44. package/src/lib/render.tsx +11 -4
  45. package/src/lib/resolve-config.ts +157 -0
  46. package/src/lib/schemas.ts +76 -12
  47. package/src/lib/settings-bridge.ts +139 -0
  48. package/src/lib/storage.ts +37 -16
  49. package/src/lib/theme.ts +5 -7
  50. package/src/lib/timeline.ts +4 -8
  51. package/src/lib/toast.ts +134 -0
  52. package/src/lib/upload.ts +71 -0
  53. package/src/lib/url.ts +9 -1
  54. package/src/lib/version.ts +16 -0
  55. package/src/lib/view.ts +9 -10
  56. package/src/middleware/__tests__/auth.test.ts +6 -28
  57. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  58. package/src/middleware/auth.ts +6 -12
  59. package/src/middleware/config.ts +51 -0
  60. package/src/middleware/error-handler.ts +56 -0
  61. package/src/middleware/onboarding.ts +1 -1
  62. package/src/preset.css +6 -0
  63. package/src/routes/__tests__/compose.test.ts +104 -17
  64. package/src/routes/api/__tests__/collections.test.ts +93 -2
  65. package/src/routes/api/__tests__/posts.test.ts +2 -1
  66. package/src/routes/api/__tests__/settings.test.ts +1 -1
  67. package/src/routes/api/collections.ts +64 -68
  68. package/src/routes/api/nav-items.ts +21 -59
  69. package/src/routes/api/pages.ts +18 -46
  70. package/src/routes/api/posts.ts +64 -86
  71. package/src/routes/api/search.ts +6 -4
  72. package/src/routes/api/settings.ts +8 -24
  73. package/src/routes/api/upload.ts +55 -53
  74. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  75. package/src/routes/auth/reset.tsx +17 -66
  76. package/src/routes/auth/setup.tsx +67 -11
  77. package/src/routes/auth/signin.tsx +44 -8
  78. package/src/routes/compose.tsx +194 -0
  79. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  80. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  81. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  82. package/src/routes/dash/appearance.tsx +173 -0
  83. package/src/routes/dash/collections.tsx +80 -14
  84. package/src/routes/dash/index.tsx +12 -14
  85. package/src/routes/dash/media.tsx +46 -49
  86. package/src/routes/dash/pages.tsx +85 -37
  87. package/src/routes/dash/posts.tsx +60 -23
  88. package/src/routes/dash/redirects.tsx +43 -33
  89. package/src/routes/dash/settings.tsx +234 -214
  90. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  91. package/src/routes/feed/rss.ts +11 -16
  92. package/src/routes/feed/sitemap.ts +15 -9
  93. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  94. package/src/routes/pages/archive.tsx +2 -2
  95. package/src/routes/pages/collection.tsx +76 -9
  96. package/src/routes/pages/collections.tsx +3 -1
  97. package/src/routes/pages/featured.tsx +2 -2
  98. package/src/routes/pages/home.tsx +3 -3
  99. package/src/routes/pages/latest.tsx +2 -2
  100. package/src/routes/pages/page.tsx +2 -2
  101. package/src/routes/pages/post.tsx +2 -2
  102. package/src/routes/pages/search.tsx +2 -2
  103. package/src/services/__tests__/collection.test.ts +324 -34
  104. package/src/services/__tests__/media.test.ts +1 -1
  105. package/src/services/__tests__/page.test.ts +116 -1
  106. package/src/services/auth.ts +88 -0
  107. package/src/services/collection.ts +169 -30
  108. package/src/services/index.ts +8 -3
  109. package/src/services/media.ts +39 -12
  110. package/src/services/navigation.ts +17 -5
  111. package/src/services/page.ts +24 -4
  112. package/src/services/post.ts +87 -19
  113. package/src/services/search.ts +0 -1
  114. package/src/services/settings.ts +21 -13
  115. package/src/style.css +3 -0
  116. package/src/styles/components.css +42 -1
  117. package/src/styles/tokens.css +4 -0
  118. package/src/styles/ui.css +902 -73
  119. package/src/types/app-context.ts +25 -0
  120. package/src/types/bindings.ts +1 -0
  121. package/src/types/config.ts +60 -23
  122. package/src/types/entities.ts +12 -2
  123. package/src/types/lingui-react-macro.d.ts +3 -3
  124. package/src/types/operations.ts +2 -4
  125. package/src/types/views.ts +1 -3
  126. package/src/ui/__tests__/font-themes.test.ts +27 -8
  127. package/src/ui/color-themes.ts +1 -1
  128. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  129. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  130. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  131. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  132. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  133. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  134. package/src/ui/components/collection-types.ts +45 -0
  135. package/src/ui/components/compose-types.ts +75 -0
  136. package/src/ui/components/jant-collection-form.ts +512 -0
  137. package/src/ui/components/jant-compose-dialog.ts +494 -0
  138. package/src/ui/components/jant-compose-editor.ts +799 -0
  139. package/src/ui/components/jant-post-form.ts +290 -0
  140. package/src/ui/components/jant-settings-avatar.ts +231 -0
  141. package/src/ui/components/jant-settings-general.ts +436 -0
  142. package/src/ui/components/post-form-template.ts +260 -0
  143. package/src/ui/components/post-form-types.ts +87 -0
  144. package/src/ui/components/settings-types.ts +62 -0
  145. package/src/ui/compose/ComposeDialog.tsx +141 -385
  146. package/src/ui/compose/ComposePrompt.tsx +3 -3
  147. package/src/ui/dash/PostList.tsx +55 -61
  148. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  149. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  150. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  151. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  152. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  153. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  154. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  155. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  156. package/src/ui/dash/index.ts +1 -1
  157. package/src/ui/dash/posts/PostForm.tsx +248 -0
  158. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  159. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  160. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  161. package/src/ui/font-themes.ts +115 -32
  162. package/src/ui/layouts/BaseLayout.tsx +49 -19
  163. package/src/ui/layouts/DashLayout.tsx +14 -9
  164. package/src/ui/layouts/SiteLayout.tsx +38 -23
  165. package/src/ui/pages/CollectionPage.tsx +12 -2
  166. package/src/ui/pages/CollectionsPage.tsx +27 -27
  167. package/src/ui/pages/HomePage.tsx +15 -6
  168. package/src/ui/pages/SearchPage.tsx +1 -2
  169. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  170. package/src/ui/shared/Pagination.tsx +2 -2
  171. package/dist/app.js +0 -265
  172. package/dist/auth.js +0 -36
  173. package/dist/client.js +0 -13
  174. package/dist/db/index.js +0 -10
  175. package/dist/db/schema.js +0 -224
  176. package/dist/i18n/Trans.js +0 -24
  177. package/dist/i18n/context.js +0 -58
  178. package/dist/i18n/detect.js +0 -26
  179. package/dist/i18n/i18n.js +0 -49
  180. package/dist/i18n/index.js +0 -44
  181. package/dist/i18n/locales/en.js +0 -1
  182. package/dist/i18n/locales/zh-Hans.js +0 -1
  183. package/dist/i18n/locales/zh-Hant.js +0 -1
  184. package/dist/i18n/locales.js +0 -13
  185. package/dist/i18n/middleware.js +0 -30
  186. package/dist/lib/avatar-upload.js +0 -134
  187. package/dist/lib/config.js +0 -143
  188. package/dist/lib/constants.js +0 -50
  189. package/dist/lib/excerpt.js +0 -76
  190. package/dist/lib/favicon.js +0 -102
  191. package/dist/lib/feed.js +0 -123
  192. package/dist/lib/image-processor.js +0 -187
  193. package/dist/lib/image.js +0 -97
  194. package/dist/lib/index.js +0 -7
  195. package/dist/lib/markdown.js +0 -83
  196. package/dist/lib/media-helpers.js +0 -49
  197. package/dist/lib/media-upload.js +0 -104
  198. package/dist/lib/nav-reorder.js +0 -27
  199. package/dist/lib/navigation.js +0 -79
  200. package/dist/lib/pagination.js +0 -44
  201. package/dist/lib/render.js +0 -53
  202. package/dist/lib/schemas.js +0 -174
  203. package/dist/lib/sqid.js +0 -72
  204. package/dist/lib/sse.js +0 -218
  205. package/dist/lib/storage.js +0 -164
  206. package/dist/lib/theme.js +0 -65
  207. package/dist/lib/time.js +0 -159
  208. package/dist/lib/timeline.js +0 -95
  209. package/dist/lib/timezones.js +0 -388
  210. package/dist/lib/url.js +0 -89
  211. package/dist/lib/view.js +0 -217
  212. package/dist/middleware/auth.js +0 -52
  213. package/dist/middleware/onboarding.js +0 -41
  214. package/dist/routes/api/collections.js +0 -124
  215. package/dist/routes/api/nav-items.js +0 -104
  216. package/dist/routes/api/pages.js +0 -91
  217. package/dist/routes/api/posts.js +0 -218
  218. package/dist/routes/api/search.js +0 -48
  219. package/dist/routes/api/settings.js +0 -68
  220. package/dist/routes/api/upload.js +0 -246
  221. package/dist/routes/auth/reset.js +0 -221
  222. package/dist/routes/auth/setup.js +0 -194
  223. package/dist/routes/auth/signin.js +0 -176
  224. package/dist/routes/compose.js +0 -48
  225. package/dist/routes/dash/collections.js +0 -115
  226. package/dist/routes/dash/index.js +0 -118
  227. package/dist/routes/dash/media.js +0 -106
  228. package/dist/routes/dash/pages.js +0 -294
  229. package/dist/routes/dash/posts.js +0 -244
  230. package/dist/routes/dash/redirects.js +0 -257
  231. package/dist/routes/dash/settings.js +0 -379
  232. package/dist/routes/feed/rss.js +0 -62
  233. package/dist/routes/feed/sitemap.js +0 -49
  234. package/dist/routes/pages/archive.js +0 -62
  235. package/dist/routes/pages/collection.js +0 -34
  236. package/dist/routes/pages/collections.js +0 -28
  237. package/dist/routes/pages/featured.js +0 -36
  238. package/dist/routes/pages/home.js +0 -64
  239. package/dist/routes/pages/latest.js +0 -45
  240. package/dist/routes/pages/page.js +0 -68
  241. package/dist/routes/pages/post.js +0 -44
  242. package/dist/routes/pages/search.js +0 -54
  243. package/dist/services/collection.js +0 -109
  244. package/dist/services/index.js +0 -24
  245. package/dist/services/media.js +0 -117
  246. package/dist/services/navigation.js +0 -91
  247. package/dist/services/page.js +0 -84
  248. package/dist/services/post.js +0 -229
  249. package/dist/services/redirect.js +0 -48
  250. package/dist/services/search.js +0 -67
  251. package/dist/services/settings.js +0 -68
  252. package/dist/types/bindings.js +0 -3
  253. package/dist/types/config.js +0 -147
  254. package/dist/types/constants.js +0 -27
  255. package/dist/types/entities.js +0 -3
  256. package/dist/types/lingui-react-macro.d.js +0 -9
  257. package/dist/types/operations.js +0 -3
  258. package/dist/types/props.js +0 -3
  259. package/dist/types/sortablejs.d.js +0 -5
  260. package/dist/types/views.js +0 -5
  261. package/dist/types.js +0 -11
  262. package/dist/ui/color-themes.js +0 -268
  263. package/dist/ui/compose/ComposeDialog.js +0 -467
  264. package/dist/ui/compose/ComposePrompt.js +0 -55
  265. package/dist/ui/dash/ActionButtons.js +0 -46
  266. package/dist/ui/dash/CrudPageHeader.js +0 -22
  267. package/dist/ui/dash/DangerZone.js +0 -36
  268. package/dist/ui/dash/FormatBadge.js +0 -27
  269. package/dist/ui/dash/ListItemRow.js +0 -21
  270. package/dist/ui/dash/PageForm.js +0 -195
  271. package/dist/ui/dash/PostForm.js +0 -395
  272. package/dist/ui/dash/PostList.js +0 -83
  273. package/dist/ui/dash/StatusBadge.js +0 -46
  274. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  275. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  276. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  277. package/dist/ui/dash/index.js +0 -10
  278. package/dist/ui/dash/media/MediaListContent.js +0 -166
  279. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  280. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  281. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  282. package/dist/ui/dash/settings/AccountContent.js +0 -209
  283. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  284. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  285. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  286. package/dist/ui/feed/LinkCard.js +0 -72
  287. package/dist/ui/feed/NoteCard.js +0 -58
  288. package/dist/ui/feed/QuoteCard.js +0 -63
  289. package/dist/ui/feed/ThreadPreview.js +0 -48
  290. package/dist/ui/feed/TimelineFeed.js +0 -41
  291. package/dist/ui/feed/TimelineItem.js +0 -27
  292. package/dist/ui/font-themes.js +0 -36
  293. package/dist/ui/layouts/BaseLayout.js +0 -153
  294. package/dist/ui/layouts/DashLayout.js +0 -141
  295. package/dist/ui/layouts/SiteLayout.js +0 -169
  296. package/dist/ui/pages/ArchivePage.js +0 -143
  297. package/dist/ui/pages/CollectionPage.js +0 -70
  298. package/dist/ui/pages/CollectionsPage.js +0 -76
  299. package/dist/ui/pages/FeaturedPage.js +0 -24
  300. package/dist/ui/pages/HomePage.js +0 -24
  301. package/dist/ui/pages/PostPage.js +0 -55
  302. package/dist/ui/pages/SearchPage.js +0 -122
  303. package/dist/ui/pages/SinglePage.js +0 -23
  304. package/dist/ui/shared/EmptyState.js +0 -27
  305. package/dist/ui/shared/MediaGallery.js +0 -35
  306. package/dist/ui/shared/Pagination.js +0 -195
  307. package/dist/ui/shared/ThreadView.js +0 -108
  308. package/dist/ui/shared/index.js +0 -5
  309. package/dist/vendor/datastar.js +0 -1606
  310. package/src/lib/__tests__/config.test.ts +0 -192
  311. package/src/lib/config.ts +0 -167
  312. package/src/routes/compose.ts +0 -63
  313. package/src/ui/dash/PostForm.tsx +0 -360
  314. package/src/ui/dash/settings/AppearanceContent.tsx +0 -254
@@ -29,7 +29,6 @@ describe("CollectionService", () => {
29
29
  expect(collection.description).toBeNull();
30
30
  expect(collection.icon).toBeNull();
31
31
  expect(collection.sortOrder).toBe("newest");
32
- expect(collection.showDivider).toBe(0);
33
32
  });
34
33
 
35
34
  it("creates a collection with all fields", async () => {
@@ -40,7 +39,6 @@ describe("CollectionService", () => {
40
39
  icon: "laptop",
41
40
  sortOrder: "oldest",
42
41
  position: 5,
43
- showDivider: true,
44
42
  });
45
43
 
46
44
  expect(collection.slug).toBe("tech");
@@ -49,7 +47,6 @@ describe("CollectionService", () => {
49
47
  expect(collection.icon).toBe("laptop");
50
48
  expect(collection.sortOrder).toBe("oldest");
51
49
  expect(collection.position).toBe(5);
52
- expect(collection.showDivider).toBe(1);
53
50
  });
54
51
 
55
52
  it("sets timestamps", async () => {
@@ -214,7 +211,7 @@ describe("CollectionService", () => {
214
211
  expect(updated?.icon).toBeNull();
215
212
  });
216
213
 
217
- it("updates icon, sortOrder, position, and showDivider", async () => {
214
+ it("updates icon, sortOrder, and position", async () => {
218
215
  const collection = await collectionService.create({
219
216
  slug: "test",
220
217
  title: "Test",
@@ -224,13 +221,11 @@ describe("CollectionService", () => {
224
221
  icon: "rocket",
225
222
  sortOrder: "rating_desc",
226
223
  position: 10,
227
- showDivider: true,
228
224
  });
229
225
 
230
226
  expect(updated?.icon).toBe("rocket");
231
227
  expect(updated?.sortOrder).toBe("rating_desc");
232
228
  expect(updated?.position).toBe(10);
233
- expect(updated?.showDivider).toBe(1);
234
229
  });
235
230
 
236
231
  it("updates updatedAt timestamp", async () => {
@@ -266,7 +261,7 @@ describe("CollectionService", () => {
266
261
  expect(found).toBeNull();
267
262
  });
268
263
 
269
- it("clears collectionId on related posts", async () => {
264
+ it("removes junction table entries on cascade", async () => {
270
265
  const collection = await collectionService.create({
271
266
  slug: "test",
272
267
  title: "Test",
@@ -274,15 +269,23 @@ describe("CollectionService", () => {
274
269
  const post = await postService.create({
275
270
  format: "note",
276
271
  body: "test post",
277
- collectionId: collection.id,
278
272
  });
279
273
 
274
+ await collectionService.addPost(collection.id, post.id);
275
+
276
+ // Verify association exists
277
+ const before = await collectionService.getCollectionsByPostId(post.id);
278
+ expect(before).toHaveLength(1);
279
+
280
280
  await collectionService.delete(collection.id);
281
281
 
282
- // Post itself should still exist but with null collectionId
282
+ // Post should still exist
283
283
  const found = await postService.getById(post.id);
284
284
  expect(found).not.toBeNull();
285
- expect(found?.collectionId).toBeNull();
285
+
286
+ // Association should be gone (cascade delete)
287
+ const after = await collectionService.getCollectionsByPostId(post.id);
288
+ expect(after).toHaveLength(0);
286
289
  });
287
290
 
288
291
  it("returns false for non-existent collection", async () => {
@@ -358,21 +361,13 @@ describe("CollectionService", () => {
358
361
  title: "Col 2",
359
362
  });
360
363
 
361
- await postService.create({
362
- format: "note",
363
- body: "post 1",
364
- collectionId: col1.id,
365
- });
366
- await postService.create({
367
- format: "note",
368
- body: "post 2",
369
- collectionId: col1.id,
370
- });
371
- await postService.create({
372
- format: "note",
373
- body: "post 3",
374
- collectionId: col2.id,
375
- });
364
+ const p1 = await postService.create({ format: "note", body: "post 1" });
365
+ const p2 = await postService.create({ format: "note", body: "post 2" });
366
+ const p3 = await postService.create({ format: "note", body: "post 3" });
367
+
368
+ await collectionService.addPost(col1.id, p1.id);
369
+ await collectionService.addPost(col1.id, p2.id);
370
+ await collectionService.addPost(col2.id, p3.id);
376
371
 
377
372
  const counts = await collectionService.getPostCounts();
378
373
  expect(counts.get(col1.id)).toBe(2);
@@ -385,15 +380,13 @@ describe("CollectionService", () => {
385
380
  title: "Col",
386
381
  });
387
382
 
388
- await postService.create({
383
+ const p1 = await postService.create({
389
384
  format: "note",
390
385
  body: "with collection",
391
- collectionId: col.id,
392
- });
393
- await postService.create({
394
- format: "note",
395
- body: "no collection",
396
386
  });
387
+ await postService.create({ format: "note", body: "no collection" });
388
+
389
+ await collectionService.addPost(col.id, p1.id);
397
390
 
398
391
  const counts = await collectionService.getPostCounts();
399
392
  expect(counts.get(col.id)).toBe(1);
@@ -409,14 +402,15 @@ describe("CollectionService", () => {
409
402
  const post = await postService.create({
410
403
  format: "note",
411
404
  body: "will be deleted",
412
- collectionId: col.id,
413
405
  });
414
- await postService.create({
406
+ const post2 = await postService.create({
415
407
  format: "note",
416
408
  body: "still alive",
417
- collectionId: col.id,
418
409
  });
419
410
 
411
+ await collectionService.addPost(col.id, post.id);
412
+ await collectionService.addPost(col.id, post2.id);
413
+
420
414
  // Soft-delete one post
421
415
  await postService.delete(post.id);
422
416
 
@@ -424,4 +418,300 @@ describe("CollectionService", () => {
424
418
  expect(counts.get(col.id)).toBe(1);
425
419
  });
426
420
  });
421
+
422
+ describe("addPost / removePost", () => {
423
+ it("adds a post to a collection", async () => {
424
+ const col = await collectionService.create({
425
+ slug: "test",
426
+ title: "Test",
427
+ });
428
+ const post = await postService.create({
429
+ format: "note",
430
+ body: "test",
431
+ });
432
+
433
+ await collectionService.addPost(col.id, post.id);
434
+
435
+ const collections = await collectionService.getCollectionsByPostId(
436
+ post.id,
437
+ );
438
+ expect(collections).toHaveLength(1);
439
+ expect(collections[0]?.id).toBe(col.id);
440
+ });
441
+
442
+ it("does not duplicate on re-add", async () => {
443
+ const col = await collectionService.create({
444
+ slug: "test",
445
+ title: "Test",
446
+ });
447
+ const post = await postService.create({
448
+ format: "note",
449
+ body: "test",
450
+ });
451
+
452
+ await collectionService.addPost(col.id, post.id);
453
+ await collectionService.addPost(col.id, post.id); // duplicate
454
+
455
+ const postIds = await collectionService.getPostIds(col.id);
456
+ expect(postIds).toHaveLength(1);
457
+ });
458
+
459
+ it("removes a post from a collection", async () => {
460
+ const col = await collectionService.create({
461
+ slug: "test",
462
+ title: "Test",
463
+ });
464
+ const post = await postService.create({
465
+ format: "note",
466
+ body: "test",
467
+ });
468
+
469
+ await collectionService.addPost(col.id, post.id);
470
+ await collectionService.removePost(col.id, post.id);
471
+
472
+ const collections = await collectionService.getCollectionsByPostId(
473
+ post.id,
474
+ );
475
+ expect(collections).toHaveLength(0);
476
+ });
477
+ });
478
+
479
+ describe("getCollectionsByPostId", () => {
480
+ it("returns all collections a post belongs to", async () => {
481
+ const col1 = await collectionService.create({
482
+ slug: "col1",
483
+ title: "Col 1",
484
+ position: 0,
485
+ });
486
+ const col2 = await collectionService.create({
487
+ slug: "col2",
488
+ title: "Col 2",
489
+ position: 1,
490
+ });
491
+
492
+ const post = await postService.create({
493
+ format: "note",
494
+ body: "test",
495
+ });
496
+
497
+ await collectionService.addPost(col1.id, post.id);
498
+ await collectionService.addPost(col2.id, post.id);
499
+
500
+ const collections = await collectionService.getCollectionsByPostId(
501
+ post.id,
502
+ );
503
+ expect(collections).toHaveLength(2);
504
+ expect(collections[0]?.slug).toBe("col1");
505
+ expect(collections[1]?.slug).toBe("col2");
506
+ });
507
+
508
+ it("returns empty array for post with no collections", async () => {
509
+ const post = await postService.create({
510
+ format: "note",
511
+ body: "test",
512
+ });
513
+
514
+ const collections = await collectionService.getCollectionsByPostId(
515
+ post.id,
516
+ );
517
+ expect(collections).toHaveLength(0);
518
+ });
519
+ });
520
+
521
+ describe("getPostIds", () => {
522
+ it("returns all post IDs in a collection", async () => {
523
+ const col = await collectionService.create({
524
+ slug: "test",
525
+ title: "Test",
526
+ });
527
+ const p1 = await postService.create({ format: "note", body: "one" });
528
+ const p2 = await postService.create({ format: "note", body: "two" });
529
+
530
+ await collectionService.addPost(col.id, p1.id);
531
+ await collectionService.addPost(col.id, p2.id);
532
+
533
+ const ids = await collectionService.getPostIds(col.id);
534
+ expect(ids).toHaveLength(2);
535
+ expect(ids).toContain(p1.id);
536
+ expect(ids).toContain(p2.id);
537
+ });
538
+ });
539
+
540
+ describe("createDivider", () => {
541
+ it("creates a divider with auto-assigned position", async () => {
542
+ const divider = await collectionService.createDivider();
543
+
544
+ expect(divider.id).toBe(1);
545
+ expect(divider.position).toBe(0);
546
+ expect(divider.createdAt).toBeGreaterThan(0);
547
+ expect(divider.updatedAt).toBeGreaterThan(0);
548
+ });
549
+
550
+ it("assigns position after existing collections", async () => {
551
+ await collectionService.create({ slug: "a", title: "A" }); // position 0
552
+ await collectionService.create({ slug: "b", title: "B" }); // position 1
553
+
554
+ const divider = await collectionService.createDivider();
555
+ expect(divider.position).toBe(2);
556
+ });
557
+
558
+ it("assigns position after existing dividers", async () => {
559
+ const d1 = await collectionService.createDivider(); // position 0
560
+ const d2 = await collectionService.createDivider(); // position 1
561
+
562
+ expect(d1.position).toBe(0);
563
+ expect(d2.position).toBe(1);
564
+ });
565
+
566
+ it("considers both collections and dividers for position", async () => {
567
+ await collectionService.create({ slug: "a", title: "A" }); // position 0
568
+ await collectionService.createDivider(); // position 1
569
+ await collectionService.create({ slug: "b", title: "B" }); // position 2
570
+
571
+ const divider = await collectionService.createDivider();
572
+ expect(divider.position).toBe(3);
573
+ });
574
+ });
575
+
576
+ describe("deleteDivider", () => {
577
+ it("deletes a divider by ID", async () => {
578
+ const divider = await collectionService.createDivider();
579
+
580
+ const result = await collectionService.deleteDivider(divider.id);
581
+ expect(result).toBe(true);
582
+
583
+ const list = await collectionService.listDividers();
584
+ expect(list).toHaveLength(0);
585
+ });
586
+
587
+ it("returns false for non-existent divider", async () => {
588
+ const result = await collectionService.deleteDivider(9999);
589
+ expect(result).toBe(false);
590
+ });
591
+ });
592
+
593
+ describe("listDividers", () => {
594
+ it("returns empty array when no dividers exist", async () => {
595
+ const list = await collectionService.listDividers();
596
+ expect(list).toEqual([]);
597
+ });
598
+
599
+ it("returns dividers ordered by position", async () => {
600
+ const d1 = await collectionService.createDivider();
601
+ const d2 = await collectionService.createDivider();
602
+
603
+ const list = await collectionService.listDividers();
604
+ expect(list).toHaveLength(2);
605
+ expect(list[0]?.id).toBe(d1.id);
606
+ expect(list[1]?.id).toBe(d2.id);
607
+ });
608
+ });
609
+
610
+ describe("reorderAll", () => {
611
+ it("handles mixed prefixed IDs correctly", async () => {
612
+ const a = await collectionService.create({ slug: "a", title: "A" });
613
+ const b = await collectionService.create({ slug: "b", title: "B" });
614
+ const d1 = await collectionService.createDivider();
615
+
616
+ // Reorder: divider first, then B, then A
617
+ await collectionService.reorderAll([
618
+ `d-${d1.id}`,
619
+ `c-${b.id}`,
620
+ `c-${a.id}`,
621
+ ]);
622
+
623
+ const dividers = await collectionService.listDividers();
624
+ expect(dividers[0]?.position).toBe(0);
625
+
626
+ const colB = await collectionService.getById(b.id);
627
+ const colA = await collectionService.getById(a.id);
628
+ expect(colB?.position).toBe(1);
629
+ expect(colA?.position).toBe(2);
630
+ });
631
+
632
+ it("handles empty array", async () => {
633
+ await collectionService.reorderAll([]);
634
+ // Should not throw
635
+ const list = await collectionService.list();
636
+ expect(list).toEqual([]);
637
+ });
638
+
639
+ it("reflects new order in combined list", async () => {
640
+ const a = await collectionService.create({ slug: "a", title: "A" });
641
+ const d1 = await collectionService.createDivider();
642
+ const b = await collectionService.create({ slug: "b", title: "B" });
643
+
644
+ // Put divider between B and A
645
+ await collectionService.reorderAll([
646
+ `c-${b.id}`,
647
+ `d-${d1.id}`,
648
+ `c-${a.id}`,
649
+ ]);
650
+
651
+ const cols = await collectionService.list();
652
+ const divs = await collectionService.listDividers();
653
+
654
+ // B at position 0, divider at 1, A at 2
655
+ expect(cols.find((c) => c.id === b.id)?.position).toBe(0);
656
+ expect(divs[0]?.position).toBe(1);
657
+ expect(cols.find((c) => c.id === a.id)?.position).toBe(2);
658
+ });
659
+ });
660
+
661
+ describe("syncPostCollections", () => {
662
+ it("replaces all collection memberships for a post", async () => {
663
+ const col1 = await collectionService.create({
664
+ slug: "col1",
665
+ title: "Col 1",
666
+ });
667
+ const col2 = await collectionService.create({
668
+ slug: "col2",
669
+ title: "Col 2",
670
+ });
671
+ const col3 = await collectionService.create({
672
+ slug: "col3",
673
+ title: "Col 3",
674
+ });
675
+
676
+ const post = await postService.create({
677
+ format: "note",
678
+ body: "test",
679
+ });
680
+
681
+ // Initially in col1 and col2
682
+ await collectionService.addPost(col1.id, post.id);
683
+ await collectionService.addPost(col2.id, post.id);
684
+
685
+ // Sync to col2 and col3
686
+ await collectionService.syncPostCollections(post.id, [col2.id, col3.id]);
687
+
688
+ const collections = await collectionService.getCollectionsByPostId(
689
+ post.id,
690
+ );
691
+ const ids = collections.map((c) => c.id);
692
+ expect(ids).toHaveLength(2);
693
+ expect(ids).toContain(col2.id);
694
+ expect(ids).toContain(col3.id);
695
+ expect(ids).not.toContain(col1.id);
696
+ });
697
+
698
+ it("removes all collections when empty array provided", async () => {
699
+ const col = await collectionService.create({
700
+ slug: "test",
701
+ title: "Test",
702
+ });
703
+ const post = await postService.create({
704
+ format: "note",
705
+ body: "test",
706
+ });
707
+
708
+ await collectionService.addPost(col.id, post.id);
709
+ await collectionService.syncPostCollections(post.id, []);
710
+
711
+ const collections = await collectionService.getCollectionsByPostId(
712
+ post.id,
713
+ );
714
+ expect(collections).toHaveLength(0);
715
+ });
716
+ });
427
717
  });
@@ -301,7 +301,7 @@ describe("MediaService", () => {
301
301
  });
302
302
  }
303
303
 
304
- const list = await mediaService.list(2);
304
+ const list = await mediaService.list({ limit: 2 });
305
305
  expect(list).toHaveLength(2);
306
306
  });
307
307
  });
@@ -92,7 +92,9 @@ describe("PageService", () => {
92
92
  type: "page",
93
93
  label: "Second",
94
94
  url: "/second",
95
- pageId: pages.find((p) => p.slug === "second")!.id,
95
+ pageId: (
96
+ pages.find((p) => p.slug === "second") as (typeof pages)[number]
97
+ ).id,
96
98
  });
97
99
 
98
100
  const notInNav = await pageService.listNotInNav();
@@ -103,4 +105,117 @@ describe("PageService", () => {
103
105
  expect(slugs).not.toContain("second");
104
106
  });
105
107
  });
108
+
109
+ describe("update nav item sync", () => {
110
+ it("syncs nav item label when page title changes", async () => {
111
+ const page = await pageService.create({
112
+ slug: "about",
113
+ title: "About",
114
+ });
115
+ await navItemService.create({
116
+ type: "page",
117
+ label: "About",
118
+ url: "/about",
119
+ pageId: page.id,
120
+ });
121
+
122
+ await pageService.update(page.id, { title: "About Us" });
123
+
124
+ const navs = await navItemService.list();
125
+ expect(navs).toHaveLength(1);
126
+ expect(navs[0]?.label).toBe("About Us");
127
+ });
128
+
129
+ it("syncs nav item url when page slug changes", async () => {
130
+ const page = await pageService.create({
131
+ slug: "about",
132
+ title: "About",
133
+ });
134
+ await navItemService.create({
135
+ type: "page",
136
+ label: "About",
137
+ url: "/about",
138
+ pageId: page.id,
139
+ });
140
+
141
+ await pageService.update(page.id, { slug: "about-us" });
142
+
143
+ const navs = await navItemService.list();
144
+ expect(navs).toHaveLength(1);
145
+ expect(navs[0]?.url).toBe("/about-us");
146
+ });
147
+
148
+ it("syncs both label and url when title and slug change together", async () => {
149
+ const page = await pageService.create({
150
+ slug: "about",
151
+ title: "About",
152
+ });
153
+ await navItemService.create({
154
+ type: "page",
155
+ label: "About",
156
+ url: "/about",
157
+ pageId: page.id,
158
+ });
159
+
160
+ await pageService.update(page.id, {
161
+ title: "About Our Company",
162
+ slug: "about-our-company",
163
+ });
164
+
165
+ const navs = await navItemService.list();
166
+ expect(navs).toHaveLength(1);
167
+ expect(navs[0]?.label).toBe("About Our Company");
168
+ expect(navs[0]?.url).toBe("/about-our-company");
169
+ });
170
+
171
+ it("does not change nav item label when title is unchanged", async () => {
172
+ const page = await pageService.create({
173
+ slug: "about",
174
+ title: "About",
175
+ });
176
+ await navItemService.create({
177
+ type: "page",
178
+ label: "Custom Label",
179
+ url: "/about",
180
+ pageId: page.id,
181
+ });
182
+
183
+ // Update body only, not title
184
+ await pageService.update(page.id, { body: "New content" });
185
+
186
+ const navs = await navItemService.list();
187
+ expect(navs[0]?.label).toBe("Custom Label");
188
+ });
189
+
190
+ it("does not affect nav items for other pages", async () => {
191
+ const page1 = await pageService.create({
192
+ slug: "about",
193
+ title: "About",
194
+ });
195
+ const page2 = await pageService.create({
196
+ slug: "contact",
197
+ title: "Contact",
198
+ });
199
+ await navItemService.create({
200
+ type: "page",
201
+ label: "About",
202
+ url: "/about",
203
+ pageId: page1.id,
204
+ });
205
+ await navItemService.create({
206
+ type: "page",
207
+ label: "Contact",
208
+ url: "/contact",
209
+ pageId: page2.id,
210
+ });
211
+
212
+ await pageService.update(page1.id, { title: "About Us" });
213
+
214
+ const navs = await navItemService.list();
215
+ const aboutNav = navs.find((n) => n.pageId === page1.id);
216
+ const contactNav = navs.find((n) => n.pageId === page2.id);
217
+ expect(aboutNav?.label).toBe("About Us");
218
+ expect(contactNav?.label).toBe("Contact");
219
+ });
220
+ });
106
221
  });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Auth Service
3
+ *
4
+ * Handles authentication-related business logic:
5
+ * password reset token validation, password updates, and session management.
6
+ */
7
+
8
+ import { eq, and } from "drizzle-orm";
9
+ import { hashPassword } from "better-auth/crypto";
10
+ import type { Database } from "../db/index.js";
11
+ import { user, account, session } from "../db/schema.js";
12
+ import type { SettingsService } from "./settings.js";
13
+ import { SETTINGS_KEYS } from "../lib/constants.js";
14
+ import { ValidationError, NotFoundError } from "../lib/errors.js";
15
+
16
+ export interface AuthService {
17
+ /**
18
+ * Validate a password reset token against the stored value.
19
+ *
20
+ * @param token - The reset token from the URL
21
+ * @returns true if the token is valid and not expired
22
+ */
23
+ validateResetToken(token: string): Promise<boolean>;
24
+
25
+ /**
26
+ * Reset the admin user's password.
27
+ *
28
+ * Validates the token, hashes the new password, updates the account,
29
+ * clears all sessions, and removes the reset token.
30
+ *
31
+ * @param token - The reset token (re-validated to prevent TOCTOU)
32
+ * @param newPassword - The new plaintext password
33
+ * @throws {ValidationError} if token is invalid or expired
34
+ * @throws {NotFoundError} if no user account exists
35
+ */
36
+ resetPassword(token: string, newPassword: string): Promise<void>;
37
+ }
38
+
39
+ export function createAuthService(
40
+ db: Database,
41
+ settings: SettingsService,
42
+ ): AuthService {
43
+ async function validateResetToken(token: string): Promise<boolean> {
44
+ const stored = await settings.get(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
45
+ if (!stored) return false;
46
+
47
+ const separatorIndex = stored.lastIndexOf(":");
48
+ const storedToken = stored.substring(0, separatorIndex);
49
+ const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
50
+ const now = Math.floor(Date.now() / 1000);
51
+
52
+ return token === storedToken && now <= expiry;
53
+ }
54
+
55
+ return {
56
+ validateResetToken,
57
+
58
+ async resetPassword(token, newPassword) {
59
+ const isValid = await validateResetToken(token);
60
+ if (!isValid) {
61
+ throw new ValidationError("Invalid or expired reset token");
62
+ }
63
+
64
+ const hashedPw = await hashPassword(newPassword);
65
+
66
+ // Get admin user (single-author system)
67
+ const userResult = await db.select({ id: user.id }).from(user).limit(1);
68
+ if (!userResult[0]) {
69
+ throw new NotFoundError("User account");
70
+ }
71
+ const userId = userResult[0].id;
72
+
73
+ // Update password
74
+ await db
75
+ .update(account)
76
+ .set({ password: hashedPw })
77
+ .where(
78
+ and(eq(account.userId, userId), eq(account.providerId, "credential")),
79
+ );
80
+
81
+ // Clear all sessions
82
+ await db.delete(session).where(eq(session.userId, userId));
83
+
84
+ // Remove the reset token
85
+ await settings.remove(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
86
+ },
87
+ };
88
+ }