@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -16,8 +16,17 @@ import {
16
16
  FEATURED_SPARKLE_PATH,
17
17
  } from "../../lib/featured-icons.js";
18
18
  import { showConfirmDialog } from "../confirm.js";
19
+ import { refreshArticleView } from "../post-refresh.js";
19
20
  import { showToast } from "../toast.js";
21
+ import { readErrorMessage } from "../json.js";
20
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";
21
30
  import {
22
31
  applyItemOrder,
23
32
  filterCollectionsBySearch,
@@ -33,6 +42,52 @@ interface PostMenuData {
33
42
  featured: boolean;
34
43
  visibility: string;
35
44
  isReply: boolean;
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;
36
91
  }
37
92
 
38
93
  interface CollectionItem {
@@ -49,7 +104,16 @@ interface ThreadCollectionsResponse {
49
104
  collectionIds?: string[];
50
105
  }
51
106
 
52
- 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]";
53
117
 
54
118
  interface MenuTriggerRect {
55
119
  top: number;
@@ -104,11 +168,15 @@ function readPostMenuData(article: HTMLElement): PostMenuData | null {
104
168
  featured: article.hasAttribute("data-post-featured"),
105
169
  visibility: article.dataset.postVisibility ?? "public",
106
170
  isReply: threadId !== id || article.hasAttribute("data-post-reply"),
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,
107
174
  };
108
175
  }
109
176
 
110
177
  export class JantPostMenu extends LitElement {
111
178
  static properties = {
179
+ languages: { type: Array },
112
180
  _open: { state: true },
113
181
  _data: { state: true },
114
182
  _x: { state: true },
@@ -120,8 +188,12 @@ export class JantPostMenu extends LitElement {
120
188
  _collectionSearch: { state: true },
121
189
  _threadCollectionIds: { state: true },
122
190
  _addCollectionPanelOpen: { state: true },
191
+ _translations: { state: true },
192
+ _translationsLoading: { state: true },
193
+ _translationBusy: { state: true },
123
194
  };
124
195
 
196
+ declare languages: MenuLanguage[];
125
197
  declare _open: boolean;
126
198
  declare _data: PostMenuData | null;
127
199
  declare _x: number;
@@ -133,6 +205,9 @@ export class JantPostMenu extends LitElement {
133
205
  declare _collectionSearch: string;
134
206
  declare _threadCollectionIds: string[];
135
207
  declare _addCollectionPanelOpen: boolean;
208
+ declare _translations: TranslationItem[] | null;
209
+ declare _translationsLoading: boolean;
210
+ declare _translationBusy: boolean;
136
211
  declare _triggerEl: HTMLElement | null;
137
212
 
138
213
  /** Whether collections were modified during this session (triggers page reload on close) */
@@ -147,6 +222,7 @@ export class JantPostMenu extends LitElement {
147
222
 
148
223
  constructor() {
149
224
  super();
225
+ this.languages = [];
150
226
  this._open = false;
151
227
  this._data = null;
152
228
  this._x = 0;
@@ -158,6 +234,9 @@ export class JantPostMenu extends LitElement {
158
234
  this._collectionSearch = "";
159
235
  this._threadCollectionIds = [];
160
236
  this._addCollectionPanelOpen = false;
237
+ this._translations = null;
238
+ this._translationsLoading = false;
239
+ this._translationBusy = false;
161
240
  this._triggerEl = null;
162
241
  }
163
242
 
@@ -215,6 +294,12 @@ export class JantPostMenu extends LitElement {
215
294
  return;
216
295
  }
217
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
+ }
218
303
  if (this._view !== "menu") {
219
304
  this.#showMainMenu();
220
305
  return;
@@ -297,11 +382,27 @@ export class JantPostMenu extends LitElement {
297
382
  return;
298
383
  }
299
384
 
300
- // 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.)
301
404
  if (this._open) {
302
- const inside = target.closest?.(
303
- "[role='menu'], [data-collection-picker], [data-visibility-panel]",
304
- );
405
+ const inside = target.closest?.(".post-menu-view, .post-menu-panel");
305
406
  if (inside) return;
306
407
  }
307
408
 
@@ -462,6 +563,402 @@ export class JantPostMenu extends LitElement {
462
563
  );
463
564
  }
464
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
+
465
962
  #close(options: { restoreFocus?: boolean } = {}) {
466
963
  const restoreFocus =
467
964
  options.restoreFocus ?? this.#restoreTriggerFocusOnClose;
@@ -473,6 +970,7 @@ export class JantPostMenu extends LitElement {
473
970
  this._view = "menu";
474
971
  this._addCollectionPanelOpen = false;
475
972
  this._collectionSearch = "";
973
+ this._translations = null;
476
974
 
477
975
  if (this.#collectionsDirty) {
478
976
  this.#collectionsDirty = false;
@@ -518,17 +1016,42 @@ export class JantPostMenu extends LitElement {
518
1016
  const postId = this._data.id;
519
1017
  this.#close({ restoreFocus: false });
520
1018
 
521
- const dialog = document.getElementById(
522
- "compose-dialog",
523
- ) as HTMLDialogElement | null;
524
- const composeEl = dialog?.querySelector("jant-compose-dialog") as
525
- | import("./jant-compose-dialog.js").JantComposeDialog
526
- | null;
1019
+ const composeEl = await ensureComposeDialog();
527
1020
  if (composeEl) {
528
1021
  await composeEl.openEdit(postId);
529
1022
  }
530
1023
  }
531
1024
 
1025
+ async #publish() {
1026
+ if (!this._data) return;
1027
+ const postId = this._data.id;
1028
+ const trigger = this._triggerEl;
1029
+ this.#close({ restoreFocus: false });
1030
+
1031
+ const article = document.querySelector<HTMLElement>(
1032
+ `article[data-post-id="${postId}"]`,
1033
+ );
1034
+
1035
+ try {
1036
+ const res = await fetch(`/api/posts/${postId}`, {
1037
+ method: "PUT",
1038
+ headers: { "Content-Type": "application/json" },
1039
+ body: JSON.stringify({ status: "published" }),
1040
+ });
1041
+ if (!res.ok) throw new Error();
1042
+
1043
+ showToast("Draft published.");
1044
+ // The badge, the timestamp, and any drafts published alongside this one
1045
+ // all change at once — re-render the surface rather than patching it.
1046
+ if (!article || !(await refreshArticleView(article))) {
1047
+ globalThis.location.reload();
1048
+ }
1049
+ } catch {
1050
+ showToast("Could not publish the draft. Try again.", "error");
1051
+ trigger?.focus();
1052
+ }
1053
+ }
1054
+
532
1055
  #setVisibility(newVisibility: string) {
533
1056
  if (!this._data) return;
534
1057
  const postId = this._data.id;
@@ -711,8 +1234,10 @@ export class JantPostMenu extends LitElement {
711
1234
  this.#close({ restoreFocus: false });
712
1235
 
713
1236
  const confirmed = await showConfirmDialog({
714
- message: "Delete this post permanently? This can't be undone.",
715
- confirmLabel: "Delete",
1237
+ message: this._data.isDraft
1238
+ ? "Discard this draft permanently? This can't be undone."
1239
+ : "Delete this post permanently? This can't be undone.",
1240
+ confirmLabel: this._data.isDraft ? "Discard" : "Delete",
716
1241
  cancelLabel: "Cancel",
717
1242
  tone: "danger",
718
1243
  });
@@ -896,8 +1421,7 @@ export class JantPostMenu extends LitElement {
896
1421
  if (!detail) return;
897
1422
 
898
1423
  const formEl = this.querySelector("jant-collection-form") as
899
- | (HTMLElement & { loading: boolean })
900
- | null;
1424
+ (HTMLElement & { loading: boolean }) | null;
901
1425
  if (formEl) formEl.loading = true;
902
1426
 
903
1427
  try {
@@ -957,26 +1481,23 @@ export class JantPostMenu extends LitElement {
957
1481
  }
958
1482
  }
959
1483
 
960
- /** Get collection form labels from the compose dialog (already on the page) */
1484
+ /** Collection form labels the server rendered onto the compose dialog. */
961
1485
  #getCollectionFormLabels() {
962
- const composeEl = document.querySelector("jant-compose-dialog") as
963
- | import("./jant-compose-dialog.js").JantComposeDialog
964
- | null;
965
- return composeEl?.labels?.collectionFormLabels ?? null;
1486
+ return readComposeDialogLabels()?.collectionFormLabels ?? null;
966
1487
  }
967
1488
 
968
1489
  #getAddCollectionLabel() {
969
- const composeEl = document.querySelector("jant-compose-dialog") as
970
- | import("./jant-compose-dialog.js").JantComposeDialog
971
- | null;
972
- return composeEl?.labels?.addCollection ?? "Add Collection";
1490
+ return readComposeDialogLabels()?.addCollection ?? "Add Collection";
973
1491
  }
974
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
+ */
975
1498
  async #refreshComposeCollections() {
976
- const composeEl = document.querySelector("jant-compose-dialog") as {
977
- refreshCollections?: () => Promise<boolean>;
978
- } | null;
979
- await composeEl?.refreshCollections?.();
1499
+ const composeEl = await ensureComposeDialog();
1500
+ await composeEl?.refreshCollections();
980
1501
  }
981
1502
 
982
1503
  #getVisibilityLabel(visibility: string) {
@@ -992,6 +1513,21 @@ export class JantPostMenu extends LitElement {
992
1513
 
993
1514
  // --- Icons (inline SVG) ---
994
1515
 
1516
+ #iconPublish() {
1517
+ return html`<svg
1518
+ xmlns="http://www.w3.org/2000/svg"
1519
+ viewBox="0 0 24 24"
1520
+ fill="none"
1521
+ stroke="currentColor"
1522
+ stroke-width="1.75"
1523
+ stroke-linecap="round"
1524
+ stroke-linejoin="round"
1525
+ >
1526
+ <path d="M12 19V5" />
1527
+ <path d="m5 12 7-7 7 7" />
1528
+ </svg>`;
1529
+ }
1530
+
995
1531
  #iconEdit() {
996
1532
  return html`<svg
997
1533
  xmlns="http://www.w3.org/2000/svg"
@@ -1131,6 +1667,22 @@ export class JantPostMenu extends LitElement {
1131
1667
  </svg>`;
1132
1668
  }
1133
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
+
1134
1686
  // --- Render ---
1135
1687
 
1136
1688
  #renderCollectionPicker() {
@@ -1164,116 +1716,126 @@ export class JantPostMenu extends LitElement {
1164
1716
  <span>Collections</span>
1165
1717
  </div>
1166
1718
  </div>
1167
- ${collections.length > 0
1168
- ? html`<div class="post-menu-picker-search">
1169
- <svg
1170
- width="14"
1171
- height="14"
1172
- viewBox="0 0 24 24"
1173
- fill="none"
1174
- stroke="currentColor"
1175
- stroke-width="2"
1176
- stroke-linecap="round"
1177
- stroke-linejoin="round"
1178
- >
1179
- <circle cx="11" cy="11" r="8" />
1180
- <path d="m21 21-4.3-4.3" />
1181
- </svg>
1182
- <input
1183
- type="text"
1184
- placeholder="Search collections..."
1185
- autocomplete="off"
1186
- autocorrect="off"
1187
- spellcheck="false"
1188
- .value=${this._collectionSearch}
1189
- @keydown=${this.#handleCollectionSearchKeydown}
1190
- @input=${(e: Event) => {
1191
- this._collectionSearch = (e.target as HTMLInputElement).value;
1192
- }}
1193
- />
1194
- </div>`
1195
- : 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
+ }
1196
1752
  <div
1197
1753
  class="post-menu-picker-list"
1198
1754
  role="listbox"
1199
1755
  aria-multiselectable="true"
1200
1756
  >
1201
- ${this._collectionsLoading
1202
- ? html`<div class="post-menu-picker-empty">Loading...</div>`
1203
- : filtered.length > 0
1204
- ? filtered.map((c) => {
1205
- const selected = this._threadCollectionIds.includes(c.id);
1206
- return html`
1207
- <button
1208
- type="button"
1209
- role="option"
1210
- aria-selected=${selected ? "true" : "false"}
1211
- class=${`post-menu-picker-option${
1212
- selected ? " post-menu-picker-option-selected" : ""
1213
- }`}
1214
- @keydown=${(event: globalThis.KeyboardEvent) =>
1215
- this.#handleCollectionOptionKeydown(event, c.id)}
1216
- @click=${(event: globalThis.MouseEvent) =>
1217
- this.#handleCollectionOptionClick(event, c.id)}
1218
- >
1219
- <span class="post-menu-picker-title">${c.title}</span>
1220
- ${selected
1221
- ? html`<span
1222
- class="post-menu-picker-marker post-menu-picker-marker-selected"
1223
- >
1224
- <svg
1225
- xmlns="http://www.w3.org/2000/svg"
1226
- viewBox="0 0 24 24"
1227
- fill="none"
1228
- aria-hidden="true"
1229
- >
1230
- <circle
1231
- cx="12"
1232
- cy="12"
1233
- r="10"
1234
- fill="currentColor"
1235
- />
1236
- <path
1237
- d="M8 12.5 10.7 15.2 16.4 9.5"
1238
- stroke="var(--site-page-bg)"
1239
- stroke-width="2.3"
1240
- stroke-linecap="round"
1241
- stroke-linejoin="round"
1242
- />
1243
- </svg>
1244
- </span>`
1245
- : html`<span
1246
- class="post-menu-picker-marker post-menu-picker-marker-add"
1247
- >
1248
- <svg
1249
- xmlns="http://www.w3.org/2000/svg"
1250
- viewBox="0 0 24 24"
1251
- fill="none"
1252
- aria-hidden="true"
1253
- >
1254
- <circle
1255
- cx="12"
1256
- cy="12"
1257
- r="9"
1258
- stroke="currentColor"
1259
- stroke-width="1.8"
1260
- />
1261
- <path
1262
- d="M12 8v8M8 12h8"
1263
- stroke="currentColor"
1264
- stroke-width="1.9"
1265
- stroke-linecap="round"
1266
- />
1267
- </svg>
1268
- </span>`}
1269
- </button>
1270
- `;
1271
- })
1272
- : html`<div class="post-menu-picker-empty">
1273
- ${hasSearch
1274
- ? "No matching collections"
1275
- : "No collections yet"}
1276
- </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
+ }
1277
1839
  </div>
1278
1840
  <div class="post-menu-picker-footer">
1279
1841
  <button
@@ -1302,6 +1864,236 @@ export class JantPostMenu extends LitElement {
1302
1864
  `;
1303
1865
  }
1304
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
+
1305
2097
  #renderVisibilityPanel() {
1306
2098
  if (!this._data || this._data.isReply) return nothing;
1307
2099
 
@@ -1332,9 +2124,9 @@ export class JantPostMenu extends LitElement {
1332
2124
  role="menuitemradio"
1333
2125
  aria-checked=${visibility === "public" ? "true" : "false"}
1334
2126
  data-post-menu-visibility-option
1335
- data-post-menu-visibility-current=${visibility === "public"
1336
- ? "true"
1337
- : "false"}
2127
+ data-post-menu-visibility-current=${
2128
+ visibility === "public" ? "true" : "false"
2129
+ }
1338
2130
  class=${`post-menu-item${
1339
2131
  visibility === "public" ? " post-menu-item-active" : ""
1340
2132
  }`}
@@ -1344,12 +2136,14 @@ export class JantPostMenu extends LitElement {
1344
2136
  : this.#setVisibility("public")}
1345
2137
  >
1346
2138
  <span class="post-menu-item-label">Public</span>
1347
- ${visibility === "public"
1348
- ? html`<span
1349
- class="post-menu-item-trailing post-menu-item-check"
1350
- >${this.#iconCheck()}</span
1351
- >`
1352
- : 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
+ }
1353
2147
  </button>
1354
2148
 
1355
2149
  <button
@@ -1357,9 +2151,9 @@ export class JantPostMenu extends LitElement {
1357
2151
  role="menuitemradio"
1358
2152
  aria-checked=${visibility === "latest_hidden" ? "true" : "false"}
1359
2153
  data-post-menu-visibility-option
1360
- data-post-menu-visibility-current=${visibility === "latest_hidden"
1361
- ? "true"
1362
- : "false"}
2154
+ data-post-menu-visibility-current=${
2155
+ visibility === "latest_hidden" ? "true" : "false"
2156
+ }
1363
2157
  class=${`post-menu-item${
1364
2158
  visibility === "latest_hidden" ? " post-menu-item-active" : ""
1365
2159
  }`}
@@ -1369,12 +2163,14 @@ export class JantPostMenu extends LitElement {
1369
2163
  : this.#setVisibility("latest_hidden")}
1370
2164
  >
1371
2165
  <span class="post-menu-item-label">Hidden from Latest</span>
1372
- ${visibility === "latest_hidden"
1373
- ? html`<span
1374
- class="post-menu-item-trailing post-menu-item-check"
1375
- >${this.#iconCheck()}</span
1376
- >`
1377
- : 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
+ }
1378
2174
  </button>
1379
2175
 
1380
2176
  <button
@@ -1382,9 +2178,9 @@ export class JantPostMenu extends LitElement {
1382
2178
  role="menuitemradio"
1383
2179
  aria-checked=${visibility === "private" ? "true" : "false"}
1384
2180
  data-post-menu-visibility-option
1385
- data-post-menu-visibility-current=${visibility === "private"
1386
- ? "true"
1387
- : "false"}
2181
+ data-post-menu-visibility-current=${
2182
+ visibility === "private" ? "true" : "false"
2183
+ }
1388
2184
  class=${`post-menu-item${
1389
2185
  visibility === "private" ? " post-menu-item-active" : ""
1390
2186
  }`}
@@ -1394,12 +2190,14 @@ export class JantPostMenu extends LitElement {
1394
2190
  : this.#setVisibility("private")}
1395
2191
  >
1396
2192
  <span class="post-menu-item-label">Private</span>
1397
- ${visibility === "private"
1398
- ? html`<span
1399
- class="post-menu-item-trailing post-menu-item-check"
1400
- >${this.#iconCheck()}</span
1401
- >`
1402
- : 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
+ }
1403
2201
  </button>
1404
2202
  </div>
1405
2203
  </div>
@@ -1501,100 +2299,164 @@ export class JantPostMenu extends LitElement {
1501
2299
  <span class="post-menu-item-trailing">${this.#iconEdit()}</span>
1502
2300
  </button>
1503
2301
 
1504
- ${this._data.isReply
1505
- ? nothing
1506
- : html`
1507
- <button
1508
- type="button"
1509
- role="menuitem"
1510
- class="post-menu-item"
1511
- data-post-menu-open-collections
1512
- @click=${() => this.#openCollectionPicker()}
1513
- >
1514
- <span class="post-menu-item-label">Add to collection</span>
1515
- <span class="post-menu-item-trailing post-menu-item-chevron"
1516
- >${this.#iconChevronRight()}</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()}
2310
+ >
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()}
1517
2329
  >
1518
- </button>
1519
- `}
1520
- ${this._data.isReply
1521
- ? nothing
1522
- : html`
1523
- <button
1524
- type="button"
1525
- role="menuitem"
1526
- class="post-menu-item"
1527
- data-post-menu-open-visibility
1528
- @click=${() => this.#openVisibilityPanel()}
1529
- >
1530
- <span class="post-menu-item-label">Visibility</span>
1531
- <span class="post-menu-item-meta"
1532
- >${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()}
1533
2350
  >
1534
- <span class="post-menu-item-trailing post-menu-item-chevron"
1535
- >${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()}
1536
2372
  >
1537
- </button>
1538
- `}
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
+ }
1539
2384
  </div>
1540
2385
 
1541
2386
  <div class="post-menu-section">
1542
- <button
1543
- type="button"
1544
- role="menuitem"
1545
- class="post-menu-item"
1546
- @click=${() => this.#setFeatured(!isFeatured)}
1547
- >
1548
- <span class="post-menu-item-label"
1549
- >${isFeatured
1550
- ? "Remove from Featured"
1551
- : "Add to Featured"}</span
1552
- >
1553
- <span class="post-menu-item-trailing"
1554
- >${isFeatured
1555
- ? this.#iconFeaturedOff()
1556
- : this.#iconFeatured()}</span
1557
- >
1558
- </button>
1559
-
1560
- ${this._data.isReply
1561
- ? nothing
1562
- : html`
1563
- <button
1564
- type="button"
1565
- role="menuitem"
1566
- class="post-menu-item"
1567
- @click=${() => this.#togglePin()}
1568
- >
1569
- <span class="post-menu-item-label"
1570
- >${isPinned ? "Unpin" : "Pin this post"}</span
1571
- >
1572
- <span class="post-menu-item-trailing"
1573
- >${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)}
1574
2396
  >
1575
- </button>
1576
- `}
1577
- ${collectionId && !this._data.isReply
1578
- ? html`
1579
- <button
1580
- type="button"
1581
- role="menuitem"
1582
- class="post-menu-item"
1583
- @click=${() => this.#toggleCollectionPin()}
1584
- >
1585
- <span class="post-menu-item-label"
1586
- >${isPinnedInCollection
1587
- ? "Unpin from collection"
1588
- : "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()}
1589
2423
  >
1590
- <span class="post-menu-item-trailing"
1591
- >${isPinnedInCollection
1592
- ? this.#iconPinOff()
1593
- : 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()}
1594
2441
  >
1595
- </button>
1596
- `
1597
- : 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
+ }
1598
2460
  </div>
1599
2461
 
1600
2462
  <div class="post-menu-section post-menu-section-danger">
@@ -1604,7 +2466,9 @@ export class JantPostMenu extends LitElement {
1604
2466
  class="post-menu-item post-menu-item-danger"
1605
2467
  @click=${() => this.#delete()}
1606
2468
  >
1607
- <span class="post-menu-item-label">Delete</span>
2469
+ <span class="post-menu-item-label"
2470
+ >${this._data.isDraft ? "Discard" : "Delete"}</span
2471
+ >
1608
2472
  <span class="post-menu-item-trailing">${this.#iconTrash()}</span>
1609
2473
  </button>
1610
2474
  </div>
@@ -1623,30 +2487,40 @@ export class JantPostMenu extends LitElement {
1623
2487
  const showMenuSurface = !this._addCollectionPanelOpen;
1624
2488
 
1625
2489
  return html`
1626
- ${showMenuSurface
1627
- ? html`
1628
- <div
1629
- class="post-menu-backdrop"
1630
- @click=${() => this.#close({ restoreFocus: false })}
1631
- ></div>
1632
- <div class="dropdown-menu" style=${wrapperStyle}>
2490
+ ${
2491
+ showMenuSurface
2492
+ ? html`
1633
2493
  <div
1634
- data-popover
1635
- aria-hidden="false"
1636
- class="!static post-menu-panel"
1637
- >
1638
- ${this._view === "collections"
1639
- ? this.#renderCollectionPicker()
1640
- : this._view === "visibility"
1641
- ? this.#renderVisibilityPanel()
1642
- : 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>
1643
2515
  </div>
1644
- </div>
1645
- `
1646
- : nothing}
1647
- ${this._addCollectionPanelOpen
1648
- ? this.#renderAddCollectionPanel()
1649
- : nothing}
2516
+ `
2517
+ : nothing
2518
+ }
2519
+ ${
2520
+ this._addCollectionPanelOpen
2521
+ ? this.#renderAddCollectionPanel()
2522
+ : nothing
2523
+ }
1650
2524
  `;
1651
2525
  }
1652
2526
  }