@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
@@ -18,7 +18,15 @@ import {
18
18
  import { showConfirmDialog } from "../confirm.js";
19
19
  import { refreshArticleView } from "../post-refresh.js";
20
20
  import { showToast } from "../toast.js";
21
+ import { readErrorMessage } from "../json.js";
21
22
  import { publicPath } from "../runtime-paths.js";
23
+ import { looksLikeAddress } from "../../lib/url.js";
24
+ import { pickPost } from "../post-picker.js";
25
+ import {
26
+ ensureComposeDialog,
27
+ readComposeDialogLabels,
28
+ } from "../compose-launch.js";
29
+ import type { PostPickerResult } from "./jant-post-picker.js";
22
30
  import {
23
31
  applyItemOrder,
24
32
  filterCollectionsBySearch,
@@ -35,6 +43,51 @@ interface PostMenuData {
35
43
  visibility: string;
36
44
  isReply: boolean;
37
45
  isDraft: boolean;
46
+ /** Content language of the Thread, when the site publishes more than one. */
47
+ language: string | null;
48
+ }
49
+
50
+ /** One language the site publishes. */
51
+ interface MenuLanguage {
52
+ tag: string;
53
+ label: string;
54
+ }
55
+
56
+ /**
57
+ * What the server made of an address pasted into the translation picker: the
58
+ * Thread it names, or why that Thread cannot be linked to this one.
59
+ */
60
+ type TranslationAddressResolution =
61
+ | {
62
+ kind: "ok";
63
+ address: string;
64
+ candidate: { id: string; label: string; language: string | null };
65
+ }
66
+ | {
67
+ kind:
68
+ | "external"
69
+ | "not_found"
70
+ | "not_a_post"
71
+ | "same_thread"
72
+ | "unpublished"
73
+ | "no_language"
74
+ | "same_language"
75
+ | "group_conflict";
76
+ address: string;
77
+ }
78
+ | {
79
+ kind: "language_taken" | "group_language_taken";
80
+ address: string;
81
+ language: string;
82
+ };
83
+
84
+ /** A Thread already linked to this one as a translation. */
85
+ interface TranslationItem {
86
+ id: string;
87
+ slug: string;
88
+ /** What to call it — its title, or something derived when it has none. */
89
+ label: string;
90
+ language: string | null;
38
91
  }
39
92
 
40
93
  interface CollectionItem {
@@ -51,7 +104,16 @@ interface ThreadCollectionsResponse {
51
104
  collectionIds?: string[];
52
105
  }
53
106
 
54
- type PostMenuView = "menu" | "collections" | "visibility";
107
+ type PostMenuView =
108
+ "menu" | "collections" | "visibility" | "language" | "language-switch";
109
+
110
+ /**
111
+ * Where the language panel puts focus, in document order rather than in the
112
+ * order written here — `querySelector` picks the first match on the page, so
113
+ * this reads as "the topmost real action in the panel", whichever it is.
114
+ */
115
+ const LANGUAGE_PANEL_FOCUS =
116
+ "[data-post-menu-translation] a, [data-post-menu-translation-first], [data-post-menu-open-language-switch]";
55
117
 
56
118
  interface MenuTriggerRect {
57
119
  top: number;
@@ -107,11 +169,14 @@ function readPostMenuData(article: HTMLElement): PostMenuData | null {
107
169
  visibility: article.dataset.postVisibility ?? "public",
108
170
  isReply: threadId !== id || article.hasAttribute("data-post-reply"),
109
171
  isDraft: article.hasAttribute("data-post-draft"),
172
+ // Carried on the Thread root, so a reply reads it from there.
173
+ language: threadRootArticle.dataset.postLanguage ?? null,
110
174
  };
111
175
  }
112
176
 
113
177
  export class JantPostMenu extends LitElement {
114
178
  static properties = {
179
+ languages: { type: Array },
115
180
  _open: { state: true },
116
181
  _data: { state: true },
117
182
  _x: { state: true },
@@ -123,8 +188,12 @@ export class JantPostMenu extends LitElement {
123
188
  _collectionSearch: { state: true },
124
189
  _threadCollectionIds: { state: true },
125
190
  _addCollectionPanelOpen: { state: true },
191
+ _translations: { state: true },
192
+ _translationsLoading: { state: true },
193
+ _translationBusy: { state: true },
126
194
  };
127
195
 
196
+ declare languages: MenuLanguage[];
128
197
  declare _open: boolean;
129
198
  declare _data: PostMenuData | null;
130
199
  declare _x: number;
@@ -136,6 +205,9 @@ export class JantPostMenu extends LitElement {
136
205
  declare _collectionSearch: string;
137
206
  declare _threadCollectionIds: string[];
138
207
  declare _addCollectionPanelOpen: boolean;
208
+ declare _translations: TranslationItem[] | null;
209
+ declare _translationsLoading: boolean;
210
+ declare _translationBusy: boolean;
139
211
  declare _triggerEl: HTMLElement | null;
140
212
 
141
213
  /** Whether collections were modified during this session (triggers page reload on close) */
@@ -150,6 +222,7 @@ export class JantPostMenu extends LitElement {
150
222
 
151
223
  constructor() {
152
224
  super();
225
+ this.languages = [];
153
226
  this._open = false;
154
227
  this._data = null;
155
228
  this._x = 0;
@@ -161,6 +234,9 @@ export class JantPostMenu extends LitElement {
161
234
  this._collectionSearch = "";
162
235
  this._threadCollectionIds = [];
163
236
  this._addCollectionPanelOpen = false;
237
+ this._translations = null;
238
+ this._translationsLoading = false;
239
+ this._translationBusy = false;
164
240
  this._triggerEl = null;
165
241
  }
166
242
 
@@ -218,6 +294,12 @@ export class JantPostMenu extends LitElement {
218
294
  return;
219
295
  }
220
296
  if (this._open) {
297
+ // One step back per press, not one step out: the language picker is a
298
+ // level deeper than the panel that opens it.
299
+ if (this._view === "language-switch") {
300
+ this.#showLanguagePanel();
301
+ return;
302
+ }
221
303
  if (this._view !== "menu") {
222
304
  this.#showMainMenu();
223
305
  return;
@@ -300,11 +382,27 @@ export class JantPostMenu extends LitElement {
300
382
  return;
301
383
  }
302
384
 
303
- // Clicking inside the dropdown — don't close (menu or collection picker)
385
+ // Clicking inside the dropdown — don't close (menu or any of its panels).
386
+ //
387
+ // `.post-menu-view` — the root of whichever panel is on screen — and not
388
+ // the two obvious alternatives, for two different reasons.
389
+ //
390
+ // Not `[role="menu"]`: that is on the *list*, and a panel's header sits
391
+ // outside it, so every back button read as a click outside the menu and
392
+ // closed the whole thing.
393
+ //
394
+ // Not `.post-menu-panel`, the container the views render into, even though
395
+ // it reads as the more honest "anywhere in the dropdown". On a *real*
396
+ // click the browser runs a microtask checkpoint between event listeners,
397
+ // so a menu item that switches panels has already re-rendered by the time
398
+ // this handler runs: `target` is a detached node whose subtree ends at the
399
+ // view root, and `closest` can no longer reach the container. Clicking
400
+ // Language would open the panel and close the menu in the same event.
401
+ // (`el.click()` from a script never shows this — the stack never empties,
402
+ // so nothing re-renders mid-dispatch. Hence the `performUpdate()` in the
403
+ // test that covers it.)
304
404
  if (this._open) {
305
- const inside = target.closest?.(
306
- "[role='menu'], [data-collection-picker], [data-visibility-panel]",
307
- );
405
+ const inside = target.closest?.(".post-menu-view, .post-menu-panel");
308
406
  if (inside) return;
309
407
  }
310
408
 
@@ -465,6 +563,402 @@ export class JantPostMenu extends LitElement {
465
563
  );
466
564
  }
467
565
 
566
+ // --- Language and translations ---
567
+
568
+ /** Whether this site publishes enough languages for any of this to matter. */
569
+ get #multilingual(): boolean {
570
+ return this.languages.length > 1;
571
+ }
572
+
573
+ #languageLabel(tag: string | null): string {
574
+ if (!tag) return "Not set";
575
+ return this.languages.find((l) => l.tag === tag)?.label ?? tag;
576
+ }
577
+
578
+ async #openLanguagePanel() {
579
+ if (this._data?.isReply) return;
580
+ this._view = "language";
581
+ this.#focusAfterUpdate(LANGUAGE_PANEL_FOCUS);
582
+ // The panel is mostly about them, and the picker one level down needs them
583
+ // too: a language is unavailable exactly when another post in the group
584
+ // already holds it.
585
+ await this.#loadTranslations();
586
+ // A Thread whose every other language is already spoken for has nothing to
587
+ // put on screen until this resolves, so the first attempt found nothing to
588
+ // land on. Anywhere else, focus is already inside and must stay put.
589
+ if (!this.contains(document.activeElement)) {
590
+ this.#focusAfterUpdate(LANGUAGE_PANEL_FOCUS);
591
+ }
592
+ }
593
+
594
+ #showLanguagePanel(focusSelector = "[data-post-menu-open-language-switch]") {
595
+ this._view = "language";
596
+ this.#focusAfterUpdate(focusSelector);
597
+ }
598
+
599
+ /**
600
+ * The one place the language picker lives.
601
+ *
602
+ * Behind a row of its own rather than at the top of the language panel:
603
+ * switching a Thread's language is a correction, made once if ever, while
604
+ * reading and adding other versions is the daily work. Left inline it also
605
+ * degenerated — on a two-language site whose other version is already linked,
606
+ * the radio group rendered a single unclickable row for the current language.
607
+ */
608
+ #openLanguageSwitch() {
609
+ if (this._data?.isReply) return;
610
+ this._view = "language-switch";
611
+ this.#focusAfterUpdate(
612
+ "[data-post-menu-language-current='true'], [data-post-menu-language-option]",
613
+ );
614
+ }
615
+
616
+ /**
617
+ * Split the site's languages by what this Thread can do with each.
618
+ *
619
+ * A language another version already holds is neither selectable nor free:
620
+ * the group allows one post per language, so it is spoken for either way.
621
+ */
622
+ #languageChoices() {
623
+ const current = this._data?.language ?? null;
624
+ const linked = this._translations ?? [];
625
+ const held = new Set(
626
+ linked.flatMap((translation) =>
627
+ translation.language ? [translation.language] : [],
628
+ ),
629
+ );
630
+ return {
631
+ current,
632
+ linked,
633
+ selectable: this.languages.filter(
634
+ (language) => language.tag === current || !held.has(language.tag),
635
+ ),
636
+ free: this.languages.filter(
637
+ (language) => language.tag !== current && !held.has(language.tag),
638
+ ),
639
+ };
640
+ }
641
+
642
+ /**
643
+ * Set the language of this whole Thread.
644
+ *
645
+ * Not optimistic: unlike visibility, this can be refused server-side — the
646
+ * Thread's translation group may already hold that language — and showing the
647
+ * new language before the server agrees would be a lie the author acts on.
648
+ */
649
+ async #setLanguage(tag: string) {
650
+ const data = this._data;
651
+ if (!data || this._translationBusy) return;
652
+ this._translationBusy = true;
653
+
654
+ try {
655
+ const response = await fetch(
656
+ publicPath(`/api/posts/${data.threadId}/language`),
657
+ {
658
+ method: "PUT",
659
+ headers: { "Content-Type": "application/json" },
660
+ credentials: "same-origin",
661
+ body: JSON.stringify({ language: tag }),
662
+ },
663
+ );
664
+ if (!response.ok) {
665
+ showToast(
666
+ await readErrorMessage(
667
+ response,
668
+ "Could not change the language. Try again.",
669
+ ),
670
+ "error",
671
+ );
672
+ return;
673
+ }
674
+
675
+ const article = document.querySelector<HTMLElement>(
676
+ `article[data-post-id="${data.threadId}"]`,
677
+ );
678
+ if (article) article.dataset.postLanguage = tag;
679
+ this._data = { ...data, language: tag };
680
+ showToast(`Thread is now in ${this.#languageLabel(tag)}.`);
681
+ this.#close();
682
+ } catch {
683
+ showToast("Could not change the language. Try again.", "error");
684
+ } finally {
685
+ this._translationBusy = false;
686
+ }
687
+ }
688
+
689
+ async #loadTranslations() {
690
+ const data = this._data;
691
+ if (!data) return;
692
+ this._translationsLoading = true;
693
+ try {
694
+ const response = await fetch(
695
+ publicPath(`/api/posts/${data.threadId}/translations`),
696
+ { credentials: "same-origin" },
697
+ );
698
+ if (!response.ok) return;
699
+ const body = (await response.json()) as {
700
+ translations?: TranslationItem[];
701
+ };
702
+ this._translations = body.translations ?? [];
703
+ } catch {
704
+ this._translations = [];
705
+ } finally {
706
+ this._translationsLoading = false;
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Open the composer on a new post that translates this one.
712
+ *
713
+ * Prefers the dialog, so the author writes the translation without leaving
714
+ * the post they are translating — navigating away to a blank page loses that
715
+ * context exactly when it is most useful. Pages that render no dialog (the
716
+ * standalone composer among them) fall back to the URL, which carries the
717
+ * same two values.
718
+ */
719
+ async #writeTranslation(tag: string) {
720
+ const data = this._data;
721
+ if (!data) return;
722
+ this.#close({ restoreFocus: false });
723
+
724
+ const composeEl = await ensureComposeDialog();
725
+ if (composeEl) {
726
+ await composeEl.openTranslation(data.threadId, tag);
727
+ return;
728
+ }
729
+
730
+ const params = new URLSearchParams({
731
+ translationOf: data.threadId,
732
+ lang: tag,
733
+ });
734
+ window.location.assign(publicPath(`/new?${params.toString()}`));
735
+ }
736
+
737
+ /**
738
+ * Ask the author which post to link, in a dialog rather than in this popover.
739
+ *
740
+ * The menu is the wrong shape for a search: results need room for a real
741
+ * title and a keyboard. The picker is generic — the eligibility rules stay on
742
+ * the server, and this only supplies the copy and the request.
743
+ */
744
+ async #openLinkPicker() {
745
+ const data = this._data;
746
+ if (!data) return;
747
+ this.#close({ restoreFocus: false });
748
+
749
+ const picked = await pickPost({
750
+ heading: "Link a translation",
751
+ hint: "Only posts you could actually link are listed: published, and in a language this one's group does not already have.",
752
+ placeholder: "Search your posts, or paste a URL…",
753
+ emptyHint: "Nothing matched that you could link.",
754
+ search: async (query) => {
755
+ // An author writing a translation has the other post open in a tab, so
756
+ // the URL is closer to hand than the title. Resolving it also lets the
757
+ // answer be a reason rather than an empty list.
758
+ if (looksLikeAddress(query)) {
759
+ return this.#resolveTranslationAddress(data.threadId, query);
760
+ }
761
+
762
+ const response = await fetch(
763
+ publicPath(
764
+ `/api/posts/${data.threadId}/translations/candidates?q=${encodeURIComponent(query)}`,
765
+ ),
766
+ { credentials: "same-origin" },
767
+ );
768
+ if (!response.ok) return { items: [] };
769
+ const body = (await response.json()) as {
770
+ candidates?: Array<{
771
+ id: string;
772
+ label: string;
773
+ language: string | null;
774
+ }>;
775
+ };
776
+ return {
777
+ items: (body.candidates ?? []).map((candidate) => ({
778
+ id: candidate.id,
779
+ label: candidate.label,
780
+ meta: this.#languageLabel(candidate.language),
781
+ })),
782
+ };
783
+ },
784
+ });
785
+
786
+ if (!picked) return;
787
+ this._data = data;
788
+ await this.#linkTranslation(picked);
789
+ // The page renders the link too ("Also available in …"), so it has to catch
790
+ // up with the change the author just made.
791
+ window.location.reload();
792
+ }
793
+
794
+ /**
795
+ * Look up the post at a pasted address, and say what is wrong with it when
796
+ * it cannot be linked. The rules are the server's; this only puts the answer
797
+ * into words.
798
+ */
799
+ async #resolveTranslationAddress(
800
+ threadId: string,
801
+ address: string,
802
+ ): Promise<PostPickerResult> {
803
+ let resolution: TranslationAddressResolution | undefined;
804
+ try {
805
+ const response = await fetch(
806
+ publicPath(
807
+ `/api/posts/${threadId}/translations/resolve?url=${encodeURIComponent(address)}`,
808
+ ),
809
+ { credentials: "same-origin" },
810
+ );
811
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
812
+ ({ resolution } = (await response.json()) as {
813
+ resolution?: TranslationAddressResolution;
814
+ });
815
+ } catch {
816
+ return {
817
+ items: [],
818
+ note: "Could not check that address. Try again in a moment.",
819
+ };
820
+ }
821
+
822
+ if (!resolution) return { items: [] };
823
+ if (resolution.kind === "ok") {
824
+ return {
825
+ items: [
826
+ {
827
+ id: resolution.candidate.id,
828
+ label: resolution.candidate.label,
829
+ meta: this.#languageLabel(resolution.candidate.language),
830
+ },
831
+ ],
832
+ };
833
+ }
834
+
835
+ return { items: [], note: this.#translationAddressNote(resolution) };
836
+ }
837
+
838
+ #translationAddressNote(resolution: TranslationAddressResolution): string {
839
+ const language = this.#languageLabel(
840
+ "language" in resolution ? resolution.language : null,
841
+ );
842
+
843
+ switch (resolution.kind) {
844
+ case "external":
845
+ return "That address is somewhere else. Only posts on this site can be linked.";
846
+ case "not_found":
847
+ return `Nothing at ${resolution.address}. Check the address, or search by title.`;
848
+ case "not_a_post":
849
+ return "That address is not a post. Translations link posts to posts.";
850
+ case "same_thread":
851
+ return "That is this post.";
852
+ case "unpublished":
853
+ return "That post is a draft. Publish it, then link it.";
854
+ case "no_language":
855
+ return "That post has no language yet. Set one on it first.";
856
+ case "same_language":
857
+ return `That post is written in ${this.#languageLabel(
858
+ this._data?.language ?? null,
859
+ )} too. A translation has to be in another language.`;
860
+ case "language_taken":
861
+ return `A ${language} version is already linked here. Unlink that one first.`;
862
+ case "group_conflict":
863
+ return "That post belongs to another translation group. Unlink it first.";
864
+ case "group_language_taken":
865
+ return `Its translation group already has a ${language} version.`;
866
+ default:
867
+ return "That post cannot be linked.";
868
+ }
869
+ }
870
+
871
+ async #linkTranslation(otherId: string) {
872
+ const data = this._data;
873
+ if (!data || this._translationBusy) return;
874
+ this._translationBusy = true;
875
+
876
+ try {
877
+ const response = await fetch(
878
+ publicPath(`/api/posts/${data.threadId}/translations`),
879
+ {
880
+ method: "POST",
881
+ headers: { "Content-Type": "application/json" },
882
+ credentials: "same-origin",
883
+ body: JSON.stringify({ postId: otherId }),
884
+ },
885
+ );
886
+ if (!response.ok) {
887
+ showToast(
888
+ await readErrorMessage(
889
+ response,
890
+ "Could not link those posts. Try again.",
891
+ ),
892
+ "error",
893
+ );
894
+ return;
895
+ }
896
+ await this.#loadTranslations();
897
+ showToast("Posts linked as translations.");
898
+ } catch {
899
+ showToast("Could not link those posts. Try again.", "error");
900
+ } finally {
901
+ this._translationBusy = false;
902
+ }
903
+ }
904
+
905
+ /**
906
+ * Take one other version out of this Thread's translation group.
907
+ *
908
+ * Unlinking is per-version rather than "leave the group": from the author's
909
+ * seat the group *is* the list of other versions, so removing the row they
910
+ * are looking at is the action they mean. A group left with a single member
911
+ * collapses server-side, which makes the two-post case do the obvious thing.
912
+ *
913
+ * The confirm runs after the menu closes, the way deleting a post does — a
914
+ * modal opened underneath a popover fights it for focus and for the backdrop
915
+ * click.
916
+ */
917
+ async #unlinkTranslation(target: TranslationItem) {
918
+ const data = this._data;
919
+ if (!data || this._translationBusy) return;
920
+
921
+ const trigger = this._triggerEl;
922
+ this.#close({ restoreFocus: false });
923
+
924
+ const languageLabel = this.#languageLabel(target.language);
925
+ const confirmed = await showConfirmDialog({
926
+ message: `Unlink the ${languageLabel} version, “${target.label}”? Both posts stay published — they just stop pointing at each other.`,
927
+ confirmLabel: "Unlink",
928
+ cancelLabel: "Cancel",
929
+ tone: "danger",
930
+ });
931
+ if (!confirmed) {
932
+ trigger?.focus();
933
+ return;
934
+ }
935
+
936
+ this._translationBusy = true;
937
+ try {
938
+ const response = await fetch(
939
+ publicPath(`/api/posts/${target.id}/translations`),
940
+ { method: "DELETE", credentials: "same-origin" },
941
+ );
942
+ if (!response.ok) {
943
+ showToast(
944
+ await readErrorMessage(response, "Could not unlink. Try again."),
945
+ "error",
946
+ );
947
+ trigger?.focus();
948
+ return;
949
+ }
950
+ showToast("Translation link removed.");
951
+ // The page renders the link too ("Also available in …"), so it has to
952
+ // catch up with the change the author just made.
953
+ window.location.reload();
954
+ } catch {
955
+ showToast("Could not unlink. Try again.", "error");
956
+ trigger?.focus();
957
+ } finally {
958
+ this._translationBusy = false;
959
+ }
960
+ }
961
+
468
962
  #close(options: { restoreFocus?: boolean } = {}) {
469
963
  const restoreFocus =
470
964
  options.restoreFocus ?? this.#restoreTriggerFocusOnClose;
@@ -476,6 +970,7 @@ export class JantPostMenu extends LitElement {
476
970
  this._view = "menu";
477
971
  this._addCollectionPanelOpen = false;
478
972
  this._collectionSearch = "";
973
+ this._translations = null;
479
974
 
480
975
  if (this.#collectionsDirty) {
481
976
  this.#collectionsDirty = false;
@@ -521,12 +1016,7 @@ export class JantPostMenu extends LitElement {
521
1016
  const postId = this._data.id;
522
1017
  this.#close({ restoreFocus: false });
523
1018
 
524
- const dialog = document.getElementById(
525
- "compose-dialog",
526
- ) as HTMLDialogElement | null;
527
- const composeEl = dialog?.querySelector("jant-compose-dialog") as
528
- | import("./jant-compose-dialog.js").JantComposeDialog
529
- | null;
1019
+ const composeEl = await ensureComposeDialog();
530
1020
  if (composeEl) {
531
1021
  await composeEl.openEdit(postId);
532
1022
  }
@@ -931,8 +1421,7 @@ export class JantPostMenu extends LitElement {
931
1421
  if (!detail) return;
932
1422
 
933
1423
  const formEl = this.querySelector("jant-collection-form") as
934
- | (HTMLElement & { loading: boolean })
935
- | null;
1424
+ (HTMLElement & { loading: boolean }) | null;
936
1425
  if (formEl) formEl.loading = true;
937
1426
 
938
1427
  try {
@@ -992,26 +1481,23 @@ export class JantPostMenu extends LitElement {
992
1481
  }
993
1482
  }
994
1483
 
995
- /** Get collection form labels from the compose dialog (already on the page) */
1484
+ /** Collection form labels the server rendered onto the compose dialog. */
996
1485
  #getCollectionFormLabels() {
997
- const composeEl = document.querySelector("jant-compose-dialog") as
998
- | import("./jant-compose-dialog.js").JantComposeDialog
999
- | null;
1000
- return composeEl?.labels?.collectionFormLabels ?? null;
1486
+ return readComposeDialogLabels()?.collectionFormLabels ?? null;
1001
1487
  }
1002
1488
 
1003
1489
  #getAddCollectionLabel() {
1004
- const composeEl = document.querySelector("jant-compose-dialog") as
1005
- | import("./jant-compose-dialog.js").JantComposeDialog
1006
- | null;
1007
- return composeEl?.labels?.addCollection ?? "Add Collection";
1490
+ return readComposeDialogLabels()?.addCollection ?? "Add Collection";
1008
1491
  }
1009
1492
 
1493
+ /**
1494
+ * The composer reads its collections from server-rendered markup, so a
1495
+ * collection created here would be missing from it until the next page
1496
+ * load. Loading the composer to refresh it is the price of staying right.
1497
+ */
1010
1498
  async #refreshComposeCollections() {
1011
- const composeEl = document.querySelector("jant-compose-dialog") as {
1012
- refreshCollections?: () => Promise<boolean>;
1013
- } | null;
1014
- await composeEl?.refreshCollections?.();
1499
+ const composeEl = await ensureComposeDialog();
1500
+ await composeEl?.refreshCollections();
1015
1501
  }
1016
1502
 
1017
1503
  #getVisibilityLabel(visibility: string) {
@@ -1181,6 +1667,22 @@ export class JantPostMenu extends LitElement {
1181
1667
  </svg>`;
1182
1668
  }
1183
1669
 
1670
+ #iconExternal() {
1671
+ return html`<svg
1672
+ xmlns="http://www.w3.org/2000/svg"
1673
+ viewBox="0 0 24 24"
1674
+ fill="none"
1675
+ stroke="currentColor"
1676
+ stroke-width="1.9"
1677
+ stroke-linecap="round"
1678
+ stroke-linejoin="round"
1679
+ >
1680
+ <path d="M14 4h6v6" />
1681
+ <path d="M20 4 11 13" />
1682
+ <path d="M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4" />
1683
+ </svg>`;
1684
+ }
1685
+
1184
1686
  // --- Render ---
1185
1687
 
1186
1688
  #renderCollectionPicker() {
@@ -1214,116 +1716,126 @@ export class JantPostMenu extends LitElement {
1214
1716
  <span>Collections</span>
1215
1717
  </div>
1216
1718
  </div>
1217
- ${collections.length > 0
1218
- ? html`<div class="post-menu-picker-search">
1219
- <svg
1220
- width="14"
1221
- height="14"
1222
- viewBox="0 0 24 24"
1223
- fill="none"
1224
- stroke="currentColor"
1225
- stroke-width="2"
1226
- stroke-linecap="round"
1227
- stroke-linejoin="round"
1228
- >
1229
- <circle cx="11" cy="11" r="8" />
1230
- <path d="m21 21-4.3-4.3" />
1231
- </svg>
1232
- <input
1233
- type="text"
1234
- placeholder="Search collections..."
1235
- autocomplete="off"
1236
- autocorrect="off"
1237
- spellcheck="false"
1238
- .value=${this._collectionSearch}
1239
- @keydown=${this.#handleCollectionSearchKeydown}
1240
- @input=${(e: Event) => {
1241
- this._collectionSearch = (e.target as HTMLInputElement).value;
1242
- }}
1243
- />
1244
- </div>`
1245
- : nothing}
1719
+ ${
1720
+ collections.length > 0
1721
+ ? html`<div class="post-menu-picker-search">
1722
+ <svg
1723
+ width="14"
1724
+ height="14"
1725
+ viewBox="0 0 24 24"
1726
+ fill="none"
1727
+ stroke="currentColor"
1728
+ stroke-width="2"
1729
+ stroke-linecap="round"
1730
+ stroke-linejoin="round"
1731
+ >
1732
+ <circle cx="11" cy="11" r="8" />
1733
+ <path d="m21 21-4.3-4.3" />
1734
+ </svg>
1735
+ <input
1736
+ type="text"
1737
+ placeholder="Search collections..."
1738
+ autocomplete="off"
1739
+ autocorrect="off"
1740
+ spellcheck="false"
1741
+ .value=${this._collectionSearch}
1742
+ @keydown=${this.#handleCollectionSearchKeydown}
1743
+ @input=${(e: Event) => {
1744
+ this._collectionSearch = (
1745
+ e.target as HTMLInputElement
1746
+ ).value;
1747
+ }}
1748
+ />
1749
+ </div>`
1750
+ : nothing
1751
+ }
1246
1752
  <div
1247
1753
  class="post-menu-picker-list"
1248
1754
  role="listbox"
1249
1755
  aria-multiselectable="true"
1250
1756
  >
1251
- ${this._collectionsLoading
1252
- ? html`<div class="post-menu-picker-empty">Loading...</div>`
1253
- : filtered.length > 0
1254
- ? filtered.map((c) => {
1255
- const selected = this._threadCollectionIds.includes(c.id);
1256
- return html`
1257
- <button
1258
- type="button"
1259
- role="option"
1260
- aria-selected=${selected ? "true" : "false"}
1261
- class=${`post-menu-picker-option${
1262
- selected ? " post-menu-picker-option-selected" : ""
1263
- }`}
1264
- @keydown=${(event: globalThis.KeyboardEvent) =>
1265
- this.#handleCollectionOptionKeydown(event, c.id)}
1266
- @click=${(event: globalThis.MouseEvent) =>
1267
- this.#handleCollectionOptionClick(event, c.id)}
1268
- >
1269
- <span class="post-menu-picker-title">${c.title}</span>
1270
- ${selected
1271
- ? html`<span
1272
- class="post-menu-picker-marker post-menu-picker-marker-selected"
1273
- >
1274
- <svg
1275
- xmlns="http://www.w3.org/2000/svg"
1276
- viewBox="0 0 24 24"
1277
- fill="none"
1278
- aria-hidden="true"
1279
- >
1280
- <circle
1281
- cx="12"
1282
- cy="12"
1283
- r="10"
1284
- fill="currentColor"
1285
- />
1286
- <path
1287
- d="M8 12.5 10.7 15.2 16.4 9.5"
1288
- stroke="var(--site-page-bg)"
1289
- stroke-width="2.3"
1290
- stroke-linecap="round"
1291
- stroke-linejoin="round"
1292
- />
1293
- </svg>
1294
- </span>`
1295
- : html`<span
1296
- class="post-menu-picker-marker post-menu-picker-marker-add"
1297
- >
1298
- <svg
1299
- xmlns="http://www.w3.org/2000/svg"
1300
- viewBox="0 0 24 24"
1301
- fill="none"
1302
- aria-hidden="true"
1303
- >
1304
- <circle
1305
- cx="12"
1306
- cy="12"
1307
- r="9"
1308
- stroke="currentColor"
1309
- stroke-width="1.8"
1310
- />
1311
- <path
1312
- d="M12 8v8M8 12h8"
1313
- stroke="currentColor"
1314
- stroke-width="1.9"
1315
- stroke-linecap="round"
1316
- />
1317
- </svg>
1318
- </span>`}
1319
- </button>
1320
- `;
1321
- })
1322
- : html`<div class="post-menu-picker-empty">
1323
- ${hasSearch
1324
- ? "No matching collections"
1325
- : "No collections yet"}
1326
- </div>`}
1757
+ ${
1758
+ this._collectionsLoading
1759
+ ? html`<div class="post-menu-picker-empty">Loading...</div>`
1760
+ : filtered.length > 0
1761
+ ? filtered.map((c) => {
1762
+ const selected = this._threadCollectionIds.includes(c.id);
1763
+ return html`
1764
+ <button
1765
+ type="button"
1766
+ role="option"
1767
+ aria-selected=${selected ? "true" : "false"}
1768
+ class=${`post-menu-picker-option${
1769
+ selected ? " post-menu-picker-option-selected" : ""
1770
+ }`}
1771
+ @keydown=${(event: globalThis.KeyboardEvent) =>
1772
+ this.#handleCollectionOptionKeydown(event, c.id)}
1773
+ @click=${(event: globalThis.MouseEvent) =>
1774
+ this.#handleCollectionOptionClick(event, c.id)}
1775
+ >
1776
+ <span class="post-menu-picker-title">${c.title}</span>
1777
+ ${
1778
+ selected
1779
+ ? html`<span
1780
+ class="post-menu-picker-marker post-menu-picker-marker-selected"
1781
+ >
1782
+ <svg
1783
+ xmlns="http://www.w3.org/2000/svg"
1784
+ viewBox="0 0 24 24"
1785
+ fill="none"
1786
+ aria-hidden="true"
1787
+ >
1788
+ <circle
1789
+ cx="12"
1790
+ cy="12"
1791
+ r="10"
1792
+ fill="currentColor"
1793
+ />
1794
+ <path
1795
+ d="M8 12.5 10.7 15.2 16.4 9.5"
1796
+ stroke="var(--site-page-bg)"
1797
+ stroke-width="2.3"
1798
+ stroke-linecap="round"
1799
+ stroke-linejoin="round"
1800
+ />
1801
+ </svg>
1802
+ </span>`
1803
+ : html`<span
1804
+ class="post-menu-picker-marker post-menu-picker-marker-add"
1805
+ >
1806
+ <svg
1807
+ xmlns="http://www.w3.org/2000/svg"
1808
+ viewBox="0 0 24 24"
1809
+ fill="none"
1810
+ aria-hidden="true"
1811
+ >
1812
+ <circle
1813
+ cx="12"
1814
+ cy="12"
1815
+ r="9"
1816
+ stroke="currentColor"
1817
+ stroke-width="1.8"
1818
+ />
1819
+ <path
1820
+ d="M12 8v8M8 12h8"
1821
+ stroke="currentColor"
1822
+ stroke-width="1.9"
1823
+ stroke-linecap="round"
1824
+ />
1825
+ </svg>
1826
+ </span>`
1827
+ }
1828
+ </button>
1829
+ `;
1830
+ })
1831
+ : html`<div class="post-menu-picker-empty">
1832
+ ${
1833
+ hasSearch
1834
+ ? "No matching collections"
1835
+ : "No collections yet"
1836
+ }
1837
+ </div>`
1838
+ }
1327
1839
  </div>
1328
1840
  <div class="post-menu-picker-footer">
1329
1841
  <button
@@ -1352,6 +1864,236 @@ export class JantPostMenu extends LitElement {
1352
1864
  `;
1353
1865
  }
1354
1866
 
1867
+ /**
1868
+ * Everything about this Thread's language, in one place.
1869
+ *
1870
+ * The picker and the translation list belong together: a language is
1871
+ * unavailable here precisely because another post in the group already holds
1872
+ * it, and that reason is only legible next to the list of those posts. Two
1873
+ * top-level menu entries for one subject was one too many.
1874
+ *
1875
+ * Top to bottom, the panel answers three questions in the order they get
1876
+ * asked. *What language is this?* — the entry that opened this panel already
1877
+ * said "Language · 简体中文", so the panel opens by confirming it, and the
1878
+ * picker itself is one level down (see `#openLanguageSwitch`) because
1879
+ * changing it is a correction, made once if ever. *What can I do?* — writing
1880
+ * or linking a version, the recurring work, and self-describing enough to
1881
+ * need no section label over it. *What is already there?* — the other
1882
+ * versions, last, because they arrive with a fetch: any earlier and the
1883
+ * panel would shove itself downward under the author's cursor a moment
1884
+ * after opening.
1885
+ */
1886
+ #renderLanguagePanel() {
1887
+ if (!this._data || this._data.isReply) return nothing;
1888
+
1889
+ const { current, linked, free } = this.#languageChoices();
1890
+ return html`
1891
+ <div class="post-menu-view post-menu-language-panel">
1892
+ <div class="post-menu-panel-header">
1893
+ <button
1894
+ type="button"
1895
+ class="post-menu-panel-back"
1896
+ aria-label="Back"
1897
+ @click=${() => this.#showMainMenu("[data-post-menu-open-language]")}
1898
+ >
1899
+ ${this.#iconChevronLeft()}
1900
+ </button>
1901
+ <div class="post-menu-panel-heading">
1902
+ <span>Language</span>
1903
+ </div>
1904
+ </div>
1905
+ <div role="menu" class="post-menu-list">
1906
+ <!-- Both sections stand or fall together: with every other language
1907
+ spoken for there is nothing to switch to and nothing to add. -->
1908
+ ${
1909
+ free.length > 0
1910
+ ? html`
1911
+ <div class="post-menu-section">
1912
+ <button
1913
+ type="button"
1914
+ role="menuitem"
1915
+ class="post-menu-item"
1916
+ data-post-menu-open-language-switch
1917
+ ?disabled=${this._translationBusy}
1918
+ @click=${() => this.#openLanguageSwitch()}
1919
+ >
1920
+ <span class="post-menu-item-label">Change language</span>
1921
+ <span class="post-menu-item-meta"
1922
+ >${this.#languageLabel(current)}</span
1923
+ >
1924
+ <span
1925
+ class="post-menu-item-trailing post-menu-item-chevron"
1926
+ >${this.#iconChevronRight()}</span
1927
+ >
1928
+ </button>
1929
+ </div>
1930
+ <div class="post-menu-section">
1931
+ ${free.map(
1932
+ (language, index) => html`
1933
+ <button
1934
+ type="button"
1935
+ role="menuitem"
1936
+ class="post-menu-item"
1937
+ ?data-post-menu-translation-first=${index === 0}
1938
+ @click=${() => this.#writeTranslation(language.tag)}
1939
+ >
1940
+ <span class="post-menu-item-label"
1941
+ >Write the ${language.label} version</span
1942
+ >
1943
+ <span
1944
+ class="post-menu-item-trailing post-menu-item-chevron"
1945
+ >${this.#iconChevronRight()}</span
1946
+ >
1947
+ </button>
1948
+ `,
1949
+ )}
1950
+ <button
1951
+ type="button"
1952
+ role="menuitem"
1953
+ class="post-menu-item"
1954
+ @click=${() => this.#openLinkPicker()}
1955
+ >
1956
+ <span class="post-menu-item-label"
1957
+ >Link a version you already wrote</span
1958
+ >
1959
+ <span
1960
+ class="post-menu-item-trailing post-menu-item-chevron"
1961
+ >${this.#iconChevronRight()}</span
1962
+ >
1963
+ </button>
1964
+ </div>
1965
+ `
1966
+ : nothing
1967
+ }
1968
+ ${
1969
+ linked.length > 0
1970
+ ? html`
1971
+ <div class="post-menu-section">
1972
+ <p class="post-menu-section-label">Other versions</p>
1973
+ ${linked.map((translation) => {
1974
+ const languageLabel = this.#languageLabel(
1975
+ translation.language,
1976
+ );
1977
+ return html`
1978
+ <div class="post-menu-row" data-post-menu-translation>
1979
+ <a
1980
+ role="menuitem"
1981
+ class="post-menu-item post-menu-row-main"
1982
+ href=${publicPath(`/${translation.slug}`)}
1983
+ target="_blank"
1984
+ rel="noopener noreferrer"
1985
+ title=${translation.label}
1986
+ aria-label=${`Open the ${languageLabel} version, ${translation.label}, in a new tab`}
1987
+ >
1988
+ <span
1989
+ class="post-menu-item-label"
1990
+ lang=${translation.language ?? nothing}
1991
+ >${languageLabel}</span
1992
+ >
1993
+ <span
1994
+ class="post-menu-item-trailing post-menu-item-chevron"
1995
+ >${this.#iconExternal()}</span
1996
+ >
1997
+ </a>
1998
+ <button
1999
+ type="button"
2000
+ role="menuitem"
2001
+ class="post-menu-row-action post-menu-row-action-danger"
2002
+ data-post-menu-translation-unlink
2003
+ ?disabled=${this._translationBusy}
2004
+ aria-label=${`Unlink the ${languageLabel} version`}
2005
+ @click=${() => this.#unlinkTranslation(translation)}
2006
+ >
2007
+ Unlink
2008
+ </button>
2009
+ </div>
2010
+ `;
2011
+ })}
2012
+ </div>
2013
+ `
2014
+ : nothing
2015
+ }
2016
+ ${
2017
+ this._translationsLoading
2018
+ ? html`<p class="post-menu-hint">Loading…</p>`
2019
+ : nothing
2020
+ }
2021
+ </div>
2022
+ </div>
2023
+ `;
2024
+ }
2025
+
2026
+ /**
2027
+ * Which language this Thread is written in.
2028
+ *
2029
+ * A language another version already holds is left out entirely rather than
2030
+ * shown greyed out: the author cannot switch to it, and a dead row saying
2031
+ * "Taken" answers a question nobody asked. The version holding it is one
2032
+ * level up under *Other versions*, where reading it and unlinking it are one
2033
+ * click each.
2034
+ */
2035
+ #renderLanguageSwitchPanel() {
2036
+ if (!this._data || this._data.isReply) return nothing;
2037
+
2038
+ const { current, selectable } = this.#languageChoices();
2039
+ return html`
2040
+ <div
2041
+ class="post-menu-view post-menu-language-panel post-menu-language-switch-panel"
2042
+ >
2043
+ <div class="post-menu-panel-header">
2044
+ <button
2045
+ type="button"
2046
+ class="post-menu-panel-back"
2047
+ aria-label="Back"
2048
+ @click=${() => this.#showLanguagePanel()}
2049
+ >
2050
+ ${this.#iconChevronLeft()}
2051
+ </button>
2052
+ <div class="post-menu-panel-heading">
2053
+ <span>Change language</span>
2054
+ </div>
2055
+ </div>
2056
+ <div role="menu" class="post-menu-list">
2057
+ <div
2058
+ class="post-menu-section"
2059
+ role="radiogroup"
2060
+ aria-label="Language"
2061
+ >
2062
+ ${selectable.map((language) => {
2063
+ const selected = language.tag === current;
2064
+ return html`
2065
+ <button
2066
+ type="button"
2067
+ role="menuitemradio"
2068
+ lang=${language.tag}
2069
+ aria-checked=${selected ? "true" : "false"}
2070
+ ?disabled=${this._translationBusy}
2071
+ data-post-menu-language-option
2072
+ data-post-menu-language-current=${selected ? "true" : "false"}
2073
+ class=${`post-menu-item${selected ? " post-menu-item-active" : ""}`}
2074
+ @click=${() =>
2075
+ selected
2076
+ ? this.#showLanguagePanel()
2077
+ : this.#setLanguage(language.tag)}
2078
+ >
2079
+ <span class="post-menu-item-label">${language.label}</span>
2080
+ ${
2081
+ selected
2082
+ ? html`<span
2083
+ class="post-menu-item-trailing post-menu-item-check"
2084
+ >${this.#iconCheck()}</span
2085
+ >`
2086
+ : nothing
2087
+ }
2088
+ </button>
2089
+ `;
2090
+ })}
2091
+ </div>
2092
+ </div>
2093
+ </div>
2094
+ `;
2095
+ }
2096
+
1355
2097
  #renderVisibilityPanel() {
1356
2098
  if (!this._data || this._data.isReply) return nothing;
1357
2099
 
@@ -1382,9 +2124,9 @@ export class JantPostMenu extends LitElement {
1382
2124
  role="menuitemradio"
1383
2125
  aria-checked=${visibility === "public" ? "true" : "false"}
1384
2126
  data-post-menu-visibility-option
1385
- data-post-menu-visibility-current=${visibility === "public"
1386
- ? "true"
1387
- : "false"}
2127
+ data-post-menu-visibility-current=${
2128
+ visibility === "public" ? "true" : "false"
2129
+ }
1388
2130
  class=${`post-menu-item${
1389
2131
  visibility === "public" ? " post-menu-item-active" : ""
1390
2132
  }`}
@@ -1394,12 +2136,14 @@ export class JantPostMenu extends LitElement {
1394
2136
  : this.#setVisibility("public")}
1395
2137
  >
1396
2138
  <span class="post-menu-item-label">Public</span>
1397
- ${visibility === "public"
1398
- ? html`<span
1399
- class="post-menu-item-trailing post-menu-item-check"
1400
- >${this.#iconCheck()}</span
1401
- >`
1402
- : nothing}
2139
+ ${
2140
+ visibility === "public"
2141
+ ? html`<span
2142
+ class="post-menu-item-trailing post-menu-item-check"
2143
+ >${this.#iconCheck()}</span
2144
+ >`
2145
+ : nothing
2146
+ }
1403
2147
  </button>
1404
2148
 
1405
2149
  <button
@@ -1407,9 +2151,9 @@ export class JantPostMenu extends LitElement {
1407
2151
  role="menuitemradio"
1408
2152
  aria-checked=${visibility === "latest_hidden" ? "true" : "false"}
1409
2153
  data-post-menu-visibility-option
1410
- data-post-menu-visibility-current=${visibility === "latest_hidden"
1411
- ? "true"
1412
- : "false"}
2154
+ data-post-menu-visibility-current=${
2155
+ visibility === "latest_hidden" ? "true" : "false"
2156
+ }
1413
2157
  class=${`post-menu-item${
1414
2158
  visibility === "latest_hidden" ? " post-menu-item-active" : ""
1415
2159
  }`}
@@ -1419,12 +2163,14 @@ export class JantPostMenu extends LitElement {
1419
2163
  : this.#setVisibility("latest_hidden")}
1420
2164
  >
1421
2165
  <span class="post-menu-item-label">Hidden from Latest</span>
1422
- ${visibility === "latest_hidden"
1423
- ? html`<span
1424
- class="post-menu-item-trailing post-menu-item-check"
1425
- >${this.#iconCheck()}</span
1426
- >`
1427
- : nothing}
2166
+ ${
2167
+ visibility === "latest_hidden"
2168
+ ? html`<span
2169
+ class="post-menu-item-trailing post-menu-item-check"
2170
+ >${this.#iconCheck()}</span
2171
+ >`
2172
+ : nothing
2173
+ }
1428
2174
  </button>
1429
2175
 
1430
2176
  <button
@@ -1432,9 +2178,9 @@ export class JantPostMenu extends LitElement {
1432
2178
  role="menuitemradio"
1433
2179
  aria-checked=${visibility === "private" ? "true" : "false"}
1434
2180
  data-post-menu-visibility-option
1435
- data-post-menu-visibility-current=${visibility === "private"
1436
- ? "true"
1437
- : "false"}
2181
+ data-post-menu-visibility-current=${
2182
+ visibility === "private" ? "true" : "false"
2183
+ }
1438
2184
  class=${`post-menu-item${
1439
2185
  visibility === "private" ? " post-menu-item-active" : ""
1440
2186
  }`}
@@ -1444,12 +2190,14 @@ export class JantPostMenu extends LitElement {
1444
2190
  : this.#setVisibility("private")}
1445
2191
  >
1446
2192
  <span class="post-menu-item-label">Private</span>
1447
- ${visibility === "private"
1448
- ? html`<span
1449
- class="post-menu-item-trailing post-menu-item-check"
1450
- >${this.#iconCheck()}</span
1451
- >`
1452
- : nothing}
2193
+ ${
2194
+ visibility === "private"
2195
+ ? html`<span
2196
+ class="post-menu-item-trailing post-menu-item-check"
2197
+ >${this.#iconCheck()}</span
2198
+ >`
2199
+ : nothing
2200
+ }
1453
2201
  </button>
1454
2202
  </div>
1455
2203
  </div>
@@ -1551,118 +2299,164 @@ export class JantPostMenu extends LitElement {
1551
2299
  <span class="post-menu-item-trailing">${this.#iconEdit()}</span>
1552
2300
  </button>
1553
2301
 
1554
- ${this._data.isDraft
1555
- ? html`
1556
- <button
1557
- type="button"
1558
- role="menuitem"
1559
- class="post-menu-item"
1560
- @click=${() => this.#publish()}
1561
- >
1562
- <span class="post-menu-item-label">Publish</span>
1563
- <span class="post-menu-item-trailing"
1564
- >${this.#iconPublish()}</span
2302
+ ${
2303
+ this._data.isDraft
2304
+ ? html`
2305
+ <button
2306
+ type="button"
2307
+ role="menuitem"
2308
+ class="post-menu-item"
2309
+ @click=${() => this.#publish()}
1565
2310
  >
1566
- </button>
1567
- `
1568
- : nothing}
1569
- ${this._data.isReply
1570
- ? nothing
1571
- : html`
1572
- <button
1573
- type="button"
1574
- role="menuitem"
1575
- class="post-menu-item"
1576
- data-post-menu-open-collections
1577
- @click=${() => this.#openCollectionPicker()}
1578
- >
1579
- <span class="post-menu-item-label">Add to collection</span>
1580
- <span class="post-menu-item-trailing post-menu-item-chevron"
1581
- >${this.#iconChevronRight()}</span
2311
+ <span class="post-menu-item-label">Publish</span>
2312
+ <span class="post-menu-item-trailing"
2313
+ >${this.#iconPublish()}</span
2314
+ >
2315
+ </button>
2316
+ `
2317
+ : nothing
2318
+ }
2319
+ ${
2320
+ this._data.isReply
2321
+ ? nothing
2322
+ : html`
2323
+ <button
2324
+ type="button"
2325
+ role="menuitem"
2326
+ class="post-menu-item"
2327
+ data-post-menu-open-collections
2328
+ @click=${() => this.#openCollectionPicker()}
1582
2329
  >
1583
- </button>
1584
- `}
1585
- ${this._data.isReply
1586
- ? nothing
1587
- : html`
1588
- <button
1589
- type="button"
1590
- role="menuitem"
1591
- class="post-menu-item"
1592
- data-post-menu-open-visibility
1593
- @click=${() => this.#openVisibilityPanel()}
1594
- >
1595
- <span class="post-menu-item-label">Visibility</span>
1596
- <span class="post-menu-item-meta"
1597
- >${this.#getVisibilityLabel(visibility)}</span
2330
+ <span class="post-menu-item-label"
2331
+ >Add to collection</span
2332
+ >
2333
+ <span
2334
+ class="post-menu-item-trailing post-menu-item-chevron"
2335
+ >${this.#iconChevronRight()}</span
2336
+ >
2337
+ </button>
2338
+ `
2339
+ }
2340
+ ${
2341
+ this._data.isReply
2342
+ ? nothing
2343
+ : html`
2344
+ <button
2345
+ type="button"
2346
+ role="menuitem"
2347
+ class="post-menu-item"
2348
+ data-post-menu-open-visibility
2349
+ @click=${() => this.#openVisibilityPanel()}
1598
2350
  >
1599
- <span class="post-menu-item-trailing post-menu-item-chevron"
1600
- >${this.#iconChevronRight()}</span
2351
+ <span class="post-menu-item-label">Visibility</span>
2352
+ <span class="post-menu-item-meta"
2353
+ >${this.#getVisibilityLabel(visibility)}</span
2354
+ >
2355
+ <span
2356
+ class="post-menu-item-trailing post-menu-item-chevron"
2357
+ >${this.#iconChevronRight()}</span
2358
+ >
2359
+ </button>
2360
+ `
2361
+ }
2362
+ ${
2363
+ this._data.isReply || !this.#multilingual
2364
+ ? nothing
2365
+ : html`
2366
+ <button
2367
+ type="button"
2368
+ role="menuitem"
2369
+ class="post-menu-item"
2370
+ data-post-menu-open-language
2371
+ @click=${() => this.#openLanguagePanel()}
1601
2372
  >
1602
- </button>
1603
- `}
2373
+ <span class="post-menu-item-label">Language</span>
2374
+ <span class="post-menu-item-meta"
2375
+ >${this.#languageLabel(this._data.language)}</span
2376
+ >
2377
+ <span
2378
+ class="post-menu-item-trailing post-menu-item-chevron"
2379
+ >${this.#iconChevronRight()}</span
2380
+ >
2381
+ </button>
2382
+ `
2383
+ }
1604
2384
  </div>
1605
2385
 
1606
2386
  <div class="post-menu-section">
1607
- ${this._data.isDraft
1608
- ? nothing
1609
- : html`
1610
- <button
1611
- type="button"
1612
- role="menuitem"
1613
- class="post-menu-item"
1614
- @click=${() => this.#setFeatured(!isFeatured)}
1615
- >
1616
- <span class="post-menu-item-label"
1617
- >${isFeatured
1618
- ? "Remove from Featured"
1619
- : "Add to Featured"}</span
1620
- >
1621
- <span class="post-menu-item-trailing"
1622
- >${isFeatured
1623
- ? this.#iconFeaturedOff()
1624
- : this.#iconFeatured()}</span
1625
- >
1626
- </button>
1627
- `}
1628
- ${this._data.isReply
1629
- ? nothing
1630
- : html`
1631
- <button
1632
- type="button"
1633
- role="menuitem"
1634
- class="post-menu-item"
1635
- @click=${() => this.#togglePin()}
1636
- >
1637
- <span class="post-menu-item-label"
1638
- >${isPinned ? "Unpin" : "Pin this post"}</span
1639
- >
1640
- <span class="post-menu-item-trailing"
1641
- >${isPinned ? this.#iconPinOff() : this.#iconPin()}</span
2387
+ ${
2388
+ this._data.isDraft
2389
+ ? nothing
2390
+ : html`
2391
+ <button
2392
+ type="button"
2393
+ role="menuitem"
2394
+ class="post-menu-item"
2395
+ @click=${() => this.#setFeatured(!isFeatured)}
1642
2396
  >
1643
- </button>
1644
- `}
1645
- ${collectionId && !this._data.isReply
1646
- ? html`
1647
- <button
1648
- type="button"
1649
- role="menuitem"
1650
- class="post-menu-item"
1651
- @click=${() => this.#toggleCollectionPin()}
1652
- >
1653
- <span class="post-menu-item-label"
1654
- >${isPinnedInCollection
1655
- ? "Unpin from collection"
1656
- : "Pin in collection"}</span
2397
+ <span class="post-menu-item-label"
2398
+ >${
2399
+ isFeatured
2400
+ ? "Remove from Featured"
2401
+ : "Add to Featured"
2402
+ }</span
2403
+ >
2404
+ <span class="post-menu-item-trailing"
2405
+ >${
2406
+ isFeatured
2407
+ ? this.#iconFeaturedOff()
2408
+ : this.#iconFeatured()
2409
+ }</span
2410
+ >
2411
+ </button>
2412
+ `
2413
+ }
2414
+ ${
2415
+ this._data.isReply
2416
+ ? nothing
2417
+ : html`
2418
+ <button
2419
+ type="button"
2420
+ role="menuitem"
2421
+ class="post-menu-item"
2422
+ @click=${() => this.#togglePin()}
1657
2423
  >
1658
- <span class="post-menu-item-trailing"
1659
- >${isPinnedInCollection
1660
- ? this.#iconPinOff()
1661
- : this.#iconPin()}</span
2424
+ <span class="post-menu-item-label"
2425
+ >${isPinned ? "Unpin" : "Pin this post"}</span
2426
+ >
2427
+ <span class="post-menu-item-trailing"
2428
+ >${isPinned ? this.#iconPinOff() : this.#iconPin()}</span
2429
+ >
2430
+ </button>
2431
+ `
2432
+ }
2433
+ ${
2434
+ collectionId && !this._data.isReply
2435
+ ? html`
2436
+ <button
2437
+ type="button"
2438
+ role="menuitem"
2439
+ class="post-menu-item"
2440
+ @click=${() => this.#toggleCollectionPin()}
1662
2441
  >
1663
- </button>
1664
- `
1665
- : nothing}
2442
+ <span class="post-menu-item-label"
2443
+ >${
2444
+ isPinnedInCollection
2445
+ ? "Unpin from collection"
2446
+ : "Pin in collection"
2447
+ }</span
2448
+ >
2449
+ <span class="post-menu-item-trailing"
2450
+ >${
2451
+ isPinnedInCollection
2452
+ ? this.#iconPinOff()
2453
+ : this.#iconPin()
2454
+ }</span
2455
+ >
2456
+ </button>
2457
+ `
2458
+ : nothing
2459
+ }
1666
2460
  </div>
1667
2461
 
1668
2462
  <div class="post-menu-section post-menu-section-danger">
@@ -1693,30 +2487,40 @@ export class JantPostMenu extends LitElement {
1693
2487
  const showMenuSurface = !this._addCollectionPanelOpen;
1694
2488
 
1695
2489
  return html`
1696
- ${showMenuSurface
1697
- ? html`
1698
- <div
1699
- class="post-menu-backdrop"
1700
- @click=${() => this.#close({ restoreFocus: false })}
1701
- ></div>
1702
- <div class="dropdown-menu" style=${wrapperStyle}>
2490
+ ${
2491
+ showMenuSurface
2492
+ ? html`
1703
2493
  <div
1704
- data-popover
1705
- aria-hidden="false"
1706
- class="!static post-menu-panel"
1707
- >
1708
- ${this._view === "collections"
1709
- ? this.#renderCollectionPicker()
1710
- : this._view === "visibility"
1711
- ? this.#renderVisibilityPanel()
1712
- : this.#renderMenu()}
2494
+ class="post-menu-backdrop"
2495
+ @click=${() => this.#close({ restoreFocus: false })}
2496
+ ></div>
2497
+ <div class="dropdown-menu" style=${wrapperStyle}>
2498
+ <div
2499
+ data-popover
2500
+ aria-hidden="false"
2501
+ class="!static post-menu-panel"
2502
+ >
2503
+ ${
2504
+ this._view === "collections"
2505
+ ? this.#renderCollectionPicker()
2506
+ : this._view === "visibility"
2507
+ ? this.#renderVisibilityPanel()
2508
+ : this._view === "language"
2509
+ ? this.#renderLanguagePanel()
2510
+ : this._view === "language-switch"
2511
+ ? this.#renderLanguageSwitchPanel()
2512
+ : this.#renderMenu()
2513
+ }
2514
+ </div>
1713
2515
  </div>
1714
- </div>
1715
- `
1716
- : nothing}
1717
- ${this._addCollectionPanelOpen
1718
- ? this.#renderAddCollectionPanel()
1719
- : nothing}
2516
+ `
2517
+ : nothing
2518
+ }
2519
+ ${
2520
+ this._addCollectionPanelOpen
2521
+ ? this.#renderAddCollectionPanel()
2522
+ : nothing
2523
+ }
1720
2524
  `;
1721
2525
  }
1722
2526
  }