@jant/core 0.3.22 → 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.
Files changed (178) hide show
  1. package/dist/app.js +23 -5
  2. package/dist/db/schema.js +72 -47
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +5 -6
  7. package/dist/lib/constants.js +1 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/navigation.js +4 -5
  11. package/dist/lib/render.js +1 -1
  12. package/dist/lib/schemas.js +80 -38
  13. package/dist/lib/theme-components.js +8 -11
  14. package/dist/lib/time.js +56 -1
  15. package/dist/lib/timeline.js +119 -0
  16. package/dist/lib/view.js +62 -73
  17. package/dist/routes/api/posts.js +29 -35
  18. package/dist/routes/api/search.js +5 -6
  19. package/dist/routes/api/upload.js +13 -13
  20. package/dist/routes/dash/collections.js +22 -40
  21. package/dist/routes/dash/index.js +2 -2
  22. package/dist/routes/dash/navigation.js +25 -24
  23. package/dist/routes/dash/pages.js +42 -57
  24. package/dist/routes/dash/posts.js +27 -35
  25. package/dist/routes/feed/rss.js +2 -4
  26. package/dist/routes/feed/sitemap.js +10 -7
  27. package/dist/routes/pages/archive.js +12 -11
  28. package/dist/routes/pages/collection.js +11 -5
  29. package/dist/routes/pages/home.js +53 -61
  30. package/dist/routes/pages/page.js +60 -29
  31. package/dist/routes/pages/post.js +5 -12
  32. package/dist/routes/pages/search.js +3 -4
  33. package/dist/services/collection.js +52 -64
  34. package/dist/services/index.js +5 -3
  35. package/dist/services/navigation.js +29 -53
  36. package/dist/services/page.js +80 -0
  37. package/dist/services/post.js +68 -69
  38. package/dist/services/search.js +24 -18
  39. package/dist/theme/components/MediaGallery.js +19 -91
  40. package/dist/theme/components/PageForm.js +15 -15
  41. package/dist/theme/components/PostForm.js +136 -129
  42. package/dist/theme/components/PostList.js +13 -8
  43. package/dist/theme/components/ThreadView.js +3 -3
  44. package/dist/theme/components/TypeBadge.js +3 -14
  45. package/dist/theme/components/VisibilityBadge.js +33 -23
  46. package/dist/theme/components/index.js +0 -2
  47. package/dist/theme/index.js +10 -16
  48. package/dist/theme/layouts/index.js +0 -1
  49. package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
  50. package/dist/themes/threads/index.js +81 -0
  51. package/dist/{theme → themes/threads}/pages/ArchivePage.js +31 -47
  52. package/dist/themes/threads/pages/CollectionPage.js +65 -0
  53. package/dist/{theme → themes/threads}/pages/HomePage.js +4 -5
  54. package/dist/{theme → themes/threads}/pages/PostPage.js +10 -8
  55. package/dist/{theme → themes/threads}/pages/SearchPage.js +8 -8
  56. package/dist/{theme → themes/threads}/pages/SinglePage.js +5 -6
  57. package/dist/{theme/components → themes/threads}/timeline/LinkCard.js +20 -11
  58. package/dist/themes/threads/timeline/NoteCard.js +53 -0
  59. package/dist/themes/threads/timeline/QuoteCard.js +59 -0
  60. package/dist/{theme/components → themes/threads}/timeline/ThreadPreview.js +5 -6
  61. package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
  62. package/dist/{theme/components → themes/threads}/timeline/TimelineItem.js +8 -17
  63. package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
  64. package/dist/themes/threads/timeline/groupByDate.js +22 -0
  65. package/dist/themes/threads/timeline/timelineMore.js +107 -0
  66. package/dist/types.js +24 -40
  67. package/package.json +2 -1
  68. package/src/__tests__/helpers/app.ts +4 -0
  69. package/src/__tests__/helpers/db.ts +51 -74
  70. package/src/app.tsx +27 -6
  71. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  72. package/src/db/migrations/meta/_journal.json +7 -0
  73. package/src/db/schema.ts +63 -46
  74. package/src/i18n/locales/en.po +216 -164
  75. package/src/i18n/locales/en.ts +1 -1
  76. package/src/i18n/locales/zh-Hans.po +216 -164
  77. package/src/i18n/locales/zh-Hans.ts +1 -1
  78. package/src/i18n/locales/zh-Hant.po +216 -164
  79. package/src/i18n/locales/zh-Hant.ts +1 -1
  80. package/src/index.ts +30 -15
  81. package/src/lib/__tests__/excerpt.test.ts +125 -0
  82. package/src/lib/__tests__/schemas.test.ts +166 -105
  83. package/src/lib/__tests__/theme-components.test.ts +4 -25
  84. package/src/lib/__tests__/time.test.ts +62 -0
  85. package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
  86. package/src/lib/__tests__/view.test.ts +217 -67
  87. package/src/lib/constants.ts +1 -4
  88. package/src/lib/excerpt.ts +87 -0
  89. package/src/lib/feed.ts +22 -7
  90. package/src/lib/navigation.ts +6 -7
  91. package/src/lib/render.tsx +1 -1
  92. package/src/lib/schemas.ts +118 -52
  93. package/src/lib/theme-components.ts +10 -13
  94. package/src/lib/time.ts +64 -0
  95. package/src/lib/timeline.ts +170 -0
  96. package/src/lib/view.ts +81 -83
  97. package/src/preset.css +45 -0
  98. package/src/routes/api/__tests__/posts.test.ts +50 -108
  99. package/src/routes/api/__tests__/search.test.ts +2 -3
  100. package/src/routes/api/posts.ts +30 -30
  101. package/src/routes/api/search.ts +4 -4
  102. package/src/routes/api/upload.ts +16 -6
  103. package/src/routes/dash/collections.tsx +18 -40
  104. package/src/routes/dash/index.tsx +2 -2
  105. package/src/routes/dash/navigation.tsx +27 -26
  106. package/src/routes/dash/pages.tsx +45 -60
  107. package/src/routes/dash/posts.tsx +44 -52
  108. package/src/routes/feed/rss.ts +2 -1
  109. package/src/routes/feed/sitemap.ts +14 -4
  110. package/src/routes/pages/archive.tsx +14 -10
  111. package/src/routes/pages/collection.tsx +17 -6
  112. package/src/routes/pages/home.tsx +56 -81
  113. package/src/routes/pages/page.tsx +64 -27
  114. package/src/routes/pages/post.tsx +5 -14
  115. package/src/routes/pages/search.tsx +2 -2
  116. package/src/services/__tests__/collection.test.ts +257 -158
  117. package/src/services/__tests__/media.test.ts +18 -18
  118. package/src/services/__tests__/navigation.test.ts +161 -87
  119. package/src/services/__tests__/post-timeline.test.ts +92 -88
  120. package/src/services/__tests__/post.test.ts +342 -206
  121. package/src/services/__tests__/search.test.ts +19 -25
  122. package/src/services/collection.ts +71 -113
  123. package/src/services/index.ts +9 -8
  124. package/src/services/navigation.ts +38 -71
  125. package/src/services/page.ts +124 -0
  126. package/src/services/post.ts +93 -103
  127. package/src/services/search.ts +38 -27
  128. package/src/styles/components.css +0 -54
  129. package/src/theme/components/MediaGallery.tsx +27 -96
  130. package/src/theme/components/PageForm.tsx +21 -21
  131. package/src/theme/components/PostForm.tsx +122 -118
  132. package/src/theme/components/PostList.tsx +58 -49
  133. package/src/theme/components/ThreadView.tsx +6 -3
  134. package/src/theme/components/TypeBadge.tsx +9 -17
  135. package/src/theme/components/VisibilityBadge.tsx +40 -23
  136. package/src/theme/components/index.ts +0 -13
  137. package/src/theme/index.ts +10 -16
  138. package/src/theme/layouts/index.ts +0 -1
  139. package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
  140. package/src/themes/threads/index.ts +100 -0
  141. package/src/{theme → themes/threads}/pages/ArchivePage.tsx +52 -55
  142. package/src/themes/threads/pages/CollectionPage.tsx +61 -0
  143. package/src/{theme → themes/threads}/pages/HomePage.tsx +5 -6
  144. package/src/{theme → themes/threads}/pages/PostPage.tsx +11 -8
  145. package/src/{theme → themes/threads}/pages/SearchPage.tsx +9 -13
  146. package/src/themes/threads/pages/SinglePage.tsx +23 -0
  147. package/src/themes/threads/style.css +336 -0
  148. package/src/{theme/components → themes/threads}/timeline/LinkCard.tsx +21 -13
  149. package/src/themes/threads/timeline/NoteCard.tsx +58 -0
  150. package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
  151. package/src/{theme/components → themes/threads}/timeline/ThreadPreview.tsx +6 -6
  152. package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
  153. package/src/{theme/components → themes/threads}/timeline/TimelineItem.tsx +9 -20
  154. package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
  155. package/src/themes/threads/timeline/groupByDate.ts +30 -0
  156. package/src/themes/threads/timeline/timelineMore.tsx +130 -0
  157. package/src/types.ts +242 -98
  158. package/dist/routes/api/timeline.js +0 -120
  159. package/dist/theme/components/timeline/ArticleCard.js +0 -46
  160. package/dist/theme/components/timeline/ImageCard.js +0 -83
  161. package/dist/theme/components/timeline/NoteCard.js +0 -34
  162. package/dist/theme/components/timeline/QuoteCard.js +0 -48
  163. package/dist/theme/components/timeline/TimelineFeed.js +0 -46
  164. package/dist/theme/components/timeline/index.js +0 -8
  165. package/dist/theme/layouts/SiteLayout.js +0 -131
  166. package/dist/theme/pages/CollectionPage.js +0 -63
  167. package/dist/theme/pages/index.js +0 -11
  168. package/src/routes/api/timeline.tsx +0 -159
  169. package/src/theme/components/timeline/ArticleCard.tsx +0 -45
  170. package/src/theme/components/timeline/ImageCard.tsx +0 -70
  171. package/src/theme/components/timeline/NoteCard.tsx +0 -34
  172. package/src/theme/components/timeline/QuoteCard.tsx +0 -48
  173. package/src/theme/components/timeline/TimelineFeed.tsx +0 -56
  174. package/src/theme/components/timeline/index.ts +0 -8
  175. package/src/theme/layouts/SiteLayout.tsx +0 -132
  176. package/src/theme/pages/CollectionPage.tsx +0 -60
  177. package/src/theme/pages/SinglePage.tsx +0 -24
  178. package/src/theme/pages/index.ts +0 -13
@@ -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
- POST_TYPES,
14
- VISIBILITY_LEVELS,
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 type enum schema
22
- * Based on POST_TYPES from types.ts
21
+ * Post format enum schema
22
+ * Based on FORMATS from types.ts
23
23
  */
24
- export const PostTypeSchema = z.enum(POST_TYPES);
24
+ export const FormatSchema = z.enum(FORMATS);
25
25
 
26
26
  /**
27
- * Visibility enum schema
28
- * Based on VISIBILITY_LEVELS from types.ts
27
+ * Post status enum schema
28
+ * Based on STATUSES from types.ts
29
29
  */
30
- export const VisibilitySchema = z.enum(VISIBILITY_LEVELS);
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
- type: PostTypeSchema,
43
- title: z.string().optional(),
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 type = parseFormData(formData, "type", PostTypeSchema);
69
- * // type is PostType, throws if invalid
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 against post type rules.
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 validateMediaForPostType(
119
- type: PostType,
120
- mediaIds: string[],
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 { PostType, ThemeComponents, TimelineCardProps } from "../types.js";
8
+ import type { Format, ThemeComponents, TimelineCardProps } from "../types.js";
9
9
 
10
- const THEME_KEY_MAP: Record<PostType, keyof ThemeComponents> = {
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 type.
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 type - The post type to resolve a card for
51
- * @param defaults - Map of post type to default card component
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("article", DEFAULT_CARD_MAP, c.var.config.theme?.components);
54
+ * const Card = resolveCardComponent("note", DEFAULT_CARD_MAP, c.var.config.theme?.components);
58
55
  * ```
59
56
  */
60
57
  export function resolveCardComponent(
61
- type: PostType,
62
- defaults: Record<PostType, FC<TimelineCardProps>>,
58
+ format: Format,
59
+ defaults: Record<Format, FC<TimelineCardProps>>,
63
60
  themeComponents?: ThemeComponents,
64
61
  ): FC<TimelineCardProps> {
65
- const key = THEME_KEY_MAP[type];
62
+ const key = THEME_KEY_MAP[format];
66
63
  const override = themeComponents?.[key] as FC<TimelineCardProps> | undefined;
67
- return override ?? defaults[type];
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
+ }