@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
@@ -4,12 +4,13 @@
4
4
  import * as sqid from "../../lib/sqid.js";
5
5
  import { CreatePostSchema, UpdatePostSchema, validateMediaForPostType } from "../../lib/schemas.js";
6
6
  import { requireAuthApi } from "../../middleware/auth.js";
7
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
7
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
8
8
  export const postsApiRoutes = new Hono();
9
9
  /**
10
10
  * Converts a Media record to a MediaAttachment API response shape.
11
- */ function toMediaAttachment(m, r2PublicUrl, imageTransformUrl) {
12
- const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
11
+ */ function toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl) {
12
+ const publicUrl = getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl);
13
+ const url = getMediaUrl(m.id, m.storageKey, publicUrl);
13
14
  const previewUrl = getImageUrl(url, imageTransformUrl, {
14
15
  width: 400,
15
16
  quality: 80,
@@ -50,11 +51,12 @@ postsApiRoutes.get("/", async (c)=>{
50
51
  const mediaMap = await c.var.services.media.getByPostIds(postIds);
51
52
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
52
53
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
54
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
53
55
  return c.json({
54
56
  posts: posts.map((p)=>({
55
57
  ...p,
56
58
  sqid: sqid.encode(p.id),
57
- mediaAttachments: (mediaMap.get(p.id) ?? []).map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
59
+ mediaAttachments: (mediaMap.get(p.id) ?? []).map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
58
60
  })),
59
61
  nextCursor: posts.length === limit ? sqid.encode(posts[posts.length - 1]?.id ?? 0) : null
60
62
  });
@@ -72,10 +74,11 @@ postsApiRoutes.get("/:id", async (c)=>{
72
74
  const mediaList = await c.var.services.media.getByPostId(post.id);
73
75
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
74
76
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
77
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
75
78
  return c.json({
76
79
  ...post,
77
80
  sqid: sqid.encode(post.id),
78
- mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
81
+ mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
79
82
  });
80
83
  });
81
84
  // Create post (requires auth)
@@ -126,10 +129,11 @@ postsApiRoutes.post("/", requireAuthApi(), async (c)=>{
126
129
  const mediaList = await c.var.services.media.getByPostId(post.id);
127
130
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
128
131
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
132
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
129
133
  return c.json({
130
134
  ...post,
131
135
  sqid: sqid.encode(post.id),
132
- mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
136
+ mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
133
137
  }, 201);
134
138
  });
135
139
  // Update post (requires auth)
@@ -195,10 +199,11 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
195
199
  const mediaList = await c.var.services.media.getByPostId(post.id);
196
200
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
197
201
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
202
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
198
203
  return c.json({
199
204
  ...post,
200
205
  sqid: sqid.encode(post.id),
201
- mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl))
206
+ mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
202
207
  });
203
208
  });
204
209
  // Delete post (requires auth)
@@ -0,0 +1,116 @@
1
+ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
2
+ /**
3
+ * Timeline API Routes
4
+ *
5
+ * Provides load-more functionality for the timeline feed via SSE.
6
+ */ import { Hono } from "hono";
7
+ import { sse } from "../../lib/sse.js";
8
+ import { buildMediaMap } from "../../lib/media-helpers.js";
9
+ import { TimelineItem } from "../../theme/components/timeline/TimelineItem.js";
10
+ import { ThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
11
+ const PAGE_SIZE = 20;
12
+ export const timelineApiRoutes = new Hono();
13
+ timelineApiRoutes.get("/", async (c)=>{
14
+ const cursorParam = c.req.query("cursor");
15
+ const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
16
+ if (!cursor || isNaN(cursor)) {
17
+ return c.json({
18
+ error: "cursor parameter required"
19
+ }, 400);
20
+ }
21
+ // Fetch one extra to determine if there are more
22
+ const posts = await c.var.services.posts.list({
23
+ visibility: [
24
+ "featured",
25
+ "quiet"
26
+ ],
27
+ excludeReplies: true,
28
+ excludeTypes: [
29
+ "page"
30
+ ],
31
+ limit: PAGE_SIZE + 1,
32
+ cursor
33
+ });
34
+ const hasMore = posts.length > PAGE_SIZE;
35
+ const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
36
+ if (displayPosts.length === 0) {
37
+ return sse(c, async (stream)=>{
38
+ stream.remove("#load-more-container");
39
+ });
40
+ }
41
+ // Build media map
42
+ const postIds = displayPosts.map((p)=>p.id);
43
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
44
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
45
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
46
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
47
+ const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl, s3PublicUrl);
48
+ // Get reply counts to identify thread roots
49
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
50
+ const threadRootIds = postIds.filter((id)=>(replyCounts.get(id) ?? 0) > 0);
51
+ // Get thread previews
52
+ const threadPreviews = await c.var.services.posts.getThreadPreviews(threadRootIds, 3);
53
+ // Load media for preview replies
54
+ const previewReplyIds = [];
55
+ for (const replies of threadPreviews.values()){
56
+ for (const reply of replies){
57
+ previewReplyIds.push(reply.id);
58
+ }
59
+ }
60
+ const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), r2PublicUrl, imageTransformUrl, s3PublicUrl) : new Map();
61
+ // Assemble timeline items
62
+ const items = displayPosts.map((post)=>{
63
+ const postWithMedia = {
64
+ ...post,
65
+ mediaAttachments: mediaMap.get(post.id) ?? []
66
+ };
67
+ const replyCount = replyCounts.get(post.id) ?? 0;
68
+ const previewReplies = threadPreviews.get(post.id);
69
+ if (replyCount > 0 && previewReplies) {
70
+ return {
71
+ post: postWithMedia,
72
+ threadPreview: {
73
+ replies: previewReplies.map((r)=>({
74
+ ...r,
75
+ mediaAttachments: previewMediaMap.get(r.id) ?? []
76
+ })),
77
+ totalReplyCount: replyCount
78
+ }
79
+ };
80
+ }
81
+ return {
82
+ post: postWithMedia
83
+ };
84
+ });
85
+ // Render items to HTML
86
+ const itemsHtml = items.map((item)=>{
87
+ if (item.threadPreview) {
88
+ return /*#__PURE__*/ _jsx(ThreadPreview, {
89
+ rootPost: item.post,
90
+ previewReplies: item.threadPreview.replies,
91
+ totalReplyCount: item.threadPreview.totalReplyCount
92
+ });
93
+ }
94
+ return /*#__PURE__*/ _jsx(TimelineItem, {
95
+ item: item
96
+ });
97
+ }).map((jsx)=>jsx.toString()).join("");
98
+ // Determine next cursor
99
+ const lastPost = displayPosts[displayPosts.length - 1];
100
+ const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
101
+ // Build load-more button HTML
102
+ const loadMoreHtml = nextCursor ? `<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>` : "";
103
+ return sse(c, async (stream)=>{
104
+ // Append new items to the feed
105
+ stream.patchElements(itemsHtml, {
106
+ mode: "append",
107
+ selector: "#timeline-feed"
108
+ });
109
+ // Replace or remove the load-more container
110
+ if (loadMoreHtml) {
111
+ stream.patchElements(loadMoreHtml);
112
+ } else {
113
+ stream.remove("#load-more-container");
114
+ }
115
+ });
116
+ });
@@ -5,16 +5,17 @@
5
5
  * Supports both JSON and SSE (Datastar) responses.
6
6
  */ import { Hono } from "hono";
7
7
  import { html } from "hono/html";
8
+ import { uuidv7 } from "uuidv7";
8
9
  import { requireAuthApi } from "../../middleware/auth.js";
9
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
10
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
10
11
  import { sse, dsSignals } from "../../lib/sse.js";
11
12
  export const uploadApiRoutes = new Hono();
12
13
  // Require auth for all upload routes
13
14
  uploadApiRoutes.use("*", requireAuthApi());
14
15
  /**
15
16
  * Render a media card HTML string for SSE response
16
- */ function renderMediaCard(media, r2PublicUrl, imageTransformUrl) {
17
- const fullUrl = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
17
+ */ function renderMediaCard(media, publicUrl, imageTransformUrl) {
18
+ const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
18
19
  const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
19
20
  width: 300,
20
21
  quality: 80,
@@ -86,14 +87,15 @@ function formatSize(bytes) {
86
87
  }
87
88
  // Upload a file
88
89
  uploadApiRoutes.post("/", async (c)=>{
89
- if (!c.env.R2) {
90
+ const storage = c.var.storage;
91
+ if (!storage) {
90
92
  if (wantsSSE(c)) {
91
93
  return dsSignals({
92
- _uploadError: "R2 storage not configured"
94
+ _uploadError: "Storage not configured"
93
95
  });
94
96
  }
95
97
  return c.json({
96
- error: "R2 storage not configured"
98
+ error: "Storage not configured"
97
99
  }, 500);
98
100
  }
99
101
  const formData = await c.req.formData();
@@ -138,30 +140,34 @@ uploadApiRoutes.post("/", async (c)=>{
138
140
  error: "File too large (max 10MB)"
139
141
  }, 400);
140
142
  }
141
- // Generate unique filename
143
+ // Generate unique filename using UUIDv7
142
144
  const ext = file.name.split(".").pop() || "bin";
143
- const timestamp = Date.now();
144
- const random = Math.random().toString(36).substring(2, 8);
145
- const filename = `${timestamp}-${random}.${ext}`;
146
- const r2Key = `uploads/${filename}`;
145
+ const id = uuidv7();
146
+ const date = new Date();
147
+ const year = date.getUTCFullYear();
148
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
149
+ const filename = `${id}.${ext}`;
150
+ const storageKey = `media/${year}/${month}/${filename}`;
147
151
  try {
148
- // Upload to R2
149
- await c.env.R2.put(r2Key, file.stream(), {
150
- httpMetadata: {
151
- contentType: file.type
152
- }
152
+ // Upload to storage
153
+ await storage.put(storageKey, file.stream(), {
154
+ contentType: file.type
153
155
  });
154
156
  // Save to database
155
157
  const media = await c.var.services.media.create({
158
+ id,
156
159
  filename,
157
160
  originalName: file.name,
158
161
  mimeType: file.type,
159
162
  size: file.size,
160
- r2Key
163
+ storageKey,
164
+ provider: c.env.STORAGE_DRIVER || "r2"
161
165
  });
162
166
  // SSE response for Datastar
163
167
  if (wantsSSE(c)) {
164
- const cardHtml = renderMediaCard(media, c.env.R2_PUBLIC_URL, c.env.IMAGE_TRANSFORM_URL);
168
+ const provider = c.env.STORAGE_DRIVER || "r2";
169
+ const mediaPublicUrl = getPublicUrlForProvider(provider, c.env.R2_PUBLIC_URL, c.env.S3_PUBLIC_URL);
170
+ const cardHtml = renderMediaCard(media, mediaPublicUrl, c.env.IMAGE_TRANSFORM_URL);
165
171
  return sse(c, async (stream)=>{
166
172
  // Replace placeholder with real media card
167
173
  await stream.patchElements(cardHtml, {
@@ -172,7 +178,9 @@ uploadApiRoutes.post("/", async (c)=>{
172
178
  });
173
179
  }
174
180
  // JSON response for API clients
175
- const publicUrl = getMediaUrl(media.id, r2Key, c.env.R2_PUBLIC_URL);
181
+ const provider = c.env.STORAGE_DRIVER || "r2";
182
+ const mediaPublicUrl = getPublicUrlForProvider(provider, c.env.R2_PUBLIC_URL, c.env.S3_PUBLIC_URL);
183
+ const publicUrl = getMediaUrl(media.id, storageKey, mediaPublicUrl);
176
184
  return c.json({
177
185
  id: media.id,
178
186
  filename: media.filename,
@@ -198,11 +206,13 @@ uploadApiRoutes.post("/", async (c)=>{
198
206
  uploadApiRoutes.get("/", async (c)=>{
199
207
  const limit = parseInt(c.req.query("limit") ?? "50", 10);
200
208
  const mediaList = await c.var.services.media.list(limit);
209
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
210
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
201
211
  return c.json({
202
212
  media: mediaList.map((m)=>({
203
213
  id: m.id,
204
214
  filename: m.filename,
205
- url: getMediaUrl(m.id, m.r2Key, c.env.R2_PUBLIC_URL),
215
+ url: getMediaUrl(m.id, m.storageKey, getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl)),
206
216
  mimeType: m.mimeType,
207
217
  size: m.size,
208
218
  createdAt: m.createdAt
@@ -218,13 +228,14 @@ uploadApiRoutes.delete("/:id", async (c)=>{
218
228
  error: "Not found"
219
229
  }, 404);
220
230
  }
221
- // Delete from R2
222
- if (c.env.R2) {
231
+ // Delete from storage
232
+ const storage = c.var.storage;
233
+ if (storage) {
223
234
  try {
224
- await c.env.R2.delete(media.r2Key);
235
+ await storage.delete(media.storageKey);
225
236
  } catch (err) {
226
237
  // eslint-disable-next-line no-console -- Error logging is intentional
227
- console.error("R2 delete error:", err);
238
+ console.error("Storage delete error:", err);
228
239
  }
229
240
  }
230
241
  // Delete from database
@@ -10,7 +10,7 @@ import { useLingui as $_useLingui } from "@jant/core/i18n";
10
10
  import { DashLayout } from "../../theme/layouts/index.js";
11
11
  import { EmptyState, DangerZone } from "../../theme/components/index.js";
12
12
  import * as time from "../../lib/time.js";
13
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
13
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
14
14
  import { dsRedirect } from "../../lib/sse.js";
15
15
  export const mediaRoutes = new Hono();
16
16
  /**
@@ -22,8 +22,9 @@ export const mediaRoutes = new Hono();
22
22
  }
23
23
  /**
24
24
  * Media card component for the grid
25
- */ function MediaCard({ media, r2PublicUrl, imageTransformUrl }) {
26
- const fullUrl = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
25
+ */ function MediaCard({ media, r2PublicUrl, imageTransformUrl, s3PublicUrl }) {
26
+ const publicUrl = getPublicUrlForProvider(media.provider, r2PublicUrl, s3PublicUrl);
27
+ const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
27
28
  const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
28
29
  width: 300,
29
30
  quality: 80,
@@ -73,7 +74,7 @@ export const mediaRoutes = new Hono();
73
74
  * Media list page content
74
75
  *
75
76
  * Upload is handled by media-upload.ts (client module) + Datastar @post for SSE.
76
- */ function MediaListContent({ mediaList, r2PublicUrl, imageTransformUrl }) {
77
+ */ function MediaListContent({ mediaList, r2PublicUrl, imageTransformUrl, s3PublicUrl }) {
77
78
  const { i18n: $__i18n, _: $__ } = $_useLingui();
78
79
  const processingText = $__i18n._({
79
80
  id: "k1ifdL",
@@ -161,7 +162,8 @@ export const mediaRoutes = new Hono();
161
162
  children: mediaList.map((m)=>/*#__PURE__*/ _jsx(MediaCard, {
162
163
  media: m,
163
164
  r2PublicUrl: r2PublicUrl,
164
- imageTransformUrl: imageTransformUrl
165
+ imageTransformUrl: imageTransformUrl,
166
+ s3PublicUrl: s3PublicUrl
165
167
  }, m.id))
166
168
  })
167
169
  }),
@@ -181,9 +183,10 @@ export const mediaRoutes = new Hono();
181
183
  }
182
184
  /**
183
185
  * View single media content
184
- */ function ViewMediaContent({ media, r2PublicUrl, imageTransformUrl }) {
186
+ */ function ViewMediaContent({ media, r2PublicUrl, imageTransformUrl, s3PublicUrl }) {
185
187
  const { i18n: $__i18n, _: $__ } = $_useLingui();
186
- const url = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
188
+ const publicUrl = getPublicUrlForProvider(media.provider, r2PublicUrl, s3PublicUrl);
189
+ const url = getMediaUrl(media.id, media.storageKey, publicUrl);
187
190
  const thumbnailUrl = getImageUrl(url, imageTransformUrl, {
188
191
  width: 600,
189
192
  quality: 85,
@@ -386,6 +389,7 @@ mediaRoutes.get("/", async (c)=>{
386
389
  const siteName = await getSiteName(c);
387
390
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
388
391
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
392
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
389
393
  return c.html(/*#__PURE__*/ _jsx(DashLayout, {
390
394
  c: c,
391
395
  title: "Media",
@@ -394,7 +398,8 @@ mediaRoutes.get("/", async (c)=>{
394
398
  children: /*#__PURE__*/ _jsx(MediaListContent, {
395
399
  mediaList: mediaList,
396
400
  r2PublicUrl: r2PublicUrl,
397
- imageTransformUrl: imageTransformUrl
401
+ imageTransformUrl: imageTransformUrl,
402
+ s3PublicUrl: s3PublicUrl
398
403
  })
399
404
  }));
400
405
  });
@@ -404,6 +409,7 @@ mediaRoutes.get("/picker", async (c)=>{
404
409
  const mediaList = await c.var.services.media.list(100);
405
410
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
406
411
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
412
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
407
413
  if (mediaList.length === 0) {
408
414
  return c.html(/*#__PURE__*/ _jsx("p", {
409
415
  class: "text-muted-foreground text-sm col-span-4",
@@ -412,7 +418,8 @@ mediaRoutes.get("/picker", async (c)=>{
412
418
  }
413
419
  return c.html(/*#__PURE__*/ _jsx(_Fragment, {
414
420
  children: mediaList.filter((m)=>m.mimeType.startsWith("image/")).map((m)=>{
415
- const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
421
+ const pUrl = getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl);
422
+ const url = getMediaUrl(m.id, m.storageKey, pUrl);
416
423
  const thumbUrl = getImageUrl(url, imageTransformUrl, {
417
424
  width: 150,
418
425
  quality: 80,
@@ -444,6 +451,7 @@ mediaRoutes.get("/:id", async (c)=>{
444
451
  const siteName = await getSiteName(c);
445
452
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
446
453
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
454
+ const s3PublicUrl = c.env.S3_PUBLIC_URL;
447
455
  return c.html(/*#__PURE__*/ _jsx(DashLayout, {
448
456
  c: c,
449
457
  title: media.originalName,
@@ -452,7 +460,8 @@ mediaRoutes.get("/:id", async (c)=>{
452
460
  children: /*#__PURE__*/ _jsx(ViewMediaContent, {
453
461
  media: media,
454
462
  r2PublicUrl: r2PublicUrl,
455
- imageTransformUrl: imageTransformUrl
463
+ imageTransformUrl: imageTransformUrl,
464
+ s3PublicUrl: s3PublicUrl
456
465
  })
457
466
  }));
458
467
  });
@@ -461,13 +470,14 @@ mediaRoutes.post("/:id/delete", async (c)=>{
461
470
  const id = c.req.param("id");
462
471
  const media = await c.var.services.media.getById(id);
463
472
  if (!media) return c.notFound();
464
- // Delete from R2
465
- if (c.env.R2) {
473
+ // Delete from storage
474
+ const storage = c.var.storage;
475
+ if (storage) {
466
476
  try {
467
- await c.env.R2.delete(media.r2Key);
477
+ await storage.delete(media.storageKey);
468
478
  } catch (err) {
469
479
  // eslint-disable-next-line no-console -- Error logging is intentional
470
- console.error("R2 delete error:", err);
480
+ console.error("Storage delete error:", err);
471
481
  }
472
482
  }
473
483
  // Delete from database