@jant/core 0.3.24 → 0.3.26

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 (277) hide show
  1. package/dist/app.js +101 -571
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +1 -1
  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/index.js +3 -9
  8. package/dist/lib/avatar-upload.js +134 -0
  9. package/dist/lib/config.js +39 -0
  10. package/dist/lib/constants.js +10 -9
  11. package/dist/lib/favicon.js +102 -0
  12. package/dist/lib/image.js +13 -17
  13. package/dist/lib/media-helpers.js +2 -2
  14. package/dist/lib/nav-reorder.js +1 -1
  15. package/dist/lib/navigation.js +48 -3
  16. package/dist/lib/pagination.js +44 -0
  17. package/dist/lib/render.js +16 -11
  18. package/dist/lib/schemas.js +34 -3
  19. package/dist/lib/theme.js +4 -4
  20. package/dist/lib/timeline.js +24 -48
  21. package/dist/lib/timezones.js +388 -0
  22. package/dist/lib/view.js +3 -3
  23. package/dist/routes/api/collections.js +124 -0
  24. package/dist/routes/api/nav-items.js +104 -0
  25. package/dist/routes/api/pages.js +91 -0
  26. package/dist/routes/api/posts.js +3 -3
  27. package/dist/routes/api/search.js +2 -2
  28. package/dist/routes/api/settings.js +68 -0
  29. package/dist/routes/api/upload.js +3 -3
  30. package/dist/routes/auth/reset.js +221 -0
  31. package/dist/routes/auth/setup.js +194 -0
  32. package/dist/routes/auth/signin.js +176 -0
  33. package/dist/routes/compose.js +48 -0
  34. package/dist/routes/dash/collections.js +24 -416
  35. package/dist/routes/dash/index.js +1 -1
  36. package/dist/routes/dash/media.js +13 -393
  37. package/dist/routes/dash/pages.js +112 -86
  38. package/dist/routes/dash/posts.js +3 -5
  39. package/dist/routes/dash/redirects.js +20 -14
  40. package/dist/routes/dash/settings.js +213 -518
  41. package/dist/routes/feed/rss.js +4 -3
  42. package/dist/routes/feed/sitemap.js +5 -3
  43. package/dist/routes/pages/archive.js +3 -6
  44. package/dist/routes/pages/collection.js +3 -6
  45. package/dist/routes/pages/collections.js +28 -0
  46. package/dist/routes/pages/featured.js +36 -0
  47. package/dist/routes/pages/home.js +33 -49
  48. package/dist/routes/pages/latest.js +45 -0
  49. package/dist/routes/pages/page.js +29 -32
  50. package/dist/routes/pages/post.js +3 -6
  51. package/dist/routes/pages/search.js +3 -6
  52. package/dist/services/page.js +5 -1
  53. package/dist/services/post.js +45 -31
  54. package/dist/services/search.js +1 -1
  55. package/dist/types/bindings.js +3 -0
  56. package/dist/types/config.js +147 -0
  57. package/dist/types/constants.js +27 -0
  58. package/dist/types/entities.js +3 -0
  59. package/dist/types/operations.js +3 -0
  60. package/dist/types/props.js +3 -0
  61. package/dist/types/views.js +5 -0
  62. package/dist/types.js +8 -111
  63. package/dist/{theme → ui}/color-themes.js +33 -33
  64. package/dist/ui/compose/ComposeDialog.js +467 -0
  65. package/dist/ui/compose/ComposePrompt.js +55 -0
  66. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
  67. package/dist/{theme/components → ui/dash}/PageForm.js +21 -15
  68. package/dist/{theme/components → ui/dash}/PostForm.js +22 -43
  69. package/dist/{theme/components → ui/dash}/PostList.js +6 -6
  70. package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
  71. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  72. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  73. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  74. package/dist/{theme/components → ui/dash}/index.js +3 -6
  75. package/dist/ui/dash/media/MediaListContent.js +166 -0
  76. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  77. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  78. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  79. package/dist/ui/dash/settings/AccountContent.js +209 -0
  80. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  81. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  82. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  83. package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
  84. package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
  85. package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
  86. package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
  87. package/dist/ui/feed/TimelineFeed.js +41 -0
  88. package/dist/ui/feed/TimelineItem.js +27 -0
  89. package/dist/ui/font-themes.js +36 -0
  90. package/dist/{theme → ui}/layouts/BaseLayout.js +34 -2
  91. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  92. package/dist/ui/layouts/SiteLayout.js +169 -0
  93. package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
  94. package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
  95. package/dist/ui/pages/CollectionsPage.js +76 -0
  96. package/dist/ui/pages/FeaturedPage.js +24 -0
  97. package/dist/ui/pages/HomePage.js +24 -0
  98. package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
  99. package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
  100. package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
  101. package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
  102. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  103. package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
  104. package/dist/ui/shared/index.js +5 -0
  105. package/package.json +1 -9
  106. package/src/__tests__/helpers/db.ts +3 -0
  107. package/src/app.tsx +131 -561
  108. package/src/client.ts +1 -0
  109. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  110. package/src/db/migrations/meta/_journal.json +7 -0
  111. package/src/db/schema.ts +1 -1
  112. package/src/i18n/locales/en.po +477 -261
  113. package/src/i18n/locales/en.ts +1 -1
  114. package/src/i18n/locales/zh-Hans.po +477 -261
  115. package/src/i18n/locales/zh-Hans.ts +1 -1
  116. package/src/i18n/locales/zh-Hant.po +477 -261
  117. package/src/i18n/locales/zh-Hant.ts +1 -1
  118. package/src/index.ts +7 -36
  119. package/src/lib/__tests__/config.test.ts +192 -0
  120. package/src/lib/__tests__/favicon.test.ts +151 -0
  121. package/src/lib/__tests__/image.test.ts +2 -6
  122. package/src/lib/__tests__/schemas.test.ts +60 -19
  123. package/src/lib/__tests__/timeline.test.ts +45 -81
  124. package/src/lib/__tests__/timezones.test.ts +61 -0
  125. package/src/lib/__tests__/view.test.ts +15 -9
  126. package/src/lib/avatar-upload.ts +165 -0
  127. package/src/lib/config.ts +47 -0
  128. package/src/lib/constants.ts +19 -10
  129. package/src/lib/favicon.ts +115 -0
  130. package/src/lib/image.ts +13 -21
  131. package/src/lib/media-helpers.ts +2 -2
  132. package/src/lib/nav-reorder.ts +1 -1
  133. package/src/lib/navigation.ts +73 -4
  134. package/src/lib/pagination.ts +50 -0
  135. package/src/lib/render.tsx +22 -15
  136. package/src/lib/schemas.ts +47 -6
  137. package/src/lib/theme.ts +5 -5
  138. package/src/lib/timeline.ts +28 -57
  139. package/src/lib/timezones.ts +325 -0
  140. package/src/lib/view.ts +3 -3
  141. package/src/preset.css +2 -1
  142. package/src/routes/__tests__/compose.test.ts +199 -0
  143. package/src/routes/api/__tests__/collections.test.ts +249 -0
  144. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  145. package/src/routes/api/__tests__/pages.test.ts +218 -0
  146. package/src/routes/api/__tests__/settings.test.ts +132 -0
  147. package/src/routes/api/collections.ts +143 -0
  148. package/src/routes/api/nav-items.ts +115 -0
  149. package/src/routes/api/pages.ts +101 -0
  150. package/src/routes/api/posts.ts +3 -3
  151. package/src/routes/api/search.ts +2 -2
  152. package/src/routes/api/settings.ts +91 -0
  153. package/src/routes/api/upload.ts +2 -3
  154. package/src/routes/auth/reset.tsx +239 -0
  155. package/src/routes/auth/setup.tsx +189 -0
  156. package/src/routes/auth/signin.tsx +163 -0
  157. package/src/routes/compose.ts +63 -0
  158. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  159. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  160. package/src/routes/dash/collections.tsx +18 -367
  161. package/src/routes/dash/index.tsx +1 -1
  162. package/src/routes/dash/media.tsx +13 -415
  163. package/src/routes/dash/pages.tsx +131 -98
  164. package/src/routes/dash/posts.tsx +3 -7
  165. package/src/routes/dash/redirects.tsx +22 -16
  166. package/src/routes/dash/settings.tsx +265 -478
  167. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  168. package/src/routes/feed/rss.ts +5 -3
  169. package/src/routes/feed/sitemap.ts +5 -3
  170. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  171. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  172. package/src/routes/pages/archive.tsx +2 -6
  173. package/src/routes/pages/collection.tsx +2 -6
  174. package/src/routes/pages/collections.tsx +36 -0
  175. package/src/routes/pages/featured.tsx +44 -0
  176. package/src/routes/pages/home.tsx +30 -53
  177. package/src/routes/pages/latest.tsx +59 -0
  178. package/src/routes/pages/page.tsx +28 -30
  179. package/src/routes/pages/post.tsx +2 -5
  180. package/src/routes/pages/search.tsx +2 -6
  181. package/src/services/__tests__/page.test.ts +106 -0
  182. package/src/services/__tests__/post.test.ts +114 -15
  183. package/src/services/page.ts +13 -1
  184. package/src/services/post.ts +58 -40
  185. package/src/services/search.ts +2 -2
  186. package/src/styles/components.css +0 -65
  187. package/src/styles/tokens.css +47 -0
  188. package/src/styles/ui.css +475 -0
  189. package/src/types/bindings.ts +30 -0
  190. package/src/types/config.ts +183 -0
  191. package/src/types/constants.ts +26 -0
  192. package/src/types/entities.ts +109 -0
  193. package/src/types/operations.ts +88 -0
  194. package/src/types/props.ts +115 -0
  195. package/src/types/views.ts +172 -0
  196. package/src/types.ts +8 -774
  197. package/src/ui/__tests__/font-themes.test.ts +34 -0
  198. package/src/{theme → ui}/color-themes.ts +34 -34
  199. package/src/ui/compose/ComposeDialog.tsx +414 -0
  200. package/src/ui/compose/ComposePrompt.tsx +55 -0
  201. package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
  202. package/src/{theme/components → ui/dash}/PageForm.tsx +25 -19
  203. package/src/{theme/components → ui/dash}/PostForm.tsx +26 -45
  204. package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
  205. package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
  206. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  207. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  208. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  209. package/src/ui/dash/index.ts +10 -0
  210. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  211. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  212. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  213. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  214. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  215. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  216. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  217. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  218. package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
  219. package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
  220. package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
  221. package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
  222. package/src/ui/feed/TimelineFeed.tsx +49 -0
  223. package/src/ui/feed/TimelineItem.tsx +45 -0
  224. package/src/ui/font-themes.ts +54 -0
  225. package/src/{theme → ui}/layouts/BaseLayout.tsx +28 -1
  226. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  227. package/src/ui/layouts/SiteLayout.tsx +164 -0
  228. package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
  229. package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
  230. package/src/ui/pages/CollectionsPage.tsx +73 -0
  231. package/src/ui/pages/FeaturedPage.tsx +31 -0
  232. package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
  233. package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
  234. package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
  235. package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
  236. package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
  237. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  238. package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
  239. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  240. package/src/ui/shared/index.ts +12 -0
  241. package/bin/jant.js +0 -185
  242. package/dist/lib/theme-components.js +0 -46
  243. package/dist/routes/dash/navigation.js +0 -289
  244. package/dist/theme/index.js +0 -18
  245. package/dist/theme/layouts/index.js +0 -2
  246. package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
  247. package/dist/themes/threads/index.js +0 -81
  248. package/dist/themes/threads/pages/HomePage.js +0 -25
  249. package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
  250. package/dist/themes/threads/timeline/TimelineItem.js +0 -36
  251. package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
  252. package/dist/themes/threads/timeline/groupByDate.js +0 -22
  253. package/dist/themes/threads/timeline/timelineMore.js +0 -107
  254. package/src/lib/__tests__/theme-components.test.ts +0 -105
  255. package/src/lib/theme-components.ts +0 -65
  256. package/src/routes/dash/navigation.tsx +0 -317
  257. package/src/theme/components/index.ts +0 -23
  258. package/src/theme/index.ts +0 -22
  259. package/src/theme/layouts/index.ts +0 -7
  260. package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
  261. package/src/themes/threads/index.ts +0 -100
  262. package/src/themes/threads/style.css +0 -336
  263. package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
  264. package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
  265. package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
  266. package/src/themes/threads/timeline/groupByDate.ts +0 -30
  267. package/src/themes/threads/timeline/timelineMore.tsx +0 -130
  268. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  269. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  270. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  271. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  272. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  273. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  274. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  275. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  276. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  277. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
package/src/lib/image.ts CHANGED
@@ -102,36 +102,28 @@ export function getPublicUrlForProvider(
102
102
  }
103
103
 
104
104
  /**
105
- * Generates a media URL using UUIDv7-based paths.
105
+ * Generates a media URL from a storage key.
106
106
  *
107
- * Returns a public URL for a media file. If `publicUrl` is set, uses that directly
108
- * with the storage key. Otherwise, generates a `/media/{id}.{ext}` local proxy URL.
107
+ * Both proxy and CDN paths use the same structure only the domain differs.
108
+ * Without a public URL, returns a root-relative path for the local proxy.
109
+ * With a public URL, prefixes that domain.
109
110
  *
110
- * @param mediaId - The UUIDv7 database ID of the media
111
- * @param storageKey - The storage object key (used to build CDN path and extract extension)
111
+ * @param storageKey - The storage object key (e.g. `"media/2025/01/uuid.webp"`)
112
112
  * @param publicUrl - Optional public URL base for direct CDN access
113
113
  * @returns The public URL for the media file
114
114
  *
115
115
  * @example
116
116
  * ```ts
117
- * // Without public URL - uses local proxy with UUID and extension
118
- * getMediaUrl("01902a9f-1a2b-7c3d", "media/2025/01/01902a9f-1a2b-7c3d.webp");
119
- * // Returns: "/media/01902a9f-1a2b-7c3d.webp"
117
+ * // Without public URL - local proxy
118
+ * getMediaUrl("media/2025/01/01902a9f-1a2b-7c3d.webp");
119
+ * // Returns: "/media/2025/01/01902a9f-1a2b-7c3d.webp"
120
120
  *
121
- * // With public URL - uses direct CDN
122
- * getMediaUrl("01902a9f-1a2b-7c3d", "media/2025/01/01902a9f-1a2b-7c3d.webp", "https://cdn.example.com");
121
+ * // With public URL - CDN
122
+ * getMediaUrl("media/2025/01/01902a9f-1a2b-7c3d.webp", "https://cdn.example.com");
123
123
  * // Returns: "https://cdn.example.com/media/2025/01/01902a9f-1a2b-7c3d.webp"
124
124
  * ```
125
125
  */
126
- export function getMediaUrl(
127
- mediaId: string,
128
- storageKey: string,
129
- publicUrl?: string,
130
- ): string {
131
- if (publicUrl) {
132
- return `${publicUrl.replace(/\/+$/, "")}/${storageKey}`;
133
- }
134
- // Extract extension from storage key
135
- const ext = storageKey.split(".").pop() || "bin";
136
- return `/media/${mediaId}.${ext}`;
126
+ export function getMediaUrl(storageKey: string, publicUrl?: string): string {
127
+ const base = publicUrl ? publicUrl.replace(/\/+$/, "") : "";
128
+ return `${base}/${storageKey}`;
137
129
  }
@@ -45,9 +45,9 @@ export function buildMediaMap(
45
45
  );
46
46
  return {
47
47
  id: m.id,
48
- url: getMediaUrl(m.id, m.storageKey, publicUrl),
48
+ url: getMediaUrl(m.storageKey, publicUrl),
49
49
  previewUrl: getImageUrl(
50
- getMediaUrl(m.id, m.storageKey, publicUrl),
50
+ getMediaUrl(m.storageKey, publicUrl),
51
51
  imageTransformUrl,
52
52
  { width: 400, quality: 80, format: "auto", fit: "cover" },
53
53
  ),
@@ -16,7 +16,7 @@ if (list) {
16
16
  const ids = [...list.querySelectorAll<HTMLElement>("[data-id]")].map(
17
17
  (el) => Number(el.dataset.id),
18
18
  );
19
- fetch("/dash/navigation/reorder", {
19
+ fetch("/dash/pages/reorder", {
20
20
  method: "POST",
21
21
  headers: { "Content-Type": "application/json" },
22
22
  body: JSON.stringify({ ids }),
@@ -5,9 +5,11 @@
5
5
  */
6
6
 
7
7
  import type { Context } from "hono";
8
- import { getSiteName } from "./config.js";
9
- import type { NavItemView } from "../types.js";
8
+ import { getSiteName, getHomeDefaultView, getSiteFooter } from "./config.js";
9
+ import type { Collection, NavItemView } from "../types.js";
10
10
  import { toNavItemViews } from "./view.js";
11
+ import { getMediaUrl, getPublicUrlForProvider } from "./image.js";
12
+ import { render as renderMarkdown } from "./markdown.js";
11
13
 
12
14
  /**
13
15
  * Navigation data needed by SiteLayout
@@ -16,12 +18,20 @@ export interface NavigationData {
16
18
  links: NavItemView[];
17
19
  currentPath: string;
18
20
  siteName: string;
21
+ siteDescription: string;
22
+ isAuthenticated: boolean;
23
+ collections: Collection[];
24
+ homeDefaultView: string;
25
+ siteAvatarUrl?: string;
26
+ showHeaderAvatar?: boolean;
27
+ siteFooterHtml?: string;
19
28
  }
20
29
 
21
30
  /**
22
31
  * Fetch navigation data for public pages.
23
32
  *
24
33
  * Returns NavItemView[] with pre-computed isActive/isExternal state.
34
+ * Also checks authentication status and loads collections for authenticated users.
25
35
  *
26
36
  * @param c - Hono context
27
37
  * @returns Navigation data for SiteLayout
@@ -39,7 +49,66 @@ export interface NavigationData {
39
49
  export async function getNavigationData(c: Context): Promise<NavigationData> {
40
50
  const items = await c.var.services.navItems.list();
41
51
  const currentPath = new URL(c.req.url).pathname;
42
- const siteName = await getSiteName(c);
52
+ const [siteName, homeDefaultView, siteFooter] = await Promise.all([
53
+ getSiteName(c),
54
+ getHomeDefaultView(c),
55
+ getSiteFooter(c),
56
+ ]);
57
+
58
+ // Only include description if explicitly set (DB or env), not the default
59
+ const dbDescription = await c.var.services.settings.get("SITE_DESCRIPTION");
60
+ const envDescription = c.env.SITE_DESCRIPTION;
61
+ const siteDescription =
62
+ dbDescription || (typeof envDescription === "string" ? envDescription : "");
63
+
64
+ // Resolve avatar URL from storage key
65
+ const avatarKey = await c.var.services.settings.get("SITE_AVATAR");
66
+ const showHeaderAvatar =
67
+ (await c.var.services.settings.get("SHOW_HEADER_AVATAR")) === "true";
68
+ let siteAvatarUrl: string | undefined;
69
+ if (avatarKey) {
70
+ const publicUrl = getPublicUrlForProvider(
71
+ c.env.STORAGE_DRIVER || "r2",
72
+ c.env.R2_PUBLIC_URL,
73
+ c.env.S3_PUBLIC_URL,
74
+ );
75
+ siteAvatarUrl = getMediaUrl(avatarKey, publicUrl);
76
+ }
77
+
78
+ // Render footer markdown
79
+ const siteFooterHtml = siteFooter ? renderMarkdown(siteFooter) : undefined;
80
+
43
81
  const links = toNavItemViews(items, currentPath);
44
- return { links, currentPath, siteName };
82
+
83
+ // Check auth status for compose button
84
+ let isAuthenticated = false;
85
+ let collections: Collection[] = [];
86
+ if (c.var.auth) {
87
+ try {
88
+ const session = await c.var.auth.api.getSession({
89
+ headers: c.req.raw.headers,
90
+ });
91
+ isAuthenticated = !!session?.user;
92
+ } catch {
93
+ // Not authenticated
94
+ }
95
+ }
96
+
97
+ // Only load collections when authenticated (for compose dialog)
98
+ if (isAuthenticated) {
99
+ collections = await c.var.services.collections.list();
100
+ }
101
+
102
+ return {
103
+ links,
104
+ currentPath,
105
+ siteName,
106
+ siteDescription,
107
+ isAuthenticated,
108
+ collections,
109
+ homeDefaultView,
110
+ siteAvatarUrl,
111
+ showHeaderAvatar: showHeaderAvatar && !!siteAvatarUrl,
112
+ siteFooterHtml,
113
+ };
45
114
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pagination Utilities
3
+ *
4
+ * Pure utility functions for page-based pagination.
5
+ */
6
+
7
+ /**
8
+ * Computes which page numbers to display in a numbered pagination control.
9
+ * Always includes: first page, last page, current page, and 1 page on each side of current.
10
+ * Gaps between non-consecutive pages are represented by 0 (ellipsis marker).
11
+ *
12
+ * @param currentPage - The current active page (1-indexed)
13
+ * @param totalPages - Total number of pages
14
+ * @returns Array of page numbers, with 0 representing ellipsis gaps
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * getPageNumbers(1, 5) // [1, 2, 3, 4, 5]
19
+ * getPageNumbers(1, 20) // [1, 2, 0, 20]
20
+ * getPageNumbers(10, 20) // [1, 0, 9, 10, 11, 0, 20]
21
+ * ```
22
+ */
23
+ export function getPageNumbers(
24
+ currentPage: number,
25
+ totalPages: number,
26
+ ): number[] {
27
+ if (totalPages <= 7) {
28
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
29
+ }
30
+
31
+ const pages = new Set<number>();
32
+ pages.add(1);
33
+ pages.add(totalPages);
34
+ pages.add(currentPage);
35
+ if (currentPage > 1) pages.add(currentPage - 1);
36
+ if (currentPage < totalPages) pages.add(currentPage + 1);
37
+
38
+ const sorted = [...pages].sort((a, b) => a - b);
39
+
40
+ // Insert 0 for gaps
41
+ const result: number[] = [];
42
+ for (let i = 0; i < sorted.length; i++) {
43
+ if (i > 0 && sorted[i]! - sorted[i - 1]! > 1) {
44
+ result.push(0); // ellipsis marker
45
+ }
46
+ result.push(sorted[i]!);
47
+ }
48
+
49
+ return result;
50
+ }
@@ -3,16 +3,13 @@
3
3
  *
4
4
  * Provides a single entry point for rendering public pages with the
5
5
  * correct layout stack: BaseLayout > SiteLayout > content.
6
- *
7
- * BaseLayout is always the built-in implementation (handles Vite assets,
8
- * I18nProvider, toast). SiteLayout is resolved from theme components.
9
6
  */
10
7
 
11
8
  import type { Context } from "hono";
12
9
  import type { Child } from "hono/jsx";
13
- import type { ThemeComponents, SiteLayoutProps } from "../types.js";
14
- import { BaseLayout } from "../theme/layouts/BaseLayout.js";
15
- import { ThreadsSiteLayout as DefaultSiteLayout } from "../themes/threads/ThreadsSiteLayout.js";
10
+ import type { SiteLayoutProps } from "../types.js";
11
+ import { BaseLayout } from "../ui/layouts/BaseLayout.js";
12
+ import { SiteLayout } from "../ui/layouts/SiteLayout.js";
16
13
  import type { NavigationData } from "./navigation.js";
17
14
 
18
15
  export interface RenderPublicPageOptions {
@@ -29,8 +26,6 @@ export interface RenderPublicPageOptions {
29
26
  /**
30
27
  * Render a public page with the standard layout stack.
31
28
  *
32
- * Always uses the built-in BaseLayout, resolves SiteLayout from theme config.
33
- *
34
29
  * @param c - Hono context
35
30
  * @param options - Page rendering options
36
31
  * @returns Hono HTML response
@@ -48,20 +43,32 @@ export interface RenderPublicPageOptions {
48
43
  export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
49
44
  const { title, description, navData, content } = options;
50
45
 
51
- const components = c.var.config?.theme?.components as
52
- | ThemeComponents
53
- | undefined;
54
- const Layout = components?.SiteLayout ?? DefaultSiteLayout;
55
-
56
46
  const layoutProps: SiteLayoutProps = {
57
47
  siteName: navData.siteName,
48
+ siteDescription: navData.siteDescription,
58
49
  links: navData.links,
59
50
  currentPath: navData.currentPath,
51
+ isAuthenticated: navData.isAuthenticated,
52
+ collections: navData.collections,
53
+ homeDefaultView: navData.homeDefaultView,
54
+ siteAvatarUrl: navData.siteAvatarUrl,
55
+ showHeaderAvatar: navData.showHeaderAvatar,
56
+ siteFooterHtml: navData.siteFooterHtml,
60
57
  };
61
58
 
59
+ // Read favicon and noindex from context (set by theme middleware)
60
+ const faviconUrl = c.get("faviconUrl") as string | undefined;
61
+ const noindex = c.get("noindex") as boolean | undefined;
62
+
62
63
  return c.html(
63
- <BaseLayout title={title} description={description} c={c}>
64
- <Layout {...layoutProps}>{content}</Layout>
64
+ <BaseLayout
65
+ title={title}
66
+ description={description}
67
+ c={c}
68
+ faviconUrl={faviconUrl}
69
+ noindex={noindex}
70
+ >
71
+ <SiteLayout {...layoutProps}>{content}</SiteLayout>
65
72
  </BaseLayout>,
66
73
  );
67
74
  }
@@ -51,19 +51,20 @@ export const RedirectTypeSchema = z.enum(["301", "302"]);
51
51
  export const RatingSchema = z.coerce
52
52
  .number()
53
53
  .int()
54
- .min(1)
54
+ .min(0)
55
55
  .max(5)
56
56
  .optional()
57
- .or(z.literal("").transform(() => undefined));
57
+ .or(z.literal("").transform(() => undefined))
58
+ .transform((v) => (v === 0 ? undefined : v));
58
59
 
59
60
  /**
60
61
  * API request body schema for creating a post
61
62
  */
62
63
  export const CreatePostSchema = z.object({
63
64
  format: FormatSchema,
64
- slug: z
65
+ path: z
65
66
  .string()
66
- .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/)
67
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\/[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/)
67
68
  .optional()
68
69
  .or(z.literal("").transform(() => undefined)),
69
70
  title: z.string().optional(),
@@ -81,9 +82,10 @@ export const CreatePostSchema = z.object({
81
82
  collectionId: z.coerce
82
83
  .number()
83
84
  .int()
84
- .positive()
85
+ .min(0)
85
86
  .optional()
86
- .or(z.literal("").transform(() => undefined)),
87
+ .or(z.literal("").transform(() => undefined))
88
+ .transform((v) => (v === 0 ? undefined : v)),
87
89
  replyToId: z.string().optional(), // Sqid format
88
90
  publishedAt: z.number().int().positive().optional(),
89
91
  mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
@@ -151,6 +153,45 @@ export const CreateCollectionSchema = z.object({
151
153
  */
152
154
  export const UpdateCollectionSchema = CreateCollectionSchema.partial();
153
155
 
156
+ // =============================================================================
157
+ // Auth Schemas
158
+ // =============================================================================
159
+
160
+ /**
161
+ * Setup form validation schema
162
+ */
163
+ export const SetupSchema = z.object({
164
+ name: z.string().min(1, "Name is required"),
165
+ email: z.string().email("Invalid email address"),
166
+ password: z.string().min(8, "Password must be at least 8 characters"),
167
+ });
168
+
169
+ /**
170
+ * Sign-in form validation schema
171
+ */
172
+ export const SigninSchema = z.object({
173
+ email: z.string().email("Invalid email address"),
174
+ password: z.string().min(1, "Password is required"),
175
+ });
176
+
177
+ /**
178
+ * Password reset form validation schema
179
+ */
180
+ export const ResetPasswordSchema = z
181
+ .object({
182
+ password: z.string().min(8, "Password must be at least 8 characters"),
183
+ confirmPassword: z.string().min(1),
184
+ token: z.string().min(1),
185
+ })
186
+ .refine((d) => d.password === d.confirmPassword, {
187
+ message: "Passwords do not match",
188
+ path: ["confirmPassword"],
189
+ });
190
+
191
+ // =============================================================================
192
+ // Form Data Helpers
193
+ // =============================================================================
194
+
154
195
  /**
155
196
  * Form data helper: safely parse a FormData value with a schema
156
197
  *
package/src/lib/theme.ts CHANGED
@@ -4,14 +4,14 @@
4
4
  * Resolves the active color theme and builds CSS for injection into `<head>`.
5
5
  */
6
6
 
7
- import type { ColorTheme } from "../theme/color-themes.js";
8
- import { BUILTIN_COLOR_THEMES } from "../theme/color-themes.js";
7
+ import type { ColorTheme } from "../ui/color-themes.js";
8
+ import { BUILTIN_COLOR_THEMES } from "../ui/color-themes.js";
9
9
  import type { JantConfig } from "../types.js";
10
10
 
11
11
  /**
12
12
  * Get the list of available color themes.
13
13
  *
14
- * Returns `config.theme.colorThemes` if provided, otherwise the built-in list.
14
+ * Returns `config.colorThemes` if provided, otherwise the built-in list.
15
15
  *
16
16
  * @param config - The Jant configuration
17
17
  * @returns Array of available color themes
@@ -22,7 +22,7 @@ import type { JantConfig } from "../types.js";
22
22
  * ```
23
23
  */
24
24
  export function getAvailableThemes(config: JantConfig): ColorTheme[] {
25
- return config.theme?.colorThemes ?? BUILTIN_COLOR_THEMES;
25
+ return config.colorThemes ?? BUILTIN_COLOR_THEMES;
26
26
  }
27
27
 
28
28
  /**
@@ -32,7 +32,7 @@ export function getAvailableThemes(config: JantConfig): ColorTheme[] {
32
32
  * BaseCoat defaults → selected theme → cssVariables
33
33
  *
34
34
  * @param theme - The active color theme (undefined = no theme overrides)
35
- * @param cssVariables - Extra CSS variable overrides from `createApp({ theme: { cssVariables } })`
35
+ * @param cssVariables - Extra CSS variable overrides from `createApp({ cssVariables })`
36
36
  * @returns CSS string to inject in `<head>`, or empty string if nothing to inject
37
37
  *
38
38
  * Uses `:root:root` and `:root.dark` selectors for higher specificity than
@@ -2,11 +2,11 @@
2
2
  * Timeline Data Assembly
3
3
  *
4
4
  * Shared helper for assembling timeline items with media and thread previews.
5
- * Used by both full-page rendering and load-more SSE responses.
5
+ * Used by page rendering with page-based pagination.
6
6
  */
7
7
 
8
8
  import type { Context } from "hono";
9
- import type { Bindings, TimelineItemView, DateGroup } from "../types.js";
9
+ import type { Bindings, TimelineItemView } from "../types.js";
10
10
  import type { AppVariables } from "../app.js";
11
11
  import { buildMediaMap } from "./media-helpers.js";
12
12
  import { createMediaContext, toPostView, toPostViews } from "./view.js";
@@ -20,51 +20,58 @@ const DEFAULT_PAGE_SIZE = 20;
20
20
  */
21
21
  export interface TimelineResult {
22
22
  items: TimelineItemView[];
23
- hasMore: boolean;
24
- nextCursor?: number;
23
+ currentPage: number;
24
+ totalPages: number;
25
25
  }
26
26
 
27
27
  /**
28
28
  * Assembles a page of timeline items with media attachments and thread previews.
29
29
  *
30
- * Fetches posts, batch-loads media, identifies threads, and returns
31
- * render-ready `TimelineItemView[]` with pagination info.
30
+ * Fetches posts using offset-based pagination, batch-loads media, identifies
31
+ * threads, and returns render-ready `TimelineItemView[]` with page info.
32
32
  *
33
33
  * @param c - Hono context (provides services + env)
34
- * @param options - Optional cursor for pagination
34
+ * @param options - Optional page number (1-indexed, defaults to 1)
35
35
  * @returns Assembled timeline items with pagination info
36
36
  *
37
37
  * @example
38
38
  * ```ts
39
- * const { items, hasMore, nextCursor } = await assembleTimeline(c);
40
- * const { items, hasMore, nextCursor } = await assembleTimeline(c, { cursor: 42 });
39
+ * const { items, currentPage, totalPages } = await assembleTimeline(c);
40
+ * const { items, currentPage, totalPages } = await assembleTimeline(c, { page: 2 });
41
41
  * ```
42
42
  */
43
43
  export async function assembleTimeline(
44
44
  c: Context<Env>,
45
- options?: { cursor?: number },
45
+ options?: { page?: number },
46
46
  ): Promise<TimelineResult> {
47
47
  const pageSize =
48
48
  parseInt(c.env.PAGE_SIZE ?? String(DEFAULT_PAGE_SIZE), 10) ||
49
49
  DEFAULT_PAGE_SIZE;
50
50
 
51
- // Fetch one extra to determine if there are more
52
- const posts = await c.var.services.posts.list({
51
+ const page = Math.max(1, options?.page ?? 1);
52
+ const offset = (page - 1) * pageSize;
53
+
54
+ // Get total count for pagination
55
+ const totalCount = await c.var.services.posts.count({
53
56
  status: "published",
54
57
  excludeReplies: true,
55
- limit: pageSize + 1,
56
- cursor: options?.cursor,
57
58
  });
59
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
58
60
 
59
- const hasMore = posts.length > pageSize;
60
- const displayPosts = hasMore ? posts.slice(0, pageSize) : posts;
61
+ // Fetch posts for the current page
62
+ const posts = await c.var.services.posts.list({
63
+ status: "published",
64
+ excludeReplies: true,
65
+ limit: pageSize,
66
+ offset,
67
+ });
61
68
 
62
- if (displayPosts.length === 0) {
63
- return { items: [], hasMore: false };
69
+ if (posts.length === 0) {
70
+ return { items: [], currentPage: page, totalPages };
64
71
  }
65
72
 
66
73
  // Batch load media attachments
67
- const postIds = displayPosts.map((p) => p.id);
74
+ const postIds = posts.map((p) => p.id);
68
75
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
69
76
  const mediaCtx = createMediaContext(c);
70
77
  const mediaMap = buildMediaMap(
@@ -102,7 +109,7 @@ export async function assembleTimeline(
102
109
  : new Map();
103
110
 
104
111
  // Assemble timeline items with View Models
105
- const items: TimelineItemView[] = displayPosts.map((post) => {
112
+ const items: TimelineItemView[] = posts.map((post) => {
106
113
  const postView = toPostView(
107
114
  { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
108
115
  mediaCtx,
@@ -130,41 +137,5 @@ export async function assembleTimeline(
130
137
  return { post: postView };
131
138
  });
132
139
 
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;
140
+ return { items, currentPage: page, totalPages };
170
141
  }