@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.
- 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 +150 -80
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +150 -80
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +150 -80
- 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
package/src/routes/api/posts.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
|
@@ -5,7 +5,7 @@ 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, Media, Collection } from "../../types.js";
|
|
9
9
|
import type { AppVariables } from "../../app.js";
|
|
10
10
|
import { DashLayout } from "../../theme/layouts/index.js";
|
|
11
11
|
import {
|
|
@@ -38,14 +38,14 @@ function PostsListContent({ posts }: { posts: Post[] }) {
|
|
|
38
38
|
);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function NewPostContent() {
|
|
41
|
+
function NewPostContent({ collections }: { collections: Collection[] }) {
|
|
42
42
|
const { t } = useLingui();
|
|
43
43
|
return (
|
|
44
44
|
<>
|
|
45
45
|
<h1 class="text-2xl font-semibold mb-6">
|
|
46
46
|
{t({ message: "New Post", comment: "@context: Page heading" })}
|
|
47
47
|
</h1>
|
|
48
|
-
<PostForm action="/dash/posts" />
|
|
48
|
+
<PostForm action="/dash/posts" collections={collections} />
|
|
49
49
|
</>
|
|
50
50
|
);
|
|
51
51
|
}
|
|
@@ -72,6 +72,7 @@ postsRoutes.get("/", async (c) => {
|
|
|
72
72
|
// New post form
|
|
73
73
|
postsRoutes.get("/new", async (c) => {
|
|
74
74
|
const siteName = await getSiteName(c);
|
|
75
|
+
const collections = await c.var.services.collections.list();
|
|
75
76
|
|
|
76
77
|
return c.html(
|
|
77
78
|
<DashLayout
|
|
@@ -80,7 +81,7 @@ postsRoutes.get("/new", async (c) => {
|
|
|
80
81
|
siteName={siteName}
|
|
81
82
|
currentPath="/dash/posts"
|
|
82
83
|
>
|
|
83
|
-
<NewPostContent />
|
|
84
|
+
<NewPostContent collections={collections} />
|
|
84
85
|
</DashLayout>,
|
|
85
86
|
);
|
|
86
87
|
});
|
|
@@ -93,7 +94,10 @@ postsRoutes.post("/", async (c) => {
|
|
|
93
94
|
content: string;
|
|
94
95
|
visibility: string;
|
|
95
96
|
sourceUrl?: string;
|
|
97
|
+
sourceName?: string;
|
|
96
98
|
path?: string;
|
|
99
|
+
mediaIds?: string[];
|
|
100
|
+
collectionIds?: number[];
|
|
97
101
|
}>();
|
|
98
102
|
|
|
99
103
|
const post = await c.var.services.posts.create({
|
|
@@ -102,9 +106,23 @@ postsRoutes.post("/", async (c) => {
|
|
|
102
106
|
content: body.content,
|
|
103
107
|
visibility: body.visibility as Post["visibility"],
|
|
104
108
|
sourceUrl: body.sourceUrl || undefined,
|
|
109
|
+
sourceName: body.sourceName || undefined,
|
|
105
110
|
path: body.path || undefined,
|
|
106
111
|
});
|
|
107
112
|
|
|
113
|
+
// Attach media if provided
|
|
114
|
+
if (body.mediaIds && body.mediaIds.length > 0) {
|
|
115
|
+
await c.var.services.media.attachToPost(post.id, body.mediaIds);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Sync collection associations
|
|
119
|
+
if (body.collectionIds) {
|
|
120
|
+
await c.var.services.collections.syncPostCollections(
|
|
121
|
+
post.id,
|
|
122
|
+
body.collectionIds,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
108
126
|
return dsRedirect(`/dash/posts/${sqid.encode(post.id)}`);
|
|
109
127
|
});
|
|
110
128
|
|
|
@@ -145,14 +163,36 @@ function ViewPostContent({ post }: { post: Post }) {
|
|
|
145
163
|
);
|
|
146
164
|
}
|
|
147
165
|
|
|
148
|
-
function EditPostContent({
|
|
166
|
+
function EditPostContent({
|
|
167
|
+
post,
|
|
168
|
+
mediaAttachments,
|
|
169
|
+
r2PublicUrl,
|
|
170
|
+
imageTransformUrl,
|
|
171
|
+
collections,
|
|
172
|
+
postCollectionIds,
|
|
173
|
+
}: {
|
|
174
|
+
post: Post;
|
|
175
|
+
mediaAttachments: Media[];
|
|
176
|
+
r2PublicUrl?: string;
|
|
177
|
+
imageTransformUrl?: string;
|
|
178
|
+
collections: Collection[];
|
|
179
|
+
postCollectionIds: number[];
|
|
180
|
+
}) {
|
|
149
181
|
const { t } = useLingui();
|
|
150
182
|
return (
|
|
151
183
|
<>
|
|
152
184
|
<h1 class="text-2xl font-semibold mb-6">
|
|
153
185
|
{t({ message: "Edit Post", comment: "@context: Page heading" })}
|
|
154
186
|
</h1>
|
|
155
|
-
<PostForm
|
|
187
|
+
<PostForm
|
|
188
|
+
post={post}
|
|
189
|
+
action={`/dash/posts/${sqid.encode(post.id)}`}
|
|
190
|
+
mediaAttachments={mediaAttachments}
|
|
191
|
+
r2PublicUrl={r2PublicUrl}
|
|
192
|
+
imageTransformUrl={imageTransformUrl}
|
|
193
|
+
collections={collections}
|
|
194
|
+
postCollectionIds={postCollectionIds}
|
|
195
|
+
/>
|
|
156
196
|
</>
|
|
157
197
|
);
|
|
158
198
|
}
|
|
@@ -189,6 +229,13 @@ postsRoutes.get("/:id/edit", async (c) => {
|
|
|
189
229
|
if (!post) return c.notFound();
|
|
190
230
|
|
|
191
231
|
const siteName = await getSiteName(c);
|
|
232
|
+
const mediaAttachments = await c.var.services.media.getByPostId(post.id);
|
|
233
|
+
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
234
|
+
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
235
|
+
const collections = await c.var.services.collections.list();
|
|
236
|
+
const postCollections =
|
|
237
|
+
await c.var.services.collections.getCollectionsForPost(post.id);
|
|
238
|
+
const postCollectionIds = postCollections.map((col) => col.id);
|
|
192
239
|
|
|
193
240
|
return c.html(
|
|
194
241
|
<DashLayout
|
|
@@ -197,7 +244,14 @@ postsRoutes.get("/:id/edit", async (c) => {
|
|
|
197
244
|
siteName={siteName}
|
|
198
245
|
currentPath="/dash/posts"
|
|
199
246
|
>
|
|
200
|
-
<EditPostContent
|
|
247
|
+
<EditPostContent
|
|
248
|
+
post={post}
|
|
249
|
+
mediaAttachments={mediaAttachments}
|
|
250
|
+
r2PublicUrl={r2PublicUrl}
|
|
251
|
+
imageTransformUrl={imageTransformUrl}
|
|
252
|
+
collections={collections}
|
|
253
|
+
postCollectionIds={postCollectionIds}
|
|
254
|
+
/>
|
|
201
255
|
</DashLayout>,
|
|
202
256
|
);
|
|
203
257
|
});
|
|
@@ -213,7 +267,10 @@ postsRoutes.post("/:id", async (c) => {
|
|
|
213
267
|
content?: string;
|
|
214
268
|
visibility: string;
|
|
215
269
|
sourceUrl?: string;
|
|
270
|
+
sourceName?: string;
|
|
216
271
|
path?: string;
|
|
272
|
+
mediaIds?: string[];
|
|
273
|
+
collectionIds?: number[];
|
|
217
274
|
}>();
|
|
218
275
|
|
|
219
276
|
await c.var.services.posts.update(id, {
|
|
@@ -222,9 +279,23 @@ postsRoutes.post("/:id", async (c) => {
|
|
|
222
279
|
content: body.content || null,
|
|
223
280
|
visibility: body.visibility as Post["visibility"],
|
|
224
281
|
sourceUrl: body.sourceUrl || null,
|
|
282
|
+
sourceName: body.sourceName || null,
|
|
225
283
|
path: body.path || null,
|
|
226
284
|
});
|
|
227
285
|
|
|
286
|
+
// Update media attachments if provided
|
|
287
|
+
if (body.mediaIds !== undefined) {
|
|
288
|
+
await c.var.services.media.attachToPost(id, body.mediaIds);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Sync collection associations
|
|
292
|
+
if (body.collectionIds !== undefined) {
|
|
293
|
+
await c.var.services.collections.syncPostCollections(
|
|
294
|
+
id,
|
|
295
|
+
body.collectionIds,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
228
299
|
return dsRedirect(`/dash/posts/${sqid.encode(id)}`);
|
|
229
300
|
});
|
|
230
301
|
|
|
@@ -233,6 +304,7 @@ postsRoutes.post("/:id/delete", async (c) => {
|
|
|
233
304
|
const id = sqid.decode(c.req.param("id"));
|
|
234
305
|
if (!id) return c.notFound();
|
|
235
306
|
|
|
307
|
+
await c.var.services.media.detachFromPost(id);
|
|
236
308
|
await c.var.services.posts.delete(id);
|
|
237
309
|
|
|
238
310
|
return dsRedirect("/dash/posts");
|
package/src/routes/feed/rss.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Bindings } from "../../types.js";
|
|
|
7
7
|
import type { AppVariables } from "../../app.js";
|
|
8
8
|
import * as sqid from "../../lib/sqid.js";
|
|
9
9
|
import * as time from "../../lib/time.js";
|
|
10
|
+
import { getMediaUrl } from "../../lib/image.js";
|
|
10
11
|
|
|
11
12
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
12
13
|
|
|
@@ -18,25 +19,37 @@ rssRoutes.get("/", async (c) => {
|
|
|
18
19
|
const siteName = all["SITE_NAME"] ?? "Jant";
|
|
19
20
|
const siteDescription = all["SITE_DESCRIPTION"] ?? "";
|
|
20
21
|
const siteUrl = c.env.SITE_URL;
|
|
22
|
+
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
21
23
|
|
|
22
24
|
const posts = await c.var.services.posts.list({
|
|
23
25
|
visibility: ["featured", "quiet"],
|
|
24
26
|
limit: 50,
|
|
25
27
|
});
|
|
26
28
|
|
|
29
|
+
// Batch load media for enclosures
|
|
30
|
+
const postIds = posts.map((p) => p.id);
|
|
31
|
+
const mediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
32
|
+
|
|
27
33
|
const items = posts
|
|
28
34
|
.map((post) => {
|
|
29
35
|
const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
|
|
30
36
|
const title = post.title || `Post #${post.id}`;
|
|
31
37
|
const pubDate = new Date(post.publishedAt * 1000).toUTCString();
|
|
32
38
|
|
|
39
|
+
// Add enclosure for first media attachment
|
|
40
|
+
const postMedia = mediaMap.get(post.id);
|
|
41
|
+
const firstMedia = postMedia?.[0];
|
|
42
|
+
const enclosure = firstMedia
|
|
43
|
+
? `\n <enclosure url="${getMediaUrl(firstMedia.id, firstMedia.r2Key, r2PublicUrl)}" length="${firstMedia.size}" type="${firstMedia.mimeType}"/>`
|
|
44
|
+
: "";
|
|
45
|
+
|
|
33
46
|
return `
|
|
34
47
|
<item>
|
|
35
48
|
<title><![CDATA[${escapeXml(title)}]]></title>
|
|
36
49
|
<link>${link}</link>
|
|
37
50
|
<guid isPermaLink="true">${link}</guid>
|
|
38
51
|
<pubDate>${pubDate}</pubDate>
|
|
39
|
-
<description><![CDATA[${post.contentHtml || ""}]]></description
|
|
52
|
+
<description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
|
|
40
53
|
</item>`;
|
|
41
54
|
})
|
|
42
55
|
.join("");
|
|
@@ -4,18 +4,28 @@
|
|
|
4
4
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { useLingui } from "@lingui/react/macro";
|
|
7
|
-
import type { Bindings, Post } from "../../types.js";
|
|
7
|
+
import type { Bindings, Post, MediaAttachment } from "../../types.js";
|
|
8
8
|
import type { AppVariables } from "../../app.js";
|
|
9
9
|
import { BaseLayout } from "../../theme/layouts/index.js";
|
|
10
|
+
import { MediaGallery } from "../../theme/components/index.js";
|
|
10
11
|
import * as sqid from "../../lib/sqid.js";
|
|
11
12
|
import * as time from "../../lib/time.js";
|
|
12
13
|
import { getSiteName } from "../../lib/config.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 homeRoutes = new Hono<Env>();
|
|
17
19
|
|
|
18
|
-
function HomeContent({
|
|
20
|
+
function HomeContent({
|
|
21
|
+
siteName,
|
|
22
|
+
posts,
|
|
23
|
+
mediaMap,
|
|
24
|
+
}: {
|
|
25
|
+
siteName: string;
|
|
26
|
+
posts: Post[];
|
|
27
|
+
mediaMap: Map<number, MediaAttachment[]>;
|
|
28
|
+
}) {
|
|
19
29
|
const { t } = useLingui();
|
|
20
30
|
|
|
21
31
|
return (
|
|
@@ -47,40 +57,46 @@ function HomeContent({ siteName, posts }: { siteName: string; posts: Post[] }) {
|
|
|
47
57
|
})}
|
|
48
58
|
</p>
|
|
49
59
|
) : (
|
|
50
|
-
posts.map((post) =>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
>
|
|
71
|
-
{time.formatDate(post.publishedAt)}
|
|
72
|
-
</time>
|
|
73
|
-
{post.visibility === "featured" && (
|
|
74
|
-
<span class="ml-2 text-xs">
|
|
75
|
-
{t({
|
|
76
|
-
message: "Featured",
|
|
77
|
-
comment: "@context: Post visibility badge",
|
|
78
|
-
})}
|
|
79
|
-
</span>
|
|
60
|
+
posts.map((post) => {
|
|
61
|
+
const attachments = mediaMap.get(post.id) ?? [];
|
|
62
|
+
return (
|
|
63
|
+
<article key={post.id} class="h-entry">
|
|
64
|
+
{post.title && (
|
|
65
|
+
<h2 class="p-name text-lg font-medium mb-2">
|
|
66
|
+
<a
|
|
67
|
+
href={`/p/${sqid.encode(post.id)}`}
|
|
68
|
+
class="u-url hover:underline"
|
|
69
|
+
>
|
|
70
|
+
{post.title}
|
|
71
|
+
</a>
|
|
72
|
+
</h2>
|
|
73
|
+
)}
|
|
74
|
+
<div
|
|
75
|
+
class="e-content prose prose-sm"
|
|
76
|
+
dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
|
|
77
|
+
/>
|
|
78
|
+
{attachments.length > 0 && (
|
|
79
|
+
<MediaGallery attachments={attachments} />
|
|
80
80
|
)}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
<footer class="mt-2 text-sm text-muted-foreground">
|
|
82
|
+
<time
|
|
83
|
+
class="dt-published"
|
|
84
|
+
datetime={time.toISOString(post.publishedAt)}
|
|
85
|
+
>
|
|
86
|
+
{time.formatDate(post.publishedAt)}
|
|
87
|
+
</time>
|
|
88
|
+
{post.visibility === "featured" && (
|
|
89
|
+
<span class="ml-2 text-xs">
|
|
90
|
+
{t({
|
|
91
|
+
message: "Featured",
|
|
92
|
+
comment: "@context: Post visibility badge",
|
|
93
|
+
})}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</footer>
|
|
97
|
+
</article>
|
|
98
|
+
);
|
|
99
|
+
})
|
|
84
100
|
)}
|
|
85
101
|
</main>
|
|
86
102
|
|
|
@@ -109,9 +125,37 @@ homeRoutes.get("/", async (c) => {
|
|
|
109
125
|
limit: 20,
|
|
110
126
|
});
|
|
111
127
|
|
|
128
|
+
// Batch load media attachments
|
|
129
|
+
const postIds = posts.map((p) => p.id);
|
|
130
|
+
const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
131
|
+
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
132
|
+
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
133
|
+
|
|
134
|
+
const mediaMap = new Map<number, MediaAttachment[]>();
|
|
135
|
+
for (const [postId, mediaList] of rawMediaMap) {
|
|
136
|
+
mediaMap.set(
|
|
137
|
+
postId,
|
|
138
|
+
mediaList.map((m) => ({
|
|
139
|
+
id: m.id,
|
|
140
|
+
url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
|
|
141
|
+
previewUrl: getImageUrl(
|
|
142
|
+
getMediaUrl(m.id, m.r2Key, r2PublicUrl),
|
|
143
|
+
imageTransformUrl,
|
|
144
|
+
{ width: 400, quality: 80, format: "auto", fit: "cover" },
|
|
145
|
+
),
|
|
146
|
+
alt: m.alt,
|
|
147
|
+
blurhash: m.blurhash,
|
|
148
|
+
width: m.width,
|
|
149
|
+
height: m.height,
|
|
150
|
+
position: m.position,
|
|
151
|
+
mimeType: m.mimeType,
|
|
152
|
+
})),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
112
156
|
return c.html(
|
|
113
157
|
<BaseLayout title={siteName} c={c}>
|
|
114
|
-
<HomeContent siteName={siteName} posts={posts} />
|
|
158
|
+
<HomeContent siteName={siteName} posts={posts} mediaMap={mediaMap} />
|
|
115
159
|
</BaseLayout>,
|
|
116
160
|
);
|
|
117
161
|
});
|