@jant/core 0.3.35 → 0.3.37

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 (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -10,8 +10,8 @@ import { getNavigationData } from "../../lib/navigation.js";
10
10
  import { renderPublicPage } from "../../lib/render.js";
11
11
  import {
12
12
  createMediaContext,
13
- toPostViewsFromPosts,
14
13
  toPostViews,
14
+ loadThreadRootPermalinks,
15
15
  } from "../../lib/view.js";
16
16
  import { defaultRssRenderer } from "../../lib/feed.js";
17
17
  import { buildMediaMap } from "../../lib/media-helpers.js";
@@ -27,35 +27,63 @@ collectionRoutes.get("/:slug", async (c) => {
27
27
  const collection = await c.var.services.collections.getBySlug(slug);
28
28
  if (!collection) return c.notFound();
29
29
 
30
- // Fetch posts and all collections in parallel
31
- const [posts, allCollections] = await Promise.all([
30
+ // Fetch posts, all collections, sidebar items, and post counts in parallel
31
+ const navData = await getNavigationData(c);
32
+
33
+ const [posts, allCollections, sidebarItems, postCounts] = await Promise.all([
32
34
  c.var.services.posts.list({
33
35
  collectionId: collection.id,
34
36
  status: "published",
35
- excludeReplies: true,
37
+ excludePrivate: !navData.isAuthenticated,
36
38
  }),
37
39
  c.var.services.collections.list(),
40
+ c.var.services.collections.listSidebarItems(),
41
+ c.var.services.collections.getPostCounts(),
38
42
  ]);
39
43
 
40
- const navData = await getNavigationData(c);
41
-
42
- // Transform to View Models
44
+ // Batch-load media and thread root permalinks in parallel
45
+ const postIds = posts.map((p) => p.id);
46
+ const [rawMediaMap, rootPermalinkMap] = await Promise.all([
47
+ c.var.services.media.getByPostIds(postIds),
48
+ loadThreadRootPermalinks(
49
+ posts,
50
+ c.var.services.posts.getById.bind(c.var.services.posts),
51
+ ),
52
+ ]);
43
53
  const mediaCtx = createMediaContext(c.var.appConfig);
44
- const postViews = toPostViewsFromPosts(posts, mediaCtx);
54
+ const mediaMap = buildMediaMap(
55
+ rawMediaMap,
56
+ mediaCtx.r2PublicUrl,
57
+ mediaCtx.imageTransformUrl,
58
+ mediaCtx.s3PublicUrl,
59
+ );
60
+
61
+ const postViews = toPostViews(
62
+ posts.map((p) => ({
63
+ ...p,
64
+ mediaAttachments: mediaMap.get(p.id) ?? [],
65
+ })),
66
+ mediaCtx,
67
+ rootPermalinkMap,
68
+ );
69
+
70
+ const items = postViews.map((post) => ({ post }));
45
71
 
46
72
  return renderPublicPage(c, {
47
73
  title: `${collection.title} - ${navData.siteName}`,
48
74
  description: collection.description ?? undefined,
49
75
  navData,
50
76
  sidebar: (
51
- <CollectionsSidebar collections={allCollections} activeSlug={slug} />
77
+ <CollectionsSidebar
78
+ collections={allCollections}
79
+ sidebarItems={sidebarItems}
80
+ activeSlug={slug}
81
+ isAuthenticated={navData.isAuthenticated}
82
+ postCounts={postCounts}
83
+ />
52
84
  ),
53
85
  content: (
54
- <CollectionPage
55
- collection={collection}
56
- posts={postViews}
57
- hasMore={false}
58
- />
86
+ <CollectionPage collection={collection} items={items} hasMore={false} />
59
87
  ),
60
88
  });
61
89
  });
@@ -77,6 +105,7 @@ collectionRoutes.get("/:slug/feed", async (c) => {
77
105
  collectionId: collection.id,
78
106
  status: "published",
79
107
  excludeReplies: true,
108
+ excludePrivate: true,
80
109
  limit: feedLimit,
81
110
  });
82
111
 
@@ -17,8 +17,9 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
17
17
  export const collectionsPageRoutes = new Hono<Env>();
18
18
 
19
19
  collectionsPageRoutes.get("/", async (c) => {
20
- const [allCollections, postCounts] = await Promise.all([
20
+ const [allCollections, sidebarItems, postCounts] = await Promise.all([
21
21
  c.var.services.collections.list(),
22
+ c.var.services.collections.listSidebarItems(),
22
23
  c.var.services.collections.getPostCounts(),
23
24
  ]);
24
25
 
@@ -32,7 +33,15 @@ collectionsPageRoutes.get("/", async (c) => {
32
33
  return renderPublicPage(c, {
33
34
  title: `Collections - ${navData.siteName}`,
34
35
  navData,
35
- sidebar: <CollectionsSidebar collections={allCollections} />,
36
+ sidebar: (
37
+ <CollectionsSidebar
38
+ collections={allCollections}
39
+ sidebarItems={sidebarItems}
40
+ activeSlug={undefined}
41
+ isAuthenticated={navData.isAuthenticated}
42
+ postCounts={postCounts}
43
+ />
44
+ ),
36
45
  content: <CollectionsPage collections={collections} />,
37
46
  });
38
47
  });
@@ -9,7 +9,11 @@ import type { Bindings } from "../../types.js";
9
9
  import type { AppVariables } from "../../types/app-context.js";
10
10
  import { getNavigationData } from "../../lib/navigation.js";
11
11
  import { renderPublicPage } from "../../lib/render.js";
12
- import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
12
+ import {
13
+ createMediaContext,
14
+ toPostViewsFromPosts,
15
+ loadThreadRootPermalinks,
16
+ } from "../../lib/view.js";
13
17
  import { FeaturedPage } from "../../ui/pages/FeaturedPage.js";
14
18
 
15
19
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -27,11 +31,31 @@ featuredRoutes.get("/", async (c) => {
27
31
  const posts = await c.var.services.posts.list({
28
32
  featured: true,
29
33
  status: "published",
30
- excludeReplies: true,
34
+ excludePrivate: !navData.isAuthenticated,
31
35
  });
32
36
 
33
37
  const mediaCtx = createMediaContext(c.var.appConfig);
34
- const postViews = toPostViewsFromPosts(posts, mediaCtx);
38
+
39
+ const rootPermalinkMap = await loadThreadRootPermalinks(
40
+ posts,
41
+ c.var.services.posts.getById.bind(c.var.services.posts),
42
+ );
43
+
44
+ // Determine which posts are last in their thread for reply button visibility
45
+ const threadIds = [...new Set(posts.map((p) => p.threadId))];
46
+ const lastPostMap =
47
+ await c.var.services.posts.getLastPostIdsByThread(threadIds);
48
+ const isLastInThreadMap = new Map<string, boolean>();
49
+ for (const p of posts) {
50
+ isLastInThreadMap.set(p.id, lastPostMap.get(p.threadId) === p.id);
51
+ }
52
+
53
+ const postViews = toPostViewsFromPosts(
54
+ posts,
55
+ mediaCtx,
56
+ rootPermalinkMap,
57
+ isLastInThreadMap,
58
+ );
35
59
 
36
60
  // Convert to timeline items (simple — no thread previews)
37
61
  const items = postViews.map((post) => ({ post }));
@@ -14,7 +14,11 @@ import type { AppVariables } from "../../types/app-context.js";
14
14
  import { getNavigationData } from "../../lib/navigation.js";
15
15
  import { renderPublicPage } from "../../lib/render.js";
16
16
  import { assembleTimeline } from "../../lib/timeline.js";
17
- import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
17
+ import {
18
+ createMediaContext,
19
+ toPostViewsFromPosts,
20
+ loadThreadRootPermalinks,
21
+ } from "../../lib/view.js";
18
22
  import { HomePage } from "../../ui/pages/HomePage.js";
19
23
  import { FeaturedPage } from "../../ui/pages/FeaturedPage.js";
20
24
 
@@ -30,10 +34,16 @@ homeRoutes.get("/", async (c) => {
30
34
  const posts = await c.var.services.posts.list({
31
35
  featured: true,
32
36
  status: "published",
33
- excludeReplies: true,
37
+ excludePrivate: !navData.isAuthenticated,
34
38
  });
35
39
  const mediaCtx = createMediaContext(c.var.appConfig);
36
- const postViews = toPostViewsFromPosts(posts, mediaCtx);
40
+
41
+ const rootPermalinkMap = await loadThreadRootPermalinks(
42
+ posts,
43
+ c.var.services.posts.getById.bind(c.var.services.posts),
44
+ );
45
+
46
+ const postViews = toPostViewsFromPosts(posts, mediaCtx, rootPermalinkMap);
37
47
  const items = postViews.map((post) => ({ post }));
38
48
 
39
49
  return renderPublicPage(c, {
@@ -43,30 +53,21 @@ homeRoutes.get("/", async (c) => {
43
53
  });
44
54
  }
45
55
 
46
- // Default: show latest posts
56
+ // Default: show latest posts (pinned posts sort to top via service layer)
47
57
  const pageParam = c.req.query("page");
48
58
  const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
49
59
 
50
60
  const { items, currentPage, totalPages } = await assembleTimeline(c, {
51
61
  page,
62
+ isAuthenticated: navData.isAuthenticated,
52
63
  });
53
64
 
54
- // Fetch pinned posts
55
- const pinnedPosts = await c.var.services.posts.list({
56
- pinned: true,
57
- status: "published",
58
- excludeReplies: true,
59
- });
60
- const mediaCtx = createMediaContext(c.var.appConfig);
61
- const pinnedItems = toPostViewsFromPosts(pinnedPosts, mediaCtx);
62
-
63
65
  return renderPublicPage(c, {
64
66
  title: navData.siteName,
65
67
  navData,
66
68
  content: (
67
69
  <HomePage
68
70
  items={items}
69
- pinnedItems={pinnedItems}
70
71
  currentPage={currentPage}
71
72
  totalPages={totalPages}
72
73
  />
@@ -13,7 +13,6 @@ import type { AppVariables } from "../../types/app-context.js";
13
13
  import { getNavigationData } from "../../lib/navigation.js";
14
14
  import { renderPublicPage } from "../../lib/render.js";
15
15
  import { assembleTimeline } from "../../lib/timeline.js";
16
- import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
17
16
  import { HomePage } from "../../ui/pages/HomePage.js";
18
17
 
19
18
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -33,24 +32,15 @@ latestRoutes.get("/", async (c) => {
33
32
 
34
33
  const { items, currentPage, totalPages } = await assembleTimeline(c, {
35
34
  page,
35
+ isAuthenticated: navData.isAuthenticated,
36
36
  });
37
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.var.appConfig);
45
- const pinnedItems = toPostViewsFromPosts(pinnedPosts, mediaCtx);
46
-
47
38
  return renderPublicPage(c, {
48
39
  title: `Latest - ${navData.siteName}`,
49
40
  navData,
50
41
  content: (
51
42
  <HomePage
52
43
  items={items}
53
- pinnedItems={pinnedItems}
54
44
  currentPage={currentPage}
55
45
  totalPages={totalPages}
56
46
  />
@@ -0,0 +1,39 @@
1
+ import { Hono } from "hono";
2
+ import { msg } from "@lingui/core/macro";
3
+ import type { Bindings } from "../../types.js";
4
+ import type { AppVariables } from "../../types/app-context.js";
5
+ import { requireAuth } from "../../middleware/auth.js";
6
+ import { getNavigationData } from "../../lib/navigation.js";
7
+ import { renderPublicPage } from "../../lib/render.js";
8
+ import { getI18n } from "../../i18n/index.js";
9
+ import { ComposePage } from "../../ui/pages/ComposePage.js";
10
+
11
+ type Env = { Bindings: Bindings; Variables: AppVariables };
12
+
13
+ export const newPostRoutes = new Hono<Env>();
14
+
15
+ newPostRoutes.use("/new", requireAuth());
16
+
17
+ newPostRoutes.get("/new", async (c) => {
18
+ const navData = await getNavigationData(c);
19
+ const i18n = getI18n(c);
20
+
21
+ return renderPublicPage(c, {
22
+ title: `${i18n._(
23
+ msg({
24
+ message: "New post",
25
+ comment: "@context: Browser page title for the new post page",
26
+ }),
27
+ )} - ${navData.siteName}`,
28
+ navData,
29
+ showComposeDialog: false,
30
+ showHeader: false,
31
+ content: (
32
+ <ComposePage
33
+ collections={navData.collections}
34
+ uploadMaxFileSize={c.var.appConfig.uploadMaxFileSize}
35
+ closeHref="/"
36
+ />
37
+ ),
38
+ });
39
+ });
@@ -1,80 +1,126 @@
1
1
  /**
2
- * Custom Page Route
2
+ * Catch-all Route
3
3
  *
4
- * Serves pages and posts with custom paths via the path registry.
5
- * This is a catch-all route mounted at "/" - must be registered last.
6
- * The path registry eliminates ambiguity: each path maps to exactly
7
- * one entity (page, post, or redirect).
4
+ * Resolves post slugs, aliases, redirects, and collection aliases.
5
+ * Must be registered last.
8
6
  */
9
7
 
10
- import { Hono } from "hono";
8
+ import { Hono, type Context } from "hono";
11
9
  import type { Bindings } from "../../types.js";
12
10
  import type { AppVariables } from "../../types/app-context.js";
13
- import { SinglePage } from "../../ui/pages/SinglePage.js";
14
11
  import { PostPage } from "../../ui/pages/PostPage.js";
15
12
  import { getNavigationData } from "../../lib/navigation.js";
16
13
  import { renderPublicPage } from "../../lib/render.js";
17
14
  import { buildMediaMap } from "../../lib/media-helpers.js";
18
- import { createMediaContext, toPageView, toPostView } from "../../lib/view.js";
15
+ import { createMediaContext, toPostView } from "../../lib/view.js";
16
+ import type { Post } from "../../types.js";
19
17
 
20
18
  type Env = { Bindings: Bindings; Variables: AppVariables };
21
19
 
22
20
  export const pageRoutes = new Hono<Env>();
23
21
 
24
- // Catch-all for custom page slugs and post paths (including multi-level)
22
+ async function renderPost(c: Context<Env>, post: Post) {
23
+ const mediaCtx = createMediaContext(c.var.appConfig);
24
+
25
+ // Load the full thread if this post is part of one
26
+ const threadRootId = post.threadId;
27
+ const threadPosts = (
28
+ await c.var.services.posts.getThread(threadRootId)
29
+ ).filter((threadPost) => threadPost.status === "published");
30
+
31
+ // Batch load media for all thread posts (or just this post if solo)
32
+ const allPostIds =
33
+ threadPosts.length > 1 ? threadPosts.map((p) => p.id) : [post.id];
34
+ const rawMediaMap = await c.var.services.media.getByPostIds(allPostIds);
35
+ const mediaMap = buildMediaMap(
36
+ rawMediaMap,
37
+ mediaCtx.r2PublicUrl,
38
+ mediaCtx.imageTransformUrl,
39
+ mediaCtx.s3PublicUrl,
40
+ );
41
+
42
+ // Batch load collections for all posts
43
+ const collectionsMap =
44
+ await c.var.services.collections.getCollectionsByPostIds(allPostIds);
45
+
46
+ const postView = toPostView(
47
+ { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
48
+ mediaCtx,
49
+ collectionsMap.get(post.id),
50
+ );
51
+
52
+ // Build thread post views if this is a multi-post thread
53
+ const threadPostViews =
54
+ threadPosts.length > 1
55
+ ? threadPosts.map((tp, i) =>
56
+ toPostView(
57
+ { ...tp, mediaAttachments: mediaMap.get(tp.id) ?? [] },
58
+ mediaCtx,
59
+ collectionsMap.get(tp.id),
60
+ undefined,
61
+ i === threadPosts.length - 1,
62
+ ),
63
+ )
64
+ : undefined;
65
+
66
+ const navData = await getNavigationData(c);
67
+ const title = post.title || navData.siteName;
68
+
69
+ return renderPublicPage(c, {
70
+ title,
71
+ description: post.body?.slice(0, 160),
72
+ navData,
73
+ content: <PostPage post={postView} threadPosts={threadPostViews} />,
74
+ });
75
+ }
76
+
77
+ // Catch-all for path-registry backed post URLs, aliases, and redirects
25
78
  pageRoutes.get("/*", async (c) => {
26
79
  const fullPath = c.req.path.slice(1); // Remove leading /
27
80
  if (!fullPath) return c.notFound();
28
81
 
29
- const entry = await c.var.services.pathRegistry.getByPath(fullPath);
82
+ const resolved = await c.var.services.paths.resolve(fullPath);
83
+ if (!resolved) return c.notFound();
30
84
 
31
- if (entry?.ownerType === "page") {
32
- const page = await c.var.services.pages.getById(entry.ownerId);
33
- if (!page || page.status === "draft") {
34
- return c.notFound();
35
- }
85
+ if (resolved.kind === "redirect" && resolved.redirectToPath) {
86
+ return c.redirect(
87
+ `/${resolved.redirectToPath}`,
88
+ resolved.redirectType ?? 301,
89
+ );
90
+ }
36
91
 
37
- const navData = await getNavigationData(c);
38
- const pageView = toPageView(page);
92
+ if (resolved.postId) {
93
+ const post = await c.var.services.posts.getById(resolved.postId);
94
+ if (!post || post.status === "draft") return c.notFound();
39
95
 
40
- return renderPublicPage(c, {
41
- title: `${page.title || fullPath} - ${navData.siteName}`,
42
- description: page.body?.slice(0, 160),
43
- navData,
44
- content: <SinglePage page={pageView} />,
45
- });
46
- }
96
+ if (post.visibility === "private") {
97
+ const navData = await getNavigationData(c);
98
+ if (!navData.isAuthenticated) return c.notFound();
99
+ }
47
100
 
48
- if (entry?.ownerType === "post") {
49
- const post = await c.var.services.posts.getById(entry.ownerId);
50
- if (!post || post.status === "draft") {
51
- return c.notFound();
101
+ // If accessed via slug but an alias exists, redirect to the alias
102
+ if (resolved.kind === "slug") {
103
+ const alias = await c.var.services.customUrls.getByTarget(
104
+ "post",
105
+ post.id,
106
+ );
107
+ if (alias) {
108
+ return c.redirect(`/${alias.path}`, 301);
109
+ }
52
110
  }
53
111
 
54
- // Load media attachments
55
- const rawMediaMap = await c.var.services.media.getByPostIds([post.id]);
56
- const mediaCtx = createMediaContext(c.var.appConfig);
57
- const mediaMap = buildMediaMap(
58
- rawMediaMap,
59
- mediaCtx.r2PublicUrl,
60
- mediaCtx.imageTransformUrl,
61
- mediaCtx.s3PublicUrl,
62
- );
112
+ return renderPost(c, post);
113
+ }
63
114
 
64
- const postView = toPostView(
65
- { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
66
- mediaCtx,
115
+ if (resolved.collectionId) {
116
+ const collection = await c.var.services.collections.getById(
117
+ resolved.collectionId,
67
118
  );
119
+ if (!collection) return c.notFound();
68
120
 
69
- const navData = await getNavigationData(c);
70
- const title = post.title || navData.siteName;
71
-
72
- return renderPublicPage(c, {
73
- title,
74
- description: post.body?.slice(0, 160),
75
- navData,
76
- content: <PostPage post={postView} />,
77
- });
121
+ if (resolved.kind === "alias") {
122
+ return c.redirect(`/c/${collection.slug}`, 301);
123
+ }
78
124
  }
79
125
 
80
126
  return c.notFound();
@@ -50,7 +50,7 @@ searchRoutes.get("/", async (c) => {
50
50
 
51
51
  // Transform to View Models
52
52
  const mediaCtx = createMediaContext(c.var.appConfig);
53
- const resultViews = toSearchResultViews(results, mediaCtx);
53
+ const resultViews = toSearchResultViews(results, mediaCtx, query);
54
54
 
55
55
  return renderPublicPage(c, {
56
56
  title: query
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { createApiTokenService } from "../api-token.js";
4
+ import type { Database } from "../../db/index.js";
5
+
6
+ describe("ApiTokenService", () => {
7
+ let db: Database;
8
+ let service: ReturnType<typeof createApiTokenService>;
9
+
10
+ beforeEach(() => {
11
+ const testDb = createTestDatabase();
12
+ db = testDb.db as unknown as Database;
13
+ service = createApiTokenService(db);
14
+ });
15
+
16
+ describe("create", () => {
17
+ it("returns a token with jnt_ prefix and metadata", async () => {
18
+ const { token, plaintext } = await service.create("Test Token");
19
+
20
+ expect(plaintext).toMatch(/^jnt_[0-9a-f]{64}$/);
21
+ expect(token.name).toBe("Test Token");
22
+ expect(token.prefix).toHaveLength(8);
23
+ expect(token.lastUsedAt).toBeNull();
24
+ expect(token.createdAt).toBeGreaterThan(0);
25
+ expect(token.updatedAt).toBeGreaterThan(0);
26
+ expect(token.id).toBeTruthy();
27
+ });
28
+
29
+ it("does not expose tokenHash in the returned entity", async () => {
30
+ const { token } = await service.create("Test");
31
+
32
+ expect(token).not.toHaveProperty("tokenHash");
33
+ });
34
+
35
+ it("generates unique tokens each time", async () => {
36
+ const { plaintext: t1 } = await service.create("Token 1");
37
+ const { plaintext: t2 } = await service.create("Token 2");
38
+
39
+ expect(t1).not.toBe(t2);
40
+ });
41
+ });
42
+
43
+ describe("list", () => {
44
+ it("returns empty array when no tokens exist", async () => {
45
+ const tokens = await service.list();
46
+ expect(tokens).toEqual([]);
47
+ });
48
+
49
+ it("returns all created tokens", async () => {
50
+ await service.create("Token A");
51
+ await service.create("Token B");
52
+
53
+ const tokens = await service.list();
54
+ expect(tokens).toHaveLength(2);
55
+ expect(tokens[0]?.name).toBe("Token A");
56
+ expect(tokens[1]?.name).toBe("Token B");
57
+ });
58
+
59
+ it("does not include tokenHash in listed tokens", async () => {
60
+ await service.create("Token");
61
+
62
+ const tokens = await service.list();
63
+ expect(tokens[0]).not.toHaveProperty("tokenHash");
64
+ });
65
+ });
66
+
67
+ describe("verify", () => {
68
+ it("returns token ID for valid token", async () => {
69
+ const { token, plaintext } = await service.create("Test");
70
+
71
+ const result = await service.verify(plaintext);
72
+ expect(result).toBe(token.id);
73
+ });
74
+
75
+ it("returns null for invalid token", async () => {
76
+ await service.create("Test");
77
+
78
+ const result = await service.verify("jnt_" + "0".repeat(64));
79
+ expect(result).toBeNull();
80
+ });
81
+
82
+ it("returns null for token without jnt_ prefix", async () => {
83
+ const result = await service.verify("invalid_token");
84
+ expect(result).toBeNull();
85
+ });
86
+
87
+ it("returns null for empty string", async () => {
88
+ const result = await service.verify("");
89
+ expect(result).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe("delete", () => {
94
+ it("returns true when token exists", async () => {
95
+ const { token } = await service.create("Test");
96
+
97
+ const result = await service.delete(token.id);
98
+ expect(result).toBe(true);
99
+ });
100
+
101
+ it("returns false when token does not exist", async () => {
102
+ const result = await service.delete("nonexistent-id");
103
+ expect(result).toBe(false);
104
+ });
105
+
106
+ it("prevents verification of deleted token", async () => {
107
+ const { token, plaintext } = await service.create("Test");
108
+ await service.delete(token.id);
109
+
110
+ const result = await service.verify(plaintext);
111
+ expect(result).toBeNull();
112
+ });
113
+
114
+ it("removes token from list", async () => {
115
+ const { token } = await service.create("Test");
116
+ await service.delete(token.id);
117
+
118
+ const tokens = await service.list();
119
+ expect(tokens).toHaveLength(0);
120
+ });
121
+ });
122
+
123
+ describe("updateLastUsed", () => {
124
+ it("updates the lastUsedAt timestamp", async () => {
125
+ const { token } = await service.create("Test");
126
+ expect(token.lastUsedAt).toBeNull();
127
+
128
+ await service.updateLastUsed(token.id);
129
+
130
+ const tokens = await service.list();
131
+ const updated = tokens.find((t) => t.id === token.id);
132
+ expect(updated?.lastUsedAt).toBeGreaterThan(0);
133
+ });
134
+ });
135
+ });