@jant/core 0.3.6 → 0.3.8
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 -21
- package/dist/client.js +1 -0
- package/dist/db/schema.js +15 -0
- 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/index.js +1 -1
- package/dist/lib/image.js +3 -3
- package/dist/lib/media-helpers.js +43 -0
- package/dist/lib/nav-reorder.js +27 -0
- package/dist/lib/navigation.js +35 -0
- package/dist/lib/schemas.js +32 -2
- package/dist/lib/sse.js +7 -8
- package/dist/lib/theme-components.js +49 -0
- package/dist/routes/api/posts.js +101 -5
- package/dist/routes/api/timeline.js +115 -0
- package/dist/routes/api/upload.js +9 -5
- package/dist/routes/dash/media.js +38 -0
- package/dist/routes/dash/navigation.js +274 -0
- package/dist/routes/dash/posts.js +45 -6
- package/dist/routes/feed/rss.js +10 -1
- package/dist/routes/pages/archive.js +14 -27
- package/dist/routes/pages/collection.js +10 -19
- package/dist/routes/pages/home.js +88 -98
- package/dist/routes/pages/page.js +19 -38
- package/dist/routes/pages/post.js +61 -48
- package/dist/routes/pages/search.js +13 -26
- package/dist/services/collection.js +13 -0
- package/dist/services/index.js +3 -1
- package/dist/services/media.js +55 -2
- package/dist/services/navigation.js +115 -0
- package/dist/services/post.js +26 -1
- package/dist/theme/components/MediaGallery.js +107 -0
- package/dist/theme/components/PostForm.js +158 -2
- package/dist/theme/components/PostList.js +5 -0
- package/dist/theme/components/index.js +3 -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 +27 -0
- package/package.json +3 -2
- package/src/__tests__/helpers/app.ts +6 -1
- package/src/__tests__/helpers/db.ts +20 -0
- package/src/app.tsx +11 -25
- package/src/client.ts +1 -0
- package/src/db/migrations/0002_add_media_attachments.sql +3 -0
- package/src/db/migrations/0003_add_navigation_links.sql +8 -0
- package/src/db/migrations/meta/0003_snapshot.json +821 -0
- package/src/db/migrations/meta/_journal.json +14 -0
- package/src/db/schema.ts +15 -0
- package/src/i18n/locales/en.po +170 -58
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +162 -71
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +162 -71
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +13 -1
- package/src/lib/__tests__/schemas.test.ts +89 -1
- package/src/lib/__tests__/sse.test.ts +13 -1
- package/src/lib/__tests__/theme-components.test.ts +107 -0
- package/src/lib/image.ts +3 -3
- package/src/lib/media-helpers.ts +54 -0
- package/src/lib/nav-reorder.ts +26 -0
- package/src/lib/navigation.ts +46 -0
- package/src/lib/schemas.ts +47 -1
- package/src/lib/sse.ts +10 -11
- package/src/lib/theme-components.ts +76 -0
- package/src/routes/api/__tests__/posts.test.ts +239 -0
- package/src/routes/api/__tests__/timeline.test.ts +242 -0
- package/src/routes/api/posts.ts +134 -5
- package/src/routes/api/timeline.tsx +145 -0
- package/src/routes/api/upload.ts +9 -5
- package/src/routes/dash/media.tsx +50 -0
- package/src/routes/dash/navigation.tsx +306 -0
- package/src/routes/dash/posts.tsx +79 -7
- package/src/routes/feed/rss.ts +14 -1
- package/src/routes/pages/archive.tsx +15 -23
- package/src/routes/pages/collection.tsx +8 -15
- package/src/routes/pages/home.tsx +121 -88
- package/src/routes/pages/page.tsx +17 -30
- package/src/routes/pages/post.tsx +64 -40
- package/src/routes/pages/search.tsx +18 -22
- package/src/services/__tests__/collection.test.ts +102 -0
- package/src/services/__tests__/media.test.ts +282 -7
- package/src/services/__tests__/navigation.test.ts +213 -0
- package/src/services/__tests__/post-timeline.test.ts +220 -0
- package/src/services/collection.ts +19 -0
- package/src/services/index.ts +7 -0
- package/src/services/media.ts +78 -2
- 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/MediaGallery.tsx +128 -0
- package/src/theme/components/PostForm.tsx +170 -2
- package/src/theme/components/PostList.tsx +7 -0
- package/src/theme/components/index.ts +13 -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 +97 -0
- 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 -1507
- 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 -113
- 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 -31
- 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 -27
- 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/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 -11
- 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 -13
- 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 -213
- package/dist/types.d.ts.map +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
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 mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
|
|
53
|
+
|
|
54
|
+
// Get reply counts to identify thread roots
|
|
55
|
+
const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
|
|
56
|
+
const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
|
|
57
|
+
|
|
58
|
+
// Get thread previews
|
|
59
|
+
const threadPreviews = await c.var.services.posts.getThreadPreviews(
|
|
60
|
+
threadRootIds,
|
|
61
|
+
3,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Load media for preview replies
|
|
65
|
+
const previewReplyIds: number[] = [];
|
|
66
|
+
for (const replies of threadPreviews.values()) {
|
|
67
|
+
for (const reply of replies) {
|
|
68
|
+
previewReplyIds.push(reply.id);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const previewMediaMap =
|
|
72
|
+
previewReplyIds.length > 0
|
|
73
|
+
? buildMediaMap(
|
|
74
|
+
await c.var.services.media.getByPostIds(previewReplyIds),
|
|
75
|
+
r2PublicUrl,
|
|
76
|
+
imageTransformUrl,
|
|
77
|
+
)
|
|
78
|
+
: new Map();
|
|
79
|
+
|
|
80
|
+
// Assemble timeline items
|
|
81
|
+
const items: TimelineItemData[] = displayPosts.map((post) => {
|
|
82
|
+
const postWithMedia: PostWithMedia = {
|
|
83
|
+
...post,
|
|
84
|
+
mediaAttachments: mediaMap.get(post.id) ?? [],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const replyCount = replyCounts.get(post.id) ?? 0;
|
|
88
|
+
const previewReplies = threadPreviews.get(post.id);
|
|
89
|
+
|
|
90
|
+
if (replyCount > 0 && previewReplies) {
|
|
91
|
+
return {
|
|
92
|
+
post: postWithMedia,
|
|
93
|
+
threadPreview: {
|
|
94
|
+
replies: previewReplies.map((r) => ({
|
|
95
|
+
...r,
|
|
96
|
+
mediaAttachments: previewMediaMap.get(r.id) ?? [],
|
|
97
|
+
})),
|
|
98
|
+
totalReplyCount: replyCount,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { post: postWithMedia };
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Render items to HTML
|
|
107
|
+
const itemsHtml = items
|
|
108
|
+
.map((item) => {
|
|
109
|
+
if (item.threadPreview) {
|
|
110
|
+
return (
|
|
111
|
+
<ThreadPreview
|
|
112
|
+
rootPost={item.post}
|
|
113
|
+
previewReplies={item.threadPreview.replies}
|
|
114
|
+
totalReplyCount={item.threadPreview.totalReplyCount}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return <TimelineItem item={item} />;
|
|
119
|
+
})
|
|
120
|
+
.map((jsx) => jsx.toString())
|
|
121
|
+
.join("");
|
|
122
|
+
|
|
123
|
+
// Determine next cursor
|
|
124
|
+
const lastPost = displayPosts[displayPosts.length - 1];
|
|
125
|
+
const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
|
|
126
|
+
|
|
127
|
+
// Build load-more button HTML
|
|
128
|
+
const loadMoreHtml = nextCursor
|
|
129
|
+
? `<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>`
|
|
130
|
+
: "";
|
|
131
|
+
|
|
132
|
+
return sse(c, async (stream) => {
|
|
133
|
+
// Append new items to the feed
|
|
134
|
+
stream.patchElements(itemsHtml, {
|
|
135
|
+
mode: "append",
|
|
136
|
+
selector: "#timeline-feed",
|
|
137
|
+
});
|
|
138
|
+
// Replace or remove the load-more container
|
|
139
|
+
if (loadMoreHtml) {
|
|
140
|
+
stream.patchElements(loadMoreHtml);
|
|
141
|
+
} else {
|
|
142
|
+
stream.remove("#load-more-container");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/routes/api/upload.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
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";
|
|
@@ -156,12 +157,14 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
156
157
|
return c.json({ error: "File too large (max 10MB)" }, 400);
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
// Generate unique filename
|
|
160
|
+
// Generate unique filename using UUIDv7
|
|
160
161
|
const ext = file.name.split(".").pop() || "bin";
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const
|
|
162
|
+
const id = uuidv7();
|
|
163
|
+
const date = new Date();
|
|
164
|
+
const year = date.getUTCFullYear();
|
|
165
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
166
|
+
const filename = `${id}.${ext}`;
|
|
167
|
+
const r2Key = `media/${year}/${month}/${filename}`;
|
|
165
168
|
|
|
166
169
|
try {
|
|
167
170
|
// Upload to R2
|
|
@@ -173,6 +176,7 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
173
176
|
|
|
174
177
|
// Save to database
|
|
175
178
|
const media = await c.var.services.media.create({
|
|
179
|
+
id,
|
|
176
180
|
filename,
|
|
177
181
|
originalName: file.name,
|
|
178
182
|
mimeType: file.type,
|
|
@@ -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");
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { getSiteName } from "../../lib/config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Dashboard Navigation Links Routes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
import { useLingui } from "@lingui/react/macro";
|
|
8
|
+
import type { Bindings, NavigationLink } from "../../types.js";
|
|
9
|
+
import type { AppVariables } from "../../app.js";
|
|
10
|
+
import { DashLayout } from "../../theme/layouts/index.js";
|
|
11
|
+
import {
|
|
12
|
+
EmptyState,
|
|
13
|
+
ListItemRow,
|
|
14
|
+
ActionButtons,
|
|
15
|
+
CrudPageHeader,
|
|
16
|
+
} from "../../theme/components/index.js";
|
|
17
|
+
import { dsRedirect, dsToast } from "../../lib/sse.js";
|
|
18
|
+
|
|
19
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
20
|
+
|
|
21
|
+
export const navigationRoutes = new Hono<Env>();
|
|
22
|
+
|
|
23
|
+
function NavigationListContent({ links }: { links: NavigationLink[] }) {
|
|
24
|
+
const { t } = useLingui();
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<CrudPageHeader
|
|
29
|
+
title={t({
|
|
30
|
+
message: "Navigation",
|
|
31
|
+
comment: "@context: Dashboard heading",
|
|
32
|
+
})}
|
|
33
|
+
ctaLabel={t({
|
|
34
|
+
message: "New Link",
|
|
35
|
+
comment: "@context: Button to create new navigation link",
|
|
36
|
+
})}
|
|
37
|
+
ctaHref="/dash/navigation/new"
|
|
38
|
+
/>
|
|
39
|
+
|
|
40
|
+
{links.length === 0 ? (
|
|
41
|
+
<EmptyState
|
|
42
|
+
message={t({
|
|
43
|
+
message: "No navigation links configured.",
|
|
44
|
+
comment: "@context: Empty state message",
|
|
45
|
+
})}
|
|
46
|
+
ctaText={t({
|
|
47
|
+
message: "New Link",
|
|
48
|
+
comment: "@context: Button to create new navigation link",
|
|
49
|
+
})}
|
|
50
|
+
ctaHref="/dash/navigation/new"
|
|
51
|
+
/>
|
|
52
|
+
) : (
|
|
53
|
+
<>
|
|
54
|
+
<div id="nav-links-list" class="flex flex-col divide-y">
|
|
55
|
+
{links.map((link) => (
|
|
56
|
+
<ListItemRow
|
|
57
|
+
key={link.id}
|
|
58
|
+
actions={
|
|
59
|
+
<ActionButtons
|
|
60
|
+
editHref={`/dash/navigation/${link.id}/edit`}
|
|
61
|
+
editLabel={t({
|
|
62
|
+
message: "Edit",
|
|
63
|
+
comment: "@context: Button to edit navigation link",
|
|
64
|
+
})}
|
|
65
|
+
deleteAction={`/dash/navigation/${link.id}/delete`}
|
|
66
|
+
deleteLabel={t({
|
|
67
|
+
message: "Delete",
|
|
68
|
+
comment: "@context: Button to delete navigation link",
|
|
69
|
+
})}
|
|
70
|
+
/>
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
<div
|
|
74
|
+
class="flex items-center gap-3 cursor-grab"
|
|
75
|
+
data-id={link.id}
|
|
76
|
+
>
|
|
77
|
+
<span class="text-muted-foreground select-none">⠿</span>
|
|
78
|
+
<div class="flex items-center gap-2">
|
|
79
|
+
<span class="font-medium">{link.label}</span>
|
|
80
|
+
<code class="text-sm text-muted-foreground bg-muted px-1 rounded">
|
|
81
|
+
{link.url}
|
|
82
|
+
</code>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</ListItemRow>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* SortableJS is initialized by client.ts via lib/nav-reorder.ts */}
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
</>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function NavigationFormContent({
|
|
97
|
+
link,
|
|
98
|
+
isEdit,
|
|
99
|
+
}: {
|
|
100
|
+
link?: NavigationLink;
|
|
101
|
+
isEdit?: boolean;
|
|
102
|
+
}) {
|
|
103
|
+
const { t } = useLingui();
|
|
104
|
+
const title = isEdit
|
|
105
|
+
? t({ message: "Edit Link", comment: "@context: Page heading" })
|
|
106
|
+
: t({ message: "New Link", comment: "@context: Page heading" });
|
|
107
|
+
|
|
108
|
+
const signals = JSON.stringify({
|
|
109
|
+
label: link?.label ?? "",
|
|
110
|
+
url: link?.url ?? "",
|
|
111
|
+
}).replace(/</g, "\\u003c");
|
|
112
|
+
|
|
113
|
+
const action = isEdit ? `/dash/navigation/${link?.id}` : "/dash/navigation";
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
<h1 class="text-2xl font-semibold mb-6">{title}</h1>
|
|
118
|
+
|
|
119
|
+
<form
|
|
120
|
+
data-signals={signals}
|
|
121
|
+
data-on:submit__prevent={`@post('${action}')`}
|
|
122
|
+
class="flex flex-col gap-4 max-w-lg"
|
|
123
|
+
>
|
|
124
|
+
<div class="field">
|
|
125
|
+
<label class="label">
|
|
126
|
+
{t({
|
|
127
|
+
message: "Label",
|
|
128
|
+
comment: "@context: Navigation link form field",
|
|
129
|
+
})}
|
|
130
|
+
</label>
|
|
131
|
+
<input
|
|
132
|
+
type="text"
|
|
133
|
+
data-bind="label"
|
|
134
|
+
class="input"
|
|
135
|
+
placeholder="Home"
|
|
136
|
+
required
|
|
137
|
+
/>
|
|
138
|
+
<p class="text-xs text-muted-foreground mt-1">
|
|
139
|
+
{t({
|
|
140
|
+
message: "Display text for the link",
|
|
141
|
+
comment: "@context: Navigation label help text",
|
|
142
|
+
})}
|
|
143
|
+
</p>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div class="field">
|
|
147
|
+
<label class="label">
|
|
148
|
+
{t({
|
|
149
|
+
message: "URL",
|
|
150
|
+
comment: "@context: Navigation link form field",
|
|
151
|
+
})}
|
|
152
|
+
</label>
|
|
153
|
+
<input
|
|
154
|
+
type="text"
|
|
155
|
+
data-bind="url"
|
|
156
|
+
class="input"
|
|
157
|
+
placeholder="/archive or https://..."
|
|
158
|
+
required
|
|
159
|
+
/>
|
|
160
|
+
<p class="text-xs text-muted-foreground mt-1">
|
|
161
|
+
{t({
|
|
162
|
+
message:
|
|
163
|
+
"Path (e.g. /archive) or full URL (e.g. https://example.com)",
|
|
164
|
+
comment: "@context: Navigation URL help text",
|
|
165
|
+
})}
|
|
166
|
+
</p>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div class="flex gap-2">
|
|
170
|
+
<button type="submit" class="btn">
|
|
171
|
+
{isEdit
|
|
172
|
+
? t({
|
|
173
|
+
message: "Save Changes",
|
|
174
|
+
comment: "@context: Button to save edited navigation link",
|
|
175
|
+
})
|
|
176
|
+
: t({
|
|
177
|
+
message: "Create Link",
|
|
178
|
+
comment: "@context: Button to save new navigation link",
|
|
179
|
+
})}
|
|
180
|
+
</button>
|
|
181
|
+
<a href="/dash/navigation" class="btn-outline">
|
|
182
|
+
{t({
|
|
183
|
+
message: "Cancel",
|
|
184
|
+
comment: "@context: Button to cancel form",
|
|
185
|
+
})}
|
|
186
|
+
</a>
|
|
187
|
+
</div>
|
|
188
|
+
</form>
|
|
189
|
+
</>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// List navigation links
|
|
194
|
+
navigationRoutes.get("/", async (c) => {
|
|
195
|
+
const siteName = await getSiteName(c);
|
|
196
|
+
const links = await c.var.services.navigationLinks.list();
|
|
197
|
+
|
|
198
|
+
return c.html(
|
|
199
|
+
<DashLayout
|
|
200
|
+
c={c}
|
|
201
|
+
title="Navigation"
|
|
202
|
+
siteName={siteName}
|
|
203
|
+
currentPath="/dash/navigation"
|
|
204
|
+
>
|
|
205
|
+
<NavigationListContent links={links} />
|
|
206
|
+
</DashLayout>,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// New link form
|
|
211
|
+
navigationRoutes.get("/new", async (c) => {
|
|
212
|
+
const siteName = await getSiteName(c);
|
|
213
|
+
|
|
214
|
+
return c.html(
|
|
215
|
+
<DashLayout
|
|
216
|
+
c={c}
|
|
217
|
+
title="New Link"
|
|
218
|
+
siteName={siteName}
|
|
219
|
+
currentPath="/dash/navigation"
|
|
220
|
+
>
|
|
221
|
+
<NavigationFormContent />
|
|
222
|
+
</DashLayout>,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Create link
|
|
227
|
+
navigationRoutes.post("/", async (c) => {
|
|
228
|
+
const body = await c.req.json<{ label: string; url: string }>();
|
|
229
|
+
|
|
230
|
+
if (!body.label || !body.url) {
|
|
231
|
+
return dsToast("Label and URL are required", "error");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await c.var.services.navigationLinks.create({
|
|
235
|
+
label: body.label,
|
|
236
|
+
url: body.url,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return dsRedirect("/dash/navigation");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Reorder links (must be before /:id to avoid "reorder" matching as :id)
|
|
243
|
+
navigationRoutes.post("/reorder", async (c) => {
|
|
244
|
+
const body = await c.req.json<{ ids: number[] }>();
|
|
245
|
+
|
|
246
|
+
if (!Array.isArray(body.ids)) {
|
|
247
|
+
return dsToast("Invalid request", "error");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
await c.var.services.navigationLinks.reorder(body.ids);
|
|
251
|
+
|
|
252
|
+
return dsToast("Order saved");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Edit link form
|
|
256
|
+
navigationRoutes.get("/:id/edit", async (c) => {
|
|
257
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
258
|
+
if (isNaN(id)) return c.notFound();
|
|
259
|
+
|
|
260
|
+
const link = await c.var.services.navigationLinks.getById(id);
|
|
261
|
+
if (!link) return c.notFound();
|
|
262
|
+
|
|
263
|
+
const siteName = await getSiteName(c);
|
|
264
|
+
|
|
265
|
+
return c.html(
|
|
266
|
+
<DashLayout
|
|
267
|
+
c={c}
|
|
268
|
+
title="Edit Link"
|
|
269
|
+
siteName={siteName}
|
|
270
|
+
currentPath="/dash/navigation"
|
|
271
|
+
>
|
|
272
|
+
<NavigationFormContent link={link} isEdit />
|
|
273
|
+
</DashLayout>,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Update link
|
|
278
|
+
navigationRoutes.post("/:id", async (c) => {
|
|
279
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
280
|
+
if (isNaN(id)) return c.notFound();
|
|
281
|
+
|
|
282
|
+
const body = await c.req.json<{ label: string; url: string }>();
|
|
283
|
+
|
|
284
|
+
if (!body.label || !body.url) {
|
|
285
|
+
return dsToast("Label and URL are required", "error");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const updated = await c.var.services.navigationLinks.update(id, {
|
|
289
|
+
label: body.label,
|
|
290
|
+
url: body.url,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (!updated) return c.notFound();
|
|
294
|
+
|
|
295
|
+
return dsRedirect("/dash/navigation");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Delete link
|
|
299
|
+
navigationRoutes.post("/:id/delete", async (c) => {
|
|
300
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
301
|
+
if (!isNaN(id)) {
|
|
302
|
+
await c.var.services.navigationLinks.delete(id);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return dsRedirect("/dash/navigation");
|
|
306
|
+
});
|