@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.
Files changed (44) hide show
  1. package/dist/app.js +7 -4
  2. package/dist/db/schema.js +2 -1
  3. package/dist/lib/image.js +39 -15
  4. package/dist/lib/media-helpers.js +14 -8
  5. package/dist/lib/storage.js +164 -0
  6. package/dist/routes/api/posts.js +12 -7
  7. package/dist/routes/api/timeline.js +3 -2
  8. package/dist/routes/api/upload.js +27 -20
  9. package/dist/routes/dash/media.js +24 -14
  10. package/dist/routes/dash/posts.js +4 -1
  11. package/dist/routes/feed/rss.js +3 -2
  12. package/dist/routes/pages/home.js +3 -2
  13. package/dist/routes/pages/post.js +9 -5
  14. package/dist/services/media.js +7 -5
  15. package/dist/theme/components/PostForm.js +4 -3
  16. package/dist/types.js +32 -0
  17. package/package.json +2 -1
  18. package/src/__tests__/helpers/app.ts +1 -0
  19. package/src/__tests__/helpers/db.ts +10 -0
  20. package/src/app.tsx +8 -7
  21. package/src/db/migrations/0004_add_storage_provider.sql +3 -0
  22. package/src/db/migrations/meta/_journal.json +7 -0
  23. package/src/db/schema.ts +2 -1
  24. package/src/i18n/locales/en.po +67 -67
  25. package/src/i18n/locales/zh-Hans.po +67 -67
  26. package/src/i18n/locales/zh-Hant.po +67 -67
  27. package/src/lib/__tests__/image.test.ts +96 -0
  28. package/src/lib/__tests__/storage.test.ts +162 -0
  29. package/src/lib/image.ts +46 -16
  30. package/src/lib/media-helpers.ts +29 -18
  31. package/src/lib/storage.ts +236 -0
  32. package/src/routes/api/__tests__/posts.test.ts +8 -8
  33. package/src/routes/api/posts.ts +20 -6
  34. package/src/routes/api/timeline.tsx +8 -1
  35. package/src/routes/api/upload.ts +44 -21
  36. package/src/routes/dash/media.tsx +40 -8
  37. package/src/routes/dash/posts.tsx +5 -0
  38. package/src/routes/feed/rss.ts +3 -2
  39. package/src/routes/pages/home.tsx +8 -1
  40. package/src/routes/pages/post.tsx +29 -17
  41. package/src/services/__tests__/media.test.ts +44 -26
  42. package/src/services/media.ts +10 -7
  43. package/src/theme/components/PostForm.tsx +13 -2
  44. 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
- r2Key: "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
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.r2Key).toBe(
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
- r2Key: "media/2025/01/other.jpg",
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
- r2Key: "media/2025/01/auto.jpg",
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
- r2Key: "media/a.jpg",
140
+ storageKey: "media/a.jpg",
126
141
  });
127
142
  const m2 = await mediaService.create({
128
143
  ...sampleMedia,
129
- r2Key: "media/b.jpg",
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
- r2Key: "media/a.jpg",
175
+ storageKey: "media/a.jpg",
161
176
  });
162
177
  const m2 = await mediaService.create({
163
178
  ...sampleMedia,
164
- r2Key: "media/b.jpg",
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
- r2Key: "media/a.jpg",
216
+ storageKey: "media/a.jpg",
202
217
  });
203
218
  const m2 = await mediaService.create({
204
219
  ...sampleMedia,
205
- r2Key: "media/b.jpg",
220
+ storageKey: "media/b.jpg",
206
221
  });
207
222
  const m3 = await mediaService.create({
208
223
  ...sampleMedia,
209
- r2Key: "media/c.jpg",
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
- r2Key: "media/a.jpg",
249
+ storageKey: "media/a.jpg",
235
250
  });
236
251
  const m2 = await mediaService.create({
237
252
  ...sampleMedia,
238
- r2Key: "media/b.jpg",
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("getByR2Key", () => {
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.getByR2Key(
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.getByR2Key("nonexistent");
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, r2Key: "a.jpg" });
275
- await mediaService.create({ ...sampleMedia, r2Key: "b.jpg" });
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({ ...sampleMedia, r2Key: `img${i}.jpg` });
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
- r2Key: "media/a.jpg",
318
+ storageKey: "media/a.jpg",
301
319
  });
302
320
  const m2 = await mediaService.create({
303
321
  ...sampleMedia,
304
- r2Key: "media/b.jpg",
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
- r2Key: "media/a.jpg",
343
+ storageKey: "media/a.jpg",
326
344
  });
327
345
  const m2 = await mediaService.create({
328
346
  ...sampleMedia,
329
- r2Key: "media/b.jpg",
347
+ storageKey: "media/b.jpg",
330
348
  });
331
349
  const m3 = await mediaService.create({
332
350
  ...sampleMedia,
333
- r2Key: "media/c.jpg",
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
- r2Key: "media/a.jpg",
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
- r2Key: "media/a.jpg",
396
+ storageKey: "media/a.jpg",
379
397
  });
380
398
 
381
399
  await mediaService.attachToPost(post.id, [m1.id]);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Media Service
3
3
  *
4
- * Handles media upload and management with R2 storage
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
- getByR2Key(r2Key: string): Promise<Media | null>;
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
- r2Key: string;
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
- r2Key: row.r2Key,
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 getByR2Key(r2Key) {
112
+ async getByStorageKey(storageKey) {
111
113
  const result = await db
112
114
  .select()
113
115
  .from(media)
114
- .where(eq(media.r2Key, r2Key))
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
- r2Key: data.r2Key,
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 { getMediaUrl, getImageUrl } from "../../lib/image.js";
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 url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
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
- r2Key: string;
189
+ storageKey: string;
190
+ provider: string;
151
191
  width: number | null;
152
192
  height: number | null;
153
193
  alt: string | null;