@jant/core 0.3.36 → 0.3.38

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 (271) 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/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -1,77 +1,211 @@
1
1
  /**
2
2
  * Archive Page Route
3
3
  *
4
- * Shows all posts, optionally filtered by format or visibility
4
+ * Tumblr-style archive grid with rich filtering:
5
+ * year, collection, format, media types, title presence.
6
+ * Page-based pagination with media-enriched post tiles.
5
7
  */
6
8
 
7
9
  import { Hono } from "hono";
8
- import type { Bindings, Format, Visibility } from "../../types.js";
10
+ import type {
11
+ Bindings,
12
+ Format,
13
+ MediaKind,
14
+ PostWithMedia,
15
+ } from "../../types.js";
9
16
  import type { AppVariables } from "../../types/app-context.js";
10
- import { FORMATS, VISIBILITIES } from "../../types.js";
17
+ import type {
18
+ ArchiveFilters,
19
+ ArchiveView,
20
+ ArchiveVisibility,
21
+ } from "../../types/props.js";
22
+ import { FORMATS, MEDIA_KINDS } from "../../types.js";
11
23
  import { ArchivePage } from "../../ui/pages/ArchivePage.js";
12
24
  import { getNavigationData } from "../../lib/navigation.js";
13
25
  import { renderPublicPage } from "../../lib/render.js";
14
- import { createMediaContext, toArchiveGroups } from "../../lib/view.js";
26
+ import {
27
+ createMediaContext,
28
+ toArchiveGroupsWithMedia,
29
+ } from "../../lib/view.js";
30
+ import { buildMediaMap } from "../../lib/media-helpers.js";
31
+ import type { PostFilters } from "../../services/post.js";
15
32
 
16
33
  type Env = { Bindings: Bindings; Variables: AppVariables };
17
34
 
18
- const PAGE_SIZE = 50;
35
+ const PAGE_SIZE = 60;
19
36
 
20
37
  export const archiveRoutes = new Hono<Env>();
21
38
 
22
- // Archive page - all posts
23
39
  archiveRoutes.get("/", async (c) => {
40
+ const { services, appConfig } = c.var;
41
+
42
+ // --- Parse query params ---------------------------------------------------
43
+
24
44
  const formatParam = c.req.query("format") as Format | undefined;
25
45
  const format =
26
46
  formatParam && FORMATS.includes(formatParam) ? formatParam : undefined;
27
- const visibilityParam = c.req.query("visibility") as Visibility | undefined;
47
+
48
+ const yearParam = c.req.query("year");
49
+ const year = yearParam ? parseInt(yearParam, 10) : undefined;
50
+ const validYear = year && !isNaN(year) && year > 1970 ? year : undefined;
51
+
52
+ const collectionSlug = c.req.query("collection") || undefined;
53
+
54
+ const mediaParam = c.req.query("media") || undefined;
55
+ const mediaKinds = mediaParam
56
+ ? (mediaParam
57
+ .split(",")
58
+ .filter((m): m is MediaKind =>
59
+ (MEDIA_KINDS as readonly string[]).includes(m),
60
+ ) as MediaKind[])
61
+ : undefined;
62
+
63
+ const hasMediaParam = c.req.query("hasMedia");
64
+ const hasMedia =
65
+ hasMediaParam === "1" ? true : hasMediaParam === "0" ? false : undefined;
66
+
67
+ const hasTitleParam = c.req.query("hasTitle");
68
+ const hasTitle =
69
+ hasTitleParam === "1" ? true : hasTitleParam === "0" ? false : undefined;
70
+
71
+ const VALID_VISIBILITIES = ["public", "unlisted", "private", "featured"];
72
+ const visibilityParam = c.req.query("visibility");
73
+ const visibilityAll = visibilityParam === "all";
28
74
  const visibility =
29
- visibilityParam &&
30
- (VISIBILITIES as readonly string[]).includes(visibilityParam)
31
- ? visibilityParam
75
+ visibilityParam && VALID_VISIBILITIES.includes(visibilityParam)
76
+ ? (visibilityParam as ArchiveVisibility)
77
+ : undefined;
78
+
79
+ const viewParam = c.req.query("view") as ArchiveView | undefined;
80
+ const view =
81
+ viewParam && (viewParam === "grid" || viewParam === "list")
82
+ ? viewParam
32
83
  : undefined;
33
84
 
34
- // Parse cursor
35
- const cursorParam = c.req.query("cursor");
36
- const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
85
+ const pageParam = c.req.query("page");
86
+ const currentPage = Math.max(1, parseInt(pageParam || "1", 10) || 1);
87
+
88
+ // --- Resolve collection slug to ID ----------------------------------------
89
+
90
+ const collection = collectionSlug
91
+ ? await services.collections.getBySlug(collectionSlug)
92
+ : undefined;
93
+ const collectionId = collection?.id;
94
+
95
+ // --- Build timestamp range for year filter --------------------------------
96
+
97
+ let publishedAfter: number | undefined;
98
+ let publishedBefore: number | undefined;
99
+ if (validYear) {
100
+ publishedAfter = Date.UTC(validYear, 0, 1) / 1000;
101
+ publishedBefore = Date.UTC(validYear + 1, 0, 1) / 1000;
102
+ }
103
+
104
+ // --- Build filters --------------------------------------------------------
37
105
 
38
106
  const navData = await getNavigationData(c);
39
107
 
40
- // Fetch one extra to check for more
41
- const posts = await c.var.services.posts.list({
108
+ // --- Map visibility filter to service-level filters -------------------------
109
+ // Visibility filter is only meaningful when authenticated — unauthenticated
110
+ // users cannot see unlisted or private posts regardless of the query param.
111
+
112
+ // Default to "public" when authenticated unless explicitly set to "all"
113
+ const effectiveVisibility = navData.isAuthenticated
114
+ ? visibilityAll
115
+ ? undefined
116
+ : (visibility ?? "public")
117
+ : undefined;
118
+
119
+ const filters: PostFilters = {
42
120
  format,
43
121
  status: "published",
44
- visibility,
45
122
  excludeReplies: true,
46
- cursor,
47
- limit: PAGE_SIZE + 1,
48
- });
123
+ excludePrivate: !navData.isAuthenticated,
124
+ excludeUnlisted: !navData.isAuthenticated,
125
+ ...(effectiveVisibility === "featured"
126
+ ? { featured: true }
127
+ : effectiveVisibility
128
+ ? { visibility: effectiveVisibility }
129
+ : {}),
130
+ collectionId,
131
+ publishedAfter,
132
+ publishedBefore,
133
+ mediaKinds: mediaKinds && mediaKinds.length > 0 ? mediaKinds : undefined,
134
+ hasMedia,
135
+ hasTitle,
136
+ };
49
137
 
50
- const hasMore = posts.length > PAGE_SIZE;
51
- const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
138
+ // --- Parallel data fetches ------------------------------------------------
52
139
 
53
- // Get next cursor
54
- const nextCursor =
55
- hasMore && displayPosts.length > 0
56
- ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Length check above guarantees element exists
57
- displayPosts[displayPosts.length - 1]!.id
58
- : undefined;
140
+ const [totalCount, posts, availableYears, allCollections] = await Promise.all(
141
+ [
142
+ services.posts.count(filters),
143
+ services.posts.list({
144
+ ...filters,
145
+ limit: PAGE_SIZE,
146
+ offset: (currentPage - 1) * PAGE_SIZE,
147
+ }),
148
+ services.posts.getDistinctYears({
149
+ status: "published",
150
+ excludeReplies: true,
151
+ }),
152
+ services.collections.list(),
153
+ ],
154
+ );
155
+
156
+ const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
59
157
 
60
- // Group posts by year-month
61
- const grouped = new Map<string, typeof displayPosts>();
62
- for (const post of displayPosts) {
63
- const date = new Date(post.publishedAt * 1000);
158
+ // --- Batch-load media for posts -------------------------------------------
159
+
160
+ const postIds = posts.map((p) => p.id);
161
+ const rawMediaMap = await services.media.getByPostIds(postIds);
162
+ const mediaCtx = createMediaContext(appConfig);
163
+ const mediaMap = buildMediaMap(
164
+ rawMediaMap,
165
+ mediaCtx.r2PublicUrl,
166
+ mediaCtx.imageTransformUrl,
167
+ mediaCtx.s3PublicUrl,
168
+ );
169
+
170
+ // --- Group posts by year-month with media ---------------------------------
171
+
172
+ const grouped = new Map<string, PostWithMedia[]>();
173
+ for (const post of posts) {
174
+ const publishedAt = post.publishedAt ?? post.updatedAt;
175
+ const date = new Date(publishedAt * 1000);
64
176
  const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
65
177
  if (!grouped.has(key)) {
66
178
  grouped.set(key, []);
67
179
  }
68
180
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Map.set() above guarantees key exists
69
- grouped.get(key)!.push(post);
181
+ grouped.get(key)!.push({
182
+ ...post,
183
+ mediaAttachments: mediaMap.get(post.id) ?? [],
184
+ });
70
185
  }
71
186
 
72
- // Transform to View Models
73
- const mediaCtx = createMediaContext(c.var.appConfig);
74
- const groups = toArchiveGroups(grouped, mediaCtx);
187
+ const groups = toArchiveGroupsWithMedia(grouped, mediaCtx);
188
+
189
+ // --- Build active filter state for UI -------------------------------------
190
+
191
+ const archiveFilters: ArchiveFilters = {
192
+ year: validYear,
193
+ collectionSlug,
194
+ collectionTitle: collection?.title,
195
+ collectionIcon: collection?.icon,
196
+ format,
197
+ mediaKinds: mediaKinds && mediaKinds.length > 0 ? mediaKinds : undefined,
198
+ hasMedia,
199
+ hasTitle,
200
+ visibility: effectiveVisibility,
201
+ view,
202
+ };
203
+
204
+ const availableCollectionsList = allCollections.map((col) => ({
205
+ slug: col.slug,
206
+ title: col.title,
207
+ icon: col.icon,
208
+ }));
75
209
 
76
210
  return renderPublicPage(c, {
77
211
  title: `Archive - ${navData.siteName}`,
@@ -79,10 +213,12 @@ archiveRoutes.get("/", async (c) => {
79
213
  content: (
80
214
  <ArchivePage
81
215
  groups={groups}
82
- hasMore={hasMore}
83
- nextCursor={nextCursor}
84
- format={format}
85
- visibility={visibility}
216
+ currentPage={currentPage}
217
+ totalPages={totalPages}
218
+ filters={archiveFilters}
219
+ availableYears={availableYears}
220
+ availableCollections={availableCollectionsList}
221
+ isAuthenticated={navData.isAuthenticated}
86
222
  />
87
223
  ),
88
224
  });
@@ -8,7 +8,11 @@ import type { AppVariables } from "../../types/app-context.js";
8
8
  import { CollectionPage } from "../../ui/pages/CollectionPage.js";
9
9
  import { getNavigationData } from "../../lib/navigation.js";
10
10
  import { renderPublicPage } from "../../lib/render.js";
11
- import { createMediaContext, toPostViews } from "../../lib/view.js";
11
+ import {
12
+ createMediaContext,
13
+ toPostViews,
14
+ loadThreadRootPermalinks,
15
+ } from "../../lib/view.js";
12
16
  import { defaultRssRenderer } from "../../lib/feed.js";
13
17
  import { buildMediaMap } from "../../lib/media-helpers.js";
14
18
  import { CollectionsSidebar } from "../../ui/shared/CollectionsSidebar.js";
@@ -23,23 +27,29 @@ collectionRoutes.get("/:slug", async (c) => {
23
27
  const collection = await c.var.services.collections.getBySlug(slug);
24
28
  if (!collection) return c.notFound();
25
29
 
26
- // Fetch posts, all collections, dividers, and post counts in parallel
27
- const [posts, allCollections, dividers, postCounts] = 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([
28
34
  c.var.services.posts.list({
29
35
  collectionId: collection.id,
30
36
  status: "published",
31
- excludeReplies: true,
37
+ excludePrivate: !navData.isAuthenticated,
32
38
  }),
33
39
  c.var.services.collections.list(),
34
- c.var.services.collections.listDividers(),
40
+ c.var.services.collections.listSidebarItems(),
35
41
  c.var.services.collections.getPostCounts(),
36
42
  ]);
37
43
 
38
- const navData = await getNavigationData(c);
39
-
40
- // Batch-load media for posts
44
+ // Batch-load media and thread root permalinks in parallel
41
45
  const postIds = posts.map((p) => p.id);
42
- const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
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
54
  const mediaMap = buildMediaMap(
45
55
  rawMediaMap,
@@ -54,6 +64,7 @@ collectionRoutes.get("/:slug", async (c) => {
54
64
  mediaAttachments: mediaMap.get(p.id) ?? [],
55
65
  })),
56
66
  mediaCtx,
67
+ rootPermalinkMap,
57
68
  );
58
69
 
59
70
  const items = postViews.map((post) => ({ post }));
@@ -65,7 +76,7 @@ collectionRoutes.get("/:slug", async (c) => {
65
76
  sidebar: (
66
77
  <CollectionsSidebar
67
78
  collections={allCollections}
68
- dividers={dividers}
79
+ sidebarItems={sidebarItems}
69
80
  activeSlug={slug}
70
81
  isAuthenticated={navData.isAuthenticated}
71
82
  postCounts={postCounts}
@@ -94,6 +105,7 @@ collectionRoutes.get("/:slug/feed", async (c) => {
94
105
  collectionId: collection.id,
95
106
  status: "published",
96
107
  excludeReplies: true,
108
+ excludePrivate: true,
97
109
  limit: feedLimit,
98
110
  });
99
111
 
@@ -17,9 +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, dividers, postCounts] = await Promise.all([
20
+ const [allCollections, sidebarItems, postCounts] = await Promise.all([
21
21
  c.var.services.collections.list(),
22
- c.var.services.collections.listDividers(),
22
+ c.var.services.collections.listSidebarItems(),
23
23
  c.var.services.collections.getPostCounts(),
24
24
  ]);
25
25
 
@@ -36,7 +36,7 @@ collectionsPageRoutes.get("/", async (c) => {
36
36
  sidebar: (
37
37
  <CollectionsSidebar
38
38
  collections={allCollections}
39
- dividers={dividers}
39
+ sidebarItems={sidebarItems}
40
40
  activeSlug={undefined}
41
41
  isAuthenticated={navData.isAuthenticated}
42
42
  postCounts={postCounts}
@@ -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 };
@@ -25,13 +29,33 @@ featuredRoutes.get("/", async (c) => {
25
29
  }
26
30
 
27
31
  const posts = await c.var.services.posts.list({
28
- visibility: "featured",
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
 
@@ -28,12 +32,18 @@ homeRoutes.get("/", async (c) => {
28
32
  if (navData.homeDefaultView === "featured") {
29
33
  // Show featured posts on homepage
30
34
  const posts = await c.var.services.posts.list({
31
- visibility: "featured",
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
+ });