@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.
Files changed (99) hide show
  1. package/dist/app.js +23 -4
  2. package/dist/index.js +11 -4
  3. package/dist/lib/feed.js +112 -0
  4. package/dist/lib/navigation.js +9 -9
  5. package/dist/lib/render.js +48 -0
  6. package/dist/lib/theme-components.js +18 -18
  7. package/dist/lib/view.js +228 -0
  8. package/dist/routes/api/timeline.js +22 -18
  9. package/dist/routes/feed/rss.js +34 -78
  10. package/dist/routes/feed/sitemap.js +11 -26
  11. package/dist/routes/pages/archive.js +18 -195
  12. package/dist/routes/pages/collection.js +16 -70
  13. package/dist/routes/pages/home.js +25 -47
  14. package/dist/routes/pages/page.js +15 -27
  15. package/dist/routes/pages/post.js +25 -79
  16. package/dist/routes/pages/search.js +20 -130
  17. package/dist/theme/components/MediaGallery.js +10 -10
  18. package/dist/theme/components/index.js +0 -2
  19. package/dist/theme/index.js +10 -13
  20. package/dist/theme/layouts/index.js +0 -1
  21. package/dist/themes/minimal/MinimalSiteLayout.js +83 -0
  22. package/dist/themes/minimal/index.js +65 -0
  23. package/dist/themes/minimal/pages/ArchivePage.js +156 -0
  24. package/dist/themes/minimal/pages/CollectionPage.js +65 -0
  25. package/dist/themes/minimal/pages/HomePage.js +25 -0
  26. package/dist/themes/minimal/pages/PostPage.js +47 -0
  27. package/dist/themes/minimal/pages/SearchPage.js +121 -0
  28. package/dist/themes/minimal/pages/SinglePage.js +22 -0
  29. package/dist/themes/minimal/timeline/ArticleCard.js +36 -0
  30. package/dist/themes/minimal/timeline/ImageCard.js +67 -0
  31. package/dist/themes/minimal/timeline/LinkCard.js +47 -0
  32. package/dist/themes/minimal/timeline/NoteCard.js +34 -0
  33. package/dist/{theme/components → themes/minimal}/timeline/QuoteCard.js +9 -12
  34. package/dist/themes/minimal/timeline/ThreadPreview.js +46 -0
  35. package/dist/themes/minimal/timeline/TimelineFeed.js +48 -0
  36. package/dist/themes/minimal/timeline/TimelineItem.js +44 -0
  37. package/package.json +2 -1
  38. package/src/app.tsx +27 -4
  39. package/src/i18n/locales/en.po +53 -53
  40. package/src/i18n/locales/zh-Hans.po +53 -53
  41. package/src/i18n/locales/zh-Hant.po +53 -53
  42. package/src/index.ts +54 -6
  43. package/src/lib/__tests__/theme-components.test.ts +33 -14
  44. package/src/lib/__tests__/view.test.ts +377 -0
  45. package/src/lib/feed.ts +148 -0
  46. package/src/lib/navigation.ts +11 -11
  47. package/src/lib/render.tsx +67 -0
  48. package/src/lib/theme-components.ts +27 -35
  49. package/src/lib/view.ts +318 -0
  50. package/src/routes/api/__tests__/timeline.test.ts +3 -3
  51. package/src/routes/api/timeline.tsx +34 -27
  52. package/src/routes/feed/rss.ts +47 -94
  53. package/src/routes/feed/sitemap.ts +8 -30
  54. package/src/routes/pages/archive.tsx +24 -209
  55. package/src/routes/pages/collection.tsx +19 -75
  56. package/src/routes/pages/home.tsx +42 -76
  57. package/src/routes/pages/page.tsx +17 -28
  58. package/src/routes/pages/post.tsx +28 -86
  59. package/src/routes/pages/search.tsx +29 -151
  60. package/src/services/search.ts +2 -8
  61. package/src/styles/components.css +0 -54
  62. package/src/theme/components/MediaGallery.tsx +12 -12
  63. package/src/theme/components/index.ts +0 -12
  64. package/src/theme/index.ts +11 -13
  65. package/src/theme/layouts/index.ts +1 -1
  66. package/src/themes/minimal/MinimalSiteLayout.tsx +100 -0
  67. package/src/themes/minimal/index.ts +83 -0
  68. package/src/themes/minimal/pages/ArchivePage.tsx +157 -0
  69. package/src/themes/minimal/pages/CollectionPage.tsx +60 -0
  70. package/src/themes/minimal/pages/HomePage.tsx +41 -0
  71. package/src/themes/minimal/pages/PostPage.tsx +43 -0
  72. package/src/themes/minimal/pages/SearchPage.tsx +122 -0
  73. package/src/themes/minimal/pages/SinglePage.tsx +23 -0
  74. package/src/themes/minimal/timeline/ArticleCard.tsx +37 -0
  75. package/src/themes/minimal/timeline/ImageCard.tsx +63 -0
  76. package/src/themes/minimal/timeline/LinkCard.tsx +48 -0
  77. package/src/themes/minimal/timeline/NoteCard.tsx +35 -0
  78. package/src/{theme/components → themes/minimal}/timeline/QuoteCard.tsx +11 -17
  79. package/src/themes/minimal/timeline/ThreadPreview.tsx +47 -0
  80. package/src/{theme/components → themes/minimal}/timeline/TimelineFeed.tsx +20 -15
  81. package/src/themes/minimal/timeline/TimelineItem.tsx +75 -0
  82. package/src/types.ts +262 -38
  83. package/dist/theme/components/timeline/ArticleCard.js +0 -50
  84. package/dist/theme/components/timeline/ImageCard.js +0 -86
  85. package/dist/theme/components/timeline/LinkCard.js +0 -62
  86. package/dist/theme/components/timeline/NoteCard.js +0 -37
  87. package/dist/theme/components/timeline/ThreadPreview.js +0 -52
  88. package/dist/theme/components/timeline/TimelineFeed.js +0 -43
  89. package/dist/theme/components/timeline/TimelineItem.js +0 -25
  90. package/dist/theme/components/timeline/index.js +0 -8
  91. package/dist/theme/layouts/SiteLayout.js +0 -160
  92. package/src/theme/components/timeline/ArticleCard.tsx +0 -57
  93. package/src/theme/components/timeline/ImageCard.tsx +0 -80
  94. package/src/theme/components/timeline/LinkCard.tsx +0 -66
  95. package/src/theme/components/timeline/NoteCard.tsx +0 -41
  96. package/src/theme/components/timeline/ThreadPreview.tsx +0 -49
  97. package/src/theme/components/timeline/TimelineItem.tsx +0 -39
  98. package/src/theme/components/timeline/index.ts +0 -8
  99. 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
- }
@@ -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, TimelineItemData } from "../../../types.js";
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: TimelineItemData[] = posts.map((p) => ({
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: TimelineItemData[] = posts.map((post) => {
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, PostWithMedia, TimelineItemData } from "../../types.js";
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 "../../theme/components/timeline/TimelineItem.js";
13
- import { ThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
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 r2PublicUrl = c.env.R2_PUBLIC_URL;
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: TimelineItemData[] = displayPosts.map((post) => {
89
- const postWithMedia: PostWithMedia = {
90
- ...post,
91
- mediaAttachments: mediaMap.get(post.id) ?? [],
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: postWithMedia,
98
+ post: postView,
100
99
  threadPreview: {
101
- replies: previewReplies.map((r) => ({
102
- ...r,
103
- mediaAttachments: previewMediaMap.get(r.id) ?? [],
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: postWithMedia };
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
- <ThreadPreview
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-6 text-center"><button class="btn btn-outline" data-on:click="@get('/api/timeline?cursor=${nextCursor}')">Load more</button></div>`
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) => {