@jant/core 0.3.8 → 0.3.9
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.js +7 -4
- package/dist/db/schema.js +2 -1
- package/dist/lib/image.js +39 -15
- package/dist/lib/media-helpers.js +14 -8
- package/dist/lib/storage.js +164 -0
- package/dist/routes/api/posts.js +12 -7
- package/dist/routes/api/timeline.js +3 -2
- package/dist/routes/api/upload.js +27 -20
- package/dist/routes/dash/media.js +24 -14
- package/dist/routes/dash/posts.js +4 -1
- package/dist/routes/feed/rss.js +3 -2
- package/dist/routes/pages/home.js +3 -2
- package/dist/routes/pages/post.js +9 -5
- package/dist/services/media.js +7 -5
- package/dist/theme/components/PostForm.js +4 -3
- package/dist/types.js +32 -0
- package/package.json +2 -1
- package/src/__tests__/helpers/app.ts +1 -0
- package/src/__tests__/helpers/db.ts +10 -0
- package/src/app.tsx +8 -7
- package/src/db/migrations/0004_add_storage_provider.sql +3 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/schema.ts +2 -1
- package/src/i18n/locales/en.po +67 -67
- package/src/i18n/locales/zh-Hans.po +67 -67
- package/src/i18n/locales/zh-Hant.po +67 -67
- package/src/lib/__tests__/image.test.ts +96 -0
- package/src/lib/__tests__/storage.test.ts +162 -0
- package/src/lib/image.ts +46 -16
- package/src/lib/media-helpers.ts +29 -18
- package/src/lib/storage.ts +236 -0
- package/src/routes/api/__tests__/posts.test.ts +8 -8
- package/src/routes/api/posts.ts +20 -6
- package/src/routes/api/timeline.tsx +8 -1
- package/src/routes/api/upload.ts +44 -21
- package/src/routes/dash/media.tsx +40 -8
- package/src/routes/dash/posts.tsx +5 -0
- package/src/routes/feed/rss.ts +3 -2
- package/src/routes/pages/home.tsx +8 -1
- package/src/routes/pages/post.tsx +29 -17
- package/src/services/__tests__/media.test.ts +44 -26
- package/src/services/media.ts +10 -7
- package/src/theme/components/PostForm.tsx +13 -2
- package/src/types.ts +41 -1
|
@@ -22,7 +22,7 @@ describe("MediaService", () => {
|
|
|
22
22
|
originalName: "photo.jpg",
|
|
23
23
|
mimeType: "image/jpeg",
|
|
24
24
|
size: 102400,
|
|
25
|
-
|
|
25
|
+
storageKey: "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
|
|
26
26
|
width: 1920,
|
|
27
27
|
height: 1080,
|
|
28
28
|
};
|
|
@@ -36,9 +36,10 @@ describe("MediaService", () => {
|
|
|
36
36
|
expect(media.originalName).toBe("photo.jpg");
|
|
37
37
|
expect(media.mimeType).toBe("image/jpeg");
|
|
38
38
|
expect(media.size).toBe(102400);
|
|
39
|
-
expect(media.
|
|
39
|
+
expect(media.storageKey).toBe(
|
|
40
40
|
"media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
|
|
41
41
|
);
|
|
42
|
+
expect(media.provider).toBe("r2");
|
|
42
43
|
expect(media.width).toBe(1920);
|
|
43
44
|
expect(media.height).toBe(1080);
|
|
44
45
|
expect(media.postId).toBeNull();
|
|
@@ -56,6 +57,20 @@ describe("MediaService", () => {
|
|
|
56
57
|
expect(media.alt).toBe("A beautiful sunset");
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
it("defaults provider to 'r2'", async () => {
|
|
61
|
+
const media = await mediaService.create(sampleMedia);
|
|
62
|
+
expect(media.provider).toBe("r2");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("accepts provider 's3'", async () => {
|
|
66
|
+
const media = await mediaService.create({
|
|
67
|
+
...sampleMedia,
|
|
68
|
+
storageKey: "media/2025/01/s3-upload.jpg",
|
|
69
|
+
provider: "s3",
|
|
70
|
+
});
|
|
71
|
+
expect(media.provider).toBe("s3");
|
|
72
|
+
});
|
|
73
|
+
|
|
59
74
|
it("creates media with position and blurhash", async () => {
|
|
60
75
|
const media = await mediaService.create({
|
|
61
76
|
...sampleMedia,
|
|
@@ -71,7 +86,7 @@ describe("MediaService", () => {
|
|
|
71
86
|
const media1 = await mediaService.create(sampleMedia);
|
|
72
87
|
const media2 = await mediaService.create({
|
|
73
88
|
...sampleMedia,
|
|
74
|
-
|
|
89
|
+
storageKey: "media/2025/01/other.jpg",
|
|
75
90
|
});
|
|
76
91
|
|
|
77
92
|
expect(media1.id).not.toBe(media2.id);
|
|
@@ -92,7 +107,7 @@ describe("MediaService", () => {
|
|
|
92
107
|
it("auto-generates id when not provided", async () => {
|
|
93
108
|
const media = await mediaService.create({
|
|
94
109
|
...sampleMedia,
|
|
95
|
-
|
|
110
|
+
storageKey: "media/2025/01/auto.jpg",
|
|
96
111
|
});
|
|
97
112
|
|
|
98
113
|
expect(media.id).toBeTruthy();
|
|
@@ -122,11 +137,11 @@ describe("MediaService", () => {
|
|
|
122
137
|
it("returns media for valid IDs", async () => {
|
|
123
138
|
const m1 = await mediaService.create({
|
|
124
139
|
...sampleMedia,
|
|
125
|
-
|
|
140
|
+
storageKey: "media/a.jpg",
|
|
126
141
|
});
|
|
127
142
|
const m2 = await mediaService.create({
|
|
128
143
|
...sampleMedia,
|
|
129
|
-
|
|
144
|
+
storageKey: "media/b.jpg",
|
|
130
145
|
});
|
|
131
146
|
|
|
132
147
|
const results = await mediaService.getByIds([m1.id, m2.id]);
|
|
@@ -157,11 +172,11 @@ describe("MediaService", () => {
|
|
|
157
172
|
|
|
158
173
|
const m1 = await mediaService.create({
|
|
159
174
|
...sampleMedia,
|
|
160
|
-
|
|
175
|
+
storageKey: "media/a.jpg",
|
|
161
176
|
});
|
|
162
177
|
const m2 = await mediaService.create({
|
|
163
178
|
...sampleMedia,
|
|
164
|
-
|
|
179
|
+
storageKey: "media/b.jpg",
|
|
165
180
|
});
|
|
166
181
|
|
|
167
182
|
await mediaService.attachToPost(post.id, [m2.id, m1.id]);
|
|
@@ -198,15 +213,15 @@ describe("MediaService", () => {
|
|
|
198
213
|
|
|
199
214
|
const m1 = await mediaService.create({
|
|
200
215
|
...sampleMedia,
|
|
201
|
-
|
|
216
|
+
storageKey: "media/a.jpg",
|
|
202
217
|
});
|
|
203
218
|
const m2 = await mediaService.create({
|
|
204
219
|
...sampleMedia,
|
|
205
|
-
|
|
220
|
+
storageKey: "media/b.jpg",
|
|
206
221
|
});
|
|
207
222
|
const m3 = await mediaService.create({
|
|
208
223
|
...sampleMedia,
|
|
209
|
-
|
|
224
|
+
storageKey: "media/c.jpg",
|
|
210
225
|
});
|
|
211
226
|
|
|
212
227
|
await mediaService.attachToPost(post1.id, [m1.id, m2.id]);
|
|
@@ -231,11 +246,11 @@ describe("MediaService", () => {
|
|
|
231
246
|
|
|
232
247
|
const m1 = await mediaService.create({
|
|
233
248
|
...sampleMedia,
|
|
234
|
-
|
|
249
|
+
storageKey: "media/a.jpg",
|
|
235
250
|
});
|
|
236
251
|
const m2 = await mediaService.create({
|
|
237
252
|
...sampleMedia,
|
|
238
|
-
|
|
253
|
+
storageKey: "media/b.jpg",
|
|
239
254
|
});
|
|
240
255
|
|
|
241
256
|
await mediaService.attachToPost(post.id, [m2.id, m1.id]);
|
|
@@ -247,11 +262,11 @@ describe("MediaService", () => {
|
|
|
247
262
|
});
|
|
248
263
|
});
|
|
249
264
|
|
|
250
|
-
describe("
|
|
265
|
+
describe("getByStorageKey", () => {
|
|
251
266
|
it("returns media by R2 key", async () => {
|
|
252
267
|
await mediaService.create(sampleMedia);
|
|
253
268
|
|
|
254
|
-
const found = await mediaService.
|
|
269
|
+
const found = await mediaService.getByStorageKey(
|
|
255
270
|
"media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
|
|
256
271
|
);
|
|
257
272
|
expect(found).not.toBeNull();
|
|
@@ -259,7 +274,7 @@ describe("MediaService", () => {
|
|
|
259
274
|
});
|
|
260
275
|
|
|
261
276
|
it("returns null for non-existent R2 key", async () => {
|
|
262
|
-
const found = await mediaService.
|
|
277
|
+
const found = await mediaService.getByStorageKey("nonexistent");
|
|
263
278
|
expect(found).toBeNull();
|
|
264
279
|
});
|
|
265
280
|
});
|
|
@@ -271,8 +286,8 @@ describe("MediaService", () => {
|
|
|
271
286
|
});
|
|
272
287
|
|
|
273
288
|
it("returns media ordered by createdAt desc", async () => {
|
|
274
|
-
await mediaService.create({ ...sampleMedia,
|
|
275
|
-
await mediaService.create({ ...sampleMedia,
|
|
289
|
+
await mediaService.create({ ...sampleMedia, storageKey: "a.jpg" });
|
|
290
|
+
await mediaService.create({ ...sampleMedia, storageKey: "b.jpg" });
|
|
276
291
|
|
|
277
292
|
const list = await mediaService.list();
|
|
278
293
|
expect(list).toHaveLength(2);
|
|
@@ -280,7 +295,10 @@ describe("MediaService", () => {
|
|
|
280
295
|
|
|
281
296
|
it("respects limit parameter", async () => {
|
|
282
297
|
for (let i = 0; i < 5; i++) {
|
|
283
|
-
await mediaService.create({
|
|
298
|
+
await mediaService.create({
|
|
299
|
+
...sampleMedia,
|
|
300
|
+
storageKey: `img${i}.jpg`,
|
|
301
|
+
});
|
|
284
302
|
}
|
|
285
303
|
|
|
286
304
|
const list = await mediaService.list(2);
|
|
@@ -297,11 +315,11 @@ describe("MediaService", () => {
|
|
|
297
315
|
|
|
298
316
|
const m1 = await mediaService.create({
|
|
299
317
|
...sampleMedia,
|
|
300
|
-
|
|
318
|
+
storageKey: "media/a.jpg",
|
|
301
319
|
});
|
|
302
320
|
const m2 = await mediaService.create({
|
|
303
321
|
...sampleMedia,
|
|
304
|
-
|
|
322
|
+
storageKey: "media/b.jpg",
|
|
305
323
|
});
|
|
306
324
|
|
|
307
325
|
await mediaService.attachToPost(post.id, [m1.id, m2.id]);
|
|
@@ -322,15 +340,15 @@ describe("MediaService", () => {
|
|
|
322
340
|
|
|
323
341
|
const m1 = await mediaService.create({
|
|
324
342
|
...sampleMedia,
|
|
325
|
-
|
|
343
|
+
storageKey: "media/a.jpg",
|
|
326
344
|
});
|
|
327
345
|
const m2 = await mediaService.create({
|
|
328
346
|
...sampleMedia,
|
|
329
|
-
|
|
347
|
+
storageKey: "media/b.jpg",
|
|
330
348
|
});
|
|
331
349
|
const m3 = await mediaService.create({
|
|
332
350
|
...sampleMedia,
|
|
333
|
-
|
|
351
|
+
storageKey: "media/c.jpg",
|
|
334
352
|
});
|
|
335
353
|
|
|
336
354
|
await mediaService.attachToPost(post.id, [m1.id, m2.id]);
|
|
@@ -355,7 +373,7 @@ describe("MediaService", () => {
|
|
|
355
373
|
|
|
356
374
|
const m1 = await mediaService.create({
|
|
357
375
|
...sampleMedia,
|
|
358
|
-
|
|
376
|
+
storageKey: "media/a.jpg",
|
|
359
377
|
});
|
|
360
378
|
|
|
361
379
|
await mediaService.attachToPost(post.id, [m1.id]);
|
|
@@ -375,7 +393,7 @@ describe("MediaService", () => {
|
|
|
375
393
|
|
|
376
394
|
const m1 = await mediaService.create({
|
|
377
395
|
...sampleMedia,
|
|
378
|
-
|
|
396
|
+
storageKey: "media/a.jpg",
|
|
379
397
|
});
|
|
380
398
|
|
|
381
399
|
await mediaService.attachToPost(post.id, [m1.id]);
|
package/src/services/media.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Media Service
|
|
3
3
|
*
|
|
4
|
-
* Handles media upload and management with
|
|
4
|
+
* Handles media upload and management with pluggable storage backends.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { eq, desc, inArray, asc } from "drizzle-orm";
|
|
@@ -19,7 +19,7 @@ export interface MediaService {
|
|
|
19
19
|
list(limit?: number): Promise<Media[]>;
|
|
20
20
|
create(data: CreateMediaData): Promise<Media>;
|
|
21
21
|
delete(id: string): Promise<boolean>;
|
|
22
|
-
|
|
22
|
+
getByStorageKey(storageKey: string): Promise<Media | null>;
|
|
23
23
|
attachToPost(postId: number, mediaIds: string[]): Promise<void>;
|
|
24
24
|
detachFromPost(postId: number): Promise<void>;
|
|
25
25
|
}
|
|
@@ -31,7 +31,8 @@ export interface CreateMediaData {
|
|
|
31
31
|
originalName: string;
|
|
32
32
|
mimeType: string;
|
|
33
33
|
size: number;
|
|
34
|
-
|
|
34
|
+
storageKey: string;
|
|
35
|
+
provider?: string;
|
|
35
36
|
width?: number;
|
|
36
37
|
height?: number;
|
|
37
38
|
alt?: string;
|
|
@@ -48,7 +49,8 @@ export function createMediaService(db: Database): MediaService {
|
|
|
48
49
|
originalName: row.originalName,
|
|
49
50
|
mimeType: row.mimeType,
|
|
50
51
|
size: row.size,
|
|
51
|
-
|
|
52
|
+
storageKey: row.storageKey,
|
|
53
|
+
provider: row.provider,
|
|
52
54
|
width: row.width,
|
|
53
55
|
height: row.height,
|
|
54
56
|
alt: row.alt,
|
|
@@ -107,11 +109,11 @@ export function createMediaService(db: Database): MediaService {
|
|
|
107
109
|
return result;
|
|
108
110
|
},
|
|
109
111
|
|
|
110
|
-
async
|
|
112
|
+
async getByStorageKey(storageKey) {
|
|
111
113
|
const result = await db
|
|
112
114
|
.select()
|
|
113
115
|
.from(media)
|
|
114
|
-
.where(eq(media.
|
|
116
|
+
.where(eq(media.storageKey, storageKey))
|
|
115
117
|
.limit(1);
|
|
116
118
|
return result[0] ? toMedia(result[0]) : null;
|
|
117
119
|
},
|
|
@@ -138,7 +140,8 @@ export function createMediaService(db: Database): MediaService {
|
|
|
138
140
|
originalName: data.originalName,
|
|
139
141
|
mimeType: data.mimeType,
|
|
140
142
|
size: data.size,
|
|
141
|
-
|
|
143
|
+
storageKey: data.storageKey,
|
|
144
|
+
provider: data.provider ?? "r2",
|
|
142
145
|
width: data.width ?? null,
|
|
143
146
|
height: data.height ?? null,
|
|
144
147
|
alt: data.alt ?? null,
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
import type { FC } from "hono/jsx";
|
|
6
6
|
import type { Post, Media, Collection } from "../../types.js";
|
|
7
7
|
import { useLingui } from "@lingui/react/macro";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getMediaUrl,
|
|
10
|
+
getImageUrl,
|
|
11
|
+
getPublicUrlForProvider,
|
|
12
|
+
} from "../../lib/image.js";
|
|
9
13
|
|
|
10
14
|
export interface PostFormProps {
|
|
11
15
|
post?: Post;
|
|
@@ -13,6 +17,7 @@ export interface PostFormProps {
|
|
|
13
17
|
mediaAttachments?: Media[];
|
|
14
18
|
r2PublicUrl?: string;
|
|
15
19
|
imageTransformUrl?: string;
|
|
20
|
+
s3PublicUrl?: string;
|
|
16
21
|
collections?: Collection[];
|
|
17
22
|
postCollectionIds?: number[];
|
|
18
23
|
}
|
|
@@ -23,6 +28,7 @@ export const PostForm: FC<PostFormProps> = ({
|
|
|
23
28
|
mediaAttachments,
|
|
24
29
|
r2PublicUrl,
|
|
25
30
|
imageTransformUrl,
|
|
31
|
+
s3PublicUrl,
|
|
26
32
|
collections,
|
|
27
33
|
postCollectionIds,
|
|
28
34
|
}) => {
|
|
@@ -135,7 +141,12 @@ export const PostForm: FC<PostFormProps> = ({
|
|
|
135
141
|
{mediaAttachments && mediaAttachments.length > 0 && (
|
|
136
142
|
<div class="grid grid-cols-4 sm:grid-cols-6 gap-2 mb-2">
|
|
137
143
|
{mediaAttachments.map((m) => {
|
|
138
|
-
const
|
|
144
|
+
const pUrl = getPublicUrlForProvider(
|
|
145
|
+
m.provider,
|
|
146
|
+
r2PublicUrl,
|
|
147
|
+
s3PublicUrl,
|
|
148
|
+
);
|
|
149
|
+
const url = getMediaUrl(m.id, m.storageKey, pUrl);
|
|
139
150
|
const thumbUrl = getImageUrl(url, imageTransformUrl, {
|
|
140
151
|
width: 150,
|
|
141
152
|
quality: 80,
|
package/src/types.ts
CHANGED
|
@@ -32,6 +32,9 @@ export const POST_TYPE_MEDIA_RULES: Record<PostType, [number, number] | null> =
|
|
|
32
32
|
page: null,
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
export const STORAGE_DRIVERS = ["r2", "s3"] as const;
|
|
36
|
+
export type StorageDriver = (typeof STORAGE_DRIVERS)[number];
|
|
37
|
+
|
|
35
38
|
export const VISIBILITY_LEVELS = [
|
|
36
39
|
"featured",
|
|
37
40
|
"quiet",
|
|
@@ -57,6 +60,14 @@ export interface Bindings {
|
|
|
57
60
|
SITE_NAME?: string;
|
|
58
61
|
SITE_DESCRIPTION?: string;
|
|
59
62
|
SITE_LANGUAGE?: string;
|
|
63
|
+
// S3-compatible storage (alternative to R2)
|
|
64
|
+
STORAGE_DRIVER?: string;
|
|
65
|
+
S3_ENDPOINT?: string;
|
|
66
|
+
S3_BUCKET?: string;
|
|
67
|
+
S3_ACCESS_KEY_ID?: string;
|
|
68
|
+
S3_SECRET_ACCESS_KEY?: string;
|
|
69
|
+
S3_REGION?: string;
|
|
70
|
+
S3_PUBLIC_URL?: string;
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
// =============================================================================
|
|
@@ -113,6 +124,34 @@ export const CONFIG_FIELDS = {
|
|
|
113
124
|
defaultValue: "",
|
|
114
125
|
envOnly: true,
|
|
115
126
|
},
|
|
127
|
+
STORAGE_DRIVER: {
|
|
128
|
+
defaultValue: "r2",
|
|
129
|
+
envOnly: true,
|
|
130
|
+
},
|
|
131
|
+
S3_ENDPOINT: {
|
|
132
|
+
defaultValue: "",
|
|
133
|
+
envOnly: true,
|
|
134
|
+
},
|
|
135
|
+
S3_BUCKET: {
|
|
136
|
+
defaultValue: "",
|
|
137
|
+
envOnly: true,
|
|
138
|
+
},
|
|
139
|
+
S3_ACCESS_KEY_ID: {
|
|
140
|
+
defaultValue: "",
|
|
141
|
+
envOnly: true,
|
|
142
|
+
},
|
|
143
|
+
S3_SECRET_ACCESS_KEY: {
|
|
144
|
+
defaultValue: "",
|
|
145
|
+
envOnly: true,
|
|
146
|
+
},
|
|
147
|
+
S3_REGION: {
|
|
148
|
+
defaultValue: "auto",
|
|
149
|
+
envOnly: true,
|
|
150
|
+
},
|
|
151
|
+
S3_PUBLIC_URL: {
|
|
152
|
+
defaultValue: "",
|
|
153
|
+
envOnly: true,
|
|
154
|
+
},
|
|
116
155
|
} as const;
|
|
117
156
|
|
|
118
157
|
export type ConfigKey = keyof typeof CONFIG_FIELDS;
|
|
@@ -147,7 +186,8 @@ export interface Media {
|
|
|
147
186
|
originalName: string;
|
|
148
187
|
mimeType: string;
|
|
149
188
|
size: number;
|
|
150
|
-
|
|
189
|
+
storageKey: string;
|
|
190
|
+
provider: string;
|
|
151
191
|
width: number | null;
|
|
152
192
|
height: number | null;
|
|
153
193
|
alt: string | null;
|