@jant/core 0.3.7 → 0.3.8

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 (241) hide show
  1. package/dist/app.js +4 -0
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +13 -0
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/lib/image.js +3 -3
  8. package/dist/lib/media-helpers.js +43 -0
  9. package/dist/lib/nav-reorder.js +27 -0
  10. package/dist/lib/navigation.js +35 -0
  11. package/dist/lib/theme-components.js +49 -0
  12. package/dist/routes/api/timeline.js +115 -0
  13. package/dist/routes/api/upload.js +9 -5
  14. package/dist/routes/dash/navigation.js +274 -0
  15. package/dist/routes/pages/archive.js +14 -27
  16. package/dist/routes/pages/collection.js +10 -19
  17. package/dist/routes/pages/home.js +83 -126
  18. package/dist/routes/pages/page.js +19 -38
  19. package/dist/routes/pages/post.js +38 -51
  20. package/dist/routes/pages/search.js +13 -26
  21. package/dist/services/index.js +3 -1
  22. package/dist/services/media.js +1 -1
  23. package/dist/services/navigation.js +115 -0
  24. package/dist/services/post.js +26 -1
  25. package/dist/theme/components/PostList.js +5 -0
  26. package/dist/theme/components/index.js +2 -0
  27. package/dist/theme/components/timeline/ArticleCard.js +50 -0
  28. package/dist/theme/components/timeline/ImageCard.js +86 -0
  29. package/dist/theme/components/timeline/LinkCard.js +62 -0
  30. package/dist/theme/components/timeline/NoteCard.js +37 -0
  31. package/dist/theme/components/timeline/QuoteCard.js +51 -0
  32. package/dist/theme/components/timeline/ThreadPreview.js +52 -0
  33. package/dist/theme/components/timeline/TimelineFeed.js +43 -0
  34. package/dist/theme/components/timeline/TimelineItem.js +25 -0
  35. package/dist/theme/components/timeline/index.js +8 -0
  36. package/dist/theme/layouts/DashLayout.js +8 -0
  37. package/dist/theme/layouts/SiteLayout.js +160 -0
  38. package/dist/theme/layouts/index.js +1 -0
  39. package/dist/types/sortablejs.d.js +5 -0
  40. package/package.json +3 -2
  41. package/src/__tests__/helpers/db.ts +10 -0
  42. package/src/app.tsx +4 -0
  43. package/src/client.ts +1 -0
  44. package/src/db/migrations/0003_add_navigation_links.sql +8 -0
  45. package/src/db/migrations/meta/0003_snapshot.json +821 -0
  46. package/src/db/migrations/meta/_journal.json +14 -0
  47. package/src/db/schema.ts +13 -0
  48. package/src/i18n/locales/en.po +100 -32
  49. package/src/i18n/locales/en.ts +1 -1
  50. package/src/i18n/locales/zh-Hans.po +102 -55
  51. package/src/i18n/locales/zh-Hans.ts +1 -1
  52. package/src/i18n/locales/zh-Hant.po +102 -55
  53. package/src/i18n/locales/zh-Hant.ts +1 -1
  54. package/src/index.ts +5 -0
  55. package/src/lib/__tests__/theme-components.test.ts +107 -0
  56. package/src/lib/image.ts +3 -3
  57. package/src/lib/media-helpers.ts +54 -0
  58. package/src/lib/nav-reorder.ts +26 -0
  59. package/src/lib/navigation.ts +46 -0
  60. package/src/lib/theme-components.ts +76 -0
  61. package/src/routes/api/__tests__/posts.test.ts +8 -8
  62. package/src/routes/api/__tests__/timeline.test.ts +242 -0
  63. package/src/routes/api/timeline.tsx +145 -0
  64. package/src/routes/api/upload.ts +9 -5
  65. package/src/routes/dash/navigation.tsx +306 -0
  66. package/src/routes/pages/archive.tsx +15 -23
  67. package/src/routes/pages/collection.tsx +8 -15
  68. package/src/routes/pages/home.tsx +111 -122
  69. package/src/routes/pages/page.tsx +17 -30
  70. package/src/routes/pages/post.tsx +33 -42
  71. package/src/routes/pages/search.tsx +18 -22
  72. package/src/services/__tests__/media.test.ts +34 -7
  73. package/src/services/__tests__/navigation.test.ts +213 -0
  74. package/src/services/__tests__/post-timeline.test.ts +220 -0
  75. package/src/services/index.ts +7 -0
  76. package/src/services/media.ts +2 -1
  77. package/src/services/navigation.ts +165 -0
  78. package/src/services/post.ts +48 -1
  79. package/src/styles/components.css +59 -0
  80. package/src/theme/components/PostList.tsx +7 -0
  81. package/src/theme/components/index.ts +12 -0
  82. package/src/theme/components/timeline/ArticleCard.tsx +57 -0
  83. package/src/theme/components/timeline/ImageCard.tsx +80 -0
  84. package/src/theme/components/timeline/LinkCard.tsx +66 -0
  85. package/src/theme/components/timeline/NoteCard.tsx +41 -0
  86. package/src/theme/components/timeline/QuoteCard.tsx +55 -0
  87. package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
  88. package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
  89. package/src/theme/components/timeline/TimelineItem.tsx +39 -0
  90. package/src/theme/components/timeline/index.ts +8 -0
  91. package/src/theme/layouts/DashLayout.tsx +10 -0
  92. package/src/theme/layouts/SiteLayout.tsx +184 -0
  93. package/src/theme/layouts/index.ts +1 -0
  94. package/src/types/sortablejs.d.ts +23 -0
  95. package/src/types.ts +61 -0
  96. package/dist/app.d.ts +0 -38
  97. package/dist/app.d.ts.map +0 -1
  98. package/dist/auth.d.ts +0 -25
  99. package/dist/auth.d.ts.map +0 -1
  100. package/dist/db/index.d.ts +0 -10
  101. package/dist/db/index.d.ts.map +0 -1
  102. package/dist/db/schema.d.ts +0 -1543
  103. package/dist/db/schema.d.ts.map +0 -1
  104. package/dist/i18n/Trans.d.ts +0 -25
  105. package/dist/i18n/Trans.d.ts.map +0 -1
  106. package/dist/i18n/context.d.ts +0 -69
  107. package/dist/i18n/context.d.ts.map +0 -1
  108. package/dist/i18n/detect.d.ts +0 -20
  109. package/dist/i18n/detect.d.ts.map +0 -1
  110. package/dist/i18n/i18n.d.ts +0 -32
  111. package/dist/i18n/i18n.d.ts.map +0 -1
  112. package/dist/i18n/index.d.ts +0 -41
  113. package/dist/i18n/index.d.ts.map +0 -1
  114. package/dist/i18n/locales/en.d.ts +0 -3
  115. package/dist/i18n/locales/en.d.ts.map +0 -1
  116. package/dist/i18n/locales/zh-Hans.d.ts +0 -3
  117. package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
  118. package/dist/i18n/locales/zh-Hant.d.ts +0 -3
  119. package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
  120. package/dist/i18n/locales.d.ts +0 -11
  121. package/dist/i18n/locales.d.ts.map +0 -1
  122. package/dist/i18n/middleware.d.ts +0 -21
  123. package/dist/i18n/middleware.d.ts.map +0 -1
  124. package/dist/index.d.ts +0 -16
  125. package/dist/index.d.ts.map +0 -1
  126. package/dist/lib/config.d.ts +0 -83
  127. package/dist/lib/config.d.ts.map +0 -1
  128. package/dist/lib/constants.d.ts +0 -37
  129. package/dist/lib/constants.d.ts.map +0 -1
  130. package/dist/lib/image.d.ts +0 -73
  131. package/dist/lib/image.d.ts.map +0 -1
  132. package/dist/lib/index.d.ts +0 -9
  133. package/dist/lib/index.d.ts.map +0 -1
  134. package/dist/lib/markdown.d.ts +0 -60
  135. package/dist/lib/markdown.d.ts.map +0 -1
  136. package/dist/lib/schemas.d.ts +0 -130
  137. package/dist/lib/schemas.d.ts.map +0 -1
  138. package/dist/lib/sqid.d.ts +0 -60
  139. package/dist/lib/sqid.d.ts.map +0 -1
  140. package/dist/lib/sse.d.ts +0 -192
  141. package/dist/lib/sse.d.ts.map +0 -1
  142. package/dist/lib/theme.d.ts +0 -44
  143. package/dist/lib/theme.d.ts.map +0 -1
  144. package/dist/lib/time.d.ts +0 -90
  145. package/dist/lib/time.d.ts.map +0 -1
  146. package/dist/lib/url.d.ts +0 -82
  147. package/dist/lib/url.d.ts.map +0 -1
  148. package/dist/middleware/auth.d.ts +0 -24
  149. package/dist/middleware/auth.d.ts.map +0 -1
  150. package/dist/middleware/onboarding.d.ts +0 -26
  151. package/dist/middleware/onboarding.d.ts.map +0 -1
  152. package/dist/routes/api/posts.d.ts +0 -13
  153. package/dist/routes/api/posts.d.ts.map +0 -1
  154. package/dist/routes/api/search.d.ts +0 -13
  155. package/dist/routes/api/search.d.ts.map +0 -1
  156. package/dist/routes/api/upload.d.ts +0 -16
  157. package/dist/routes/api/upload.d.ts.map +0 -1
  158. package/dist/routes/dash/collections.d.ts +0 -13
  159. package/dist/routes/dash/collections.d.ts.map +0 -1
  160. package/dist/routes/dash/index.d.ts +0 -15
  161. package/dist/routes/dash/index.d.ts.map +0 -1
  162. package/dist/routes/dash/media.d.ts +0 -16
  163. package/dist/routes/dash/media.d.ts.map +0 -1
  164. package/dist/routes/dash/pages.d.ts +0 -15
  165. package/dist/routes/dash/pages.d.ts.map +0 -1
  166. package/dist/routes/dash/posts.d.ts +0 -13
  167. package/dist/routes/dash/posts.d.ts.map +0 -1
  168. package/dist/routes/dash/redirects.d.ts +0 -13
  169. package/dist/routes/dash/redirects.d.ts.map +0 -1
  170. package/dist/routes/dash/settings.d.ts +0 -15
  171. package/dist/routes/dash/settings.d.ts.map +0 -1
  172. package/dist/routes/feed/rss.d.ts +0 -13
  173. package/dist/routes/feed/rss.d.ts.map +0 -1
  174. package/dist/routes/feed/sitemap.d.ts +0 -13
  175. package/dist/routes/feed/sitemap.d.ts.map +0 -1
  176. package/dist/routes/pages/archive.d.ts +0 -15
  177. package/dist/routes/pages/archive.d.ts.map +0 -1
  178. package/dist/routes/pages/collection.d.ts +0 -13
  179. package/dist/routes/pages/collection.d.ts.map +0 -1
  180. package/dist/routes/pages/home.d.ts +0 -13
  181. package/dist/routes/pages/home.d.ts.map +0 -1
  182. package/dist/routes/pages/page.d.ts +0 -15
  183. package/dist/routes/pages/page.d.ts.map +0 -1
  184. package/dist/routes/pages/post.d.ts +0 -13
  185. package/dist/routes/pages/post.d.ts.map +0 -1
  186. package/dist/routes/pages/search.d.ts +0 -13
  187. package/dist/routes/pages/search.d.ts.map +0 -1
  188. package/dist/services/collection.d.ts +0 -32
  189. package/dist/services/collection.d.ts.map +0 -1
  190. package/dist/services/index.d.ts +0 -28
  191. package/dist/services/index.d.ts.map +0 -1
  192. package/dist/services/media.d.ts +0 -34
  193. package/dist/services/media.d.ts.map +0 -1
  194. package/dist/services/post.d.ts +0 -31
  195. package/dist/services/post.d.ts.map +0 -1
  196. package/dist/services/redirect.d.ts +0 -15
  197. package/dist/services/redirect.d.ts.map +0 -1
  198. package/dist/services/search.d.ts +0 -26
  199. package/dist/services/search.d.ts.map +0 -1
  200. package/dist/services/settings.d.ts +0 -18
  201. package/dist/services/settings.d.ts.map +0 -1
  202. package/dist/theme/color-themes.d.ts +0 -30
  203. package/dist/theme/color-themes.d.ts.map +0 -1
  204. package/dist/theme/components/ActionButtons.d.ts +0 -43
  205. package/dist/theme/components/ActionButtons.d.ts.map +0 -1
  206. package/dist/theme/components/CrudPageHeader.d.ts +0 -23
  207. package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
  208. package/dist/theme/components/DangerZone.d.ts +0 -36
  209. package/dist/theme/components/DangerZone.d.ts.map +0 -1
  210. package/dist/theme/components/EmptyState.d.ts +0 -27
  211. package/dist/theme/components/EmptyState.d.ts.map +0 -1
  212. package/dist/theme/components/ListItemRow.d.ts +0 -15
  213. package/dist/theme/components/ListItemRow.d.ts.map +0 -1
  214. package/dist/theme/components/MediaGallery.d.ts +0 -13
  215. package/dist/theme/components/MediaGallery.d.ts.map +0 -1
  216. package/dist/theme/components/PageForm.d.ts +0 -14
  217. package/dist/theme/components/PageForm.d.ts.map +0 -1
  218. package/dist/theme/components/Pagination.d.ts +0 -46
  219. package/dist/theme/components/Pagination.d.ts.map +0 -1
  220. package/dist/theme/components/PostForm.d.ts +0 -16
  221. package/dist/theme/components/PostForm.d.ts.map +0 -1
  222. package/dist/theme/components/PostList.d.ts +0 -10
  223. package/dist/theme/components/PostList.d.ts.map +0 -1
  224. package/dist/theme/components/ThreadView.d.ts +0 -15
  225. package/dist/theme/components/ThreadView.d.ts.map +0 -1
  226. package/dist/theme/components/TypeBadge.d.ts +0 -12
  227. package/dist/theme/components/TypeBadge.d.ts.map +0 -1
  228. package/dist/theme/components/VisibilityBadge.d.ts +0 -12
  229. package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
  230. package/dist/theme/components/index.d.ts +0 -14
  231. package/dist/theme/components/index.d.ts.map +0 -1
  232. package/dist/theme/index.d.ts +0 -21
  233. package/dist/theme/index.d.ts.map +0 -1
  234. package/dist/theme/layouts/BaseLayout.d.ts +0 -23
  235. package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
  236. package/dist/theme/layouts/DashLayout.d.ts +0 -17
  237. package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
  238. package/dist/theme/layouts/index.d.ts +0 -3
  239. package/dist/theme/layouts/index.d.ts.map +0 -1
  240. package/dist/types.d.ts +0 -237
  241. package/dist/types.d.ts.map +0 -1
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Media Helper Utilities
3
+ *
4
+ * Shared logic for building MediaAttachment maps from raw media data.
5
+ */
6
+
7
+ import type { Media, MediaAttachment } from "../types.js";
8
+ import { getMediaUrl, getImageUrl } from "./image.js";
9
+
10
+ /**
11
+ * Builds a map of post IDs to MediaAttachment arrays from raw media data.
12
+ *
13
+ * Transforms raw Media objects (with R2 keys) into MediaAttachment objects
14
+ * (with public URLs and preview URLs) suitable for rendering.
15
+ *
16
+ * @param rawMediaMap - Map of post IDs to raw Media arrays from the media service
17
+ * @param r2PublicUrl - Optional R2 public URL for direct CDN access
18
+ * @param imageTransformUrl - Optional image transformation service URL
19
+ * @returns Map of post IDs to MediaAttachment arrays
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * const rawMediaMap = await services.media.getByPostIds(postIds);
24
+ * const mediaMap = buildMediaMap(rawMediaMap, c.env.R2_PUBLIC_URL, c.env.IMAGE_TRANSFORM_URL);
25
+ * ```
26
+ */
27
+ export function buildMediaMap(
28
+ rawMediaMap: Map<number, Media[]>,
29
+ r2PublicUrl?: string,
30
+ imageTransformUrl?: string,
31
+ ): Map<number, MediaAttachment[]> {
32
+ const mediaMap = new Map<number, MediaAttachment[]>();
33
+ for (const [postId, mediaList] of rawMediaMap) {
34
+ mediaMap.set(
35
+ postId,
36
+ mediaList.map((m) => ({
37
+ id: m.id,
38
+ url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
39
+ previewUrl: getImageUrl(
40
+ getMediaUrl(m.id, m.r2Key, r2PublicUrl),
41
+ imageTransformUrl,
42
+ { width: 400, quality: 80, format: "auto", fit: "cover" },
43
+ ),
44
+ alt: m.alt,
45
+ blurhash: m.blurhash,
46
+ width: m.width,
47
+ height: m.height,
48
+ position: m.position,
49
+ mimeType: m.mimeType,
50
+ })),
51
+ );
52
+ }
53
+ return mediaMap;
54
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Navigation Link Reorder
3
+ *
4
+ * Initializes SortableJS on the navigation links list in the dashboard.
5
+ * Auto-detects the list element and only activates when present.
6
+ */
7
+
8
+ import Sortable from "sortablejs";
9
+
10
+ const list = document.getElementById("nav-links-list");
11
+ if (list) {
12
+ Sortable.create(list, {
13
+ animation: 150,
14
+ handle: "[data-id]",
15
+ onEnd() {
16
+ const ids = [...list.querySelectorAll<HTMLElement>("[data-id]")].map(
17
+ (el) => Number(el.dataset.id),
18
+ );
19
+ fetch("/dash/navigation/reorder", {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify({ ids }),
23
+ });
24
+ },
25
+ });
26
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Navigation Helper
3
+ *
4
+ * Provides shared data fetching for public page navigation.
5
+ */
6
+
7
+ import type { Context } from "hono";
8
+ import { getSiteName } from "./config.js";
9
+ import type { NavigationLink } from "../types.js";
10
+
11
+ /**
12
+ * Navigation data needed by SiteLayout
13
+ */
14
+ export interface NavigationData {
15
+ navigationLinks: NavigationLink[];
16
+ currentPath: string;
17
+ siteName: string;
18
+ }
19
+
20
+ /**
21
+ * Fetch navigation data for public pages.
22
+ *
23
+ * Ensures default links exist (Home, Archive, RSS) and returns
24
+ * the current path and site name alongside the links.
25
+ *
26
+ * @param c - Hono context
27
+ * @returns Navigation data for SiteLayout
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * const navData = await getNavigationData(c);
32
+ * return c.html(
33
+ * <BaseLayout c={c}>
34
+ * <SiteLayout {...navData}>
35
+ * <MyContent />
36
+ * </SiteLayout>
37
+ * </BaseLayout>
38
+ * );
39
+ * ```
40
+ */
41
+ export async function getNavigationData(c: Context): Promise<NavigationData> {
42
+ const navigationLinks = await c.var.services.navigationLinks.ensureDefaults();
43
+ const currentPath = new URL(c.req.url).pathname;
44
+ const siteName = await getSiteName(c);
45
+ return { navigationLinks, currentPath, siteName };
46
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Theme Component Resolution
3
+ *
4
+ * Resolves theme-overridable components, falling back to defaults.
5
+ */
6
+
7
+ import type { FC } from "hono/jsx";
8
+ import type {
9
+ PostType,
10
+ ThemeComponents,
11
+ TimelineCardProps,
12
+ ThreadPreviewProps,
13
+ TimelineFeedProps,
14
+ } from "../types.js";
15
+
16
+ const THEME_KEY_MAP: Record<PostType, keyof ThemeComponents> = {
17
+ note: "NoteCard",
18
+ article: "ArticleCard",
19
+ link: "LinkCard",
20
+ quote: "QuoteCard",
21
+ image: "ImageCard",
22
+ page: "NoteCard",
23
+ };
24
+
25
+ /**
26
+ * Resolves the card component for a given post type.
27
+ *
28
+ * Checks theme overrides first, then falls back to the provided default card component.
29
+ *
30
+ * @param type - The post type to resolve a card for
31
+ * @param defaults - Map of post type to default card component
32
+ * @param themeComponents - Optional theme component overrides
33
+ * @returns The resolved card component
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * const Card = resolveCardComponent("article", DEFAULT_CARD_MAP, c.var.config.theme?.components);
38
+ * ```
39
+ */
40
+ export function resolveCardComponent(
41
+ type: PostType,
42
+ defaults: Record<PostType, FC<TimelineCardProps>>,
43
+ themeComponents?: ThemeComponents,
44
+ ): FC<TimelineCardProps> {
45
+ const key = THEME_KEY_MAP[type];
46
+ const override = themeComponents?.[key] as FC<TimelineCardProps> | undefined;
47
+ return override ?? defaults[type];
48
+ }
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
+ }
@@ -50,7 +50,7 @@ describe("Posts API Routes", () => {
50
50
  originalName: "test.jpg",
51
51
  mimeType: "image/jpeg",
52
52
  size: 1024,
53
- r2Key: "uploads/test.jpg",
53
+ r2Key: "media/2025/01/test.jpg",
54
54
  width: 800,
55
55
  height: 600,
56
56
  });
@@ -145,7 +145,7 @@ describe("Posts API Routes", () => {
145
145
  originalName: "test.jpg",
146
146
  mimeType: "image/jpeg",
147
147
  size: 1024,
148
- r2Key: "uploads/test.jpg",
148
+ r2Key: "media/2025/01/test.jpg",
149
149
  });
150
150
 
151
151
  await services.media.attachToPost(post.id, [media.id]);
@@ -223,14 +223,14 @@ describe("Posts API Routes", () => {
223
223
  originalName: "a.jpg",
224
224
  mimeType: "image/jpeg",
225
225
  size: 1024,
226
- r2Key: "uploads/a.jpg",
226
+ r2Key: "media/2025/01/a.jpg",
227
227
  });
228
228
  const m2 = await services.media.create({
229
229
  filename: "b.jpg",
230
230
  originalName: "b.jpg",
231
231
  mimeType: "image/jpeg",
232
232
  size: 2048,
233
- r2Key: "uploads/b.jpg",
233
+ r2Key: "media/2025/01/b.jpg",
234
234
  });
235
235
 
236
236
  const res = await app.request("/api/posts", {
@@ -282,7 +282,7 @@ describe("Posts API Routes", () => {
282
282
  originalName: "a.jpg",
283
283
  mimeType: "image/jpeg",
284
284
  size: 1024,
285
- r2Key: "uploads/a.jpg",
285
+ r2Key: "media/2025/01/a.jpg",
286
286
  });
287
287
 
288
288
  const res = await app.request("/api/posts", {
@@ -404,7 +404,7 @@ describe("Posts API Routes", () => {
404
404
  originalName: "a.jpg",
405
405
  mimeType: "image/jpeg",
406
406
  size: 1024,
407
- r2Key: "uploads/a.jpg",
407
+ r2Key: "media/2025/01/a.jpg",
408
408
  });
409
409
 
410
410
  await services.media.attachToPost(post.id, [m1.id]);
@@ -414,7 +414,7 @@ describe("Posts API Routes", () => {
414
414
  originalName: "b.jpg",
415
415
  mimeType: "image/jpeg",
416
416
  size: 2048,
417
- r2Key: "uploads/b.jpg",
417
+ r2Key: "media/2025/01/b.jpg",
418
418
  });
419
419
 
420
420
  const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
@@ -443,7 +443,7 @@ describe("Posts API Routes", () => {
443
443
  originalName: "a.jpg",
444
444
  mimeType: "image/jpeg",
445
445
  size: 1024,
446
- r2Key: "uploads/a.jpg",
446
+ r2Key: "media/2025/01/a.jpg",
447
447
  });
448
448
 
449
449
  await services.media.attachToPost(post.id, [m1.id]);
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Timeline API Tests
3
+ *
4
+ * Tests the timeline data assembly logic via the service layer.
5
+ * The actual route handler renders JSX components which require the Lingui SWC
6
+ * plugin (not available in vitest). We test the underlying service operations
7
+ * that power the timeline API instead.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach } from "vitest";
11
+ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
12
+ import { createPostService } from "../../../services/post.js";
13
+ import { createMediaService } from "../../../services/media.js";
14
+ import { buildMediaMap } from "../../../lib/media-helpers.js";
15
+ import type { Database } from "../../../db/index.js";
16
+ import type { PostWithMedia, TimelineItemData } from "../../../types.js";
17
+
18
+ describe("Timeline data assembly", () => {
19
+ let db: Database;
20
+ let postService: ReturnType<typeof createPostService>;
21
+ let mediaService: ReturnType<typeof createMediaService>;
22
+
23
+ beforeEach(() => {
24
+ const testDb = createTestDatabase();
25
+ db = testDb.db as unknown as Database;
26
+ postService = createPostService(db);
27
+ mediaService = createMediaService(db);
28
+ });
29
+
30
+ it("assembles timeline items with media attachments", async () => {
31
+ const post = await postService.create({
32
+ type: "note",
33
+ content: "Hello",
34
+ visibility: "featured",
35
+ });
36
+
37
+ const posts = await postService.list({
38
+ visibility: ["featured", "quiet"],
39
+ excludeReplies: true,
40
+ excludeTypes: ["page"],
41
+ limit: 21,
42
+ });
43
+
44
+ expect(posts).toHaveLength(1);
45
+ expect(posts[0]?.id).toBe(post.id);
46
+
47
+ // Build media map
48
+ const postIds = posts.map((p) => p.id);
49
+ const rawMediaMap = await mediaService.getByPostIds(postIds);
50
+ const mediaMap = buildMediaMap(rawMediaMap);
51
+
52
+ // Assemble items
53
+ const items: TimelineItemData[] = posts.map((p) => ({
54
+ post: { ...p, mediaAttachments: mediaMap.get(p.id) ?? [] },
55
+ }));
56
+
57
+ expect(items).toHaveLength(1);
58
+ expect(items[0]?.post.mediaAttachments).toEqual([]);
59
+ });
60
+
61
+ it("identifies thread roots and builds thread previews", async () => {
62
+ const root = await postService.create({
63
+ type: "note",
64
+ content: "Thread root",
65
+ visibility: "featured",
66
+ });
67
+ await postService.create({
68
+ type: "note",
69
+ content: "Reply 1",
70
+ replyToId: root.id,
71
+ });
72
+ await postService.create({
73
+ type: "note",
74
+ content: "Reply 2",
75
+ replyToId: root.id,
76
+ });
77
+
78
+ const posts = await postService.list({
79
+ visibility: ["featured", "quiet"],
80
+ excludeReplies: true,
81
+ excludeTypes: ["page"],
82
+ limit: 21,
83
+ });
84
+
85
+ expect(posts).toHaveLength(1);
86
+
87
+ const postIds = posts.map((p) => p.id);
88
+ const replyCounts = await postService.getReplyCounts(postIds);
89
+ const threadRootIds = postIds.filter(
90
+ (id) => (replyCounts.get(id) ?? 0) > 0,
91
+ );
92
+
93
+ expect(threadRootIds).toEqual([root.id]);
94
+ expect(replyCounts.get(root.id)).toBe(2);
95
+
96
+ const threadPreviews = await postService.getThreadPreviews(threadRootIds);
97
+ const replies = threadPreviews.get(root.id);
98
+ expect(replies).toHaveLength(2);
99
+ expect(replies?.[0]?.content).toBe("Reply 1");
100
+
101
+ // Assemble items
102
+ const rawMediaMap = await mediaService.getByPostIds(postIds);
103
+ const mediaMap = buildMediaMap(rawMediaMap);
104
+
105
+ const items: TimelineItemData[] = posts.map((post) => {
106
+ const postWithMedia: PostWithMedia = {
107
+ ...post,
108
+ mediaAttachments: mediaMap.get(post.id) ?? [],
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: postWithMedia,
117
+ threadPreview: {
118
+ replies: previewReplies.map((r) => ({
119
+ ...r,
120
+ mediaAttachments: [],
121
+ })),
122
+ totalReplyCount: replyCount,
123
+ },
124
+ };
125
+ }
126
+
127
+ return { post: postWithMedia };
128
+ });
129
+
130
+ expect(items).toHaveLength(1);
131
+ expect(items[0]?.threadPreview).toBeDefined();
132
+ expect(items[0]?.threadPreview?.replies).toHaveLength(2);
133
+ expect(items[0]?.threadPreview?.totalReplyCount).toBe(2);
134
+ });
135
+
136
+ it("excludes pages from timeline", async () => {
137
+ await postService.create({
138
+ type: "note",
139
+ content: "A note",
140
+ visibility: "quiet",
141
+ });
142
+ await postService.create({
143
+ type: "page",
144
+ content: "A page",
145
+ visibility: "quiet",
146
+ });
147
+
148
+ const posts = await postService.list({
149
+ visibility: ["featured", "quiet"],
150
+ excludeReplies: true,
151
+ excludeTypes: ["page"],
152
+ limit: 21,
153
+ });
154
+
155
+ expect(posts).toHaveLength(1);
156
+ expect(posts[0]?.type).toBe("note");
157
+ });
158
+
159
+ it("excludes replies from top-level list", async () => {
160
+ const root = await postService.create({
161
+ type: "note",
162
+ content: "Root",
163
+ visibility: "quiet",
164
+ });
165
+ await postService.create({
166
+ type: "note",
167
+ content: "Reply",
168
+ replyToId: root.id,
169
+ });
170
+
171
+ const posts = await postService.list({
172
+ visibility: ["featured", "quiet"],
173
+ excludeReplies: true,
174
+ excludeTypes: ["page"],
175
+ limit: 21,
176
+ });
177
+
178
+ expect(posts).toHaveLength(1);
179
+ expect(posts[0]?.content).toBe("Root");
180
+ });
181
+
182
+ it("supports cursor pagination for load more", async () => {
183
+ const posts = [];
184
+ for (let i = 0; i < 5; i++) {
185
+ posts.push(
186
+ await postService.create({
187
+ type: "note",
188
+ content: `Post ${i}`,
189
+ visibility: "quiet",
190
+ publishedAt: 1000 + i,
191
+ }),
192
+ );
193
+ }
194
+
195
+ // First page
196
+ const page1 = await postService.list({
197
+ visibility: ["featured", "quiet"],
198
+ excludeReplies: true,
199
+ excludeTypes: ["page"],
200
+ limit: 3,
201
+ });
202
+ expect(page1).toHaveLength(3);
203
+
204
+ // Second page using cursor
205
+ const lastPost = page1[page1.length - 1];
206
+ expect(lastPost).toBeDefined();
207
+ const page2 = await postService.list({
208
+ visibility: ["featured", "quiet"],
209
+ excludeReplies: true,
210
+ excludeTypes: ["page"],
211
+ limit: 3,
212
+ cursor: lastPost?.id,
213
+ });
214
+ expect(page2).toHaveLength(2);
215
+ expect(page2.every((p) => p.id < (lastPost?.id ?? 0))).toBe(true);
216
+ });
217
+
218
+ it("correctly determines hasMore flag", async () => {
219
+ for (let i = 0; i < 3; i++) {
220
+ await postService.create({
221
+ type: "note",
222
+ content: `Post ${i}`,
223
+ visibility: "quiet",
224
+ });
225
+ }
226
+
227
+ // Request limit + 1 to check for more
228
+ const pageSize = 2;
229
+ const posts = await postService.list({
230
+ visibility: ["featured", "quiet"],
231
+ excludeReplies: true,
232
+ excludeTypes: ["page"],
233
+ limit: pageSize + 1,
234
+ });
235
+
236
+ const hasMore = posts.length > pageSize;
237
+ expect(hasMore).toBe(true);
238
+
239
+ const displayPosts = posts.slice(0, pageSize);
240
+ expect(displayPosts).toHaveLength(2);
241
+ });
242
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Timeline API Routes
3
+ *
4
+ * Provides load-more functionality for the timeline feed via SSE.
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import type { Bindings, PostWithMedia, TimelineItemData } from "../../types.js";
9
+ import type { AppVariables } from "../../app.js";
10
+ import { sse } from "../../lib/sse.js";
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";
14
+
15
+ type Env = { Bindings: Bindings; Variables: AppVariables };
16
+
17
+ const PAGE_SIZE = 20;
18
+
19
+ export const timelineApiRoutes = new Hono<Env>();
20
+
21
+ timelineApiRoutes.get("/", async (c) => {
22
+ const cursorParam = c.req.query("cursor");
23
+ const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
24
+
25
+ if (!cursor || isNaN(cursor)) {
26
+ return c.json({ error: "cursor parameter required" }, 400);
27
+ }
28
+
29
+ // Fetch one extra to determine if there are more
30
+ const posts = await c.var.services.posts.list({
31
+ visibility: ["featured", "quiet"],
32
+ excludeReplies: true,
33
+ excludeTypes: ["page"],
34
+ limit: PAGE_SIZE + 1,
35
+ cursor,
36
+ });
37
+
38
+ const hasMore = posts.length > PAGE_SIZE;
39
+ const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
40
+
41
+ if (displayPosts.length === 0) {
42
+ return sse(c, async (stream) => {
43
+ stream.remove("#load-more-container");
44
+ });
45
+ }
46
+
47
+ // Build media map
48
+ const postIds = displayPosts.map((p) => p.id);
49
+ 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 mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
53
+
54
+ // Get reply counts to identify thread roots
55
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
56
+ const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
57
+
58
+ // Get thread previews
59
+ const threadPreviews = await c.var.services.posts.getThreadPreviews(
60
+ threadRootIds,
61
+ 3,
62
+ );
63
+
64
+ // Load media for preview replies
65
+ const previewReplyIds: number[] = [];
66
+ for (const replies of threadPreviews.values()) {
67
+ for (const reply of replies) {
68
+ previewReplyIds.push(reply.id);
69
+ }
70
+ }
71
+ const previewMediaMap =
72
+ previewReplyIds.length > 0
73
+ ? buildMediaMap(
74
+ await c.var.services.media.getByPostIds(previewReplyIds),
75
+ r2PublicUrl,
76
+ imageTransformUrl,
77
+ )
78
+ : new Map();
79
+
80
+ // Assemble timeline items
81
+ const items: TimelineItemData[] = displayPosts.map((post) => {
82
+ const postWithMedia: PostWithMedia = {
83
+ ...post,
84
+ mediaAttachments: mediaMap.get(post.id) ?? [],
85
+ };
86
+
87
+ const replyCount = replyCounts.get(post.id) ?? 0;
88
+ const previewReplies = threadPreviews.get(post.id);
89
+
90
+ if (replyCount > 0 && previewReplies) {
91
+ return {
92
+ post: postWithMedia,
93
+ threadPreview: {
94
+ replies: previewReplies.map((r) => ({
95
+ ...r,
96
+ mediaAttachments: previewMediaMap.get(r.id) ?? [],
97
+ })),
98
+ totalReplyCount: replyCount,
99
+ },
100
+ };
101
+ }
102
+
103
+ return { post: postWithMedia };
104
+ });
105
+
106
+ // Render items to HTML
107
+ const itemsHtml = items
108
+ .map((item) => {
109
+ if (item.threadPreview) {
110
+ return (
111
+ <ThreadPreview
112
+ rootPost={item.post}
113
+ previewReplies={item.threadPreview.replies}
114
+ totalReplyCount={item.threadPreview.totalReplyCount}
115
+ />
116
+ );
117
+ }
118
+ return <TimelineItem item={item} />;
119
+ })
120
+ .map((jsx) => jsx.toString())
121
+ .join("");
122
+
123
+ // Determine next cursor
124
+ const lastPost = displayPosts[displayPosts.length - 1];
125
+ const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
126
+
127
+ // Build load-more button HTML
128
+ const loadMoreHtml = nextCursor
129
+ ? `<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>`
130
+ : "";
131
+
132
+ return sse(c, async (stream) => {
133
+ // Append new items to the feed
134
+ stream.patchElements(itemsHtml, {
135
+ mode: "append",
136
+ selector: "#timeline-feed",
137
+ });
138
+ // Replace or remove the load-more container
139
+ if (loadMoreHtml) {
140
+ stream.patchElements(loadMoreHtml);
141
+ } else {
142
+ stream.remove("#load-more-container");
143
+ }
144
+ });
145
+ });
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { Hono } from "hono";
9
9
  import { html } from "hono/html";
10
+ import { uuidv7 } from "uuidv7";
10
11
  import type { Bindings } from "../../types.js";
11
12
  import type { AppVariables } from "../../app.js";
12
13
  import { requireAuthApi } from "../../middleware/auth.js";
@@ -156,12 +157,14 @@ uploadApiRoutes.post("/", async (c) => {
156
157
  return c.json({ error: "File too large (max 10MB)" }, 400);
157
158
  }
158
159
 
159
- // Generate unique filename
160
+ // Generate unique filename using UUIDv7
160
161
  const ext = file.name.split(".").pop() || "bin";
161
- const timestamp = Date.now();
162
- const random = Math.random().toString(36).substring(2, 8);
163
- const filename = `${timestamp}-${random}.${ext}`;
164
- const r2Key = `uploads/${filename}`;
162
+ const id = uuidv7();
163
+ const date = new Date();
164
+ const year = date.getUTCFullYear();
165
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
166
+ const filename = `${id}.${ext}`;
167
+ const r2Key = `media/${year}/${month}/${filename}`;
165
168
 
166
169
  try {
167
170
  // Upload to R2
@@ -173,6 +176,7 @@ uploadApiRoutes.post("/", async (c) => {
173
176
 
174
177
  // Save to database
175
178
  const media = await c.var.services.media.create({
179
+ id,
176
180
  filename,
177
181
  originalName: file.name,
178
182
  mimeType: file.type,