@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.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +7 -21
- package/dist/db/schema.d.ts +36 -0
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +2 -0
- package/dist/i18n/locales/en.d.ts.map +1 -1
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/schemas.d.ts +17 -0
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +32 -2
- package/dist/lib/sse.d.ts +3 -3
- package/dist/lib/sse.d.ts.map +1 -1
- package/dist/lib/sse.js +7 -8
- package/dist/routes/api/posts.d.ts.map +1 -1
- package/dist/routes/api/posts.js +101 -5
- package/dist/routes/dash/media.js +38 -0
- package/dist/routes/dash/posts.d.ts.map +1 -1
- package/dist/routes/dash/posts.js +45 -6
- package/dist/routes/feed/rss.d.ts.map +1 -1
- package/dist/routes/feed/rss.js +10 -1
- package/dist/routes/pages/home.d.ts.map +1 -1
- package/dist/routes/pages/home.js +37 -4
- package/dist/routes/pages/post.d.ts.map +1 -1
- package/dist/routes/pages/post.js +28 -2
- package/dist/services/collection.d.ts +1 -0
- package/dist/services/collection.d.ts.map +1 -1
- package/dist/services/collection.js +13 -0
- package/dist/services/media.d.ts +7 -0
- package/dist/services/media.d.ts.map +1 -1
- package/dist/services/media.js +54 -1
- package/dist/theme/components/MediaGallery.d.ts +13 -0
- package/dist/theme/components/MediaGallery.d.ts.map +1 -0
- package/dist/theme/components/MediaGallery.js +107 -0
- package/dist/theme/components/PostForm.d.ts +6 -1
- package/dist/theme/components/PostForm.d.ts.map +1 -1
- package/dist/theme/components/PostForm.js +158 -2
- package/dist/theme/components/index.d.ts +1 -0
- package/dist/theme/components/index.d.ts.map +1 -1
- package/dist/theme/components/index.js +1 -0
- package/dist/types.d.ts +24 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +27 -0
- package/package.json +1 -1
- package/src/__tests__/helpers/app.ts +6 -1
- package/src/__tests__/helpers/db.ts +10 -0
- package/src/app.tsx +7 -25
- package/src/db/migrations/0002_add_media_attachments.sql +3 -0
- package/src/db/schema.ts +2 -0
- package/src/i18n/locales/en.po +81 -37
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +81 -37
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +81 -37
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +8 -1
- package/src/lib/__tests__/schemas.test.ts +89 -1
- package/src/lib/__tests__/sse.test.ts +13 -1
- package/src/lib/schemas.ts +47 -1
- package/src/lib/sse.ts +10 -11
- package/src/routes/api/__tests__/posts.test.ts +239 -0
- package/src/routes/api/posts.ts +134 -5
- package/src/routes/dash/media.tsx +50 -0
- package/src/routes/dash/posts.tsx +79 -7
- package/src/routes/feed/rss.ts +14 -1
- package/src/routes/pages/home.tsx +80 -36
- package/src/routes/pages/post.tsx +36 -3
- package/src/services/__tests__/collection.test.ts +102 -0
- package/src/services/__tests__/media.test.ts +248 -0
- package/src/services/collection.ts +19 -0
- package/src/services/media.ts +76 -1
- package/src/theme/components/MediaGallery.tsx +128 -0
- package/src/theme/components/PostForm.tsx +170 -2
- package/src/theme/components/index.ts +1 -0
- package/src/types.ts +36 -0
|
@@ -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
|
}
|
package/src/services/media.ts
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Gallery Component
|
|
3
|
+
*
|
|
4
|
+
* Renders media attachments on public post pages.
|
|
5
|
+
* Layout adapts based on the number of images.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FC } from "hono/jsx";
|
|
9
|
+
import type { MediaAttachment } from "../../types.js";
|
|
10
|
+
|
|
11
|
+
export interface MediaGalleryProps {
|
|
12
|
+
attachments: MediaAttachment[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const MediaGallery: FC<MediaGalleryProps> = ({ attachments }) => {
|
|
16
|
+
const images = attachments.filter((a) => a.mimeType.startsWith("image/"));
|
|
17
|
+
if (images.length === 0) return null;
|
|
18
|
+
|
|
19
|
+
if (images.length === 1) {
|
|
20
|
+
const [img] = images;
|
|
21
|
+
if (!img) return null;
|
|
22
|
+
return (
|
|
23
|
+
<div class="mt-3">
|
|
24
|
+
<a href={img.url} target="_blank" rel="noopener noreferrer">
|
|
25
|
+
<img
|
|
26
|
+
src={img.previewUrl}
|
|
27
|
+
alt={img.alt || ""}
|
|
28
|
+
width={img.width ?? undefined}
|
|
29
|
+
height={img.height ?? undefined}
|
|
30
|
+
class="rounded-lg max-w-full h-auto"
|
|
31
|
+
loading="lazy"
|
|
32
|
+
/>
|
|
33
|
+
</a>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (images.length === 2) {
|
|
39
|
+
return (
|
|
40
|
+
<div class="mt-3 grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
|
|
41
|
+
{images.map((img) => (
|
|
42
|
+
<a
|
|
43
|
+
key={img.id}
|
|
44
|
+
href={img.url}
|
|
45
|
+
target="_blank"
|
|
46
|
+
rel="noopener noreferrer"
|
|
47
|
+
class="aspect-square"
|
|
48
|
+
>
|
|
49
|
+
<img
|
|
50
|
+
src={img.previewUrl}
|
|
51
|
+
alt={img.alt || ""}
|
|
52
|
+
class="w-full h-full object-cover"
|
|
53
|
+
loading="lazy"
|
|
54
|
+
/>
|
|
55
|
+
</a>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (images.length === 3) {
|
|
62
|
+
const [first, ...rest] = images;
|
|
63
|
+
if (!first) return null;
|
|
64
|
+
return (
|
|
65
|
+
<div class="mt-3 grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
|
|
66
|
+
<a
|
|
67
|
+
href={first.url}
|
|
68
|
+
target="_blank"
|
|
69
|
+
rel="noopener noreferrer"
|
|
70
|
+
class="row-span-2"
|
|
71
|
+
>
|
|
72
|
+
<img
|
|
73
|
+
src={first.previewUrl}
|
|
74
|
+
alt={first.alt || ""}
|
|
75
|
+
class="w-full h-full object-cover"
|
|
76
|
+
loading="lazy"
|
|
77
|
+
/>
|
|
78
|
+
</a>
|
|
79
|
+
{rest.map((img) => (
|
|
80
|
+
<a
|
|
81
|
+
key={img.id}
|
|
82
|
+
href={img.url}
|
|
83
|
+
target="_blank"
|
|
84
|
+
rel="noopener noreferrer"
|
|
85
|
+
class="aspect-square"
|
|
86
|
+
>
|
|
87
|
+
<img
|
|
88
|
+
src={img.previewUrl}
|
|
89
|
+
alt={img.alt || ""}
|
|
90
|
+
class="w-full h-full object-cover"
|
|
91
|
+
loading="lazy"
|
|
92
|
+
/>
|
|
93
|
+
</a>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 4+ images: 2-column grid, show first 4 with remaining count
|
|
100
|
+
const shown = images.slice(0, 4);
|
|
101
|
+
const remaining = images.length - 4;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div class="mt-3 grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
|
|
105
|
+
{shown.map((img, i) => (
|
|
106
|
+
<a
|
|
107
|
+
key={img.id}
|
|
108
|
+
href={img.url}
|
|
109
|
+
target="_blank"
|
|
110
|
+
rel="noopener noreferrer"
|
|
111
|
+
class="relative aspect-square"
|
|
112
|
+
>
|
|
113
|
+
<img
|
|
114
|
+
src={img.previewUrl}
|
|
115
|
+
alt={img.alt || ""}
|
|
116
|
+
class="w-full h-full object-cover"
|
|
117
|
+
loading="lazy"
|
|
118
|
+
/>
|
|
119
|
+
{i === 3 && remaining > 0 && (
|
|
120
|
+
<div class="absolute inset-0 bg-black/50 flex items-center justify-center text-white text-xl font-semibold">
|
|
121
|
+
+{remaining}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</a>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
};
|