@jant/core 0.3.7 → 0.3.9

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 (258) hide show
  1. package/dist/app.js +11 -4
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +15 -1
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/lib/image.js +39 -15
  8. package/dist/lib/media-helpers.js +49 -0
  9. package/dist/lib/nav-reorder.js +27 -0
  10. package/dist/lib/navigation.js +35 -0
  11. package/dist/lib/storage.js +164 -0
  12. package/dist/lib/theme-components.js +49 -0
  13. package/dist/routes/api/posts.js +12 -7
  14. package/dist/routes/api/timeline.js +116 -0
  15. package/dist/routes/api/upload.js +35 -24
  16. package/dist/routes/dash/media.js +24 -14
  17. package/dist/routes/dash/navigation.js +274 -0
  18. package/dist/routes/dash/posts.js +4 -1
  19. package/dist/routes/feed/rss.js +3 -2
  20. package/dist/routes/pages/archive.js +14 -27
  21. package/dist/routes/pages/collection.js +10 -19
  22. package/dist/routes/pages/home.js +84 -126
  23. package/dist/routes/pages/page.js +19 -38
  24. package/dist/routes/pages/post.js +47 -56
  25. package/dist/routes/pages/search.js +13 -26
  26. package/dist/services/index.js +3 -1
  27. package/dist/services/media.js +8 -6
  28. package/dist/services/navigation.js +115 -0
  29. package/dist/services/post.js +26 -1
  30. package/dist/theme/components/PostForm.js +4 -3
  31. package/dist/theme/components/PostList.js +5 -0
  32. package/dist/theme/components/index.js +2 -0
  33. package/dist/theme/components/timeline/ArticleCard.js +50 -0
  34. package/dist/theme/components/timeline/ImageCard.js +86 -0
  35. package/dist/theme/components/timeline/LinkCard.js +62 -0
  36. package/dist/theme/components/timeline/NoteCard.js +37 -0
  37. package/dist/theme/components/timeline/QuoteCard.js +51 -0
  38. package/dist/theme/components/timeline/ThreadPreview.js +52 -0
  39. package/dist/theme/components/timeline/TimelineFeed.js +43 -0
  40. package/dist/theme/components/timeline/TimelineItem.js +25 -0
  41. package/dist/theme/components/timeline/index.js +8 -0
  42. package/dist/theme/layouts/DashLayout.js +8 -0
  43. package/dist/theme/layouts/SiteLayout.js +160 -0
  44. package/dist/theme/layouts/index.js +1 -0
  45. package/dist/types/sortablejs.d.js +5 -0
  46. package/dist/types.js +32 -0
  47. package/package.json +4 -2
  48. package/src/__tests__/helpers/app.ts +1 -0
  49. package/src/__tests__/helpers/db.ts +20 -0
  50. package/src/app.tsx +12 -7
  51. package/src/client.ts +1 -0
  52. package/src/db/migrations/0003_add_navigation_links.sql +8 -0
  53. package/src/db/migrations/0004_add_storage_provider.sql +3 -0
  54. package/src/db/migrations/meta/0003_snapshot.json +821 -0
  55. package/src/db/migrations/meta/_journal.json +21 -0
  56. package/src/db/schema.ts +15 -1
  57. package/src/i18n/locales/en.po +148 -80
  58. package/src/i18n/locales/en.ts +1 -1
  59. package/src/i18n/locales/zh-Hans.po +150 -103
  60. package/src/i18n/locales/zh-Hans.ts +1 -1
  61. package/src/i18n/locales/zh-Hant.po +150 -103
  62. package/src/i18n/locales/zh-Hant.ts +1 -1
  63. package/src/index.ts +5 -0
  64. package/src/lib/__tests__/image.test.ts +96 -0
  65. package/src/lib/__tests__/storage.test.ts +162 -0
  66. package/src/lib/__tests__/theme-components.test.ts +107 -0
  67. package/src/lib/image.ts +46 -16
  68. package/src/lib/media-helpers.ts +65 -0
  69. package/src/lib/nav-reorder.ts +26 -0
  70. package/src/lib/navigation.ts +46 -0
  71. package/src/lib/storage.ts +236 -0
  72. package/src/lib/theme-components.ts +76 -0
  73. package/src/routes/api/__tests__/posts.test.ts +8 -8
  74. package/src/routes/api/__tests__/timeline.test.ts +242 -0
  75. package/src/routes/api/posts.ts +20 -6
  76. package/src/routes/api/timeline.tsx +152 -0
  77. package/src/routes/api/upload.ts +52 -25
  78. package/src/routes/dash/media.tsx +40 -8
  79. package/src/routes/dash/navigation.tsx +306 -0
  80. package/src/routes/dash/posts.tsx +5 -0
  81. package/src/routes/feed/rss.ts +3 -2
  82. package/src/routes/pages/archive.tsx +15 -23
  83. package/src/routes/pages/collection.tsx +8 -15
  84. package/src/routes/pages/home.tsx +118 -122
  85. package/src/routes/pages/page.tsx +17 -30
  86. package/src/routes/pages/post.tsx +63 -60
  87. package/src/routes/pages/search.tsx +18 -22
  88. package/src/services/__tests__/media.test.ts +73 -28
  89. package/src/services/__tests__/navigation.test.ts +213 -0
  90. package/src/services/__tests__/post-timeline.test.ts +220 -0
  91. package/src/services/index.ts +7 -0
  92. package/src/services/media.ts +12 -8
  93. package/src/services/navigation.ts +165 -0
  94. package/src/services/post.ts +48 -1
  95. package/src/styles/components.css +59 -0
  96. package/src/theme/components/PostForm.tsx +13 -2
  97. package/src/theme/components/PostList.tsx +7 -0
  98. package/src/theme/components/index.ts +12 -0
  99. package/src/theme/components/timeline/ArticleCard.tsx +57 -0
  100. package/src/theme/components/timeline/ImageCard.tsx +80 -0
  101. package/src/theme/components/timeline/LinkCard.tsx +66 -0
  102. package/src/theme/components/timeline/NoteCard.tsx +41 -0
  103. package/src/theme/components/timeline/QuoteCard.tsx +55 -0
  104. package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
  105. package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
  106. package/src/theme/components/timeline/TimelineItem.tsx +39 -0
  107. package/src/theme/components/timeline/index.ts +8 -0
  108. package/src/theme/layouts/DashLayout.tsx +10 -0
  109. package/src/theme/layouts/SiteLayout.tsx +184 -0
  110. package/src/theme/layouts/index.ts +1 -0
  111. package/src/types/sortablejs.d.ts +23 -0
  112. package/src/types.ts +102 -1
  113. package/dist/app.d.ts +0 -38
  114. package/dist/app.d.ts.map +0 -1
  115. package/dist/auth.d.ts +0 -25
  116. package/dist/auth.d.ts.map +0 -1
  117. package/dist/db/index.d.ts +0 -10
  118. package/dist/db/index.d.ts.map +0 -1
  119. package/dist/db/schema.d.ts +0 -1543
  120. package/dist/db/schema.d.ts.map +0 -1
  121. package/dist/i18n/Trans.d.ts +0 -25
  122. package/dist/i18n/Trans.d.ts.map +0 -1
  123. package/dist/i18n/context.d.ts +0 -69
  124. package/dist/i18n/context.d.ts.map +0 -1
  125. package/dist/i18n/detect.d.ts +0 -20
  126. package/dist/i18n/detect.d.ts.map +0 -1
  127. package/dist/i18n/i18n.d.ts +0 -32
  128. package/dist/i18n/i18n.d.ts.map +0 -1
  129. package/dist/i18n/index.d.ts +0 -41
  130. package/dist/i18n/index.d.ts.map +0 -1
  131. package/dist/i18n/locales/en.d.ts +0 -3
  132. package/dist/i18n/locales/en.d.ts.map +0 -1
  133. package/dist/i18n/locales/zh-Hans.d.ts +0 -3
  134. package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
  135. package/dist/i18n/locales/zh-Hant.d.ts +0 -3
  136. package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
  137. package/dist/i18n/locales.d.ts +0 -11
  138. package/dist/i18n/locales.d.ts.map +0 -1
  139. package/dist/i18n/middleware.d.ts +0 -21
  140. package/dist/i18n/middleware.d.ts.map +0 -1
  141. package/dist/index.d.ts +0 -16
  142. package/dist/index.d.ts.map +0 -1
  143. package/dist/lib/config.d.ts +0 -83
  144. package/dist/lib/config.d.ts.map +0 -1
  145. package/dist/lib/constants.d.ts +0 -37
  146. package/dist/lib/constants.d.ts.map +0 -1
  147. package/dist/lib/image.d.ts +0 -73
  148. package/dist/lib/image.d.ts.map +0 -1
  149. package/dist/lib/index.d.ts +0 -9
  150. package/dist/lib/index.d.ts.map +0 -1
  151. package/dist/lib/markdown.d.ts +0 -60
  152. package/dist/lib/markdown.d.ts.map +0 -1
  153. package/dist/lib/schemas.d.ts +0 -130
  154. package/dist/lib/schemas.d.ts.map +0 -1
  155. package/dist/lib/sqid.d.ts +0 -60
  156. package/dist/lib/sqid.d.ts.map +0 -1
  157. package/dist/lib/sse.d.ts +0 -192
  158. package/dist/lib/sse.d.ts.map +0 -1
  159. package/dist/lib/theme.d.ts +0 -44
  160. package/dist/lib/theme.d.ts.map +0 -1
  161. package/dist/lib/time.d.ts +0 -90
  162. package/dist/lib/time.d.ts.map +0 -1
  163. package/dist/lib/url.d.ts +0 -82
  164. package/dist/lib/url.d.ts.map +0 -1
  165. package/dist/middleware/auth.d.ts +0 -24
  166. package/dist/middleware/auth.d.ts.map +0 -1
  167. package/dist/middleware/onboarding.d.ts +0 -26
  168. package/dist/middleware/onboarding.d.ts.map +0 -1
  169. package/dist/routes/api/posts.d.ts +0 -13
  170. package/dist/routes/api/posts.d.ts.map +0 -1
  171. package/dist/routes/api/search.d.ts +0 -13
  172. package/dist/routes/api/search.d.ts.map +0 -1
  173. package/dist/routes/api/upload.d.ts +0 -16
  174. package/dist/routes/api/upload.d.ts.map +0 -1
  175. package/dist/routes/dash/collections.d.ts +0 -13
  176. package/dist/routes/dash/collections.d.ts.map +0 -1
  177. package/dist/routes/dash/index.d.ts +0 -15
  178. package/dist/routes/dash/index.d.ts.map +0 -1
  179. package/dist/routes/dash/media.d.ts +0 -16
  180. package/dist/routes/dash/media.d.ts.map +0 -1
  181. package/dist/routes/dash/pages.d.ts +0 -15
  182. package/dist/routes/dash/pages.d.ts.map +0 -1
  183. package/dist/routes/dash/posts.d.ts +0 -13
  184. package/dist/routes/dash/posts.d.ts.map +0 -1
  185. package/dist/routes/dash/redirects.d.ts +0 -13
  186. package/dist/routes/dash/redirects.d.ts.map +0 -1
  187. package/dist/routes/dash/settings.d.ts +0 -15
  188. package/dist/routes/dash/settings.d.ts.map +0 -1
  189. package/dist/routes/feed/rss.d.ts +0 -13
  190. package/dist/routes/feed/rss.d.ts.map +0 -1
  191. package/dist/routes/feed/sitemap.d.ts +0 -13
  192. package/dist/routes/feed/sitemap.d.ts.map +0 -1
  193. package/dist/routes/pages/archive.d.ts +0 -15
  194. package/dist/routes/pages/archive.d.ts.map +0 -1
  195. package/dist/routes/pages/collection.d.ts +0 -13
  196. package/dist/routes/pages/collection.d.ts.map +0 -1
  197. package/dist/routes/pages/home.d.ts +0 -13
  198. package/dist/routes/pages/home.d.ts.map +0 -1
  199. package/dist/routes/pages/page.d.ts +0 -15
  200. package/dist/routes/pages/page.d.ts.map +0 -1
  201. package/dist/routes/pages/post.d.ts +0 -13
  202. package/dist/routes/pages/post.d.ts.map +0 -1
  203. package/dist/routes/pages/search.d.ts +0 -13
  204. package/dist/routes/pages/search.d.ts.map +0 -1
  205. package/dist/services/collection.d.ts +0 -32
  206. package/dist/services/collection.d.ts.map +0 -1
  207. package/dist/services/index.d.ts +0 -28
  208. package/dist/services/index.d.ts.map +0 -1
  209. package/dist/services/media.d.ts +0 -34
  210. package/dist/services/media.d.ts.map +0 -1
  211. package/dist/services/post.d.ts +0 -31
  212. package/dist/services/post.d.ts.map +0 -1
  213. package/dist/services/redirect.d.ts +0 -15
  214. package/dist/services/redirect.d.ts.map +0 -1
  215. package/dist/services/search.d.ts +0 -26
  216. package/dist/services/search.d.ts.map +0 -1
  217. package/dist/services/settings.d.ts +0 -18
  218. package/dist/services/settings.d.ts.map +0 -1
  219. package/dist/theme/color-themes.d.ts +0 -30
  220. package/dist/theme/color-themes.d.ts.map +0 -1
  221. package/dist/theme/components/ActionButtons.d.ts +0 -43
  222. package/dist/theme/components/ActionButtons.d.ts.map +0 -1
  223. package/dist/theme/components/CrudPageHeader.d.ts +0 -23
  224. package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
  225. package/dist/theme/components/DangerZone.d.ts +0 -36
  226. package/dist/theme/components/DangerZone.d.ts.map +0 -1
  227. package/dist/theme/components/EmptyState.d.ts +0 -27
  228. package/dist/theme/components/EmptyState.d.ts.map +0 -1
  229. package/dist/theme/components/ListItemRow.d.ts +0 -15
  230. package/dist/theme/components/ListItemRow.d.ts.map +0 -1
  231. package/dist/theme/components/MediaGallery.d.ts +0 -13
  232. package/dist/theme/components/MediaGallery.d.ts.map +0 -1
  233. package/dist/theme/components/PageForm.d.ts +0 -14
  234. package/dist/theme/components/PageForm.d.ts.map +0 -1
  235. package/dist/theme/components/Pagination.d.ts +0 -46
  236. package/dist/theme/components/Pagination.d.ts.map +0 -1
  237. package/dist/theme/components/PostForm.d.ts +0 -16
  238. package/dist/theme/components/PostForm.d.ts.map +0 -1
  239. package/dist/theme/components/PostList.d.ts +0 -10
  240. package/dist/theme/components/PostList.d.ts.map +0 -1
  241. package/dist/theme/components/ThreadView.d.ts +0 -15
  242. package/dist/theme/components/ThreadView.d.ts.map +0 -1
  243. package/dist/theme/components/TypeBadge.d.ts +0 -12
  244. package/dist/theme/components/TypeBadge.d.ts.map +0 -1
  245. package/dist/theme/components/VisibilityBadge.d.ts +0 -12
  246. package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
  247. package/dist/theme/components/index.d.ts +0 -14
  248. package/dist/theme/components/index.d.ts.map +0 -1
  249. package/dist/theme/index.d.ts +0 -21
  250. package/dist/theme/index.d.ts.map +0 -1
  251. package/dist/theme/layouts/BaseLayout.d.ts +0 -23
  252. package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
  253. package/dist/theme/layouts/DashLayout.d.ts +0 -17
  254. package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
  255. package/dist/theme/layouts/index.d.ts +0 -3
  256. package/dist/theme/layouts/index.d.ts.map +0 -1
  257. package/dist/types.d.ts +0 -237
  258. package/dist/types.d.ts.map +0 -1
@@ -18,11 +18,11 @@ describe("MediaService", () => {
18
18
  });
19
19
 
20
20
  const sampleMedia = {
21
- filename: "image-abc123.jpg",
21
+ filename: "0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
22
22
  originalName: "photo.jpg",
23
23
  mimeType: "image/jpeg",
24
24
  size: 102400,
25
- r2Key: "media/image-abc123.jpg",
25
+ storageKey: "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
26
26
  width: 1920,
27
27
  height: 1080,
28
28
  };
@@ -32,11 +32,14 @@ describe("MediaService", () => {
32
32
  const media = await mediaService.create(sampleMedia);
33
33
 
34
34
  expect(media.id).toBeTruthy(); // UUIDv7
35
- expect(media.filename).toBe("image-abc123.jpg");
35
+ expect(media.filename).toBe("0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg");
36
36
  expect(media.originalName).toBe("photo.jpg");
37
37
  expect(media.mimeType).toBe("image/jpeg");
38
38
  expect(media.size).toBe(102400);
39
- expect(media.r2Key).toBe("media/image-abc123.jpg");
39
+ expect(media.storageKey).toBe(
40
+ "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
41
+ );
42
+ expect(media.provider).toBe("r2");
40
43
  expect(media.width).toBe(1920);
41
44
  expect(media.height).toBe(1080);
42
45
  expect(media.postId).toBeNull();
@@ -54,6 +57,20 @@ describe("MediaService", () => {
54
57
  expect(media.alt).toBe("A beautiful sunset");
55
58
  });
56
59
 
60
+ it("defaults provider to 'r2'", async () => {
61
+ const media = await mediaService.create(sampleMedia);
62
+ expect(media.provider).toBe("r2");
63
+ });
64
+
65
+ it("accepts provider 's3'", async () => {
66
+ const media = await mediaService.create({
67
+ ...sampleMedia,
68
+ storageKey: "media/2025/01/s3-upload.jpg",
69
+ provider: "s3",
70
+ });
71
+ expect(media.provider).toBe("s3");
72
+ });
73
+
57
74
  it("creates media with position and blurhash", async () => {
58
75
  const media = await mediaService.create({
59
76
  ...sampleMedia,
@@ -69,13 +86,36 @@ describe("MediaService", () => {
69
86
  const media1 = await mediaService.create(sampleMedia);
70
87
  const media2 = await mediaService.create({
71
88
  ...sampleMedia,
72
- r2Key: "media/other.jpg",
89
+ storageKey: "media/2025/01/other.jpg",
73
90
  });
74
91
 
75
92
  expect(media1.id).not.toBe(media2.id);
76
93
  // UUIDv7 should be sortable — later ID is lexicographically greater
77
94
  expect(media2.id > media1.id).toBe(true);
78
95
  });
96
+
97
+ it("uses provided id when given", async () => {
98
+ const customId = "0192a9f1-a2b7-7c3d-8e4f-custom000001";
99
+ const media = await mediaService.create({
100
+ ...sampleMedia,
101
+ id: customId,
102
+ });
103
+
104
+ expect(media.id).toBe(customId);
105
+ });
106
+
107
+ it("auto-generates id when not provided", async () => {
108
+ const media = await mediaService.create({
109
+ ...sampleMedia,
110
+ storageKey: "media/2025/01/auto.jpg",
111
+ });
112
+
113
+ expect(media.id).toBeTruthy();
114
+ // UUIDv7 format: 8-4-4-4-12 hex chars
115
+ expect(media.id).toMatch(
116
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
117
+ );
118
+ });
79
119
  });
80
120
 
81
121
  describe("getById", () => {
@@ -84,7 +124,7 @@ describe("MediaService", () => {
84
124
 
85
125
  const found = await mediaService.getById(created.id);
86
126
  expect(found).not.toBeNull();
87
- expect(found?.filename).toBe("image-abc123.jpg");
127
+ expect(found?.filename).toBe("0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg");
88
128
  });
89
129
 
90
130
  it("returns null for non-existent ID", async () => {
@@ -97,11 +137,11 @@ describe("MediaService", () => {
97
137
  it("returns media for valid IDs", async () => {
98
138
  const m1 = await mediaService.create({
99
139
  ...sampleMedia,
100
- r2Key: "media/a.jpg",
140
+ storageKey: "media/a.jpg",
101
141
  });
102
142
  const m2 = await mediaService.create({
103
143
  ...sampleMedia,
104
- r2Key: "media/b.jpg",
144
+ storageKey: "media/b.jpg",
105
145
  });
106
146
 
107
147
  const results = await mediaService.getByIds([m1.id, m2.id]);
@@ -132,11 +172,11 @@ describe("MediaService", () => {
132
172
 
133
173
  const m1 = await mediaService.create({
134
174
  ...sampleMedia,
135
- r2Key: "media/a.jpg",
175
+ storageKey: "media/a.jpg",
136
176
  });
137
177
  const m2 = await mediaService.create({
138
178
  ...sampleMedia,
139
- r2Key: "media/b.jpg",
179
+ storageKey: "media/b.jpg",
140
180
  });
141
181
 
142
182
  await mediaService.attachToPost(post.id, [m2.id, m1.id]);
@@ -173,15 +213,15 @@ describe("MediaService", () => {
173
213
 
174
214
  const m1 = await mediaService.create({
175
215
  ...sampleMedia,
176
- r2Key: "media/a.jpg",
216
+ storageKey: "media/a.jpg",
177
217
  });
178
218
  const m2 = await mediaService.create({
179
219
  ...sampleMedia,
180
- r2Key: "media/b.jpg",
220
+ storageKey: "media/b.jpg",
181
221
  });
182
222
  const m3 = await mediaService.create({
183
223
  ...sampleMedia,
184
- r2Key: "media/c.jpg",
224
+ storageKey: "media/c.jpg",
185
225
  });
186
226
 
187
227
  await mediaService.attachToPost(post1.id, [m1.id, m2.id]);
@@ -206,11 +246,11 @@ describe("MediaService", () => {
206
246
 
207
247
  const m1 = await mediaService.create({
208
248
  ...sampleMedia,
209
- r2Key: "media/a.jpg",
249
+ storageKey: "media/a.jpg",
210
250
  });
211
251
  const m2 = await mediaService.create({
212
252
  ...sampleMedia,
213
- r2Key: "media/b.jpg",
253
+ storageKey: "media/b.jpg",
214
254
  });
215
255
 
216
256
  await mediaService.attachToPost(post.id, [m2.id, m1.id]);
@@ -222,17 +262,19 @@ describe("MediaService", () => {
222
262
  });
223
263
  });
224
264
 
225
- describe("getByR2Key", () => {
265
+ describe("getByStorageKey", () => {
226
266
  it("returns media by R2 key", async () => {
227
267
  await mediaService.create(sampleMedia);
228
268
 
229
- const found = await mediaService.getByR2Key("media/image-abc123.jpg");
269
+ const found = await mediaService.getByStorageKey(
270
+ "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
271
+ );
230
272
  expect(found).not.toBeNull();
231
273
  expect(found?.originalName).toBe("photo.jpg");
232
274
  });
233
275
 
234
276
  it("returns null for non-existent R2 key", async () => {
235
- const found = await mediaService.getByR2Key("nonexistent");
277
+ const found = await mediaService.getByStorageKey("nonexistent");
236
278
  expect(found).toBeNull();
237
279
  });
238
280
  });
@@ -244,8 +286,8 @@ describe("MediaService", () => {
244
286
  });
245
287
 
246
288
  it("returns media ordered by createdAt desc", async () => {
247
- await mediaService.create({ ...sampleMedia, r2Key: "a.jpg" });
248
- await mediaService.create({ ...sampleMedia, r2Key: "b.jpg" });
289
+ await mediaService.create({ ...sampleMedia, storageKey: "a.jpg" });
290
+ await mediaService.create({ ...sampleMedia, storageKey: "b.jpg" });
249
291
 
250
292
  const list = await mediaService.list();
251
293
  expect(list).toHaveLength(2);
@@ -253,7 +295,10 @@ describe("MediaService", () => {
253
295
 
254
296
  it("respects limit parameter", async () => {
255
297
  for (let i = 0; i < 5; i++) {
256
- await mediaService.create({ ...sampleMedia, r2Key: `img${i}.jpg` });
298
+ await mediaService.create({
299
+ ...sampleMedia,
300
+ storageKey: `img${i}.jpg`,
301
+ });
257
302
  }
258
303
 
259
304
  const list = await mediaService.list(2);
@@ -270,11 +315,11 @@ describe("MediaService", () => {
270
315
 
271
316
  const m1 = await mediaService.create({
272
317
  ...sampleMedia,
273
- r2Key: "media/a.jpg",
318
+ storageKey: "media/a.jpg",
274
319
  });
275
320
  const m2 = await mediaService.create({
276
321
  ...sampleMedia,
277
- r2Key: "media/b.jpg",
322
+ storageKey: "media/b.jpg",
278
323
  });
279
324
 
280
325
  await mediaService.attachToPost(post.id, [m1.id, m2.id]);
@@ -295,15 +340,15 @@ describe("MediaService", () => {
295
340
 
296
341
  const m1 = await mediaService.create({
297
342
  ...sampleMedia,
298
- r2Key: "media/a.jpg",
343
+ storageKey: "media/a.jpg",
299
344
  });
300
345
  const m2 = await mediaService.create({
301
346
  ...sampleMedia,
302
- r2Key: "media/b.jpg",
347
+ storageKey: "media/b.jpg",
303
348
  });
304
349
  const m3 = await mediaService.create({
305
350
  ...sampleMedia,
306
- r2Key: "media/c.jpg",
351
+ storageKey: "media/c.jpg",
307
352
  });
308
353
 
309
354
  await mediaService.attachToPost(post.id, [m1.id, m2.id]);
@@ -328,7 +373,7 @@ describe("MediaService", () => {
328
373
 
329
374
  const m1 = await mediaService.create({
330
375
  ...sampleMedia,
331
- r2Key: "media/a.jpg",
376
+ storageKey: "media/a.jpg",
332
377
  });
333
378
 
334
379
  await mediaService.attachToPost(post.id, [m1.id]);
@@ -348,7 +393,7 @@ describe("MediaService", () => {
348
393
 
349
394
  const m1 = await mediaService.create({
350
395
  ...sampleMedia,
351
- r2Key: "media/a.jpg",
396
+ storageKey: "media/a.jpg",
352
397
  });
353
398
 
354
399
  await mediaService.attachToPost(post.id, [m1.id]);
@@ -0,0 +1,213 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { createNavigationLinkService } from "../navigation.js";
4
+ import type { Database } from "../../db/index.js";
5
+
6
+ describe("NavigationLinkService", () => {
7
+ let db: Database;
8
+ let navigationService: ReturnType<typeof createNavigationLinkService>;
9
+
10
+ beforeEach(() => {
11
+ const testDb = createTestDatabase();
12
+ db = testDb.db as unknown as Database;
13
+ navigationService = createNavigationLinkService(db);
14
+ });
15
+
16
+ describe("create", () => {
17
+ it("creates a navigation link with auto-assigned position", async () => {
18
+ const link = await navigationService.create({
19
+ label: "Home",
20
+ url: "/",
21
+ });
22
+
23
+ expect(link.label).toBe("Home");
24
+ expect(link.url).toBe("/");
25
+ expect(link.position).toBe(0);
26
+ expect(link.id).toBe(1);
27
+ });
28
+
29
+ it("auto-increments position for subsequent links", async () => {
30
+ await navigationService.create({ label: "Home", url: "/" });
31
+ const second = await navigationService.create({
32
+ label: "Archive",
33
+ url: "/archive",
34
+ });
35
+
36
+ expect(second.position).toBe(1);
37
+ });
38
+
39
+ it("uses provided position when specified", async () => {
40
+ const link = await navigationService.create({
41
+ label: "Home",
42
+ url: "/",
43
+ position: 5,
44
+ });
45
+
46
+ expect(link.position).toBe(5);
47
+ });
48
+ });
49
+
50
+ describe("getById", () => {
51
+ it("returns a link by ID", async () => {
52
+ const created = await navigationService.create({
53
+ label: "Home",
54
+ url: "/",
55
+ });
56
+
57
+ const found = await navigationService.getById(created.id);
58
+ expect(found).not.toBeNull();
59
+ expect(found?.label).toBe("Home");
60
+ });
61
+
62
+ it("returns null for non-existent ID", async () => {
63
+ const found = await navigationService.getById(9999);
64
+ expect(found).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe("list", () => {
69
+ it("returns empty array when no links exist", async () => {
70
+ const links = await navigationService.list();
71
+ expect(links).toEqual([]);
72
+ });
73
+
74
+ it("returns links ordered by position", async () => {
75
+ await navigationService.create({
76
+ label: "C",
77
+ url: "/c",
78
+ position: 2,
79
+ });
80
+ await navigationService.create({
81
+ label: "A",
82
+ url: "/a",
83
+ position: 0,
84
+ });
85
+ await navigationService.create({
86
+ label: "B",
87
+ url: "/b",
88
+ position: 1,
89
+ });
90
+
91
+ const links = await navigationService.list();
92
+ expect(links).toHaveLength(3);
93
+ expect(links[0]?.label).toBe("A");
94
+ expect(links[1]?.label).toBe("B");
95
+ expect(links[2]?.label).toBe("C");
96
+ });
97
+ });
98
+
99
+ describe("update", () => {
100
+ it("updates a link's label", async () => {
101
+ const created = await navigationService.create({
102
+ label: "Home",
103
+ url: "/",
104
+ });
105
+
106
+ const updated = await navigationService.update(created.id, {
107
+ label: "Main Page",
108
+ });
109
+
110
+ expect(updated?.label).toBe("Main Page");
111
+ expect(updated?.url).toBe("/");
112
+ });
113
+
114
+ it("updates a link's url", async () => {
115
+ const created = await navigationService.create({
116
+ label: "Blog",
117
+ url: "/blog",
118
+ });
119
+
120
+ const updated = await navigationService.update(created.id, {
121
+ url: "/posts",
122
+ });
123
+
124
+ expect(updated?.url).toBe("/posts");
125
+ expect(updated?.label).toBe("Blog");
126
+ });
127
+
128
+ it("returns null for non-existent ID", async () => {
129
+ const result = await navigationService.update(9999, { label: "Nope" });
130
+ expect(result).toBeNull();
131
+ });
132
+ });
133
+
134
+ describe("delete", () => {
135
+ it("deletes a link by ID", async () => {
136
+ const link = await navigationService.create({
137
+ label: "Home",
138
+ url: "/",
139
+ });
140
+ const result = await navigationService.delete(link.id);
141
+
142
+ expect(result).toBe(true);
143
+
144
+ const found = await navigationService.getById(link.id);
145
+ expect(found).toBeNull();
146
+ });
147
+
148
+ it("returns false for non-existent ID", async () => {
149
+ const result = await navigationService.delete(9999);
150
+ expect(result).toBe(false);
151
+ });
152
+ });
153
+
154
+ describe("reorder", () => {
155
+ it("updates positions to match array order", async () => {
156
+ const a = await navigationService.create({
157
+ label: "A",
158
+ url: "/a",
159
+ });
160
+ const b = await navigationService.create({
161
+ label: "B",
162
+ url: "/b",
163
+ });
164
+ const c = await navigationService.create({
165
+ label: "C",
166
+ url: "/c",
167
+ });
168
+
169
+ // Reverse the order
170
+ await navigationService.reorder([c.id, b.id, a.id]);
171
+
172
+ const links = await navigationService.list();
173
+ expect(links[0]?.label).toBe("C");
174
+ expect(links[0]?.position).toBe(0);
175
+ expect(links[1]?.label).toBe("B");
176
+ expect(links[1]?.position).toBe(1);
177
+ expect(links[2]?.label).toBe("A");
178
+ expect(links[2]?.position).toBe(2);
179
+ });
180
+ });
181
+
182
+ describe("ensureDefaults", () => {
183
+ it("creates default links when table is empty", async () => {
184
+ const links = await navigationService.ensureDefaults();
185
+
186
+ expect(links).toHaveLength(3);
187
+ expect(links[0]?.label).toBe("Home");
188
+ expect(links[0]?.url).toBe("/");
189
+ expect(links[1]?.label).toBe("Archive");
190
+ expect(links[1]?.url).toBe("/archive");
191
+ expect(links[2]?.label).toBe("RSS");
192
+ expect(links[2]?.url).toBe("/feed");
193
+ });
194
+
195
+ it("returns existing links without creating new ones", async () => {
196
+ await navigationService.create({ label: "Custom", url: "/custom" });
197
+
198
+ const links = await navigationService.ensureDefaults();
199
+
200
+ expect(links).toHaveLength(1);
201
+ expect(links[0]?.label).toBe("Custom");
202
+ });
203
+
204
+ it("is idempotent - calling twice returns same result", async () => {
205
+ const first = await navigationService.ensureDefaults();
206
+ const second = await navigationService.ensureDefaults();
207
+
208
+ expect(first).toHaveLength(3);
209
+ expect(second).toHaveLength(3);
210
+ expect(first[0]?.id).toBe(second[0]?.id);
211
+ });
212
+ });
213
+ });
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { createPostService } from "../post.js";
4
+ import type { Database } from "../../db/index.js";
5
+
6
+ describe("PostService - Timeline features", () => {
7
+ let db: Database;
8
+ let postService: ReturnType<typeof createPostService>;
9
+
10
+ beforeEach(() => {
11
+ const testDb = createTestDatabase();
12
+ db = testDb.db as unknown as Database;
13
+ postService = createPostService(db);
14
+ });
15
+
16
+ describe("excludeTypes filter", () => {
17
+ it("excludes posts of specified types", async () => {
18
+ await postService.create({ type: "note", content: "a note" });
19
+ await postService.create({ type: "page", content: "a page" });
20
+ await postService.create({
21
+ type: "article",
22
+ content: "an article",
23
+ title: "Article",
24
+ });
25
+
26
+ const posts = await postService.list({ excludeTypes: ["page"] });
27
+ expect(posts).toHaveLength(2);
28
+ expect(posts.every((p) => p.type !== "page")).toBe(true);
29
+ });
30
+
31
+ it("excludes multiple types", async () => {
32
+ await postService.create({ type: "note", content: "a note" });
33
+ await postService.create({ type: "page", content: "a page" });
34
+ await postService.create({
35
+ type: "article",
36
+ content: "an article",
37
+ title: "Article",
38
+ });
39
+ await postService.create({
40
+ type: "link",
41
+ content: "a link",
42
+ sourceUrl: "https://example.com",
43
+ });
44
+
45
+ const posts = await postService.list({
46
+ excludeTypes: ["page", "link"],
47
+ });
48
+ expect(posts).toHaveLength(2);
49
+ expect(posts.every((p) => p.type !== "page" && p.type !== "link")).toBe(
50
+ true,
51
+ );
52
+ });
53
+
54
+ it("returns all posts when excludeTypes is empty", async () => {
55
+ await postService.create({ type: "note", content: "a note" });
56
+ await postService.create({ type: "page", content: "a page" });
57
+
58
+ const posts = await postService.list({ excludeTypes: [] });
59
+ expect(posts).toHaveLength(2);
60
+ });
61
+
62
+ it("works combined with other filters", async () => {
63
+ await postService.create({
64
+ type: "note",
65
+ content: "featured note",
66
+ visibility: "featured",
67
+ });
68
+ await postService.create({
69
+ type: "page",
70
+ content: "featured page",
71
+ visibility: "featured",
72
+ });
73
+ await postService.create({
74
+ type: "note",
75
+ content: "draft note",
76
+ visibility: "draft",
77
+ });
78
+
79
+ const posts = await postService.list({
80
+ excludeTypes: ["page"],
81
+ visibility: "featured",
82
+ });
83
+ expect(posts).toHaveLength(1);
84
+ expect(posts[0]?.type).toBe("note");
85
+ expect(posts[0]?.visibility).toBe("featured");
86
+ });
87
+ });
88
+
89
+ describe("getThreadPreviews", () => {
90
+ it("returns empty map for empty input", async () => {
91
+ const previews = await postService.getThreadPreviews([]);
92
+ expect(previews.size).toBe(0);
93
+ });
94
+
95
+ it("returns preview replies for a thread root", async () => {
96
+ const root = await postService.create({
97
+ type: "note",
98
+ content: "root",
99
+ });
100
+ await postService.create({
101
+ type: "note",
102
+ content: "reply 1",
103
+ replyToId: root.id,
104
+ });
105
+ await postService.create({
106
+ type: "note",
107
+ content: "reply 2",
108
+ replyToId: root.id,
109
+ });
110
+
111
+ const previews = await postService.getThreadPreviews([root.id]);
112
+ const replies = previews.get(root.id);
113
+ expect(replies).toBeDefined();
114
+ expect(replies).toHaveLength(2);
115
+ expect(replies?.[0]?.content).toBe("reply 1");
116
+ expect(replies?.[1]?.content).toBe("reply 2");
117
+ });
118
+
119
+ it("limits preview replies to previewCount", async () => {
120
+ const root = await postService.create({
121
+ type: "note",
122
+ content: "root",
123
+ });
124
+ for (let i = 0; i < 5; i++) {
125
+ await postService.create({
126
+ type: "note",
127
+ content: `reply ${i}`,
128
+ replyToId: root.id,
129
+ });
130
+ }
131
+
132
+ const previews = await postService.getThreadPreviews([root.id], 2);
133
+ const replies = previews.get(root.id);
134
+ expect(replies).toHaveLength(2);
135
+ expect(replies?.[0]?.content).toBe("reply 0");
136
+ expect(replies?.[1]?.content).toBe("reply 1");
137
+ });
138
+
139
+ it("defaults to 3 preview replies", async () => {
140
+ const root = await postService.create({
141
+ type: "note",
142
+ content: "root",
143
+ });
144
+ for (let i = 0; i < 5; i++) {
145
+ await postService.create({
146
+ type: "note",
147
+ content: `reply ${i}`,
148
+ replyToId: root.id,
149
+ });
150
+ }
151
+
152
+ const previews = await postService.getThreadPreviews([root.id]);
153
+ const replies = previews.get(root.id);
154
+ expect(replies).toHaveLength(3);
155
+ });
156
+
157
+ it("handles multiple thread roots", async () => {
158
+ const root1 = await postService.create({
159
+ type: "note",
160
+ content: "root 1",
161
+ });
162
+ const root2 = await postService.create({
163
+ type: "note",
164
+ content: "root 2",
165
+ });
166
+ await postService.create({
167
+ type: "note",
168
+ content: "reply to root 1",
169
+ replyToId: root1.id,
170
+ });
171
+ await postService.create({
172
+ type: "note",
173
+ content: "reply to root 2",
174
+ replyToId: root2.id,
175
+ });
176
+
177
+ const previews = await postService.getThreadPreviews([
178
+ root1.id,
179
+ root2.id,
180
+ ]);
181
+ expect(previews.size).toBe(2);
182
+ expect(previews.get(root1.id)).toHaveLength(1);
183
+ expect(previews.get(root2.id)).toHaveLength(1);
184
+ });
185
+
186
+ it("excludes deleted replies", async () => {
187
+ const root = await postService.create({
188
+ type: "note",
189
+ content: "root",
190
+ });
191
+ const reply1 = await postService.create({
192
+ type: "note",
193
+ content: "reply 1",
194
+ replyToId: root.id,
195
+ });
196
+ await postService.create({
197
+ type: "note",
198
+ content: "reply 2",
199
+ replyToId: root.id,
200
+ });
201
+
202
+ await postService.delete(reply1.id);
203
+
204
+ const previews = await postService.getThreadPreviews([root.id]);
205
+ const replies = previews.get(root.id);
206
+ expect(replies).toHaveLength(1);
207
+ expect(replies?.[0]?.content).toBe("reply 2");
208
+ });
209
+
210
+ it("returns empty for roots with no replies", async () => {
211
+ const root = await postService.create({
212
+ type: "note",
213
+ content: "root with no replies",
214
+ });
215
+
216
+ const previews = await postService.getThreadPreviews([root.id]);
217
+ expect(previews.get(root.id)).toBeUndefined();
218
+ });
219
+ });
220
+ });