@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
@@ -12,7 +12,11 @@ import {
12
12
  validateMediaForPostType,
13
13
  } from "../../lib/schemas.js";
14
14
  import { requireAuthApi } from "../../middleware/auth.js";
15
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
15
+ import {
16
+ getMediaUrl,
17
+ getImageUrl,
18
+ getPublicUrlForProvider,
19
+ } from "../../lib/image.js";
16
20
 
17
21
  type Env = { Bindings: Bindings; Variables: AppVariables };
18
22
 
@@ -25,8 +29,14 @@ function toMediaAttachment(
25
29
  m: Media,
26
30
  r2PublicUrl?: string,
27
31
  imageTransformUrl?: string,
32
+ s3PublicUrl?: string,
28
33
  ) {
29
- const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
34
+ const publicUrl = getPublicUrlForProvider(
35
+ m.provider,
36
+ r2PublicUrl,
37
+ s3PublicUrl,
38
+ );
39
+ const url = getMediaUrl(m.id, m.storageKey, publicUrl);
30
40
  const previewUrl = getImageUrl(url, imageTransformUrl, {
31
41
  width: 400,
32
42
  quality: 80,
@@ -66,13 +76,14 @@ postsApiRoutes.get("/", async (c) => {
66
76
  const mediaMap = await c.var.services.media.getByPostIds(postIds);
67
77
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
68
78
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
79
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
69
80
 
70
81
  return c.json({
71
82
  posts: posts.map((p) => ({
72
83
  ...p,
73
84
  sqid: sqid.encode(p.id),
74
85
  mediaAttachments: (mediaMap.get(p.id) ?? []).map((m) =>
75
- toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
86
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
76
87
  ),
77
88
  })),
78
89
 
@@ -94,12 +105,13 @@ postsApiRoutes.get("/:id", async (c) => {
94
105
  const mediaList = await c.var.services.media.getByPostId(post.id);
95
106
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
96
107
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
108
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
97
109
 
98
110
  return c.json({
99
111
  ...post,
100
112
  sqid: sqid.encode(post.id),
101
113
  mediaAttachments: mediaList.map((m) =>
102
- toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
114
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
103
115
  ),
104
116
  });
105
117
  });
@@ -157,13 +169,14 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
157
169
  const mediaList = await c.var.services.media.getByPostId(post.id);
158
170
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
159
171
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
172
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
160
173
 
161
174
  return c.json(
162
175
  {
163
176
  ...post,
164
177
  sqid: sqid.encode(post.id),
165
178
  mediaAttachments: mediaList.map((m) =>
166
- toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
179
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
167
180
  ),
168
181
  },
169
182
  201,
@@ -233,12 +246,13 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
233
246
  const mediaList = await c.var.services.media.getByPostId(post.id);
234
247
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
235
248
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
249
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
236
250
 
237
251
  return c.json({
238
252
  ...post,
239
253
  sqid: sqid.encode(post.id),
240
254
  mediaAttachments: mediaList.map((m) =>
241
- toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
255
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
242
256
  ),
243
257
  });
244
258
  });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Timeline API Routes
3
+ *
4
+ * Provides load-more functionality for the timeline feed via SSE.
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import type { Bindings, PostWithMedia, TimelineItemData } from "../../types.js";
9
+ import type { AppVariables } from "../../app.js";
10
+ import { sse } from "../../lib/sse.js";
11
+ import { buildMediaMap } from "../../lib/media-helpers.js";
12
+ import { TimelineItem } from "../../theme/components/timeline/TimelineItem.js";
13
+ import { ThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
14
+
15
+ type Env = { Bindings: Bindings; Variables: AppVariables };
16
+
17
+ const PAGE_SIZE = 20;
18
+
19
+ export const timelineApiRoutes = new Hono<Env>();
20
+
21
+ timelineApiRoutes.get("/", async (c) => {
22
+ const cursorParam = c.req.query("cursor");
23
+ const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
24
+
25
+ if (!cursor || isNaN(cursor)) {
26
+ return c.json({ error: "cursor parameter required" }, 400);
27
+ }
28
+
29
+ // Fetch one extra to determine if there are more
30
+ const posts = await c.var.services.posts.list({
31
+ visibility: ["featured", "quiet"],
32
+ excludeReplies: true,
33
+ excludeTypes: ["page"],
34
+ limit: PAGE_SIZE + 1,
35
+ cursor,
36
+ });
37
+
38
+ const hasMore = posts.length > PAGE_SIZE;
39
+ const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
40
+
41
+ if (displayPosts.length === 0) {
42
+ return sse(c, async (stream) => {
43
+ stream.remove("#load-more-container");
44
+ });
45
+ }
46
+
47
+ // Build media map
48
+ const postIds = displayPosts.map((p) => p.id);
49
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
50
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
51
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
52
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
53
+ const mediaMap = buildMediaMap(
54
+ rawMediaMap,
55
+ r2PublicUrl,
56
+ imageTransformUrl,
57
+ s3PublicUrl,
58
+ );
59
+
60
+ // Get reply counts to identify thread roots
61
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
62
+ const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
63
+
64
+ // Get thread previews
65
+ const threadPreviews = await c.var.services.posts.getThreadPreviews(
66
+ threadRootIds,
67
+ 3,
68
+ );
69
+
70
+ // Load media for preview replies
71
+ const previewReplyIds: number[] = [];
72
+ for (const replies of threadPreviews.values()) {
73
+ for (const reply of replies) {
74
+ previewReplyIds.push(reply.id);
75
+ }
76
+ }
77
+ const previewMediaMap =
78
+ previewReplyIds.length > 0
79
+ ? buildMediaMap(
80
+ await c.var.services.media.getByPostIds(previewReplyIds),
81
+ r2PublicUrl,
82
+ imageTransformUrl,
83
+ s3PublicUrl,
84
+ )
85
+ : new Map();
86
+
87
+ // Assemble timeline items
88
+ const items: TimelineItemData[] = displayPosts.map((post) => {
89
+ const postWithMedia: PostWithMedia = {
90
+ ...post,
91
+ mediaAttachments: mediaMap.get(post.id) ?? [],
92
+ };
93
+
94
+ const replyCount = replyCounts.get(post.id) ?? 0;
95
+ const previewReplies = threadPreviews.get(post.id);
96
+
97
+ if (replyCount > 0 && previewReplies) {
98
+ return {
99
+ post: postWithMedia,
100
+ threadPreview: {
101
+ replies: previewReplies.map((r) => ({
102
+ ...r,
103
+ mediaAttachments: previewMediaMap.get(r.id) ?? [],
104
+ })),
105
+ totalReplyCount: replyCount,
106
+ },
107
+ };
108
+ }
109
+
110
+ return { post: postWithMedia };
111
+ });
112
+
113
+ // Render items to HTML
114
+ const itemsHtml = items
115
+ .map((item) => {
116
+ if (item.threadPreview) {
117
+ return (
118
+ <ThreadPreview
119
+ rootPost={item.post}
120
+ previewReplies={item.threadPreview.replies}
121
+ totalReplyCount={item.threadPreview.totalReplyCount}
122
+ />
123
+ );
124
+ }
125
+ return <TimelineItem item={item} />;
126
+ })
127
+ .map((jsx) => jsx.toString())
128
+ .join("");
129
+
130
+ // Determine next cursor
131
+ const lastPost = displayPosts[displayPosts.length - 1];
132
+ const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
133
+
134
+ // Build load-more button HTML
135
+ const loadMoreHtml = nextCursor
136
+ ? `<div id="load-more-container" class="mt-6 text-center"><button class="btn btn-outline" data-on:click="@get('/api/timeline?cursor=${nextCursor}')">Load more</button></div>`
137
+ : "";
138
+
139
+ return sse(c, async (stream) => {
140
+ // Append new items to the feed
141
+ stream.patchElements(itemsHtml, {
142
+ mode: "append",
143
+ selector: "#timeline-feed",
144
+ });
145
+ // Replace or remove the load-more container
146
+ if (loadMoreHtml) {
147
+ stream.patchElements(loadMoreHtml);
148
+ } else {
149
+ stream.remove("#load-more-container");
150
+ }
151
+ });
152
+ });
@@ -7,10 +7,15 @@
7
7
 
8
8
  import { Hono } from "hono";
9
9
  import { html } from "hono/html";
10
+ import { uuidv7 } from "uuidv7";
10
11
  import type { Bindings } from "../../types.js";
11
12
  import type { AppVariables } from "../../app.js";
12
13
  import { requireAuthApi } from "../../middleware/auth.js";
13
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
14
+ import {
15
+ getMediaUrl,
16
+ getImageUrl,
17
+ getPublicUrlForProvider,
18
+ } from "../../lib/image.js";
14
19
  import { sse, dsSignals } from "../../lib/sse.js";
15
20
 
16
21
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -26,16 +31,16 @@ uploadApiRoutes.use("*", requireAuthApi());
26
31
  function renderMediaCard(
27
32
  media: {
28
33
  id: string;
29
- r2Key: string;
34
+ storageKey: string;
30
35
  mimeType: string;
31
36
  originalName: string;
32
37
  alt: string | null;
33
38
  size: number;
34
39
  },
35
- r2PublicUrl?: string,
40
+ publicUrl?: string,
36
41
  imageTransformUrl?: string,
37
42
  ): string {
38
- const fullUrl = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
43
+ const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
39
44
  const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
40
45
  width: 300,
41
46
  quality: 80,
@@ -115,11 +120,12 @@ function wantsSSE(c: {
115
120
 
116
121
  // Upload a file
117
122
  uploadApiRoutes.post("/", async (c) => {
118
- if (!c.env.R2) {
123
+ const storage = c.var.storage;
124
+ if (!storage) {
119
125
  if (wantsSSE(c)) {
120
- return dsSignals({ _uploadError: "R2 storage not configured" });
126
+ return dsSignals({ _uploadError: "Storage not configured" });
121
127
  }
122
- return c.json({ error: "R2 storage not configured" }, 500);
128
+ return c.json({ error: "Storage not configured" }, 500);
123
129
  }
124
130
 
125
131
  const formData = await c.req.formData();
@@ -156,35 +162,43 @@ uploadApiRoutes.post("/", async (c) => {
156
162
  return c.json({ error: "File too large (max 10MB)" }, 400);
157
163
  }
158
164
 
159
- // Generate unique filename
165
+ // Generate unique filename using UUIDv7
160
166
  const ext = file.name.split(".").pop() || "bin";
161
- const timestamp = Date.now();
162
- const random = Math.random().toString(36).substring(2, 8);
163
- const filename = `${timestamp}-${random}.${ext}`;
164
- const r2Key = `uploads/${filename}`;
167
+ const id = uuidv7();
168
+ const date = new Date();
169
+ const year = date.getUTCFullYear();
170
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
171
+ const filename = `${id}.${ext}`;
172
+ const storageKey = `media/${year}/${month}/${filename}`;
165
173
 
166
174
  try {
167
- // Upload to R2
168
- await c.env.R2.put(r2Key, file.stream(), {
169
- httpMetadata: {
170
- contentType: file.type,
171
- },
175
+ // Upload to storage
176
+ await storage.put(storageKey, file.stream(), {
177
+ contentType: file.type,
172
178
  });
173
179
 
174
180
  // Save to database
175
181
  const media = await c.var.services.media.create({
182
+ id,
176
183
  filename,
177
184
  originalName: file.name,
178
185
  mimeType: file.type,
179
186
  size: file.size,
180
- r2Key,
187
+ storageKey,
188
+ provider: c.env.STORAGE_DRIVER || "r2",
181
189
  });
182
190
 
183
191
  // SSE response for Datastar
184
192
  if (wantsSSE(c)) {
193
+ const provider = c.env.STORAGE_DRIVER || "r2";
194
+ const mediaPublicUrl = getPublicUrlForProvider(
195
+ provider,
196
+ c.env.R2_PUBLIC_URL,
197
+ c.env.S3_PUBLIC_URL,
198
+ );
185
199
  const cardHtml = renderMediaCard(
186
200
  media,
187
- c.env.R2_PUBLIC_URL,
201
+ mediaPublicUrl,
188
202
  c.env.IMAGE_TRANSFORM_URL,
189
203
  );
190
204
 
@@ -199,7 +213,13 @@ uploadApiRoutes.post("/", async (c) => {
199
213
  }
200
214
 
201
215
  // JSON response for API clients
202
- const publicUrl = getMediaUrl(media.id, r2Key, c.env.R2_PUBLIC_URL);
216
+ const provider = c.env.STORAGE_DRIVER || "r2";
217
+ const mediaPublicUrl = getPublicUrlForProvider(
218
+ provider,
219
+ c.env.R2_PUBLIC_URL,
220
+ c.env.S3_PUBLIC_URL,
221
+ );
222
+ const publicUrl = getMediaUrl(media.id, storageKey, mediaPublicUrl);
203
223
  return c.json({
204
224
  id: media.id,
205
225
  filename: media.filename,
@@ -225,12 +245,18 @@ uploadApiRoutes.post("/", async (c) => {
225
245
  uploadApiRoutes.get("/", async (c) => {
226
246
  const limit = parseInt(c.req.query("limit") ?? "50", 10);
227
247
  const mediaList = await c.var.services.media.list(limit);
248
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
249
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
228
250
 
229
251
  return c.json({
230
252
  media: mediaList.map((m) => ({
231
253
  id: m.id,
232
254
  filename: m.filename,
233
- url: getMediaUrl(m.id, m.r2Key, c.env.R2_PUBLIC_URL),
255
+ url: getMediaUrl(
256
+ m.id,
257
+ m.storageKey,
258
+ getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl),
259
+ ),
234
260
  mimeType: m.mimeType,
235
261
  size: m.size,
236
262
  createdAt: m.createdAt,
@@ -246,13 +272,14 @@ uploadApiRoutes.delete("/:id", async (c) => {
246
272
  return c.json({ error: "Not found" }, 404);
247
273
  }
248
274
 
249
- // Delete from R2
250
- if (c.env.R2) {
275
+ // Delete from storage
276
+ const storage = c.var.storage;
277
+ if (storage) {
251
278
  try {
252
- await c.env.R2.delete(media.r2Key);
279
+ await storage.delete(media.storageKey);
253
280
  } catch (err) {
254
281
  // eslint-disable-next-line no-console -- Error logging is intentional
255
- console.error("R2 delete error:", err);
282
+ console.error("Storage delete error:", err);
256
283
  }
257
284
  }
258
285
 
@@ -13,7 +13,11 @@ import type { AppVariables } from "../../app.js";
13
13
  import { DashLayout } from "../../theme/layouts/index.js";
14
14
  import { EmptyState, DangerZone } from "../../theme/components/index.js";
15
15
  import * as time from "../../lib/time.js";
16
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
16
+ import {
17
+ getMediaUrl,
18
+ getImageUrl,
19
+ getPublicUrlForProvider,
20
+ } from "../../lib/image.js";
17
21
  import { dsRedirect } from "../../lib/sse.js";
18
22
 
19
23
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -36,12 +40,19 @@ function MediaCard({
36
40
  media,
37
41
  r2PublicUrl,
38
42
  imageTransformUrl,
43
+ s3PublicUrl,
39
44
  }: {
40
45
  media: Media;
41
46
  r2PublicUrl?: string;
42
47
  imageTransformUrl?: string;
48
+ s3PublicUrl?: string;
43
49
  }) {
44
- const fullUrl = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
50
+ const publicUrl = getPublicUrlForProvider(
51
+ media.provider,
52
+ r2PublicUrl,
53
+ s3PublicUrl,
54
+ );
55
+ const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
45
56
  const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
46
57
  width: 300,
47
58
  quality: 80,
@@ -96,10 +107,12 @@ function MediaListContent({
96
107
  mediaList,
97
108
  r2PublicUrl,
98
109
  imageTransformUrl,
110
+ s3PublicUrl,
99
111
  }: {
100
112
  mediaList: Media[];
101
113
  r2PublicUrl?: string;
102
114
  imageTransformUrl?: string;
115
+ s3PublicUrl?: string;
103
116
  }) {
104
117
  const { t } = useLingui();
105
118
 
@@ -187,6 +200,7 @@ function MediaListContent({
187
200
  media={m}
188
201
  r2PublicUrl={r2PublicUrl}
189
202
  imageTransformUrl={imageTransformUrl}
203
+ s3PublicUrl={s3PublicUrl}
190
204
  />
191
205
  ))}
192
206
  </div>
@@ -217,13 +231,20 @@ function ViewMediaContent({
217
231
  media,
218
232
  r2PublicUrl,
219
233
  imageTransformUrl,
234
+ s3PublicUrl,
220
235
  }: {
221
236
  media: Media;
222
237
  r2PublicUrl?: string;
223
238
  imageTransformUrl?: string;
239
+ s3PublicUrl?: string;
224
240
  }) {
225
241
  const { t } = useLingui();
226
- const url = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
242
+ const publicUrl = getPublicUrlForProvider(
243
+ media.provider,
244
+ r2PublicUrl,
245
+ s3PublicUrl,
246
+ );
247
+ const url = getMediaUrl(media.id, media.storageKey, publicUrl);
227
248
  const thumbnailUrl = getImageUrl(url, imageTransformUrl, {
228
249
  width: 600,
229
250
  quality: 85,
@@ -401,6 +422,7 @@ mediaRoutes.get("/", async (c) => {
401
422
  const siteName = await getSiteName(c);
402
423
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
403
424
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
425
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
404
426
 
405
427
  return c.html(
406
428
  <DashLayout
@@ -413,6 +435,7 @@ mediaRoutes.get("/", async (c) => {
413
435
  mediaList={mediaList}
414
436
  r2PublicUrl={r2PublicUrl}
415
437
  imageTransformUrl={imageTransformUrl}
438
+ s3PublicUrl={s3PublicUrl}
416
439
  />
417
440
  </DashLayout>,
418
441
  );
@@ -424,6 +447,7 @@ mediaRoutes.get("/picker", async (c) => {
424
447
  const mediaList = await c.var.services.media.list(100);
425
448
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
426
449
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
450
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
427
451
 
428
452
  if (mediaList.length === 0) {
429
453
  return c.html(
@@ -438,7 +462,12 @@ mediaRoutes.get("/picker", async (c) => {
438
462
  {mediaList
439
463
  .filter((m) => m.mimeType.startsWith("image/"))
440
464
  .map((m) => {
441
- const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
465
+ const pUrl = getPublicUrlForProvider(
466
+ m.provider,
467
+ r2PublicUrl,
468
+ s3PublicUrl,
469
+ );
470
+ const url = getMediaUrl(m.id, m.storageKey, pUrl);
442
471
  const thumbUrl = getImageUrl(url, imageTransformUrl, {
443
472
  width: 150,
444
473
  quality: 80,
@@ -477,6 +506,7 @@ mediaRoutes.get("/:id", async (c) => {
477
506
  const siteName = await getSiteName(c);
478
507
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
479
508
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
509
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
480
510
 
481
511
  return c.html(
482
512
  <DashLayout
@@ -489,6 +519,7 @@ mediaRoutes.get("/:id", async (c) => {
489
519
  media={media}
490
520
  r2PublicUrl={r2PublicUrl}
491
521
  imageTransformUrl={imageTransformUrl}
522
+ s3PublicUrl={s3PublicUrl}
492
523
  />
493
524
  </DashLayout>,
494
525
  );
@@ -500,13 +531,14 @@ mediaRoutes.post("/:id/delete", async (c) => {
500
531
  const media = await c.var.services.media.getById(id);
501
532
  if (!media) return c.notFound();
502
533
 
503
- // Delete from R2
504
- if (c.env.R2) {
534
+ // Delete from storage
535
+ const storage = c.var.storage;
536
+ if (storage) {
505
537
  try {
506
- await c.env.R2.delete(media.r2Key);
538
+ await storage.delete(media.storageKey);
507
539
  } catch (err) {
508
540
  // eslint-disable-next-line no-console -- Error logging is intentional
509
- console.error("R2 delete error:", err);
541
+ console.error("Storage delete error:", err);
510
542
  }
511
543
  }
512
544