@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
|
@@ -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
|
});
|
|
@@ -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({
|
|
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
|
});
|