@jant/core 0.3.21 → 0.3.23
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 +23 -4
- package/dist/index.js +11 -4
- package/dist/lib/feed.js +112 -0
- package/dist/lib/navigation.js +9 -9
- package/dist/lib/render.js +48 -0
- package/dist/lib/theme-components.js +18 -18
- package/dist/lib/view.js +228 -0
- package/dist/routes/api/timeline.js +22 -18
- package/dist/routes/feed/rss.js +34 -78
- package/dist/routes/feed/sitemap.js +11 -26
- package/dist/routes/pages/archive.js +18 -195
- package/dist/routes/pages/collection.js +16 -70
- package/dist/routes/pages/home.js +25 -47
- package/dist/routes/pages/page.js +15 -27
- package/dist/routes/pages/post.js +25 -79
- package/dist/routes/pages/search.js +20 -130
- package/dist/theme/components/MediaGallery.js +10 -10
- package/dist/theme/components/index.js +0 -2
- package/dist/theme/index.js +10 -13
- package/dist/theme/layouts/index.js +0 -1
- package/dist/themes/minimal/MinimalSiteLayout.js +83 -0
- package/dist/themes/minimal/index.js +65 -0
- package/dist/themes/minimal/pages/ArchivePage.js +156 -0
- package/dist/themes/minimal/pages/CollectionPage.js +65 -0
- package/dist/themes/minimal/pages/HomePage.js +25 -0
- package/dist/themes/minimal/pages/PostPage.js +47 -0
- package/dist/themes/minimal/pages/SearchPage.js +121 -0
- package/dist/themes/minimal/pages/SinglePage.js +22 -0
- package/dist/themes/minimal/timeline/ArticleCard.js +36 -0
- package/dist/themes/minimal/timeline/ImageCard.js +67 -0
- package/dist/themes/minimal/timeline/LinkCard.js +47 -0
- package/dist/themes/minimal/timeline/NoteCard.js +34 -0
- package/dist/{theme/components → themes/minimal}/timeline/QuoteCard.js +9 -12
- package/dist/themes/minimal/timeline/ThreadPreview.js +46 -0
- package/dist/themes/minimal/timeline/TimelineFeed.js +48 -0
- package/dist/themes/minimal/timeline/TimelineItem.js +44 -0
- package/package.json +2 -1
- package/src/app.tsx +27 -4
- package/src/i18n/locales/en.po +53 -53
- package/src/i18n/locales/zh-Hans.po +53 -53
- package/src/i18n/locales/zh-Hant.po +53 -53
- package/src/index.ts +54 -6
- package/src/lib/__tests__/theme-components.test.ts +33 -14
- package/src/lib/__tests__/view.test.ts +377 -0
- package/src/lib/feed.ts +148 -0
- package/src/lib/navigation.ts +11 -11
- package/src/lib/render.tsx +67 -0
- package/src/lib/theme-components.ts +27 -35
- package/src/lib/view.ts +318 -0
- package/src/routes/api/__tests__/timeline.test.ts +3 -3
- package/src/routes/api/timeline.tsx +34 -27
- package/src/routes/feed/rss.ts +47 -94
- package/src/routes/feed/sitemap.ts +8 -30
- package/src/routes/pages/archive.tsx +24 -209
- package/src/routes/pages/collection.tsx +19 -75
- package/src/routes/pages/home.tsx +42 -76
- package/src/routes/pages/page.tsx +17 -28
- package/src/routes/pages/post.tsx +28 -86
- package/src/routes/pages/search.tsx +29 -151
- package/src/services/search.ts +2 -8
- package/src/styles/components.css +0 -54
- package/src/theme/components/MediaGallery.tsx +12 -12
- package/src/theme/components/index.ts +0 -12
- package/src/theme/index.ts +11 -13
- package/src/theme/layouts/index.ts +1 -1
- package/src/themes/minimal/MinimalSiteLayout.tsx +100 -0
- package/src/themes/minimal/index.ts +83 -0
- package/src/themes/minimal/pages/ArchivePage.tsx +157 -0
- package/src/themes/minimal/pages/CollectionPage.tsx +60 -0
- package/src/themes/minimal/pages/HomePage.tsx +41 -0
- package/src/themes/minimal/pages/PostPage.tsx +43 -0
- package/src/themes/minimal/pages/SearchPage.tsx +122 -0
- package/src/themes/minimal/pages/SinglePage.tsx +23 -0
- package/src/themes/minimal/timeline/ArticleCard.tsx +37 -0
- package/src/themes/minimal/timeline/ImageCard.tsx +63 -0
- package/src/themes/minimal/timeline/LinkCard.tsx +48 -0
- package/src/themes/minimal/timeline/NoteCard.tsx +35 -0
- package/src/{theme/components → themes/minimal}/timeline/QuoteCard.tsx +11 -17
- package/src/themes/minimal/timeline/ThreadPreview.tsx +47 -0
- package/src/{theme/components → themes/minimal}/timeline/TimelineFeed.tsx +20 -15
- package/src/themes/minimal/timeline/TimelineItem.tsx +75 -0
- package/src/types.ts +262 -38
- package/dist/theme/components/timeline/ArticleCard.js +0 -50
- package/dist/theme/components/timeline/ImageCard.js +0 -86
- package/dist/theme/components/timeline/LinkCard.js +0 -62
- package/dist/theme/components/timeline/NoteCard.js +0 -37
- package/dist/theme/components/timeline/ThreadPreview.js +0 -52
- package/dist/theme/components/timeline/TimelineFeed.js +0 -43
- package/dist/theme/components/timeline/TimelineItem.js +0 -25
- package/dist/theme/components/timeline/index.js +0 -8
- package/dist/theme/layouts/SiteLayout.js +0 -160
- package/src/theme/components/timeline/ArticleCard.tsx +0 -57
- package/src/theme/components/timeline/ImageCard.tsx +0 -80
- package/src/theme/components/timeline/LinkCard.tsx +0 -66
- package/src/theme/components/timeline/NoteCard.tsx +0 -41
- package/src/theme/components/timeline/ThreadPreview.tsx +0 -49
- package/src/theme/components/timeline/TimelineItem.tsx +0 -39
- package/src/theme/components/timeline/index.ts +0 -8
- package/src/theme/layouts/SiteLayout.tsx +0 -184
|
@@ -5,13 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { FC } from "hono/jsx";
|
|
8
|
-
import type {
|
|
9
|
-
PostType,
|
|
10
|
-
ThemeComponents,
|
|
11
|
-
TimelineCardProps,
|
|
12
|
-
ThreadPreviewProps,
|
|
13
|
-
TimelineFeedProps,
|
|
14
|
-
} from "../types.js";
|
|
8
|
+
import type { PostType, ThemeComponents, TimelineCardProps } from "../types.js";
|
|
15
9
|
|
|
16
10
|
const THEME_KEY_MAP: Record<PostType, keyof ThemeComponents> = {
|
|
17
11
|
note: "NoteCard",
|
|
@@ -22,6 +16,32 @@ const THEME_KEY_MAP: Record<PostType, keyof ThemeComponents> = {
|
|
|
22
16
|
page: "NoteCard",
|
|
23
17
|
};
|
|
24
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Generic component resolver.
|
|
21
|
+
*
|
|
22
|
+
* Looks up a component by key in `ThemeComponents` and falls back to the
|
|
23
|
+
* provided default component.
|
|
24
|
+
*
|
|
25
|
+
* @param key - ThemeComponents key to look up
|
|
26
|
+
* @param defaultComponent - Fallback component
|
|
27
|
+
* @param themeComponents - Optional theme component overrides
|
|
28
|
+
* @returns The resolved component
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* const Gallery = resolveComponent("MediaGallery", DefaultMediaGallery, theme);
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function resolveComponent<K extends keyof ThemeComponents>(
|
|
36
|
+
key: K,
|
|
37
|
+
defaultComponent: NonNullable<ThemeComponents[K]>,
|
|
38
|
+
themeComponents?: ThemeComponents,
|
|
39
|
+
): NonNullable<ThemeComponents[K]> {
|
|
40
|
+
return (themeComponents?.[key] ?? defaultComponent) as NonNullable<
|
|
41
|
+
ThemeComponents[K]
|
|
42
|
+
>;
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
/**
|
|
26
46
|
* Resolves the card component for a given post type.
|
|
27
47
|
*
|
|
@@ -46,31 +66,3 @@ export function resolveCardComponent(
|
|
|
46
66
|
const override = themeComponents?.[key] as FC<TimelineCardProps> | undefined;
|
|
47
67
|
return override ?? defaults[type];
|
|
48
68
|
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Resolves the ThreadPreview component.
|
|
52
|
-
*
|
|
53
|
-
* @param defaultComponent - The default ThreadPreview component
|
|
54
|
-
* @param themeComponents - Optional theme component overrides
|
|
55
|
-
* @returns The resolved ThreadPreview component
|
|
56
|
-
*/
|
|
57
|
-
export function resolveThreadPreview(
|
|
58
|
-
defaultComponent: FC<ThreadPreviewProps>,
|
|
59
|
-
themeComponents?: ThemeComponents,
|
|
60
|
-
): FC<ThreadPreviewProps> {
|
|
61
|
-
return themeComponents?.ThreadPreview ?? defaultComponent;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Resolves the TimelineFeed component.
|
|
66
|
-
*
|
|
67
|
-
* @param defaultComponent - The default TimelineFeed component
|
|
68
|
-
* @param themeComponents - Optional theme component overrides
|
|
69
|
-
* @returns The resolved TimelineFeed component
|
|
70
|
-
*/
|
|
71
|
-
export function resolveTimelineFeed(
|
|
72
|
-
defaultComponent: FC<TimelineFeedProps>,
|
|
73
|
-
themeComponents?: ThemeComponents,
|
|
74
|
-
): FC<TimelineFeedProps> {
|
|
75
|
-
return themeComponents?.TimelineFeed ?? defaultComponent;
|
|
76
|
-
}
|
package/src/lib/view.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View Model Conversions
|
|
3
|
+
*
|
|
4
|
+
* Transforms raw database models into render-ready View types.
|
|
5
|
+
* Theme components receive only View types — no lib/ imports needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from "hono";
|
|
9
|
+
import type {
|
|
10
|
+
Post,
|
|
11
|
+
PostWithMedia,
|
|
12
|
+
Media,
|
|
13
|
+
MediaView,
|
|
14
|
+
PostView,
|
|
15
|
+
NavLinkView,
|
|
16
|
+
NavigationLink,
|
|
17
|
+
SearchResult,
|
|
18
|
+
SearchResultView,
|
|
19
|
+
ArchiveGroup,
|
|
20
|
+
} from "../types.js";
|
|
21
|
+
import { encode } from "./sqid.js";
|
|
22
|
+
import { toISOString, formatDate } from "./time.js";
|
|
23
|
+
import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Media Context
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Central media config — extracted once per request from env.
|
|
31
|
+
*/
|
|
32
|
+
export interface MediaContext {
|
|
33
|
+
r2PublicUrl?: string;
|
|
34
|
+
imageTransformUrl?: string;
|
|
35
|
+
s3PublicUrl?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a MediaContext from Hono context environment variables.
|
|
40
|
+
*
|
|
41
|
+
* @param c - Hono context
|
|
42
|
+
* @returns MediaContext with env values
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* const mediaCtx = createMediaContext(c);
|
|
47
|
+
* const postView = toPostView(post, mediaCtx);
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function createMediaContext(c: Context): MediaContext {
|
|
51
|
+
return {
|
|
52
|
+
r2PublicUrl: c.env.R2_PUBLIC_URL,
|
|
53
|
+
imageTransformUrl: c.env.IMAGE_TRANSFORM_URL,
|
|
54
|
+
s3PublicUrl: c.env.S3_PUBLIC_URL,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Media Conversions
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Converts a raw Media record to a render-ready MediaView.
|
|
64
|
+
*
|
|
65
|
+
* @param media - Raw media record from database
|
|
66
|
+
* @param ctx - Media context with URL configuration
|
|
67
|
+
* @returns Render-ready MediaView with pre-computed URLs
|
|
68
|
+
*/
|
|
69
|
+
export function toMediaView(media: Media, ctx: MediaContext): MediaView {
|
|
70
|
+
const publicUrl = getPublicUrlForProvider(
|
|
71
|
+
media.provider,
|
|
72
|
+
ctx.r2PublicUrl,
|
|
73
|
+
ctx.s3PublicUrl,
|
|
74
|
+
);
|
|
75
|
+
const url = getMediaUrl(media.id, media.storageKey, publicUrl);
|
|
76
|
+
const thumbnailUrl = getImageUrl(url, ctx.imageTransformUrl, {
|
|
77
|
+
width: 400,
|
|
78
|
+
quality: 80,
|
|
79
|
+
format: "auto",
|
|
80
|
+
fit: "cover",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
id: media.id,
|
|
85
|
+
url,
|
|
86
|
+
thumbnailUrl,
|
|
87
|
+
mimeType: media.mimeType,
|
|
88
|
+
altText: media.alt ?? undefined,
|
|
89
|
+
width: media.width ?? undefined,
|
|
90
|
+
height: media.height ?? undefined,
|
|
91
|
+
size: media.size,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// =============================================================================
|
|
96
|
+
// Post Conversions
|
|
97
|
+
// =============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Converts a PostWithMedia to a render-ready PostView.
|
|
101
|
+
*
|
|
102
|
+
* @param post - Post with media attachments from database
|
|
103
|
+
* @param ctx - Media context with URL configuration
|
|
104
|
+
* @returns Render-ready PostView with pre-computed fields
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* const mediaCtx = createMediaContext(c);
|
|
109
|
+
* const postView = toPostView({ ...post, mediaAttachments: [...] }, mediaCtx);
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
|
|
113
|
+
const permalink = `/p/${encode(post.id)}`;
|
|
114
|
+
|
|
115
|
+
// Pre-compute excerpt from raw content
|
|
116
|
+
let excerpt: string | undefined;
|
|
117
|
+
if (post.content) {
|
|
118
|
+
excerpt =
|
|
119
|
+
post.content.length > 160
|
|
120
|
+
? post.content.slice(0, 160) + "..."
|
|
121
|
+
: post.content;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Convert media attachments
|
|
125
|
+
const media: MediaView[] = post.mediaAttachments.map((m) => ({
|
|
126
|
+
id: m.id,
|
|
127
|
+
url: m.url,
|
|
128
|
+
thumbnailUrl: m.previewUrl,
|
|
129
|
+
mimeType: m.mimeType,
|
|
130
|
+
altText: m.alt ?? undefined,
|
|
131
|
+
width: m.width ?? undefined,
|
|
132
|
+
height: m.height ?? undefined,
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
id: post.id,
|
|
137
|
+
permalink,
|
|
138
|
+
title: post.title ?? undefined,
|
|
139
|
+
contentHtml: post.contentHtml ?? undefined,
|
|
140
|
+
excerpt,
|
|
141
|
+
type: post.type,
|
|
142
|
+
visibility: post.visibility,
|
|
143
|
+
path: post.path ?? undefined,
|
|
144
|
+
publishedAt: toISOString(post.publishedAt),
|
|
145
|
+
publishedAtFormatted: formatDate(post.publishedAt),
|
|
146
|
+
updatedAt: toISOString(post.updatedAt),
|
|
147
|
+
sourceUrl: post.sourceUrl ?? undefined,
|
|
148
|
+
sourceName: post.sourceName ?? undefined,
|
|
149
|
+
sourceDomain: post.sourceDomain ?? undefined,
|
|
150
|
+
media,
|
|
151
|
+
replyToId: post.replyToId ?? undefined,
|
|
152
|
+
threadRootId: post.threadId ?? undefined,
|
|
153
|
+
content: post.content ?? undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Batch converts PostWithMedia[] to PostView[].
|
|
159
|
+
*
|
|
160
|
+
* @param posts - Array of posts with media
|
|
161
|
+
* @param ctx - Media context
|
|
162
|
+
* @returns Array of PostView
|
|
163
|
+
*/
|
|
164
|
+
export function toPostViews(
|
|
165
|
+
posts: PostWithMedia[],
|
|
166
|
+
ctx: MediaContext,
|
|
167
|
+
): PostView[] {
|
|
168
|
+
return posts.map((p) => toPostView(p, ctx));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Converts a bare Post (no media) to a PostView with empty media array.
|
|
173
|
+
*
|
|
174
|
+
* @param post - Post without media
|
|
175
|
+
* @param ctx - Media context (unused but kept for consistency)
|
|
176
|
+
* @returns PostView with empty media
|
|
177
|
+
*/
|
|
178
|
+
export function toPostViewFromPost(post: Post, ctx: MediaContext): PostView {
|
|
179
|
+
return toPostView({ ...post, mediaAttachments: [] }, ctx);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Batch converts Post[] (no media) to PostView[].
|
|
184
|
+
*
|
|
185
|
+
* @param posts - Array of posts without media
|
|
186
|
+
* @param ctx - Media context
|
|
187
|
+
* @returns Array of PostView
|
|
188
|
+
*/
|
|
189
|
+
export function toPostViewsFromPosts(
|
|
190
|
+
posts: Post[],
|
|
191
|
+
ctx: MediaContext,
|
|
192
|
+
): PostView[] {
|
|
193
|
+
return posts.map((p) => toPostViewFromPost(p, ctx));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// =============================================================================
|
|
197
|
+
// Navigation Conversions
|
|
198
|
+
// =============================================================================
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Converts a NavigationLink to a NavLinkView with pre-computed state.
|
|
202
|
+
*
|
|
203
|
+
* @param link - Raw navigation link from database
|
|
204
|
+
* @param currentPath - Current page path for active state computation
|
|
205
|
+
* @returns NavLinkView with isActive and isExternal pre-computed
|
|
206
|
+
*/
|
|
207
|
+
export function toNavLinkView(
|
|
208
|
+
link: NavigationLink,
|
|
209
|
+
currentPath: string,
|
|
210
|
+
): NavLinkView {
|
|
211
|
+
const isExternal =
|
|
212
|
+
link.url.startsWith("http://") || link.url.startsWith("https://");
|
|
213
|
+
|
|
214
|
+
let isActive = false;
|
|
215
|
+
if (!isExternal) {
|
|
216
|
+
if (link.url === "/") {
|
|
217
|
+
isActive = currentPath === "/";
|
|
218
|
+
} else {
|
|
219
|
+
isActive =
|
|
220
|
+
currentPath === link.url || currentPath.startsWith(link.url + "/");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
id: link.id,
|
|
226
|
+
label: link.label,
|
|
227
|
+
url: link.url,
|
|
228
|
+
isActive,
|
|
229
|
+
isExternal,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Batch converts NavigationLink[] to NavLinkView[].
|
|
235
|
+
*
|
|
236
|
+
* @param links - Raw navigation links
|
|
237
|
+
* @param currentPath - Current page path
|
|
238
|
+
* @returns Array of NavLinkView
|
|
239
|
+
*/
|
|
240
|
+
export function toNavLinkViews(
|
|
241
|
+
links: NavigationLink[],
|
|
242
|
+
currentPath: string,
|
|
243
|
+
): NavLinkView[] {
|
|
244
|
+
return links.map((l) => toNavLinkView(l, currentPath));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// =============================================================================
|
|
248
|
+
// Search Result Conversions
|
|
249
|
+
// =============================================================================
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Converts a SearchResult to a SearchResultView with PostView.
|
|
253
|
+
*
|
|
254
|
+
* @param result - Raw search result
|
|
255
|
+
* @param ctx - Media context
|
|
256
|
+
* @returns SearchResultView with PostView
|
|
257
|
+
*/
|
|
258
|
+
export function toSearchResultView(
|
|
259
|
+
result: SearchResult,
|
|
260
|
+
ctx: MediaContext,
|
|
261
|
+
): SearchResultView {
|
|
262
|
+
return {
|
|
263
|
+
post: toPostViewFromPost(result.post, ctx),
|
|
264
|
+
rank: result.rank,
|
|
265
|
+
snippet: result.snippet,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Batch converts SearchResult[] to SearchResultView[].
|
|
271
|
+
*
|
|
272
|
+
* @param results - Raw search results
|
|
273
|
+
* @param ctx - Media context
|
|
274
|
+
* @returns Array of SearchResultView
|
|
275
|
+
*/
|
|
276
|
+
export function toSearchResultViews(
|
|
277
|
+
results: SearchResult[],
|
|
278
|
+
ctx: MediaContext,
|
|
279
|
+
): SearchResultView[] {
|
|
280
|
+
return results.map((r) => toSearchResultView(r, ctx));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// =============================================================================
|
|
284
|
+
// Archive Group Conversions
|
|
285
|
+
// =============================================================================
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Converts a grouped post map to typed ArchiveGroup[].
|
|
289
|
+
*
|
|
290
|
+
* @param grouped - Map of "YYYY-MM" keys to Post arrays
|
|
291
|
+
* @param ctx - Media context
|
|
292
|
+
* @returns Array of ArchiveGroup with pre-formatted labels
|
|
293
|
+
*/
|
|
294
|
+
export function toArchiveGroups(
|
|
295
|
+
grouped: Map<string, Post[]>,
|
|
296
|
+
ctx: MediaContext,
|
|
297
|
+
): ArchiveGroup[] {
|
|
298
|
+
const groups: ArchiveGroup[] = [];
|
|
299
|
+
for (const [yearMonth, posts] of grouped) {
|
|
300
|
+
const [year, month] = yearMonth.split("-");
|
|
301
|
+
if (!year || !month) continue;
|
|
302
|
+
|
|
303
|
+
// Format label like "February 2024"
|
|
304
|
+
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
|
|
305
|
+
const label = date.toLocaleDateString("en-US", {
|
|
306
|
+
year: "numeric",
|
|
307
|
+
month: "long",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
groups.push({
|
|
311
|
+
year,
|
|
312
|
+
month,
|
|
313
|
+
label,
|
|
314
|
+
posts: toPostViewsFromPosts(posts, ctx),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return groups;
|
|
318
|
+
}
|
|
@@ -13,7 +13,7 @@ import { createPostService } from "../../../services/post.js";
|
|
|
13
13
|
import { createMediaService } from "../../../services/media.js";
|
|
14
14
|
import { buildMediaMap } from "../../../lib/media-helpers.js";
|
|
15
15
|
import type { Database } from "../../../db/index.js";
|
|
16
|
-
import type { PostWithMedia
|
|
16
|
+
import type { PostWithMedia } from "../../../types.js";
|
|
17
17
|
|
|
18
18
|
describe("Timeline data assembly", () => {
|
|
19
19
|
let db: Database;
|
|
@@ -50,7 +50,7 @@ describe("Timeline data assembly", () => {
|
|
|
50
50
|
const mediaMap = buildMediaMap(rawMediaMap);
|
|
51
51
|
|
|
52
52
|
// Assemble items
|
|
53
|
-
const items
|
|
53
|
+
const items = posts.map((p) => ({
|
|
54
54
|
post: { ...p, mediaAttachments: mediaMap.get(p.id) ?? [] },
|
|
55
55
|
}));
|
|
56
56
|
|
|
@@ -102,7 +102,7 @@ describe("Timeline data assembly", () => {
|
|
|
102
102
|
const rawMediaMap = await mediaService.getByPostIds(postIds);
|
|
103
103
|
const mediaMap = buildMediaMap(rawMediaMap);
|
|
104
104
|
|
|
105
|
-
const items
|
|
105
|
+
const items = posts.map((post) => {
|
|
106
106
|
const postWithMedia: PostWithMedia = {
|
|
107
107
|
...post,
|
|
108
108
|
mediaAttachments: mediaMap.get(post.id) ?? [],
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Hono } from "hono";
|
|
8
|
-
import type { Bindings,
|
|
8
|
+
import type { Bindings, TimelineItemView } from "../../types.js";
|
|
9
9
|
import type { AppVariables } from "../../app.js";
|
|
10
10
|
import { sse } from "../../lib/sse.js";
|
|
11
11
|
import { buildMediaMap } from "../../lib/media-helpers.js";
|
|
12
|
-
import { TimelineItem } from "../../
|
|
13
|
-
import { ThreadPreview } from "../../
|
|
12
|
+
import { TimelineItem } from "../../themes/minimal/timeline/TimelineItem.js";
|
|
13
|
+
import { ThreadPreview as DefaultThreadPreview } from "../../themes/minimal/timeline/ThreadPreview.js";
|
|
14
|
+
import { createMediaContext, toPostView, toPostViews } from "../../lib/view.js";
|
|
14
15
|
|
|
15
16
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
16
17
|
|
|
@@ -47,14 +48,12 @@ timelineApiRoutes.get("/", async (c) => {
|
|
|
47
48
|
// Build media map
|
|
48
49
|
const postIds = displayPosts.map((p) => p.id);
|
|
49
50
|
const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
50
|
-
const
|
|
51
|
-
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
52
|
-
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
51
|
+
const mediaCtx = createMediaContext(c);
|
|
53
52
|
const mediaMap = buildMediaMap(
|
|
54
53
|
rawMediaMap,
|
|
55
|
-
r2PublicUrl,
|
|
56
|
-
imageTransformUrl,
|
|
57
|
-
s3PublicUrl,
|
|
54
|
+
mediaCtx.r2PublicUrl,
|
|
55
|
+
mediaCtx.imageTransformUrl,
|
|
56
|
+
mediaCtx.s3PublicUrl,
|
|
58
57
|
);
|
|
59
58
|
|
|
60
59
|
// Get reply counts to identify thread roots
|
|
@@ -78,51 +77,59 @@ timelineApiRoutes.get("/", async (c) => {
|
|
|
78
77
|
previewReplyIds.length > 0
|
|
79
78
|
? buildMediaMap(
|
|
80
79
|
await c.var.services.media.getByPostIds(previewReplyIds),
|
|
81
|
-
r2PublicUrl,
|
|
82
|
-
imageTransformUrl,
|
|
83
|
-
s3PublicUrl,
|
|
80
|
+
mediaCtx.r2PublicUrl,
|
|
81
|
+
mediaCtx.imageTransformUrl,
|
|
82
|
+
mediaCtx.s3PublicUrl,
|
|
84
83
|
)
|
|
85
84
|
: new Map();
|
|
86
85
|
|
|
87
|
-
// Assemble timeline items
|
|
88
|
-
const items:
|
|
89
|
-
const
|
|
90
|
-
...post,
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
// Assemble timeline items with View Models
|
|
87
|
+
const items: TimelineItemView[] = displayPosts.map((post) => {
|
|
88
|
+
const postView = toPostView(
|
|
89
|
+
{ ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
|
|
90
|
+
mediaCtx,
|
|
91
|
+
);
|
|
93
92
|
|
|
94
93
|
const replyCount = replyCounts.get(post.id) ?? 0;
|
|
95
94
|
const previewReplies = threadPreviews.get(post.id);
|
|
96
95
|
|
|
97
96
|
if (replyCount > 0 && previewReplies) {
|
|
98
97
|
return {
|
|
99
|
-
post:
|
|
98
|
+
post: postView,
|
|
100
99
|
threadPreview: {
|
|
101
|
-
replies:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
replies: toPostViews(
|
|
101
|
+
previewReplies.map((r) => ({
|
|
102
|
+
...r,
|
|
103
|
+
mediaAttachments: previewMediaMap.get(r.id) ?? [],
|
|
104
|
+
})),
|
|
105
|
+
mediaCtx,
|
|
106
|
+
),
|
|
105
107
|
totalReplyCount: replyCount,
|
|
106
108
|
},
|
|
107
109
|
};
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
return { post:
|
|
112
|
+
return { post: postView };
|
|
111
113
|
});
|
|
112
114
|
|
|
115
|
+
// Resolve theme components for card rendering
|
|
116
|
+
const theme = c.var.config.theme?.components;
|
|
117
|
+
const ResolvedThreadPreview = theme?.ThreadPreview ?? DefaultThreadPreview;
|
|
118
|
+
|
|
113
119
|
// Render items to HTML
|
|
114
120
|
const itemsHtml = items
|
|
115
121
|
.map((item) => {
|
|
116
122
|
if (item.threadPreview) {
|
|
117
123
|
return (
|
|
118
|
-
<
|
|
124
|
+
<ResolvedThreadPreview
|
|
119
125
|
rootPost={item.post}
|
|
120
126
|
previewReplies={item.threadPreview.replies}
|
|
121
127
|
totalReplyCount={item.threadPreview.totalReplyCount}
|
|
128
|
+
theme={theme}
|
|
122
129
|
/>
|
|
123
130
|
);
|
|
124
131
|
}
|
|
125
|
-
return <TimelineItem item={item} />;
|
|
132
|
+
return <TimelineItem item={item} theme={theme} />;
|
|
126
133
|
})
|
|
127
134
|
.map((jsx) => jsx.toString())
|
|
128
135
|
.join("");
|
|
@@ -133,7 +140,7 @@ timelineApiRoutes.get("/", async (c) => {
|
|
|
133
140
|
|
|
134
141
|
// Build load-more button HTML
|
|
135
142
|
const loadMoreHtml = nextCursor
|
|
136
|
-
? `<div id="load-more-container" class="mt-
|
|
143
|
+
? `<div id="load-more-container" class="mt-8 text-center"><button class="text-sm text-muted-foreground hover:text-foreground hover:underline" data-on:click="@get('/api/timeline?cursor=${nextCursor}')">Load more</button></div>`
|
|
137
144
|
: "";
|
|
138
145
|
|
|
139
146
|
return sse(c, async (stream) => {
|