@jant/core 0.6.16 → 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 (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -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-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  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-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  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__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. 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,50 +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);
65
- if (merged.sort === "updated") params.set("sort", "updated");
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");
66
93
 
67
94
  const qs = params.toString();
68
95
  return qs
69
- ? toPublicPath(`/archive?${qs}`, sitePathPrefix)
70
- : toPublicPath("/archive", sitePathPrefix);
96
+ ? toPublicPath(`/archive?${qs}`, basePath)
97
+ : toPublicPath("/archive", basePath);
71
98
  }
72
99
 
73
100
  // =============================================================================
@@ -99,29 +126,9 @@ function getFormatLabel(format: string): string {
99
126
  return labels[format] ?? format;
100
127
  }
101
128
 
102
- function getFormatLabelPlural(format: string): string {
129
+ function getFormatLabelPlural(format: Format): string {
103
130
  const { i18n } = useLingui();
104
- const labels: Record<string, string> = {
105
- note: i18n._(
106
- msg({
107
- message: "Notes",
108
- comment: "@context: Post format label plural - notes",
109
- }),
110
- ),
111
- link: i18n._(
112
- msg({
113
- message: "Links",
114
- comment: "@context: Post format label plural - links",
115
- }),
116
- ),
117
- quote: i18n._(
118
- msg({
119
- message: "Quotes",
120
- comment: "@context: Post format label plural - quotes",
121
- }),
122
- ),
123
- };
124
- return labels[format] ?? format + "s";
131
+ return getSharedFormatLabelPlural(format, i18n);
125
132
  }
126
133
 
127
134
  /** Icon name mapping for post formats. */
@@ -142,39 +149,21 @@ const MEDIA_KIND_ICONS: Record<MediaKind, string> = {
142
149
 
143
150
  function getMediaKindLabel(kind: MediaKind): string {
144
151
  const { i18n } = useLingui();
145
- const labels: Record<MediaKind, string> = {
146
- image: i18n._(
147
- msg({
148
- message: "Images",
149
- comment: "@context: Archive media filter - images",
150
- }),
151
- ),
152
- video: i18n._(
153
- msg({
154
- message: "Video",
155
- comment: "@context: Archive media filter - video",
156
- }),
157
- ),
158
- audio: i18n._(
159
- msg({
160
- message: "Audio",
161
- comment: "@context: Archive media filter - audio",
162
- }),
163
- ),
164
- text: i18n._(
165
- msg({
166
- message: "Text attachment",
167
- comment: "@context: Archive media filter - text file attachments",
168
- }),
169
- ),
170
- document: i18n._(
171
- msg({
172
- message: "Files",
173
- comment: "@context: Archive media filter - files/documents",
174
- }),
175
- ),
176
- };
177
- 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;
178
167
  }
179
168
 
180
169
  // =============================================================================
@@ -352,28 +341,25 @@ const ChipMediaSelect: FC<{
352
341
  id: string;
353
342
  icon: string;
354
343
  filters: ArchiveFilters;
344
+ ctx: DimensionContext;
355
345
  activeLabel?: string;
356
346
  clearUrl: string;
357
- sitePathPrefix?: string;
358
- }> = ({ id, icon, filters: f, activeLabel, clearUrl, sitePathPrefix = "" }) => {
347
+ basePath?: string;
348
+ }> = ({ id, icon, filters: f, ctx, activeLabel, clearUrl, basePath = "" }) => {
359
349
  const { i18n } = useLingui();
360
350
  const isActive = !!activeLabel;
361
- const activeKinds = f.mediaKinds ?? [];
351
+ const activeKinds = selectedMediaKinds(f.selection.media);
362
352
 
363
353
  const singleKind = activeKinds.length === 1 ? activeKinds[0] : undefined;
364
354
  const activeMediaIcon = isActive
365
- ? f.hasMedia === false
355
+ ? f.selection.media === "none"
366
356
  ? "text"
367
357
  : singleKind
368
358
  ? MEDIA_KIND_ICONS[singleKind]
369
359
  : icon
370
360
  : undefined;
371
361
 
372
- const textOnlyUrl = buildFilterUrl(
373
- { ...f, mediaKinds: undefined, hasMedia: undefined },
374
- { hasMedia: false, mediaKinds: undefined },
375
- sitePathPrefix,
376
- );
362
+ const textOnlyUrl = buildFilterUrl(f, { media: "none" }, ctx, basePath);
377
363
  const clearLabel = i18n._(
378
364
  msg({
379
365
  message: "Clear filter",
@@ -387,7 +373,7 @@ const ChipMediaSelect: FC<{
387
373
  id={id}
388
374
  class="archive-chip-select archive-chip-dropdown archive-chip-media select"
389
375
  data-select-initialized
390
- data-filter-key="media"
376
+ data-filter-key={FILTER_DIMENSIONS.media.url.param}
391
377
  >
392
378
  <button
393
379
  type="button"
@@ -420,7 +406,7 @@ const ChipMediaSelect: FC<{
420
406
  role="option"
421
407
  data-value={textOnlyUrl}
422
408
  data-navigate="true"
423
- aria-selected={f.hasMedia === false ? "true" : undefined}
409
+ aria-selected={f.selection.media === "none" ? "true" : undefined}
424
410
  >
425
411
  <span class="flex items-center gap-2">
426
412
  <Icon name="text" class="[&>svg]:size-4 text-muted-foreground" />
@@ -487,12 +473,14 @@ const ToggleOption: FC<{
487
473
  </a>
488
474
  );
489
475
 
490
- const ViewToggle: FC<{
476
+ const LayoutToggle: FC<{
491
477
  filters: ArchiveFilters;
492
- sitePathPrefix?: string;
493
- }> = ({ filters, sitePathPrefix = "" }) => {
478
+ ctx: DimensionContext;
479
+ defaultLayout: ArchiveLayout;
480
+ basePath?: string;
481
+ }> = ({ filters, ctx, defaultLayout, basePath = "" }) => {
494
482
  const { i18n } = useLingui();
495
- const currentView: ArchiveView = filters.view ?? "grid";
483
+ const currentLayout: ArchiveLayout = filters.layout ?? defaultLayout;
496
484
 
497
485
  return (
498
486
  <div
@@ -500,15 +488,15 @@ const ViewToggle: FC<{
500
488
  role="radiogroup"
501
489
  aria-label={i18n._(
502
490
  msg({
503
- message: "View mode",
491
+ message: "Layout",
504
492
  comment: "@context: Archive grid/list toggle group label",
505
493
  }),
506
494
  )}
507
495
  >
508
496
  <ToggleOption
509
- href={buildFilterUrl(filters, { view: undefined }, sitePathPrefix)}
497
+ href={buildFilterUrl(filters, { layout: "grid" }, ctx, basePath)}
510
498
  icon="layout-grid"
511
- active={currentView === "grid"}
499
+ active={currentLayout === "grid"}
512
500
  label={i18n._(
513
501
  msg({
514
502
  message: "Show as a grid of tiles",
@@ -517,9 +505,9 @@ const ViewToggle: FC<{
517
505
  )}
518
506
  />
519
507
  <ToggleOption
520
- href={buildFilterUrl(filters, { view: "list" }, sitePathPrefix)}
508
+ href={buildFilterUrl(filters, { layout: "list" }, ctx, basePath)}
521
509
  icon="list"
522
- active={currentView === "list"}
510
+ active={currentLayout === "list"}
523
511
  label={i18n._(
524
512
  msg({
525
513
  message: "Show as a list with full posts",
@@ -537,8 +525,9 @@ const ViewToggle: FC<{
537
525
  */
538
526
  const SortToggle: FC<{
539
527
  filters: ArchiveFilters;
540
- sitePathPrefix?: string;
541
- }> = ({ filters, sitePathPrefix = "" }) => {
528
+ ctx: DimensionContext;
529
+ basePath?: string;
530
+ }> = ({ filters, ctx, basePath = "" }) => {
542
531
  const { i18n } = useLingui();
543
532
  const sortsByActivity = filters.sort === "updated";
544
533
 
@@ -554,7 +543,7 @@ const SortToggle: FC<{
554
543
  )}
555
544
  >
556
545
  <ToggleOption
557
- href={buildFilterUrl(filters, { sort: undefined }, sitePathPrefix)}
546
+ href={buildFilterUrl(filters, { sort: undefined }, ctx, basePath)}
558
547
  icon="clock"
559
548
  active={!sortsByActivity}
560
549
  label={i18n._(
@@ -565,7 +554,7 @@ const SortToggle: FC<{
565
554
  )}
566
555
  />
567
556
  <ToggleOption
568
- href={buildFilterUrl(filters, { sort: "updated" }, sitePathPrefix)}
557
+ href={buildFilterUrl(filters, { sort: "updated" }, ctx, basePath)}
569
558
  icon="history"
570
559
  active={sortsByActivity}
571
560
  label={i18n._(
@@ -584,43 +573,9 @@ const SortToggle: FC<{
584
573
  // Filter Bar
585
574
  // =============================================================================
586
575
 
587
- const ARCHIVE_VISIBILITIES: ArchiveVisibility[] = [
588
- "public",
589
- "featured",
590
- "latest_hidden",
591
- "private",
592
- ];
593
-
594
576
  function getVisibilityLabel(v: ArchiveVisibility): string {
595
577
  const { i18n } = useLingui();
596
- const labels: Record<ArchiveVisibility, string> = {
597
- public: i18n._(
598
- msg({
599
- message: "Public",
600
- comment: "@context: Archive visibility filter - public posts",
601
- }),
602
- ),
603
- latest_hidden: i18n._(
604
- msg({
605
- message: "Hidden from Latest",
606
- comment:
607
- "@context: Archive visibility filter for posts hidden from Latest",
608
- }),
609
- ),
610
- private: i18n._(
611
- msg({
612
- message: "Private",
613
- comment: "@context: Archive visibility filter - private posts",
614
- }),
615
- ),
616
- featured: i18n._(
617
- msg({
618
- message: "Featured",
619
- comment: "@context: Archive visibility filter - featured posts",
620
- }),
621
- ),
622
- };
623
- return labels[v];
578
+ return getSharedVisibilityLabel(v, i18n);
624
579
  }
625
580
 
626
581
  const VISIBILITY_ICONS: Record<ArchiveVisibility, string> = {
@@ -652,19 +607,27 @@ const THREAD_ICONS = {
652
607
 
653
608
  const FilterBar: FC<{
654
609
  filters: ArchiveFilters;
610
+ defaultLayout: ArchiveLayout;
655
611
  availableYears: number[];
656
- availableCollections: { slug: string; title: string }[];
612
+ availableCollections: { id: string; slug: string; title: string }[];
657
613
  isAuthenticated: boolean;
658
- sitePathPrefix?: string;
614
+ basePath?: string;
659
615
  }> = ({
660
616
  filters,
617
+ defaultLayout,
661
618
  availableYears,
662
619
  availableCollections,
663
620
  isAuthenticated,
664
- sitePathPrefix = "",
621
+ basePath = "",
665
622
  }) => {
666
623
  const { i18n } = useLingui();
667
- 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;
668
631
 
669
632
  // --- Year options ---------------------------------------------------------
670
633
 
@@ -677,20 +640,24 @@ const FilterBar: FC<{
677
640
  }),
678
641
  ),
679
642
  icon: FILTER_ICONS.year,
680
- value: buildFilterUrl(
681
- { ...filters, year: undefined },
682
- { year: undefined },
683
- sitePathPrefix,
684
- ),
643
+ value: buildFilterUrl(filters, { year: undefined }, ctx, basePath),
685
644
  },
686
645
  ...availableYears.map((year) => ({
687
646
  label: String(year),
688
- value: buildFilterUrl(filters, { year }, sitePathPrefix),
647
+ value: buildFilterUrl(filters, { year }, ctx, basePath),
689
648
  })),
690
649
  ];
691
650
 
692
651
  // --- Collection options ---------------------------------------------------
693
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
+
694
661
  const collectionOptions: ChipSelectOption[] = [
695
662
  {
696
663
  label: i18n._(
@@ -700,52 +667,40 @@ const FilterBar: FC<{
700
667
  }),
701
668
  ),
702
669
  icon: FILTER_ICONS.collection,
703
- value: buildFilterUrl(
704
- {
705
- ...filters,
706
- collectionSlug: undefined,
707
- collectionTitle: undefined,
708
- },
709
- { collectionSlug: undefined, collectionTitle: undefined },
710
- sitePathPrefix,
711
- ),
670
+ value: buildFilterUrl(filters, { collection: undefined }, ctx, basePath),
712
671
  },
713
672
  ...availableCollections.map((col) => ({
714
673
  label: col.title,
715
- value: buildFilterUrl(
716
- filters,
717
- { collectionSlug: col.slug },
718
- sitePathPrefix,
719
- ),
674
+ value: buildFilterUrl(filters, { collection: [col.id] }, ctx, basePath),
720
675
  })),
721
676
  ];
722
677
 
723
678
  // --- Format options (Notes split into All / Titled / Untitled) -----------
724
679
 
725
- const formatActiveLabel = filters.format
726
- ? filters.hasTitle === true
680
+ const formatActiveLabel = selection.format
681
+ ? selection.title === true
727
682
  ? i18n._(
728
683
  msg({
729
684
  message: "Titled",
730
685
  comment: "@context: Archive filter - notes that have a title",
731
686
  }),
732
687
  )
733
- : filters.hasTitle === false
688
+ : selection.title === false
734
689
  ? i18n._(
735
690
  msg({
736
691
  message: "Untitled",
737
692
  comment: "@context: Archive filter - notes without a title",
738
693
  }),
739
694
  )
740
- : getFormatLabelPlural(filters.format)
695
+ : getFormatLabelPlural(selection.format)
741
696
  : undefined;
742
697
 
743
- const formatActiveIcon = filters.format
744
- ? filters.hasTitle === true
698
+ const formatActiveIcon = selection.format
699
+ ? selection.title === true
745
700
  ? "type"
746
- : filters.hasTitle === false
701
+ : selection.title === false
747
702
  ? "text"
748
- : FORMAT_ICONS[filters.format]
703
+ : FORMAT_ICONS[selection.format]
749
704
  : undefined;
750
705
 
751
706
  const formatOptions: ChipSelectOption[] = [
@@ -758,9 +713,10 @@ const FilterBar: FC<{
758
713
  ),
759
714
  icon: FILTER_ICONS.format,
760
715
  value: buildFilterUrl(
761
- { ...filters, format: undefined, hasTitle: undefined },
762
- { format: undefined, hasTitle: undefined },
763
- sitePathPrefix,
716
+ filters,
717
+ { format: undefined, title: undefined },
718
+ ctx,
719
+ basePath,
764
720
  ),
765
721
  },
766
722
  {
@@ -768,11 +724,9 @@ const FilterBar: FC<{
768
724
  icon: FORMAT_ICONS.note,
769
725
  value: buildFilterUrl(
770
726
  filters,
771
- {
772
- format: "note",
773
- hasTitle: undefined,
774
- },
775
- sitePathPrefix,
727
+ { format: "note", title: undefined },
728
+ ctx,
729
+ basePath,
776
730
  ),
777
731
  },
778
732
  {
@@ -786,11 +740,9 @@ const FilterBar: FC<{
786
740
  indent: true,
787
741
  value: buildFilterUrl(
788
742
  filters,
789
- {
790
- format: "note",
791
- hasTitle: true,
792
- },
793
- sitePathPrefix,
743
+ { format: "note", title: true },
744
+ ctx,
745
+ basePath,
794
746
  ),
795
747
  },
796
748
  {
@@ -804,11 +756,9 @@ const FilterBar: FC<{
804
756
  indent: true,
805
757
  value: buildFilterUrl(
806
758
  filters,
807
- {
808
- format: "note",
809
- hasTitle: false,
810
- },
811
- sitePathPrefix,
759
+ { format: "note", title: false },
760
+ ctx,
761
+ basePath,
812
762
  ),
813
763
  },
814
764
  ...FORMATS.filter((f) => f !== "note").map((f) => ({
@@ -816,25 +766,33 @@ const FilterBar: FC<{
816
766
  icon: FORMAT_ICONS[f],
817
767
  value: buildFilterUrl(
818
768
  filters,
819
- { format: f, hasTitle: undefined },
820
- sitePathPrefix,
769
+ { format: f, title: undefined },
770
+ ctx,
771
+ basePath,
821
772
  ),
822
773
  })),
823
774
  ];
824
775
 
825
- // --- Visibility options (authenticated only) --------------------------------
826
-
827
- // "All visibility" needs the explicit ?visibility=all param so the route
828
- // doesn't default back to "public". Build its URL by appending to the
829
- // base URL (which has no visibility param since we merge undefined).
830
- const allVisibilityBaseUrl = buildFilterUrl(
831
- { ...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,
832
792
  { visibility: undefined },
833
- sitePathPrefix,
793
+ ctx,
794
+ basePath,
834
795
  );
835
- const allVisibilityUrl = allVisibilityBaseUrl.includes("?")
836
- ? `${allVisibilityBaseUrl}&visibility=all`
837
- : `${allVisibilityBaseUrl}?visibility=all`;
838
796
 
839
797
  const visibilityOptions: ChipSelectOption[] = [
840
798
  {
@@ -847,21 +805,22 @@ const FilterBar: FC<{
847
805
  icon: FILTER_ICONS.visibility,
848
806
  value: allVisibilityUrl,
849
807
  },
850
- ...ARCHIVE_VISIBILITIES.map((v) => ({
808
+ ...selectableVisibilities.map((v) => ({
851
809
  label: getVisibilityLabel(v),
852
810
  ...(v === "featured"
853
811
  ? { iconHtml: FEATURED_VISIBILITY_ICON_HTML }
854
812
  : { icon: VISIBILITY_ICONS[v] }),
855
- value: buildFilterUrl(filters, { visibility: v }, sitePathPrefix),
813
+ value: buildFilterUrl(filters, { visibility: v }, ctx, basePath),
856
814
  })),
857
815
  ];
858
816
 
859
817
  // --- Thread options ---------------------------------------------------------
860
818
 
861
819
  const threadClearUrl = buildFilterUrl(
862
- { ...filters, hasReplies: undefined },
863
- { hasReplies: undefined },
864
- sitePathPrefix,
820
+ filters,
821
+ { replies: undefined },
822
+ ctx,
823
+ basePath,
865
824
  );
866
825
 
867
826
  const threadsLabel = i18n._(
@@ -891,31 +850,31 @@ const FilterBar: FC<{
891
850
  {
892
851
  label: threadsLabel,
893
852
  icon: THREAD_ICONS.threads,
894
- value: buildFilterUrl(filters, { hasReplies: true }, sitePathPrefix),
853
+ value: buildFilterUrl(filters, { replies: true }, ctx, basePath),
895
854
  },
896
855
  {
897
856
  label: singlePostsLabel,
898
857
  icon: THREAD_ICONS.single,
899
- value: buildFilterUrl(filters, { hasReplies: false }, sitePathPrefix),
858
+ value: buildFilterUrl(filters, { replies: false }, ctx, basePath),
900
859
  },
901
860
  ];
902
861
 
903
862
  const threadActiveLabel =
904
- filters.hasReplies === true
863
+ selection.replies === true
905
864
  ? threadsLabel
906
- : filters.hasReplies === false
865
+ : selection.replies === false
907
866
  ? singlePostsLabel
908
867
  : undefined;
909
868
  const threadActiveIcon =
910
- filters.hasReplies === true
869
+ selection.replies === true
911
870
  ? THREAD_ICONS.threads
912
- : filters.hasReplies === false
871
+ : selection.replies === false
913
872
  ? THREAD_ICONS.single
914
873
  : undefined;
915
874
 
916
- const activeKinds = filters.mediaKinds ?? [];
875
+ const activeKinds = selectedMediaKinds(selection.media);
917
876
  const mediaActiveLabel =
918
- filters.hasMedia === false
877
+ selection.media === "none"
919
878
  ? i18n._(
920
879
  msg({
921
880
  message: "Text",
@@ -928,11 +887,20 @@ const FilterBar: FC<{
928
887
  getMediaKindLabel(activeKinds[0]!)
929
888
  : activeKinds.length > 1
930
889
  ? String(activeKinds.length)
931
- : 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;
932
899
  const mediaClearUrl = buildFilterUrl(
933
- { ...filters, mediaKinds: undefined, hasMedia: undefined },
934
- { mediaKinds: undefined, hasMedia: undefined },
935
- sitePathPrefix,
900
+ filters,
901
+ { media: undefined },
902
+ ctx,
903
+ basePath,
936
904
  );
937
905
 
938
906
  return (
@@ -945,11 +913,12 @@ const FilterBar: FC<{
945
913
  options={yearOptions}
946
914
  currentValue={currentUrl}
947
915
  clearUrl={buildFilterUrl(
948
- { ...filters, year: undefined },
916
+ filters,
949
917
  { year: undefined },
950
- sitePathPrefix,
918
+ ctx,
919
+ basePath,
951
920
  )}
952
- activeLabel={filters.year ? String(filters.year) : undefined}
921
+ activeLabel={selection.year ? String(selection.year) : undefined}
953
922
  />
954
923
  )}
955
924
  {availableCollections.length > 0 && (
@@ -959,15 +928,16 @@ const FilterBar: FC<{
959
928
  options={collectionOptions}
960
929
  currentValue={currentUrl}
961
930
  clearUrl={buildFilterUrl(
962
- {
963
- ...filters,
964
- collectionSlug: undefined,
965
- collectionTitle: undefined,
966
- },
967
- { collectionSlug: undefined, collectionTitle: undefined },
968
- sitePathPrefix,
931
+ filters,
932
+ { collection: undefined },
933
+ ctx,
934
+ basePath,
969
935
  )}
970
- activeLabel={filters.collectionTitle}
936
+ activeLabel={
937
+ selectedCollectionTitles.length > 0
938
+ ? selectedCollectionTitles.join(", ")
939
+ : undefined
940
+ }
971
941
  iconOnly
972
942
  />
973
943
  )}
@@ -977,9 +947,10 @@ const FilterBar: FC<{
977
947
  options={formatOptions}
978
948
  currentValue={currentUrl}
979
949
  clearUrl={buildFilterUrl(
980
- { ...filters, format: undefined, hasTitle: undefined },
981
- { format: undefined, hasTitle: undefined },
982
- sitePathPrefix,
950
+ filters,
951
+ { format: undefined, title: undefined },
952
+ ctx,
953
+ basePath,
983
954
  )}
984
955
  activeLabel={formatActiveLabel}
985
956
  activeIcon={formatActiveIcon}
@@ -1001,41 +972,45 @@ const FilterBar: FC<{
1001
972
  id="af-media"
1002
973
  icon={FILTER_ICONS.media}
1003
974
  filters={filters}
975
+ ctx={ctx}
1004
976
  activeLabel={mediaActiveLabel}
1005
977
  clearUrl={mediaClearUrl}
1006
- sitePathPrefix={sitePathPrefix}
978
+ basePath={basePath}
1007
979
  />
1008
980
 
1009
- {isAuthenticated && (
1010
- <ChipSelect
1011
- id="af-visibility"
1012
- icon={FILTER_ICONS.visibility}
1013
- options={visibilityOptions}
1014
- currentValue={currentUrl}
1015
- clearUrl={allVisibilityUrl}
1016
- activeLabel={
1017
- filters.visibility
1018
- ? getVisibilityLabel(filters.visibility)
1019
- : undefined
1020
- }
1021
- activeIcon={
1022
- filters.visibility && filters.visibility !== "featured"
1023
- ? VISIBILITY_ICONS[filters.visibility]
1024
- : undefined
1025
- }
1026
- activeIconHtml={
1027
- filters.visibility === "featured"
1028
- ? FEATURED_VISIBILITY_ICON_HTML
1029
- : undefined
1030
- }
1031
- iconOnly
1032
- />
1033
- )}
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
+ />
1034
1004
  </div>
1035
1005
 
1036
1006
  <div class="archive-toolbar-toggles">
1037
- <SortToggle filters={filters} sitePathPrefix={sitePathPrefix} />
1038
- <ViewToggle filters={filters} sitePathPrefix={sitePathPrefix} />
1007
+ <SortToggle filters={filters} ctx={ctx} basePath={basePath} />
1008
+ <LayoutToggle
1009
+ filters={filters}
1010
+ ctx={ctx}
1011
+ defaultLayout={defaultLayout}
1012
+ basePath={basePath}
1013
+ />
1039
1014
  </div>
1040
1015
  </div>
1041
1016
  );
@@ -1333,21 +1308,26 @@ const ArchiveTile: FC<{ post: PostView; timeZone?: string }> = ({
1333
1308
  export const ArchivePage: FC<ArchivePageProps> = ({
1334
1309
  groups,
1335
1310
  items,
1311
+ defaultLayout = "list",
1336
1312
  totalCount,
1313
+ baselineCount,
1337
1314
  currentPage,
1338
1315
  totalPages,
1339
1316
  filters,
1340
1317
  availableYears,
1341
1318
  availableCollections,
1342
1319
  isAuthenticated,
1343
- sitePathPrefix = "",
1320
+ basePath = "",
1344
1321
  timeZone = "UTC",
1345
1322
  feedHref,
1346
1323
  }) => {
1347
1324
  const { i18n } = useLingui();
1348
- const currentView: ArchiveView = filters.view ?? "grid";
1325
+ const currentLayout: ArchiveLayout = filters.layout ?? defaultLayout;
1349
1326
  const sortsByActivity = filters.sort === "updated";
1350
- const paginationBaseUrl = buildFilterUrl(filters, {}, sitePathPrefix);
1327
+ const dimensionCtx: DimensionContext = {
1328
+ collections: buildCollectionVocabulary(availableCollections),
1329
+ };
1330
+ const paginationBaseUrl = buildFilterUrl(filters, {}, dimensionCtx, basePath);
1351
1331
  const totalCountUnit =
1352
1332
  totalCount === 1
1353
1333
  ? i18n._(
@@ -1365,25 +1345,37 @@ export const ArchivePage: FC<ArchivePageProps> = ({
1365
1345
  }),
1366
1346
  );
1367
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
+
1368
1370
  return (
1369
1371
  <div class="py-6" data-page="archive">
1370
1372
  <header class="archive-page-header page-intro">
1371
1373
  <div class="page-intro-title-row">
1372
- <h1 class="page-intro-title">
1373
- {i18n._(
1374
- msg({
1375
- message: "Archive",
1376
- comment: "@context: Archive page title",
1377
- }),
1378
- )}
1379
- </h1>
1374
+ <h1 class="page-intro-title">{i18n._(ARCHIVE_ALL_POSTS_LABEL)}</h1>
1380
1375
  </div>
1381
- <p
1382
- class="page-intro-meta archive-page-meta"
1383
- aria-label={`${totalCount} ${totalCountUnit}`}
1384
- >
1376
+ <p class="page-intro-meta archive-page-meta" aria-label={countSummary}>
1385
1377
  <span class="archive-page-summary-count">{totalCount}</span>{" "}
1386
- {totalCountUnit}
1378
+ {countRemainder}
1387
1379
  {sortsByActivity && (
1388
1380
  <>
1389
1381
  {" · "}
@@ -1400,7 +1392,7 @@ export const ArchivePage: FC<ArchivePageProps> = ({
1400
1392
  <>
1401
1393
  {" "}
1402
1394
  <a
1403
- href={toPublicPath(feedHref, sitePathPrefix)}
1395
+ href={toPublicPath(feedHref, basePath)}
1404
1396
  class="feed-link"
1405
1397
  title={i18n._(
1406
1398
  msg({
@@ -1423,10 +1415,11 @@ export const ArchivePage: FC<ArchivePageProps> = ({
1423
1415
 
1424
1416
  <FilterBar
1425
1417
  filters={filters}
1418
+ defaultLayout={defaultLayout}
1426
1419
  availableYears={availableYears}
1427
1420
  availableCollections={availableCollections}
1428
1421
  isAuthenticated={isAuthenticated}
1429
- sitePathPrefix={sitePathPrefix}
1422
+ basePath={basePath}
1430
1423
  />
1431
1424
  </header>
1432
1425
 
@@ -1441,7 +1434,7 @@ export const ArchivePage: FC<ArchivePageProps> = ({
1441
1434
  }),
1442
1435
  )}
1443
1436
  </p>
1444
- ) : currentView === "grid" ? (
1437
+ ) : currentLayout === "grid" ? (
1445
1438
  <div class="archive-grid-wrapper">
1446
1439
  <div class="archive-grid">
1447
1440
  {groups.map((group, groupIndex) => (