@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
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Storage Driver Abstraction
3
+ *
4
+ * Provides a common interface for file storage with R2 and S3-compatible backends.
5
+ */
6
+
7
+ import type { Bindings } from "../types.js";
8
+
9
+ /**
10
+ * Common interface for storage operations.
11
+ *
12
+ * Both R2 and S3-compatible drivers implement this interface,
13
+ * allowing the rest of the application to be storage-agnostic.
14
+ */
15
+ export interface StorageDriver {
16
+ /** Upload a file to storage */
17
+ put(
18
+ key: string,
19
+ body: ReadableStream | Uint8Array,
20
+ opts?: { contentType?: string },
21
+ ): Promise<void>;
22
+
23
+ /** Retrieve a file from storage. Returns null if not found. */
24
+ get(
25
+ key: string,
26
+ ): Promise<{ body: ReadableStream; contentType?: string } | null>;
27
+
28
+ /** Delete a file from storage */
29
+ delete(key: string): Promise<void>;
30
+ }
31
+
32
+ /**
33
+ * Creates an R2 storage driver that delegates to a Cloudflare R2 bucket binding.
34
+ *
35
+ * @param r2 - The R2 bucket binding from the Cloudflare Workers environment
36
+ * @returns A StorageDriver backed by R2
37
+ */
38
+ export function createR2Driver(r2: R2Bucket): StorageDriver {
39
+ return {
40
+ async put(key, body, opts) {
41
+ await r2.put(key, body, {
42
+ httpMetadata: opts?.contentType
43
+ ? { contentType: opts.contentType }
44
+ : undefined,
45
+ });
46
+ },
47
+
48
+ async get(key) {
49
+ const object = await r2.get(key);
50
+ if (!object) return null;
51
+ return {
52
+ body: object.body,
53
+ contentType: object.httpMetadata?.contentType ?? undefined,
54
+ };
55
+ },
56
+
57
+ async delete(key) {
58
+ await r2.delete(key);
59
+ },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Configuration for the S3-compatible storage driver.
65
+ */
66
+ export interface S3DriverConfig {
67
+ endpoint: string;
68
+ bucket: string;
69
+ accessKeyId: string;
70
+ secretAccessKey: string;
71
+ region: string;
72
+ }
73
+
74
+ /**
75
+ * Creates an S3-compatible storage driver using the AWS SDK.
76
+ *
77
+ * Supports any S3-compatible service: AWS S3, Backblaze B2, MinIO, etc.
78
+ * Uses path-style addressing for non-AWS endpoints.
79
+ *
80
+ * @param config - S3 connection configuration
81
+ * @returns A StorageDriver backed by S3
82
+ */
83
+ export function createS3Driver(config: S3DriverConfig): StorageDriver {
84
+ // Lazy-load the AWS SDK to avoid bundling it when using R2
85
+ let clientPromise: Promise<{
86
+ send: (command: unknown) => Promise<unknown>;
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
88
+ S3Client: any;
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
90
+ PutObjectCommand: any;
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
92
+ GetObjectCommand: any;
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
94
+ DeleteObjectCommand: any;
95
+ bucket: string;
96
+ }> | null = null;
97
+
98
+ function getClient() {
99
+ if (!clientPromise) {
100
+ clientPromise = import("@aws-sdk/client-s3").then((sdk) => {
101
+ const forcePathStyle = !config.endpoint.includes("amazonaws.com");
102
+ const client = new sdk.S3Client({
103
+ endpoint: config.endpoint,
104
+ region: config.region,
105
+ credentials: {
106
+ accessKeyId: config.accessKeyId,
107
+ secretAccessKey: config.secretAccessKey,
108
+ },
109
+ forcePathStyle,
110
+ });
111
+ return {
112
+ send: (cmd: unknown) => client.send(cmd as never),
113
+ S3Client: sdk.S3Client,
114
+ PutObjectCommand: sdk.PutObjectCommand,
115
+ GetObjectCommand: sdk.GetObjectCommand,
116
+ DeleteObjectCommand: sdk.DeleteObjectCommand,
117
+ bucket: config.bucket,
118
+ };
119
+ });
120
+ }
121
+ return clientPromise;
122
+ }
123
+
124
+ return {
125
+ async put(key, body, opts) {
126
+ const s3 = await getClient();
127
+
128
+ // Buffer the stream to Uint8Array for the S3 SDK
129
+ let bodyBytes: Uint8Array;
130
+ if (body instanceof Uint8Array) {
131
+ bodyBytes = body;
132
+ } else {
133
+ const reader = body.getReader();
134
+ const chunks: Uint8Array[] = [];
135
+ for (;;) {
136
+ const { done, value } = await reader.read();
137
+ if (done) break;
138
+ chunks.push(value);
139
+ }
140
+ let totalLength = 0;
141
+ for (const chunk of chunks) totalLength += chunk.length;
142
+ bodyBytes = new Uint8Array(totalLength);
143
+ let offset = 0;
144
+ for (const chunk of chunks) {
145
+ bodyBytes.set(chunk, offset);
146
+ offset += chunk.length;
147
+ }
148
+ }
149
+
150
+ const command = new s3.PutObjectCommand({
151
+ Bucket: s3.bucket,
152
+ Key: key,
153
+ Body: bodyBytes,
154
+ ContentType: opts?.contentType,
155
+ });
156
+ await s3.send(command);
157
+ },
158
+
159
+ async get(key) {
160
+ const s3 = await getClient();
161
+ try {
162
+ const command = new s3.GetObjectCommand({
163
+ Bucket: s3.bucket,
164
+ Key: key,
165
+ });
166
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- AWS SDK response type
167
+ const response = (await s3.send(command)) as any;
168
+ if (!response.Body) return null;
169
+ return {
170
+ body: response.Body.transformToWebStream() as ReadableStream,
171
+ contentType: response.ContentType ?? undefined,
172
+ };
173
+ } catch (err: unknown) {
174
+ // NoSuchKey → return null instead of throwing
175
+ if (
176
+ err instanceof Error &&
177
+ (err.name === "NoSuchKey" || err.name === "NotFound")
178
+ ) {
179
+ return null;
180
+ }
181
+ throw err;
182
+ }
183
+ },
184
+
185
+ async delete(key) {
186
+ const s3 = await getClient();
187
+ const command = new s3.DeleteObjectCommand({
188
+ Bucket: s3.bucket,
189
+ Key: key,
190
+ });
191
+ await s3.send(command);
192
+ },
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Creates the appropriate storage driver based on environment configuration.
198
+ *
199
+ * Returns `null` if no storage is configured (no R2 binding and no S3 config).
200
+ *
201
+ * @param env - The Cloudflare Workers environment bindings
202
+ * @returns A StorageDriver instance or null
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * const storage = createStorageDriver(c.env);
207
+ * if (storage) {
208
+ * await storage.put("media/file.jpg", stream, { contentType: "image/jpeg" });
209
+ * }
210
+ * ```
211
+ */
212
+ export function createStorageDriver(env: Bindings): StorageDriver | null {
213
+ const driver = env.STORAGE_DRIVER || "r2";
214
+
215
+ if (driver === "s3") {
216
+ if (
217
+ !env.S3_ENDPOINT ||
218
+ !env.S3_BUCKET ||
219
+ !env.S3_ACCESS_KEY_ID ||
220
+ !env.S3_SECRET_ACCESS_KEY
221
+ ) {
222
+ return null;
223
+ }
224
+ return createS3Driver({
225
+ endpoint: env.S3_ENDPOINT,
226
+ bucket: env.S3_BUCKET,
227
+ accessKeyId: env.S3_ACCESS_KEY_ID,
228
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY,
229
+ region: env.S3_REGION || "auto",
230
+ });
231
+ }
232
+
233
+ // Default: R2
234
+ if (!env.R2) return null;
235
+ return createR2Driver(env.R2);
236
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Theme Component Resolution
3
+ *
4
+ * Resolves theme-overridable components, falling back to defaults.
5
+ */
6
+
7
+ import type { FC } from "hono/jsx";
8
+ import type {
9
+ PostType,
10
+ ThemeComponents,
11
+ TimelineCardProps,
12
+ ThreadPreviewProps,
13
+ TimelineFeedProps,
14
+ } from "../types.js";
15
+
16
+ const THEME_KEY_MAP: Record<PostType, keyof ThemeComponents> = {
17
+ note: "NoteCard",
18
+ article: "ArticleCard",
19
+ link: "LinkCard",
20
+ quote: "QuoteCard",
21
+ image: "ImageCard",
22
+ page: "NoteCard",
23
+ };
24
+
25
+ /**
26
+ * Resolves the card component for a given post type.
27
+ *
28
+ * Checks theme overrides first, then falls back to the provided default card component.
29
+ *
30
+ * @param type - The post type to resolve a card for
31
+ * @param defaults - Map of post type to default card component
32
+ * @param themeComponents - Optional theme component overrides
33
+ * @returns The resolved card component
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * const Card = resolveCardComponent("article", DEFAULT_CARD_MAP, c.var.config.theme?.components);
38
+ * ```
39
+ */
40
+ export function resolveCardComponent(
41
+ type: PostType,
42
+ defaults: Record<PostType, FC<TimelineCardProps>>,
43
+ themeComponents?: ThemeComponents,
44
+ ): FC<TimelineCardProps> {
45
+ const key = THEME_KEY_MAP[type];
46
+ const override = themeComponents?.[key] as FC<TimelineCardProps> | undefined;
47
+ return override ?? defaults[type];
48
+ }
49
+
50
+ /**
51
+ * Resolves the ThreadPreview component.
52
+ *
53
+ * @param defaultComponent - The default ThreadPreview component
54
+ * @param themeComponents - Optional theme component overrides
55
+ * @returns The resolved ThreadPreview component
56
+ */
57
+ export function resolveThreadPreview(
58
+ defaultComponent: FC<ThreadPreviewProps>,
59
+ themeComponents?: ThemeComponents,
60
+ ): FC<ThreadPreviewProps> {
61
+ return themeComponents?.ThreadPreview ?? defaultComponent;
62
+ }
63
+
64
+ /**
65
+ * Resolves the TimelineFeed component.
66
+ *
67
+ * @param defaultComponent - The default TimelineFeed component
68
+ * @param themeComponents - Optional theme component overrides
69
+ * @returns The resolved TimelineFeed component
70
+ */
71
+ export function resolveTimelineFeed(
72
+ defaultComponent: FC<TimelineFeedProps>,
73
+ themeComponents?: ThemeComponents,
74
+ ): FC<TimelineFeedProps> {
75
+ return themeComponents?.TimelineFeed ?? defaultComponent;
76
+ }
@@ -50,7 +50,7 @@ describe("Posts API Routes", () => {
50
50
  originalName: "test.jpg",
51
51
  mimeType: "image/jpeg",
52
52
  size: 1024,
53
- r2Key: "uploads/test.jpg",
53
+ storageKey: "media/2025/01/test.jpg",
54
54
  width: 800,
55
55
  height: 600,
56
56
  });
@@ -145,7 +145,7 @@ describe("Posts API Routes", () => {
145
145
  originalName: "test.jpg",
146
146
  mimeType: "image/jpeg",
147
147
  size: 1024,
148
- r2Key: "uploads/test.jpg",
148
+ storageKey: "media/2025/01/test.jpg",
149
149
  });
150
150
 
151
151
  await services.media.attachToPost(post.id, [media.id]);
@@ -223,14 +223,14 @@ describe("Posts API Routes", () => {
223
223
  originalName: "a.jpg",
224
224
  mimeType: "image/jpeg",
225
225
  size: 1024,
226
- r2Key: "uploads/a.jpg",
226
+ storageKey: "media/2025/01/a.jpg",
227
227
  });
228
228
  const m2 = await services.media.create({
229
229
  filename: "b.jpg",
230
230
  originalName: "b.jpg",
231
231
  mimeType: "image/jpeg",
232
232
  size: 2048,
233
- r2Key: "uploads/b.jpg",
233
+ storageKey: "media/2025/01/b.jpg",
234
234
  });
235
235
 
236
236
  const res = await app.request("/api/posts", {
@@ -282,7 +282,7 @@ describe("Posts API Routes", () => {
282
282
  originalName: "a.jpg",
283
283
  mimeType: "image/jpeg",
284
284
  size: 1024,
285
- r2Key: "uploads/a.jpg",
285
+ storageKey: "media/2025/01/a.jpg",
286
286
  });
287
287
 
288
288
  const res = await app.request("/api/posts", {
@@ -404,7 +404,7 @@ describe("Posts API Routes", () => {
404
404
  originalName: "a.jpg",
405
405
  mimeType: "image/jpeg",
406
406
  size: 1024,
407
- r2Key: "uploads/a.jpg",
407
+ storageKey: "media/2025/01/a.jpg",
408
408
  });
409
409
 
410
410
  await services.media.attachToPost(post.id, [m1.id]);
@@ -414,7 +414,7 @@ describe("Posts API Routes", () => {
414
414
  originalName: "b.jpg",
415
415
  mimeType: "image/jpeg",
416
416
  size: 2048,
417
- r2Key: "uploads/b.jpg",
417
+ storageKey: "media/2025/01/b.jpg",
418
418
  });
419
419
 
420
420
  const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
@@ -443,7 +443,7 @@ describe("Posts API Routes", () => {
443
443
  originalName: "a.jpg",
444
444
  mimeType: "image/jpeg",
445
445
  size: 1024,
446
- r2Key: "uploads/a.jpg",
446
+ storageKey: "media/2025/01/a.jpg",
447
447
  });
448
448
 
449
449
  await services.media.attachToPost(post.id, [m1.id]);
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Timeline API Tests
3
+ *
4
+ * Tests the timeline data assembly logic via the service layer.
5
+ * The actual route handler renders JSX components which require the Lingui SWC
6
+ * plugin (not available in vitest). We test the underlying service operations
7
+ * that power the timeline API instead.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach } from "vitest";
11
+ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
12
+ import { createPostService } from "../../../services/post.js";
13
+ import { createMediaService } from "../../../services/media.js";
14
+ import { buildMediaMap } from "../../../lib/media-helpers.js";
15
+ import type { Database } from "../../../db/index.js";
16
+ import type { PostWithMedia, TimelineItemData } from "../../../types.js";
17
+
18
+ describe("Timeline data assembly", () => {
19
+ let db: Database;
20
+ let postService: ReturnType<typeof createPostService>;
21
+ let mediaService: ReturnType<typeof createMediaService>;
22
+
23
+ beforeEach(() => {
24
+ const testDb = createTestDatabase();
25
+ db = testDb.db as unknown as Database;
26
+ postService = createPostService(db);
27
+ mediaService = createMediaService(db);
28
+ });
29
+
30
+ it("assembles timeline items with media attachments", async () => {
31
+ const post = await postService.create({
32
+ type: "note",
33
+ content: "Hello",
34
+ visibility: "featured",
35
+ });
36
+
37
+ const posts = await postService.list({
38
+ visibility: ["featured", "quiet"],
39
+ excludeReplies: true,
40
+ excludeTypes: ["page"],
41
+ limit: 21,
42
+ });
43
+
44
+ expect(posts).toHaveLength(1);
45
+ expect(posts[0]?.id).toBe(post.id);
46
+
47
+ // Build media map
48
+ const postIds = posts.map((p) => p.id);
49
+ const rawMediaMap = await mediaService.getByPostIds(postIds);
50
+ const mediaMap = buildMediaMap(rawMediaMap);
51
+
52
+ // Assemble items
53
+ const items: TimelineItemData[] = posts.map((p) => ({
54
+ post: { ...p, mediaAttachments: mediaMap.get(p.id) ?? [] },
55
+ }));
56
+
57
+ expect(items).toHaveLength(1);
58
+ expect(items[0]?.post.mediaAttachments).toEqual([]);
59
+ });
60
+
61
+ it("identifies thread roots and builds thread previews", async () => {
62
+ const root = await postService.create({
63
+ type: "note",
64
+ content: "Thread root",
65
+ visibility: "featured",
66
+ });
67
+ await postService.create({
68
+ type: "note",
69
+ content: "Reply 1",
70
+ replyToId: root.id,
71
+ });
72
+ await postService.create({
73
+ type: "note",
74
+ content: "Reply 2",
75
+ replyToId: root.id,
76
+ });
77
+
78
+ const posts = await postService.list({
79
+ visibility: ["featured", "quiet"],
80
+ excludeReplies: true,
81
+ excludeTypes: ["page"],
82
+ limit: 21,
83
+ });
84
+
85
+ expect(posts).toHaveLength(1);
86
+
87
+ const postIds = posts.map((p) => p.id);
88
+ const replyCounts = await postService.getReplyCounts(postIds);
89
+ const threadRootIds = postIds.filter(
90
+ (id) => (replyCounts.get(id) ?? 0) > 0,
91
+ );
92
+
93
+ expect(threadRootIds).toEqual([root.id]);
94
+ expect(replyCounts.get(root.id)).toBe(2);
95
+
96
+ const threadPreviews = await postService.getThreadPreviews(threadRootIds);
97
+ const replies = threadPreviews.get(root.id);
98
+ expect(replies).toHaveLength(2);
99
+ expect(replies?.[0]?.content).toBe("Reply 1");
100
+
101
+ // Assemble items
102
+ const rawMediaMap = await mediaService.getByPostIds(postIds);
103
+ const mediaMap = buildMediaMap(rawMediaMap);
104
+
105
+ const items: TimelineItemData[] = posts.map((post) => {
106
+ const postWithMedia: PostWithMedia = {
107
+ ...post,
108
+ mediaAttachments: mediaMap.get(post.id) ?? [],
109
+ };
110
+
111
+ const replyCount = replyCounts.get(post.id) ?? 0;
112
+ const previewReplies = threadPreviews.get(post.id);
113
+
114
+ if (replyCount > 0 && previewReplies) {
115
+ return {
116
+ post: postWithMedia,
117
+ threadPreview: {
118
+ replies: previewReplies.map((r) => ({
119
+ ...r,
120
+ mediaAttachments: [],
121
+ })),
122
+ totalReplyCount: replyCount,
123
+ },
124
+ };
125
+ }
126
+
127
+ return { post: postWithMedia };
128
+ });
129
+
130
+ expect(items).toHaveLength(1);
131
+ expect(items[0]?.threadPreview).toBeDefined();
132
+ expect(items[0]?.threadPreview?.replies).toHaveLength(2);
133
+ expect(items[0]?.threadPreview?.totalReplyCount).toBe(2);
134
+ });
135
+
136
+ it("excludes pages from timeline", async () => {
137
+ await postService.create({
138
+ type: "note",
139
+ content: "A note",
140
+ visibility: "quiet",
141
+ });
142
+ await postService.create({
143
+ type: "page",
144
+ content: "A page",
145
+ visibility: "quiet",
146
+ });
147
+
148
+ const posts = await postService.list({
149
+ visibility: ["featured", "quiet"],
150
+ excludeReplies: true,
151
+ excludeTypes: ["page"],
152
+ limit: 21,
153
+ });
154
+
155
+ expect(posts).toHaveLength(1);
156
+ expect(posts[0]?.type).toBe("note");
157
+ });
158
+
159
+ it("excludes replies from top-level list", async () => {
160
+ const root = await postService.create({
161
+ type: "note",
162
+ content: "Root",
163
+ visibility: "quiet",
164
+ });
165
+ await postService.create({
166
+ type: "note",
167
+ content: "Reply",
168
+ replyToId: root.id,
169
+ });
170
+
171
+ const posts = await postService.list({
172
+ visibility: ["featured", "quiet"],
173
+ excludeReplies: true,
174
+ excludeTypes: ["page"],
175
+ limit: 21,
176
+ });
177
+
178
+ expect(posts).toHaveLength(1);
179
+ expect(posts[0]?.content).toBe("Root");
180
+ });
181
+
182
+ it("supports cursor pagination for load more", async () => {
183
+ const posts = [];
184
+ for (let i = 0; i < 5; i++) {
185
+ posts.push(
186
+ await postService.create({
187
+ type: "note",
188
+ content: `Post ${i}`,
189
+ visibility: "quiet",
190
+ publishedAt: 1000 + i,
191
+ }),
192
+ );
193
+ }
194
+
195
+ // First page
196
+ const page1 = await postService.list({
197
+ visibility: ["featured", "quiet"],
198
+ excludeReplies: true,
199
+ excludeTypes: ["page"],
200
+ limit: 3,
201
+ });
202
+ expect(page1).toHaveLength(3);
203
+
204
+ // Second page using cursor
205
+ const lastPost = page1[page1.length - 1];
206
+ expect(lastPost).toBeDefined();
207
+ const page2 = await postService.list({
208
+ visibility: ["featured", "quiet"],
209
+ excludeReplies: true,
210
+ excludeTypes: ["page"],
211
+ limit: 3,
212
+ cursor: lastPost?.id,
213
+ });
214
+ expect(page2).toHaveLength(2);
215
+ expect(page2.every((p) => p.id < (lastPost?.id ?? 0))).toBe(true);
216
+ });
217
+
218
+ it("correctly determines hasMore flag", async () => {
219
+ for (let i = 0; i < 3; i++) {
220
+ await postService.create({
221
+ type: "note",
222
+ content: `Post ${i}`,
223
+ visibility: "quiet",
224
+ });
225
+ }
226
+
227
+ // Request limit + 1 to check for more
228
+ const pageSize = 2;
229
+ const posts = await postService.list({
230
+ visibility: ["featured", "quiet"],
231
+ excludeReplies: true,
232
+ excludeTypes: ["page"],
233
+ limit: pageSize + 1,
234
+ });
235
+
236
+ const hasMore = posts.length > pageSize;
237
+ expect(hasMore).toBe(true);
238
+
239
+ const displayPosts = posts.slice(0, pageSize);
240
+ expect(displayPosts).toHaveLength(2);
241
+ });
242
+ });