@jant/core 0.6.15 → 0.7.0

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 (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -11,12 +11,29 @@ import { useLingui } from "../../i18n/context.js";
11
11
  import type {
12
12
  ArchivePageProps,
13
13
  ArchiveFilters,
14
- ArchiveView,
14
+ ArchiveLayout,
15
+ ArchiveSort,
15
16
  ArchiveVisibility,
17
+ Format,
16
18
  MediaKind,
17
19
  } from "../../types.js";
18
20
  import type { PostView } from "../../types/views.js";
19
- import { FORMATS, MEDIA_KINDS } from "../../types.js";
21
+ import {
22
+ ARCHIVE_VISIBILITIES,
23
+ FORMATS,
24
+ MEDIA_KINDS,
25
+ PUBLIC_ARCHIVE_VISIBILITIES,
26
+ } from "../../types.js";
27
+ import type {
28
+ DimensionContext,
29
+ FilterDimensionValues,
30
+ MediaSelection,
31
+ } from "../../lib/filter-dimensions.js";
32
+ import {
33
+ buildCollectionVocabulary,
34
+ FILTER_DIMENSIONS,
35
+ serializePostFilterSelection,
36
+ } from "../../lib/filter-dimensions.js";
20
37
  import { getFeaturedIconSvg } from "../../lib/featured-icons.js";
21
38
  import { getIconSvg } from "../../lib/icons.js";
22
39
  import { toPublicPath } from "../../lib/url.js";
@@ -24,49 +41,60 @@ import { toMediaKind } from "../../lib/upload.js";
24
41
  import { PagePagination } from "../shared/Pagination.js";
25
42
  import { TimelineFeedItem } from "../feed/TimelineFeed.js";
26
43
  import { DecorativeQuoteMark } from "../shared/DecorativeQuoteMark.js";
44
+ import {
45
+ ARCHIVE_ALL_POSTS_LABEL,
46
+ getFormatLabelPlural as getSharedFormatLabelPlural,
47
+ getMediaKindLabel as getSharedMediaKindLabel,
48
+ getVisibilityLabel as getSharedVisibilityLabel,
49
+ hasActiveArchiveFilter,
50
+ } from "../shared/archive-labels.js";
27
51
 
28
52
  // =============================================================================
29
53
  // URL Builder
30
54
  // =============================================================================
31
55
 
56
+ /**
57
+ * One chip's edit to the current view.
58
+ *
59
+ * Dimensions are named exactly as the shared registry names them, so a chip
60
+ * cannot spell a filter the route is unable to read back. An explicit
61
+ * `undefined` clears a dimension, the same way spreading it does.
62
+ */
63
+ type FilterUpdates = Partial<FilterDimensionValues> & {
64
+ layout?: ArchiveLayout;
65
+ sort?: ArchiveSort;
66
+ /** Drop every selection and return to the bare archive. */
67
+ clear?: boolean;
68
+ };
69
+
32
70
  /** Build an archive URL preserving existing filter params, overriding with updates. */
33
71
  function buildFilterUrl(
34
72
  current: ArchiveFilters,
35
- updates: Partial<ArchiveFilters & { clear?: boolean }>,
36
- sitePathPrefix = "",
73
+ updates: FilterUpdates,
74
+ ctx: DimensionContext,
75
+ basePath = "",
37
76
  ): string {
38
- if (updates.clear) return toPublicPath("/archive", sitePathPrefix);
39
-
40
- const merged = { ...current, ...updates };
41
- const params = new URLSearchParams();
42
-
43
- if (merged.year) params.set("year", String(merged.year));
44
- if (merged.collectionSlug) params.set("collection", merged.collectionSlug);
45
- if (merged.format) params.set("format", merged.format);
46
- if (merged.mediaKinds && merged.mediaKinds.length > 0) {
47
- params.set("media", merged.mediaKinds.join(","));
48
- } else if (merged.hasMedia !== undefined) {
49
- params.set("media", merged.hasMedia ? "any" : "none");
50
- }
51
- if (merged.hasTitle !== undefined) {
52
- params.set("title", merged.hasTitle ? "any" : "none");
53
- }
54
- if (merged.hasReplies !== undefined) {
55
- params.set("replies", merged.hasReplies ? "any" : "none");
56
- }
57
- if (merged.visibility) {
58
- // "hidden" is the URL spelling of the internal latest_hidden value
59
- params.set(
60
- "visibility",
61
- merged.visibility === "latest_hidden" ? "hidden" : merged.visibility,
62
- );
63
- }
64
- if (merged.view && merged.view !== "grid") params.set("view", merged.view);
77
+ if (updates.clear) return toPublicPath("/archive", basePath);
78
+
79
+ const { layout, sort, clear: _clear, ...selectionUpdates } = updates;
80
+ const params = serializePostFilterSelection(
81
+ { ...current.selection, ...selectionUpdates },
82
+ ctx,
83
+ );
84
+
85
+ // Both layouts are written out in full. An absent `layout` means "whatever
86
+ // this site defaults to", so a link shared with a layout chosen keeps that
87
+ // layout even if the site default changes later. Only `layout` is ever
88
+ // emitted; the older `view` spelling is read-only now.
89
+ const mergedLayout = "layout" in updates ? layout : current.layout;
90
+ if (mergedLayout) params.set("layout", mergedLayout);
91
+ const mergedSort = "sort" in updates ? sort : current.sort;
92
+ if (mergedSort === "updated") params.set("sort", "updated");
65
93
 
66
94
  const qs = params.toString();
67
95
  return qs
68
- ? toPublicPath(`/archive?${qs}`, sitePathPrefix)
69
- : toPublicPath("/archive", sitePathPrefix);
96
+ ? toPublicPath(`/archive?${qs}`, basePath)
97
+ : toPublicPath("/archive", basePath);
70
98
  }
71
99
 
72
100
  // =============================================================================
@@ -98,36 +126,16 @@ function getFormatLabel(format: string): string {
98
126
  return labels[format] ?? format;
99
127
  }
100
128
 
101
- function getFormatLabelPlural(format: string): string {
129
+ function getFormatLabelPlural(format: Format): string {
102
130
  const { i18n } = useLingui();
103
- const labels: Record<string, string> = {
104
- note: i18n._(
105
- msg({
106
- message: "Notes",
107
- comment: "@context: Post format label plural - notes",
108
- }),
109
- ),
110
- link: i18n._(
111
- msg({
112
- message: "Links",
113
- comment: "@context: Post format label plural - links",
114
- }),
115
- ),
116
- quote: i18n._(
117
- msg({
118
- message: "Quotes",
119
- comment: "@context: Post format label plural - quotes",
120
- }),
121
- ),
122
- };
123
- return labels[format] ?? format + "s";
131
+ return getSharedFormatLabelPlural(format, i18n);
124
132
  }
125
133
 
126
134
  /** Icon name mapping for post formats. */
127
135
  const FORMAT_ICONS: Record<string, string> = {
128
136
  note: "notepad-text",
129
137
  link: "external-link",
130
- quote: "text-quote",
138
+ quote: "quote",
131
139
  };
132
140
 
133
141
  /** Icon name mapping for media kinds. */
@@ -141,39 +149,21 @@ const MEDIA_KIND_ICONS: Record<MediaKind, string> = {
141
149
 
142
150
  function getMediaKindLabel(kind: MediaKind): string {
143
151
  const { i18n } = useLingui();
144
- const labels: Record<MediaKind, string> = {
145
- image: i18n._(
146
- msg({
147
- message: "Images",
148
- comment: "@context: Archive media filter - images",
149
- }),
150
- ),
151
- video: i18n._(
152
- msg({
153
- message: "Video",
154
- comment: "@context: Archive media filter - video",
155
- }),
156
- ),
157
- audio: i18n._(
158
- msg({
159
- message: "Audio",
160
- comment: "@context: Archive media filter - audio",
161
- }),
162
- ),
163
- text: i18n._(
164
- msg({
165
- message: "Text attachment",
166
- comment: "@context: Archive media filter - text file attachments",
167
- }),
168
- ),
169
- document: i18n._(
170
- msg({
171
- message: "Files",
172
- comment: "@context: Archive media filter - files/documents",
173
- }),
174
- ),
175
- };
176
- return labels[kind] ?? kind;
152
+ return getSharedMediaKindLabel(kind, i18n);
153
+ }
154
+
155
+ /**
156
+ * The kinds a media selection names, or none when it only asks about presence.
157
+ *
158
+ * `any`/`none` and a list of kinds are one value in the shared vocabulary; the
159
+ * chip's multi-select only has something to highlight in the list case.
160
+ */
161
+ function selectedMediaKinds(
162
+ selection: MediaSelection | undefined,
163
+ ): readonly MediaKind[] {
164
+ return selection === undefined || selection === "any" || selection === "none"
165
+ ? []
166
+ : selection;
177
167
  }
178
168
 
179
169
  // =============================================================================
@@ -351,28 +341,25 @@ const ChipMediaSelect: FC<{
351
341
  id: string;
352
342
  icon: string;
353
343
  filters: ArchiveFilters;
344
+ ctx: DimensionContext;
354
345
  activeLabel?: string;
355
346
  clearUrl: string;
356
- sitePathPrefix?: string;
357
- }> = ({ id, icon, filters: f, activeLabel, clearUrl, sitePathPrefix = "" }) => {
347
+ basePath?: string;
348
+ }> = ({ id, icon, filters: f, ctx, activeLabel, clearUrl, basePath = "" }) => {
358
349
  const { i18n } = useLingui();
359
350
  const isActive = !!activeLabel;
360
- const activeKinds = f.mediaKinds ?? [];
351
+ const activeKinds = selectedMediaKinds(f.selection.media);
361
352
 
362
353
  const singleKind = activeKinds.length === 1 ? activeKinds[0] : undefined;
363
354
  const activeMediaIcon = isActive
364
- ? f.hasMedia === false
355
+ ? f.selection.media === "none"
365
356
  ? "text"
366
357
  : singleKind
367
358
  ? MEDIA_KIND_ICONS[singleKind]
368
359
  : icon
369
360
  : undefined;
370
361
 
371
- const textOnlyUrl = buildFilterUrl(
372
- { ...f, mediaKinds: undefined, hasMedia: undefined },
373
- { hasMedia: false, mediaKinds: undefined },
374
- sitePathPrefix,
375
- );
362
+ const textOnlyUrl = buildFilterUrl(f, { media: "none" }, ctx, basePath);
376
363
  const clearLabel = i18n._(
377
364
  msg({
378
365
  message: "Clear filter",
@@ -386,7 +373,7 @@ const ChipMediaSelect: FC<{
386
373
  id={id}
387
374
  class="archive-chip-select archive-chip-dropdown archive-chip-media select"
388
375
  data-select-initialized
389
- data-filter-key="media"
376
+ data-filter-key={FILTER_DIMENSIONS.media.url.param}
390
377
  >
391
378
  <button
392
379
  type="button"
@@ -419,7 +406,7 @@ const ChipMediaSelect: FC<{
419
406
  role="option"
420
407
  data-value={textOnlyUrl}
421
408
  data-navigate="true"
422
- aria-selected={f.hasMedia === false ? "true" : undefined}
409
+ aria-selected={f.selection.media === "none" ? "true" : undefined}
423
410
  >
424
411
  <span class="flex items-center gap-2">
425
412
  <Icon name="text" class="[&>svg]:size-4 text-muted-foreground" />
@@ -463,34 +450,121 @@ const ChipMediaSelect: FC<{
463
450
  // View Toggle
464
451
  // =============================================================================
465
452
 
466
- const ViewToggle: FC<{
453
+ /**
454
+ * One option in a toolbar toggle. `label` is used for both the hover tooltip
455
+ * and the accessible name — an icon-only control has to explain itself to
456
+ * both audiences, and one string keeps them from drifting apart.
457
+ */
458
+ const ToggleOption: FC<{
459
+ href: string;
460
+ icon: string;
461
+ label: string;
462
+ active: boolean;
463
+ }> = ({ href, icon, label, active }) => (
464
+ <a
465
+ href={href}
466
+ class={`archive-view-btn${active ? " archive-view-btn-active" : ""}`}
467
+ role="radio"
468
+ aria-checked={active ? "true" : "false"}
469
+ aria-label={label}
470
+ title={label}
471
+ >
472
+ <Icon name={icon} class="[&>svg]:size-4" />
473
+ </a>
474
+ );
475
+
476
+ const LayoutToggle: FC<{
467
477
  filters: ArchiveFilters;
468
- sitePathPrefix?: string;
469
- }> = ({ filters, sitePathPrefix = "" }) => {
470
- const currentView: ArchiveView = filters.view ?? "grid";
471
- const gridUrl = buildFilterUrl(filters, { view: undefined }, sitePathPrefix);
472
- const listUrl = buildFilterUrl(filters, { view: "list" }, sitePathPrefix);
478
+ ctx: DimensionContext;
479
+ defaultLayout: ArchiveLayout;
480
+ basePath?: string;
481
+ }> = ({ filters, ctx, defaultLayout, basePath = "" }) => {
482
+ const { i18n } = useLingui();
483
+ const currentLayout: ArchiveLayout = filters.layout ?? defaultLayout;
473
484
 
474
485
  return (
475
- <div class="archive-view-toggle" role="radiogroup" aria-label="View mode">
476
- <a
477
- href={gridUrl}
478
- class={`archive-view-btn${currentView === "grid" ? " archive-view-btn-active" : ""}`}
479
- role="radio"
480
- aria-checked={currentView === "grid" ? "true" : "false"}
481
- aria-label="Grid view"
482
- >
483
- <Icon name="layout-grid" class="[&>svg]:size-4" />
484
- </a>
485
- <a
486
- href={listUrl}
487
- class={`archive-view-btn${currentView === "list" ? " archive-view-btn-active" : ""}`}
488
- role="radio"
489
- aria-checked={currentView === "list" ? "true" : "false"}
490
- aria-label="List view"
491
- >
492
- <Icon name="list" class="[&>svg]:size-4" />
493
- </a>
486
+ <div
487
+ class="archive-view-toggle"
488
+ role="radiogroup"
489
+ aria-label={i18n._(
490
+ msg({
491
+ message: "Layout",
492
+ comment: "@context: Archive grid/list toggle group label",
493
+ }),
494
+ )}
495
+ >
496
+ <ToggleOption
497
+ href={buildFilterUrl(filters, { layout: "grid" }, ctx, basePath)}
498
+ icon="layout-grid"
499
+ active={currentLayout === "grid"}
500
+ label={i18n._(
501
+ msg({
502
+ message: "Show as a grid of tiles",
503
+ comment: "@context: Archive view option - grid",
504
+ }),
505
+ )}
506
+ />
507
+ <ToggleOption
508
+ href={buildFilterUrl(filters, { layout: "list" }, ctx, basePath)}
509
+ icon="list"
510
+ active={currentLayout === "list"}
511
+ label={i18n._(
512
+ msg({
513
+ message: "Show as a list with full posts",
514
+ comment: "@context: Archive view option - list",
515
+ }),
516
+ )}
517
+ />
518
+ </div>
519
+ );
520
+ };
521
+
522
+ /**
523
+ * Time-axis toggle. Sits next to the view toggle because it changes how the
524
+ * page is arranged, not which threads it contains — unlike the filter chips.
525
+ */
526
+ const SortToggle: FC<{
527
+ filters: ArchiveFilters;
528
+ ctx: DimensionContext;
529
+ basePath?: string;
530
+ }> = ({ filters, ctx, basePath = "" }) => {
531
+ const { i18n } = useLingui();
532
+ const sortsByActivity = filters.sort === "updated";
533
+
534
+ return (
535
+ <div
536
+ class="archive-view-toggle"
537
+ role="radiogroup"
538
+ aria-label={i18n._(
539
+ msg({
540
+ message: "Sort order",
541
+ comment: "@context: Archive sort toggle group label",
542
+ }),
543
+ )}
544
+ >
545
+ <ToggleOption
546
+ href={buildFilterUrl(filters, { sort: undefined }, ctx, basePath)}
547
+ icon="clock"
548
+ active={!sortsByActivity}
549
+ label={i18n._(
550
+ msg({
551
+ message: "Sort by when each thread was published",
552
+ comment: "@context: Archive sort option - newest published first",
553
+ }),
554
+ )}
555
+ />
556
+ <ToggleOption
557
+ href={buildFilterUrl(filters, { sort: "updated" }, ctx, basePath)}
558
+ icon="history"
559
+ active={sortsByActivity}
560
+ label={i18n._(
561
+ msg({
562
+ message: "Sort by when each thread was last added to",
563
+ comment:
564
+ "@context: Archive sort option - threads that recently gained a post first",
565
+ }),
566
+ )}
567
+ />
494
568
  </div>
495
569
  );
496
570
  };
@@ -499,43 +573,9 @@ const ViewToggle: FC<{
499
573
  // Filter Bar
500
574
  // =============================================================================
501
575
 
502
- const ARCHIVE_VISIBILITIES: ArchiveVisibility[] = [
503
- "public",
504
- "featured",
505
- "latest_hidden",
506
- "private",
507
- ];
508
-
509
576
  function getVisibilityLabel(v: ArchiveVisibility): string {
510
577
  const { i18n } = useLingui();
511
- const labels: Record<ArchiveVisibility, string> = {
512
- public: i18n._(
513
- msg({
514
- message: "Public",
515
- comment: "@context: Archive visibility filter - public posts",
516
- }),
517
- ),
518
- latest_hidden: i18n._(
519
- msg({
520
- message: "Hidden from Latest",
521
- comment:
522
- "@context: Archive visibility filter for posts hidden from Latest",
523
- }),
524
- ),
525
- private: i18n._(
526
- msg({
527
- message: "Private",
528
- comment: "@context: Archive visibility filter - private posts",
529
- }),
530
- ),
531
- featured: i18n._(
532
- msg({
533
- message: "Featured",
534
- comment: "@context: Archive visibility filter - featured posts",
535
- }),
536
- ),
537
- };
538
- return labels[v];
578
+ return getSharedVisibilityLabel(v, i18n);
539
579
  }
540
580
 
541
581
  const VISIBILITY_ICONS: Record<ArchiveVisibility, string> = {
@@ -567,19 +607,27 @@ const THREAD_ICONS = {
567
607
 
568
608
  const FilterBar: FC<{
569
609
  filters: ArchiveFilters;
610
+ defaultLayout: ArchiveLayout;
570
611
  availableYears: number[];
571
- availableCollections: { slug: string; title: string }[];
612
+ availableCollections: { id: string; slug: string; title: string }[];
572
613
  isAuthenticated: boolean;
573
- sitePathPrefix?: string;
614
+ basePath?: string;
574
615
  }> = ({
575
616
  filters,
617
+ defaultLayout,
576
618
  availableYears,
577
619
  availableCollections,
578
620
  isAuthenticated,
579
- sitePathPrefix = "",
621
+ basePath = "",
580
622
  }) => {
581
623
  const { i18n } = useLingui();
582
- const currentUrl = buildFilterUrl(filters, {}, sitePathPrefix);
624
+ // Every link on this bar is this page with one dimension edited, so the bar
625
+ // spells them with the same serializer the route parses with.
626
+ const ctx: DimensionContext = {
627
+ collections: buildCollectionVocabulary(availableCollections),
628
+ };
629
+ const currentUrl = buildFilterUrl(filters, {}, ctx, basePath);
630
+ const selection = filters.selection;
583
631
 
584
632
  // --- Year options ---------------------------------------------------------
585
633
 
@@ -592,20 +640,24 @@ const FilterBar: FC<{
592
640
  }),
593
641
  ),
594
642
  icon: FILTER_ICONS.year,
595
- value: buildFilterUrl(
596
- { ...filters, year: undefined },
597
- { year: undefined },
598
- sitePathPrefix,
599
- ),
643
+ value: buildFilterUrl(filters, { year: undefined }, ctx, basePath),
600
644
  },
601
645
  ...availableYears.map((year) => ({
602
646
  label: String(year),
603
- value: buildFilterUrl(filters, { year }, sitePathPrefix),
647
+ value: buildFilterUrl(filters, { year }, ctx, basePath),
604
648
  })),
605
649
  ];
606
650
 
607
651
  // --- Collection options ---------------------------------------------------
608
652
 
653
+ // The chip picks one collection at a time. A URL may name several — the
654
+ // vocabulary supports it and the page renders it — but a dropdown that
655
+ // replaces the selection is the honest control for a single click.
656
+ const selectedCollectionIds = selection.collection ?? [];
657
+ const selectedCollectionTitles = selectedCollectionIds
658
+ .map((id) => ctx.collections?.titleById.get(id))
659
+ .filter((title): title is string => Boolean(title));
660
+
609
661
  const collectionOptions: ChipSelectOption[] = [
610
662
  {
611
663
  label: i18n._(
@@ -615,52 +667,40 @@ const FilterBar: FC<{
615
667
  }),
616
668
  ),
617
669
  icon: FILTER_ICONS.collection,
618
- value: buildFilterUrl(
619
- {
620
- ...filters,
621
- collectionSlug: undefined,
622
- collectionTitle: undefined,
623
- },
624
- { collectionSlug: undefined, collectionTitle: undefined },
625
- sitePathPrefix,
626
- ),
670
+ value: buildFilterUrl(filters, { collection: undefined }, ctx, basePath),
627
671
  },
628
672
  ...availableCollections.map((col) => ({
629
673
  label: col.title,
630
- value: buildFilterUrl(
631
- filters,
632
- { collectionSlug: col.slug },
633
- sitePathPrefix,
634
- ),
674
+ value: buildFilterUrl(filters, { collection: [col.id] }, ctx, basePath),
635
675
  })),
636
676
  ];
637
677
 
638
678
  // --- Format options (Notes split into All / Titled / Untitled) -----------
639
679
 
640
- const formatActiveLabel = filters.format
641
- ? filters.hasTitle === true
680
+ const formatActiveLabel = selection.format
681
+ ? selection.title === true
642
682
  ? i18n._(
643
683
  msg({
644
684
  message: "Titled",
645
685
  comment: "@context: Archive filter - notes that have a title",
646
686
  }),
647
687
  )
648
- : filters.hasTitle === false
688
+ : selection.title === false
649
689
  ? i18n._(
650
690
  msg({
651
691
  message: "Untitled",
652
692
  comment: "@context: Archive filter - notes without a title",
653
693
  }),
654
694
  )
655
- : getFormatLabelPlural(filters.format)
695
+ : getFormatLabelPlural(selection.format)
656
696
  : undefined;
657
697
 
658
- const formatActiveIcon = filters.format
659
- ? filters.hasTitle === true
698
+ const formatActiveIcon = selection.format
699
+ ? selection.title === true
660
700
  ? "type"
661
- : filters.hasTitle === false
701
+ : selection.title === false
662
702
  ? "text"
663
- : FORMAT_ICONS[filters.format]
703
+ : FORMAT_ICONS[selection.format]
664
704
  : undefined;
665
705
 
666
706
  const formatOptions: ChipSelectOption[] = [
@@ -673,9 +713,10 @@ const FilterBar: FC<{
673
713
  ),
674
714
  icon: FILTER_ICONS.format,
675
715
  value: buildFilterUrl(
676
- { ...filters, format: undefined, hasTitle: undefined },
677
- { format: undefined, hasTitle: undefined },
678
- sitePathPrefix,
716
+ filters,
717
+ { format: undefined, title: undefined },
718
+ ctx,
719
+ basePath,
679
720
  ),
680
721
  },
681
722
  {
@@ -683,11 +724,9 @@ const FilterBar: FC<{
683
724
  icon: FORMAT_ICONS.note,
684
725
  value: buildFilterUrl(
685
726
  filters,
686
- {
687
- format: "note",
688
- hasTitle: undefined,
689
- },
690
- sitePathPrefix,
727
+ { format: "note", title: undefined },
728
+ ctx,
729
+ basePath,
691
730
  ),
692
731
  },
693
732
  {
@@ -701,11 +740,9 @@ const FilterBar: FC<{
701
740
  indent: true,
702
741
  value: buildFilterUrl(
703
742
  filters,
704
- {
705
- format: "note",
706
- hasTitle: true,
707
- },
708
- sitePathPrefix,
743
+ { format: "note", title: true },
744
+ ctx,
745
+ basePath,
709
746
  ),
710
747
  },
711
748
  {
@@ -719,11 +756,9 @@ const FilterBar: FC<{
719
756
  indent: true,
720
757
  value: buildFilterUrl(
721
758
  filters,
722
- {
723
- format: "note",
724
- hasTitle: false,
725
- },
726
- sitePathPrefix,
759
+ { format: "note", title: false },
760
+ ctx,
761
+ basePath,
727
762
  ),
728
763
  },
729
764
  ...FORMATS.filter((f) => f !== "note").map((f) => ({
@@ -731,25 +766,33 @@ const FilterBar: FC<{
731
766
  icon: FORMAT_ICONS[f],
732
767
  value: buildFilterUrl(
733
768
  filters,
734
- { format: f, hasTitle: undefined },
735
- sitePathPrefix,
769
+ { format: f, title: undefined },
770
+ ctx,
771
+ basePath,
736
772
  ),
737
773
  })),
738
774
  ];
739
775
 
740
- // --- Visibility options (authenticated only) --------------------------------
741
-
742
- // "All visibility" needs the explicit ?visibility=all param so the route
743
- // doesn't default back to "public". Build its URL by appending to the
744
- // base URL (which has no visibility param since we merge undefined).
745
- const allVisibilityBaseUrl = buildFilterUrl(
746
- { ...filters, visibility: undefined },
776
+ // --- Visibility options ------------------------------------------------------
777
+
778
+ // A signed-out reader gets the same dimension minus `private`: the archive
779
+ // already shows them Hidden-from-Latest posts, so those two values name sets
780
+ // they can actually reach. `private` names one they cannot, and the route
781
+ // redirects rather than quietly widening it.
782
+ const selectableVisibilities = isAuthenticated
783
+ ? ARCHIVE_VISIBILITIES
784
+ : PUBLIC_ARCHIVE_VISIBILITIES;
785
+
786
+ // "All visibility" is the absence of the parameter, not a value of it. The
787
+ // page once emitted `?visibility=all` here, which read back as exactly the
788
+ // same thing — a second spelling of "nothing selected" that only the parser
789
+ // knew about. It is still read, for URLs already written; it is not written.
790
+ const allVisibilityUrl = buildFilterUrl(
791
+ filters,
747
792
  { visibility: undefined },
748
- sitePathPrefix,
793
+ ctx,
794
+ basePath,
749
795
  );
750
- const allVisibilityUrl = allVisibilityBaseUrl.includes("?")
751
- ? `${allVisibilityBaseUrl}&visibility=all`
752
- : `${allVisibilityBaseUrl}?visibility=all`;
753
796
 
754
797
  const visibilityOptions: ChipSelectOption[] = [
755
798
  {
@@ -762,21 +805,22 @@ const FilterBar: FC<{
762
805
  icon: FILTER_ICONS.visibility,
763
806
  value: allVisibilityUrl,
764
807
  },
765
- ...ARCHIVE_VISIBILITIES.map((v) => ({
808
+ ...selectableVisibilities.map((v) => ({
766
809
  label: getVisibilityLabel(v),
767
810
  ...(v === "featured"
768
811
  ? { iconHtml: FEATURED_VISIBILITY_ICON_HTML }
769
812
  : { icon: VISIBILITY_ICONS[v] }),
770
- value: buildFilterUrl(filters, { visibility: v }, sitePathPrefix),
813
+ value: buildFilterUrl(filters, { visibility: v }, ctx, basePath),
771
814
  })),
772
815
  ];
773
816
 
774
817
  // --- Thread options ---------------------------------------------------------
775
818
 
776
819
  const threadClearUrl = buildFilterUrl(
777
- { ...filters, hasReplies: undefined },
778
- { hasReplies: undefined },
779
- sitePathPrefix,
820
+ filters,
821
+ { replies: undefined },
822
+ ctx,
823
+ basePath,
780
824
  );
781
825
 
782
826
  const threadsLabel = i18n._(
@@ -806,31 +850,31 @@ const FilterBar: FC<{
806
850
  {
807
851
  label: threadsLabel,
808
852
  icon: THREAD_ICONS.threads,
809
- value: buildFilterUrl(filters, { hasReplies: true }, sitePathPrefix),
853
+ value: buildFilterUrl(filters, { replies: true }, ctx, basePath),
810
854
  },
811
855
  {
812
856
  label: singlePostsLabel,
813
857
  icon: THREAD_ICONS.single,
814
- value: buildFilterUrl(filters, { hasReplies: false }, sitePathPrefix),
858
+ value: buildFilterUrl(filters, { replies: false }, ctx, basePath),
815
859
  },
816
860
  ];
817
861
 
818
862
  const threadActiveLabel =
819
- filters.hasReplies === true
863
+ selection.replies === true
820
864
  ? threadsLabel
821
- : filters.hasReplies === false
865
+ : selection.replies === false
822
866
  ? singlePostsLabel
823
867
  : undefined;
824
868
  const threadActiveIcon =
825
- filters.hasReplies === true
869
+ selection.replies === true
826
870
  ? THREAD_ICONS.threads
827
- : filters.hasReplies === false
871
+ : selection.replies === false
828
872
  ? THREAD_ICONS.single
829
873
  : undefined;
830
874
 
831
- const activeKinds = filters.mediaKinds ?? [];
875
+ const activeKinds = selectedMediaKinds(selection.media);
832
876
  const mediaActiveLabel =
833
- filters.hasMedia === false
877
+ selection.media === "none"
834
878
  ? i18n._(
835
879
  msg({
836
880
  message: "Text",
@@ -843,11 +887,20 @@ const FilterBar: FC<{
843
887
  getMediaKindLabel(activeKinds[0]!)
844
888
  : activeKinds.length > 1
845
889
  ? String(activeKinds.length)
846
- : undefined;
890
+ : selection.media === "any"
891
+ ? i18n._(
892
+ msg({
893
+ message: "With media",
894
+ comment:
895
+ "@context: Archive filter - posts carrying any media attachment",
896
+ }),
897
+ )
898
+ : undefined;
847
899
  const mediaClearUrl = buildFilterUrl(
848
- { ...filters, mediaKinds: undefined, hasMedia: undefined },
849
- { mediaKinds: undefined, hasMedia: undefined },
850
- sitePathPrefix,
900
+ filters,
901
+ { media: undefined },
902
+ ctx,
903
+ basePath,
851
904
  );
852
905
 
853
906
  return (
@@ -860,11 +913,12 @@ const FilterBar: FC<{
860
913
  options={yearOptions}
861
914
  currentValue={currentUrl}
862
915
  clearUrl={buildFilterUrl(
863
- { ...filters, year: undefined },
916
+ filters,
864
917
  { year: undefined },
865
- sitePathPrefix,
918
+ ctx,
919
+ basePath,
866
920
  )}
867
- activeLabel={filters.year ? String(filters.year) : undefined}
921
+ activeLabel={selection.year ? String(selection.year) : undefined}
868
922
  />
869
923
  )}
870
924
  {availableCollections.length > 0 && (
@@ -874,15 +928,16 @@ const FilterBar: FC<{
874
928
  options={collectionOptions}
875
929
  currentValue={currentUrl}
876
930
  clearUrl={buildFilterUrl(
877
- {
878
- ...filters,
879
- collectionSlug: undefined,
880
- collectionTitle: undefined,
881
- },
882
- { collectionSlug: undefined, collectionTitle: undefined },
883
- sitePathPrefix,
931
+ filters,
932
+ { collection: undefined },
933
+ ctx,
934
+ basePath,
884
935
  )}
885
- activeLabel={filters.collectionTitle}
936
+ activeLabel={
937
+ selectedCollectionTitles.length > 0
938
+ ? selectedCollectionTitles.join(", ")
939
+ : undefined
940
+ }
886
941
  iconOnly
887
942
  />
888
943
  )}
@@ -892,9 +947,10 @@ const FilterBar: FC<{
892
947
  options={formatOptions}
893
948
  currentValue={currentUrl}
894
949
  clearUrl={buildFilterUrl(
895
- { ...filters, format: undefined, hasTitle: undefined },
896
- { format: undefined, hasTitle: undefined },
897
- sitePathPrefix,
950
+ filters,
951
+ { format: undefined, title: undefined },
952
+ ctx,
953
+ basePath,
898
954
  )}
899
955
  activeLabel={formatActiveLabel}
900
956
  activeIcon={formatActiveIcon}
@@ -916,39 +972,46 @@ const FilterBar: FC<{
916
972
  id="af-media"
917
973
  icon={FILTER_ICONS.media}
918
974
  filters={filters}
975
+ ctx={ctx}
919
976
  activeLabel={mediaActiveLabel}
920
977
  clearUrl={mediaClearUrl}
921
- sitePathPrefix={sitePathPrefix}
978
+ basePath={basePath}
922
979
  />
923
980
 
924
- {isAuthenticated && (
925
- <ChipSelect
926
- id="af-visibility"
927
- icon={FILTER_ICONS.visibility}
928
- options={visibilityOptions}
929
- currentValue={currentUrl}
930
- clearUrl={allVisibilityUrl}
931
- activeLabel={
932
- filters.visibility
933
- ? getVisibilityLabel(filters.visibility)
934
- : undefined
935
- }
936
- activeIcon={
937
- filters.visibility && filters.visibility !== "featured"
938
- ? VISIBILITY_ICONS[filters.visibility]
939
- : undefined
940
- }
941
- activeIconHtml={
942
- filters.visibility === "featured"
943
- ? FEATURED_VISIBILITY_ICON_HTML
944
- : undefined
945
- }
946
- iconOnly
947
- />
948
- )}
981
+ <ChipSelect
982
+ id="af-visibility"
983
+ icon={FILTER_ICONS.visibility}
984
+ options={visibilityOptions}
985
+ currentValue={currentUrl}
986
+ clearUrl={allVisibilityUrl}
987
+ activeLabel={
988
+ selection.visibility
989
+ ? getVisibilityLabel(selection.visibility)
990
+ : undefined
991
+ }
992
+ activeIcon={
993
+ selection.visibility && selection.visibility !== "featured"
994
+ ? VISIBILITY_ICONS[selection.visibility]
995
+ : undefined
996
+ }
997
+ activeIconHtml={
998
+ selection.visibility === "featured"
999
+ ? FEATURED_VISIBILITY_ICON_HTML
1000
+ : undefined
1001
+ }
1002
+ iconOnly
1003
+ />
949
1004
  </div>
950
1005
 
951
- <ViewToggle filters={filters} sitePathPrefix={sitePathPrefix} />
1006
+ <div class="archive-toolbar-toggles">
1007
+ <SortToggle filters={filters} ctx={ctx} basePath={basePath} />
1008
+ <LayoutToggle
1009
+ filters={filters}
1010
+ ctx={ctx}
1011
+ defaultLayout={defaultLayout}
1012
+ basePath={basePath}
1013
+ />
1014
+ </div>
952
1015
  </div>
953
1016
  );
954
1017
  };
@@ -1245,20 +1308,26 @@ const ArchiveTile: FC<{ post: PostView; timeZone?: string }> = ({
1245
1308
  export const ArchivePage: FC<ArchivePageProps> = ({
1246
1309
  groups,
1247
1310
  items,
1311
+ defaultLayout = "list",
1248
1312
  totalCount,
1313
+ baselineCount,
1249
1314
  currentPage,
1250
1315
  totalPages,
1251
1316
  filters,
1252
1317
  availableYears,
1253
1318
  availableCollections,
1254
1319
  isAuthenticated,
1255
- sitePathPrefix = "",
1320
+ basePath = "",
1256
1321
  timeZone = "UTC",
1257
1322
  feedHref,
1258
1323
  }) => {
1259
1324
  const { i18n } = useLingui();
1260
- const currentView: ArchiveView = filters.view ?? "grid";
1261
- const paginationBaseUrl = buildFilterUrl(filters, {}, sitePathPrefix);
1325
+ const currentLayout: ArchiveLayout = filters.layout ?? defaultLayout;
1326
+ const sortsByActivity = filters.sort === "updated";
1327
+ const dimensionCtx: DimensionContext = {
1328
+ collections: buildCollectionVocabulary(availableCollections),
1329
+ };
1330
+ const paginationBaseUrl = buildFilterUrl(filters, {}, dimensionCtx, basePath);
1262
1331
  const totalCountUnit =
1263
1332
  totalCount === 1
1264
1333
  ? i18n._(
@@ -1276,30 +1345,54 @@ export const ArchivePage: FC<ArchivePageProps> = ({
1276
1345
  }),
1277
1346
  );
1278
1347
 
1348
+ // A reader who arrives on a pre-filtered URL never applied the filter and has
1349
+ // no way to know how much it removed. Naming the seven dimensions back at
1350
+ // them is what the chip bar already does; what it cannot say is how much is
1351
+ // left, so the count carries its own baseline instead.
1352
+ const showsSubset =
1353
+ hasActiveArchiveFilter(filters.selection) &&
1354
+ baselineCount !== undefined &&
1355
+ baselineCount > totalCount;
1356
+
1357
+ const countRemainder = showsSubset
1358
+ ? i18n._(
1359
+ msg({
1360
+ message: "of {total} {unit}",
1361
+ comment:
1362
+ "@context: Archive page count when a filter narrows the view, following the matching count — reads as '42 of 1,240 threads'",
1363
+ }),
1364
+ { total: baselineCount, unit: totalCountUnit },
1365
+ )
1366
+ : totalCountUnit;
1367
+
1368
+ const countSummary = `${totalCount} ${countRemainder}`;
1369
+
1279
1370
  return (
1280
1371
  <div class="py-6" data-page="archive">
1281
1372
  <header class="archive-page-header page-intro">
1282
1373
  <div class="page-intro-title-row">
1283
- <h1 class="page-intro-title">
1284
- {i18n._(
1285
- msg({
1286
- message: "Archive",
1287
- comment: "@context: Archive page title",
1288
- }),
1289
- )}
1290
- </h1>
1374
+ <h1 class="page-intro-title">{i18n._(ARCHIVE_ALL_POSTS_LABEL)}</h1>
1291
1375
  </div>
1292
- <p
1293
- class="page-intro-meta archive-page-meta"
1294
- aria-label={`${totalCount} ${totalCountUnit}`}
1295
- >
1376
+ <p class="page-intro-meta archive-page-meta" aria-label={countSummary}>
1296
1377
  <span class="archive-page-summary-count">{totalCount}</span>{" "}
1297
- {totalCountUnit}
1378
+ {countRemainder}
1379
+ {sortsByActivity && (
1380
+ <>
1381
+ {" · "}
1382
+ {i18n._(
1383
+ msg({
1384
+ message: "newest changes first",
1385
+ comment:
1386
+ "@context: Archive page meta note explaining the active sort order",
1387
+ }),
1388
+ )}
1389
+ </>
1390
+ )}
1298
1391
  {feedHref && (
1299
1392
  <>
1300
1393
  {" "}
1301
1394
  <a
1302
- href={toPublicPath(feedHref, sitePathPrefix)}
1395
+ href={toPublicPath(feedHref, basePath)}
1303
1396
  class="feed-link"
1304
1397
  title={i18n._(
1305
1398
  msg({
@@ -1322,10 +1415,11 @@ export const ArchivePage: FC<ArchivePageProps> = ({
1322
1415
 
1323
1416
  <FilterBar
1324
1417
  filters={filters}
1418
+ defaultLayout={defaultLayout}
1325
1419
  availableYears={availableYears}
1326
1420
  availableCollections={availableCollections}
1327
1421
  isAuthenticated={isAuthenticated}
1328
- sitePathPrefix={sitePathPrefix}
1422
+ basePath={basePath}
1329
1423
  />
1330
1424
  </header>
1331
1425
 
@@ -1340,14 +1434,20 @@ export const ArchivePage: FC<ArchivePageProps> = ({
1340
1434
  }),
1341
1435
  )}
1342
1436
  </p>
1343
- ) : currentView === "grid" ? (
1437
+ ) : currentLayout === "grid" ? (
1344
1438
  <div class="archive-grid-wrapper">
1345
1439
  <div class="archive-grid">
1346
1440
  {groups.map((group, groupIndex) => (
1347
1441
  <div key={`grid-${group.year}-${group.month}`} class="contents">
1348
1442
  <ArchiveMonthHeader
1349
1443
  class={`archive-month-header${groupIndex > 0 ? " archive-month-header-spaced" : ""}`}
1350
- label={group.label}
1444
+ // Not translated, on purpose: `group.label` is built with
1445
+ // a hardcoded en-US month name, so a translated wrapper
1446
+ // would only produce "更新于 August 2026". Both halves
1447
+ // become translatable together, or neither does.
1448
+ label={
1449
+ sortsByActivity ? `Updated ${group.label}` : group.label
1450
+ }
1351
1451
  count={group.totalCount}
1352
1452
  />
1353
1453
  {group.posts.map((post) => (