@jant/core 0.3.5 → 0.3.7

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 (81) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +7 -21
  3. package/dist/db/schema.d.ts +36 -0
  4. package/dist/db/schema.d.ts.map +1 -1
  5. package/dist/db/schema.js +2 -0
  6. package/dist/i18n/locales/en.d.ts.map +1 -1
  7. package/dist/i18n/locales/en.js +1 -1
  8. package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
  9. package/dist/i18n/locales/zh-Hans.js +1 -1
  10. package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
  11. package/dist/i18n/locales/zh-Hant.js +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/lib/schemas.d.ts +17 -0
  16. package/dist/lib/schemas.d.ts.map +1 -1
  17. package/dist/lib/schemas.js +32 -2
  18. package/dist/lib/sse.d.ts +3 -3
  19. package/dist/lib/sse.d.ts.map +1 -1
  20. package/dist/lib/sse.js +7 -8
  21. package/dist/routes/api/posts.d.ts.map +1 -1
  22. package/dist/routes/api/posts.js +101 -5
  23. package/dist/routes/dash/media.js +38 -0
  24. package/dist/routes/dash/posts.d.ts.map +1 -1
  25. package/dist/routes/dash/posts.js +45 -6
  26. package/dist/routes/feed/rss.d.ts.map +1 -1
  27. package/dist/routes/feed/rss.js +10 -1
  28. package/dist/routes/pages/home.d.ts.map +1 -1
  29. package/dist/routes/pages/home.js +37 -4
  30. package/dist/routes/pages/post.d.ts.map +1 -1
  31. package/dist/routes/pages/post.js +28 -2
  32. package/dist/services/collection.d.ts +1 -0
  33. package/dist/services/collection.d.ts.map +1 -1
  34. package/dist/services/collection.js +13 -0
  35. package/dist/services/media.d.ts +7 -0
  36. package/dist/services/media.d.ts.map +1 -1
  37. package/dist/services/media.js +54 -1
  38. package/dist/theme/components/MediaGallery.d.ts +13 -0
  39. package/dist/theme/components/MediaGallery.d.ts.map +1 -0
  40. package/dist/theme/components/MediaGallery.js +107 -0
  41. package/dist/theme/components/PostForm.d.ts +6 -1
  42. package/dist/theme/components/PostForm.d.ts.map +1 -1
  43. package/dist/theme/components/PostForm.js +158 -2
  44. package/dist/theme/components/index.d.ts +1 -0
  45. package/dist/theme/components/index.d.ts.map +1 -1
  46. package/dist/theme/components/index.js +1 -0
  47. package/dist/types.d.ts +24 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/types.js +27 -0
  50. package/package.json +1 -1
  51. package/src/__tests__/helpers/app.ts +6 -1
  52. package/src/__tests__/helpers/db.ts +10 -0
  53. package/src/app.tsx +7 -25
  54. package/src/db/migrations/0002_add_media_attachments.sql +3 -0
  55. package/src/db/schema.ts +2 -0
  56. package/src/i18n/locales/en.po +150 -80
  57. package/src/i18n/locales/en.ts +1 -1
  58. package/src/i18n/locales/zh-Hans.po +150 -80
  59. package/src/i18n/locales/zh-Hans.ts +1 -1
  60. package/src/i18n/locales/zh-Hant.po +150 -80
  61. package/src/i18n/locales/zh-Hant.ts +1 -1
  62. package/src/index.ts +8 -1
  63. package/src/lib/__tests__/schemas.test.ts +89 -1
  64. package/src/lib/__tests__/sse.test.ts +13 -1
  65. package/src/lib/schemas.ts +47 -1
  66. package/src/lib/sse.ts +10 -11
  67. package/src/routes/api/__tests__/posts.test.ts +239 -0
  68. package/src/routes/api/posts.ts +134 -5
  69. package/src/routes/dash/media.tsx +50 -0
  70. package/src/routes/dash/posts.tsx +79 -7
  71. package/src/routes/feed/rss.ts +14 -1
  72. package/src/routes/pages/home.tsx +80 -36
  73. package/src/routes/pages/post.tsx +36 -3
  74. package/src/services/__tests__/collection.test.ts +102 -0
  75. package/src/services/__tests__/media.test.ts +248 -0
  76. package/src/services/collection.ts +19 -0
  77. package/src/services/media.ts +76 -1
  78. package/src/theme/components/MediaGallery.tsx +128 -0
  79. package/src/theme/components/PostForm.tsx +170 -2
  80. package/src/theme/components/index.ts +1 -0
  81. package/src/types.ts +36 -0
@@ -3,16 +3,50 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import type { Bindings, PostType, Visibility } from "../../types.js";
6
+ import type { Bindings, PostType, Visibility, Media } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
8
  import * as sqid from "../../lib/sqid.js";
9
- import { CreatePostSchema, UpdatePostSchema } from "../../lib/schemas.js";
9
+ import {
10
+ CreatePostSchema,
11
+ UpdatePostSchema,
12
+ validateMediaForPostType,
13
+ } from "../../lib/schemas.js";
10
14
  import { requireAuthApi } from "../../middleware/auth.js";
15
+ import { getMediaUrl, getImageUrl } from "../../lib/image.js";
11
16
 
12
17
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
18
 
14
19
  export const postsApiRoutes = new Hono<Env>();
15
20
 
21
+ /**
22
+ * Converts a Media record to a MediaAttachment API response shape.
23
+ */
24
+ function toMediaAttachment(
25
+ m: Media,
26
+ r2PublicUrl?: string,
27
+ imageTransformUrl?: string,
28
+ ) {
29
+ const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
30
+ const previewUrl = getImageUrl(url, imageTransformUrl, {
31
+ width: 400,
32
+ quality: 80,
33
+ format: "auto",
34
+ fit: "cover",
35
+ });
36
+
37
+ return {
38
+ id: m.id,
39
+ url,
40
+ previewUrl,
41
+ alt: m.alt,
42
+ blurhash: m.blurhash,
43
+ width: m.width,
44
+ height: m.height,
45
+ position: m.position,
46
+ mimeType: m.mimeType,
47
+ };
48
+ }
49
+
16
50
  // List posts
17
51
  postsApiRoutes.get("/", async (c) => {
18
52
  const type = c.req.query("type") as PostType | undefined;
@@ -27,10 +61,19 @@ postsApiRoutes.get("/", async (c) => {
27
61
  limit,
28
62
  });
29
63
 
64
+ // Batch load media for all posts
65
+ const postIds = posts.map((p) => p.id);
66
+ const mediaMap = await c.var.services.media.getByPostIds(postIds);
67
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
68
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
69
+
30
70
  return c.json({
31
71
  posts: posts.map((p) => ({
32
72
  ...p,
33
73
  sqid: sqid.encode(p.id),
74
+ mediaAttachments: (mediaMap.get(p.id) ?? []).map((m) =>
75
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
76
+ ),
34
77
  })),
35
78
 
36
79
  nextCursor:
@@ -48,7 +91,17 @@ postsApiRoutes.get("/:id", async (c) => {
48
91
  const post = await c.var.services.posts.getById(id);
49
92
  if (!post) return c.json({ error: "Not found" }, 404);
50
93
 
51
- return c.json({ ...post, sqid: sqid.encode(post.id) });
94
+ const mediaList = await c.var.services.media.getByPostId(post.id);
95
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
96
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
97
+
98
+ return c.json({
99
+ ...post,
100
+ sqid: sqid.encode(post.id),
101
+ mediaAttachments: mediaList.map((m) =>
102
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
103
+ ),
104
+ });
52
105
  });
53
106
 
54
107
  // Create post (requires auth)
@@ -66,6 +119,22 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
66
119
 
67
120
  const body = parseResult.data;
68
121
 
122
+ // Validate media for post type
123
+ if (body.mediaIds) {
124
+ const mediaError = validateMediaForPostType(body.type, body.mediaIds);
125
+ if (mediaError) {
126
+ return c.json({ error: mediaError }, 400);
127
+ }
128
+
129
+ // Verify all media IDs exist
130
+ if (body.mediaIds.length > 0) {
131
+ const existing = await c.var.services.media.getByIds(body.mediaIds);
132
+ if (existing.length !== body.mediaIds.length) {
133
+ return c.json({ error: "One or more media IDs are invalid" }, 400);
134
+ }
135
+ }
136
+ }
137
+
69
138
  const post = await c.var.services.posts.create({
70
139
  type: body.type,
71
140
  title: body.title,
@@ -80,7 +149,25 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
80
149
  publishedAt: body.publishedAt,
81
150
  });
82
151
 
83
- return c.json({ ...post, sqid: sqid.encode(post.id) }, 201);
152
+ // Attach media
153
+ if (body.mediaIds && body.mediaIds.length > 0) {
154
+ await c.var.services.media.attachToPost(post.id, body.mediaIds);
155
+ }
156
+
157
+ const mediaList = await c.var.services.media.getByPostId(post.id);
158
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
159
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
160
+
161
+ return c.json(
162
+ {
163
+ ...post,
164
+ sqid: sqid.encode(post.id),
165
+ mediaAttachments: mediaList.map((m) =>
166
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
167
+ ),
168
+ },
169
+ 201,
170
+ );
84
171
  });
85
172
 
86
173
  // Update post (requires auth)
@@ -101,6 +188,30 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
101
188
 
102
189
  const body = parseResult.data;
103
190
 
191
+ // Validate media for post type if mediaIds is provided
192
+ if (body.mediaIds !== undefined) {
193
+ // Need the post type — use the new type if provided, else fetch existing
194
+ let postType = body.type;
195
+ if (!postType) {
196
+ const existing = await c.var.services.posts.getById(id);
197
+ if (!existing) return c.json({ error: "Not found" }, 404);
198
+ postType = existing.type;
199
+ }
200
+
201
+ const mediaError = validateMediaForPostType(postType, body.mediaIds);
202
+ if (mediaError) {
203
+ return c.json({ error: mediaError }, 400);
204
+ }
205
+
206
+ // Verify all media IDs exist
207
+ if (body.mediaIds.length > 0) {
208
+ const existing = await c.var.services.media.getByIds(body.mediaIds);
209
+ if (existing.length !== body.mediaIds.length) {
210
+ return c.json({ error: "One or more media IDs are invalid" }, 400);
211
+ }
212
+ }
213
+ }
214
+
104
215
  const post = await c.var.services.posts.update(id, {
105
216
  type: body.type,
106
217
  title: body.title,
@@ -114,7 +225,22 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
114
225
 
115
226
  if (!post) return c.json({ error: "Not found" }, 404);
116
227
 
117
- return c.json({ ...post, sqid: sqid.encode(post.id) });
228
+ // Update media attachments if provided (including empty array to clear)
229
+ if (body.mediaIds !== undefined) {
230
+ await c.var.services.media.attachToPost(post.id, body.mediaIds);
231
+ }
232
+
233
+ const mediaList = await c.var.services.media.getByPostId(post.id);
234
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
235
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
236
+
237
+ return c.json({
238
+ ...post,
239
+ sqid: sqid.encode(post.id),
240
+ mediaAttachments: mediaList.map((m) =>
241
+ toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
242
+ ),
243
+ });
118
244
  });
119
245
 
120
246
  // Delete post (requires auth)
@@ -122,6 +248,9 @@ postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
122
248
  const id = sqid.decode(c.req.param("id"));
123
249
  if (!id) return c.json({ error: "Invalid ID" }, 400);
124
250
 
251
+ // Detach media before deleting
252
+ await c.var.services.media.detachFromPost(id);
253
+
125
254
  const success = await c.var.services.posts.delete(id);
126
255
  if (!success) return c.json({ error: "Not found" }, 404);
127
256
 
@@ -418,6 +418,56 @@ mediaRoutes.get("/", async (c) => {
418
418
  );
419
419
  });
420
420
 
421
+ // Media picker (returns HTML fragment for PostForm dialog)
422
+ // Must be defined before /:id to avoid "picker" matching as an ID
423
+ mediaRoutes.get("/picker", async (c) => {
424
+ const mediaList = await c.var.services.media.list(100);
425
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
426
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
427
+
428
+ if (mediaList.length === 0) {
429
+ return c.html(
430
+ <p class="text-muted-foreground text-sm col-span-4">
431
+ No media uploaded yet. Upload media from the Media page first.
432
+ </p>,
433
+ );
434
+ }
435
+
436
+ return c.html(
437
+ <>
438
+ {mediaList
439
+ .filter((m) => m.mimeType.startsWith("image/"))
440
+ .map((m) => {
441
+ const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
442
+ const thumbUrl = getImageUrl(url, imageTransformUrl, {
443
+ width: 150,
444
+ quality: 80,
445
+ format: "auto",
446
+ fit: "cover",
447
+ });
448
+ return (
449
+ <button
450
+ key={m.id}
451
+ type="button"
452
+ class="aspect-square rounded-lg overflow-hidden border-2 hover:border-primary cursor-pointer transition-colors"
453
+ data-on:click={`$mediaIds.includes('${m.id}') ? ($mediaIds = $mediaIds.filter(id => id !== '${m.id}')) : ($mediaIds = [...$mediaIds, '${m.id}'])`}
454
+ data-class:border-primary={`$mediaIds.includes('${m.id}')`}
455
+ data-class:ring-2={`$mediaIds.includes('${m.id}')`}
456
+ data-class:ring-primary={`$mediaIds.includes('${m.id}')`}
457
+ >
458
+ <img
459
+ src={thumbUrl}
460
+ alt={m.alt || m.originalName}
461
+ class="w-full h-full object-cover"
462
+ loading="lazy"
463
+ />
464
+ </button>
465
+ );
466
+ })}
467
+ </>,
468
+ );
469
+ });
470
+
421
471
  // View single media
422
472
  mediaRoutes.get("/:id", async (c) => {
423
473
  const id = c.req.param("id");
@@ -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("");
@@ -4,18 +4,28 @@
4
4
 
5
5
  import { Hono } from "hono";
6
6
  import { useLingui } from "@lingui/react/macro";
7
- import type { Bindings, Post } from "../../types.js";
7
+ import type { Bindings, Post, MediaAttachment } from "../../types.js";
8
8
  import type { AppVariables } from "../../app.js";
9
9
  import { BaseLayout } from "../../theme/layouts/index.js";
10
+ import { MediaGallery } from "../../theme/components/index.js";
10
11
  import * as sqid from "../../lib/sqid.js";
11
12
  import * as time from "../../lib/time.js";
12
13
  import { getSiteName } from "../../lib/config.js";
14
+ import { getMediaUrl, getImageUrl } from "../../lib/image.js";
13
15
 
14
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
17
 
16
18
  export const homeRoutes = new Hono<Env>();
17
19
 
18
- function HomeContent({ siteName, posts }: { siteName: string; posts: Post[] }) {
20
+ function HomeContent({
21
+ siteName,
22
+ posts,
23
+ mediaMap,
24
+ }: {
25
+ siteName: string;
26
+ posts: Post[];
27
+ mediaMap: Map<number, MediaAttachment[]>;
28
+ }) {
19
29
  const { t } = useLingui();
20
30
 
21
31
  return (
@@ -47,40 +57,46 @@ function HomeContent({ siteName, posts }: { siteName: string; posts: Post[] }) {
47
57
  })}
48
58
  </p>
49
59
  ) : (
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>
60
+ posts.map((post) => {
61
+ const attachments = mediaMap.get(post.id) ?? [];
62
+ return (
63
+ <article key={post.id} class="h-entry">
64
+ {post.title && (
65
+ <h2 class="p-name text-lg font-medium mb-2">
66
+ <a
67
+ href={`/p/${sqid.encode(post.id)}`}
68
+ class="u-url hover:underline"
69
+ >
70
+ {post.title}
71
+ </a>
72
+ </h2>
73
+ )}
74
+ <div
75
+ class="e-content prose prose-sm"
76
+ dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
77
+ />
78
+ {attachments.length > 0 && (
79
+ <MediaGallery attachments={attachments} />
80
80
  )}
81
- </footer>
82
- </article>
83
- ))
81
+ <footer class="mt-2 text-sm text-muted-foreground">
82
+ <time
83
+ class="dt-published"
84
+ datetime={time.toISOString(post.publishedAt)}
85
+ >
86
+ {time.formatDate(post.publishedAt)}
87
+ </time>
88
+ {post.visibility === "featured" && (
89
+ <span class="ml-2 text-xs">
90
+ {t({
91
+ message: "Featured",
92
+ comment: "@context: Post visibility badge",
93
+ })}
94
+ </span>
95
+ )}
96
+ </footer>
97
+ </article>
98
+ );
99
+ })
84
100
  )}
85
101
  </main>
86
102
 
@@ -109,9 +125,37 @@ homeRoutes.get("/", async (c) => {
109
125
  limit: 20,
110
126
  });
111
127
 
128
+ // Batch load media attachments
129
+ const postIds = posts.map((p) => p.id);
130
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
131
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
132
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
133
+
134
+ const mediaMap = new Map<number, MediaAttachment[]>();
135
+ for (const [postId, mediaList] of rawMediaMap) {
136
+ mediaMap.set(
137
+ postId,
138
+ mediaList.map((m) => ({
139
+ id: m.id,
140
+ url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
141
+ previewUrl: getImageUrl(
142
+ getMediaUrl(m.id, m.r2Key, r2PublicUrl),
143
+ imageTransformUrl,
144
+ { width: 400, quality: 80, format: "auto", fit: "cover" },
145
+ ),
146
+ alt: m.alt,
147
+ blurhash: m.blurhash,
148
+ width: m.width,
149
+ height: m.height,
150
+ position: m.position,
151
+ mimeType: m.mimeType,
152
+ })),
153
+ );
154
+ }
155
+
112
156
  return c.html(
113
157
  <BaseLayout title={siteName} c={c}>
114
- <HomeContent siteName={siteName} posts={posts} />
158
+ <HomeContent siteName={siteName} posts={posts} mediaMap={mediaMap} />
115
159
  </BaseLayout>,
116
160
  );
117
161
  });