@jant/core 0.3.6 → 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 +81 -37
  57. package/src/i18n/locales/en.ts +1 -1
  58. package/src/i18n/locales/zh-Hans.po +81 -37
  59. package/src/i18n/locales/zh-Hans.ts +1 -1
  60. package/src/i18n/locales/zh-Hant.po +81 -37
  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
package/src/lib/sse.ts CHANGED
@@ -299,7 +299,7 @@ export function sse(
299
299
  * Use instead of `sse()` when the only action is a redirect.
300
300
  *
301
301
  * @param url - The URL to redirect to
302
- * @param options - Optional extra headers (e.g. Set-Cookie for auth)
302
+ * @param options - Optional extra headers (accepts any `HeadersInit`)
303
303
  * @returns Response with text/html content-type
304
304
  *
305
305
  * @example
@@ -307,21 +307,20 @@ export function sse(
307
307
  * return dsRedirect("/dash/posts");
308
308
  *
309
309
  * // With cookie forwarding (for auth)
310
- * return dsRedirect("/dash", { headers: { "Set-Cookie": cookie } });
310
+ * return dsRedirect("/dash", { headers: authResponse.headers });
311
311
  * ```
312
312
  */
313
313
  export function dsRedirect(
314
314
  url: string,
315
- options?: { headers?: Record<string, string> },
315
+ options?: { headers?: Headers | Record<string, string> | string[][] },
316
316
  ): Response {
317
- return new Response(buildRedirectScript(url), {
318
- headers: {
319
- "Content-Type": "text/html",
320
- "Datastar-Mode": "append",
321
- "Datastar-Selector": "body",
322
- ...options?.headers,
323
- },
324
- });
317
+ const headers = options?.headers
318
+ ? new Headers(options.headers)
319
+ : new Headers();
320
+ headers.set("Content-Type", "text/html");
321
+ headers.set("Datastar-Mode", "append");
322
+ headers.set("Datastar-Selector", "body");
323
+ return new Response(buildRedirectScript(url), { headers });
325
324
  }
326
325
 
327
326
  /**
@@ -35,6 +35,39 @@ describe("Posts API Routes", () => {
35
35
  expect(body.posts[0].sqid).toBeTruthy();
36
36
  });
37
37
 
38
+ it("includes mediaAttachments in list response", async () => {
39
+ const { app, services } = createTestApp();
40
+ app.route("/api/posts", postsApiRoutes);
41
+
42
+ const post = await services.posts.create({
43
+ type: "note",
44
+ content: "with media",
45
+ visibility: "featured",
46
+ });
47
+
48
+ const media = await services.media.create({
49
+ filename: "test.jpg",
50
+ originalName: "test.jpg",
51
+ mimeType: "image/jpeg",
52
+ size: 1024,
53
+ r2Key: "uploads/test.jpg",
54
+ width: 800,
55
+ height: 600,
56
+ });
57
+
58
+ await services.media.attachToPost(post.id, [media.id]);
59
+
60
+ const res = await app.request("/api/posts");
61
+ const body = await res.json();
62
+
63
+ expect(body.posts[0].mediaAttachments).toHaveLength(1);
64
+ expect(body.posts[0].mediaAttachments[0].id).toBe(media.id);
65
+ expect(body.posts[0].mediaAttachments[0].mimeType).toBe("image/jpeg");
66
+ expect(body.posts[0].mediaAttachments[0].url).toBeTruthy();
67
+ expect(body.posts[0].mediaAttachments[0].previewUrl).toBeTruthy();
68
+ expect(body.posts[0].mediaAttachments[0].position).toBe(0);
69
+ });
70
+
38
71
  it("filters by visibility", async () => {
39
72
  const { app, services } = createTestApp();
40
73
  app.route("/api/posts", postsApiRoutes);
@@ -97,6 +130,33 @@ describe("Posts API Routes", () => {
97
130
  expect(body.sqid).toBe(id);
98
131
  });
99
132
 
133
+ it("includes mediaAttachments in single post response", async () => {
134
+ const { app, services } = createTestApp();
135
+ app.route("/api/posts", postsApiRoutes);
136
+
137
+ const post = await services.posts.create({
138
+ type: "note",
139
+ content: "with media",
140
+ visibility: "featured",
141
+ });
142
+
143
+ const media = await services.media.create({
144
+ filename: "test.jpg",
145
+ originalName: "test.jpg",
146
+ mimeType: "image/jpeg",
147
+ size: 1024,
148
+ r2Key: "uploads/test.jpg",
149
+ });
150
+
151
+ await services.media.attachToPost(post.id, [media.id]);
152
+
153
+ const res = await app.request(`/api/posts/${sqid.encode(post.id)}`);
154
+ const body = await res.json();
155
+
156
+ expect(body.mediaAttachments).toHaveLength(1);
157
+ expect(body.mediaAttachments[0].id).toBe(media.id);
158
+ });
159
+
100
160
  it("returns 400 for invalid sqid", async () => {
101
161
  const { app } = createTestApp();
102
162
  app.route("/api/posts", postsApiRoutes);
@@ -151,6 +211,114 @@ describe("Posts API Routes", () => {
151
211
  const body = await res.json();
152
212
  expect(body.content).toBe("Hello from API");
153
213
  expect(body.sqid).toBeTruthy();
214
+ expect(body.mediaAttachments).toEqual([]);
215
+ });
216
+
217
+ it("creates a post with mediaIds and attaches them", async () => {
218
+ const { app, services } = createTestApp({ authenticated: true });
219
+ app.route("/api/posts", postsApiRoutes);
220
+
221
+ const m1 = await services.media.create({
222
+ filename: "a.jpg",
223
+ originalName: "a.jpg",
224
+ mimeType: "image/jpeg",
225
+ size: 1024,
226
+ r2Key: "uploads/a.jpg",
227
+ });
228
+ const m2 = await services.media.create({
229
+ filename: "b.jpg",
230
+ originalName: "b.jpg",
231
+ mimeType: "image/jpeg",
232
+ size: 2048,
233
+ r2Key: "uploads/b.jpg",
234
+ });
235
+
236
+ const res = await app.request("/api/posts", {
237
+ method: "POST",
238
+ headers: { "Content-Type": "application/json" },
239
+ body: JSON.stringify({
240
+ type: "note",
241
+ content: "with images",
242
+ visibility: "quiet",
243
+ mediaIds: [m1.id, m2.id],
244
+ }),
245
+ });
246
+
247
+ expect(res.status).toBe(201);
248
+ const body = await res.json();
249
+ expect(body.mediaAttachments).toHaveLength(2);
250
+ expect(body.mediaAttachments[0].id).toBe(m1.id);
251
+ expect(body.mediaAttachments[0].position).toBe(0);
252
+ expect(body.mediaAttachments[1].id).toBe(m2.id);
253
+ expect(body.mediaAttachments[1].position).toBe(1);
254
+ });
255
+
256
+ it("returns 400 for image type without media", async () => {
257
+ const { app } = createTestApp({ authenticated: true });
258
+ app.route("/api/posts", postsApiRoutes);
259
+
260
+ const res = await app.request("/api/posts", {
261
+ method: "POST",
262
+ headers: { "Content-Type": "application/json" },
263
+ body: JSON.stringify({
264
+ type: "image",
265
+ content: "should fail",
266
+ visibility: "quiet",
267
+ mediaIds: [],
268
+ }),
269
+ });
270
+
271
+ expect(res.status).toBe(400);
272
+ const body = await res.json();
273
+ expect(body.error).toContain("image posts require at least 1");
274
+ });
275
+
276
+ it("returns 400 for page type with media", async () => {
277
+ const { app, services } = createTestApp({ authenticated: true });
278
+ app.route("/api/posts", postsApiRoutes);
279
+
280
+ const m1 = await services.media.create({
281
+ filename: "a.jpg",
282
+ originalName: "a.jpg",
283
+ mimeType: "image/jpeg",
284
+ size: 1024,
285
+ r2Key: "uploads/a.jpg",
286
+ });
287
+
288
+ const res = await app.request("/api/posts", {
289
+ method: "POST",
290
+ headers: { "Content-Type": "application/json" },
291
+ body: JSON.stringify({
292
+ type: "page",
293
+ content: "test",
294
+ visibility: "quiet",
295
+ mediaIds: [m1.id],
296
+ }),
297
+ });
298
+
299
+ expect(res.status).toBe(400);
300
+ const body = await res.json();
301
+ expect(body.error).toContain("page posts do not allow");
302
+ });
303
+
304
+ it("returns 400 for invalid media IDs", async () => {
305
+ const { app } = createTestApp({ authenticated: true });
306
+ app.route("/api/posts", postsApiRoutes);
307
+
308
+ const res = await app.request("/api/posts", {
309
+ method: "POST",
310
+ headers: { "Content-Type": "application/json" },
311
+ body: JSON.stringify({
312
+ type: "note",
313
+ content: "test",
314
+ visibility: "quiet",
315
+ mediaIds: ["nonexistent-id"],
316
+ }),
317
+ });
318
+
319
+ expect(res.status).toBe(400);
320
+ const body = await res.json();
321
+ expect(body.error).toContain("media IDs are invalid");
154
322
  });
155
323
 
156
324
  it("returns 400 for invalid body", async () => {
@@ -219,6 +387,77 @@ describe("Posts API Routes", () => {
219
387
  expect(res.status).toBe(200);
220
388
  const body = await res.json();
221
389
  expect(body.content).toBe("updated");
390
+ expect(body.mediaAttachments).toEqual([]);
391
+ });
392
+
393
+ it("updates post with mediaIds to replace attachments", async () => {
394
+ const { app, services } = createTestApp({ authenticated: true });
395
+ app.route("/api/posts", postsApiRoutes);
396
+
397
+ const post = await services.posts.create({
398
+ type: "note",
399
+ content: "test",
400
+ });
401
+
402
+ const m1 = await services.media.create({
403
+ filename: "a.jpg",
404
+ originalName: "a.jpg",
405
+ mimeType: "image/jpeg",
406
+ size: 1024,
407
+ r2Key: "uploads/a.jpg",
408
+ });
409
+
410
+ await services.media.attachToPost(post.id, [m1.id]);
411
+
412
+ const m2 = await services.media.create({
413
+ filename: "b.jpg",
414
+ originalName: "b.jpg",
415
+ mimeType: "image/jpeg",
416
+ size: 2048,
417
+ r2Key: "uploads/b.jpg",
418
+ });
419
+
420
+ const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
421
+ method: "PUT",
422
+ headers: { "Content-Type": "application/json" },
423
+ body: JSON.stringify({ mediaIds: [m2.id] }),
424
+ });
425
+
426
+ expect(res.status).toBe(200);
427
+ const body = await res.json();
428
+ expect(body.mediaAttachments).toHaveLength(1);
429
+ expect(body.mediaAttachments[0].id).toBe(m2.id);
430
+ });
431
+
432
+ it("preserves existing attachments when mediaIds is omitted", async () => {
433
+ const { app, services } = createTestApp({ authenticated: true });
434
+ app.route("/api/posts", postsApiRoutes);
435
+
436
+ const post = await services.posts.create({
437
+ type: "note",
438
+ content: "test",
439
+ });
440
+
441
+ const m1 = await services.media.create({
442
+ filename: "a.jpg",
443
+ originalName: "a.jpg",
444
+ mimeType: "image/jpeg",
445
+ size: 1024,
446
+ r2Key: "uploads/a.jpg",
447
+ });
448
+
449
+ await services.media.attachToPost(post.id, [m1.id]);
450
+
451
+ const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
452
+ method: "PUT",
453
+ headers: { "Content-Type": "application/json" },
454
+ body: JSON.stringify({ content: "updated content" }),
455
+ });
456
+
457
+ expect(res.status).toBe(200);
458
+ const body = await res.json();
459
+ expect(body.mediaAttachments).toHaveLength(1);
460
+ expect(body.mediaAttachments[0].id).toBe(m1.id);
222
461
  });
223
462
 
224
463
  it("returns 404 for non-existent post", async () => {
@@ -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");