@jant/core 0.3.7 → 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 +11 -4
- package/dist/client.js +1 -0
- package/dist/db/schema.js +15 -1
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/lib/image.js +39 -15
- package/dist/lib/media-helpers.js +49 -0
- package/dist/lib/nav-reorder.js +27 -0
- package/dist/lib/navigation.js +35 -0
- package/dist/lib/storage.js +164 -0
- package/dist/lib/theme-components.js +49 -0
- package/dist/routes/api/posts.js +12 -7
- package/dist/routes/api/timeline.js +116 -0
- package/dist/routes/api/upload.js +35 -24
- package/dist/routes/dash/media.js +24 -14
- package/dist/routes/dash/navigation.js +274 -0
- package/dist/routes/dash/posts.js +4 -1
- package/dist/routes/feed/rss.js +3 -2
- package/dist/routes/pages/archive.js +14 -27
- package/dist/routes/pages/collection.js +10 -19
- package/dist/routes/pages/home.js +84 -126
- package/dist/routes/pages/page.js +19 -38
- package/dist/routes/pages/post.js +47 -56
- package/dist/routes/pages/search.js +13 -26
- package/dist/services/index.js +3 -1
- package/dist/services/media.js +8 -6
- package/dist/services/navigation.js +115 -0
- package/dist/services/post.js +26 -1
- package/dist/theme/components/PostForm.js +4 -3
- package/dist/theme/components/PostList.js +5 -0
- package/dist/theme/components/index.js +2 -0
- package/dist/theme/components/timeline/ArticleCard.js +50 -0
- package/dist/theme/components/timeline/ImageCard.js +86 -0
- package/dist/theme/components/timeline/LinkCard.js +62 -0
- package/dist/theme/components/timeline/NoteCard.js +37 -0
- package/dist/theme/components/timeline/QuoteCard.js +51 -0
- package/dist/theme/components/timeline/ThreadPreview.js +52 -0
- package/dist/theme/components/timeline/TimelineFeed.js +43 -0
- package/dist/theme/components/timeline/TimelineItem.js +25 -0
- package/dist/theme/components/timeline/index.js +8 -0
- package/dist/theme/layouts/DashLayout.js +8 -0
- package/dist/theme/layouts/SiteLayout.js +160 -0
- package/dist/theme/layouts/index.js +1 -0
- package/dist/types/sortablejs.d.js +5 -0
- package/dist/types.js +32 -0
- package/package.json +4 -2
- package/src/__tests__/helpers/app.ts +1 -0
- package/src/__tests__/helpers/db.ts +20 -0
- package/src/app.tsx +12 -7
- package/src/client.ts +1 -0
- package/src/db/migrations/0003_add_navigation_links.sql +8 -0
- package/src/db/migrations/0004_add_storage_provider.sql +3 -0
- package/src/db/migrations/meta/0003_snapshot.json +821 -0
- package/src/db/migrations/meta/_journal.json +21 -0
- package/src/db/schema.ts +15 -1
- package/src/i18n/locales/en.po +148 -80
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +150 -103
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +150 -103
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +5 -0
- package/src/lib/__tests__/image.test.ts +96 -0
- package/src/lib/__tests__/storage.test.ts +162 -0
- package/src/lib/__tests__/theme-components.test.ts +107 -0
- package/src/lib/image.ts +46 -16
- package/src/lib/media-helpers.ts +65 -0
- package/src/lib/nav-reorder.ts +26 -0
- package/src/lib/navigation.ts +46 -0
- package/src/lib/storage.ts +236 -0
- package/src/lib/theme-components.ts +76 -0
- package/src/routes/api/__tests__/posts.test.ts +8 -8
- package/src/routes/api/__tests__/timeline.test.ts +242 -0
- package/src/routes/api/posts.ts +20 -6
- package/src/routes/api/timeline.tsx +152 -0
- package/src/routes/api/upload.ts +52 -25
- package/src/routes/dash/media.tsx +40 -8
- package/src/routes/dash/navigation.tsx +306 -0
- package/src/routes/dash/posts.tsx +5 -0
- package/src/routes/feed/rss.ts +3 -2
- package/src/routes/pages/archive.tsx +15 -23
- package/src/routes/pages/collection.tsx +8 -15
- package/src/routes/pages/home.tsx +118 -122
- package/src/routes/pages/page.tsx +17 -30
- package/src/routes/pages/post.tsx +63 -60
- package/src/routes/pages/search.tsx +18 -22
- package/src/services/__tests__/media.test.ts +73 -28
- package/src/services/__tests__/navigation.test.ts +213 -0
- package/src/services/__tests__/post-timeline.test.ts +220 -0
- package/src/services/index.ts +7 -0
- package/src/services/media.ts +12 -8
- package/src/services/navigation.ts +165 -0
- package/src/services/post.ts +48 -1
- package/src/styles/components.css +59 -0
- package/src/theme/components/PostForm.tsx +13 -2
- package/src/theme/components/PostList.tsx +7 -0
- package/src/theme/components/index.ts +12 -0
- package/src/theme/components/timeline/ArticleCard.tsx +57 -0
- package/src/theme/components/timeline/ImageCard.tsx +80 -0
- package/src/theme/components/timeline/LinkCard.tsx +66 -0
- package/src/theme/components/timeline/NoteCard.tsx +41 -0
- package/src/theme/components/timeline/QuoteCard.tsx +55 -0
- package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
- package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
- package/src/theme/components/timeline/TimelineItem.tsx +39 -0
- package/src/theme/components/timeline/index.ts +8 -0
- package/src/theme/layouts/DashLayout.tsx +10 -0
- package/src/theme/layouts/SiteLayout.tsx +184 -0
- package/src/theme/layouts/index.ts +1 -0
- package/src/types/sortablejs.d.ts +23 -0
- package/src/types.ts +102 -1
- package/dist/app.d.ts +0 -38
- package/dist/app.d.ts.map +0 -1
- package/dist/auth.d.ts +0 -25
- package/dist/auth.d.ts.map +0 -1
- package/dist/db/index.d.ts +0 -10
- package/dist/db/index.d.ts.map +0 -1
- package/dist/db/schema.d.ts +0 -1543
- package/dist/db/schema.d.ts.map +0 -1
- package/dist/i18n/Trans.d.ts +0 -25
- package/dist/i18n/Trans.d.ts.map +0 -1
- package/dist/i18n/context.d.ts +0 -69
- package/dist/i18n/context.d.ts.map +0 -1
- package/dist/i18n/detect.d.ts +0 -20
- package/dist/i18n/detect.d.ts.map +0 -1
- package/dist/i18n/i18n.d.ts +0 -32
- package/dist/i18n/i18n.d.ts.map +0 -1
- package/dist/i18n/index.d.ts +0 -41
- package/dist/i18n/index.d.ts.map +0 -1
- package/dist/i18n/locales/en.d.ts +0 -3
- package/dist/i18n/locales/en.d.ts.map +0 -1
- package/dist/i18n/locales/zh-Hans.d.ts +0 -3
- package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
- package/dist/i18n/locales/zh-Hant.d.ts +0 -3
- package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
- package/dist/i18n/locales.d.ts +0 -11
- package/dist/i18n/locales.d.ts.map +0 -1
- package/dist/i18n/middleware.d.ts +0 -21
- package/dist/i18n/middleware.d.ts.map +0 -1
- package/dist/index.d.ts +0 -16
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/config.d.ts +0 -83
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/constants.d.ts +0 -37
- package/dist/lib/constants.d.ts.map +0 -1
- package/dist/lib/image.d.ts +0 -73
- package/dist/lib/image.d.ts.map +0 -1
- package/dist/lib/index.d.ts +0 -9
- package/dist/lib/index.d.ts.map +0 -1
- package/dist/lib/markdown.d.ts +0 -60
- package/dist/lib/markdown.d.ts.map +0 -1
- package/dist/lib/schemas.d.ts +0 -130
- package/dist/lib/schemas.d.ts.map +0 -1
- package/dist/lib/sqid.d.ts +0 -60
- package/dist/lib/sqid.d.ts.map +0 -1
- package/dist/lib/sse.d.ts +0 -192
- package/dist/lib/sse.d.ts.map +0 -1
- package/dist/lib/theme.d.ts +0 -44
- package/dist/lib/theme.d.ts.map +0 -1
- package/dist/lib/time.d.ts +0 -90
- package/dist/lib/time.d.ts.map +0 -1
- package/dist/lib/url.d.ts +0 -82
- package/dist/lib/url.d.ts.map +0 -1
- package/dist/middleware/auth.d.ts +0 -24
- package/dist/middleware/auth.d.ts.map +0 -1
- package/dist/middleware/onboarding.d.ts +0 -26
- package/dist/middleware/onboarding.d.ts.map +0 -1
- package/dist/routes/api/posts.d.ts +0 -13
- package/dist/routes/api/posts.d.ts.map +0 -1
- package/dist/routes/api/search.d.ts +0 -13
- package/dist/routes/api/search.d.ts.map +0 -1
- package/dist/routes/api/upload.d.ts +0 -16
- package/dist/routes/api/upload.d.ts.map +0 -1
- package/dist/routes/dash/collections.d.ts +0 -13
- package/dist/routes/dash/collections.d.ts.map +0 -1
- package/dist/routes/dash/index.d.ts +0 -15
- package/dist/routes/dash/index.d.ts.map +0 -1
- package/dist/routes/dash/media.d.ts +0 -16
- package/dist/routes/dash/media.d.ts.map +0 -1
- package/dist/routes/dash/pages.d.ts +0 -15
- package/dist/routes/dash/pages.d.ts.map +0 -1
- package/dist/routes/dash/posts.d.ts +0 -13
- package/dist/routes/dash/posts.d.ts.map +0 -1
- package/dist/routes/dash/redirects.d.ts +0 -13
- package/dist/routes/dash/redirects.d.ts.map +0 -1
- package/dist/routes/dash/settings.d.ts +0 -15
- package/dist/routes/dash/settings.d.ts.map +0 -1
- package/dist/routes/feed/rss.d.ts +0 -13
- package/dist/routes/feed/rss.d.ts.map +0 -1
- package/dist/routes/feed/sitemap.d.ts +0 -13
- package/dist/routes/feed/sitemap.d.ts.map +0 -1
- package/dist/routes/pages/archive.d.ts +0 -15
- package/dist/routes/pages/archive.d.ts.map +0 -1
- package/dist/routes/pages/collection.d.ts +0 -13
- package/dist/routes/pages/collection.d.ts.map +0 -1
- package/dist/routes/pages/home.d.ts +0 -13
- package/dist/routes/pages/home.d.ts.map +0 -1
- package/dist/routes/pages/page.d.ts +0 -15
- package/dist/routes/pages/page.d.ts.map +0 -1
- package/dist/routes/pages/post.d.ts +0 -13
- package/dist/routes/pages/post.d.ts.map +0 -1
- package/dist/routes/pages/search.d.ts +0 -13
- package/dist/routes/pages/search.d.ts.map +0 -1
- package/dist/services/collection.d.ts +0 -32
- package/dist/services/collection.d.ts.map +0 -1
- package/dist/services/index.d.ts +0 -28
- package/dist/services/index.d.ts.map +0 -1
- package/dist/services/media.d.ts +0 -34
- package/dist/services/media.d.ts.map +0 -1
- package/dist/services/post.d.ts +0 -31
- package/dist/services/post.d.ts.map +0 -1
- package/dist/services/redirect.d.ts +0 -15
- package/dist/services/redirect.d.ts.map +0 -1
- package/dist/services/search.d.ts +0 -26
- package/dist/services/search.d.ts.map +0 -1
- package/dist/services/settings.d.ts +0 -18
- package/dist/services/settings.d.ts.map +0 -1
- package/dist/theme/color-themes.d.ts +0 -30
- package/dist/theme/color-themes.d.ts.map +0 -1
- package/dist/theme/components/ActionButtons.d.ts +0 -43
- package/dist/theme/components/ActionButtons.d.ts.map +0 -1
- package/dist/theme/components/CrudPageHeader.d.ts +0 -23
- package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
- package/dist/theme/components/DangerZone.d.ts +0 -36
- package/dist/theme/components/DangerZone.d.ts.map +0 -1
- package/dist/theme/components/EmptyState.d.ts +0 -27
- package/dist/theme/components/EmptyState.d.ts.map +0 -1
- package/dist/theme/components/ListItemRow.d.ts +0 -15
- package/dist/theme/components/ListItemRow.d.ts.map +0 -1
- package/dist/theme/components/MediaGallery.d.ts +0 -13
- package/dist/theme/components/MediaGallery.d.ts.map +0 -1
- package/dist/theme/components/PageForm.d.ts +0 -14
- package/dist/theme/components/PageForm.d.ts.map +0 -1
- package/dist/theme/components/Pagination.d.ts +0 -46
- package/dist/theme/components/Pagination.d.ts.map +0 -1
- package/dist/theme/components/PostForm.d.ts +0 -16
- package/dist/theme/components/PostForm.d.ts.map +0 -1
- package/dist/theme/components/PostList.d.ts +0 -10
- package/dist/theme/components/PostList.d.ts.map +0 -1
- package/dist/theme/components/ThreadView.d.ts +0 -15
- package/dist/theme/components/ThreadView.d.ts.map +0 -1
- package/dist/theme/components/TypeBadge.d.ts +0 -12
- package/dist/theme/components/TypeBadge.d.ts.map +0 -1
- package/dist/theme/components/VisibilityBadge.d.ts +0 -12
- package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
- package/dist/theme/components/index.d.ts +0 -14
- package/dist/theme/components/index.d.ts.map +0 -1
- package/dist/theme/index.d.ts +0 -21
- package/dist/theme/index.d.ts.map +0 -1
- package/dist/theme/layouts/BaseLayout.d.ts +0 -23
- package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
- package/dist/theme/layouts/DashLayout.d.ts +0 -17
- package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
- package/dist/theme/layouts/index.d.ts +0 -3
- package/dist/theme/layouts/index.d.ts.map +0 -1
- package/dist/types.d.ts +0 -237
- package/dist/types.d.ts.map +0 -1
package/src/routes/api/posts.ts
CHANGED
|
@@ -12,7 +12,11 @@ import {
|
|
|
12
12
|
validateMediaForPostType,
|
|
13
13
|
} from "../../lib/schemas.js";
|
|
14
14
|
import { requireAuthApi } from "../../middleware/auth.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
getMediaUrl,
|
|
17
|
+
getImageUrl,
|
|
18
|
+
getPublicUrlForProvider,
|
|
19
|
+
} from "../../lib/image.js";
|
|
16
20
|
|
|
17
21
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
18
22
|
|
|
@@ -25,8 +29,14 @@ function toMediaAttachment(
|
|
|
25
29
|
m: Media,
|
|
26
30
|
r2PublicUrl?: string,
|
|
27
31
|
imageTransformUrl?: string,
|
|
32
|
+
s3PublicUrl?: string,
|
|
28
33
|
) {
|
|
29
|
-
const
|
|
34
|
+
const publicUrl = getPublicUrlForProvider(
|
|
35
|
+
m.provider,
|
|
36
|
+
r2PublicUrl,
|
|
37
|
+
s3PublicUrl,
|
|
38
|
+
);
|
|
39
|
+
const url = getMediaUrl(m.id, m.storageKey, publicUrl);
|
|
30
40
|
const previewUrl = getImageUrl(url, imageTransformUrl, {
|
|
31
41
|
width: 400,
|
|
32
42
|
quality: 80,
|
|
@@ -66,13 +76,14 @@ postsApiRoutes.get("/", async (c) => {
|
|
|
66
76
|
const mediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
67
77
|
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
68
78
|
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
79
|
+
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
69
80
|
|
|
70
81
|
return c.json({
|
|
71
82
|
posts: posts.map((p) => ({
|
|
72
83
|
...p,
|
|
73
84
|
sqid: sqid.encode(p.id),
|
|
74
85
|
mediaAttachments: (mediaMap.get(p.id) ?? []).map((m) =>
|
|
75
|
-
toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
|
|
86
|
+
toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
|
|
76
87
|
),
|
|
77
88
|
})),
|
|
78
89
|
|
|
@@ -94,12 +105,13 @@ postsApiRoutes.get("/:id", async (c) => {
|
|
|
94
105
|
const mediaList = await c.var.services.media.getByPostId(post.id);
|
|
95
106
|
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
96
107
|
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
108
|
+
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
97
109
|
|
|
98
110
|
return c.json({
|
|
99
111
|
...post,
|
|
100
112
|
sqid: sqid.encode(post.id),
|
|
101
113
|
mediaAttachments: mediaList.map((m) =>
|
|
102
|
-
toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
|
|
114
|
+
toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
|
|
103
115
|
),
|
|
104
116
|
});
|
|
105
117
|
});
|
|
@@ -157,13 +169,14 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
157
169
|
const mediaList = await c.var.services.media.getByPostId(post.id);
|
|
158
170
|
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
159
171
|
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
172
|
+
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
160
173
|
|
|
161
174
|
return c.json(
|
|
162
175
|
{
|
|
163
176
|
...post,
|
|
164
177
|
sqid: sqid.encode(post.id),
|
|
165
178
|
mediaAttachments: mediaList.map((m) =>
|
|
166
|
-
toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
|
|
179
|
+
toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
|
|
167
180
|
),
|
|
168
181
|
},
|
|
169
182
|
201,
|
|
@@ -233,12 +246,13 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
233
246
|
const mediaList = await c.var.services.media.getByPostId(post.id);
|
|
234
247
|
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
235
248
|
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
249
|
+
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
236
250
|
|
|
237
251
|
return c.json({
|
|
238
252
|
...post,
|
|
239
253
|
sqid: sqid.encode(post.id),
|
|
240
254
|
mediaAttachments: mediaList.map((m) =>
|
|
241
|
-
toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
|
|
255
|
+
toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
|
|
242
256
|
),
|
|
243
257
|
});
|
|
244
258
|
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline API Routes
|
|
3
|
+
*
|
|
4
|
+
* Provides load-more functionality for the timeline feed via SSE.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import type { Bindings, PostWithMedia, TimelineItemData } from "../../types.js";
|
|
9
|
+
import type { AppVariables } from "../../app.js";
|
|
10
|
+
import { sse } from "../../lib/sse.js";
|
|
11
|
+
import { buildMediaMap } from "../../lib/media-helpers.js";
|
|
12
|
+
import { TimelineItem } from "../../theme/components/timeline/TimelineItem.js";
|
|
13
|
+
import { ThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
|
|
14
|
+
|
|
15
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
16
|
+
|
|
17
|
+
const PAGE_SIZE = 20;
|
|
18
|
+
|
|
19
|
+
export const timelineApiRoutes = new Hono<Env>();
|
|
20
|
+
|
|
21
|
+
timelineApiRoutes.get("/", async (c) => {
|
|
22
|
+
const cursorParam = c.req.query("cursor");
|
|
23
|
+
const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
|
|
24
|
+
|
|
25
|
+
if (!cursor || isNaN(cursor)) {
|
|
26
|
+
return c.json({ error: "cursor parameter required" }, 400);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fetch one extra to determine if there are more
|
|
30
|
+
const posts = await c.var.services.posts.list({
|
|
31
|
+
visibility: ["featured", "quiet"],
|
|
32
|
+
excludeReplies: true,
|
|
33
|
+
excludeTypes: ["page"],
|
|
34
|
+
limit: PAGE_SIZE + 1,
|
|
35
|
+
cursor,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const hasMore = posts.length > PAGE_SIZE;
|
|
39
|
+
const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
|
|
40
|
+
|
|
41
|
+
if (displayPosts.length === 0) {
|
|
42
|
+
return sse(c, async (stream) => {
|
|
43
|
+
stream.remove("#load-more-container");
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build media map
|
|
48
|
+
const postIds = displayPosts.map((p) => p.id);
|
|
49
|
+
const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
50
|
+
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
51
|
+
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
52
|
+
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
53
|
+
const mediaMap = buildMediaMap(
|
|
54
|
+
rawMediaMap,
|
|
55
|
+
r2PublicUrl,
|
|
56
|
+
imageTransformUrl,
|
|
57
|
+
s3PublicUrl,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Get reply counts to identify thread roots
|
|
61
|
+
const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
|
|
62
|
+
const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
|
|
63
|
+
|
|
64
|
+
// Get thread previews
|
|
65
|
+
const threadPreviews = await c.var.services.posts.getThreadPreviews(
|
|
66
|
+
threadRootIds,
|
|
67
|
+
3,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Load media for preview replies
|
|
71
|
+
const previewReplyIds: number[] = [];
|
|
72
|
+
for (const replies of threadPreviews.values()) {
|
|
73
|
+
for (const reply of replies) {
|
|
74
|
+
previewReplyIds.push(reply.id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const previewMediaMap =
|
|
78
|
+
previewReplyIds.length > 0
|
|
79
|
+
? buildMediaMap(
|
|
80
|
+
await c.var.services.media.getByPostIds(previewReplyIds),
|
|
81
|
+
r2PublicUrl,
|
|
82
|
+
imageTransformUrl,
|
|
83
|
+
s3PublicUrl,
|
|
84
|
+
)
|
|
85
|
+
: new Map();
|
|
86
|
+
|
|
87
|
+
// Assemble timeline items
|
|
88
|
+
const items: TimelineItemData[] = displayPosts.map((post) => {
|
|
89
|
+
const postWithMedia: PostWithMedia = {
|
|
90
|
+
...post,
|
|
91
|
+
mediaAttachments: mediaMap.get(post.id) ?? [],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const replyCount = replyCounts.get(post.id) ?? 0;
|
|
95
|
+
const previewReplies = threadPreviews.get(post.id);
|
|
96
|
+
|
|
97
|
+
if (replyCount > 0 && previewReplies) {
|
|
98
|
+
return {
|
|
99
|
+
post: postWithMedia,
|
|
100
|
+
threadPreview: {
|
|
101
|
+
replies: previewReplies.map((r) => ({
|
|
102
|
+
...r,
|
|
103
|
+
mediaAttachments: previewMediaMap.get(r.id) ?? [],
|
|
104
|
+
})),
|
|
105
|
+
totalReplyCount: replyCount,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { post: postWithMedia };
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Render items to HTML
|
|
114
|
+
const itemsHtml = items
|
|
115
|
+
.map((item) => {
|
|
116
|
+
if (item.threadPreview) {
|
|
117
|
+
return (
|
|
118
|
+
<ThreadPreview
|
|
119
|
+
rootPost={item.post}
|
|
120
|
+
previewReplies={item.threadPreview.replies}
|
|
121
|
+
totalReplyCount={item.threadPreview.totalReplyCount}
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return <TimelineItem item={item} />;
|
|
126
|
+
})
|
|
127
|
+
.map((jsx) => jsx.toString())
|
|
128
|
+
.join("");
|
|
129
|
+
|
|
130
|
+
// Determine next cursor
|
|
131
|
+
const lastPost = displayPosts[displayPosts.length - 1];
|
|
132
|
+
const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
|
|
133
|
+
|
|
134
|
+
// Build load-more button HTML
|
|
135
|
+
const loadMoreHtml = nextCursor
|
|
136
|
+
? `<div id="load-more-container" class="mt-6 text-center"><button class="btn btn-outline" data-on:click="@get('/api/timeline?cursor=${nextCursor}')">Load more</button></div>`
|
|
137
|
+
: "";
|
|
138
|
+
|
|
139
|
+
return sse(c, async (stream) => {
|
|
140
|
+
// Append new items to the feed
|
|
141
|
+
stream.patchElements(itemsHtml, {
|
|
142
|
+
mode: "append",
|
|
143
|
+
selector: "#timeline-feed",
|
|
144
|
+
});
|
|
145
|
+
// Replace or remove the load-more container
|
|
146
|
+
if (loadMoreHtml) {
|
|
147
|
+
stream.patchElements(loadMoreHtml);
|
|
148
|
+
} else {
|
|
149
|
+
stream.remove("#load-more-container");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
package/src/routes/api/upload.ts
CHANGED
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
import { Hono } from "hono";
|
|
9
9
|
import { html } from "hono/html";
|
|
10
|
+
import { uuidv7 } from "uuidv7";
|
|
10
11
|
import type { Bindings } from "../../types.js";
|
|
11
12
|
import type { AppVariables } from "../../app.js";
|
|
12
13
|
import { requireAuthApi } from "../../middleware/auth.js";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
getMediaUrl,
|
|
16
|
+
getImageUrl,
|
|
17
|
+
getPublicUrlForProvider,
|
|
18
|
+
} from "../../lib/image.js";
|
|
14
19
|
import { sse, dsSignals } from "../../lib/sse.js";
|
|
15
20
|
|
|
16
21
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
@@ -26,16 +31,16 @@ uploadApiRoutes.use("*", requireAuthApi());
|
|
|
26
31
|
function renderMediaCard(
|
|
27
32
|
media: {
|
|
28
33
|
id: string;
|
|
29
|
-
|
|
34
|
+
storageKey: string;
|
|
30
35
|
mimeType: string;
|
|
31
36
|
originalName: string;
|
|
32
37
|
alt: string | null;
|
|
33
38
|
size: number;
|
|
34
39
|
},
|
|
35
|
-
|
|
40
|
+
publicUrl?: string,
|
|
36
41
|
imageTransformUrl?: string,
|
|
37
42
|
): string {
|
|
38
|
-
const fullUrl = getMediaUrl(media.id, media.
|
|
43
|
+
const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
|
|
39
44
|
const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
|
|
40
45
|
width: 300,
|
|
41
46
|
quality: 80,
|
|
@@ -115,11 +120,12 @@ function wantsSSE(c: {
|
|
|
115
120
|
|
|
116
121
|
// Upload a file
|
|
117
122
|
uploadApiRoutes.post("/", async (c) => {
|
|
118
|
-
|
|
123
|
+
const storage = c.var.storage;
|
|
124
|
+
if (!storage) {
|
|
119
125
|
if (wantsSSE(c)) {
|
|
120
|
-
return dsSignals({ _uploadError: "
|
|
126
|
+
return dsSignals({ _uploadError: "Storage not configured" });
|
|
121
127
|
}
|
|
122
|
-
return c.json({ error: "
|
|
128
|
+
return c.json({ error: "Storage not configured" }, 500);
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
const formData = await c.req.formData();
|
|
@@ -156,35 +162,43 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
156
162
|
return c.json({ error: "File too large (max 10MB)" }, 400);
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
// Generate unique filename
|
|
165
|
+
// Generate unique filename using UUIDv7
|
|
160
166
|
const ext = file.name.split(".").pop() || "bin";
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const
|
|
167
|
+
const id = uuidv7();
|
|
168
|
+
const date = new Date();
|
|
169
|
+
const year = date.getUTCFullYear();
|
|
170
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
171
|
+
const filename = `${id}.${ext}`;
|
|
172
|
+
const storageKey = `media/${year}/${month}/${filename}`;
|
|
165
173
|
|
|
166
174
|
try {
|
|
167
|
-
// Upload to
|
|
168
|
-
await
|
|
169
|
-
|
|
170
|
-
contentType: file.type,
|
|
171
|
-
},
|
|
175
|
+
// Upload to storage
|
|
176
|
+
await storage.put(storageKey, file.stream(), {
|
|
177
|
+
contentType: file.type,
|
|
172
178
|
});
|
|
173
179
|
|
|
174
180
|
// Save to database
|
|
175
181
|
const media = await c.var.services.media.create({
|
|
182
|
+
id,
|
|
176
183
|
filename,
|
|
177
184
|
originalName: file.name,
|
|
178
185
|
mimeType: file.type,
|
|
179
186
|
size: file.size,
|
|
180
|
-
|
|
187
|
+
storageKey,
|
|
188
|
+
provider: c.env.STORAGE_DRIVER || "r2",
|
|
181
189
|
});
|
|
182
190
|
|
|
183
191
|
// SSE response for Datastar
|
|
184
192
|
if (wantsSSE(c)) {
|
|
193
|
+
const provider = c.env.STORAGE_DRIVER || "r2";
|
|
194
|
+
const mediaPublicUrl = getPublicUrlForProvider(
|
|
195
|
+
provider,
|
|
196
|
+
c.env.R2_PUBLIC_URL,
|
|
197
|
+
c.env.S3_PUBLIC_URL,
|
|
198
|
+
);
|
|
185
199
|
const cardHtml = renderMediaCard(
|
|
186
200
|
media,
|
|
187
|
-
|
|
201
|
+
mediaPublicUrl,
|
|
188
202
|
c.env.IMAGE_TRANSFORM_URL,
|
|
189
203
|
);
|
|
190
204
|
|
|
@@ -199,7 +213,13 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
199
213
|
}
|
|
200
214
|
|
|
201
215
|
// JSON response for API clients
|
|
202
|
-
const
|
|
216
|
+
const provider = c.env.STORAGE_DRIVER || "r2";
|
|
217
|
+
const mediaPublicUrl = getPublicUrlForProvider(
|
|
218
|
+
provider,
|
|
219
|
+
c.env.R2_PUBLIC_URL,
|
|
220
|
+
c.env.S3_PUBLIC_URL,
|
|
221
|
+
);
|
|
222
|
+
const publicUrl = getMediaUrl(media.id, storageKey, mediaPublicUrl);
|
|
203
223
|
return c.json({
|
|
204
224
|
id: media.id,
|
|
205
225
|
filename: media.filename,
|
|
@@ -225,12 +245,18 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
225
245
|
uploadApiRoutes.get("/", async (c) => {
|
|
226
246
|
const limit = parseInt(c.req.query("limit") ?? "50", 10);
|
|
227
247
|
const mediaList = await c.var.services.media.list(limit);
|
|
248
|
+
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
249
|
+
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
228
250
|
|
|
229
251
|
return c.json({
|
|
230
252
|
media: mediaList.map((m) => ({
|
|
231
253
|
id: m.id,
|
|
232
254
|
filename: m.filename,
|
|
233
|
-
url: getMediaUrl(
|
|
255
|
+
url: getMediaUrl(
|
|
256
|
+
m.id,
|
|
257
|
+
m.storageKey,
|
|
258
|
+
getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl),
|
|
259
|
+
),
|
|
234
260
|
mimeType: m.mimeType,
|
|
235
261
|
size: m.size,
|
|
236
262
|
createdAt: m.createdAt,
|
|
@@ -246,13 +272,14 @@ uploadApiRoutes.delete("/:id", async (c) => {
|
|
|
246
272
|
return c.json({ error: "Not found" }, 404);
|
|
247
273
|
}
|
|
248
274
|
|
|
249
|
-
// Delete from
|
|
250
|
-
|
|
275
|
+
// Delete from storage
|
|
276
|
+
const storage = c.var.storage;
|
|
277
|
+
if (storage) {
|
|
251
278
|
try {
|
|
252
|
-
await
|
|
279
|
+
await storage.delete(media.storageKey);
|
|
253
280
|
} catch (err) {
|
|
254
281
|
// eslint-disable-next-line no-console -- Error logging is intentional
|
|
255
|
-
console.error("
|
|
282
|
+
console.error("Storage delete error:", err);
|
|
256
283
|
}
|
|
257
284
|
}
|
|
258
285
|
|
|
@@ -13,7 +13,11 @@ import type { AppVariables } from "../../app.js";
|
|
|
13
13
|
import { DashLayout } from "../../theme/layouts/index.js";
|
|
14
14
|
import { EmptyState, DangerZone } from "../../theme/components/index.js";
|
|
15
15
|
import * as time from "../../lib/time.js";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
getMediaUrl,
|
|
18
|
+
getImageUrl,
|
|
19
|
+
getPublicUrlForProvider,
|
|
20
|
+
} from "../../lib/image.js";
|
|
17
21
|
import { dsRedirect } from "../../lib/sse.js";
|
|
18
22
|
|
|
19
23
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
@@ -36,12 +40,19 @@ function MediaCard({
|
|
|
36
40
|
media,
|
|
37
41
|
r2PublicUrl,
|
|
38
42
|
imageTransformUrl,
|
|
43
|
+
s3PublicUrl,
|
|
39
44
|
}: {
|
|
40
45
|
media: Media;
|
|
41
46
|
r2PublicUrl?: string;
|
|
42
47
|
imageTransformUrl?: string;
|
|
48
|
+
s3PublicUrl?: string;
|
|
43
49
|
}) {
|
|
44
|
-
const
|
|
50
|
+
const publicUrl = getPublicUrlForProvider(
|
|
51
|
+
media.provider,
|
|
52
|
+
r2PublicUrl,
|
|
53
|
+
s3PublicUrl,
|
|
54
|
+
);
|
|
55
|
+
const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
|
|
45
56
|
const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
|
|
46
57
|
width: 300,
|
|
47
58
|
quality: 80,
|
|
@@ -96,10 +107,12 @@ function MediaListContent({
|
|
|
96
107
|
mediaList,
|
|
97
108
|
r2PublicUrl,
|
|
98
109
|
imageTransformUrl,
|
|
110
|
+
s3PublicUrl,
|
|
99
111
|
}: {
|
|
100
112
|
mediaList: Media[];
|
|
101
113
|
r2PublicUrl?: string;
|
|
102
114
|
imageTransformUrl?: string;
|
|
115
|
+
s3PublicUrl?: string;
|
|
103
116
|
}) {
|
|
104
117
|
const { t } = useLingui();
|
|
105
118
|
|
|
@@ -187,6 +200,7 @@ function MediaListContent({
|
|
|
187
200
|
media={m}
|
|
188
201
|
r2PublicUrl={r2PublicUrl}
|
|
189
202
|
imageTransformUrl={imageTransformUrl}
|
|
203
|
+
s3PublicUrl={s3PublicUrl}
|
|
190
204
|
/>
|
|
191
205
|
))}
|
|
192
206
|
</div>
|
|
@@ -217,13 +231,20 @@ function ViewMediaContent({
|
|
|
217
231
|
media,
|
|
218
232
|
r2PublicUrl,
|
|
219
233
|
imageTransformUrl,
|
|
234
|
+
s3PublicUrl,
|
|
220
235
|
}: {
|
|
221
236
|
media: Media;
|
|
222
237
|
r2PublicUrl?: string;
|
|
223
238
|
imageTransformUrl?: string;
|
|
239
|
+
s3PublicUrl?: string;
|
|
224
240
|
}) {
|
|
225
241
|
const { t } = useLingui();
|
|
226
|
-
const
|
|
242
|
+
const publicUrl = getPublicUrlForProvider(
|
|
243
|
+
media.provider,
|
|
244
|
+
r2PublicUrl,
|
|
245
|
+
s3PublicUrl,
|
|
246
|
+
);
|
|
247
|
+
const url = getMediaUrl(media.id, media.storageKey, publicUrl);
|
|
227
248
|
const thumbnailUrl = getImageUrl(url, imageTransformUrl, {
|
|
228
249
|
width: 600,
|
|
229
250
|
quality: 85,
|
|
@@ -401,6 +422,7 @@ mediaRoutes.get("/", async (c) => {
|
|
|
401
422
|
const siteName = await getSiteName(c);
|
|
402
423
|
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
403
424
|
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
425
|
+
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
404
426
|
|
|
405
427
|
return c.html(
|
|
406
428
|
<DashLayout
|
|
@@ -413,6 +435,7 @@ mediaRoutes.get("/", async (c) => {
|
|
|
413
435
|
mediaList={mediaList}
|
|
414
436
|
r2PublicUrl={r2PublicUrl}
|
|
415
437
|
imageTransformUrl={imageTransformUrl}
|
|
438
|
+
s3PublicUrl={s3PublicUrl}
|
|
416
439
|
/>
|
|
417
440
|
</DashLayout>,
|
|
418
441
|
);
|
|
@@ -424,6 +447,7 @@ mediaRoutes.get("/picker", async (c) => {
|
|
|
424
447
|
const mediaList = await c.var.services.media.list(100);
|
|
425
448
|
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
426
449
|
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
450
|
+
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
427
451
|
|
|
428
452
|
if (mediaList.length === 0) {
|
|
429
453
|
return c.html(
|
|
@@ -438,7 +462,12 @@ mediaRoutes.get("/picker", async (c) => {
|
|
|
438
462
|
{mediaList
|
|
439
463
|
.filter((m) => m.mimeType.startsWith("image/"))
|
|
440
464
|
.map((m) => {
|
|
441
|
-
const
|
|
465
|
+
const pUrl = getPublicUrlForProvider(
|
|
466
|
+
m.provider,
|
|
467
|
+
r2PublicUrl,
|
|
468
|
+
s3PublicUrl,
|
|
469
|
+
);
|
|
470
|
+
const url = getMediaUrl(m.id, m.storageKey, pUrl);
|
|
442
471
|
const thumbUrl = getImageUrl(url, imageTransformUrl, {
|
|
443
472
|
width: 150,
|
|
444
473
|
quality: 80,
|
|
@@ -477,6 +506,7 @@ mediaRoutes.get("/:id", async (c) => {
|
|
|
477
506
|
const siteName = await getSiteName(c);
|
|
478
507
|
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
479
508
|
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
509
|
+
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
480
510
|
|
|
481
511
|
return c.html(
|
|
482
512
|
<DashLayout
|
|
@@ -489,6 +519,7 @@ mediaRoutes.get("/:id", async (c) => {
|
|
|
489
519
|
media={media}
|
|
490
520
|
r2PublicUrl={r2PublicUrl}
|
|
491
521
|
imageTransformUrl={imageTransformUrl}
|
|
522
|
+
s3PublicUrl={s3PublicUrl}
|
|
492
523
|
/>
|
|
493
524
|
</DashLayout>,
|
|
494
525
|
);
|
|
@@ -500,13 +531,14 @@ mediaRoutes.post("/:id/delete", async (c) => {
|
|
|
500
531
|
const media = await c.var.services.media.getById(id);
|
|
501
532
|
if (!media) return c.notFound();
|
|
502
533
|
|
|
503
|
-
// Delete from
|
|
504
|
-
|
|
534
|
+
// Delete from storage
|
|
535
|
+
const storage = c.var.storage;
|
|
536
|
+
if (storage) {
|
|
505
537
|
try {
|
|
506
|
-
await
|
|
538
|
+
await storage.delete(media.storageKey);
|
|
507
539
|
} catch (err) {
|
|
508
540
|
// eslint-disable-next-line no-console -- Error logging is intentional
|
|
509
|
-
console.error("
|
|
541
|
+
console.error("Storage delete error:", err);
|
|
510
542
|
}
|
|
511
543
|
}
|
|
512
544
|
|