@jant/core 0.3.6 → 0.3.8

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 (264) hide show
  1. package/dist/app.js +11 -21
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +15 -0
  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/index.js +1 -1
  8. package/dist/lib/image.js +3 -3
  9. package/dist/lib/media-helpers.js +43 -0
  10. package/dist/lib/nav-reorder.js +27 -0
  11. package/dist/lib/navigation.js +35 -0
  12. package/dist/lib/schemas.js +32 -2
  13. package/dist/lib/sse.js +7 -8
  14. package/dist/lib/theme-components.js +49 -0
  15. package/dist/routes/api/posts.js +101 -5
  16. package/dist/routes/api/timeline.js +115 -0
  17. package/dist/routes/api/upload.js +9 -5
  18. package/dist/routes/dash/media.js +38 -0
  19. package/dist/routes/dash/navigation.js +274 -0
  20. package/dist/routes/dash/posts.js +45 -6
  21. package/dist/routes/feed/rss.js +10 -1
  22. package/dist/routes/pages/archive.js +14 -27
  23. package/dist/routes/pages/collection.js +10 -19
  24. package/dist/routes/pages/home.js +88 -98
  25. package/dist/routes/pages/page.js +19 -38
  26. package/dist/routes/pages/post.js +61 -48
  27. package/dist/routes/pages/search.js +13 -26
  28. package/dist/services/collection.js +13 -0
  29. package/dist/services/index.js +3 -1
  30. package/dist/services/media.js +55 -2
  31. package/dist/services/navigation.js +115 -0
  32. package/dist/services/post.js +26 -1
  33. package/dist/theme/components/MediaGallery.js +107 -0
  34. package/dist/theme/components/PostForm.js +158 -2
  35. package/dist/theme/components/PostList.js +5 -0
  36. package/dist/theme/components/index.js +3 -0
  37. package/dist/theme/components/timeline/ArticleCard.js +50 -0
  38. package/dist/theme/components/timeline/ImageCard.js +86 -0
  39. package/dist/theme/components/timeline/LinkCard.js +62 -0
  40. package/dist/theme/components/timeline/NoteCard.js +37 -0
  41. package/dist/theme/components/timeline/QuoteCard.js +51 -0
  42. package/dist/theme/components/timeline/ThreadPreview.js +52 -0
  43. package/dist/theme/components/timeline/TimelineFeed.js +43 -0
  44. package/dist/theme/components/timeline/TimelineItem.js +25 -0
  45. package/dist/theme/components/timeline/index.js +8 -0
  46. package/dist/theme/layouts/DashLayout.js +8 -0
  47. package/dist/theme/layouts/SiteLayout.js +160 -0
  48. package/dist/theme/layouts/index.js +1 -0
  49. package/dist/types/sortablejs.d.js +5 -0
  50. package/dist/types.js +27 -0
  51. package/package.json +3 -2
  52. package/src/__tests__/helpers/app.ts +6 -1
  53. package/src/__tests__/helpers/db.ts +20 -0
  54. package/src/app.tsx +11 -25
  55. package/src/client.ts +1 -0
  56. package/src/db/migrations/0002_add_media_attachments.sql +3 -0
  57. package/src/db/migrations/0003_add_navigation_links.sql +8 -0
  58. package/src/db/migrations/meta/0003_snapshot.json +821 -0
  59. package/src/db/migrations/meta/_journal.json +14 -0
  60. package/src/db/schema.ts +15 -0
  61. package/src/i18n/locales/en.po +170 -58
  62. package/src/i18n/locales/en.ts +1 -1
  63. package/src/i18n/locales/zh-Hans.po +162 -71
  64. package/src/i18n/locales/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/zh-Hant.po +162 -71
  66. package/src/i18n/locales/zh-Hant.ts +1 -1
  67. package/src/index.ts +13 -1
  68. package/src/lib/__tests__/schemas.test.ts +89 -1
  69. package/src/lib/__tests__/sse.test.ts +13 -1
  70. package/src/lib/__tests__/theme-components.test.ts +107 -0
  71. package/src/lib/image.ts +3 -3
  72. package/src/lib/media-helpers.ts +54 -0
  73. package/src/lib/nav-reorder.ts +26 -0
  74. package/src/lib/navigation.ts +46 -0
  75. package/src/lib/schemas.ts +47 -1
  76. package/src/lib/sse.ts +10 -11
  77. package/src/lib/theme-components.ts +76 -0
  78. package/src/routes/api/__tests__/posts.test.ts +239 -0
  79. package/src/routes/api/__tests__/timeline.test.ts +242 -0
  80. package/src/routes/api/posts.ts +134 -5
  81. package/src/routes/api/timeline.tsx +145 -0
  82. package/src/routes/api/upload.ts +9 -5
  83. package/src/routes/dash/media.tsx +50 -0
  84. package/src/routes/dash/navigation.tsx +306 -0
  85. package/src/routes/dash/posts.tsx +79 -7
  86. package/src/routes/feed/rss.ts +14 -1
  87. package/src/routes/pages/archive.tsx +15 -23
  88. package/src/routes/pages/collection.tsx +8 -15
  89. package/src/routes/pages/home.tsx +121 -88
  90. package/src/routes/pages/page.tsx +17 -30
  91. package/src/routes/pages/post.tsx +64 -40
  92. package/src/routes/pages/search.tsx +18 -22
  93. package/src/services/__tests__/collection.test.ts +102 -0
  94. package/src/services/__tests__/media.test.ts +282 -7
  95. package/src/services/__tests__/navigation.test.ts +213 -0
  96. package/src/services/__tests__/post-timeline.test.ts +220 -0
  97. package/src/services/collection.ts +19 -0
  98. package/src/services/index.ts +7 -0
  99. package/src/services/media.ts +78 -2
  100. package/src/services/navigation.ts +165 -0
  101. package/src/services/post.ts +48 -1
  102. package/src/styles/components.css +59 -0
  103. package/src/theme/components/MediaGallery.tsx +128 -0
  104. package/src/theme/components/PostForm.tsx +170 -2
  105. package/src/theme/components/PostList.tsx +7 -0
  106. package/src/theme/components/index.ts +13 -0
  107. package/src/theme/components/timeline/ArticleCard.tsx +57 -0
  108. package/src/theme/components/timeline/ImageCard.tsx +80 -0
  109. package/src/theme/components/timeline/LinkCard.tsx +66 -0
  110. package/src/theme/components/timeline/NoteCard.tsx +41 -0
  111. package/src/theme/components/timeline/QuoteCard.tsx +55 -0
  112. package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
  113. package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
  114. package/src/theme/components/timeline/TimelineItem.tsx +39 -0
  115. package/src/theme/components/timeline/index.ts +8 -0
  116. package/src/theme/layouts/DashLayout.tsx +10 -0
  117. package/src/theme/layouts/SiteLayout.tsx +184 -0
  118. package/src/theme/layouts/index.ts +1 -0
  119. package/src/types/sortablejs.d.ts +23 -0
  120. package/src/types.ts +97 -0
  121. package/dist/app.d.ts +0 -38
  122. package/dist/app.d.ts.map +0 -1
  123. package/dist/auth.d.ts +0 -25
  124. package/dist/auth.d.ts.map +0 -1
  125. package/dist/db/index.d.ts +0 -10
  126. package/dist/db/index.d.ts.map +0 -1
  127. package/dist/db/schema.d.ts +0 -1507
  128. package/dist/db/schema.d.ts.map +0 -1
  129. package/dist/i18n/Trans.d.ts +0 -25
  130. package/dist/i18n/Trans.d.ts.map +0 -1
  131. package/dist/i18n/context.d.ts +0 -69
  132. package/dist/i18n/context.d.ts.map +0 -1
  133. package/dist/i18n/detect.d.ts +0 -20
  134. package/dist/i18n/detect.d.ts.map +0 -1
  135. package/dist/i18n/i18n.d.ts +0 -32
  136. package/dist/i18n/i18n.d.ts.map +0 -1
  137. package/dist/i18n/index.d.ts +0 -41
  138. package/dist/i18n/index.d.ts.map +0 -1
  139. package/dist/i18n/locales/en.d.ts +0 -3
  140. package/dist/i18n/locales/en.d.ts.map +0 -1
  141. package/dist/i18n/locales/zh-Hans.d.ts +0 -3
  142. package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
  143. package/dist/i18n/locales/zh-Hant.d.ts +0 -3
  144. package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
  145. package/dist/i18n/locales.d.ts +0 -11
  146. package/dist/i18n/locales.d.ts.map +0 -1
  147. package/dist/i18n/middleware.d.ts +0 -21
  148. package/dist/i18n/middleware.d.ts.map +0 -1
  149. package/dist/index.d.ts +0 -16
  150. package/dist/index.d.ts.map +0 -1
  151. package/dist/lib/config.d.ts +0 -83
  152. package/dist/lib/config.d.ts.map +0 -1
  153. package/dist/lib/constants.d.ts +0 -37
  154. package/dist/lib/constants.d.ts.map +0 -1
  155. package/dist/lib/image.d.ts +0 -73
  156. package/dist/lib/image.d.ts.map +0 -1
  157. package/dist/lib/index.d.ts +0 -9
  158. package/dist/lib/index.d.ts.map +0 -1
  159. package/dist/lib/markdown.d.ts +0 -60
  160. package/dist/lib/markdown.d.ts.map +0 -1
  161. package/dist/lib/schemas.d.ts +0 -113
  162. package/dist/lib/schemas.d.ts.map +0 -1
  163. package/dist/lib/sqid.d.ts +0 -60
  164. package/dist/lib/sqid.d.ts.map +0 -1
  165. package/dist/lib/sse.d.ts +0 -192
  166. package/dist/lib/sse.d.ts.map +0 -1
  167. package/dist/lib/theme.d.ts +0 -44
  168. package/dist/lib/theme.d.ts.map +0 -1
  169. package/dist/lib/time.d.ts +0 -90
  170. package/dist/lib/time.d.ts.map +0 -1
  171. package/dist/lib/url.d.ts +0 -82
  172. package/dist/lib/url.d.ts.map +0 -1
  173. package/dist/middleware/auth.d.ts +0 -24
  174. package/dist/middleware/auth.d.ts.map +0 -1
  175. package/dist/middleware/onboarding.d.ts +0 -26
  176. package/dist/middleware/onboarding.d.ts.map +0 -1
  177. package/dist/routes/api/posts.d.ts +0 -13
  178. package/dist/routes/api/posts.d.ts.map +0 -1
  179. package/dist/routes/api/search.d.ts +0 -13
  180. package/dist/routes/api/search.d.ts.map +0 -1
  181. package/dist/routes/api/upload.d.ts +0 -16
  182. package/dist/routes/api/upload.d.ts.map +0 -1
  183. package/dist/routes/dash/collections.d.ts +0 -13
  184. package/dist/routes/dash/collections.d.ts.map +0 -1
  185. package/dist/routes/dash/index.d.ts +0 -15
  186. package/dist/routes/dash/index.d.ts.map +0 -1
  187. package/dist/routes/dash/media.d.ts +0 -16
  188. package/dist/routes/dash/media.d.ts.map +0 -1
  189. package/dist/routes/dash/pages.d.ts +0 -15
  190. package/dist/routes/dash/pages.d.ts.map +0 -1
  191. package/dist/routes/dash/posts.d.ts +0 -13
  192. package/dist/routes/dash/posts.d.ts.map +0 -1
  193. package/dist/routes/dash/redirects.d.ts +0 -13
  194. package/dist/routes/dash/redirects.d.ts.map +0 -1
  195. package/dist/routes/dash/settings.d.ts +0 -15
  196. package/dist/routes/dash/settings.d.ts.map +0 -1
  197. package/dist/routes/feed/rss.d.ts +0 -13
  198. package/dist/routes/feed/rss.d.ts.map +0 -1
  199. package/dist/routes/feed/sitemap.d.ts +0 -13
  200. package/dist/routes/feed/sitemap.d.ts.map +0 -1
  201. package/dist/routes/pages/archive.d.ts +0 -15
  202. package/dist/routes/pages/archive.d.ts.map +0 -1
  203. package/dist/routes/pages/collection.d.ts +0 -13
  204. package/dist/routes/pages/collection.d.ts.map +0 -1
  205. package/dist/routes/pages/home.d.ts +0 -13
  206. package/dist/routes/pages/home.d.ts.map +0 -1
  207. package/dist/routes/pages/page.d.ts +0 -15
  208. package/dist/routes/pages/page.d.ts.map +0 -1
  209. package/dist/routes/pages/post.d.ts +0 -13
  210. package/dist/routes/pages/post.d.ts.map +0 -1
  211. package/dist/routes/pages/search.d.ts +0 -13
  212. package/dist/routes/pages/search.d.ts.map +0 -1
  213. package/dist/services/collection.d.ts +0 -31
  214. package/dist/services/collection.d.ts.map +0 -1
  215. package/dist/services/index.d.ts +0 -28
  216. package/dist/services/index.d.ts.map +0 -1
  217. package/dist/services/media.d.ts +0 -27
  218. package/dist/services/media.d.ts.map +0 -1
  219. package/dist/services/post.d.ts +0 -31
  220. package/dist/services/post.d.ts.map +0 -1
  221. package/dist/services/redirect.d.ts +0 -15
  222. package/dist/services/redirect.d.ts.map +0 -1
  223. package/dist/services/search.d.ts +0 -26
  224. package/dist/services/search.d.ts.map +0 -1
  225. package/dist/services/settings.d.ts +0 -18
  226. package/dist/services/settings.d.ts.map +0 -1
  227. package/dist/theme/color-themes.d.ts +0 -30
  228. package/dist/theme/color-themes.d.ts.map +0 -1
  229. package/dist/theme/components/ActionButtons.d.ts +0 -43
  230. package/dist/theme/components/ActionButtons.d.ts.map +0 -1
  231. package/dist/theme/components/CrudPageHeader.d.ts +0 -23
  232. package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
  233. package/dist/theme/components/DangerZone.d.ts +0 -36
  234. package/dist/theme/components/DangerZone.d.ts.map +0 -1
  235. package/dist/theme/components/EmptyState.d.ts +0 -27
  236. package/dist/theme/components/EmptyState.d.ts.map +0 -1
  237. package/dist/theme/components/ListItemRow.d.ts +0 -15
  238. package/dist/theme/components/ListItemRow.d.ts.map +0 -1
  239. package/dist/theme/components/PageForm.d.ts +0 -14
  240. package/dist/theme/components/PageForm.d.ts.map +0 -1
  241. package/dist/theme/components/Pagination.d.ts +0 -46
  242. package/dist/theme/components/Pagination.d.ts.map +0 -1
  243. package/dist/theme/components/PostForm.d.ts +0 -11
  244. package/dist/theme/components/PostForm.d.ts.map +0 -1
  245. package/dist/theme/components/PostList.d.ts +0 -10
  246. package/dist/theme/components/PostList.d.ts.map +0 -1
  247. package/dist/theme/components/ThreadView.d.ts +0 -15
  248. package/dist/theme/components/ThreadView.d.ts.map +0 -1
  249. package/dist/theme/components/TypeBadge.d.ts +0 -12
  250. package/dist/theme/components/TypeBadge.d.ts.map +0 -1
  251. package/dist/theme/components/VisibilityBadge.d.ts +0 -12
  252. package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
  253. package/dist/theme/components/index.d.ts +0 -13
  254. package/dist/theme/components/index.d.ts.map +0 -1
  255. package/dist/theme/index.d.ts +0 -21
  256. package/dist/theme/index.d.ts.map +0 -1
  257. package/dist/theme/layouts/BaseLayout.d.ts +0 -23
  258. package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
  259. package/dist/theme/layouts/DashLayout.d.ts +0 -17
  260. package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
  261. package/dist/theme/layouts/index.d.ts +0 -3
  262. package/dist/theme/layouts/index.d.ts.map +0 -1
  263. package/dist/types.d.ts +0 -213
  264. package/dist/types.d.ts.map +0 -1
@@ -5,7 +5,7 @@ import { getSiteName } from "../../lib/config.js";
5
5
 
6
6
  import { Hono } from "hono";
7
7
  import { useLingui } from "@lingui/react/macro";
8
- import type { Bindings, Post } from "../../types.js";
8
+ import type { Bindings, Post, Media, Collection } from "../../types.js";
9
9
  import type { AppVariables } from "../../app.js";
10
10
  import { DashLayout } from "../../theme/layouts/index.js";
11
11
  import {
@@ -38,14 +38,14 @@ function PostsListContent({ posts }: { posts: Post[] }) {
38
38
  );
39
39
  }
40
40
 
41
- function NewPostContent() {
41
+ function NewPostContent({ collections }: { collections: Collection[] }) {
42
42
  const { t } = useLingui();
43
43
  return (
44
44
  <>
45
45
  <h1 class="text-2xl font-semibold mb-6">
46
46
  {t({ message: "New Post", comment: "@context: Page heading" })}
47
47
  </h1>
48
- <PostForm action="/dash/posts" />
48
+ <PostForm action="/dash/posts" collections={collections} />
49
49
  </>
50
50
  );
51
51
  }
@@ -72,6 +72,7 @@ postsRoutes.get("/", async (c) => {
72
72
  // New post form
73
73
  postsRoutes.get("/new", async (c) => {
74
74
  const siteName = await getSiteName(c);
75
+ const collections = await c.var.services.collections.list();
75
76
 
76
77
  return c.html(
77
78
  <DashLayout
@@ -80,7 +81,7 @@ postsRoutes.get("/new", async (c) => {
80
81
  siteName={siteName}
81
82
  currentPath="/dash/posts"
82
83
  >
83
- <NewPostContent />
84
+ <NewPostContent collections={collections} />
84
85
  </DashLayout>,
85
86
  );
86
87
  });
@@ -93,7 +94,10 @@ postsRoutes.post("/", async (c) => {
93
94
  content: string;
94
95
  visibility: string;
95
96
  sourceUrl?: string;
97
+ sourceName?: string;
96
98
  path?: string;
99
+ mediaIds?: string[];
100
+ collectionIds?: number[];
97
101
  }>();
98
102
 
99
103
  const post = await c.var.services.posts.create({
@@ -102,9 +106,23 @@ postsRoutes.post("/", async (c) => {
102
106
  content: body.content,
103
107
  visibility: body.visibility as Post["visibility"],
104
108
  sourceUrl: body.sourceUrl || undefined,
109
+ sourceName: body.sourceName || undefined,
105
110
  path: body.path || undefined,
106
111
  });
107
112
 
113
+ // Attach media if provided
114
+ if (body.mediaIds && body.mediaIds.length > 0) {
115
+ await c.var.services.media.attachToPost(post.id, body.mediaIds);
116
+ }
117
+
118
+ // Sync collection associations
119
+ if (body.collectionIds) {
120
+ await c.var.services.collections.syncPostCollections(
121
+ post.id,
122
+ body.collectionIds,
123
+ );
124
+ }
125
+
108
126
  return dsRedirect(`/dash/posts/${sqid.encode(post.id)}`);
109
127
  });
110
128
 
@@ -145,14 +163,36 @@ function ViewPostContent({ post }: { post: Post }) {
145
163
  );
146
164
  }
147
165
 
148
- function EditPostContent({ post }: { post: Post }) {
166
+ function EditPostContent({
167
+ post,
168
+ mediaAttachments,
169
+ r2PublicUrl,
170
+ imageTransformUrl,
171
+ collections,
172
+ postCollectionIds,
173
+ }: {
174
+ post: Post;
175
+ mediaAttachments: Media[];
176
+ r2PublicUrl?: string;
177
+ imageTransformUrl?: string;
178
+ collections: Collection[];
179
+ postCollectionIds: number[];
180
+ }) {
149
181
  const { t } = useLingui();
150
182
  return (
151
183
  <>
152
184
  <h1 class="text-2xl font-semibold mb-6">
153
185
  {t({ message: "Edit Post", comment: "@context: Page heading" })}
154
186
  </h1>
155
- <PostForm post={post} action={`/dash/posts/${sqid.encode(post.id)}`} />
187
+ <PostForm
188
+ post={post}
189
+ action={`/dash/posts/${sqid.encode(post.id)}`}
190
+ mediaAttachments={mediaAttachments}
191
+ r2PublicUrl={r2PublicUrl}
192
+ imageTransformUrl={imageTransformUrl}
193
+ collections={collections}
194
+ postCollectionIds={postCollectionIds}
195
+ />
156
196
  </>
157
197
  );
158
198
  }
@@ -189,6 +229,13 @@ postsRoutes.get("/:id/edit", async (c) => {
189
229
  if (!post) return c.notFound();
190
230
 
191
231
  const siteName = await getSiteName(c);
232
+ const mediaAttachments = await c.var.services.media.getByPostId(post.id);
233
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
234
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
235
+ const collections = await c.var.services.collections.list();
236
+ const postCollections =
237
+ await c.var.services.collections.getCollectionsForPost(post.id);
238
+ const postCollectionIds = postCollections.map((col) => col.id);
192
239
 
193
240
  return c.html(
194
241
  <DashLayout
@@ -197,7 +244,14 @@ postsRoutes.get("/:id/edit", async (c) => {
197
244
  siteName={siteName}
198
245
  currentPath="/dash/posts"
199
246
  >
200
- <EditPostContent post={post} />
247
+ <EditPostContent
248
+ post={post}
249
+ mediaAttachments={mediaAttachments}
250
+ r2PublicUrl={r2PublicUrl}
251
+ imageTransformUrl={imageTransformUrl}
252
+ collections={collections}
253
+ postCollectionIds={postCollectionIds}
254
+ />
201
255
  </DashLayout>,
202
256
  );
203
257
  });
@@ -213,7 +267,10 @@ postsRoutes.post("/:id", async (c) => {
213
267
  content?: string;
214
268
  visibility: string;
215
269
  sourceUrl?: string;
270
+ sourceName?: string;
216
271
  path?: string;
272
+ mediaIds?: string[];
273
+ collectionIds?: number[];
217
274
  }>();
218
275
 
219
276
  await c.var.services.posts.update(id, {
@@ -222,9 +279,23 @@ postsRoutes.post("/:id", async (c) => {
222
279
  content: body.content || null,
223
280
  visibility: body.visibility as Post["visibility"],
224
281
  sourceUrl: body.sourceUrl || null,
282
+ sourceName: body.sourceName || null,
225
283
  path: body.path || null,
226
284
  });
227
285
 
286
+ // Update media attachments if provided
287
+ if (body.mediaIds !== undefined) {
288
+ await c.var.services.media.attachToPost(id, body.mediaIds);
289
+ }
290
+
291
+ // Sync collection associations
292
+ if (body.collectionIds !== undefined) {
293
+ await c.var.services.collections.syncPostCollections(
294
+ id,
295
+ body.collectionIds,
296
+ );
297
+ }
298
+
228
299
  return dsRedirect(`/dash/posts/${sqid.encode(id)}`);
229
300
  });
230
301
 
@@ -233,6 +304,7 @@ postsRoutes.post("/:id/delete", async (c) => {
233
304
  const id = sqid.decode(c.req.param("id"));
234
305
  if (!id) return c.notFound();
235
306
 
307
+ await c.var.services.media.detachFromPost(id);
236
308
  await c.var.services.posts.delete(id);
237
309
 
238
310
  return dsRedirect("/dash/posts");
@@ -7,6 +7,7 @@ import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
8
  import * as sqid from "../../lib/sqid.js";
9
9
  import * as time from "../../lib/time.js";
10
+ import { getMediaUrl } from "../../lib/image.js";
10
11
 
11
12
  type Env = { Bindings: Bindings; Variables: AppVariables };
12
13
 
@@ -18,25 +19,37 @@ rssRoutes.get("/", async (c) => {
18
19
  const siteName = all["SITE_NAME"] ?? "Jant";
19
20
  const siteDescription = all["SITE_DESCRIPTION"] ?? "";
20
21
  const siteUrl = c.env.SITE_URL;
22
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
21
23
 
22
24
  const posts = await c.var.services.posts.list({
23
25
  visibility: ["featured", "quiet"],
24
26
  limit: 50,
25
27
  });
26
28
 
29
+ // Batch load media for enclosures
30
+ const postIds = posts.map((p) => p.id);
31
+ const mediaMap = await c.var.services.media.getByPostIds(postIds);
32
+
27
33
  const items = posts
28
34
  .map((post) => {
29
35
  const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
30
36
  const title = post.title || `Post #${post.id}`;
31
37
  const pubDate = new Date(post.publishedAt * 1000).toUTCString();
32
38
 
39
+ // Add enclosure for first media attachment
40
+ const postMedia = mediaMap.get(post.id);
41
+ const firstMedia = postMedia?.[0];
42
+ const enclosure = firstMedia
43
+ ? `\n <enclosure url="${getMediaUrl(firstMedia.id, firstMedia.r2Key, r2PublicUrl)}" length="${firstMedia.size}" type="${firstMedia.mimeType}"/>`
44
+ : "";
45
+
33
46
  return `
34
47
  <item>
35
48
  <title><![CDATA[${escapeXml(title)}]]></title>
36
49
  <link>${link}</link>
37
50
  <guid isPermaLink="true">${link}</guid>
38
51
  <pubDate>${pubDate}</pubDate>
39
- <description><![CDATA[${post.contentHtml || ""}]]></description>
52
+ <description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
40
53
  </item>`;
41
54
  })
42
55
  .join("");
@@ -1,4 +1,3 @@
1
- import { getSiteName } from "../../lib/config.js";
2
1
  /**
3
2
  * Archive Page Route
4
3
  *
@@ -9,11 +8,12 @@ import { Hono } from "hono";
9
8
  import { useLingui } from "@lingui/react/macro";
10
9
  import type { Bindings, Post, PostType } from "../../types.js";
11
10
  import type { AppVariables } from "../../app.js";
12
- import { BaseLayout } from "../../theme/layouts/index.js";
11
+ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
13
12
  import { Pagination } from "../../theme/components/index.js";
14
13
  import { POST_TYPES } from "../../types.js";
15
14
  import * as sqid from "../../lib/sqid.js";
16
15
  import * as time from "../../lib/time.js";
16
+ import { getNavigationData } from "../../lib/navigation.js";
17
17
 
18
18
  type Env = { Bindings: Bindings; Variables: AppVariables };
19
19
 
@@ -102,7 +102,7 @@ function ArchiveContent({
102
102
  : t({ message: "Archive", comment: "@context: Archive page title" });
103
103
 
104
104
  return (
105
- <div class="container py-8">
105
+ <div>
106
106
  <header class="mb-8">
107
107
  <h1 class="text-2xl font-semibold">{title}</h1>
108
108
 
@@ -202,16 +202,6 @@ function ArchiveContent({
202
202
  hasMore={hasMore}
203
203
  nextCursor={nextCursor}
204
204
  />
205
-
206
- <nav class="mt-4">
207
- <a href="/" class="text-sm hover:underline">
208
- ←{" "}
209
- {t({
210
- message: "Back to home",
211
- comment: "@context: Navigation link back to home page",
212
- })}
213
- </a>
214
- </nav>
215
205
  </div>
216
206
  );
217
207
  }
@@ -226,7 +216,7 @@ archiveRoutes.get("/", async (c) => {
226
216
  const cursorParam = c.req.query("cursor");
227
217
  const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
228
218
 
229
- const siteName = await getSiteName(c);
219
+ const navData = await getNavigationData(c);
230
220
 
231
221
  // Fetch one extra to check for more
232
222
  const posts = await c.var.services.posts.list({
@@ -264,15 +254,17 @@ archiveRoutes.get("/", async (c) => {
264
254
  }
265
255
 
266
256
  return c.html(
267
- <BaseLayout title={`Archive - ${siteName}`} c={c}>
268
- <ArchiveContent
269
- displayPosts={displayPosts}
270
- hasMore={hasMore}
271
- nextCursor={nextCursor}
272
- type={type}
273
- grouped={grouped}
274
- replyCounts={replyCounts}
275
- />
257
+ <BaseLayout title={`Archive - ${navData.siteName}`} c={c}>
258
+ <SiteLayout {...navData}>
259
+ <ArchiveContent
260
+ displayPosts={displayPosts}
261
+ hasMore={hasMore}
262
+ nextCursor={nextCursor}
263
+ type={type}
264
+ grouped={grouped}
265
+ replyCounts={replyCounts}
266
+ />
267
+ </SiteLayout>
276
268
  </BaseLayout>,
277
269
  );
278
270
  });
@@ -1,4 +1,3 @@
1
- import { getSiteName } from "../../lib/config.js";
2
1
  /**
3
2
  * Collection Page Route
4
3
  */
@@ -7,9 +6,10 @@ import { Hono } from "hono";
7
6
  import { useLingui } from "@lingui/react/macro";
8
7
  import type { Bindings, Collection, Post } from "../../types.js";
9
8
  import type { AppVariables } from "../../app.js";
10
- import { BaseLayout } from "../../theme/layouts/index.js";
9
+ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
11
10
  import * as sqid from "../../lib/sqid.js";
12
11
  import * as time from "../../lib/time.js";
12
+ import { getNavigationData } from "../../lib/navigation.js";
13
13
 
14
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
15
 
@@ -25,7 +25,7 @@ function CollectionContent({
25
25
  const { t } = useLingui();
26
26
 
27
27
  return (
28
- <div class="container py-8">
28
+ <div>
29
29
  <header class="mb-8">
30
30
  <h1 class="text-2xl font-semibold">{collection.title}</h1>
31
31
  {collection.description && (
@@ -70,15 +70,6 @@ function CollectionContent({
70
70
  ))
71
71
  )}
72
72
  </main>
73
-
74
- <nav class="mt-8">
75
- <a href="/" class="text-sm hover:underline">
76
- {t({
77
- message: "← Back to home",
78
- comment: "@context: Navigation link",
79
- })}
80
- </a>
81
- </nav>
82
73
  </div>
83
74
  );
84
75
  }
@@ -90,15 +81,17 @@ collectionRoutes.get("/:path", async (c) => {
90
81
  if (!collection) return c.notFound();
91
82
 
92
83
  const posts = await c.var.services.collections.getPosts(collection.id);
93
- const siteName = await getSiteName(c);
84
+ const navData = await getNavigationData(c);
94
85
 
95
86
  return c.html(
96
87
  <BaseLayout
97
- title={`${collection.title} - ${siteName}`}
88
+ title={`${collection.title} - ${navData.siteName}`}
98
89
  description={collection.description ?? undefined}
99
90
  c={c}
100
91
  >
101
- <CollectionContent collection={collection} posts={posts} />
92
+ <SiteLayout {...navData}>
93
+ <CollectionContent collection={collection} posts={posts} />
94
+ </SiteLayout>
102
95
  </BaseLayout>,
103
96
  );
104
97
  });
@@ -1,117 +1,150 @@
1
1
  /**
2
2
  * Home Page Route
3
+ *
4
+ * Timeline feed with per-type card components and thread previews.
3
5
  */
4
6
 
5
7
  import { Hono } from "hono";
6
8
  import { useLingui } from "@lingui/react/macro";
7
- import type { Bindings, Post } from "../../types.js";
9
+ import type { FC } from "hono/jsx";
10
+ import type {
11
+ Bindings,
12
+ PostWithMedia,
13
+ TimelineItemData,
14
+ TimelineFeedProps,
15
+ } from "../../types.js";
8
16
  import type { AppVariables } from "../../app.js";
9
- import { BaseLayout } from "../../theme/layouts/index.js";
10
- import * as sqid from "../../lib/sqid.js";
11
- import * as time from "../../lib/time.js";
12
- import { getSiteName } from "../../lib/config.js";
17
+ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
18
+ import { buildMediaMap } from "../../lib/media-helpers.js";
19
+ import { resolveTimelineFeed } from "../../lib/theme-components.js";
20
+ import { TimelineFeed as DefaultTimelineFeed } from "../../theme/components/timeline/TimelineFeed.js";
21
+ import { getNavigationData } from "../../lib/navigation.js";
13
22
 
14
23
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
24
 
25
+ const PAGE_SIZE = 20;
26
+
16
27
  export const homeRoutes = new Hono<Env>();
17
28
 
18
- function HomeContent({ siteName, posts }: { siteName: string; posts: Post[] }) {
29
+ function HomeContent({
30
+ FeedComponent,
31
+ feedProps,
32
+ }: {
33
+ FeedComponent: FC<TimelineFeedProps>;
34
+ feedProps: TimelineFeedProps;
35
+ }) {
19
36
  const { t } = useLingui();
20
37
 
21
38
  return (
22
- <div class="container py-8">
23
- <header class="mb-8 flex items-center justify-between">
24
- <h1 class="text-2xl font-semibold">{siteName}</h1>
25
- <nav class="flex items-center gap-4 text-sm">
26
- <a
27
- href="/archive"
28
- class="text-muted-foreground hover:text-foreground"
29
- >
30
- {t({
31
- message: "Archive",
32
- comment: "@context: Navigation link to archive page",
33
- })}
34
- </a>
35
- <a href="/feed" class="text-muted-foreground hover:text-foreground">
36
- RSS
37
- </a>
38
- </nav>
39
- </header>
40
-
41
- <main class="flex flex-col gap-6">
42
- {posts.length === 0 ? (
43
- <p class="text-muted-foreground">
44
- {t({
45
- message: "No posts yet.",
46
- comment: "@context: Empty state message on home page",
47
- })}
48
- </p>
49
- ) : (
50
- posts.map((post) => (
51
- <article key={post.id} class="h-entry">
52
- {post.title && (
53
- <h2 class="p-name text-lg font-medium mb-2">
54
- <a
55
- href={`/p/${sqid.encode(post.id)}`}
56
- class="u-url hover:underline"
57
- >
58
- {post.title}
59
- </a>
60
- </h2>
61
- )}
62
- <div
63
- class="e-content prose prose-sm"
64
- dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
65
- />
66
- <footer class="mt-2 text-sm text-muted-foreground">
67
- <time
68
- class="dt-published"
69
- datetime={time.toISOString(post.publishedAt)}
70
- >
71
- {time.formatDate(post.publishedAt)}
72
- </time>
73
- {post.visibility === "featured" && (
74
- <span class="ml-2 text-xs">
75
- {t({
76
- message: "Featured",
77
- comment: "@context: Post visibility badge",
78
- })}
79
- </span>
80
- )}
81
- </footer>
82
- </article>
83
- ))
84
- )}
85
- </main>
86
-
87
- {posts.length >= 20 && (
88
- <nav class="mt-8 text-center">
89
- <a
90
- href="/archive"
91
- class="text-sm text-muted-foreground hover:text-foreground"
92
- >
93
- {t({
94
- message: "View all posts →",
95
- comment: "@context: Link to view all posts on archive page",
96
- })}
97
- </a>
98
- </nav>
39
+ <>
40
+ {feedProps.items.length === 0 ? (
41
+ <p class="text-muted-foreground">
42
+ {t({
43
+ message: "No posts yet.",
44
+ comment: "@context: Empty state message on home page",
45
+ })}
46
+ </p>
47
+ ) : (
48
+ <FeedComponent {...feedProps} />
99
49
  )}
100
- </div>
50
+ </>
101
51
  );
102
52
  }
103
53
 
104
54
  homeRoutes.get("/", async (c) => {
105
- const siteName = await getSiteName(c);
55
+ const navData = await getNavigationData(c);
106
56
 
57
+ // Fetch one extra to determine if there are more
107
58
  const posts = await c.var.services.posts.list({
108
59
  visibility: ["featured", "quiet"],
109
- limit: 20,
60
+ excludeReplies: true,
61
+ excludeTypes: ["page"],
62
+ limit: PAGE_SIZE + 1,
63
+ });
64
+
65
+ const hasMore = posts.length > PAGE_SIZE;
66
+ const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
67
+
68
+ // Batch load media attachments
69
+ const postIds = displayPosts.map((p) => p.id);
70
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
71
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
72
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
73
+ const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
74
+
75
+ // Get reply counts to identify thread roots
76
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
77
+ const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
78
+
79
+ // Batch load thread previews
80
+ const threadPreviews = await c.var.services.posts.getThreadPreviews(
81
+ threadRootIds,
82
+ 3,
83
+ );
84
+
85
+ // Batch load media for preview replies
86
+ const previewReplyIds: number[] = [];
87
+ for (const replies of threadPreviews.values()) {
88
+ for (const reply of replies) {
89
+ previewReplyIds.push(reply.id);
90
+ }
91
+ }
92
+ const previewMediaMap =
93
+ previewReplyIds.length > 0
94
+ ? buildMediaMap(
95
+ await c.var.services.media.getByPostIds(previewReplyIds),
96
+ r2PublicUrl,
97
+ imageTransformUrl,
98
+ )
99
+ : new Map();
100
+
101
+ // Assemble timeline items
102
+ const items: TimelineItemData[] = displayPosts.map((post) => {
103
+ const postWithMedia: PostWithMedia = {
104
+ ...post,
105
+ mediaAttachments: mediaMap.get(post.id) ?? [],
106
+ };
107
+
108
+ const replyCount = replyCounts.get(post.id) ?? 0;
109
+ const previewReplies = threadPreviews.get(post.id);
110
+
111
+ if (replyCount > 0 && previewReplies) {
112
+ return {
113
+ post: postWithMedia,
114
+ threadPreview: {
115
+ replies: previewReplies.map((r) => ({
116
+ ...r,
117
+ mediaAttachments: previewMediaMap.get(r.id) ?? [],
118
+ })),
119
+ totalReplyCount: replyCount,
120
+ },
121
+ };
122
+ }
123
+
124
+ return { post: postWithMedia };
110
125
  });
111
126
 
127
+ // Determine next cursor
128
+ const lastPost = displayPosts[displayPosts.length - 1];
129
+ const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
130
+
131
+ // Resolve theme components
132
+ const Feed = resolveTimelineFeed(
133
+ DefaultTimelineFeed,
134
+ c.var.config.theme?.components,
135
+ );
136
+
137
+ const feedProps: TimelineFeedProps = {
138
+ items,
139
+ hasMore,
140
+ nextCursor,
141
+ };
142
+
112
143
  return c.html(
113
- <BaseLayout title={siteName} c={c}>
114
- <HomeContent siteName={siteName} posts={posts} />
144
+ <BaseLayout title={navData.siteName} c={c}>
145
+ <SiteLayout {...navData}>
146
+ <HomeContent FeedComponent={Feed} feedProps={feedProps} />
147
+ </SiteLayout>
115
148
  </BaseLayout>,
116
149
  );
117
150
  });
@@ -1,4 +1,3 @@
1
- import { getSiteName } from "../../lib/config.js";
2
1
  /**
3
2
  * Custom Page Route
4
3
  *
@@ -6,41 +5,27 @@ import { getSiteName } from "../../lib/config.js";
6
5
  */
7
6
 
8
7
  import { Hono } from "hono";
9
- import { useLingui } from "@lingui/react/macro";
10
8
  import type { Bindings, Post } from "../../types.js";
11
9
  import type { AppVariables } from "../../app.js";
12
- import { BaseLayout } from "../../theme/layouts/index.js";
10
+ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
11
+ import { getNavigationData } from "../../lib/navigation.js";
13
12
 
14
13
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
14
 
16
15
  export const pageRoutes = new Hono<Env>();
17
16
 
18
17
  function PageContent({ page }: { page: Post }) {
19
- const { t } = useLingui();
20
-
21
18
  return (
22
- <div class="container py-8 max-w-2xl">
23
- <article class="h-entry">
24
- {page.title && (
25
- <h1 class="p-name text-3xl font-semibold mb-6">{page.title}</h1>
26
- )}
27
-
28
- <div
29
- class="e-content prose"
30
- dangerouslySetInnerHTML={{ __html: page.contentHtml || "" }}
31
- />
32
- </article>
33
-
34
- <nav class="mt-8 pt-6 border-t">
35
- <a href="/" class="text-sm hover:underline">
36
- ←{" "}
37
- {t({
38
- message: "Back to home",
39
- comment: "@context: Navigation link back to home page",
40
- })}
41
- </a>
42
- </nav>
43
- </div>
19
+ <article class="h-entry">
20
+ {page.title && (
21
+ <h1 class="p-name text-3xl font-semibold mb-6">{page.title}</h1>
22
+ )}
23
+
24
+ <div
25
+ class="e-content prose"
26
+ dangerouslySetInnerHTML={{ __html: page.contentHtml || "" }}
27
+ />
28
+ </article>
44
29
  );
45
30
  }
46
31
 
@@ -61,15 +46,17 @@ pageRoutes.get("/:path", async (c) => {
61
46
  return c.notFound();
62
47
  }
63
48
 
64
- const siteName = await getSiteName(c);
49
+ const navData = await getNavigationData(c);
65
50
 
66
51
  return c.html(
67
52
  <BaseLayout
68
- title={`${page.title} - ${siteName}`}
53
+ title={`${page.title} - ${navData.siteName}`}
69
54
  description={page.content?.slice(0, 160)}
70
55
  c={c}
71
56
  >
72
- <PageContent page={page} />
57
+ <SiteLayout {...navData}>
58
+ <PageContent page={page} />
59
+ </SiteLayout>
73
60
  </BaseLayout>,
74
61
  );
75
62
  });