@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
@@ -5,17 +5,25 @@ 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, MediaAttachment } from "../../types.js";
9
9
  import type { AppVariables } from "../../app.js";
10
10
  import { BaseLayout } from "../../theme/layouts/index.js";
11
+ import { MediaGallery } from "../../theme/components/index.js";
11
12
  import * as sqid from "../../lib/sqid.js";
12
13
  import * as time from "../../lib/time.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 postRoutes = new Hono<Env>();
17
19
 
18
- function PostContent({ post }: { post: Post }) {
20
+ function PostContent({
21
+ post,
22
+ mediaAttachments,
23
+ }: {
24
+ post: Post;
25
+ mediaAttachments: MediaAttachment[];
26
+ }) {
19
27
  const { t } = useLingui();
20
28
 
21
29
  return (
@@ -30,6 +38,10 @@ function PostContent({ post }: { post: Post }) {
30
38
  dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
31
39
  />
32
40
 
41
+ {mediaAttachments.length > 0 && (
42
+ <MediaGallery attachments={mediaAttachments} />
43
+ )}
44
+
33
45
  <footer class="mt-6 pt-4 border-t text-sm text-muted-foreground">
34
46
  <time
35
47
  class="dt-published"
@@ -82,12 +94,33 @@ postRoutes.get("/:id", async (c) => {
82
94
  return c.notFound();
83
95
  }
84
96
 
97
+ // Load media attachments
98
+ const rawMedia = await c.var.services.media.getByPostId(post.id);
99
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
100
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
101
+
102
+ const mediaAttachments: MediaAttachment[] = rawMedia.map((m) => ({
103
+ id: m.id,
104
+ url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
105
+ previewUrl: getImageUrl(
106
+ getMediaUrl(m.id, m.r2Key, r2PublicUrl),
107
+ imageTransformUrl,
108
+ { width: 400, quality: 80, format: "auto", fit: "cover" },
109
+ ),
110
+ alt: m.alt,
111
+ blurhash: m.blurhash,
112
+ width: m.width,
113
+ height: m.height,
114
+ position: m.position,
115
+ mimeType: m.mimeType,
116
+ }));
117
+
85
118
  const siteName = await getSiteName(c);
86
119
  const title = post.title || siteName;
87
120
 
88
121
  return c.html(
89
122
  <BaseLayout title={title} description={post.content?.slice(0, 160)} c={c}>
90
- <PostContent post={post} />
123
+ <PostContent post={post} mediaAttachments={mediaAttachments} />
91
124
  </BaseLayout>,
92
125
  );
93
126
  });
@@ -223,4 +223,106 @@ describe("CollectionService", () => {
223
223
  expect(posts).toEqual([]);
224
224
  });
225
225
  });
226
+
227
+ describe("syncPostCollections", () => {
228
+ it("adds collections to a post with no existing collections", async () => {
229
+ const col1 = await collectionService.create({ title: "Col 1" });
230
+ const col2 = await collectionService.create({ title: "Col 2" });
231
+ const post = await postService.create({
232
+ type: "note",
233
+ content: "test",
234
+ });
235
+
236
+ await collectionService.syncPostCollections(post.id, [col1.id, col2.id]);
237
+
238
+ const collections = await collectionService.getCollectionsForPost(
239
+ post.id,
240
+ );
241
+ expect(collections).toHaveLength(2);
242
+ expect(collections.map((c) => c.id).sort()).toEqual(
243
+ [col1.id, col2.id].sort(),
244
+ );
245
+ });
246
+
247
+ it("removes collections no longer in the list", async () => {
248
+ const col1 = await collectionService.create({ title: "Col 1" });
249
+ const col2 = await collectionService.create({ title: "Col 2" });
250
+ const post = await postService.create({
251
+ type: "note",
252
+ content: "test",
253
+ });
254
+
255
+ await collectionService.addPost(col1.id, post.id);
256
+ await collectionService.addPost(col2.id, post.id);
257
+
258
+ // Sync with only col1 — col2 should be removed
259
+ await collectionService.syncPostCollections(post.id, [col1.id]);
260
+
261
+ const collections = await collectionService.getCollectionsForPost(
262
+ post.id,
263
+ );
264
+ expect(collections).toHaveLength(1);
265
+ expect(collections[0]?.id).toBe(col1.id);
266
+ });
267
+
268
+ it("handles mixed adds and removes", async () => {
269
+ const col1 = await collectionService.create({ title: "Col 1" });
270
+ const col2 = await collectionService.create({ title: "Col 2" });
271
+ const col3 = await collectionService.create({ title: "Col 3" });
272
+ const post = await postService.create({
273
+ type: "note",
274
+ content: "test",
275
+ });
276
+
277
+ // Start with col1 and col2
278
+ await collectionService.addPost(col1.id, post.id);
279
+ await collectionService.addPost(col2.id, post.id);
280
+
281
+ // Sync to col2 and col3 (remove col1, keep col2, add col3)
282
+ await collectionService.syncPostCollections(post.id, [col2.id, col3.id]);
283
+
284
+ const collections = await collectionService.getCollectionsForPost(
285
+ post.id,
286
+ );
287
+ expect(collections).toHaveLength(2);
288
+ expect(collections.map((c) => c.id).sort()).toEqual(
289
+ [col2.id, col3.id].sort(),
290
+ );
291
+ });
292
+
293
+ it("removes all collections when synced with empty array", async () => {
294
+ const col1 = await collectionService.create({ title: "Col 1" });
295
+ const post = await postService.create({
296
+ type: "note",
297
+ content: "test",
298
+ });
299
+
300
+ await collectionService.addPost(col1.id, post.id);
301
+
302
+ await collectionService.syncPostCollections(post.id, []);
303
+
304
+ const collections = await collectionService.getCollectionsForPost(
305
+ post.id,
306
+ );
307
+ expect(collections).toHaveLength(0);
308
+ });
309
+
310
+ it("is a no-op when already in sync", async () => {
311
+ const col1 = await collectionService.create({ title: "Col 1" });
312
+ const post = await postService.create({
313
+ type: "note",
314
+ content: "test",
315
+ });
316
+
317
+ await collectionService.addPost(col1.id, post.id);
318
+
319
+ await collectionService.syncPostCollections(post.id, [col1.id]);
320
+
321
+ const collections = await collectionService.getCollectionsForPost(
322
+ post.id,
323
+ );
324
+ expect(collections).toHaveLength(1);
325
+ expect(collections[0]?.id).toBe(col1.id);
326
+ });
327
+ });
226
328
  });
@@ -1,16 +1,20 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion -- Test assertions use ! for readability */
1
2
  import { describe, it, expect, beforeEach } from "vitest";
2
3
  import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
4
  import { createMediaService } from "../media.js";
5
+ import { createPostService } from "../post.js";
4
6
  import type { Database } from "../../db/index.js";
5
7
 
6
8
  describe("MediaService", () => {
7
9
  let db: Database;
8
10
  let mediaService: ReturnType<typeof createMediaService>;
11
+ let postService: ReturnType<typeof createPostService>;
9
12
 
10
13
  beforeEach(() => {
11
14
  const testDb = createTestDatabase();
12
15
  db = testDb.db as unknown as Database;
13
16
  mediaService = createMediaService(db);
17
+ postService = createPostService(db);
14
18
  });
15
19
 
16
20
  const sampleMedia = {
@@ -37,6 +41,8 @@ describe("MediaService", () => {
37
41
  expect(media.height).toBe(1080);
38
42
  expect(media.postId).toBeNull();
39
43
  expect(media.alt).toBeNull();
44
+ expect(media.position).toBe(0);
45
+ expect(media.blurhash).toBeNull();
40
46
  });
41
47
 
42
48
  it("creates media with optional alt text", async () => {
@@ -48,6 +54,17 @@ describe("MediaService", () => {
48
54
  expect(media.alt).toBe("A beautiful sunset");
49
55
  });
50
56
 
57
+ it("creates media with position and blurhash", async () => {
58
+ const media = await mediaService.create({
59
+ ...sampleMedia,
60
+ position: 3,
61
+ blurhash: "LKO2?U%2Tw=w]~RBVZRi};RPxuwH",
62
+ });
63
+
64
+ expect(media.position).toBe(3);
65
+ expect(media.blurhash).toBe("LKO2?U%2Tw=w]~RBVZRi};RPxuwH");
66
+ });
67
+
51
68
  it("generates UUIDv7 IDs", async () => {
52
69
  const media1 = await mediaService.create(sampleMedia);
53
70
  const media2 = await mediaService.create({
@@ -76,6 +93,135 @@ describe("MediaService", () => {
76
93
  });
77
94
  });
78
95
 
96
+ describe("getByIds", () => {
97
+ it("returns media for valid IDs", async () => {
98
+ const m1 = await mediaService.create({
99
+ ...sampleMedia,
100
+ r2Key: "media/a.jpg",
101
+ });
102
+ const m2 = await mediaService.create({
103
+ ...sampleMedia,
104
+ r2Key: "media/b.jpg",
105
+ });
106
+
107
+ const results = await mediaService.getByIds([m1.id, m2.id]);
108
+ expect(results).toHaveLength(2);
109
+ expect(results.map((r) => r.id).sort()).toEqual([m1.id, m2.id].sort());
110
+ });
111
+
112
+ it("returns empty array for empty input", async () => {
113
+ const results = await mediaService.getByIds([]);
114
+ expect(results).toEqual([]);
115
+ });
116
+
117
+ it("ignores non-existent IDs", async () => {
118
+ const m1 = await mediaService.create(sampleMedia);
119
+
120
+ const results = await mediaService.getByIds([m1.id, "nonexistent"]);
121
+ expect(results).toHaveLength(1);
122
+ expect(results[0]!.id).toBe(m1.id);
123
+ });
124
+ });
125
+
126
+ describe("getByPostId", () => {
127
+ it("returns media ordered by position", async () => {
128
+ const post = await postService.create({
129
+ type: "note",
130
+ content: "test",
131
+ });
132
+
133
+ const m1 = await mediaService.create({
134
+ ...sampleMedia,
135
+ r2Key: "media/a.jpg",
136
+ });
137
+ const m2 = await mediaService.create({
138
+ ...sampleMedia,
139
+ r2Key: "media/b.jpg",
140
+ });
141
+
142
+ await mediaService.attachToPost(post.id, [m2.id, m1.id]);
143
+
144
+ const results = await mediaService.getByPostId(post.id);
145
+ expect(results).toHaveLength(2);
146
+ expect(results[0]!.id).toBe(m2.id);
147
+ expect(results[0]!.position).toBe(0);
148
+ expect(results[1]!.id).toBe(m1.id);
149
+ expect(results[1]!.position).toBe(1);
150
+ });
151
+
152
+ it("returns empty array for post with no media", async () => {
153
+ const post = await postService.create({
154
+ type: "note",
155
+ content: "test",
156
+ });
157
+
158
+ const results = await mediaService.getByPostId(post.id);
159
+ expect(results).toEqual([]);
160
+ });
161
+ });
162
+
163
+ describe("getByPostIds", () => {
164
+ it("returns Map grouped by postId", async () => {
165
+ const post1 = await postService.create({
166
+ type: "note",
167
+ content: "post 1",
168
+ });
169
+ const post2 = await postService.create({
170
+ type: "note",
171
+ content: "post 2",
172
+ });
173
+
174
+ const m1 = await mediaService.create({
175
+ ...sampleMedia,
176
+ r2Key: "media/a.jpg",
177
+ });
178
+ const m2 = await mediaService.create({
179
+ ...sampleMedia,
180
+ r2Key: "media/b.jpg",
181
+ });
182
+ const m3 = await mediaService.create({
183
+ ...sampleMedia,
184
+ r2Key: "media/c.jpg",
185
+ });
186
+
187
+ await mediaService.attachToPost(post1.id, [m1.id, m2.id]);
188
+ await mediaService.attachToPost(post2.id, [m3.id]);
189
+
190
+ const results = await mediaService.getByPostIds([post1.id, post2.id]);
191
+ expect(results.size).toBe(2);
192
+ expect(results.get(post1.id)).toHaveLength(2);
193
+ expect(results.get(post2.id)).toHaveLength(1);
194
+ });
195
+
196
+ it("returns empty Map for empty input", async () => {
197
+ const results = await mediaService.getByPostIds([]);
198
+ expect(results.size).toBe(0);
199
+ });
200
+
201
+ it("returns ordered by position within each post", async () => {
202
+ const post = await postService.create({
203
+ type: "note",
204
+ content: "test",
205
+ });
206
+
207
+ const m1 = await mediaService.create({
208
+ ...sampleMedia,
209
+ r2Key: "media/a.jpg",
210
+ });
211
+ const m2 = await mediaService.create({
212
+ ...sampleMedia,
213
+ r2Key: "media/b.jpg",
214
+ });
215
+
216
+ await mediaService.attachToPost(post.id, [m2.id, m1.id]);
217
+
218
+ const results = await mediaService.getByPostIds([post.id]);
219
+ const postMedia = results.get(post.id)!;
220
+ expect(postMedia[0]!.id).toBe(m2.id);
221
+ expect(postMedia[1]!.id).toBe(m1.id);
222
+ });
223
+ });
224
+
79
225
  describe("getByR2Key", () => {
80
226
  it("returns media by R2 key", async () => {
81
227
  await mediaService.create(sampleMedia);
@@ -115,6 +261,108 @@ describe("MediaService", () => {
115
261
  });
116
262
  });
117
263
 
264
+ describe("attachToPost", () => {
265
+ it("sets postId and position for each media", async () => {
266
+ const post = await postService.create({
267
+ type: "note",
268
+ content: "test",
269
+ });
270
+
271
+ const m1 = await mediaService.create({
272
+ ...sampleMedia,
273
+ r2Key: "media/a.jpg",
274
+ });
275
+ const m2 = await mediaService.create({
276
+ ...sampleMedia,
277
+ r2Key: "media/b.jpg",
278
+ });
279
+
280
+ await mediaService.attachToPost(post.id, [m1.id, m2.id]);
281
+
282
+ const attached = await mediaService.getByPostId(post.id);
283
+ expect(attached).toHaveLength(2);
284
+ expect(attached[0]!.id).toBe(m1.id);
285
+ expect(attached[0]!.position).toBe(0);
286
+ expect(attached[1]!.id).toBe(m2.id);
287
+ expect(attached[1]!.position).toBe(1);
288
+ });
289
+
290
+ it("replaces existing attachments", async () => {
291
+ const post = await postService.create({
292
+ type: "note",
293
+ content: "test",
294
+ });
295
+
296
+ const m1 = await mediaService.create({
297
+ ...sampleMedia,
298
+ r2Key: "media/a.jpg",
299
+ });
300
+ const m2 = await mediaService.create({
301
+ ...sampleMedia,
302
+ r2Key: "media/b.jpg",
303
+ });
304
+ const m3 = await mediaService.create({
305
+ ...sampleMedia,
306
+ r2Key: "media/c.jpg",
307
+ });
308
+
309
+ await mediaService.attachToPost(post.id, [m1.id, m2.id]);
310
+ await mediaService.attachToPost(post.id, [m3.id]);
311
+
312
+ const attached = await mediaService.getByPostId(post.id);
313
+ expect(attached).toHaveLength(1);
314
+ expect(attached[0]!.id).toBe(m3.id);
315
+ expect(attached[0]!.position).toBe(0);
316
+
317
+ // Verify old media is detached
318
+ const old1 = await mediaService.getById(m1.id);
319
+ expect(old1!.postId).toBeNull();
320
+ expect(old1!.position).toBe(0);
321
+ });
322
+
323
+ it("handles empty array by clearing all attachments", async () => {
324
+ const post = await postService.create({
325
+ type: "note",
326
+ content: "test",
327
+ });
328
+
329
+ const m1 = await mediaService.create({
330
+ ...sampleMedia,
331
+ r2Key: "media/a.jpg",
332
+ });
333
+
334
+ await mediaService.attachToPost(post.id, [m1.id]);
335
+ await mediaService.attachToPost(post.id, []);
336
+
337
+ const attached = await mediaService.getByPostId(post.id);
338
+ expect(attached).toHaveLength(0);
339
+ });
340
+ });
341
+
342
+ describe("detachFromPost", () => {
343
+ it("clears postId and resets position", async () => {
344
+ const post = await postService.create({
345
+ type: "note",
346
+ content: "test",
347
+ });
348
+
349
+ const m1 = await mediaService.create({
350
+ ...sampleMedia,
351
+ r2Key: "media/a.jpg",
352
+ });
353
+
354
+ await mediaService.attachToPost(post.id, [m1.id]);
355
+ await mediaService.detachFromPost(post.id);
356
+
357
+ const attached = await mediaService.getByPostId(post.id);
358
+ expect(attached).toHaveLength(0);
359
+
360
+ const detached = await mediaService.getById(m1.id);
361
+ expect(detached!.postId).toBeNull();
362
+ expect(detached!.position).toBe(0);
363
+ });
364
+ });
365
+
118
366
  describe("delete", () => {
119
367
  it("deletes a media record", async () => {
120
368
  const media = await mediaService.create(sampleMedia);
@@ -21,6 +21,7 @@ export interface CollectionService {
21
21
  removePost(collectionId: number, postId: number): Promise<void>;
22
22
  getPosts(collectionId: number): Promise<Post[]>;
23
23
  getCollectionsForPost(postId: number): Promise<Collection[]>;
24
+ syncPostCollections(postId: number, collectionIds: number[]): Promise<void>;
24
25
  }
25
26
 
26
27
  export interface CreateCollectionData {
@@ -197,5 +198,23 @@ export function createCollectionService(db: Database): CollectionService {
197
198
 
198
199
  return rows.map((r) => toCollection(r.collection));
199
200
  },
201
+
202
+ async syncPostCollections(postId, collectionIds) {
203
+ const current = await this.getCollectionsForPost(postId);
204
+ const currentIds = new Set(current.map((c) => c.id));
205
+ const desiredIds = new Set(collectionIds);
206
+
207
+ const toAdd = collectionIds.filter((id) => !currentIds.has(id));
208
+ const toRemove = current
209
+ .map((c) => c.id)
210
+ .filter((id) => !desiredIds.has(id));
211
+
212
+ for (const collectionId of toAdd) {
213
+ await this.addPost(collectionId, postId);
214
+ }
215
+ for (const collectionId of toRemove) {
216
+ await this.removePost(collectionId, postId);
217
+ }
218
+ },
200
219
  };
201
220
  }
@@ -4,7 +4,7 @@
4
4
  * Handles media upload and management with R2 storage
5
5
  */
6
6
 
7
- import { eq, desc } from "drizzle-orm";
7
+ import { eq, desc, inArray, asc } from "drizzle-orm";
8
8
  import { uuidv7 } from "uuidv7";
9
9
  import type { Database } from "../db/index.js";
10
10
  import { media } from "../db/schema.js";
@@ -13,10 +13,15 @@ import type { Media } from "../types.js";
13
13
 
14
14
  export interface MediaService {
15
15
  getById(id: string): Promise<Media | null>;
16
+ getByIds(ids: string[]): Promise<Media[]>;
17
+ getByPostId(postId: number): Promise<Media[]>;
18
+ getByPostIds(postIds: number[]): Promise<Map<number, Media[]>>;
16
19
  list(limit?: number): Promise<Media[]>;
17
20
  create(data: CreateMediaData): Promise<Media>;
18
21
  delete(id: string): Promise<boolean>;
19
22
  getByR2Key(r2Key: string): Promise<Media | null>;
23
+ attachToPost(postId: number, mediaIds: string[]): Promise<void>;
24
+ detachFromPost(postId: number): Promise<void>;
20
25
  }
21
26
 
22
27
  export interface CreateMediaData {
@@ -29,6 +34,8 @@ export interface CreateMediaData {
29
34
  width?: number;
30
35
  height?: number;
31
36
  alt?: string;
37
+ position?: number;
38
+ blurhash?: string;
32
39
  }
33
40
 
34
41
  export function createMediaService(db: Database): MediaService {
@@ -44,6 +51,8 @@ export function createMediaService(db: Database): MediaService {
44
51
  width: row.width,
45
52
  height: row.height,
46
53
  alt: row.alt,
54
+ position: row.position,
55
+ blurhash: row.blurhash,
47
56
  createdAt: row.createdAt,
48
57
  };
49
58
  }
@@ -58,6 +67,45 @@ export function createMediaService(db: Database): MediaService {
58
67
  return result[0] ? toMedia(result[0]) : null;
59
68
  },
60
69
 
70
+ async getByIds(ids) {
71
+ if (ids.length === 0) return [];
72
+ const rows = await db.select().from(media).where(inArray(media.id, ids));
73
+ return rows.map(toMedia);
74
+ },
75
+
76
+ async getByPostId(postId) {
77
+ const rows = await db
78
+ .select()
79
+ .from(media)
80
+ .where(eq(media.postId, postId))
81
+ .orderBy(asc(media.position));
82
+ return rows.map(toMedia);
83
+ },
84
+
85
+ async getByPostIds(postIds) {
86
+ const result = new Map<number, Media[]>();
87
+ if (postIds.length === 0) return result;
88
+
89
+ const rows = await db
90
+ .select()
91
+ .from(media)
92
+ .where(inArray(media.postId, postIds))
93
+ .orderBy(asc(media.position));
94
+
95
+ for (const row of rows) {
96
+ const m = toMedia(row);
97
+ if (m.postId === null) continue;
98
+ const list = result.get(m.postId);
99
+ if (list) {
100
+ list.push(m);
101
+ } else {
102
+ result.set(m.postId, [m]);
103
+ }
104
+ }
105
+
106
+ return result;
107
+ },
108
+
61
109
  async getByR2Key(r2Key) {
62
110
  const result = await db
63
111
  .select()
@@ -93,6 +141,8 @@ export function createMediaService(db: Database): MediaService {
93
141
  width: data.width ?? null,
94
142
  height: data.height ?? null,
95
143
  alt: data.alt ?? null,
144
+ position: data.position ?? 0,
145
+ blurhash: data.blurhash ?? null,
96
146
  createdAt: timestamp,
97
147
  })
98
148
  .returning();
@@ -101,6 +151,31 @@ export function createMediaService(db: Database): MediaService {
101
151
  return toMedia(result[0]!);
102
152
  },
103
153
 
154
+ async attachToPost(postId, mediaIds) {
155
+ // Clear existing attachments
156
+ await db
157
+ .update(media)
158
+ .set({ postId: null, position: 0 })
159
+ .where(eq(media.postId, postId));
160
+
161
+ // Set new attachments with position = array index
162
+ for (let i = 0; i < mediaIds.length; i++) {
163
+ const mediaId = mediaIds[i];
164
+ if (!mediaId) continue;
165
+ await db
166
+ .update(media)
167
+ .set({ postId, position: i })
168
+ .where(eq(media.id, mediaId));
169
+ }
170
+ },
171
+
172
+ async detachFromPost(postId) {
173
+ await db
174
+ .update(media)
175
+ .set({ postId: null, position: 0 })
176
+ .where(eq(media.postId, postId));
177
+ },
178
+
104
179
  async delete(id) {
105
180
  const result = await db.delete(media).where(eq(media.id, id)).returning();
106
181
  return result.length > 0;