@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
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Latest Page Route
3
+ *
4
+ * Explicit /latest URL that always shows the latest posts timeline.
5
+ * When HOME_DEFAULT_VIEW is "latest" (default), this redirects to /
6
+ * to avoid duplicate content. When it's "featured", this serves as
7
+ * the explicit latest view.
8
+ */
9
+
10
+ import { Hono } from "hono";
11
+ import type { Bindings } from "../../types.js";
12
+ import type { AppVariables } from "../../app.js";
13
+ import { getNavigationData } from "../../lib/navigation.js";
14
+ import { renderPublicPage } from "../../lib/render.js";
15
+ import { assembleTimeline } from "../../lib/timeline.js";
16
+ import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
17
+ import { HomePage } from "../../ui/pages/HomePage.js";
18
+
19
+ type Env = { Bindings: Bindings; Variables: AppVariables };
20
+
21
+ export const latestRoutes = new Hono<Env>();
22
+
23
+ latestRoutes.get("/", async (c) => {
24
+ const navData = await getNavigationData(c);
25
+
26
+ // When homepage already shows latest, redirect to avoid duplicate content
27
+ if (navData.homeDefaultView !== "featured") {
28
+ return c.redirect("/", 302);
29
+ }
30
+
31
+ const pageParam = c.req.query("page");
32
+ const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
33
+
34
+ const { items, currentPage, totalPages } = await assembleTimeline(c, {
35
+ page,
36
+ });
37
+
38
+ // Fetch pinned posts
39
+ const pinnedPosts = await c.var.services.posts.list({
40
+ pinned: true,
41
+ status: "published",
42
+ excludeReplies: true,
43
+ });
44
+ const mediaCtx = createMediaContext(c);
45
+ const pinnedItems = toPostViewsFromPosts(pinnedPosts, mediaCtx);
46
+
47
+ return renderPublicPage(c, {
48
+ title: `Latest - ${navData.siteName}`,
49
+ navData,
50
+ content: (
51
+ <HomePage
52
+ items={items}
53
+ pinnedItems={pinnedItems}
54
+ currentPage={currentPage}
55
+ totalPages={totalPages}
56
+ />
57
+ ),
58
+ });
59
+ });
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Custom Page Route
3
3
  *
4
- * Serves pages from the pages table and posts with custom slugs.
4
+ * Serves pages from the pages table and posts with custom paths.
5
5
  * This is a catch-all route mounted at "/" - must be registered last.
6
+ * Supports multi-level paths (e.g. /2024/my-post) for posts.
6
7
  */
7
8
 
8
9
  import { Hono } from "hono";
9
10
  import type { Bindings } from "../../types.js";
10
11
  import type { AppVariables } from "../../app.js";
11
- import { SinglePage as DefaultSinglePage } from "../../themes/threads/pages/SinglePage.js";
12
- import { PostPage as DefaultPostPage } from "../../themes/threads/pages/PostPage.js";
12
+ import { SinglePage } from "../../ui/pages/SinglePage.js";
13
+ import { PostPage } from "../../ui/pages/PostPage.js";
13
14
  import { getNavigationData } from "../../lib/navigation.js";
14
15
  import { renderPublicPage } from "../../lib/render.js";
15
16
  import { buildMediaMap } from "../../lib/media-helpers.js";
@@ -19,38 +20,38 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
19
20
 
20
21
  export const pageRoutes = new Hono<Env>();
21
22
 
22
- // Catch-all for custom page paths and post slugs
23
- pageRoutes.get("/:slug", async (c) => {
24
- const slug = c.req.param("slug");
23
+ // Catch-all for custom page slugs and post paths (including multi-level)
24
+ pageRoutes.get("/*", async (c) => {
25
+ const fullPath = c.req.path.slice(1); // Remove leading /
26
+ if (!fullPath) return c.notFound();
25
27
 
26
- // First, try to find a page by slug
27
- const page = await c.var.services.pages.getBySlug(slug);
28
+ const isMultiSegment = fullPath.includes("/");
28
29
 
29
- if (page) {
30
- // Don't show draft pages
31
- if (page.status === "draft") {
32
- return c.notFound();
33
- }
30
+ // Pages only have single-level slugs; skip page lookup for multi-segment paths
31
+ if (!isMultiSegment) {
32
+ const page = await c.var.services.pages.getBySlug(fullPath);
34
33
 
35
- const navData = await getNavigationData(c);
36
- const pageView = toPageView(page);
34
+ if (page) {
35
+ if (page.status === "draft") {
36
+ return c.notFound();
37
+ }
37
38
 
38
- const components = c.var.config.theme?.components;
39
- const Page = components?.SinglePage ?? DefaultSinglePage;
39
+ const navData = await getNavigationData(c);
40
+ const pageView = toPageView(page);
40
41
 
41
- return renderPublicPage(c, {
42
- title: `${page.title || slug} - ${navData.siteName}`,
43
- description: page.body?.slice(0, 160),
44
- navData,
45
- content: <Page page={pageView} theme={components} />,
46
- });
42
+ return renderPublicPage(c, {
43
+ title: `${page.title || fullPath} - ${navData.siteName}`,
44
+ description: page.body?.slice(0, 160),
45
+ navData,
46
+ content: <SinglePage page={pageView} />,
47
+ });
48
+ }
47
49
  }
48
50
 
49
- // Then, try to find a post by slug
50
- const post = await c.var.services.posts.getBySlug(slug);
51
+ // Posts support multi-level paths
52
+ const post = await c.var.services.posts.getByPath(fullPath);
51
53
 
52
54
  if (post) {
53
- // Don't show draft posts
54
55
  if (post.status === "draft") {
55
56
  return c.notFound();
56
57
  }
@@ -73,14 +74,11 @@ pageRoutes.get("/:slug", async (c) => {
73
74
  const navData = await getNavigationData(c);
74
75
  const title = post.title || navData.siteName;
75
76
 
76
- const components = c.var.config.theme?.components;
77
- const PostPage = components?.PostPage ?? DefaultPostPage;
78
-
79
77
  return renderPublicPage(c, {
80
78
  title,
81
79
  description: post.body?.slice(0, 160),
82
80
  navData,
83
- content: <PostPage post={postView} theme={components} />,
81
+ content: <PostPage post={postView} />,
84
82
  });
85
83
  }
86
84
 
@@ -5,7 +5,7 @@
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
- import { PostPage as DefaultPostPage } from "../../themes/threads/pages/PostPage.js";
8
+ import { PostPage } from "../../ui/pages/PostPage.js";
9
9
  import * as sqid from "../../lib/sqid.js";
10
10
  import { getNavigationData } from "../../lib/navigation.js";
11
11
  import { renderPublicPage } from "../../lib/render.js";
@@ -50,13 +50,10 @@ postRoutes.get("/:id", async (c) => {
50
50
  const navData = await getNavigationData(c);
51
51
  const title = post.title || navData.siteName;
52
52
 
53
- const components = c.var.config.theme?.components;
54
- const Page = components?.PostPage ?? DefaultPostPage;
55
-
56
53
  return renderPublicPage(c, {
57
54
  title,
58
55
  description: post.body?.slice(0, 160),
59
56
  navData,
60
- content: <Page post={postView} theme={components} />,
57
+ content: <PostPage post={postView} />,
61
58
  });
62
59
  });
@@ -5,7 +5,7 @@
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings, SearchResult } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
- import { SearchPage as DefaultSearchPage } from "../../themes/threads/pages/SearchPage.js";
8
+ import { SearchPage } from "../../ui/pages/SearchPage.js";
9
9
  import { getNavigationData } from "../../lib/navigation.js";
10
10
  import { renderPublicPage } from "../../lib/render.js";
11
11
  import { createMediaContext, toSearchResultViews } from "../../lib/view.js";
@@ -52,22 +52,18 @@ searchRoutes.get("/", async (c) => {
52
52
  const mediaCtx = createMediaContext(c);
53
53
  const resultViews = toSearchResultViews(results, mediaCtx);
54
54
 
55
- const components = c.var.config.theme?.components;
56
- const Page = components?.SearchPage ?? DefaultSearchPage;
57
-
58
55
  return renderPublicPage(c, {
59
56
  title: query
60
57
  ? `Search: ${query} - ${navData.siteName}`
61
58
  : `Search - ${navData.siteName}`,
62
59
  navData,
63
60
  content: (
64
- <Page
61
+ <SearchPage
65
62
  query={query}
66
63
  results={resultViews}
67
64
  error={error}
68
65
  hasMore={hasMore}
69
66
  page={page}
70
- theme={components}
71
67
  />
72
68
  ),
73
69
  });
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { createPageService } from "../page.js";
4
+ import { createNavItemService } from "../navigation.js";
5
+ import type { Database } from "../../db/index.js";
6
+
7
+ describe("PageService", () => {
8
+ let db: Database;
9
+ let pageService: ReturnType<typeof createPageService>;
10
+ let navItemService: ReturnType<typeof createNavItemService>;
11
+
12
+ beforeEach(() => {
13
+ const testDb = createTestDatabase();
14
+ db = testDb.db as unknown as Database;
15
+ pageService = createPageService(db);
16
+ navItemService = createNavItemService(db);
17
+ });
18
+
19
+ describe("listNotInNav", () => {
20
+ it("returns all pages when none are in navigation", async () => {
21
+ await pageService.create({ slug: "about", title: "About" });
22
+ await pageService.create({ slug: "contact", title: "Contact" });
23
+
24
+ const pages = await pageService.listNotInNav();
25
+ expect(pages).toHaveLength(2);
26
+ });
27
+
28
+ it("excludes pages that have a nav item", async () => {
29
+ const aboutPage = await pageService.create({
30
+ slug: "about",
31
+ title: "About",
32
+ });
33
+ await pageService.create({ slug: "contact", title: "Contact" });
34
+
35
+ // Add "About" to navigation
36
+ await navItemService.create({
37
+ type: "page",
38
+ label: "About",
39
+ url: "/about",
40
+ pageId: aboutPage.id,
41
+ });
42
+
43
+ const pages = await pageService.listNotInNav();
44
+ expect(pages).toHaveLength(1);
45
+ expect(pages[0]?.slug).toBe("contact");
46
+ });
47
+
48
+ it("returns empty array when all pages are in navigation", async () => {
49
+ const aboutPage = await pageService.create({
50
+ slug: "about",
51
+ title: "About",
52
+ });
53
+
54
+ await navItemService.create({
55
+ type: "page",
56
+ label: "About",
57
+ url: "/about",
58
+ pageId: aboutPage.id,
59
+ });
60
+
61
+ const pages = await pageService.listNotInNav();
62
+ expect(pages).toHaveLength(0);
63
+ });
64
+
65
+ it("returns empty array when no pages exist", async () => {
66
+ const pages = await pageService.listNotInNav();
67
+ expect(pages).toHaveLength(0);
68
+ });
69
+
70
+ it("is not affected by link-type nav items (no pageId)", async () => {
71
+ await pageService.create({ slug: "about", title: "About" });
72
+
73
+ // Link-type nav items have no pageId
74
+ await navItemService.create({
75
+ type: "link",
76
+ label: "External",
77
+ url: "https://example.com",
78
+ });
79
+
80
+ const pages = await pageService.listNotInNav();
81
+ expect(pages).toHaveLength(1);
82
+ });
83
+
84
+ it("returns multiple pages correctly", async () => {
85
+ await pageService.create({ slug: "first", title: "First" });
86
+ await pageService.create({ slug: "second", title: "Second" });
87
+ await pageService.create({ slug: "third", title: "Third" });
88
+
89
+ // Add one to nav
90
+ const pages = await pageService.list();
91
+ await navItemService.create({
92
+ type: "page",
93
+ label: "Second",
94
+ url: "/second",
95
+ pageId: pages.find((p) => p.slug === "second")!.id,
96
+ });
97
+
98
+ const notInNav = await pageService.listNotInNav();
99
+ expect(notInNav).toHaveLength(2);
100
+ const slugs = notInNav.map((p) => p.slug);
101
+ expect(slugs).toContain("first");
102
+ expect(slugs).toContain("third");
103
+ expect(slugs).not.toContain("second");
104
+ });
105
+ });
106
+ });
@@ -38,7 +38,7 @@ describe("PostService", () => {
38
38
  status: "published",
39
39
  featured: true,
40
40
  pinned: true,
41
- slug: "my-link",
41
+ path: "my-link",
42
42
  url: "https://example.com/source",
43
43
  quoteText: "A notable quote",
44
44
  rating: 5,
@@ -49,7 +49,7 @@ describe("PostService", () => {
49
49
  expect(post.status).toBe("published");
50
50
  expect(post.featured).toBe(1);
51
51
  expect(post.pinned).toBe(1);
52
- expect(post.slug).toBe("my-link");
52
+ expect(post.path).toBe("my-link");
53
53
  expect(post.url).toBe("https://example.com/source");
54
54
  expect(post.quoteText).toBe("A notable quote");
55
55
  expect(post.rating).toBe(5);
@@ -154,21 +154,21 @@ describe("PostService", () => {
154
154
  });
155
155
  });
156
156
 
157
- describe("getBySlug", () => {
158
- it("returns a post by slug", async () => {
157
+ describe("getByPath", () => {
158
+ it("returns a post by path", async () => {
159
159
  await postService.create({
160
160
  format: "note",
161
161
  body: "About page",
162
- slug: "about",
162
+ path: "about",
163
163
  });
164
164
 
165
- const found = await postService.getBySlug("about");
165
+ const found = await postService.getByPath("about");
166
166
  expect(found).not.toBeNull();
167
- expect(found?.slug).toBe("about");
167
+ expect(found?.path).toBe("about");
168
168
  });
169
169
 
170
- it("returns null for non-existent slug", async () => {
171
- const found = await postService.getBySlug("nonexistent");
170
+ it("returns null for non-existent path", async () => {
171
+ const found = await postService.getByPath("nonexistent");
172
172
  expect(found).toBeNull();
173
173
  });
174
174
 
@@ -176,13 +176,25 @@ describe("PostService", () => {
176
176
  const post = await postService.create({
177
177
  format: "note",
178
178
  body: "test",
179
- slug: "test-page",
179
+ path: "test-page",
180
180
  });
181
181
  await postService.delete(post.id);
182
182
 
183
- const found = await postService.getBySlug("test-page");
183
+ const found = await postService.getByPath("test-page");
184
184
  expect(found).toBeNull();
185
185
  });
186
+
187
+ it("finds a post with a multi-level path", async () => {
188
+ await postService.create({
189
+ format: "note",
190
+ body: "Blog migration",
191
+ path: "2024/01/my-post",
192
+ });
193
+
194
+ const found = await postService.getByPath("2024/01/my-post");
195
+ expect(found).not.toBeNull();
196
+ expect(found?.path).toBe("2024/01/my-post");
197
+ });
186
198
  });
187
199
 
188
200
  describe("list", () => {
@@ -358,6 +370,93 @@ describe("PostService", () => {
358
370
  expect(posts).toHaveLength(1);
359
371
  expect(posts[0]?.body).toBe("root post");
360
372
  });
373
+
374
+ it("supports offset pagination", async () => {
375
+ for (let i = 0; i < 5; i++) {
376
+ await postService.create({
377
+ format: "note",
378
+ body: `post ${i}`,
379
+ publishedAt: 1000 + i,
380
+ });
381
+ }
382
+
383
+ // Skip the first 2 posts (newest), get 2 more
384
+ const posts = await postService.list({ limit: 2, offset: 2 });
385
+ expect(posts).toHaveLength(2);
386
+ expect(posts[0]?.body).toBe("post 2");
387
+ expect(posts[1]?.body).toBe("post 1");
388
+ });
389
+ });
390
+
391
+ describe("count", () => {
392
+ it("returns 0 when no posts exist", async () => {
393
+ const count = await postService.count();
394
+ expect(count).toBe(0);
395
+ });
396
+
397
+ it("counts all non-deleted posts", async () => {
398
+ await postService.create({ format: "note", body: "first" });
399
+ await postService.create({ format: "note", body: "second" });
400
+ await postService.create({ format: "note", body: "third" });
401
+
402
+ const count = await postService.count();
403
+ expect(count).toBe(3);
404
+ });
405
+
406
+ it("filters by status", async () => {
407
+ await postService.create({
408
+ format: "note",
409
+ body: "published",
410
+ status: "published",
411
+ });
412
+ await postService.create({
413
+ format: "note",
414
+ body: "draft",
415
+ status: "draft",
416
+ });
417
+
418
+ const count = await postService.count({ status: "published" });
419
+ expect(count).toBe(1);
420
+ });
421
+
422
+ it("filters by featured", async () => {
423
+ await postService.create({
424
+ format: "note",
425
+ body: "featured",
426
+ featured: true,
427
+ });
428
+ await postService.create({ format: "note", body: "normal" });
429
+
430
+ const count = await postService.count({ featured: true });
431
+ expect(count).toBe(1);
432
+ });
433
+
434
+ it("excludes deleted posts by default", async () => {
435
+ const post = await postService.create({
436
+ format: "note",
437
+ body: "to delete",
438
+ });
439
+ await postService.create({ format: "note", body: "keep" });
440
+ await postService.delete(post.id);
441
+
442
+ const count = await postService.count();
443
+ expect(count).toBe(1);
444
+ });
445
+
446
+ it("excludes replies when requested", async () => {
447
+ const root = await postService.create({
448
+ format: "note",
449
+ body: "root",
450
+ });
451
+ await postService.create({
452
+ format: "note",
453
+ body: "reply",
454
+ replyToId: root.id,
455
+ });
456
+
457
+ const count = await postService.count({ excludeReplies: true });
458
+ expect(count).toBe(1);
459
+ });
361
460
  });
362
461
 
363
462
  describe("update", () => {
@@ -471,18 +570,18 @@ describe("PostService", () => {
471
570
  expect(updated?.pinned).toBe(1);
472
571
  });
473
572
 
474
- it("updates slug", async () => {
573
+ it("updates path", async () => {
475
574
  const post = await postService.create({
476
575
  format: "note",
477
576
  body: "test",
478
- slug: "old-slug",
577
+ path: "old-path",
479
578
  });
480
579
 
481
580
  const updated = await postService.update(post.id, {
482
- slug: "new-slug",
581
+ path: "new-path",
483
582
  });
484
583
 
485
- expect(updated?.slug).toBe("new-slug");
584
+ expect(updated?.path).toBe("new-path");
486
585
  });
487
586
 
488
587
  it("updates quoteText and rating", async () => {
@@ -4,7 +4,7 @@
4
4
  * CRUD operations for standalone pages (about, now, etc.)
5
5
  */
6
6
 
7
- import { eq, desc } from "drizzle-orm";
7
+ import { eq, desc, sql } from "drizzle-orm";
8
8
  import type { Database } from "../db/index.js";
9
9
  import { pages, navItems } from "../db/schema.js";
10
10
  import { now } from "../lib/time.js";
@@ -15,6 +15,7 @@ export interface PageService {
15
15
  getById(id: number): Promise<Page | null>;
16
16
  getBySlug(slug: string): Promise<Page | null>;
17
17
  list(): Promise<Page[]>;
18
+ listNotInNav(): Promise<Page[]>;
18
19
  create(data: CreatePage): Promise<Page>;
19
20
  update(id: number, data: UpdatePage): Promise<Page | null>;
20
21
  delete(id: number): Promise<boolean>;
@@ -58,6 +59,17 @@ export function createPageService(db: Database): PageService {
58
59
  return rows.map(toPage);
59
60
  },
60
61
 
62
+ async listNotInNav() {
63
+ const rows = await db
64
+ .select()
65
+ .from(pages)
66
+ .where(
67
+ sql`${pages.id} NOT IN (SELECT ${navItems.pageId} FROM ${navItems} WHERE ${navItems.pageId} IS NOT NULL)`,
68
+ )
69
+ .orderBy(desc(pages.createdAt));
70
+ return rows.map(toPage);
71
+ },
72
+
61
73
  async create(data) {
62
74
  const timestamp = now();
63
75