@jant/core 0.3.23 → 0.3.24
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 +4 -5
- package/dist/db/schema.js +72 -47
- 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 -3
- package/dist/lib/constants.js +1 -4
- package/dist/lib/excerpt.js +76 -0
- package/dist/lib/feed.js +18 -7
- package/dist/lib/navigation.js +4 -5
- package/dist/lib/render.js +1 -1
- package/dist/lib/schemas.js +80 -38
- package/dist/lib/theme-components.js +8 -11
- package/dist/lib/time.js +56 -1
- package/dist/lib/timeline.js +119 -0
- package/dist/lib/view.js +61 -72
- package/dist/routes/api/posts.js +29 -35
- package/dist/routes/api/search.js +5 -6
- package/dist/routes/api/upload.js +13 -13
- package/dist/routes/dash/collections.js +22 -40
- package/dist/routes/dash/index.js +2 -2
- package/dist/routes/dash/navigation.js +25 -24
- package/dist/routes/dash/pages.js +42 -57
- package/dist/routes/dash/posts.js +27 -35
- package/dist/routes/feed/rss.js +2 -4
- package/dist/routes/feed/sitemap.js +10 -7
- package/dist/routes/pages/archive.js +12 -11
- package/dist/routes/pages/collection.js +11 -5
- package/dist/routes/pages/home.js +53 -61
- package/dist/routes/pages/page.js +60 -29
- package/dist/routes/pages/post.js +5 -12
- package/dist/routes/pages/search.js +3 -4
- package/dist/services/collection.js +52 -64
- package/dist/services/index.js +5 -3
- package/dist/services/navigation.js +29 -53
- package/dist/services/page.js +80 -0
- package/dist/services/post.js +68 -69
- package/dist/services/search.js +24 -18
- package/dist/theme/components/MediaGallery.js +19 -91
- package/dist/theme/components/PageForm.js +15 -15
- package/dist/theme/components/PostForm.js +136 -129
- package/dist/theme/components/PostList.js +13 -8
- package/dist/theme/components/ThreadView.js +3 -3
- package/dist/theme/components/TypeBadge.js +3 -14
- package/dist/theme/components/VisibilityBadge.js +33 -23
- package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
- package/dist/themes/threads/index.js +81 -0
- package/dist/themes/{minimal → threads}/pages/ArchivePage.js +32 -47
- package/dist/themes/threads/pages/CollectionPage.js +65 -0
- package/dist/themes/{minimal → threads}/pages/HomePage.js +3 -3
- package/dist/themes/{minimal → threads}/pages/PostPage.js +12 -9
- package/dist/themes/{minimal → threads}/pages/SearchPage.js +13 -14
- package/dist/themes/{minimal → threads}/pages/SinglePage.js +4 -4
- package/dist/themes/threads/timeline/LinkCard.js +68 -0
- package/dist/themes/threads/timeline/NoteCard.js +53 -0
- package/dist/themes/threads/timeline/QuoteCard.js +59 -0
- package/dist/themes/{minimal → threads}/timeline/ThreadPreview.js +17 -13
- package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
- package/dist/themes/{minimal → threads}/timeline/TimelineItem.js +8 -16
- package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
- package/dist/themes/threads/timeline/groupByDate.js +22 -0
- package/dist/themes/threads/timeline/timelineMore.js +107 -0
- package/dist/types.js +24 -40
- package/package.json +2 -1
- package/src/__tests__/helpers/app.ts +4 -0
- package/src/__tests__/helpers/db.ts +51 -74
- package/src/app.tsx +4 -6
- package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/schema.ts +63 -46
- package/src/i18n/locales/en.po +216 -164
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +216 -164
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +216 -164
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +28 -12
- package/src/lib/__tests__/excerpt.test.ts +125 -0
- package/src/lib/__tests__/schemas.test.ts +166 -105
- package/src/lib/__tests__/theme-components.test.ts +4 -25
- package/src/lib/__tests__/time.test.ts +62 -0
- package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
- package/src/lib/__tests__/view.test.ts +199 -51
- package/src/lib/constants.ts +1 -4
- package/src/lib/excerpt.ts +87 -0
- package/src/lib/feed.ts +22 -7
- package/src/lib/navigation.ts +6 -7
- package/src/lib/render.tsx +1 -1
- package/src/lib/schemas.ts +118 -52
- package/src/lib/theme-components.ts +10 -13
- package/src/lib/time.ts +64 -0
- package/src/lib/timeline.ts +170 -0
- package/src/lib/view.ts +80 -82
- package/src/preset.css +45 -0
- package/src/routes/api/__tests__/posts.test.ts +50 -108
- package/src/routes/api/__tests__/search.test.ts +2 -3
- package/src/routes/api/posts.ts +30 -30
- package/src/routes/api/search.ts +4 -4
- package/src/routes/api/upload.ts +16 -6
- package/src/routes/dash/collections.tsx +18 -40
- package/src/routes/dash/index.tsx +2 -2
- package/src/routes/dash/navigation.tsx +27 -26
- package/src/routes/dash/pages.tsx +45 -60
- package/src/routes/dash/posts.tsx +44 -52
- package/src/routes/feed/rss.ts +2 -1
- package/src/routes/feed/sitemap.ts +14 -4
- package/src/routes/pages/archive.tsx +14 -10
- package/src/routes/pages/collection.tsx +17 -6
- package/src/routes/pages/home.tsx +56 -81
- package/src/routes/pages/page.tsx +64 -27
- package/src/routes/pages/post.tsx +5 -14
- package/src/routes/pages/search.tsx +2 -2
- package/src/services/__tests__/collection.test.ts +257 -158
- package/src/services/__tests__/media.test.ts +18 -18
- package/src/services/__tests__/navigation.test.ts +161 -87
- package/src/services/__tests__/post-timeline.test.ts +92 -88
- package/src/services/__tests__/post.test.ts +342 -206
- package/src/services/__tests__/search.test.ts +19 -25
- package/src/services/collection.ts +71 -113
- package/src/services/index.ts +9 -8
- package/src/services/navigation.ts +38 -71
- package/src/services/page.ts +124 -0
- package/src/services/post.ts +93 -103
- package/src/services/search.ts +38 -27
- package/src/theme/components/MediaGallery.tsx +27 -96
- package/src/theme/components/PageForm.tsx +21 -21
- package/src/theme/components/PostForm.tsx +122 -118
- package/src/theme/components/PostList.tsx +58 -49
- package/src/theme/components/ThreadView.tsx +6 -3
- package/src/theme/components/TypeBadge.tsx +9 -17
- package/src/theme/components/VisibilityBadge.tsx +40 -23
- package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
- package/src/themes/{minimal → threads}/index.ts +30 -13
- package/src/themes/{minimal → threads}/pages/ArchivePage.tsx +53 -53
- package/src/themes/threads/pages/CollectionPage.tsx +61 -0
- package/src/themes/{minimal → threads}/pages/HomePage.tsx +3 -3
- package/src/themes/{minimal → threads}/pages/PostPage.tsx +12 -8
- package/src/themes/{minimal → threads}/pages/SearchPage.tsx +15 -13
- package/src/themes/{minimal → threads}/pages/SinglePage.tsx +4 -4
- package/src/themes/threads/style.css +336 -0
- package/src/themes/threads/timeline/LinkCard.tsx +67 -0
- package/src/themes/threads/timeline/NoteCard.tsx +58 -0
- package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
- package/src/themes/{minimal → threads}/timeline/ThreadPreview.tsx +15 -13
- package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
- package/src/themes/{minimal → threads}/timeline/TimelineItem.tsx +9 -17
- package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
- package/src/themes/threads/timeline/groupByDate.ts +30 -0
- package/src/themes/threads/timeline/timelineMore.tsx +130 -0
- package/src/types.ts +242 -98
- package/dist/routes/api/timeline.js +0 -120
- package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
- package/dist/themes/minimal/index.js +0 -65
- package/dist/themes/minimal/pages/CollectionPage.js +0 -65
- package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
- package/dist/themes/minimal/timeline/ImageCard.js +0 -67
- package/dist/themes/minimal/timeline/LinkCard.js +0 -47
- package/dist/themes/minimal/timeline/NoteCard.js +0 -34
- package/dist/themes/minimal/timeline/QuoteCard.js +0 -48
- package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
- package/src/routes/api/timeline.tsx +0 -159
- package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
- package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
- package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
- package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
- package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
- package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
- package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
- package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
package/src/lib/schemas.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared Zod schemas for validation
|
|
2
|
+
* Shared Zod schemas for validation (v2)
|
|
3
3
|
*
|
|
4
4
|
* These schemas ensure type-safe validation of user input
|
|
5
5
|
* from forms, API requests, and other external sources.
|
|
@@ -10,24 +10,34 @@
|
|
|
10
10
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
FORMATS,
|
|
14
|
+
STATUSES,
|
|
15
|
+
SORT_ORDERS,
|
|
16
|
+
NAV_ITEM_TYPES,
|
|
15
17
|
MAX_MEDIA_ATTACHMENTS,
|
|
16
|
-
POST_TYPE_MEDIA_RULES,
|
|
17
18
|
} from "../types.js";
|
|
18
|
-
import type { PostType } from "../types.js";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Post
|
|
22
|
-
* Based on
|
|
21
|
+
* Post format enum schema
|
|
22
|
+
* Based on FORMATS from types.ts
|
|
23
23
|
*/
|
|
24
|
-
export const
|
|
24
|
+
export const FormatSchema = z.enum(FORMATS);
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
* Based on
|
|
27
|
+
* Post status enum schema
|
|
28
|
+
* Based on STATUSES from types.ts
|
|
29
29
|
*/
|
|
30
|
-
export const
|
|
30
|
+
export const StatusSchema = z.enum(STATUSES);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Collection sort order enum schema
|
|
34
|
+
*/
|
|
35
|
+
export const SortOrderSchema = z.enum(SORT_ORDERS);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Navigation item type enum schema
|
|
39
|
+
*/
|
|
40
|
+
export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
|
|
31
41
|
|
|
32
42
|
/**
|
|
33
43
|
* Redirect type enum schema
|
|
@@ -35,21 +45,45 @@ export const VisibilitySchema = z.enum(VISIBILITY_LEVELS);
|
|
|
35
45
|
*/
|
|
36
46
|
export const RedirectTypeSchema = z.enum(["301", "302"]);
|
|
37
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Rating schema (1-5 integer)
|
|
50
|
+
*/
|
|
51
|
+
export const RatingSchema = z.coerce
|
|
52
|
+
.number()
|
|
53
|
+
.int()
|
|
54
|
+
.min(1)
|
|
55
|
+
.max(5)
|
|
56
|
+
.optional()
|
|
57
|
+
.or(z.literal("").transform(() => undefined));
|
|
58
|
+
|
|
38
59
|
/**
|
|
39
60
|
* API request body schema for creating a post
|
|
40
61
|
*/
|
|
41
62
|
export const CreatePostSchema = z.object({
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
content: z.string(),
|
|
45
|
-
visibility: VisibilitySchema,
|
|
46
|
-
sourceUrl: z.url().optional().or(z.literal("")),
|
|
47
|
-
sourceName: z.string().optional(),
|
|
48
|
-
path: z
|
|
63
|
+
format: FormatSchema,
|
|
64
|
+
slug: z
|
|
49
65
|
.string()
|
|
50
|
-
.regex(/^[a-z0-9-]
|
|
66
|
+
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/)
|
|
67
|
+
.optional()
|
|
68
|
+
.or(z.literal("").transform(() => undefined)),
|
|
69
|
+
title: z.string().optional(),
|
|
70
|
+
body: z.string().optional(),
|
|
71
|
+
status: StatusSchema.optional(),
|
|
72
|
+
featured: z
|
|
73
|
+
.union([z.boolean(), z.literal("on").transform(() => true)])
|
|
74
|
+
.optional(),
|
|
75
|
+
pinned: z
|
|
76
|
+
.union([z.boolean(), z.literal("on").transform(() => true)])
|
|
77
|
+
.optional(),
|
|
78
|
+
url: z.url().optional().or(z.literal("")),
|
|
79
|
+
quoteText: z.string().optional(),
|
|
80
|
+
rating: RatingSchema,
|
|
81
|
+
collectionId: z.coerce
|
|
82
|
+
.number()
|
|
83
|
+
.int()
|
|
84
|
+
.positive()
|
|
51
85
|
.optional()
|
|
52
|
-
.or(z.literal("")),
|
|
86
|
+
.or(z.literal("").transform(() => undefined)),
|
|
53
87
|
replyToId: z.string().optional(), // Sqid format
|
|
54
88
|
publishedAt: z.number().int().positive().optional(),
|
|
55
89
|
mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
|
|
@@ -60,13 +94,70 @@ export const CreatePostSchema = z.object({
|
|
|
60
94
|
*/
|
|
61
95
|
export const UpdatePostSchema = CreatePostSchema.partial();
|
|
62
96
|
|
|
97
|
+
/**
|
|
98
|
+
* API request body schema for creating a page
|
|
99
|
+
*/
|
|
100
|
+
export const CreatePageSchema = z.object({
|
|
101
|
+
slug: z
|
|
102
|
+
.string()
|
|
103
|
+
.min(1)
|
|
104
|
+
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
|
|
105
|
+
title: z.string().optional(),
|
|
106
|
+
body: z.string().optional(),
|
|
107
|
+
status: StatusSchema.optional(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* API request body schema for updating a page
|
|
112
|
+
*/
|
|
113
|
+
export const UpdatePageSchema = CreatePageSchema.partial();
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* API request body schema for creating a navigation item
|
|
117
|
+
*/
|
|
118
|
+
export const CreateNavItemSchema = z.object({
|
|
119
|
+
type: NavItemTypeSchema,
|
|
120
|
+
label: z.string().min(1),
|
|
121
|
+
url: z.string().min(1),
|
|
122
|
+
pageId: z.coerce.number().int().positive().optional(),
|
|
123
|
+
position: z.coerce.number().int().min(0).optional(),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* API request body schema for updating a navigation item
|
|
128
|
+
*/
|
|
129
|
+
export const UpdateNavItemSchema = CreateNavItemSchema.partial();
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* API request body schema for creating a collection
|
|
133
|
+
*/
|
|
134
|
+
export const CreateCollectionSchema = z.object({
|
|
135
|
+
slug: z
|
|
136
|
+
.string()
|
|
137
|
+
.min(1)
|
|
138
|
+
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
|
|
139
|
+
title: z.string().min(1),
|
|
140
|
+
description: z.string().optional(),
|
|
141
|
+
icon: z.string().optional(),
|
|
142
|
+
sortOrder: SortOrderSchema.optional(),
|
|
143
|
+
position: z.coerce.number().int().min(0).optional(),
|
|
144
|
+
showDivider: z
|
|
145
|
+
.union([z.boolean(), z.literal("on").transform(() => true)])
|
|
146
|
+
.optional(),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* API request body schema for updating a collection
|
|
151
|
+
*/
|
|
152
|
+
export const UpdateCollectionSchema = CreateCollectionSchema.partial();
|
|
153
|
+
|
|
63
154
|
/**
|
|
64
155
|
* Form data helper: safely parse a FormData value with a schema
|
|
65
156
|
*
|
|
66
157
|
* @example
|
|
67
158
|
* ```ts
|
|
68
|
-
* const
|
|
69
|
-
* //
|
|
159
|
+
* const format = parseFormData(formData, "format", FormatSchema);
|
|
160
|
+
* // format is Format, throws if invalid
|
|
70
161
|
* ```
|
|
71
162
|
*/
|
|
72
163
|
export function parseFormData<T>(
|
|
@@ -103,40 +194,15 @@ export function parseFormDataOptional<T>(
|
|
|
103
194
|
}
|
|
104
195
|
|
|
105
196
|
/**
|
|
106
|
-
* Validates media attachment count
|
|
197
|
+
* Validates media attachment count for a post.
|
|
198
|
+
* All formats allow 0-20 media attachments.
|
|
107
199
|
*
|
|
108
|
-
* @param type - The post type to validate against
|
|
109
200
|
* @param mediaIds - Array of media IDs to attach
|
|
110
201
|
* @returns null if valid, error string if invalid
|
|
111
|
-
*
|
|
112
|
-
* @example
|
|
113
|
-
* ```ts
|
|
114
|
-
* const error = validateMediaForPostType("image", []);
|
|
115
|
-
* // Returns: "image posts require at least 1 media attachment"
|
|
116
|
-
* ```
|
|
117
202
|
*/
|
|
118
|
-
export function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
): string | null {
|
|
122
|
-
const rules = POST_TYPE_MEDIA_RULES[type];
|
|
123
|
-
|
|
124
|
-
if (rules === null) {
|
|
125
|
-
if (mediaIds.length > 0) {
|
|
126
|
-
return `${type} posts do not allow media attachments`;
|
|
127
|
-
}
|
|
128
|
-
return null;
|
|
203
|
+
export function validateMediaCount(mediaIds: string[]): string | null {
|
|
204
|
+
if (mediaIds.length > MAX_MEDIA_ATTACHMENTS) {
|
|
205
|
+
return `Posts allow at most ${MAX_MEDIA_ATTACHMENTS} media attachments`;
|
|
129
206
|
}
|
|
130
|
-
|
|
131
|
-
const [min, max] = rules;
|
|
132
|
-
|
|
133
|
-
if (mediaIds.length < min) {
|
|
134
|
-
return `${type} posts require at least ${min} media attachment${min !== 1 ? "s" : ""}`;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (mediaIds.length > max) {
|
|
138
|
-
return `${type} posts allow at most ${max} media attachment${max !== 1 ? "s" : ""}`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
207
|
return null;
|
|
142
208
|
}
|
|
@@ -5,15 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { FC } from "hono/jsx";
|
|
8
|
-
import type {
|
|
8
|
+
import type { Format, ThemeComponents, TimelineCardProps } from "../types.js";
|
|
9
9
|
|
|
10
|
-
const THEME_KEY_MAP: Record<
|
|
10
|
+
const THEME_KEY_MAP: Record<Format, keyof ThemeComponents> = {
|
|
11
11
|
note: "NoteCard",
|
|
12
|
-
article: "ArticleCard",
|
|
13
12
|
link: "LinkCard",
|
|
14
13
|
quote: "QuoteCard",
|
|
15
|
-
image: "ImageCard",
|
|
16
|
-
page: "NoteCard",
|
|
17
14
|
};
|
|
18
15
|
|
|
19
16
|
/**
|
|
@@ -43,26 +40,26 @@ export function resolveComponent<K extends keyof ThemeComponents>(
|
|
|
43
40
|
}
|
|
44
41
|
|
|
45
42
|
/**
|
|
46
|
-
* Resolves the card component for a given post
|
|
43
|
+
* Resolves the card component for a given post format.
|
|
47
44
|
*
|
|
48
45
|
* Checks theme overrides first, then falls back to the provided default card component.
|
|
49
46
|
*
|
|
50
|
-
* @param
|
|
51
|
-
* @param defaults - Map of
|
|
47
|
+
* @param format - The post format to resolve a card for
|
|
48
|
+
* @param defaults - Map of format to default card component
|
|
52
49
|
* @param themeComponents - Optional theme component overrides
|
|
53
50
|
* @returns The resolved card component
|
|
54
51
|
*
|
|
55
52
|
* @example
|
|
56
53
|
* ```ts
|
|
57
|
-
* const Card = resolveCardComponent("
|
|
54
|
+
* const Card = resolveCardComponent("note", DEFAULT_CARD_MAP, c.var.config.theme?.components);
|
|
58
55
|
* ```
|
|
59
56
|
*/
|
|
60
57
|
export function resolveCardComponent(
|
|
61
|
-
|
|
62
|
-
defaults: Record<
|
|
58
|
+
format: Format,
|
|
59
|
+
defaults: Record<Format, FC<TimelineCardProps>>,
|
|
63
60
|
themeComponents?: ThemeComponents,
|
|
64
61
|
): FC<TimelineCardProps> {
|
|
65
|
-
const key = THEME_KEY_MAP[
|
|
62
|
+
const key = THEME_KEY_MAP[format];
|
|
66
63
|
const override = themeComponents?.[key] as FC<TimelineCardProps> | undefined;
|
|
67
|
-
return override ?? defaults[
|
|
64
|
+
return override ?? defaults[format];
|
|
68
65
|
}
|
package/src/lib/time.ts
CHANGED
|
@@ -109,6 +109,70 @@ export function formatDate(timestamp: number): string {
|
|
|
109
109
|
* // Returns: "2024-02"
|
|
110
110
|
* ```
|
|
111
111
|
*/
|
|
112
|
+
/**
|
|
113
|
+
* Formats a Unix timestamp as a 24-hour time string (HH:MM).
|
|
114
|
+
*
|
|
115
|
+
* Converts a Unix timestamp (in seconds) to a zero-padded time string in
|
|
116
|
+
* 24-hour format. Always uses UTC timezone for consistency.
|
|
117
|
+
*
|
|
118
|
+
* @param timestamp - Unix timestamp in seconds to format
|
|
119
|
+
* @returns Formatted time string in "HH:MM" format
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* const time = formatTime(1706745600);
|
|
124
|
+
* // Returns: "00:00"
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export function formatTime(timestamp: number): string {
|
|
128
|
+
const date = new Date(timestamp * 1000);
|
|
129
|
+
const hours = String(date.getUTCHours()).padStart(2, "0");
|
|
130
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
|
|
131
|
+
return `${hours}:${minutes}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Formats a Unix timestamp as a short relative time string.
|
|
136
|
+
*
|
|
137
|
+
* Returns compact labels like "1m", "5h", "3d" for recent timestamps,
|
|
138
|
+
* and falls back to "MMM D" (e.g. "Feb 1") for anything older than 7 days.
|
|
139
|
+
*
|
|
140
|
+
* @param timestamp - Unix timestamp in seconds
|
|
141
|
+
* @returns Short relative time string
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* // Assuming current time is Feb 16, 2026
|
|
146
|
+
* formatRelativeTime(now() - 30); // "1m" (30 seconds → rounds up)
|
|
147
|
+
* formatRelativeTime(now() - 3600); // "1h"
|
|
148
|
+
* formatRelativeTime(now() - 86400); // "1d"
|
|
149
|
+
* formatRelativeTime(now() - 604800); // "7d"
|
|
150
|
+
* formatRelativeTime(now() - 864000); // "Feb 6"
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export function formatRelativeTime(timestamp: number): string {
|
|
154
|
+
const seconds = now() - timestamp;
|
|
155
|
+
|
|
156
|
+
if (seconds < 60) return "1m";
|
|
157
|
+
|
|
158
|
+
const minutes = Math.floor(seconds / 60);
|
|
159
|
+
if (minutes < 60) return `${minutes}m`;
|
|
160
|
+
|
|
161
|
+
const hours = Math.floor(seconds / 3600);
|
|
162
|
+
if (hours < 24) return `${hours}h`;
|
|
163
|
+
|
|
164
|
+
const days = Math.floor(seconds / 86400);
|
|
165
|
+
if (days <= 7) return `${days}d`;
|
|
166
|
+
|
|
167
|
+
// Older than 7 days: show "MMM D" (e.g. "Feb 1")
|
|
168
|
+
const date = new Date(timestamp * 1000);
|
|
169
|
+
return date.toLocaleDateString("en-US", {
|
|
170
|
+
month: "short",
|
|
171
|
+
day: "numeric",
|
|
172
|
+
timeZone: "UTC",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
112
176
|
export function formatYearMonth(timestamp: number): string {
|
|
113
177
|
const date = new Date(timestamp * 1000);
|
|
114
178
|
const year = date.getUTCFullYear();
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline Data Assembly
|
|
3
|
+
*
|
|
4
|
+
* Shared helper for assembling timeline items with media and thread previews.
|
|
5
|
+
* Used by both full-page rendering and load-more SSE responses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from "hono";
|
|
9
|
+
import type { Bindings, TimelineItemView, DateGroup } from "../types.js";
|
|
10
|
+
import type { AppVariables } from "../app.js";
|
|
11
|
+
import { buildMediaMap } from "./media-helpers.js";
|
|
12
|
+
import { createMediaContext, toPostView, toPostViews } from "./view.js";
|
|
13
|
+
|
|
14
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Result from assembling a timeline page.
|
|
20
|
+
*/
|
|
21
|
+
export interface TimelineResult {
|
|
22
|
+
items: TimelineItemView[];
|
|
23
|
+
hasMore: boolean;
|
|
24
|
+
nextCursor?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Assembles a page of timeline items with media attachments and thread previews.
|
|
29
|
+
*
|
|
30
|
+
* Fetches posts, batch-loads media, identifies threads, and returns
|
|
31
|
+
* render-ready `TimelineItemView[]` with pagination info.
|
|
32
|
+
*
|
|
33
|
+
* @param c - Hono context (provides services + env)
|
|
34
|
+
* @param options - Optional cursor for pagination
|
|
35
|
+
* @returns Assembled timeline items with pagination info
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const { items, hasMore, nextCursor } = await assembleTimeline(c);
|
|
40
|
+
* const { items, hasMore, nextCursor } = await assembleTimeline(c, { cursor: 42 });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export async function assembleTimeline(
|
|
44
|
+
c: Context<Env>,
|
|
45
|
+
options?: { cursor?: number },
|
|
46
|
+
): Promise<TimelineResult> {
|
|
47
|
+
const pageSize =
|
|
48
|
+
parseInt(c.env.PAGE_SIZE ?? String(DEFAULT_PAGE_SIZE), 10) ||
|
|
49
|
+
DEFAULT_PAGE_SIZE;
|
|
50
|
+
|
|
51
|
+
// Fetch one extra to determine if there are more
|
|
52
|
+
const posts = await c.var.services.posts.list({
|
|
53
|
+
status: "published",
|
|
54
|
+
excludeReplies: true,
|
|
55
|
+
limit: pageSize + 1,
|
|
56
|
+
cursor: options?.cursor,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const hasMore = posts.length > pageSize;
|
|
60
|
+
const displayPosts = hasMore ? posts.slice(0, pageSize) : posts;
|
|
61
|
+
|
|
62
|
+
if (displayPosts.length === 0) {
|
|
63
|
+
return { items: [], hasMore: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Batch load media attachments
|
|
67
|
+
const postIds = displayPosts.map((p) => p.id);
|
|
68
|
+
const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
69
|
+
const mediaCtx = createMediaContext(c);
|
|
70
|
+
const mediaMap = buildMediaMap(
|
|
71
|
+
rawMediaMap,
|
|
72
|
+
mediaCtx.r2PublicUrl,
|
|
73
|
+
mediaCtx.imageTransformUrl,
|
|
74
|
+
mediaCtx.s3PublicUrl,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Get reply counts to identify thread roots
|
|
78
|
+
const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
|
|
79
|
+
const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
|
|
80
|
+
|
|
81
|
+
// Batch load thread previews
|
|
82
|
+
const threadPreviews = await c.var.services.posts.getThreadPreviews(
|
|
83
|
+
threadRootIds,
|
|
84
|
+
3,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Batch load media for preview replies
|
|
88
|
+
const previewReplyIds: number[] = [];
|
|
89
|
+
for (const replies of threadPreviews.values()) {
|
|
90
|
+
for (const reply of replies) {
|
|
91
|
+
previewReplyIds.push(reply.id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const previewMediaMap =
|
|
95
|
+
previewReplyIds.length > 0
|
|
96
|
+
? buildMediaMap(
|
|
97
|
+
await c.var.services.media.getByPostIds(previewReplyIds),
|
|
98
|
+
mediaCtx.r2PublicUrl,
|
|
99
|
+
mediaCtx.imageTransformUrl,
|
|
100
|
+
mediaCtx.s3PublicUrl,
|
|
101
|
+
)
|
|
102
|
+
: new Map();
|
|
103
|
+
|
|
104
|
+
// Assemble timeline items with View Models
|
|
105
|
+
const items: TimelineItemView[] = displayPosts.map((post) => {
|
|
106
|
+
const postView = toPostView(
|
|
107
|
+
{ ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
|
|
108
|
+
mediaCtx,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const replyCount = replyCounts.get(post.id) ?? 0;
|
|
112
|
+
const previewReplies = threadPreviews.get(post.id);
|
|
113
|
+
|
|
114
|
+
if (replyCount > 0 && previewReplies) {
|
|
115
|
+
return {
|
|
116
|
+
post: postView,
|
|
117
|
+
threadPreview: {
|
|
118
|
+
replies: toPostViews(
|
|
119
|
+
previewReplies.map((r) => ({
|
|
120
|
+
...r,
|
|
121
|
+
mediaAttachments: previewMediaMap.get(r.id) ?? [],
|
|
122
|
+
})),
|
|
123
|
+
mediaCtx,
|
|
124
|
+
),
|
|
125
|
+
totalReplyCount: replyCount,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { post: postView };
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Determine next cursor
|
|
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;
|
|
170
|
+
}
|