@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
@@ -1,161 +1,150 @@
1
1
  /**
2
2
  * Home Page Route
3
+ *
4
+ * Timeline feed with per-type card components and thread previews.
3
5
  */
4
6
 
5
7
  import { Hono } from "hono";
6
8
  import { useLingui } from "@lingui/react/macro";
7
- import type { Bindings, Post, MediaAttachment } from "../../types.js";
9
+ import type { FC } from "hono/jsx";
10
+ import type {
11
+ Bindings,
12
+ PostWithMedia,
13
+ TimelineItemData,
14
+ TimelineFeedProps,
15
+ } from "../../types.js";
8
16
  import type { AppVariables } from "../../app.js";
9
- import { BaseLayout } from "../../theme/layouts/index.js";
10
- import { MediaGallery } from "../../theme/components/index.js";
11
- import * as sqid from "../../lib/sqid.js";
12
- import * as time from "../../lib/time.js";
13
- import { getSiteName } from "../../lib/config.js";
14
- import { getMediaUrl, getImageUrl } from "../../lib/image.js";
17
+ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
18
+ import { buildMediaMap } from "../../lib/media-helpers.js";
19
+ import { resolveTimelineFeed } from "../../lib/theme-components.js";
20
+ import { TimelineFeed as DefaultTimelineFeed } from "../../theme/components/timeline/TimelineFeed.js";
21
+ import { getNavigationData } from "../../lib/navigation.js";
15
22
 
16
23
  type Env = { Bindings: Bindings; Variables: AppVariables };
17
24
 
25
+ const PAGE_SIZE = 20;
26
+
18
27
  export const homeRoutes = new Hono<Env>();
19
28
 
20
29
  function HomeContent({
21
- siteName,
22
- posts,
23
- mediaMap,
30
+ FeedComponent,
31
+ feedProps,
24
32
  }: {
25
- siteName: string;
26
- posts: Post[];
27
- mediaMap: Map<number, MediaAttachment[]>;
33
+ FeedComponent: FC<TimelineFeedProps>;
34
+ feedProps: TimelineFeedProps;
28
35
  }) {
29
36
  const { t } = useLingui();
30
37
 
31
38
  return (
32
- <div class="container py-8">
33
- <header class="mb-8 flex items-center justify-between">
34
- <h1 class="text-2xl font-semibold">{siteName}</h1>
35
- <nav class="flex items-center gap-4 text-sm">
36
- <a
37
- href="/archive"
38
- class="text-muted-foreground hover:text-foreground"
39
- >
40
- {t({
41
- message: "Archive",
42
- comment: "@context: Navigation link to archive page",
43
- })}
44
- </a>
45
- <a href="/feed" class="text-muted-foreground hover:text-foreground">
46
- RSS
47
- </a>
48
- </nav>
49
- </header>
50
-
51
- <main class="flex flex-col gap-6">
52
- {posts.length === 0 ? (
53
- <p class="text-muted-foreground">
54
- {t({
55
- message: "No posts yet.",
56
- comment: "@context: Empty state message on home page",
57
- })}
58
- </p>
59
- ) : (
60
- posts.map((post) => {
61
- const attachments = mediaMap.get(post.id) ?? [];
62
- return (
63
- <article key={post.id} class="h-entry">
64
- {post.title && (
65
- <h2 class="p-name text-lg font-medium mb-2">
66
- <a
67
- href={`/p/${sqid.encode(post.id)}`}
68
- class="u-url hover:underline"
69
- >
70
- {post.title}
71
- </a>
72
- </h2>
73
- )}
74
- <div
75
- class="e-content prose prose-sm"
76
- dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
77
- />
78
- {attachments.length > 0 && (
79
- <MediaGallery attachments={attachments} />
80
- )}
81
- <footer class="mt-2 text-sm text-muted-foreground">
82
- <time
83
- class="dt-published"
84
- datetime={time.toISOString(post.publishedAt)}
85
- >
86
- {time.formatDate(post.publishedAt)}
87
- </time>
88
- {post.visibility === "featured" && (
89
- <span class="ml-2 text-xs">
90
- {t({
91
- message: "Featured",
92
- comment: "@context: Post visibility badge",
93
- })}
94
- </span>
95
- )}
96
- </footer>
97
- </article>
98
- );
99
- })
100
- )}
101
- </main>
102
-
103
- {posts.length >= 20 && (
104
- <nav class="mt-8 text-center">
105
- <a
106
- href="/archive"
107
- class="text-sm text-muted-foreground hover:text-foreground"
108
- >
109
- {t({
110
- message: "View all posts →",
111
- comment: "@context: Link to view all posts on archive page",
112
- })}
113
- </a>
114
- </nav>
39
+ <>
40
+ {feedProps.items.length === 0 ? (
41
+ <p class="text-muted-foreground">
42
+ {t({
43
+ message: "No posts yet.",
44
+ comment: "@context: Empty state message on home page",
45
+ })}
46
+ </p>
47
+ ) : (
48
+ <FeedComponent {...feedProps} />
115
49
  )}
116
- </div>
50
+ </>
117
51
  );
118
52
  }
119
53
 
120
54
  homeRoutes.get("/", async (c) => {
121
- const siteName = await getSiteName(c);
55
+ const navData = await getNavigationData(c);
122
56
 
57
+ // Fetch one extra to determine if there are more
123
58
  const posts = await c.var.services.posts.list({
124
59
  visibility: ["featured", "quiet"],
125
- limit: 20,
60
+ excludeReplies: true,
61
+ excludeTypes: ["page"],
62
+ limit: PAGE_SIZE + 1,
126
63
  });
127
64
 
65
+ const hasMore = posts.length > PAGE_SIZE;
66
+ const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
67
+
128
68
  // Batch load media attachments
129
- const postIds = posts.map((p) => p.id);
69
+ const postIds = displayPosts.map((p) => p.id);
130
70
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
131
71
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
132
72
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
73
+ const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
133
74
 
134
- const mediaMap = new Map<number, MediaAttachment[]>();
135
- for (const [postId, mediaList] of rawMediaMap) {
136
- mediaMap.set(
137
- postId,
138
- mediaList.map((m) => ({
139
- id: m.id,
140
- url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
141
- previewUrl: getImageUrl(
142
- getMediaUrl(m.id, m.r2Key, r2PublicUrl),
143
- imageTransformUrl,
144
- { width: 400, quality: 80, format: "auto", fit: "cover" },
145
- ),
146
- alt: m.alt,
147
- blurhash: m.blurhash,
148
- width: m.width,
149
- height: m.height,
150
- position: m.position,
151
- mimeType: m.mimeType,
152
- })),
153
- );
75
+ // Get reply counts to identify thread roots
76
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
77
+ const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
78
+
79
+ // Batch load thread previews
80
+ const threadPreviews = await c.var.services.posts.getThreadPreviews(
81
+ threadRootIds,
82
+ 3,
83
+ );
84
+
85
+ // Batch load media for preview replies
86
+ const previewReplyIds: number[] = [];
87
+ for (const replies of threadPreviews.values()) {
88
+ for (const reply of replies) {
89
+ previewReplyIds.push(reply.id);
90
+ }
154
91
  }
92
+ const previewMediaMap =
93
+ previewReplyIds.length > 0
94
+ ? buildMediaMap(
95
+ await c.var.services.media.getByPostIds(previewReplyIds),
96
+ r2PublicUrl,
97
+ imageTransformUrl,
98
+ )
99
+ : new Map();
100
+
101
+ // Assemble timeline items
102
+ const items: TimelineItemData[] = displayPosts.map((post) => {
103
+ const postWithMedia: PostWithMedia = {
104
+ ...post,
105
+ mediaAttachments: mediaMap.get(post.id) ?? [],
106
+ };
107
+
108
+ const replyCount = replyCounts.get(post.id) ?? 0;
109
+ const previewReplies = threadPreviews.get(post.id);
110
+
111
+ if (replyCount > 0 && previewReplies) {
112
+ return {
113
+ post: postWithMedia,
114
+ threadPreview: {
115
+ replies: previewReplies.map((r) => ({
116
+ ...r,
117
+ mediaAttachments: previewMediaMap.get(r.id) ?? [],
118
+ })),
119
+ totalReplyCount: replyCount,
120
+ },
121
+ };
122
+ }
123
+
124
+ return { post: postWithMedia };
125
+ });
126
+
127
+ // Determine next cursor
128
+ const lastPost = displayPosts[displayPosts.length - 1];
129
+ const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
130
+
131
+ // Resolve theme components
132
+ const Feed = resolveTimelineFeed(
133
+ DefaultTimelineFeed,
134
+ c.var.config.theme?.components,
135
+ );
136
+
137
+ const feedProps: TimelineFeedProps = {
138
+ items,
139
+ hasMore,
140
+ nextCursor,
141
+ };
155
142
 
156
143
  return c.html(
157
- <BaseLayout title={siteName} c={c}>
158
- <HomeContent siteName={siteName} posts={posts} mediaMap={mediaMap} />
144
+ <BaseLayout title={navData.siteName} c={c}>
145
+ <SiteLayout {...navData}>
146
+ <HomeContent FeedComponent={Feed} feedProps={feedProps} />
147
+ </SiteLayout>
159
148
  </BaseLayout>,
160
149
  );
161
150
  });
@@ -1,4 +1,3 @@
1
- import { getSiteName } from "../../lib/config.js";
2
1
  /**
3
2
  * Custom Page Route
4
3
  *
@@ -6,41 +5,27 @@ import { getSiteName } from "../../lib/config.js";
6
5
  */
7
6
 
8
7
  import { Hono } from "hono";
9
- import { useLingui } from "@lingui/react/macro";
10
8
  import type { Bindings, Post } from "../../types.js";
11
9
  import type { AppVariables } from "../../app.js";
12
- import { BaseLayout } from "../../theme/layouts/index.js";
10
+ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
11
+ import { getNavigationData } from "../../lib/navigation.js";
13
12
 
14
13
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
14
 
16
15
  export const pageRoutes = new Hono<Env>();
17
16
 
18
17
  function PageContent({ page }: { page: Post }) {
19
- const { t } = useLingui();
20
-
21
18
  return (
22
- <div class="container py-8 max-w-2xl">
23
- <article class="h-entry">
24
- {page.title && (
25
- <h1 class="p-name text-3xl font-semibold mb-6">{page.title}</h1>
26
- )}
27
-
28
- <div
29
- class="e-content prose"
30
- dangerouslySetInnerHTML={{ __html: page.contentHtml || "" }}
31
- />
32
- </article>
33
-
34
- <nav class="mt-8 pt-6 border-t">
35
- <a href="/" class="text-sm hover:underline">
36
- ←{" "}
37
- {t({
38
- message: "Back to home",
39
- comment: "@context: Navigation link back to home page",
40
- })}
41
- </a>
42
- </nav>
43
- </div>
19
+ <article class="h-entry">
20
+ {page.title && (
21
+ <h1 class="p-name text-3xl font-semibold mb-6">{page.title}</h1>
22
+ )}
23
+
24
+ <div
25
+ class="e-content prose"
26
+ dangerouslySetInnerHTML={{ __html: page.contentHtml || "" }}
27
+ />
28
+ </article>
44
29
  );
45
30
  }
46
31
 
@@ -61,15 +46,17 @@ pageRoutes.get("/:path", async (c) => {
61
46
  return c.notFound();
62
47
  }
63
48
 
64
- const siteName = await getSiteName(c);
49
+ const navData = await getNavigationData(c);
65
50
 
66
51
  return c.html(
67
52
  <BaseLayout
68
- title={`${page.title} - ${siteName}`}
53
+ title={`${page.title} - ${navData.siteName}`}
69
54
  description={page.content?.slice(0, 160)}
70
55
  c={c}
71
56
  >
72
- <PageContent page={page} />
57
+ <SiteLayout {...navData}>
58
+ <PageContent page={page} />
59
+ </SiteLayout>
73
60
  </BaseLayout>,
74
61
  );
75
62
  });
@@ -1,4 +1,3 @@
1
- import { getSiteName } from "../../lib/config.js";
2
1
  /**
3
2
  * Single Post Page Route
4
3
  */
@@ -7,11 +6,12 @@ import { Hono } from "hono";
7
6
  import { useLingui } from "@lingui/react/macro";
8
7
  import type { Bindings, Post, MediaAttachment } from "../../types.js";
9
8
  import type { AppVariables } from "../../app.js";
10
- import { BaseLayout } from "../../theme/layouts/index.js";
9
+ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
11
10
  import { MediaGallery } from "../../theme/components/index.js";
12
11
  import * as sqid from "../../lib/sqid.js";
13
12
  import * as time from "../../lib/time.js";
14
13
  import { getMediaUrl, getImageUrl } from "../../lib/image.js";
14
+ import { getNavigationData } from "../../lib/navigation.js";
15
15
 
16
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
17
17
 
@@ -27,46 +27,35 @@ function PostContent({
27
27
  const { t } = useLingui();
28
28
 
29
29
  return (
30
- <div class="container py-8">
31
- <article class="h-entry">
32
- {post.title && (
33
- <h1 class="p-name text-2xl font-semibold mb-4">{post.title}</h1>
34
- )}
35
-
36
- <div
37
- class="e-content prose"
38
- dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
39
- />
40
-
41
- {mediaAttachments.length > 0 && (
42
- <MediaGallery attachments={mediaAttachments} />
43
- )}
44
-
45
- <footer class="mt-6 pt-4 border-t text-sm text-muted-foreground">
46
- <time
47
- class="dt-published"
48
- datetime={time.toISOString(post.publishedAt)}
49
- >
50
- {time.formatDate(post.publishedAt)}
51
- </time>
52
- <a href={`/p/${sqid.encode(post.id)}`} class="u-url ml-4">
53
- {t({
54
- message: "Permalink",
55
- comment: "@context: Link to permanent URL of post",
56
- })}
57
- </a>
58
- </footer>
59
- </article>
60
-
61
- <nav class="mt-8">
62
- <a href="/" class="text-sm hover:underline">
30
+ <article class="h-entry">
31
+ {post.title && (
32
+ <h1 class="p-name text-2xl font-semibold mb-4">{post.title}</h1>
33
+ )}
34
+
35
+ <div
36
+ class="e-content prose"
37
+ dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
38
+ />
39
+
40
+ {mediaAttachments.length > 0 && (
41
+ <MediaGallery attachments={mediaAttachments} />
42
+ )}
43
+
44
+ <footer class="mt-6 pt-4 border-t text-sm text-muted-foreground">
45
+ <time
46
+ class="dt-published"
47
+ datetime={time.toISOString(post.publishedAt)}
48
+ >
49
+ {time.formatDate(post.publishedAt)}
50
+ </time>
51
+ <a href={`/p/${sqid.encode(post.id)}`} class="u-url ml-4">
63
52
  {t({
64
- message: "← Back to home",
65
- comment: "@context: Navigation link",
53
+ message: "Permalink",
54
+ comment: "@context: Link to permanent URL of post",
66
55
  })}
67
56
  </a>
68
- </nav>
69
- </div>
57
+ </footer>
58
+ </article>
70
59
  );
71
60
  }
72
61
 
@@ -115,12 +104,14 @@ postRoutes.get("/:id", async (c) => {
115
104
  mimeType: m.mimeType,
116
105
  }));
117
106
 
118
- const siteName = await getSiteName(c);
119
- const title = post.title || siteName;
107
+ const navData = await getNavigationData(c);
108
+ const title = post.title || navData.siteName;
120
109
 
121
110
  return c.html(
122
111
  <BaseLayout title={title} description={post.content?.slice(0, 160)} c={c}>
123
- <PostContent post={post} mediaAttachments={mediaAttachments} />
112
+ <SiteLayout {...navData}>
113
+ <PostContent post={post} mediaAttachments={mediaAttachments} />
114
+ </SiteLayout>
124
115
  </BaseLayout>,
125
116
  );
126
117
  });
@@ -1,4 +1,3 @@
1
- import { getSiteName } from "../../lib/config.js";
2
1
  /**
3
2
  * Search Page Route
4
3
  */
@@ -8,10 +7,11 @@ import { useLingui } from "@lingui/react/macro";
8
7
  import type { Bindings } from "../../types.js";
9
8
  import type { AppVariables } from "../../app.js";
10
9
  import type { SearchResult } from "../../services/search.js";
11
- import { BaseLayout } from "../../theme/layouts/index.js";
10
+ import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
12
11
  import { PagePagination } from "../../theme/components/index.js";
13
12
  import * as sqid from "../../lib/sqid.js";
14
13
  import * as time from "../../lib/time.js";
14
+ import { getNavigationData } from "../../lib/navigation.js";
15
15
 
16
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
17
17
 
@@ -39,7 +39,7 @@ function SearchContent({
39
39
  });
40
40
 
41
41
  return (
42
- <div class="container py-8 max-w-2xl">
42
+ <div>
43
43
  <h1 class="text-2xl font-semibold mb-6">{searchTitle}</h1>
44
44
 
45
45
  {/* Search form */}
@@ -137,16 +137,6 @@ function SearchContent({
137
137
  )}
138
138
  </div>
139
139
  )}
140
-
141
- <nav class="mt-8 pt-6 border-t">
142
- <a href="/" class="text-sm hover:underline">
143
- ←{" "}
144
- {t({
145
- message: "Back to home",
146
- comment: "@context: Navigation link back to home page",
147
- })}
148
- </a>
149
- </nav>
150
140
  </div>
151
141
  );
152
142
  }
@@ -156,7 +146,7 @@ searchRoutes.get("/", async (c) => {
156
146
  const pageParam = c.req.query("page");
157
147
  const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
158
148
 
159
- const siteName = await getSiteName(c);
149
+ const navData = await getNavigationData(c);
160
150
 
161
151
  // Only search if there's a query
162
152
  let results: Awaited<ReturnType<typeof c.var.services.search.search>> = [];
@@ -185,16 +175,22 @@ searchRoutes.get("/", async (c) => {
185
175
 
186
176
  return c.html(
187
177
  <BaseLayout
188
- title={query ? `Search: ${query} - ${siteName}` : `Search - ${siteName}`}
178
+ title={
179
+ query
180
+ ? `Search: ${query} - ${navData.siteName}`
181
+ : `Search - ${navData.siteName}`
182
+ }
189
183
  c={c}
190
184
  >
191
- <SearchContent
192
- query={query}
193
- results={results}
194
- error={error}
195
- hasMore={hasMore}
196
- page={page}
197
- />
185
+ <SiteLayout {...navData}>
186
+ <SearchContent
187
+ query={query}
188
+ results={results}
189
+ error={error}
190
+ hasMore={hasMore}
191
+ page={page}
192
+ />
193
+ </SiteLayout>
198
194
  </BaseLayout>,
199
195
  );
200
196
  });
@@ -18,11 +18,11 @@ describe("MediaService", () => {
18
18
  });
19
19
 
20
20
  const sampleMedia = {
21
- filename: "image-abc123.jpg",
21
+ filename: "0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
22
22
  originalName: "photo.jpg",
23
23
  mimeType: "image/jpeg",
24
24
  size: 102400,
25
- r2Key: "media/image-abc123.jpg",
25
+ r2Key: "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
26
26
  width: 1920,
27
27
  height: 1080,
28
28
  };
@@ -32,11 +32,13 @@ describe("MediaService", () => {
32
32
  const media = await mediaService.create(sampleMedia);
33
33
 
34
34
  expect(media.id).toBeTruthy(); // UUIDv7
35
- expect(media.filename).toBe("image-abc123.jpg");
35
+ expect(media.filename).toBe("0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg");
36
36
  expect(media.originalName).toBe("photo.jpg");
37
37
  expect(media.mimeType).toBe("image/jpeg");
38
38
  expect(media.size).toBe(102400);
39
- expect(media.r2Key).toBe("media/image-abc123.jpg");
39
+ expect(media.r2Key).toBe(
40
+ "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
41
+ );
40
42
  expect(media.width).toBe(1920);
41
43
  expect(media.height).toBe(1080);
42
44
  expect(media.postId).toBeNull();
@@ -69,13 +71,36 @@ describe("MediaService", () => {
69
71
  const media1 = await mediaService.create(sampleMedia);
70
72
  const media2 = await mediaService.create({
71
73
  ...sampleMedia,
72
- r2Key: "media/other.jpg",
74
+ r2Key: "media/2025/01/other.jpg",
73
75
  });
74
76
 
75
77
  expect(media1.id).not.toBe(media2.id);
76
78
  // UUIDv7 should be sortable — later ID is lexicographically greater
77
79
  expect(media2.id > media1.id).toBe(true);
78
80
  });
81
+
82
+ it("uses provided id when given", async () => {
83
+ const customId = "0192a9f1-a2b7-7c3d-8e4f-custom000001";
84
+ const media = await mediaService.create({
85
+ ...sampleMedia,
86
+ id: customId,
87
+ });
88
+
89
+ expect(media.id).toBe(customId);
90
+ });
91
+
92
+ it("auto-generates id when not provided", async () => {
93
+ const media = await mediaService.create({
94
+ ...sampleMedia,
95
+ r2Key: "media/2025/01/auto.jpg",
96
+ });
97
+
98
+ expect(media.id).toBeTruthy();
99
+ // UUIDv7 format: 8-4-4-4-12 hex chars
100
+ expect(media.id).toMatch(
101
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
102
+ );
103
+ });
79
104
  });
80
105
 
81
106
  describe("getById", () => {
@@ -84,7 +109,7 @@ describe("MediaService", () => {
84
109
 
85
110
  const found = await mediaService.getById(created.id);
86
111
  expect(found).not.toBeNull();
87
- expect(found?.filename).toBe("image-abc123.jpg");
112
+ expect(found?.filename).toBe("0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg");
88
113
  });
89
114
 
90
115
  it("returns null for non-existent ID", async () => {
@@ -226,7 +251,9 @@ describe("MediaService", () => {
226
251
  it("returns media by R2 key", async () => {
227
252
  await mediaService.create(sampleMedia);
228
253
 
229
- const found = await mediaService.getByR2Key("media/image-abc123.jpg");
254
+ const found = await mediaService.getByR2Key(
255
+ "media/2025/01/0192a9f1-a2b7-7c3d-8e4f-5a6b7c8d9e0f.jpg",
256
+ );
230
257
  expect(found).not.toBeNull();
231
258
  expect(found?.originalName).toBe("photo.jpg");
232
259
  });