@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/src/lib/schemas.ts
CHANGED
|
@@ -51,19 +51,20 @@ export const RedirectTypeSchema = z.enum(["301", "302"]);
|
|
|
51
51
|
export const RatingSchema = z.coerce
|
|
52
52
|
.number()
|
|
53
53
|
.int()
|
|
54
|
-
.min(
|
|
54
|
+
.min(0)
|
|
55
55
|
.max(5)
|
|
56
56
|
.optional()
|
|
57
|
-
.or(z.literal("").transform(() => undefined))
|
|
57
|
+
.or(z.literal("").transform(() => undefined))
|
|
58
|
+
.transform((v) => (v === 0 ? undefined : v));
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* API request body schema for creating a post
|
|
61
62
|
*/
|
|
62
63
|
export const CreatePostSchema = z.object({
|
|
63
64
|
format: FormatSchema,
|
|
64
|
-
|
|
65
|
+
path: z
|
|
65
66
|
.string()
|
|
66
|
-
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]
|
|
67
|
+
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\/[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/)
|
|
67
68
|
.optional()
|
|
68
69
|
.or(z.literal("").transform(() => undefined)),
|
|
69
70
|
title: z.string().optional(),
|
|
@@ -81,9 +82,10 @@ export const CreatePostSchema = z.object({
|
|
|
81
82
|
collectionId: z.coerce
|
|
82
83
|
.number()
|
|
83
84
|
.int()
|
|
84
|
-
.
|
|
85
|
+
.min(0)
|
|
85
86
|
.optional()
|
|
86
|
-
.or(z.literal("").transform(() => undefined))
|
|
87
|
+
.or(z.literal("").transform(() => undefined))
|
|
88
|
+
.transform((v) => (v === 0 ? undefined : v)),
|
|
87
89
|
replyToId: z.string().optional(), // Sqid format
|
|
88
90
|
publishedAt: z.number().int().positive().optional(),
|
|
89
91
|
mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
|
package/src/lib/theme.ts
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
* Resolves the active color theme and builds CSS for injection into `<head>`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { ColorTheme } from "../
|
|
8
|
-
import { BUILTIN_COLOR_THEMES } from "../
|
|
7
|
+
import type { ColorTheme } from "../ui/color-themes.js";
|
|
8
|
+
import { BUILTIN_COLOR_THEMES } from "../ui/color-themes.js";
|
|
9
9
|
import type { JantConfig } from "../types.js";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Get the list of available color themes.
|
|
13
13
|
*
|
|
14
|
-
* Returns `config.
|
|
14
|
+
* Returns `config.colorThemes` if provided, otherwise the built-in list.
|
|
15
15
|
*
|
|
16
16
|
* @param config - The Jant configuration
|
|
17
17
|
* @returns Array of available color themes
|
|
@@ -22,7 +22,7 @@ import type { JantConfig } from "../types.js";
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
export function getAvailableThemes(config: JantConfig): ColorTheme[] {
|
|
25
|
-
return config.
|
|
25
|
+
return config.colorThemes ?? BUILTIN_COLOR_THEMES;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -32,7 +32,7 @@ export function getAvailableThemes(config: JantConfig): ColorTheme[] {
|
|
|
32
32
|
* BaseCoat defaults → selected theme → cssVariables
|
|
33
33
|
*
|
|
34
34
|
* @param theme - The active color theme (undefined = no theme overrides)
|
|
35
|
-
* @param cssVariables - Extra CSS variable overrides from `createApp({
|
|
35
|
+
* @param cssVariables - Extra CSS variable overrides from `createApp({ cssVariables })`
|
|
36
36
|
* @returns CSS string to inject in `<head>`, or empty string if nothing to inject
|
|
37
37
|
*
|
|
38
38
|
* Uses `:root:root` and `:root.dark` selectors for higher specificity than
|
package/src/lib/timeline.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
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
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Context } from "hono";
|
|
9
|
-
import type { Bindings, TimelineItemView
|
|
9
|
+
import type { Bindings, TimelineItemView } from "../types.js";
|
|
10
10
|
import type { AppVariables } from "../app.js";
|
|
11
11
|
import { buildMediaMap } from "./media-helpers.js";
|
|
12
12
|
import { createMediaContext, toPostView, toPostViews } from "./view.js";
|
|
@@ -20,51 +20,58 @@ const DEFAULT_PAGE_SIZE = 20;
|
|
|
20
20
|
*/
|
|
21
21
|
export interface TimelineResult {
|
|
22
22
|
items: TimelineItemView[];
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
currentPage: number;
|
|
24
|
+
totalPages: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Assembles a page of timeline items with media attachments and thread previews.
|
|
29
29
|
*
|
|
30
|
-
* Fetches posts, batch-loads media, identifies
|
|
31
|
-
* render-ready `TimelineItemView[]` with
|
|
30
|
+
* Fetches posts using offset-based pagination, batch-loads media, identifies
|
|
31
|
+
* threads, and returns render-ready `TimelineItemView[]` with page info.
|
|
32
32
|
*
|
|
33
33
|
* @param c - Hono context (provides services + env)
|
|
34
|
-
* @param options - Optional
|
|
34
|
+
* @param options - Optional page number (1-indexed, defaults to 1)
|
|
35
35
|
* @returns Assembled timeline items with pagination info
|
|
36
36
|
*
|
|
37
37
|
* @example
|
|
38
38
|
* ```ts
|
|
39
|
-
* const { items,
|
|
40
|
-
* const { items,
|
|
39
|
+
* const { items, currentPage, totalPages } = await assembleTimeline(c);
|
|
40
|
+
* const { items, currentPage, totalPages } = await assembleTimeline(c, { page: 2 });
|
|
41
41
|
* ```
|
|
42
42
|
*/
|
|
43
43
|
export async function assembleTimeline(
|
|
44
44
|
c: Context<Env>,
|
|
45
|
-
options?: {
|
|
45
|
+
options?: { page?: number },
|
|
46
46
|
): Promise<TimelineResult> {
|
|
47
47
|
const pageSize =
|
|
48
48
|
parseInt(c.env.PAGE_SIZE ?? String(DEFAULT_PAGE_SIZE), 10) ||
|
|
49
49
|
DEFAULT_PAGE_SIZE;
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
const
|
|
51
|
+
const page = Math.max(1, options?.page ?? 1);
|
|
52
|
+
const offset = (page - 1) * pageSize;
|
|
53
|
+
|
|
54
|
+
// Get total count for pagination
|
|
55
|
+
const totalCount = await c.var.services.posts.count({
|
|
53
56
|
status: "published",
|
|
54
57
|
excludeReplies: true,
|
|
55
|
-
limit: pageSize + 1,
|
|
56
|
-
cursor: options?.cursor,
|
|
57
58
|
});
|
|
59
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
+
// Fetch posts for the current page
|
|
62
|
+
const posts = await c.var.services.posts.list({
|
|
63
|
+
status: "published",
|
|
64
|
+
excludeReplies: true,
|
|
65
|
+
limit: pageSize,
|
|
66
|
+
offset,
|
|
67
|
+
});
|
|
61
68
|
|
|
62
|
-
if (
|
|
63
|
-
return { items: [],
|
|
69
|
+
if (posts.length === 0) {
|
|
70
|
+
return { items: [], currentPage: page, totalPages };
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
// Batch load media attachments
|
|
67
|
-
const postIds =
|
|
74
|
+
const postIds = posts.map((p) => p.id);
|
|
68
75
|
const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
69
76
|
const mediaCtx = createMediaContext(c);
|
|
70
77
|
const mediaMap = buildMediaMap(
|
|
@@ -102,7 +109,7 @@ export async function assembleTimeline(
|
|
|
102
109
|
: new Map();
|
|
103
110
|
|
|
104
111
|
// Assemble timeline items with View Models
|
|
105
|
-
const items: TimelineItemView[] =
|
|
112
|
+
const items: TimelineItemView[] = posts.map((post) => {
|
|
106
113
|
const postView = toPostView(
|
|
107
114
|
{ ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
|
|
108
115
|
mediaCtx,
|
|
@@ -130,41 +137,5 @@ export async function assembleTimeline(
|
|
|
130
137
|
return { post: postView };
|
|
131
138
|
});
|
|
132
139
|
|
|
133
|
-
|
|
134
|
-
const lastPost = displayPosts[displayPosts.length - 1];
|
|
135
|
-
const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
|
|
136
|
-
|
|
137
|
-
return { items, hasMore, nextCursor };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Groups timeline items by their publication date (YYYY-MM-DD).
|
|
142
|
-
*
|
|
143
|
-
* @param items - Timeline items to group
|
|
144
|
-
* @returns Array of date groups, each containing items published on the same day
|
|
145
|
-
*
|
|
146
|
-
* @example
|
|
147
|
-
* ```ts
|
|
148
|
-
* const groups = groupByDate(items);
|
|
149
|
-
* // [{ dateKey: "2024-02-01", label: "Feb 1, 2024", items: [...] }, ...]
|
|
150
|
-
* ```
|
|
151
|
-
*/
|
|
152
|
-
export function groupByDate(items: TimelineItemView[]): DateGroup[] {
|
|
153
|
-
const groups: DateGroup[] = [];
|
|
154
|
-
let current: DateGroup | null = null;
|
|
155
|
-
|
|
156
|
-
for (const item of items) {
|
|
157
|
-
const dateKey = item.post.publishedAt.slice(0, 10);
|
|
158
|
-
if (!current || current.dateKey !== dateKey) {
|
|
159
|
-
current = {
|
|
160
|
-
dateKey,
|
|
161
|
-
label: item.post.publishedAtFormatted,
|
|
162
|
-
items: [],
|
|
163
|
-
};
|
|
164
|
-
groups.push(current);
|
|
165
|
-
}
|
|
166
|
-
current.items.push(item);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return groups;
|
|
140
|
+
return { items, currentPage: page, totalPages };
|
|
170
141
|
}
|
package/src/lib/view.ts
CHANGED
|
@@ -109,7 +109,7 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
|
|
|
109
109
|
* @returns Render-ready PostView with pre-computed fields
|
|
110
110
|
*/
|
|
111
111
|
export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
|
|
112
|
-
const permalink = post.
|
|
112
|
+
const permalink = post.path ? `/${post.path}` : `/p/${encode(post.id)}`;
|
|
113
113
|
|
|
114
114
|
// Pre-compute excerpt from raw body
|
|
115
115
|
let excerpt: string | undefined;
|
|
@@ -141,7 +141,7 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
|
|
|
141
141
|
return {
|
|
142
142
|
id: post.id,
|
|
143
143
|
permalink,
|
|
144
|
-
|
|
144
|
+
path: post.path ?? undefined,
|
|
145
145
|
title: post.title ?? undefined,
|
|
146
146
|
bodyHtml: post.bodyHtml ?? undefined,
|
|
147
147
|
excerpt,
|
package/src/preset.css
CHANGED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createTestApp } from "../../__tests__/helpers/app.js";
|
|
3
|
+
import { composeRoutes } from "../compose.js";
|
|
4
|
+
|
|
5
|
+
describe("Compose Routes", () => {
|
|
6
|
+
describe("POST /compose", () => {
|
|
7
|
+
it("redirects to signin when not authenticated", async () => {
|
|
8
|
+
const { app } = createTestApp({ authenticated: false });
|
|
9
|
+
app.route("/compose", composeRoutes);
|
|
10
|
+
|
|
11
|
+
const res = await app.request("/compose", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
body: JSON.stringify({ format: "note", body: "Hello" }),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(res.status).toBe(302);
|
|
18
|
+
expect(res.headers.get("Location")).toBe("/signin");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("creates a note post and returns redirect", async () => {
|
|
22
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
23
|
+
app.route("/compose", composeRoutes);
|
|
24
|
+
|
|
25
|
+
const res = await app.request("/compose", {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ format: "note", body: "Hello world" }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(res.status).toBe(200);
|
|
32
|
+
expect(res.headers.get("Content-Type")).toBe("text/html");
|
|
33
|
+
|
|
34
|
+
// Verify post was created
|
|
35
|
+
const posts = await services.posts.list();
|
|
36
|
+
expect(posts).toHaveLength(1);
|
|
37
|
+
expect(posts[0].format).toBe("note");
|
|
38
|
+
expect(posts[0].body).toBe("Hello world");
|
|
39
|
+
expect(posts[0].status).toBe("published");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("creates a link post", async () => {
|
|
43
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
44
|
+
app.route("/compose", composeRoutes);
|
|
45
|
+
|
|
46
|
+
const res = await app.request("/compose", {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
format: "link",
|
|
51
|
+
body: "Check this out",
|
|
52
|
+
url: "https://example.com",
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
|
|
58
|
+
const posts = await services.posts.list();
|
|
59
|
+
expect(posts).toHaveLength(1);
|
|
60
|
+
expect(posts[0].format).toBe("link");
|
|
61
|
+
expect(posts[0].url).toBe("https://example.com");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("creates a quote post", async () => {
|
|
65
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
66
|
+
app.route("/compose", composeRoutes);
|
|
67
|
+
|
|
68
|
+
const res = await app.request("/compose", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
format: "quote",
|
|
73
|
+
body: "Great insight",
|
|
74
|
+
quoteText: "The original quote",
|
|
75
|
+
url: "https://example.com/source",
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
|
|
81
|
+
const posts = await services.posts.list();
|
|
82
|
+
expect(posts).toHaveLength(1);
|
|
83
|
+
expect(posts[0].format).toBe("quote");
|
|
84
|
+
expect(posts[0].quoteText).toBe("The original quote");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("creates a draft when status is draft", async () => {
|
|
88
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
89
|
+
app.route("/compose", composeRoutes);
|
|
90
|
+
|
|
91
|
+
const res = await app.request("/compose", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
format: "note",
|
|
96
|
+
body: "Draft content",
|
|
97
|
+
status: "draft",
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
|
|
103
|
+
const posts = await services.posts.list({ includeDrafts: true });
|
|
104
|
+
expect(posts).toHaveLength(1);
|
|
105
|
+
expect(posts[0].status).toBe("draft");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns error for invalid format", async () => {
|
|
109
|
+
const { app } = createTestApp({ authenticated: true });
|
|
110
|
+
app.route("/compose", composeRoutes);
|
|
111
|
+
|
|
112
|
+
const res = await app.request("/compose", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Content-Type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ format: "invalid", body: "Hello" }),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
expect(res.headers.get("Content-Type")).toBe("text/html");
|
|
120
|
+
// Returns a toast error (text/html with error message)
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
expect(text).toContain("toast-error");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("attaches media IDs when provided", async () => {
|
|
126
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
127
|
+
app.route("/compose", composeRoutes);
|
|
128
|
+
|
|
129
|
+
// Create media first
|
|
130
|
+
const media = await services.media.create({
|
|
131
|
+
filename: "test.jpg",
|
|
132
|
+
originalName: "test.jpg",
|
|
133
|
+
mimeType: "image/jpeg",
|
|
134
|
+
size: 1024,
|
|
135
|
+
storageKey: "media/2025/01/test.jpg",
|
|
136
|
+
width: 800,
|
|
137
|
+
height: 600,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const res = await app.request("/compose", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
format: "note",
|
|
145
|
+
body: "Post with media",
|
|
146
|
+
mediaIds: [media.id],
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(res.status).toBe(200);
|
|
151
|
+
|
|
152
|
+
const posts = await services.posts.list();
|
|
153
|
+
expect(posts).toHaveLength(1);
|
|
154
|
+
|
|
155
|
+
// Verify media is attached
|
|
156
|
+
const attachments = await services.media.getByPostId(posts[0].id);
|
|
157
|
+
expect(attachments).toHaveLength(1);
|
|
158
|
+
expect(attachments[0].id).toBe(media.id);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("sets featured and pinned flags", async () => {
|
|
162
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
163
|
+
app.route("/compose", composeRoutes);
|
|
164
|
+
|
|
165
|
+
const res = await app.request("/compose", {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "Content-Type": "application/json" },
|
|
168
|
+
body: JSON.stringify({
|
|
169
|
+
format: "note",
|
|
170
|
+
body: "Featured and pinned",
|
|
171
|
+
featured: true,
|
|
172
|
+
pinned: true,
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
|
|
178
|
+
const posts = await services.posts.list();
|
|
179
|
+
expect(posts).toHaveLength(1);
|
|
180
|
+
expect(posts[0].featured).toBe(1);
|
|
181
|
+
expect(posts[0].pinned).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns error when format is missing", async () => {
|
|
185
|
+
const { app } = createTestApp({ authenticated: true });
|
|
186
|
+
app.route("/compose", composeRoutes);
|
|
187
|
+
|
|
188
|
+
const res = await app.request("/compose", {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ body: "No format" }),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(res.status).toBe(200);
|
|
195
|
+
const text = await res.text();
|
|
196
|
+
expect(text).toContain("toast-error");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
3
|
+
import { collectionsApiRoutes } from "../collections.js";
|
|
4
|
+
|
|
5
|
+
describe("Collections API Routes", () => {
|
|
6
|
+
describe("GET /api/collections", () => {
|
|
7
|
+
it("returns empty list when no collections exist", async () => {
|
|
8
|
+
const { app } = createTestApp();
|
|
9
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
10
|
+
|
|
11
|
+
const res = await app.request("/api/collections");
|
|
12
|
+
expect(res.status).toBe(200);
|
|
13
|
+
|
|
14
|
+
const body = await res.json();
|
|
15
|
+
expect(body.collections).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns collections with post counts", async () => {
|
|
19
|
+
const { app, services } = createTestApp();
|
|
20
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
21
|
+
|
|
22
|
+
const col = await services.collections.create({
|
|
23
|
+
slug: "tech",
|
|
24
|
+
title: "Tech",
|
|
25
|
+
});
|
|
26
|
+
await services.posts.create({
|
|
27
|
+
format: "note",
|
|
28
|
+
body: "tech post",
|
|
29
|
+
collectionId: col.id,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const res = await app.request("/api/collections");
|
|
33
|
+
const body = await res.json();
|
|
34
|
+
|
|
35
|
+
expect(body.collections).toHaveLength(1);
|
|
36
|
+
expect(body.collections[0].slug).toBe("tech");
|
|
37
|
+
expect(body.collections[0].postCount).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("GET /api/collections/:id", () => {
|
|
42
|
+
it("returns a collection by id", async () => {
|
|
43
|
+
const { app, services } = createTestApp();
|
|
44
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
45
|
+
|
|
46
|
+
const col = await services.collections.create({
|
|
47
|
+
slug: "tech",
|
|
48
|
+
title: "Tech Articles",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const res = await app.request(`/api/collections/${col.id}`);
|
|
52
|
+
expect(res.status).toBe(200);
|
|
53
|
+
|
|
54
|
+
const body = await res.json();
|
|
55
|
+
expect(body.title).toBe("Tech Articles");
|
|
56
|
+
expect(body.slug).toBe("tech");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns 400 for invalid id", async () => {
|
|
60
|
+
const { app } = createTestApp();
|
|
61
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
62
|
+
|
|
63
|
+
const res = await app.request("/api/collections/abc");
|
|
64
|
+
expect(res.status).toBe(400);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns 404 for non-existent collection", async () => {
|
|
68
|
+
const { app } = createTestApp();
|
|
69
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
70
|
+
|
|
71
|
+
const res = await app.request("/api/collections/9999");
|
|
72
|
+
expect(res.status).toBe(404);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("POST /api/collections", () => {
|
|
77
|
+
it("returns 401 when not authenticated", async () => {
|
|
78
|
+
const { app } = createTestApp({ authenticated: false });
|
|
79
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
80
|
+
|
|
81
|
+
const res = await app.request("/api/collections", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify({ slug: "tech", title: "Tech" }),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(res.status).toBe(401);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("creates a collection when authenticated", async () => {
|
|
91
|
+
const { app } = createTestApp({ authenticated: true });
|
|
92
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
93
|
+
|
|
94
|
+
const res = await app.request("/api/collections", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/json" },
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
slug: "tech",
|
|
99
|
+
title: "Tech",
|
|
100
|
+
description: "Tech articles",
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(res.status).toBe(201);
|
|
105
|
+
const body = await res.json();
|
|
106
|
+
expect(body.slug).toBe("tech");
|
|
107
|
+
expect(body.title).toBe("Tech");
|
|
108
|
+
expect(body.description).toBe("Tech articles");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns 400 for missing required fields", async () => {
|
|
112
|
+
const { app } = createTestApp({ authenticated: true });
|
|
113
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
114
|
+
|
|
115
|
+
const res = await app.request("/api/collections", {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body: JSON.stringify({ slug: "tech" }),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(res.status).toBe(400);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("PUT /api/collections/reorder", () => {
|
|
126
|
+
it("returns 401 when not authenticated", async () => {
|
|
127
|
+
const { app } = createTestApp({ authenticated: false });
|
|
128
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
129
|
+
|
|
130
|
+
const res = await app.request("/api/collections/reorder", {
|
|
131
|
+
method: "PUT",
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ ids: [1, 2] }),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(res.status).toBe(401);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("reorders collections when authenticated", async () => {
|
|
140
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
141
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
142
|
+
|
|
143
|
+
const col1 = await services.collections.create({
|
|
144
|
+
slug: "first",
|
|
145
|
+
title: "First",
|
|
146
|
+
});
|
|
147
|
+
const col2 = await services.collections.create({
|
|
148
|
+
slug: "second",
|
|
149
|
+
title: "Second",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const res = await app.request("/api/collections/reorder", {
|
|
153
|
+
method: "PUT",
|
|
154
|
+
headers: { "Content-Type": "application/json" },
|
|
155
|
+
body: JSON.stringify({ ids: [col2.id, col1.id] }),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
const body = await res.json();
|
|
160
|
+
expect(body.collections[0].slug).toBe("second");
|
|
161
|
+
expect(body.collections[1].slug).toBe("first");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("PUT /api/collections/:id", () => {
|
|
166
|
+
it("updates a collection when authenticated", async () => {
|
|
167
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
168
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
169
|
+
|
|
170
|
+
const col = await services.collections.create({
|
|
171
|
+
slug: "tech",
|
|
172
|
+
title: "Tech",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const res = await app.request(`/api/collections/${col.id}`, {
|
|
176
|
+
method: "PUT",
|
|
177
|
+
headers: { "Content-Type": "application/json" },
|
|
178
|
+
body: JSON.stringify({ title: "Technology" }),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(res.status).toBe(200);
|
|
182
|
+
const body = await res.json();
|
|
183
|
+
expect(body.title).toBe("Technology");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns 404 for non-existent collection", async () => {
|
|
187
|
+
const { app } = createTestApp({ authenticated: true });
|
|
188
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
189
|
+
|
|
190
|
+
const res = await app.request("/api/collections/9999", {
|
|
191
|
+
method: "PUT",
|
|
192
|
+
headers: { "Content-Type": "application/json" },
|
|
193
|
+
body: JSON.stringify({ title: "test" }),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(res.status).toBe(404);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("DELETE /api/collections/:id", () => {
|
|
201
|
+
it("returns 401 when not authenticated", async () => {
|
|
202
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
203
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
204
|
+
|
|
205
|
+
const col = await services.collections.create({
|
|
206
|
+
slug: "tech",
|
|
207
|
+
title: "Tech",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const res = await app.request(`/api/collections/${col.id}`, {
|
|
211
|
+
method: "DELETE",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(res.status).toBe(401);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("deletes a collection when authenticated", async () => {
|
|
218
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
219
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
220
|
+
|
|
221
|
+
const col = await services.collections.create({
|
|
222
|
+
slug: "tech",
|
|
223
|
+
title: "Tech",
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const res = await app.request(`/api/collections/${col.id}`, {
|
|
227
|
+
method: "DELETE",
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(res.status).toBe(200);
|
|
231
|
+
const body = await res.json();
|
|
232
|
+
expect(body.success).toBe(true);
|
|
233
|
+
|
|
234
|
+
const found = await services.collections.getById(col.id);
|
|
235
|
+
expect(found).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("returns 404 for non-existent collection", async () => {
|
|
239
|
+
const { app } = createTestApp({ authenticated: true });
|
|
240
|
+
app.route("/api/collections", collectionsApiRoutes);
|
|
241
|
+
|
|
242
|
+
const res = await app.request("/api/collections/9999", {
|
|
243
|
+
method: "DELETE",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(res.status).toBe(404);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|