@jant/core 0.3.24 → 0.3.25
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 +50 -25
- package/dist/db/schema.js +1 -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/index.js +3 -9
- package/dist/lib/constants.js +1 -0
- package/dist/lib/nav-reorder.js +1 -1
- package/dist/lib/navigation.js +26 -1
- package/dist/lib/pagination.js +44 -0
- package/dist/lib/render.js +7 -11
- package/dist/lib/schemas.js +3 -3
- package/dist/lib/theme.js +4 -4
- package/dist/lib/timeline.js +24 -48
- package/dist/lib/view.js +2 -2
- package/dist/routes/api/collections.js +124 -0
- package/dist/routes/api/nav-items.js +104 -0
- package/dist/routes/api/pages.js +91 -0
- package/dist/routes/api/posts.js +2 -2
- package/dist/routes/api/search.js +2 -2
- package/dist/routes/api/settings.js +68 -0
- package/dist/routes/compose.js +48 -0
- package/dist/routes/dash/collections.js +2 -2
- package/dist/routes/dash/index.js +1 -1
- package/dist/routes/dash/media.js +2 -2
- package/dist/routes/dash/pages.js +411 -62
- package/dist/routes/dash/posts.js +3 -5
- package/dist/routes/dash/redirects.js +2 -2
- package/dist/routes/dash/settings.js +79 -5
- package/dist/routes/feed/rss.js +2 -2
- package/dist/routes/feed/sitemap.js +1 -1
- package/dist/routes/pages/archive.js +3 -6
- package/dist/routes/pages/collection.js +3 -6
- package/dist/routes/pages/collections.js +28 -0
- package/dist/routes/pages/featured.js +32 -0
- package/dist/routes/pages/home.js +9 -50
- package/dist/routes/pages/page.js +29 -32
- package/dist/routes/pages/post.js +3 -6
- package/dist/routes/pages/search.js +3 -6
- package/dist/services/page.js +5 -1
- package/dist/services/post.js +40 -6
- package/dist/services/search.js +1 -1
- package/dist/ui/compose/ComposeDialog.js +452 -0
- package/dist/ui/compose/ComposePrompt.js +55 -0
- package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
- package/dist/{theme/components → ui/dash}/PostForm.js +0 -27
- package/dist/{theme/components → ui/dash}/PostList.js +6 -6
- package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
- package/dist/{theme/components → ui/dash}/index.js +3 -6
- package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
- package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
- package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
- package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
- package/dist/ui/feed/TimelineFeed.js +41 -0
- package/dist/ui/feed/TimelineItem.js +27 -0
- package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
- package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
- package/dist/ui/layouts/SiteLayout.js +141 -0
- package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
- package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
- package/dist/ui/pages/CollectionsPage.js +76 -0
- package/dist/ui/pages/FeaturedPage.js +24 -0
- package/dist/ui/pages/HomePage.js +24 -0
- package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
- package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
- package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
- package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
- package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
- package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
- package/dist/ui/shared/index.js +5 -0
- package/package.json +1 -9
- package/src/__tests__/helpers/db.ts +3 -0
- package/src/app.tsx +57 -27
- package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/schema.ts +1 -1
- package/src/i18n/locales/en.po +332 -181
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +332 -181
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +332 -181
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +7 -36
- package/src/lib/__tests__/schemas.test.ts +60 -19
- package/src/lib/__tests__/timeline.test.ts +45 -81
- package/src/lib/__tests__/view.test.ts +13 -7
- package/src/lib/constants.ts +1 -0
- package/src/lib/nav-reorder.ts +1 -1
- package/src/lib/navigation.ts +40 -2
- package/src/lib/pagination.ts +50 -0
- package/src/lib/render.tsx +7 -14
- package/src/lib/schemas.ts +8 -6
- package/src/lib/theme.ts +5 -5
- package/src/lib/timeline.ts +28 -57
- package/src/lib/view.ts +2 -2
- package/src/preset.css +2 -1
- package/src/routes/__tests__/compose.test.ts +199 -0
- package/src/routes/api/__tests__/collections.test.ts +249 -0
- package/src/routes/api/__tests__/nav-items.test.ts +222 -0
- package/src/routes/api/__tests__/pages.test.ts +218 -0
- package/src/routes/api/__tests__/settings.test.ts +132 -0
- package/src/routes/api/collections.ts +143 -0
- package/src/routes/api/nav-items.ts +115 -0
- package/src/routes/api/pages.ts +101 -0
- package/src/routes/api/posts.ts +2 -2
- package/src/routes/api/search.ts +2 -2
- package/src/routes/api/settings.ts +91 -0
- package/src/routes/compose.ts +63 -0
- package/src/routes/dash/__tests__/pages.test.ts +225 -0
- package/src/routes/dash/collections.tsx +2 -2
- package/src/routes/dash/index.tsx +1 -1
- package/src/routes/dash/media.tsx +2 -2
- package/src/routes/dash/pages.tsx +443 -70
- package/src/routes/dash/posts.tsx +3 -7
- package/src/routes/dash/redirects.tsx +2 -2
- package/src/routes/dash/settings.tsx +83 -5
- package/src/routes/feed/rss.ts +2 -2
- package/src/routes/feed/sitemap.ts +1 -1
- package/src/routes/pages/__tests__/collections.test.ts +94 -0
- package/src/routes/pages/__tests__/featured.test.ts +94 -0
- package/src/routes/pages/archive.tsx +2 -6
- package/src/routes/pages/collection.tsx +2 -6
- package/src/routes/pages/collections.tsx +36 -0
- package/src/routes/pages/featured.tsx +38 -0
- package/src/routes/pages/home.tsx +9 -55
- package/src/routes/pages/page.tsx +28 -30
- package/src/routes/pages/post.tsx +2 -5
- package/src/routes/pages/search.tsx +2 -6
- package/src/services/__tests__/page.test.ts +106 -0
- package/src/services/__tests__/post.test.ts +114 -15
- package/src/services/page.ts +13 -1
- package/src/services/post.ts +57 -7
- package/src/services/search.ts +2 -2
- package/src/styles/tokens.css +47 -0
- package/src/styles/ui.css +491 -0
- package/src/types.ts +29 -159
- package/src/ui/compose/ComposeDialog.tsx +395 -0
- package/src/ui/compose/ComposePrompt.tsx +55 -0
- package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
- package/src/{theme/components → ui/dash}/PostForm.tsx +0 -25
- package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
- package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
- package/src/ui/dash/index.ts +10 -0
- package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
- package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
- package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
- package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
- package/src/ui/feed/TimelineFeed.tsx +49 -0
- package/src/ui/feed/TimelineItem.tsx +45 -0
- package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
- package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
- package/src/ui/layouts/SiteLayout.tsx +150 -0
- package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
- package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
- package/src/ui/pages/CollectionsPage.tsx +73 -0
- package/src/ui/pages/FeaturedPage.tsx +31 -0
- package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
- package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
- package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
- package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
- package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
- package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
- package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
- package/src/ui/shared/__tests__/pagination.test.ts +46 -0
- package/src/ui/shared/index.ts +12 -0
- package/bin/jant.js +0 -185
- package/dist/lib/theme-components.js +0 -46
- package/dist/routes/dash/navigation.js +0 -289
- package/dist/theme/index.js +0 -18
- package/dist/theme/layouts/index.js +0 -2
- package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
- package/dist/themes/threads/index.js +0 -81
- package/dist/themes/threads/pages/HomePage.js +0 -25
- package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
- package/dist/themes/threads/timeline/TimelineItem.js +0 -36
- package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
- package/dist/themes/threads/timeline/groupByDate.js +0 -22
- package/dist/themes/threads/timeline/timelineMore.js +0 -107
- package/src/lib/__tests__/theme-components.test.ts +0 -105
- package/src/lib/theme-components.ts +0 -65
- package/src/routes/dash/navigation.tsx +0 -317
- package/src/theme/components/index.ts +0 -23
- package/src/theme/index.ts +0 -22
- package/src/theme/layouts/index.ts +0 -7
- package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
- package/src/themes/threads/index.ts +0 -100
- package/src/themes/threads/style.css +0 -336
- package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
- package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
- package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
- package/src/themes/threads/timeline/groupByDate.ts +0 -30
- package/src/themes/threads/timeline/timelineMore.tsx +0 -130
- /package/dist/{theme → ui}/color-themes.js +0 -0
- /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
- /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
- /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
- /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
- /package/dist/{theme/components → ui/dash}/PageForm.js +0 -0
- /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
- /package/src/{theme → ui}/color-themes.ts +0 -0
- /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
- /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
- /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
- /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
- /package/src/{theme/components → ui/dash}/PageForm.tsx +0 -0
- /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
package/dist/lib/timeline.js
CHANGED
|
@@ -2,44 +2,51 @@
|
|
|
2
2
|
* Timeline Data Assembly
|
|
3
3
|
*
|
|
4
4
|
* Shared helper for assembling timeline items with media and thread previews.
|
|
5
|
-
* Used by
|
|
5
|
+
* Used by page rendering with page-based pagination.
|
|
6
6
|
*/ import { buildMediaMap } from "./media-helpers.js";
|
|
7
7
|
import { createMediaContext, toPostView, toPostViews } from "./view.js";
|
|
8
8
|
const DEFAULT_PAGE_SIZE = 20;
|
|
9
9
|
/**
|
|
10
10
|
* Assembles a page of timeline items with media attachments and thread previews.
|
|
11
11
|
*
|
|
12
|
-
* Fetches posts, batch-loads media, identifies
|
|
13
|
-
* render-ready `TimelineItemView[]` with
|
|
12
|
+
* Fetches posts using offset-based pagination, batch-loads media, identifies
|
|
13
|
+
* threads, and returns render-ready `TimelineItemView[]` with page info.
|
|
14
14
|
*
|
|
15
15
|
* @param c - Hono context (provides services + env)
|
|
16
|
-
* @param options - Optional
|
|
16
|
+
* @param options - Optional page number (1-indexed, defaults to 1)
|
|
17
17
|
* @returns Assembled timeline items with pagination info
|
|
18
18
|
*
|
|
19
19
|
* @example
|
|
20
20
|
* ```ts
|
|
21
|
-
* const { items,
|
|
22
|
-
* const { items,
|
|
21
|
+
* const { items, currentPage, totalPages } = await assembleTimeline(c);
|
|
22
|
+
* const { items, currentPage, totalPages } = await assembleTimeline(c, { page: 2 });
|
|
23
23
|
* ```
|
|
24
24
|
*/ export async function assembleTimeline(c, options) {
|
|
25
25
|
const pageSize = parseInt(c.env.PAGE_SIZE ?? String(DEFAULT_PAGE_SIZE), 10) || DEFAULT_PAGE_SIZE;
|
|
26
|
-
|
|
26
|
+
const page = Math.max(1, options?.page ?? 1);
|
|
27
|
+
const offset = (page - 1) * pageSize;
|
|
28
|
+
// Get total count for pagination
|
|
29
|
+
const totalCount = await c.var.services.posts.count({
|
|
30
|
+
status: "published",
|
|
31
|
+
excludeReplies: true
|
|
32
|
+
});
|
|
33
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
34
|
+
// Fetch posts for the current page
|
|
27
35
|
const posts = await c.var.services.posts.list({
|
|
28
36
|
status: "published",
|
|
29
37
|
excludeReplies: true,
|
|
30
|
-
limit: pageSize
|
|
31
|
-
|
|
38
|
+
limit: pageSize,
|
|
39
|
+
offset
|
|
32
40
|
});
|
|
33
|
-
|
|
34
|
-
const displayPosts = hasMore ? posts.slice(0, pageSize) : posts;
|
|
35
|
-
if (displayPosts.length === 0) {
|
|
41
|
+
if (posts.length === 0) {
|
|
36
42
|
return {
|
|
37
43
|
items: [],
|
|
38
|
-
|
|
44
|
+
currentPage: page,
|
|
45
|
+
totalPages
|
|
39
46
|
};
|
|
40
47
|
}
|
|
41
48
|
// Batch load media attachments
|
|
42
|
-
const postIds =
|
|
49
|
+
const postIds = posts.map((p)=>p.id);
|
|
43
50
|
const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
44
51
|
const mediaCtx = createMediaContext(c);
|
|
45
52
|
const mediaMap = buildMediaMap(rawMediaMap, mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl);
|
|
@@ -57,7 +64,7 @@ const DEFAULT_PAGE_SIZE = 20;
|
|
|
57
64
|
}
|
|
58
65
|
const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl) : new Map();
|
|
59
66
|
// Assemble timeline items with View Models
|
|
60
|
-
const items =
|
|
67
|
+
const items = posts.map((post)=>{
|
|
61
68
|
const postView = toPostView({
|
|
62
69
|
...post,
|
|
63
70
|
mediaAttachments: mediaMap.get(post.id) ?? []
|
|
@@ -80,40 +87,9 @@ const DEFAULT_PAGE_SIZE = 20;
|
|
|
80
87
|
post: postView
|
|
81
88
|
};
|
|
82
89
|
});
|
|
83
|
-
// Determine next cursor
|
|
84
|
-
const lastPost = displayPosts[displayPosts.length - 1];
|
|
85
|
-
const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
|
|
86
90
|
return {
|
|
87
91
|
items,
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
currentPage: page,
|
|
93
|
+
totalPages
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
|
-
/**
|
|
93
|
-
* Groups timeline items by their publication date (YYYY-MM-DD).
|
|
94
|
-
*
|
|
95
|
-
* @param items - Timeline items to group
|
|
96
|
-
* @returns Array of date groups, each containing items published on the same day
|
|
97
|
-
*
|
|
98
|
-
* @example
|
|
99
|
-
* ```ts
|
|
100
|
-
* const groups = groupByDate(items);
|
|
101
|
-
* // [{ dateKey: "2024-02-01", label: "Feb 1, 2024", items: [...] }, ...]
|
|
102
|
-
* ```
|
|
103
|
-
*/ export function groupByDate(items) {
|
|
104
|
-
const groups = [];
|
|
105
|
-
let current = null;
|
|
106
|
-
for (const item of items){
|
|
107
|
-
const dateKey = item.post.publishedAt.slice(0, 10);
|
|
108
|
-
if (!current || current.dateKey !== dateKey) {
|
|
109
|
-
current = {
|
|
110
|
-
dateKey,
|
|
111
|
-
label: item.post.publishedAtFormatted,
|
|
112
|
-
items: []
|
|
113
|
-
};
|
|
114
|
-
groups.push(current);
|
|
115
|
-
}
|
|
116
|
-
current.items.push(item);
|
|
117
|
-
}
|
|
118
|
-
return groups;
|
|
119
|
-
}
|
package/dist/lib/view.js
CHANGED
|
@@ -58,7 +58,7 @@ import { getHtmlExcerpt } from "./excerpt.js";
|
|
|
58
58
|
* @param _ctx - Media context with URL configuration
|
|
59
59
|
* @returns Render-ready PostView with pre-computed fields
|
|
60
60
|
*/ export function toPostView(post, _ctx) {
|
|
61
|
-
const permalink = post.
|
|
61
|
+
const permalink = post.path ? `/${post.path}` : `/p/${encode(post.id)}`;
|
|
62
62
|
// Pre-compute excerpt from raw body
|
|
63
63
|
let excerpt;
|
|
64
64
|
if (post.body) {
|
|
@@ -85,7 +85,7 @@ import { getHtmlExcerpt } from "./excerpt.js";
|
|
|
85
85
|
return {
|
|
86
86
|
id: post.id,
|
|
87
87
|
permalink,
|
|
88
|
-
|
|
88
|
+
path: post.path ?? undefined,
|
|
89
89
|
title: post.title ?? undefined,
|
|
90
90
|
bodyHtml: post.bodyHtml ?? undefined,
|
|
91
91
|
excerpt,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collections API Routes
|
|
3
|
+
*/ import { Hono } from "hono";
|
|
4
|
+
import { requireAuthApi } from "../../middleware/auth.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SORT_ORDERS } from "../../types.js";
|
|
7
|
+
export const collectionsApiRoutes = new Hono();
|
|
8
|
+
const SortOrderSchema = z.enum(SORT_ORDERS);
|
|
9
|
+
const CreateCollectionSchema = z.object({
|
|
10
|
+
slug: z.string().min(1),
|
|
11
|
+
title: z.string().min(1),
|
|
12
|
+
description: z.string().optional(),
|
|
13
|
+
icon: z.string().optional(),
|
|
14
|
+
sortOrder: SortOrderSchema.optional(),
|
|
15
|
+
position: z.number().int().min(0).optional(),
|
|
16
|
+
showDivider: z.boolean().optional()
|
|
17
|
+
});
|
|
18
|
+
const UpdateCollectionSchema = z.object({
|
|
19
|
+
slug: z.string().min(1).optional(),
|
|
20
|
+
title: z.string().min(1).optional(),
|
|
21
|
+
description: z.string().nullable().optional(),
|
|
22
|
+
icon: z.string().nullable().optional(),
|
|
23
|
+
sortOrder: SortOrderSchema.optional(),
|
|
24
|
+
position: z.number().int().min(0).optional(),
|
|
25
|
+
showDivider: z.boolean().optional()
|
|
26
|
+
});
|
|
27
|
+
const ReorderSchema = z.object({
|
|
28
|
+
ids: z.array(z.number().int().positive())
|
|
29
|
+
});
|
|
30
|
+
// List collections (includes post counts)
|
|
31
|
+
collectionsApiRoutes.get("/", async (c)=>{
|
|
32
|
+
const collections = await c.var.services.collections.list();
|
|
33
|
+
const postCounts = await c.var.services.collections.getPostCounts();
|
|
34
|
+
return c.json({
|
|
35
|
+
collections: collections.map((col)=>({
|
|
36
|
+
...col,
|
|
37
|
+
postCount: postCounts.get(col.id) ?? 0
|
|
38
|
+
}))
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
// Get single collection
|
|
42
|
+
collectionsApiRoutes.get("/:id", async (c)=>{
|
|
43
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
44
|
+
if (isNaN(id)) return c.json({
|
|
45
|
+
error: "Invalid ID"
|
|
46
|
+
}, 400);
|
|
47
|
+
const collection = await c.var.services.collections.getById(id);
|
|
48
|
+
if (!collection) return c.json({
|
|
49
|
+
error: "Not found"
|
|
50
|
+
}, 404);
|
|
51
|
+
return c.json(collection);
|
|
52
|
+
});
|
|
53
|
+
// Reorder collections (requires auth) — must be before /:id
|
|
54
|
+
collectionsApiRoutes.put("/reorder", requireAuthApi(), async (c)=>{
|
|
55
|
+
const rawBody = await c.req.json();
|
|
56
|
+
const parseResult = ReorderSchema.safeParse(rawBody);
|
|
57
|
+
if (!parseResult.success) {
|
|
58
|
+
return c.json({
|
|
59
|
+
error: "Validation failed",
|
|
60
|
+
details: parseResult.error.flatten()
|
|
61
|
+
}, 400);
|
|
62
|
+
}
|
|
63
|
+
await c.var.services.collections.reorder(parseResult.data.ids);
|
|
64
|
+
const collections = await c.var.services.collections.list();
|
|
65
|
+
return c.json({
|
|
66
|
+
collections
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
// Create collection (requires auth)
|
|
70
|
+
collectionsApiRoutes.post("/", requireAuthApi(), async (c)=>{
|
|
71
|
+
const rawBody = await c.req.json();
|
|
72
|
+
const parseResult = CreateCollectionSchema.safeParse(rawBody);
|
|
73
|
+
if (!parseResult.success) {
|
|
74
|
+
return c.json({
|
|
75
|
+
error: "Validation failed",
|
|
76
|
+
details: parseResult.error.flatten()
|
|
77
|
+
}, 400);
|
|
78
|
+
}
|
|
79
|
+
const body = parseResult.data;
|
|
80
|
+
const collection = await c.var.services.collections.create({
|
|
81
|
+
slug: body.slug,
|
|
82
|
+
title: body.title,
|
|
83
|
+
description: body.description,
|
|
84
|
+
icon: body.icon,
|
|
85
|
+
sortOrder: body.sortOrder,
|
|
86
|
+
position: body.position,
|
|
87
|
+
showDivider: body.showDivider
|
|
88
|
+
});
|
|
89
|
+
return c.json(collection, 201);
|
|
90
|
+
});
|
|
91
|
+
// Update collection (requires auth)
|
|
92
|
+
collectionsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
|
|
93
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
94
|
+
if (isNaN(id)) return c.json({
|
|
95
|
+
error: "Invalid ID"
|
|
96
|
+
}, 400);
|
|
97
|
+
const rawBody = await c.req.json();
|
|
98
|
+
const parseResult = UpdateCollectionSchema.safeParse(rawBody);
|
|
99
|
+
if (!parseResult.success) {
|
|
100
|
+
return c.json({
|
|
101
|
+
error: "Validation failed",
|
|
102
|
+
details: parseResult.error.flatten()
|
|
103
|
+
}, 400);
|
|
104
|
+
}
|
|
105
|
+
const collection = await c.var.services.collections.update(id, parseResult.data);
|
|
106
|
+
if (!collection) return c.json({
|
|
107
|
+
error: "Not found"
|
|
108
|
+
}, 404);
|
|
109
|
+
return c.json(collection);
|
|
110
|
+
});
|
|
111
|
+
// Delete collection (requires auth)
|
|
112
|
+
collectionsApiRoutes.delete("/:id", requireAuthApi(), async (c)=>{
|
|
113
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
114
|
+
if (isNaN(id)) return c.json({
|
|
115
|
+
error: "Invalid ID"
|
|
116
|
+
}, 400);
|
|
117
|
+
const success = await c.var.services.collections.delete(id);
|
|
118
|
+
if (!success) return c.json({
|
|
119
|
+
error: "Not found"
|
|
120
|
+
}, 404);
|
|
121
|
+
return c.json({
|
|
122
|
+
success: true
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nav Items API Routes
|
|
3
|
+
*/ import { Hono } from "hono";
|
|
4
|
+
import { requireAuthApi } from "../../middleware/auth.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
export const navItemsApiRoutes = new Hono();
|
|
7
|
+
const NavItemTypeSchema = z.enum([
|
|
8
|
+
"link",
|
|
9
|
+
"page"
|
|
10
|
+
]);
|
|
11
|
+
const CreateNavItemSchema = z.object({
|
|
12
|
+
type: NavItemTypeSchema,
|
|
13
|
+
label: z.string().min(1),
|
|
14
|
+
url: z.string().min(1),
|
|
15
|
+
pageId: z.number().int().positive().optional(),
|
|
16
|
+
position: z.number().int().min(0).optional()
|
|
17
|
+
});
|
|
18
|
+
const UpdateNavItemSchema = z.object({
|
|
19
|
+
type: NavItemTypeSchema.optional(),
|
|
20
|
+
label: z.string().min(1).optional(),
|
|
21
|
+
url: z.string().min(1).optional(),
|
|
22
|
+
pageId: z.number().int().positive().nullable().optional(),
|
|
23
|
+
position: z.number().int().min(0).optional()
|
|
24
|
+
});
|
|
25
|
+
const ReorderSchema = z.object({
|
|
26
|
+
ids: z.array(z.number().int().positive())
|
|
27
|
+
});
|
|
28
|
+
// List nav items
|
|
29
|
+
navItemsApiRoutes.get("/", async (c)=>{
|
|
30
|
+
const items = await c.var.services.navItems.list();
|
|
31
|
+
return c.json({
|
|
32
|
+
navItems: items
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
// Reorder nav items (requires auth) — must be before /:id
|
|
36
|
+
navItemsApiRoutes.put("/reorder", requireAuthApi(), async (c)=>{
|
|
37
|
+
const rawBody = await c.req.json();
|
|
38
|
+
const parseResult = ReorderSchema.safeParse(rawBody);
|
|
39
|
+
if (!parseResult.success) {
|
|
40
|
+
return c.json({
|
|
41
|
+
error: "Validation failed",
|
|
42
|
+
details: parseResult.error.flatten()
|
|
43
|
+
}, 400);
|
|
44
|
+
}
|
|
45
|
+
await c.var.services.navItems.reorder(parseResult.data.ids);
|
|
46
|
+
const items = await c.var.services.navItems.list();
|
|
47
|
+
return c.json({
|
|
48
|
+
navItems: items
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
// Create nav item (requires auth)
|
|
52
|
+
navItemsApiRoutes.post("/", requireAuthApi(), async (c)=>{
|
|
53
|
+
const rawBody = await c.req.json();
|
|
54
|
+
const parseResult = CreateNavItemSchema.safeParse(rawBody);
|
|
55
|
+
if (!parseResult.success) {
|
|
56
|
+
return c.json({
|
|
57
|
+
error: "Validation failed",
|
|
58
|
+
details: parseResult.error.flatten()
|
|
59
|
+
}, 400);
|
|
60
|
+
}
|
|
61
|
+
const body = parseResult.data;
|
|
62
|
+
const item = await c.var.services.navItems.create({
|
|
63
|
+
type: body.type,
|
|
64
|
+
label: body.label,
|
|
65
|
+
url: body.url,
|
|
66
|
+
pageId: body.pageId,
|
|
67
|
+
position: body.position
|
|
68
|
+
});
|
|
69
|
+
return c.json(item, 201);
|
|
70
|
+
});
|
|
71
|
+
// Update nav item (requires auth)
|
|
72
|
+
navItemsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
|
|
73
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
74
|
+
if (isNaN(id)) return c.json({
|
|
75
|
+
error: "Invalid ID"
|
|
76
|
+
}, 400);
|
|
77
|
+
const rawBody = await c.req.json();
|
|
78
|
+
const parseResult = UpdateNavItemSchema.safeParse(rawBody);
|
|
79
|
+
if (!parseResult.success) {
|
|
80
|
+
return c.json({
|
|
81
|
+
error: "Validation failed",
|
|
82
|
+
details: parseResult.error.flatten()
|
|
83
|
+
}, 400);
|
|
84
|
+
}
|
|
85
|
+
const item = await c.var.services.navItems.update(id, parseResult.data);
|
|
86
|
+
if (!item) return c.json({
|
|
87
|
+
error: "Not found"
|
|
88
|
+
}, 404);
|
|
89
|
+
return c.json(item);
|
|
90
|
+
});
|
|
91
|
+
// Delete nav item (requires auth)
|
|
92
|
+
navItemsApiRoutes.delete("/:id", requireAuthApi(), async (c)=>{
|
|
93
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
94
|
+
if (isNaN(id)) return c.json({
|
|
95
|
+
error: "Invalid ID"
|
|
96
|
+
}, 400);
|
|
97
|
+
const success = await c.var.services.navItems.delete(id);
|
|
98
|
+
if (!success) return c.json({
|
|
99
|
+
error: "Not found"
|
|
100
|
+
}, 404);
|
|
101
|
+
return c.json({
|
|
102
|
+
success: true
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pages API Routes
|
|
3
|
+
*/ import { Hono } from "hono";
|
|
4
|
+
import { requireAuthApi } from "../../middleware/auth.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { StatusSchema } from "../../lib/schemas.js";
|
|
7
|
+
export const pagesApiRoutes = new Hono();
|
|
8
|
+
const CreatePageSchema = z.object({
|
|
9
|
+
slug: z.string().min(1),
|
|
10
|
+
title: z.string().optional(),
|
|
11
|
+
body: z.string().optional(),
|
|
12
|
+
status: StatusSchema.optional()
|
|
13
|
+
});
|
|
14
|
+
const UpdatePageSchema = z.object({
|
|
15
|
+
slug: z.string().min(1).optional(),
|
|
16
|
+
title: z.string().nullable().optional(),
|
|
17
|
+
body: z.string().nullable().optional(),
|
|
18
|
+
status: StatusSchema.optional()
|
|
19
|
+
});
|
|
20
|
+
// List pages
|
|
21
|
+
pagesApiRoutes.get("/", async (c)=>{
|
|
22
|
+
const pages = await c.var.services.pages.list();
|
|
23
|
+
return c.json({
|
|
24
|
+
pages
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
// Get single page
|
|
28
|
+
pagesApiRoutes.get("/:id", async (c)=>{
|
|
29
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
30
|
+
if (isNaN(id)) return c.json({
|
|
31
|
+
error: "Invalid ID"
|
|
32
|
+
}, 400);
|
|
33
|
+
const page = await c.var.services.pages.getById(id);
|
|
34
|
+
if (!page) return c.json({
|
|
35
|
+
error: "Not found"
|
|
36
|
+
}, 404);
|
|
37
|
+
return c.json(page);
|
|
38
|
+
});
|
|
39
|
+
// Create page (requires auth)
|
|
40
|
+
pagesApiRoutes.post("/", requireAuthApi(), async (c)=>{
|
|
41
|
+
const rawBody = await c.req.json();
|
|
42
|
+
const parseResult = CreatePageSchema.safeParse(rawBody);
|
|
43
|
+
if (!parseResult.success) {
|
|
44
|
+
return c.json({
|
|
45
|
+
error: "Validation failed",
|
|
46
|
+
details: parseResult.error.flatten()
|
|
47
|
+
}, 400);
|
|
48
|
+
}
|
|
49
|
+
const body = parseResult.data;
|
|
50
|
+
const page = await c.var.services.pages.create({
|
|
51
|
+
slug: body.slug,
|
|
52
|
+
title: body.title,
|
|
53
|
+
body: body.body,
|
|
54
|
+
status: body.status
|
|
55
|
+
});
|
|
56
|
+
return c.json(page, 201);
|
|
57
|
+
});
|
|
58
|
+
// Update page (requires auth)
|
|
59
|
+
pagesApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
|
|
60
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
61
|
+
if (isNaN(id)) return c.json({
|
|
62
|
+
error: "Invalid ID"
|
|
63
|
+
}, 400);
|
|
64
|
+
const rawBody = await c.req.json();
|
|
65
|
+
const parseResult = UpdatePageSchema.safeParse(rawBody);
|
|
66
|
+
if (!parseResult.success) {
|
|
67
|
+
return c.json({
|
|
68
|
+
error: "Validation failed",
|
|
69
|
+
details: parseResult.error.flatten()
|
|
70
|
+
}, 400);
|
|
71
|
+
}
|
|
72
|
+
const page = await c.var.services.pages.update(id, parseResult.data);
|
|
73
|
+
if (!page) return c.json({
|
|
74
|
+
error: "Not found"
|
|
75
|
+
}, 404);
|
|
76
|
+
return c.json(page);
|
|
77
|
+
});
|
|
78
|
+
// Delete page (requires auth)
|
|
79
|
+
pagesApiRoutes.delete("/:id", requireAuthApi(), async (c)=>{
|
|
80
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
81
|
+
if (isNaN(id)) return c.json({
|
|
82
|
+
error: "Invalid ID"
|
|
83
|
+
}, 400);
|
|
84
|
+
const success = await c.var.services.pages.delete(id);
|
|
85
|
+
if (!success) return c.json({
|
|
86
|
+
error: "Not found"
|
|
87
|
+
}, 404);
|
|
88
|
+
return c.json({
|
|
89
|
+
success: true
|
|
90
|
+
});
|
|
91
|
+
});
|
package/dist/routes/api/posts.js
CHANGED
|
@@ -110,7 +110,7 @@ postsApiRoutes.post("/", requireAuthApi(), async (c)=>{
|
|
|
110
110
|
format: body.format,
|
|
111
111
|
title: body.title,
|
|
112
112
|
body: body.body,
|
|
113
|
-
|
|
113
|
+
path: body.path || undefined,
|
|
114
114
|
status: body.status,
|
|
115
115
|
featured: body.featured,
|
|
116
116
|
pinned: body.pinned,
|
|
@@ -173,7 +173,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
|
|
|
173
173
|
format: body.format,
|
|
174
174
|
title: body.title,
|
|
175
175
|
body: body.body,
|
|
176
|
-
|
|
176
|
+
path: body.path,
|
|
177
177
|
status: body.status,
|
|
178
178
|
featured: body.featured,
|
|
179
179
|
pinned: body.pinned,
|
|
@@ -31,10 +31,10 @@ searchApiRoutes.get("/", async (c)=>{
|
|
|
31
31
|
id: sqid.encode(r.post.id),
|
|
32
32
|
format: r.post.format,
|
|
33
33
|
title: r.post.title,
|
|
34
|
-
|
|
34
|
+
path: r.post.path,
|
|
35
35
|
snippet: r.snippet,
|
|
36
36
|
publishedAt: r.post.publishedAt,
|
|
37
|
-
url: r.post.
|
|
37
|
+
url: r.post.path ? `/${r.post.path}` : `/p/${sqid.encode(r.post.id)}`
|
|
38
38
|
})),
|
|
39
39
|
count: results.length
|
|
40
40
|
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings API Routes
|
|
3
|
+
*/ import { Hono } from "hono";
|
|
4
|
+
import { requireAuthApi } from "../../middleware/auth.js";
|
|
5
|
+
import { CONFIG_FIELDS } from "../../types.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
export const settingsApiRoutes = new Hono();
|
|
8
|
+
/** Config keys that can be modified via the settings API */ const editableKeys = Object.entries(CONFIG_FIELDS).filter(([, field])=>!field.envOnly).map(([key])=>key);
|
|
9
|
+
const UpdateSettingsSchema = z.record(z.string(), z.string());
|
|
10
|
+
// Get all settings (requires auth)
|
|
11
|
+
settingsApiRoutes.get("/", requireAuthApi(), async (c)=>{
|
|
12
|
+
const allSettings = await c.var.services.settings.getAll();
|
|
13
|
+
// Include default values for editable keys not yet stored in DB
|
|
14
|
+
const result = {};
|
|
15
|
+
for (const key of editableKeys){
|
|
16
|
+
result[key] = allSettings[key] ?? CONFIG_FIELDS[key].defaultValue;
|
|
17
|
+
}
|
|
18
|
+
return c.json({
|
|
19
|
+
settings: result
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
// Update settings (requires auth)
|
|
23
|
+
settingsApiRoutes.put("/", requireAuthApi(), async (c)=>{
|
|
24
|
+
const rawBody = await c.req.json();
|
|
25
|
+
const parseResult = UpdateSettingsSchema.safeParse(rawBody);
|
|
26
|
+
if (!parseResult.success) {
|
|
27
|
+
return c.json({
|
|
28
|
+
error: "Validation failed",
|
|
29
|
+
details: parseResult.error.flatten()
|
|
30
|
+
}, 400);
|
|
31
|
+
}
|
|
32
|
+
const updates = parseResult.data;
|
|
33
|
+
// Filter to only editable keys
|
|
34
|
+
const filteredUpdates = {};
|
|
35
|
+
const rejectedKeys = [];
|
|
36
|
+
for (const [key, value] of Object.entries(updates)){
|
|
37
|
+
if (editableKeys.includes(key)) {
|
|
38
|
+
filteredUpdates[key] = value;
|
|
39
|
+
} else {
|
|
40
|
+
rejectedKeys.push(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (rejectedKeys.length > 0 && Object.keys(filteredUpdates).length === 0) {
|
|
44
|
+
return c.json({
|
|
45
|
+
error: "None of the provided keys are editable",
|
|
46
|
+
rejectedKeys
|
|
47
|
+
}, 400);
|
|
48
|
+
}
|
|
49
|
+
if (Object.keys(filteredUpdates).length > 0) {
|
|
50
|
+
// Settings service expects SettingsKey, but our ConfigKeys that are
|
|
51
|
+
// editable (SITE_NAME, SITE_DESCRIPTION, SITE_LANGUAGE) are valid SettingsKeys
|
|
52
|
+
for (const [key, value] of Object.entries(filteredUpdates)){
|
|
53
|
+
await c.var.services.settings.set(key, value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Return updated state
|
|
57
|
+
const allSettings = await c.var.services.settings.getAll();
|
|
58
|
+
const result = {};
|
|
59
|
+
for (const key of editableKeys){
|
|
60
|
+
result[key] = allSettings[key] ?? CONFIG_FIELDS[key].defaultValue;
|
|
61
|
+
}
|
|
62
|
+
return c.json({
|
|
63
|
+
settings: result,
|
|
64
|
+
...rejectedKeys.length > 0 && {
|
|
65
|
+
rejectedKeys
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose Route
|
|
3
|
+
*
|
|
4
|
+
* Handles post creation from the public-site compose dialog.
|
|
5
|
+
* Returns dsRedirect to the new post's permalink (Datastar form pattern).
|
|
6
|
+
*/ import { Hono } from "hono";
|
|
7
|
+
import { requireAuth } from "../middleware/auth.js";
|
|
8
|
+
import { CreatePostSchema, validateMediaCount } from "../lib/schemas.js";
|
|
9
|
+
import * as sqid from "../lib/sqid.js";
|
|
10
|
+
import { dsRedirect, dsToast } from "../lib/sse.js";
|
|
11
|
+
export const composeRoutes = new Hono();
|
|
12
|
+
// All compose routes require authentication
|
|
13
|
+
composeRoutes.use("*", requireAuth());
|
|
14
|
+
composeRoutes.post("/", async (c)=>{
|
|
15
|
+
const raw = await c.req.json();
|
|
16
|
+
const result = CreatePostSchema.safeParse(raw);
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
const firstError = result.error.issues[0]?.message ?? "Invalid input";
|
|
19
|
+
return dsToast(firstError, "error");
|
|
20
|
+
}
|
|
21
|
+
const data = result.data;
|
|
22
|
+
// Validate media count
|
|
23
|
+
if (data.mediaIds) {
|
|
24
|
+
const mediaError = validateMediaCount(data.mediaIds);
|
|
25
|
+
if (mediaError) {
|
|
26
|
+
return dsToast(mediaError, "error");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const post = await c.var.services.posts.create({
|
|
30
|
+
format: data.format,
|
|
31
|
+
title: data.title || undefined,
|
|
32
|
+
body: data.body || undefined,
|
|
33
|
+
status: data.status ?? "published",
|
|
34
|
+
featured: data.featured,
|
|
35
|
+
pinned: data.pinned,
|
|
36
|
+
url: data.url || undefined,
|
|
37
|
+
quoteText: data.quoteText || undefined,
|
|
38
|
+
rating: data.rating || undefined,
|
|
39
|
+
collectionId: data.collectionId || undefined
|
|
40
|
+
});
|
|
41
|
+
// Attach media if provided
|
|
42
|
+
if (data.mediaIds && data.mediaIds.length > 0) {
|
|
43
|
+
await c.var.services.media.attachToPost(post.id, data.mediaIds);
|
|
44
|
+
}
|
|
45
|
+
// Redirect to the new post's permalink
|
|
46
|
+
const permalink = post.path ? `/${post.path}` : `/p/${sqid.encode(post.id)}`;
|
|
47
|
+
return dsRedirect(permalink);
|
|
48
|
+
});
|
|
@@ -4,8 +4,8 @@ import { getSiteName } from "../../lib/config.js";
|
|
|
4
4
|
* Dashboard Collections Routes
|
|
5
5
|
*/ import { Hono } from "hono";
|
|
6
6
|
import { useLingui as $_useLingui } from "@jant/core/i18n";
|
|
7
|
-
import { DashLayout } from "../../
|
|
8
|
-
import { EmptyState, ListItemRow, ActionButtons, CrudPageHeader, DangerZone } from "../../
|
|
7
|
+
import { DashLayout } from "../../ui/layouts/DashLayout.js";
|
|
8
|
+
import { EmptyState, ListItemRow, ActionButtons, CrudPageHeader, DangerZone } from "../../ui/dash/index.js";
|
|
9
9
|
import * as sqid from "../../lib/sqid.js";
|
|
10
10
|
import { dsRedirect } from "../../lib/sse.js";
|
|
11
11
|
export const collectionsRoutes = new Hono();
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
|
|
|
6
6
|
*/ import { Hono } from "hono";
|
|
7
7
|
import { useLingui as $_useLingui } from "@jant/core/i18n";
|
|
8
8
|
import { Trans as Trans_ } from "@jant/core/i18n";
|
|
9
|
-
import { DashLayout } from "../../
|
|
9
|
+
import { DashLayout } from "../../ui/layouts/DashLayout.js";
|
|
10
10
|
import { getSiteName } from "../../lib/config.js";
|
|
11
11
|
export const dashIndexRoutes = new Hono();
|
|
12
12
|
/**
|
|
@@ -7,8 +7,8 @@ import { getSiteName } from "../../lib/config.js";
|
|
|
7
7
|
* Uses SSE for real-time UI updates without page reloads.
|
|
8
8
|
*/ import { Hono } from "hono";
|
|
9
9
|
import { useLingui as $_useLingui } from "@jant/core/i18n";
|
|
10
|
-
import { DashLayout } from "../../
|
|
11
|
-
import { EmptyState, DangerZone } from "../../
|
|
10
|
+
import { DashLayout } from "../../ui/layouts/DashLayout.js";
|
|
11
|
+
import { EmptyState, DangerZone } from "../../ui/dash/index.js";
|
|
12
12
|
import * as time from "../../lib/time.js";
|
|
13
13
|
import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
|
|
14
14
|
import { dsRedirect } from "../../lib/sse.js";
|