@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
@@ -12,17 +12,22 @@ import { classMap } from "lit/directives/class-map.js";
12
12
  import { repeat } from "lit/directives/repeat.js";
13
13
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
14
14
  import { unsafeSVG } from "lit/directives/unsafe-svg.js";
15
+ import { ifDefined } from "lit/directives/if-defined.js";
15
16
  import type { Editor, JSONContent } from "@tiptap/core";
17
+ import { extractBodyText } from "../../lib/summary.js";
18
+ import { readContentLanguage } from "../../lib/lang-detect.js";
16
19
  import type {
17
20
  ComposeFormat,
18
21
  ComposeVisibility,
19
22
  ComposeLabels,
20
23
  ComposeCollection,
24
+ ComposeLanguage,
21
25
  ComposeSubmitDetail,
22
26
  ComposeSubmitAttachment,
23
27
  ComposeAttachment,
24
28
  ComposeRowStatus,
25
29
  DraftItem,
30
+ DraftRestoreOutcome,
26
31
  LocalDraft,
27
32
  LocalDraftMedia,
28
33
  ComposeFullscreenOpenDetail,
@@ -76,6 +81,11 @@ interface ThreadItem {
76
81
  slug?: string;
77
82
  /** Last availability answer for this post's slug. */
78
83
  slugTaken?: boolean;
84
+ /**
85
+ * The saved draft post this row was loaded from, if any. Its slug already
86
+ * belongs to that post, so the availability check leaves it out.
87
+ */
88
+ sourcePostId?: string;
79
89
  }
80
90
 
81
91
  interface ApiMediaAttachment {
@@ -122,6 +132,11 @@ interface ComposePostResponse {
122
132
  rating?: number | null;
123
133
  publishedAt?: number | null;
124
134
  bodyHtml?: string | null;
135
+ /**
136
+ * The language this Post is filed under. Null on a single-language site, and
137
+ * on posts written before the feature was turned on.
138
+ */
139
+ language?: string | null;
125
140
  }
126
141
 
127
142
  interface DraftsResponse {
@@ -152,6 +167,8 @@ interface ComposeStateSnapshot {
152
167
  publishedAtInput: string;
153
168
  publishedAtTimeMinutes: number | null;
154
169
  visibility: ComposeVisibility;
170
+ /** The author's explicit language choice, not what detection reads. */
171
+ language: string | null;
155
172
  title: string;
156
173
  bodyJson: JSONContent | null;
157
174
  url: string;
@@ -240,22 +257,30 @@ const COMPOSE_PUBLISH_VISIBILITY_ICONS: Record<ComposeVisibility, string> = {
240
257
  `,
241
258
  };
242
259
 
260
+ /* Drawn on a 14 grid, which is also the size these render at: a 16-unit
261
+ drawing shown at 14px scales every coordinate by 0.875, so no edge lands on
262
+ a device pixel and the whole glyph greys out. Diagonals are antialiased
263
+ whatever you do, so the checks and carets only needed the grid; the calendar
264
+ is all horizontals and verticals, and those are what the scale was smearing.
265
+ See its stroke width for the rest of that story. */
243
266
  const COMPOSE_PUBLISH_ACTION_ICONS = {
244
267
  check: `
245
- <path d="M4.35 8.2 6.9 10.7 11.65 5.95" />
268
+ <path d="M3.75 7.25 6 9.5l4.25-4.25" />
246
269
  `,
247
270
  caretRight: `
248
- <path d="M6.45 5.1 9.3 8l-2.85 2.9" />
249
- `,
250
- close: `
251
- <path d="M4.5 4.5 11.5 11.5" />
252
- <path d="M11.5 4.5 4.5 11.5" />
271
+ <path d="M5.65 4.5 8.15 7l-2.5 2.5" />
253
272
  `,
273
+ /* Every straight stroke is centred on a half unit and drawn 1 wide, which is
274
+ the only combination that lands on whole device pixels at 1x, 2x and 3x
275
+ alike: a 1px stroke centred at x.5 covers exactly one pixel at 1x, two at
276
+ 2x, three at 3x. Hence also the flat `stroke-width`, overriding the
277
+ family's 1.35 — the weight is worth less here than the edges are. The
278
+ rounded corners still antialias, as corners should. */
254
279
  calendar: `
255
- <rect x="2.75" y="3.45" width="10.5" height="9.8" rx="2.2" />
256
- <path d="M5.35 2.55v2.1" />
257
- <path d="M10.65 2.55v2.1" />
258
- <path d="M2.75 6.2h10.5" />
280
+ <rect x="2.5" y="3.5" width="9" height="8" rx="2" stroke-width="1" />
281
+ <path d="M4.5 2.5v2" stroke-width="1" />
282
+ <path d="M9.5 2.5v2" stroke-width="1" />
283
+ <path d="M2.5 6.5h9" stroke-width="1" />
259
284
  `,
260
285
  /* Two sliders — "settings for this thing", without borrowing the gear, which
261
286
  elsewhere means site settings. No enclosing box: the trigger is already a
@@ -271,6 +296,17 @@ const COMPOSE_PUBLISH_ACTION_ICONS = {
271
296
  `,
272
297
  } as const;
273
298
 
299
+ /* The site header's globe, stroke for stroke. Language already has a symbol on
300
+ this site — the switcher in the top right — and the author is the one person
301
+ who sees both. A second glyph for the same idea would only be a second thing
302
+ to learn. Drawn on a 24 grid like its counterpart, so the two are literally
303
+ the same artwork at two sizes. */
304
+ const COMPOSE_LANGUAGE_ICON = `
305
+ <circle cx="12" cy="12" r="10" />
306
+ <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
307
+ <path d="M2 12h20" />
308
+ `;
309
+
274
310
  const COMPOSE_COLLECTION_PICKER_ICONS = {
275
311
  /* A stack seen edge-on, not a folder: a collection is a post filed under
276
312
  several topics at once, and the folder glyph promises a single container
@@ -365,10 +401,10 @@ function renderComposePublishActionIcon(
365
401
  class=${classes}
366
402
  width="14"
367
403
  height="14"
368
- viewBox="0 0 16 16"
404
+ viewBox="0 0 14 14"
369
405
  fill="none"
370
406
  stroke="currentColor"
371
- stroke-width="1.55"
407
+ stroke-width="1.35"
372
408
  stroke-linecap="round"
373
409
  stroke-linejoin="round"
374
410
  aria-hidden="true"
@@ -615,6 +651,8 @@ export class JantComposeDialog extends LitElement {
615
651
 
616
652
  static properties = {
617
653
  collections: { type: Array },
654
+ languages: { type: Array },
655
+ contextLanguage: { type: String, attribute: "context-language" },
618
656
  labels: { type: Object },
619
657
  uploadMaxFileSize: { type: Number, attribute: "upload-max-file-size" },
620
658
  pageMode: { type: Boolean, attribute: "page-mode" },
@@ -663,9 +701,16 @@ export class JantComposeDialog extends LitElement {
663
701
  _slugTaken: { state: true },
664
702
  _visibilityLocked: { state: true },
665
703
  _quietReply: { state: true },
704
+ _language: { state: true },
705
+ _showLanguagePicker: { state: true },
706
+ _translationOf: { state: true },
707
+ _translationCollapsed: { state: true },
666
708
  };
667
709
 
668
710
  declare collections: ComposeCollection[];
711
+ declare languages: ComposeLanguage[];
712
+ /** Content language of the page the composer opened from. */
713
+ declare contextLanguage: string;
669
714
  declare labels: ComposeLabels;
670
715
  declare uploadMaxFileSize: number;
671
716
  declare pageMode: boolean;
@@ -715,22 +760,59 @@ export class JantComposeDialog extends LitElement {
715
760
  declare _slugTaken: boolean;
716
761
  declare _visibilityLocked: boolean;
717
762
  declare _quietReply: boolean;
763
+ /** Author's explicit language choice. Null means "let Jant read it". */
764
+ declare _language: string | null;
765
+ /** Whether the language pill's list of choices is open. */
766
+ declare _showLanguagePicker: boolean;
767
+ /**
768
+ * Thread root this post is being written as a translation of, with enough of
769
+ * it to read: translating from a title alone means keeping the original open
770
+ * in another tab.
771
+ */
772
+ declare _translationOf: {
773
+ id: string;
774
+ title: string;
775
+ href: string;
776
+ /** The post rendered server-side, exactly as its own page renders it. */
777
+ previewHtml: string;
778
+ } | null;
779
+ /**
780
+ * Whether the original is folded away.
781
+ *
782
+ * Per composer session, not remembered: an author who folds it to get room
783
+ * for one paragraph should not find it gone the next time they open a
784
+ * translation, when the first thing they need is to read.
785
+ */
786
+ declare _translationCollapsed: boolean;
718
787
 
719
788
  private _attachedEditor: Editor | null = null;
720
789
  private _attachedTextSnapshot: JSONContent | null = null;
790
+ /** Whether this composer was pre-filled from the post it translates. */
791
+ private _seededFromSource = false;
721
792
  private _confirmForDrafts = false;
722
793
  private _confirmForAttachedText = false;
723
794
  private _draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
724
795
  private _draftRestored = false;
796
+ /**
797
+ * Whether this composer has ever held content under its current storage key.
798
+ *
799
+ * The stored draft is shared: one key answers for new posts and for replies,
800
+ * across every tab. A composer that opens empty — a reply whose parent does
801
+ * not match the stored draft, a restore that bailed, a second tab sitting
802
+ * idle — is empty because it never loaded anything, not because the author
803
+ * threw the draft away. Only a composer that actually held the content may
804
+ * decide it is gone; without this, any empty composer deletes work it never
805
+ * showed.
806
+ */
807
+ private _hasOwnedContent = false;
808
+ /** Whether the author has already been told this composer cannot store drafts. */
809
+ private _reportedStorageFailure = false;
725
810
  private _initialSnapshot: string | null = null;
726
811
  private _pageFocusApplied = false;
727
812
  private _pageLeaveRequested = false;
728
813
  private _replyThreadRootId: string | null = null;
729
814
  private _replyRefreshKind:
730
- | "timeline-item"
731
- | "post-card"
732
- | "post-view"
733
- | null = null;
815
+ "timeline-item" | "post-card" | "post-view" | null = null;
734
816
  private _replyRefreshId: string | null = null;
735
817
  private _publishedAtTimeMinutes: number | null = null;
736
818
  private _originalPublishedAt: number | null = null;
@@ -761,6 +843,8 @@ export class JantComposeDialog extends LitElement {
761
843
  constructor() {
762
844
  super();
763
845
  this.collections = [];
846
+ this.languages = [];
847
+ this.contextLanguage = "";
764
848
  this.labels = {} as ComposeLabels;
765
849
  this.uploadMaxFileSize = 1024;
766
850
  this.pageMode = false;
@@ -815,6 +899,11 @@ export class JantComposeDialog extends LitElement {
815
899
  this._slugTaken = false;
816
900
  this._visibilityLocked = false;
817
901
  this._quietReply = false;
902
+ this._language = null;
903
+ this._showLanguagePicker = false;
904
+ this._translationOf = null;
905
+ this._translationCollapsed = false;
906
+ this._seededFromSource = false;
818
907
  }
819
908
 
820
909
  private get _editor(): JantComposeEditor | null {
@@ -871,6 +960,7 @@ export class JantComposeDialog extends LitElement {
871
960
  if (this._initialSnapshot === null && this._editor) {
872
961
  this._captureInitialSnapshot();
873
962
  }
963
+ this._adoptTranslationPreview();
874
964
  // The visible title used to be the dialog's accessible name. Nothing on
875
965
  // screen needs to replace it, but a screen reader announcing an unnamed
876
966
  // dialog does — so the name moves to an attribute, where it costs no space.
@@ -964,9 +1054,16 @@ export class JantComposeDialog extends LitElement {
964
1054
  this._slugSuggestionKey = "";
965
1055
  this._visibilityLocked = false;
966
1056
  this._quietReply = false;
1057
+ this._language = null;
1058
+ this._showLanguagePicker = false;
1059
+ this._translationOf = null;
1060
+ this._translationCollapsed = false;
1061
+ this._seededFromSource = false;
967
1062
  this._confirmForDrafts = false;
968
1063
  this._confirmForAttachedText = false;
969
1064
  this._initialSnapshot = null;
1065
+ this._hasOwnedContent = false;
1066
+ this._reportedStorageFailure = false;
970
1067
  this._pageFocusApplied = false;
971
1068
  this._pageLeaveRequested = false;
972
1069
  this._slugSuggestRequestId += 1;
@@ -1038,6 +1135,11 @@ export class JantComposeDialog extends LitElement {
1038
1135
  this._initialSlug = this._slug.trim();
1039
1136
  this._visibility = post.visibility ?? "public";
1040
1137
  this._visibilityLocked = Boolean(post.replyToId);
1138
+ // A published post's language is a settled fact, so it comes back as the
1139
+ // author's own choice rather than as something to re-read: editing a
1140
+ // Chinese post from an English page must not quietly re-file it, and the
1141
+ // pill next to Post has to show what this post *is*, not a fresh guess.
1142
+ this._language = post.language ?? null;
1041
1143
 
1042
1144
  if (post.replyToId) {
1043
1145
  this._replyToId = post.replyToId;
@@ -1119,8 +1221,9 @@ export class JantComposeDialog extends LitElement {
1119
1221
  // answer, which is right: that one belongs to the draft, not to the habit.
1120
1222
  this._editor?.setTitleDefault(JantComposeDialog._getNoteTitleDefault());
1121
1223
 
1224
+ let restored: DraftRestoreOutcome = "none";
1122
1225
  if (options?.restoreDraft !== false) {
1123
- await this.restoreLocalDraft({
1226
+ restored = await this.restoreLocalDraft({
1124
1227
  notify: options?.restoreToast,
1125
1228
  extraMedia: options?.restoreMedia,
1126
1229
  });
@@ -1137,12 +1240,18 @@ export class JantComposeDialog extends LitElement {
1137
1240
  this._collectionIds = [options.collectionId, ...this._collectionIds];
1138
1241
  }
1139
1242
 
1140
- // Restore per-collection visibility preference (only for new posts, not restored drafts)
1243
+ // The collection's remembered visibility is a default for a fresh post. A
1244
+ // restored draft already carries the answer the author gave it, and a
1245
+ // default must not quietly overwrite a choice — unlike the collection
1246
+ // itself just above, visibility is one value, so applying it here would
1247
+ // replace what they picked rather than add to it, with nothing on screen
1248
+ // to show that it changed.
1141
1249
  if (options?.collectionId) {
1142
1250
  this._sourceCollectionId = options.collectionId;
1143
- const saved = JantComposeDialog._getCollectionVisibility(
1144
- options.collectionId,
1145
- );
1251
+ const saved =
1252
+ restored === "restored"
1253
+ ? null
1254
+ : JantComposeDialog._getCollectionVisibility(options.collectionId);
1146
1255
  if (saved) {
1147
1256
  this._visibility = saved;
1148
1257
  }
@@ -1154,6 +1263,37 @@ export class JantComposeDialog extends LitElement {
1154
1263
  this._captureInitialSnapshot();
1155
1264
  }
1156
1265
 
1266
+ /**
1267
+ * Open the composer on a new post that translates an existing one.
1268
+ *
1269
+ * Nothing is created server-side here: the source ID and the target language
1270
+ * ride along in memory, and the translation group is minted only if the
1271
+ * author saves. Closing the composer leaves no trace — no draft row, no slug,
1272
+ * no single-member group.
1273
+ *
1274
+ * @param sourcePostId - Thread root the new post translates
1275
+ * @param language - Language to write it in
1276
+ */
1277
+ async openTranslation(sourcePostId: string, language: string) {
1278
+ this.reset();
1279
+ this._editor?.setTitleDefault(JantComposeDialog._getNoteTitleDefault());
1280
+
1281
+ if (this.languages.some((entry) => entry.tag === language)) {
1282
+ this._language = language;
1283
+ }
1284
+
1285
+ this.closest("dialog")?.showModal();
1286
+ await this.updateComplete;
1287
+ this._editor?.focusInput();
1288
+ this._captureInitialSnapshot();
1289
+
1290
+ // Deliberately after the open: the original is context, not a
1291
+ // precondition, and blocking the composer on a fetch would trade a visible
1292
+ // delay for it. `seed` is safe this late because it stands down the moment
1293
+ // the author has typed anything.
1294
+ await this._loadTranslationSource(sourcePostId, { seed: true });
1295
+ }
1296
+
1157
1297
  /**
1158
1298
  * Open compose dialog in reply mode.
1159
1299
  *
@@ -1320,6 +1460,20 @@ export class JantComposeDialog extends LitElement {
1320
1460
  );
1321
1461
  }
1322
1462
 
1463
+ /**
1464
+ * Whether closing would throw away something the author wrote.
1465
+ *
1466
+ * Normally "is there content" answers that, because a new composer opens
1467
+ * empty. A translation opens pre-seeded with the original's citation, though,
1468
+ * and being asked to save a draft of a URL you never typed is worse than the
1469
+ * prompt is worth — so once seeded, the question becomes whether anything has
1470
+ * changed since it opened.
1471
+ */
1472
+ private _hasWorkToLose(): boolean {
1473
+ if (!this._hasContent()) return false;
1474
+ return this._seededFromSource ? this._hasUnsavedChanges() : true;
1475
+ }
1476
+
1323
1477
  private _buildSnapshot(): ComposeStateSnapshot | null {
1324
1478
  const editor = this._editor;
1325
1479
  if (!editor) return null;
@@ -1337,6 +1491,7 @@ export class JantComposeDialog extends LitElement {
1337
1491
  publishedAtInput: this._publishedAtInput,
1338
1492
  publishedAtTimeMinutes: this._publishedAtTimeMinutes,
1339
1493
  visibility: this._visibility,
1494
+ language: this._language,
1340
1495
  title: editorData.title,
1341
1496
  bodyJson: editor.getNormalizedBodyJson(),
1342
1497
  url: editorData.url,
@@ -1396,6 +1551,7 @@ export class JantComposeDialog extends LitElement {
1396
1551
  if (this._showPublishPanel) {
1397
1552
  this._showPublishPanel = false;
1398
1553
  }
1554
+ this._showLanguagePicker = false;
1399
1555
 
1400
1556
  if (this._confirmPanelOpen) {
1401
1557
  const restoreAttachedFocus = this._confirmForAttachedText;
@@ -1426,7 +1582,7 @@ export class JantComposeDialog extends LitElement {
1426
1582
  return;
1427
1583
  }
1428
1584
 
1429
- if (this._hasContent()) {
1585
+ if (this._hasWorkToLose()) {
1430
1586
  this._confirmForDrafts = false;
1431
1587
  this._confirmForAttachedText = false;
1432
1588
  this._confirmPanelOpen = true;
@@ -1496,7 +1652,12 @@ export class JantComposeDialog extends LitElement {
1496
1652
  }
1497
1653
  }
1498
1654
 
1499
- /** Build the submit payload for a single thread editor (index-aware). */
1655
+ /**
1656
+ * Build the submit payload for the post at `index` — the single post, or one
1657
+ * post of a thread. The one builder for both, so a field the single post
1658
+ * gains reaches a thread's root too: the thread path used to be a copy that
1659
+ * never learned about language or translations.
1660
+ */
1500
1661
  private _buildEditorPostDetail(
1501
1662
  editor: JantComposeEditor,
1502
1663
  format: ComposeFormat,
@@ -1569,6 +1730,17 @@ export class JantComposeDialog extends LitElement {
1569
1730
  ? (this._replyRefreshKind ?? undefined)
1570
1731
  : undefined,
1571
1732
  replyRefreshId: isRoot ? (this._replyRefreshId ?? undefined) : undefined,
1733
+ // Only a Thread's root carries a language: its other posts, and a reply
1734
+ // to an existing Thread, inherit it server-side. An automatic choice
1735
+ // resolves here to whatever the pill beside this button has been showing
1736
+ // — what detection read, or the page's language while it has read
1737
+ // nothing. Absent only on a single-language site, where the server
1738
+ // decides.
1739
+ language:
1740
+ isRoot && !this._replyToId
1741
+ ? (this._effectiveLanguage() ?? undefined)
1742
+ : undefined,
1743
+ translationOfId: isRoot ? this._translationOf?.id : undefined,
1572
1744
  };
1573
1745
  }
1574
1746
 
@@ -1578,68 +1750,10 @@ export class JantComposeDialog extends LitElement {
1578
1750
  const editor = this._editor;
1579
1751
  if (!editor) return null;
1580
1752
 
1581
- editor.promoteLeadingH1Title({ force: true });
1582
- const editorData = editor.getData();
1583
- const mediaAttachments = new Map(
1584
- (editorData.attachments ?? []).map((attachment) => [
1585
- attachment.clientId,
1586
- attachment,
1587
- ]),
1588
- );
1589
- const textAttachments = new Map(
1590
- editorData.attachedTexts.map((item) => [item.clientId, item]),
1591
- );
1592
- const orderedAttachments: ComposeSubmitAttachment[] = [];
1593
- for (const clientId of editorData.attachmentOrder) {
1594
- const mediaAttachment = mediaAttachments.get(clientId);
1595
- if (mediaAttachment) {
1596
- orderedAttachments.push({
1597
- type: "media",
1598
- clientId,
1599
- mediaId: mediaAttachment.mediaId,
1600
- alt: mediaAttachment.alt || undefined,
1601
- });
1602
- continue;
1603
- }
1604
-
1605
- const textAttachment = textAttachments.get(clientId);
1606
- if (textAttachment?.bodyJson) {
1607
- orderedAttachments.push({
1608
- type: "text",
1609
- clientId,
1610
- bodyJson: textAttachment.bodyJson,
1611
- summary: textAttachment.summary,
1612
- mediaId: textAttachment.mediaId,
1613
- originalBodyJson: normalizeComposeDoc(
1614
- textAttachment.originalBodyJson ?? null,
1615
- ),
1616
- });
1617
- }
1618
- }
1619
-
1620
1753
  return {
1621
- format: this._format,
1622
- title: editorData.title,
1623
- body: editorData.body,
1624
- url: editorData.url,
1625
- quoteText: editorData.quoteText,
1626
- quoteAuthor: editorData.quoteAuthor,
1627
- slug: this._slug.trim() || undefined,
1628
- publishedAt: this._getPublishedAtSubmitValue(status),
1629
- status,
1630
- visibility: this._visibilityLocked ? undefined : this._visibility,
1631
- rating: editorData.rating,
1632
- collectionIds: this._replyToId ? [] : [...this._collectionIds],
1633
- attachments: orderedAttachments,
1754
+ ...this._buildEditorPostDetail(editor, this._format, 0, status),
1634
1755
  editPostId: this._editPostId ?? this._draftSourceId ?? undefined,
1635
1756
  draftSourceId: this._draftSourceId ?? undefined,
1636
- replyToId: this._replyToId ?? undefined,
1637
- quietReply: this._canReplyQuietly()
1638
- ? this._quietReply || undefined
1639
- : undefined,
1640
- replyThreadRootId: this._replyThreadRootId ?? undefined,
1641
- replyRefreshKind: this._replyRefreshKind ?? undefined,
1642
- replyRefreshId: this._replyRefreshId ?? undefined,
1643
1757
  };
1644
1758
  }
1645
1759
 
@@ -2275,7 +2389,12 @@ export class JantComposeDialog extends LitElement {
2275
2389
  mode: "check",
2276
2390
  slug,
2277
2391
  });
2278
- const postId = this._currentSlugOwnerId();
2392
+ // Each post is checked against its own saved copy: a reply's slug is not
2393
+ // the root's to release, and the root's is not the reply's.
2394
+ const postId =
2395
+ index === 0
2396
+ ? this._currentSlugOwnerId()
2397
+ : this._threadItems[index]?.sourcePostId;
2279
2398
  if (postId) params.set("postId", postId);
2280
2399
 
2281
2400
  try {
@@ -2612,6 +2731,11 @@ export class JantComposeDialog extends LitElement {
2612
2731
  return true;
2613
2732
  }
2614
2733
 
2734
+ if (this._showLanguagePicker) {
2735
+ this._closeLanguagePicker(true);
2736
+ return true;
2737
+ }
2738
+
2615
2739
  if (this._showCollection) {
2616
2740
  this._closeCollectionPicker({ restoreFocus: "editor" });
2617
2741
  return true;
@@ -2869,7 +2993,7 @@ export class JantComposeDialog extends LitElement {
2869
2993
 
2870
2994
  private _handleDraftButtonClick() {
2871
2995
  if (this._loading) return;
2872
- if (this._hasContent()) {
2996
+ if (this._hasWorkToLose()) {
2873
2997
  this._confirmForDrafts = true;
2874
2998
  this._confirmPanelOpen = true;
2875
2999
  } else {
@@ -2887,8 +3011,7 @@ export class JantComposeDialog extends LitElement {
2887
3011
  const res = await fetch("/api/posts?status=draft&limit=50");
2888
3012
  if (!res.ok) throw new Error("Failed to load drafts");
2889
3013
  const json = (await res.json()) as
2890
- | DraftsResponse
2891
- | Record<string, unknown>[];
3014
+ DraftsResponse | Record<string, unknown>[];
2892
3015
  const posts = Array.isArray(json) ? json : (json.posts ?? []);
2893
3016
  const allDraftItems = (posts as Record<string, unknown>[]).map(
2894
3017
  (p): DraftItem => ({
@@ -3019,8 +3142,7 @@ export class JantComposeDialog extends LitElement {
3019
3142
  const draftsRes = await fetch("/api/posts?status=draft&limit=50");
3020
3143
  if (draftsRes.ok) {
3021
3144
  const draftsJson = (await draftsRes.json()) as
3022
- | { posts?: Record<string, unknown>[] }
3023
- | Record<string, unknown>[];
3145
+ { posts?: Record<string, unknown>[] } | Record<string, unknown>[];
3024
3146
  const allDrafts = Array.isArray(draftsJson)
3025
3147
  ? draftsJson
3026
3148
  : (draftsJson.posts ?? []);
@@ -3036,6 +3158,7 @@ export class JantComposeDialog extends LitElement {
3036
3158
  .map((p) => ({
3037
3159
  id: p.id as string,
3038
3160
  format: p.format as ComposeFormat,
3161
+ slug: (p.slug as string | null) ?? null,
3039
3162
  replyToId: (p.replyToId as string) ?? null,
3040
3163
  title: (p.title as string) ?? null,
3041
3164
  body: (p.body as string) ?? null,
@@ -3063,12 +3186,16 @@ export class JantComposeDialog extends LitElement {
3063
3186
  if (!ordered.includes(p)) ordered.push(p);
3064
3187
  }
3065
3188
 
3066
- // Enter thread mode
3189
+ // Enter thread mode. Each reply keeps its own permalink: saving
3190
+ // recreates the thread, so a slug left behind here would come back
3191
+ // as a random id.
3067
3192
  this._threadItems = [
3068
3193
  { id: randomUUID(), format: post.format },
3069
3194
  ...ordered.map((p) => ({
3070
3195
  id: randomUUID(),
3071
3196
  format: p.format,
3197
+ slug: p.slug ?? "",
3198
+ sourcePostId: p.id,
3072
3199
  })),
3073
3200
  ];
3074
3201
  this._focusedThreadIndex = 0;
@@ -3267,7 +3394,7 @@ export class JantComposeDialog extends LitElement {
3267
3394
  });
3268
3395
 
3269
3396
  if (!hasContent) {
3270
- globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3397
+ this._releaseStoredDraft();
3271
3398
  return;
3272
3399
  }
3273
3400
 
@@ -3286,6 +3413,10 @@ export class JantComposeDialog extends LitElement {
3286
3413
  url: data.url,
3287
3414
  quoteText: data.quoteText,
3288
3415
  quoteAuthor: data.quoteAuthor,
3416
+ // Same rules as the single-post draft below.
3417
+ rating: data.rating,
3418
+ showTitle: item.format === "note" ? editor._showTitle : false,
3419
+ showRating: data.rating > 0 ? editor._showRating : false,
3289
3420
  attachedTexts: data.attachedTexts.map((t) => ({
3290
3421
  clientId: t.clientId,
3291
3422
  bodyJson: t.bodyJson,
@@ -3316,20 +3447,15 @@ export class JantComposeDialog extends LitElement {
3316
3447
  showRating: false,
3317
3448
  collectionIds: [...this._collectionIds],
3318
3449
  replyToId: this._replyToId,
3450
+ language: this._language,
3451
+ translationOfId: this._translationOf?.id ?? null,
3319
3452
  attachedTexts: [],
3320
3453
  attachmentOrder: [],
3321
3454
  threadItems,
3322
3455
  savedAt: Date.now(),
3323
3456
  };
3324
3457
 
3325
- try {
3326
- globalThis.localStorage.setItem(
3327
- this._currentDraftStorageKey(),
3328
- JSON.stringify(draft),
3329
- );
3330
- } catch {
3331
- // Storage full or unavailable — silently ignore
3332
- }
3458
+ this._writeStoredDraft(draft);
3333
3459
  return;
3334
3460
  }
3335
3461
 
@@ -3341,7 +3467,7 @@ export class JantComposeDialog extends LitElement {
3341
3467
  // for edit (or restoring a draft) would write a local draft of the
3342
3468
  // unchanged content, since loading fires content-change events.
3343
3469
  if (!this._hasUnsavedChanges()) {
3344
- globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3470
+ this._releaseStoredDraft();
3345
3471
  return;
3346
3472
  }
3347
3473
 
@@ -3357,7 +3483,7 @@ export class JantComposeDialog extends LitElement {
3357
3483
  data.attachedTexts.length > 0;
3358
3484
 
3359
3485
  if (!hasContent) {
3360
- globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3486
+ this._releaseStoredDraft();
3361
3487
  return;
3362
3488
  }
3363
3489
 
@@ -3380,6 +3506,8 @@ export class JantComposeDialog extends LitElement {
3380
3506
  showRating: data.rating > 0 ? editor._showRating : false,
3381
3507
  collectionIds: [...this._collectionIds],
3382
3508
  replyToId: this._replyToId,
3509
+ language: this._language,
3510
+ translationOfId: this._translationOf?.id ?? null,
3383
3511
  attachedTexts: data.attachedTexts.map((t) => ({
3384
3512
  clientId: t.clientId,
3385
3513
  bodyJson: t.bodyJson,
@@ -3393,18 +3521,49 @@ export class JantComposeDialog extends LitElement {
3393
3521
  savedAt: Date.now(),
3394
3522
  };
3395
3523
 
3524
+ this._writeStoredDraft(draft);
3525
+ }
3526
+
3527
+ /**
3528
+ * Persist the draft and take ownership of the key it went to.
3529
+ *
3530
+ * A write that does not stick is the one failure the author must hear about:
3531
+ * the local copy is the last thing standing between a dropped connection and
3532
+ * lost work, and a composer that cannot store one looks exactly like a
3533
+ * composer that can. Said once per composer, not once per keystroke.
3534
+ */
3535
+ private _writeStoredDraft(draft: LocalDraft) {
3396
3536
  try {
3397
3537
  globalThis.localStorage.setItem(
3398
3538
  this._currentDraftStorageKey(),
3399
3539
  JSON.stringify(draft),
3400
3540
  );
3401
- } catch {
3402
- // Storage full or unavailable — silently ignore
3541
+ this._hasOwnedContent = true;
3542
+ } catch (error) {
3543
+ // eslint-disable-next-line no-console -- the local copy is the last safety net; a lost one must be visible
3544
+ console.warn("[jant] compose draft could not be stored", error);
3545
+ if (!this._reportedStorageFailure) {
3546
+ this._reportedStorageFailure = true;
3547
+ showToast(this.labels.draftStoreFailed, "error");
3548
+ }
3403
3549
  }
3404
3550
  }
3405
3551
 
3552
+ /**
3553
+ * Drop the stored draft, but only when this composer is the one holding it.
3554
+ *
3555
+ * See `_hasOwnedContent`: an empty composer that never loaded anything has
3556
+ * nothing to say about a draft written by another composer or another tab.
3557
+ */
3558
+ private _releaseStoredDraft() {
3559
+ if (!this._hasOwnedContent) return;
3560
+ this._hasOwnedContent = false;
3561
+ globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3562
+ }
3563
+
3406
3564
  private _clearDraftFromStorage() {
3407
3565
  this._cancelDraftSaveTimer();
3566
+ this._hasOwnedContent = false;
3408
3567
  globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3409
3568
  }
3410
3569
 
@@ -3478,39 +3637,44 @@ export class JantComposeDialog extends LitElement {
3478
3637
  notify?: boolean;
3479
3638
  /** Completed uploads known to the bridge at failure time */
3480
3639
  extraMedia?: LocalDraftMedia[];
3481
- }) {
3640
+ }): Promise<DraftRestoreOutcome> {
3641
+ const key = JantComposeDialog._DRAFT_KEY;
3482
3642
  // Don't restore if already in edit or draft-load mode
3483
- if (this._editPostId || this._draftSourceId) return;
3643
+ if (this._editPostId || this._draftSourceId) {
3644
+ return this._reportRestoreOutcome("composer-busy", key);
3645
+ }
3484
3646
  // Don't restore if the editor already has content (e.g. reopened dialog)
3485
- if (this._hasContent()) return;
3647
+ if (this._hasContent()) {
3648
+ return this._reportRestoreOutcome("composer-has-content", key);
3649
+ }
3486
3650
 
3487
3651
  let raw: string | null;
3488
3652
  try {
3489
- raw = globalThis.localStorage.getItem(JantComposeDialog._DRAFT_KEY);
3653
+ raw = globalThis.localStorage.getItem(key);
3490
3654
  } catch {
3491
- return;
3655
+ return "none";
3492
3656
  }
3493
- if (!raw) return;
3657
+ if (!raw) return "none";
3494
3658
 
3495
3659
  let draft: LocalDraft;
3496
3660
  try {
3497
3661
  draft = JSON.parse(raw) as LocalDraft;
3498
3662
  } catch {
3499
- globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
3500
- return;
3663
+ // Deliberately kept: bytes we cannot parse are still something the author
3664
+ // wrote, and deleting them here would turn a bad read into a lost draft.
3665
+ return this._reportRestoreOutcome("unreadable", key, raw);
3501
3666
  }
3502
3667
 
3503
- // Discard stale drafts
3668
+ // Too old to put back, but still theirs — leave it where it is.
3504
3669
  if (Date.now() - draft.savedAt > JantComposeDialog._DRAFT_MAX_AGE) {
3505
- globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
3506
- return;
3670
+ return this._reportRestoreOutcome("expired", key, raw, draft);
3507
3671
  }
3508
3672
 
3509
3673
  if (
3510
3674
  options?.expectedReplyToId !== undefined &&
3511
3675
  draft.replyToId !== options.expectedReplyToId
3512
3676
  ) {
3513
- return;
3677
+ return this._reportRestoreOutcome("other-reply", key, raw, draft);
3514
3678
  }
3515
3679
 
3516
3680
  this._collectionIds = draft.replyToId
@@ -3525,6 +3689,10 @@ export class JantComposeDialog extends LitElement {
3525
3689
  this._publishedAtInput = draft.publishedAtInput ?? "";
3526
3690
  this._publishedAtTimeMinutes = draft.publishedAtTimeMinutes ?? null;
3527
3691
  this._visibility = draft.visibility ?? "public";
3692
+ this._language = draft.language ?? null;
3693
+ if (draft.translationOfId) {
3694
+ await this._loadTranslationSource(draft.translationOfId);
3695
+ }
3528
3696
 
3529
3697
  // Restore reply context if this draft was a reply
3530
3698
  if (draft.replyToId) {
@@ -3587,6 +3755,9 @@ export class JantComposeDialog extends LitElement {
3587
3755
  url: item.url || undefined,
3588
3756
  quoteText: item.quoteText || undefined,
3589
3757
  quoteAuthor: item.quoteAuthor || undefined,
3758
+ rating: item.rating || undefined,
3759
+ showTitle: item.showTitle,
3760
+ showRating: item.showRating,
3590
3761
  media: media.length
3591
3762
  ? JantComposeDialog._restoredMediaToPopulate(media)
3592
3763
  : undefined,
@@ -3598,11 +3769,12 @@ export class JantComposeDialog extends LitElement {
3598
3769
  }
3599
3770
 
3600
3771
  this._draftRestored = true;
3772
+ this._hasOwnedContent = true;
3601
3773
  if (options?.notify !== false) showToast(this.labels.draftRestored);
3602
3774
  globalThis.requestAnimationFrame(() => {
3603
3775
  this._captureInitialSnapshot();
3604
3776
  });
3605
- return;
3777
+ return "restored";
3606
3778
  }
3607
3779
 
3608
3780
  // ── Single-post draft restore ────────────────────────────────────
@@ -3648,10 +3820,59 @@ export class JantComposeDialog extends LitElement {
3648
3820
  });
3649
3821
 
3650
3822
  this._draftRestored = true;
3823
+ this._hasOwnedContent = true;
3651
3824
  if (options?.notify !== false) showToast(this.labels.draftRestored);
3652
3825
  globalThis.requestAnimationFrame(() => {
3653
3826
  this._captureInitialSnapshot();
3654
3827
  });
3828
+ return "restored";
3829
+ }
3830
+
3831
+ /**
3832
+ * Say why a stored draft did not reach the composer.
3833
+ *
3834
+ * A composer that opens empty looks the same whether there was nothing to
3835
+ * restore or something went wrong reading what was there. That costs nothing
3836
+ * until an author asks where their work went, at which point there is nothing
3837
+ * to look at. `none` — the ordinary "no draft stored" case — stays quiet;
3838
+ * everything else means bytes existed and did not come back, which is worth
3839
+ * saying out loud.
3840
+ *
3841
+ * @param outcome - Why the restore stopped
3842
+ * @param key - Storage key that was read
3843
+ * @param raw - The stored string, when one was found
3844
+ * @param draft - The parsed draft, when it parsed
3845
+ * @returns The outcome, so callers can `return` this directly
3846
+ * @example
3847
+ * return this._reportRestoreOutcome("expired", key, raw, draft);
3848
+ */
3849
+ private _reportRestoreOutcome(
3850
+ outcome: DraftRestoreOutcome,
3851
+ key: string,
3852
+ raw?: string | null,
3853
+ draft?: LocalDraft,
3854
+ ): DraftRestoreOutcome {
3855
+ if (outcome === "restored" || outcome === "none") return outcome;
3856
+
3857
+ let stored = raw;
3858
+ if (stored === undefined) {
3859
+ try {
3860
+ stored = globalThis.localStorage.getItem(key);
3861
+ } catch {
3862
+ stored = null;
3863
+ }
3864
+ }
3865
+ // Nothing was stored, so nothing was lost — the composer just opened empty.
3866
+ if (!stored) return "none";
3867
+
3868
+ // eslint-disable-next-line no-console -- the one record of why recovered work did not come back
3869
+ console.warn(`[jant] compose draft not restored: ${outcome}`, {
3870
+ key,
3871
+ bytes: stored.length,
3872
+ savedAt: draft?.savedAt ? new Date(draft.savedAt).toISOString() : null,
3873
+ thread: draft?.threadItems?.length ?? null,
3874
+ });
3875
+ return outcome;
3655
3876
  }
3656
3877
 
3657
3878
  /**
@@ -3676,12 +3897,13 @@ export class JantComposeDialog extends LitElement {
3676
3897
  try {
3677
3898
  draft = JSON.parse(raw) as LocalDraft;
3678
3899
  } catch {
3679
- globalThis.localStorage.removeItem(key);
3900
+ // Kept, not deleted: see `restoreLocalDraft`.
3901
+ this._reportRestoreOutcome("unreadable", key, raw);
3680
3902
  return false;
3681
3903
  }
3682
3904
 
3683
3905
  if (Date.now() - draft.savedAt > JantComposeDialog._DRAFT_MAX_AGE) {
3684
- globalThis.localStorage.removeItem(key);
3906
+ this._reportRestoreOutcome("expired", key, raw, draft);
3685
3907
  return false;
3686
3908
  }
3687
3909
 
@@ -3694,6 +3916,10 @@ export class JantComposeDialog extends LitElement {
3694
3916
  this._publishedAtInput = draft.publishedAtInput ?? "";
3695
3917
  this._publishedAtTimeMinutes = draft.publishedAtTimeMinutes ?? null;
3696
3918
  this._visibility = draft.visibility ?? "public";
3919
+ // The draft speaks for the language too, and its answer may be that the
3920
+ // author put this post back on automatic — so it replaces the one seeded
3921
+ // from the post rather than filling in for a missing one.
3922
+ this._language = draft.language ?? null;
3697
3923
 
3698
3924
  // Restore editor content
3699
3925
  const textAttachments = draft.attachedTexts?.flatMap((t) => {
@@ -3724,16 +3950,42 @@ export class JantComposeDialog extends LitElement {
3724
3950
  });
3725
3951
 
3726
3952
  this._draftRestored = true;
3953
+ this._hasOwnedContent = true;
3727
3954
  if (notify !== false) showToast(this.labels.draftRestored);
3728
3955
  return true;
3729
3956
  }
3730
3957
 
3958
+ /**
3959
+ * Pick up a "write the translation" request from the URL.
3960
+ *
3961
+ * The post menu sends the author here with the source Thread and the target
3962
+ * language in the query string, and nothing else exists yet: no draft row, no
3963
+ * slug, no group. Applied after any local draft restore so a draft the author
3964
+ * already started for this translation wins over the bare request.
3965
+ */
3966
+ private async _applyTranslationRequestFromUrl(): Promise<void> {
3967
+ const params = new URLSearchParams(globalThis.location?.search ?? "");
3968
+ const translationOf = params.get("translationOf");
3969
+ if (!translationOf) return;
3970
+
3971
+ const lang = params.get("lang");
3972
+ if (lang && this.languages.some((language) => language.tag === lang)) {
3973
+ this._language = lang;
3974
+ }
3975
+ if (!this._translationOf) {
3976
+ // `seed` still stands down on its own if the restore above brought
3977
+ // content back, so a draft the author already started keeps its format.
3978
+ await this._loadTranslationSource(translationOf, { seed: true });
3979
+ }
3980
+ }
3981
+
3731
3982
  private async _focusPageEditorOnMount() {
3732
3983
  if (this._pageFocusApplied) return;
3733
3984
 
3734
3985
  if (this.autoRestoreDraft) {
3735
3986
  await this.restoreLocalDraft();
3736
3987
  }
3988
+ await this._applyTranslationRequestFromUrl();
3737
3989
 
3738
3990
  await this.updateComplete;
3739
3991
  globalThis.requestAnimationFrame(() => {
@@ -3769,37 +4021,43 @@ export class JantComposeDialog extends LitElement {
3769
4021
  </button>
3770
4022
  <span class="compose-alt-title">${this.labels.drafts}</span>
3771
4023
  </div>
3772
- ${this._draftsLoading
3773
- ? html`<div class="compose-drafts-loading">
3774
- <svg
3775
- class="animate-spin size-5"
3776
- xmlns="http://www.w3.org/2000/svg"
3777
- viewBox="0 0 24 24"
3778
- fill="none"
3779
- stroke="currentColor"
3780
- stroke-width="2"
3781
- stroke-linecap="round"
3782
- stroke-linejoin="round"
3783
- >
3784
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
3785
- </svg>
3786
- </div>`
3787
- : this._draftsError
3788
- ? html`<div class="compose-drafts-empty">${this._draftsError}</div>`
3789
- : this._drafts.length === 0
4024
+ ${
4025
+ this._draftsLoading
4026
+ ? html`<div class="compose-drafts-loading">
4027
+ <svg
4028
+ class="animate-spin size-5"
4029
+ xmlns="http://www.w3.org/2000/svg"
4030
+ viewBox="0 0 24 24"
4031
+ fill="none"
4032
+ stroke="currentColor"
4033
+ stroke-width="2"
4034
+ stroke-linecap="round"
4035
+ stroke-linejoin="round"
4036
+ >
4037
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
4038
+ </svg>
4039
+ </div>`
4040
+ : this._draftsError
3790
4041
  ? html`<div class="compose-drafts-empty">
3791
- ${this.labels.draftsEmpty}
4042
+ ${this._draftsError}
3792
4043
  </div>`
3793
- : html`<div class="compose-drafts-list">
3794
- ${this._drafts.map(
3795
- (draft, i) => html`
3796
- ${i > 0
3797
- ? html`<div class="compose-drafts-divider"></div>`
3798
- : nothing}
3799
- ${this._renderDraftItem(draft)}
3800
- `,
3801
- )}
3802
- </div>`}
4044
+ : this._drafts.length === 0
4045
+ ? html`<div class="compose-drafts-empty">
4046
+ ${this.labels.draftsEmpty}
4047
+ </div>`
4048
+ : html`<div class="compose-drafts-list">
4049
+ ${this._drafts.map(
4050
+ (draft, i) => html`
4051
+ ${
4052
+ i > 0
4053
+ ? html`<div class="compose-drafts-divider"></div>`
4054
+ : nothing
4055
+ }
4056
+ ${this._renderDraftItem(draft)}
4057
+ `,
4058
+ )}
4059
+ </div>`
4060
+ }
3803
4061
  </div>
3804
4062
  `;
3805
4063
  }
@@ -3813,13 +4071,15 @@ export class JantComposeDialog extends LitElement {
3813
4071
  return html`
3814
4072
  <div class="compose-draft-item" @click=${() => this.openDraft(draft.id)}>
3815
4073
  <div class="compose-draft-content">
3816
- ${preview
3817
- ? html`<div class="compose-draft-preview">${preview}</div>`
3818
- : html`<div
3819
- class="compose-draft-preview compose-draft-preview-empty"
3820
- >
3821
- Empty draft
3822
- </div>`}
4074
+ ${
4075
+ preview
4076
+ ? html`<div class="compose-draft-preview">${preview}</div>`
4077
+ : html`<div
4078
+ class="compose-draft-preview compose-draft-preview-empty"
4079
+ >
4080
+ Empty draft
4081
+ </div>`
4082
+ }
3823
4083
  <div class="compose-draft-meta">
3824
4084
  <span class="compose-draft-format">${formatLabel}</span>
3825
4085
  <span aria-hidden="true">·</span>
@@ -3827,15 +4087,17 @@ export class JantComposeDialog extends LitElement {
3827
4087
  </div>
3828
4088
  </div>
3829
4089
  <div class="relative">
3830
- ${menuOpen
3831
- ? html`<div
3832
- class="compose-dropdown-backdrop"
3833
- @click=${(e: Event) => {
3834
- e.stopPropagation();
3835
- this._draftMenuOpenId = null;
3836
- }}
3837
- ></div>`
3838
- : nothing}
4090
+ ${
4091
+ menuOpen
4092
+ ? html`<div
4093
+ class="compose-dropdown-backdrop"
4094
+ @click=${(e: Event) => {
4095
+ e.stopPropagation();
4096
+ this._draftMenuOpenId = null;
4097
+ }}
4098
+ ></div>`
4099
+ : nothing
4100
+ }
3839
4101
  <button
3840
4102
  type="button"
3841
4103
  class="compose-draft-more"
@@ -3855,44 +4117,46 @@ export class JantComposeDialog extends LitElement {
3855
4117
  <circle cx="12" cy="8" r="1.2" />
3856
4118
  </svg>
3857
4119
  </button>
3858
- ${menuOpen
3859
- ? html`
3860
- <div
3861
- id=${menuId}
3862
- class="compose-dropdown compose-dropdown-right"
3863
- role="menu"
3864
- >
3865
- <a
3866
- class="compose-dropdown-item"
3867
- href=${publicPath(`/preview/${draft.slug}`)}
3868
- target="_blank"
3869
- rel="noopener noreferrer"
3870
- role="menuitem"
3871
- @click=${(e: Event) => {
3872
- e.stopPropagation();
3873
- this._draftMenuOpenId = null;
3874
- }}
3875
- >
3876
- ${this.labels.previewDraft}
3877
- </a>
4120
+ ${
4121
+ menuOpen
4122
+ ? html`
3878
4123
  <div
3879
- class="compose-dropdown-separator"
3880
- role="separator"
3881
- ></div>
3882
- <button
3883
- type="button"
3884
- class="compose-dropdown-item compose-dropdown-item-danger"
3885
- role="menuitem"
3886
- @click=${(e: Event) => {
3887
- e.stopPropagation();
3888
- this._deleteDraft(draft.id);
3889
- }}
4124
+ id=${menuId}
4125
+ class="compose-dropdown compose-dropdown-right"
4126
+ role="menu"
3890
4127
  >
3891
- ${this.labels.deleteDraft}
3892
- </button>
3893
- </div>
3894
- `
3895
- : nothing}
4128
+ <a
4129
+ class="compose-dropdown-item"
4130
+ href=${publicPath(`/preview/${draft.slug}`)}
4131
+ target="_blank"
4132
+ rel="noopener noreferrer"
4133
+ role="menuitem"
4134
+ @click=${(e: Event) => {
4135
+ e.stopPropagation();
4136
+ this._draftMenuOpenId = null;
4137
+ }}
4138
+ >
4139
+ ${this.labels.previewDraft}
4140
+ </a>
4141
+ <div
4142
+ class="compose-dropdown-separator"
4143
+ role="separator"
4144
+ ></div>
4145
+ <button
4146
+ type="button"
4147
+ class="compose-dropdown-item compose-dropdown-item-danger"
4148
+ role="menuitem"
4149
+ @click=${(e: Event) => {
4150
+ e.stopPropagation();
4151
+ this._deleteDraft(draft.id);
4152
+ }}
4153
+ >
4154
+ ${this.labels.deleteDraft}
4155
+ </button>
4156
+ </div>
4157
+ `
4158
+ : nothing
4159
+ }
3896
4160
  </div>
3897
4161
  </div>
3898
4162
  `;
@@ -3900,6 +4164,167 @@ export class JantComposeDialog extends LitElement {
3900
4164
 
3901
4165
  // ── Reply context rendering ──────────────────────────────────────
3902
4166
 
4167
+ /**
4168
+ * The original, above the composer that is translating it.
4169
+ *
4170
+ * Sits where the reply context sits, for the same reason: a composer that
4171
+ * was opened *about* something should say so before the author starts
4172
+ * typing, not bury it two panels deep.
4173
+ *
4174
+ * Three decisions worth keeping:
4175
+ *
4176
+ * - It shows the whole post as the site renders it — server-rendered by
4177
+ * `/_/post-preview`, so a Quote arrives with its attribution and a Link
4178
+ * with its card. Structure is part of what gets translated, and a
4179
+ * `bodyHtml`-only preview silently dropped everything that is not body.
4180
+ * - It scrolls inside a fixed frame instead of expanding. Translation is
4181
+ * read-a-bit, write-a-bit: a "show more" that hands a long post its full
4182
+ * height pushes the editor off the screen exactly when both need to be
4183
+ * visible. (The reply context expands, and is right to — it is read once,
4184
+ * before writing, not alongside.)
4185
+ * - Nothing is bolted on to say "this is a link somewhere". The post's own
4186
+ * permalink is inside the rendering already; `_adoptTranslationPreview`
4187
+ * sends it to a new tab. An external-link glyph on a title made the whole
4188
+ * card read as a Link post rather than as the article it is.
4189
+ *
4190
+ * The language goes on the divider underneath rather than at the top: it
4191
+ * belongs at the seam, where it reads as "…and below is that, in Japanese".
4192
+ */
4193
+ private _renderTranslationContext() {
4194
+ const source = this._translationOf;
4195
+ if (!source) return nothing;
4196
+
4197
+ const languageLabel =
4198
+ this.languages.find((entry) => entry.tag === this._language)?.label ?? "";
4199
+ const seam = languageLabel
4200
+ ? this.labels.translationContextInLanguage.replace(
4201
+ "{language}",
4202
+ languageLabel,
4203
+ )
4204
+ : this.labels.translationContext;
4205
+
4206
+ const collapsed = this._translationCollapsed;
4207
+
4208
+ return html`
4209
+ <div class="compose-translation-context">
4210
+ ${
4211
+ collapsed
4212
+ ? nothing
4213
+ : source.previewHtml
4214
+ ? html`<div
4215
+ class="compose-translation-original"
4216
+ tabindex="0"
4217
+ role="region"
4218
+ aria-label=${this.labels.translationContextOriginal}
4219
+ >
4220
+ <div class="compose-translation-preview">
4221
+ ${unsafeHTML(source.previewHtml)}
4222
+ </div>
4223
+ </div>`
4224
+ : html`<p class="compose-translation-fallback">
4225
+ ${
4226
+ source.href
4227
+ ? html`<a
4228
+ href=${source.href}
4229
+ target="_blank"
4230
+ rel="noopener noreferrer"
4231
+ title=${this.labels.translationContextOpen}
4232
+ >${source.title}</a
4233
+ >`
4234
+ : source.title
4235
+ }
4236
+ </p>`
4237
+ }
4238
+ <div class="compose-translation-seam">
4239
+ <span class="compose-translation-seam-label">
4240
+ ${this._iconArrowDown()}${seam}
4241
+ </span>
4242
+ <span class="compose-translation-seam-dot" aria-hidden="true">·</span>
4243
+ <button
4244
+ type="button"
4245
+ class="compose-translation-seam-toggle"
4246
+ aria-expanded=${collapsed ? "false" : "true"}
4247
+ aria-label=${
4248
+ collapsed
4249
+ ? this.labels.translationContextShowLong
4250
+ : this.labels.translationContextHideLong
4251
+ }
4252
+ @click=${() => {
4253
+ this._translationCollapsed = !this._translationCollapsed;
4254
+ }}
4255
+ >
4256
+ ${
4257
+ collapsed
4258
+ ? this.labels.translationContextShow
4259
+ : this.labels.translationContextHide
4260
+ }
4261
+ </button>
4262
+ </div>
4263
+ </div>
4264
+ `;
4265
+ }
4266
+
4267
+ /**
4268
+ * Make the server-rendered original safe to sit inside the composer.
4269
+ *
4270
+ * Two things have to be undone, both because this markup was written to *be*
4271
+ * a post rather than to be quoted inside one:
4272
+ *
4273
+ * - Every link leaves for a new tab. A link followed in place navigates the
4274
+ * composer away and takes the unsaved translation with it.
4275
+ * - The post's identity comes off. `data-post-id` and friends are how the
4276
+ * post menu, the keyboard shortcuts and `refreshArticleView` find a post;
4277
+ * leaving a second copy of the original's id in the DOM lets any of them
4278
+ * act on the preview believing it is the real card.
4279
+ *
4280
+ * Runs on every update because `unsafeHTML` re-creates the subtree whenever
4281
+ * the markup changes, and is cheap to repeat — the marker attribute makes it
4282
+ * a no-op once a given rendering has been adopted.
4283
+ */
4284
+ private _adoptTranslationPreview() {
4285
+ const preview = this.querySelector<HTMLElement>(
4286
+ ".compose-translation-preview:not([data-preview-adopted])",
4287
+ );
4288
+ if (!preview) return;
4289
+ preview.dataset.previewAdopted = "";
4290
+
4291
+ for (const link of preview.querySelectorAll("a[href]")) {
4292
+ link.setAttribute("target", "_blank");
4293
+ link.setAttribute("rel", "noopener noreferrer");
4294
+ }
4295
+
4296
+ for (const el of preview.querySelectorAll<HTMLElement>("[data-post-id]")) {
4297
+ delete el.dataset.postId;
4298
+ delete el.dataset.post;
4299
+ delete el.dataset.threadRootId;
4300
+ delete el.dataset.postMenuTarget;
4301
+ el.classList.remove("post-menu-target");
4302
+ }
4303
+ for (const trigger of preview.querySelectorAll(
4304
+ "[data-post-menu-trigger], [data-reply-trigger], [data-timeline-item]",
4305
+ )) {
4306
+ trigger.remove();
4307
+ }
4308
+ }
4309
+
4310
+ /** Points at what comes next: the version being written, below. */
4311
+ private _iconArrowDown() {
4312
+ return html`<svg
4313
+ class="compose-translation-seam-icon"
4314
+ xmlns="http://www.w3.org/2000/svg"
4315
+ viewBox="0 0 24 24"
4316
+ fill="none"
4317
+ stroke="currentColor"
4318
+ stroke-width="1.9"
4319
+ stroke-linecap="round"
4320
+ stroke-linejoin="round"
4321
+ aria-hidden="true"
4322
+ >
4323
+ <path d="M12 5v14" />
4324
+ <path d="m19 12-7 7-7-7" />
4325
+ </svg>`;
4326
+ }
4327
+
3903
4328
  private _renderReplyContext() {
3904
4329
  if (!this._replyToId || !this._replyToData) return nothing;
3905
4330
 
@@ -3917,42 +4342,44 @@ export class JantComposeDialog extends LitElement {
3917
4342
  >
3918
4343
  <div class="compose-reply-context-body">
3919
4344
  ${unsafeHTML(contentHtml)}
3920
- ${media?.length
3921
- ? html`<div
3922
- class="compose-reply-context-media"
3923
- data-post-media
3924
- data-lightbox-group=${JSON.stringify(
3925
- media.map((m) => ({
3926
- url: m.url,
3927
- alt: m.alt ?? "",
3928
- width: m.width,
3929
- height: m.height,
3930
- mimeType: m.mimeType,
3931
- })),
3932
- )}
3933
- >
3934
- ${media.map(
3935
- (m, i) => html`
3936
- <a
3937
- href=${m.url}
3938
- data-lightbox-index=${i}
3939
- class="compose-reply-context-media-link"
3940
- >
3941
- <img
3942
- src=${m.previewUrl}
3943
- alt=${m.alt ?? ""}
3944
- class="compose-reply-context-media-img"
3945
- loading="lazy"
3946
- />
3947
- </a>
3948
- `,
3949
- )}
3950
- </div>`
3951
- : nothing}
4345
+ ${
4346
+ media?.length
4347
+ ? html`<div
4348
+ class="compose-reply-context-media"
4349
+ data-post-media
4350
+ data-lightbox-group=${JSON.stringify(
4351
+ media.map((m) => ({
4352
+ url: m.url,
4353
+ alt: m.alt ?? "",
4354
+ width: m.width,
4355
+ height: m.height,
4356
+ mimeType: m.mimeType,
4357
+ })),
4358
+ )}
4359
+ >
4360
+ ${media.map(
4361
+ (m, i) => html`
4362
+ <a
4363
+ href=${m.url}
4364
+ data-lightbox-index=${i}
4365
+ class="compose-reply-context-media-link"
4366
+ >
4367
+ <img
4368
+ src=${m.previewUrl}
4369
+ alt=${m.alt ?? ""}
4370
+ class="compose-reply-context-media-img"
4371
+ loading="lazy"
4372
+ />
4373
+ </a>
4374
+ `,
4375
+ )}
4376
+ </div>`
4377
+ : nothing
4378
+ }
3952
4379
  </div>
3953
- ${!isExpanded
3954
- ? html`<div class="compose-reply-fade"></div>`
3955
- : nothing}
4380
+ ${
4381
+ !isExpanded ? html`<div class="compose-reply-fade"></div>` : nothing
4382
+ }
3956
4383
  </div>
3957
4384
  </div>
3958
4385
  <div class="compose-reply-meta">
@@ -3982,23 +4409,30 @@ export class JantComposeDialog extends LitElement {
3982
4409
  return !!(this._editPostId || this._draftSourceId);
3983
4410
  }
3984
4411
 
4412
+ /**
4413
+ * Fold fields the target format can't hold into the body before the format
4414
+ * change recreates the editor from `_bodyJson`. Only a post that already
4415
+ * exists on the server needs it — anything else keeps hidden fields in the
4416
+ * editor, so switching back finds them — and every post of a saved thread
4417
+ * draft is one. Synchronous, so the old Tiptap instance can't fire onUpdate
4418
+ * and clobber what we just wrote. `applyConvertedFields` suppresses the one
4419
+ * content-change event the conversion emits, so the switch itself never
4420
+ * schedules a draft save.
4421
+ */
4422
+ private _convertForFormatSwitch(
4423
+ editor: JantComposeEditor | null,
4424
+ from: ComposeFormat,
4425
+ to: ComposeFormat,
4426
+ ) {
4427
+ if (!editor || from === to || !this._shouldConvertOnFormatSwitch()) return;
4428
+ editor.applyConvertedFields(
4429
+ convertComposeFormat(from, to, editor.getConvertibleFields()),
4430
+ );
4431
+ }
4432
+
3985
4433
  private _switchFormat(target: ComposeFormat) {
3986
4434
  if (this._format === target) return;
3987
- const editor = this._editor;
3988
- if (editor && this._shouldConvertOnFormatSwitch()) {
3989
- // Fold fields the target can't hold into the body before the format
3990
- // change recreates the editor from `_bodyJson`. Synchronous, so the old
3991
- // Tiptap instance can't fire onUpdate and clobber what we just wrote.
3992
- // `applyConvertedFields` suppresses the one content-change event the
3993
- // conversion emits, so the switch itself never schedules a draft save.
3994
- editor.applyConvertedFields(
3995
- convertComposeFormat(
3996
- this._format,
3997
- target,
3998
- editor.getConvertibleFields(),
3999
- ),
4000
- );
4001
- }
4435
+ this._convertForFormatSwitch(this._editor, this._format, target);
4002
4436
  // A bare format switch shouldn't persist a local draft, so drop any save
4003
4437
  // already pending from loading the post.
4004
4438
  this._cancelDraftSaveTimer();
@@ -4028,8 +4462,8 @@ export class JantComposeDialog extends LitElement {
4028
4462
  <button
4029
4463
  type="button"
4030
4464
  class="compose-close-btn"
4031
- aria-label=${this.labels.cancel}
4032
- title=${this.labels.cancel}
4465
+ aria-label=${this.labels.close}
4466
+ title=${this.labels.close}
4033
4467
  @click=${() => this.requestClose()}
4034
4468
  >
4035
4469
  <svg
@@ -4070,12 +4504,14 @@ export class JantComposeDialog extends LitElement {
4070
4504
 
4071
4505
  return html`
4072
4506
  <div class="flex-1 min-w-0">
4073
- ${this._showCollection
4074
- ? html`<div
4075
- class="compose-dropdown-backdrop"
4076
- @click=${() => this._closeCollectionPicker()}
4077
- ></div>`
4078
- : nothing}
4507
+ ${
4508
+ this._showCollection
4509
+ ? html`<div
4510
+ class="compose-dropdown-backdrop"
4511
+ @click=${() => this._closeCollectionPicker()}
4512
+ ></div>`
4513
+ : nothing
4514
+ }
4079
4515
  <div
4080
4516
  class="select compose-collection-select"
4081
4517
  data-select-initialized
@@ -4092,6 +4528,7 @@ export class JantComposeDialog extends LitElement {
4092
4528
  @click=${() => {
4093
4529
  const nextOpen = !this._showCollection;
4094
4530
  this._showPublishPanel = false;
4531
+ this._showLanguagePicker = false;
4095
4532
  if (nextOpen) {
4096
4533
  this._prepareCollectionPickerOrder();
4097
4534
  }
@@ -4114,100 +4551,106 @@ export class JantComposeDialog extends LitElement {
4114
4551
  data-popover
4115
4552
  aria-hidden=${this._showCollection ? "false" : "true"}
4116
4553
  >
4117
- ${collections.length > 0
4118
- ? html`<div class="compose-collection-popover-header">
4119
- <label class="compose-collection-search-shell">
4120
- ${renderComposeCollectionPickerIcon(
4121
- COMPOSE_COLLECTION_PICKER_ICONS.search,
4122
- "compose-collection-search-icon",
4123
- )}
4124
- <input
4125
- type="text"
4126
- role="combobox"
4127
- class="compose-collection-search-input"
4128
- placeholder=${this.labels.searchCollections}
4129
- autocomplete="off"
4130
- autocorrect="off"
4131
- spellcheck="false"
4132
- .value=${this._collectionSearch}
4133
- @keydown=${this._handleCollectionSearchKeydown}
4134
- @input=${(e: Event) => {
4135
- this._collectionSearch = (
4136
- e.target as HTMLInputElement
4137
- ).value;
4138
- }}
4139
- />
4140
- </label>
4141
- </div>`
4142
- : nothing}
4554
+ ${
4555
+ collections.length > 0
4556
+ ? html`<div class="compose-collection-popover-header">
4557
+ <label class="compose-collection-search-shell">
4558
+ ${renderComposeCollectionPickerIcon(
4559
+ COMPOSE_COLLECTION_PICKER_ICONS.search,
4560
+ "compose-collection-search-icon",
4561
+ )}
4562
+ <input
4563
+ type="text"
4564
+ role="combobox"
4565
+ class="compose-collection-search-input"
4566
+ placeholder=${this.labels.searchCollections}
4567
+ autocomplete="off"
4568
+ autocorrect="off"
4569
+ spellcheck="false"
4570
+ .value=${this._collectionSearch}
4571
+ @keydown=${this._handleCollectionSearchKeydown}
4572
+ @input=${(e: Event) => {
4573
+ this._collectionSearch = (
4574
+ e.target as HTMLInputElement
4575
+ ).value;
4576
+ }}
4577
+ />
4578
+ </label>
4579
+ </div>`
4580
+ : nothing
4581
+ }
4143
4582
  <div
4144
4583
  role="listbox"
4145
4584
  class="compose-collection-options"
4146
4585
  aria-multiselectable="true"
4147
4586
  >
4148
- ${filtered.length > 0
4149
- ? filtered.map((col) => {
4150
- const selected = this._collectionIds.includes(col.id);
4151
-
4152
- return html`
4153
- <button
4154
- type="button"
4155
- class=${classMap({
4156
- "compose-collection-option": true,
4157
- "compose-collection-option-selected": selected,
4158
- })}
4159
- role="option"
4160
- data-value=${col.id}
4161
- aria-selected=${selected ? "true" : "false"}
4162
- @keydown=${(event: globalThis.KeyboardEvent) =>
4163
- this._handleCollectionOptionKeydown(event, col.id)}
4164
- @click=${() =>
4165
- this._handleCollectionOptionClick(col.id)}
4166
- >
4167
- <span class="compose-collection-option-label"
4168
- >${col.title}</span
4169
- >
4170
- <span
4587
+ ${
4588
+ filtered.length > 0
4589
+ ? filtered.map((col) => {
4590
+ const selected = this._collectionIds.includes(col.id);
4591
+
4592
+ return html`
4593
+ <button
4594
+ type="button"
4171
4595
  class=${classMap({
4172
- "compose-collection-option-marker": true,
4173
- "compose-collection-option-marker-selected":
4174
- selected,
4175
- "compose-collection-option-marker-add": !selected,
4596
+ "compose-collection-option": true,
4597
+ "compose-collection-option-selected": selected,
4176
4598
  })}
4599
+ role="option"
4600
+ data-value=${col.id}
4601
+ aria-selected=${selected ? "true" : "false"}
4602
+ @keydown=${(event: globalThis.KeyboardEvent) =>
4603
+ this._handleCollectionOptionKeydown(event, col.id)}
4604
+ @click=${() =>
4605
+ this._handleCollectionOptionClick(col.id)}
4177
4606
  >
4178
- ${selected
4179
- ? html`<svg
4180
- xmlns="http://www.w3.org/2000/svg"
4181
- class="compose-collection-option-check-circle"
4182
- viewBox="0 0 24 24"
4183
- fill="none"
4184
- aria-hidden="true"
4185
- >
4186
- <circle
4187
- cx="12"
4188
- cy="12"
4189
- r="10"
4190
- fill="currentColor"
4191
- />
4192
- <path
4193
- d="M8 12.5 10.7 15.2 16.4 9.5"
4194
- stroke="var(--site-page-bg)"
4195
- stroke-width="2.3"
4196
- stroke-linecap="round"
4197
- stroke-linejoin="round"
4198
- />
4199
- </svg>`
4200
- : renderComposeCollectionPickerIcon(
4201
- COMPOSE_COLLECTION_PICKER_ICONS.plusCircle,
4202
- "compose-collection-option-plus-circle",
4203
- )}
4204
- </span>
4205
- </button>
4206
- `;
4207
- })
4208
- : html`<div class="compose-collection-empty">
4209
- ${emptyLabel}
4210
- </div>`}
4607
+ <span class="compose-collection-option-label"
4608
+ >${col.title}</span
4609
+ >
4610
+ <span
4611
+ class=${classMap({
4612
+ "compose-collection-option-marker": true,
4613
+ "compose-collection-option-marker-selected":
4614
+ selected,
4615
+ "compose-collection-option-marker-add": !selected,
4616
+ })}
4617
+ >
4618
+ ${
4619
+ selected
4620
+ ? html`<svg
4621
+ xmlns="http://www.w3.org/2000/svg"
4622
+ class="compose-collection-option-check-circle"
4623
+ viewBox="0 0 24 24"
4624
+ fill="none"
4625
+ aria-hidden="true"
4626
+ >
4627
+ <circle
4628
+ cx="12"
4629
+ cy="12"
4630
+ r="10"
4631
+ fill="currentColor"
4632
+ />
4633
+ <path
4634
+ d="M8 12.5 10.7 15.2 16.4 9.5"
4635
+ stroke="var(--site-page-bg)"
4636
+ stroke-width="2.3"
4637
+ stroke-linecap="round"
4638
+ stroke-linejoin="round"
4639
+ />
4640
+ </svg>`
4641
+ : renderComposeCollectionPickerIcon(
4642
+ COMPOSE_COLLECTION_PICKER_ICONS.plusCircle,
4643
+ "compose-collection-option-plus-circle",
4644
+ )
4645
+ }
4646
+ </span>
4647
+ </button>
4648
+ `;
4649
+ })
4650
+ : html`<div class="compose-collection-empty">
4651
+ ${emptyLabel}
4652
+ </div>`
4653
+ }
4211
4654
  </div>
4212
4655
  <div class="compose-collection-footer">
4213
4656
  <button
@@ -4251,8 +4694,7 @@ export class JantComposeDialog extends LitElement {
4251
4694
  if (!detail) return;
4252
4695
 
4253
4696
  const formEl = this.querySelector("jant-collection-form") as
4254
- | (HTMLElement & { loading: boolean })
4255
- | null;
4697
+ (HTMLElement & { loading: boolean }) | null;
4256
4698
  if (formEl) formEl.loading = true;
4257
4699
 
4258
4700
  try {
@@ -4314,13 +4756,7 @@ export class JantComposeDialog extends LitElement {
4314
4756
  private _renderAddCollectionPanel() {
4315
4757
  if (!this._addCollectionPanelOpen) return nothing;
4316
4758
 
4317
- const initial = {
4318
- title: "",
4319
- slug: "",
4320
- description: "",
4321
- sortOrder: "newest",
4322
- icon: "",
4323
- };
4759
+ const initial = { title: "", slug: "" };
4324
4760
 
4325
4761
  return html`
4326
4762
  <div
@@ -4354,11 +4790,9 @@ export class JantComposeDialog extends LitElement {
4354
4790
  </div>
4355
4791
  <div class="collection-quick-dialog-body">
4356
4792
  <jant-collection-form
4357
- variant="quick"
4358
4793
  .labels=${this.labels.collectionFormLabels}
4359
4794
  .initial=${initial}
4360
4795
  action=${publicPath("/api/collections")}
4361
- cancel-href="javascript:void(0)"
4362
4796
  @jant:collection-submit=${(e: Event) =>
4363
4797
  this._handleAddCollectionSubmit(e)}
4364
4798
  ></jant-collection-form>
@@ -4441,22 +4875,24 @@ export class JantComposeDialog extends LitElement {
4441
4875
  <span class="compose-alt-title">${this.labels.addAltTitle}</span>
4442
4876
  </div>
4443
4877
  <div class="compose-alt-preview">
4444
- ${category === "image"
4445
- ? html`<img
4446
- src=${attachment.previewUrl}
4447
- alt=""
4448
- class="compose-alt-preview-img"
4449
- />`
4450
- : category === "video"
4451
- ? html`<video
4878
+ ${
4879
+ category === "image"
4880
+ ? html`<img
4452
4881
  src=${attachment.previewUrl}
4882
+ alt=""
4453
4883
  class="compose-alt-preview-img"
4454
- preload="metadata"
4455
- muted
4456
- ></video>`
4457
- : html`<span class="text-sm text-muted-foreground"
4458
- >${attachment.file.name}</span
4459
- >`}
4884
+ />`
4885
+ : category === "video"
4886
+ ? html`<video
4887
+ src=${attachment.previewUrl}
4888
+ class="compose-alt-preview-img"
4889
+ preload="metadata"
4890
+ muted
4891
+ ></video>`
4892
+ : html`<span class="text-sm text-muted-foreground"
4893
+ >${attachment.file.name}</span
4894
+ >`
4895
+ }
4460
4896
  </div>
4461
4897
  <div class="compose-alt-input-row">
4462
4898
  <input
@@ -4734,6 +5170,7 @@ export class JantComposeDialog extends LitElement {
4734
5170
  this._showCollection = false;
4735
5171
  this._collectionSearch = "";
4736
5172
  this._showPublishPanel = false;
5173
+ this._showLanguagePicker = false;
4737
5174
  this._postMetaIndex = index;
4738
5175
  this._confirmPanelOpen = false;
4739
5176
  this._scheduleSuggestedSlugRefresh(true);
@@ -4777,6 +5214,7 @@ export class JantComposeDialog extends LitElement {
4777
5214
  private _togglePublishPanel() {
4778
5215
  this._showCollection = false;
4779
5216
  this._collectionSearch = "";
5217
+ this._showLanguagePicker = false;
4780
5218
  const nextOpen = !this._showPublishPanel;
4781
5219
  this._showPublishPanel = nextOpen;
4782
5220
  if (nextOpen) {
@@ -4878,18 +5316,392 @@ export class JantComposeDialog extends LitElement {
4878
5316
  <span class="compose-sheet-title">${label}</span>
4879
5317
  <span class="compose-sheet-sub">${hint}</span>
4880
5318
  </span>
4881
- ${selected
4882
- ? html`<span class="compose-sheet-check" aria-hidden="true">
4883
- ${renderComposePublishActionIcon(
4884
- COMPOSE_PUBLISH_ACTION_ICONS.check,
4885
- "compose-sheet-check-icon",
4886
- )}
4887
- </span>`
4888
- : nothing}
5319
+ ${
5320
+ selected
5321
+ ? html`<span class="compose-sheet-check" aria-hidden="true">
5322
+ ${renderComposePublishActionIcon(
5323
+ COMPOSE_PUBLISH_ACTION_ICONS.check,
5324
+ "compose-sheet-check-icon",
5325
+ )}
5326
+ </span>`
5327
+ : nothing
5328
+ }
5329
+ </button>
5330
+ `;
5331
+ }
5332
+
5333
+ /**
5334
+ * Load the post a translation is being written for.
5335
+ *
5336
+ * Only the ID is carried around — through the URL that opens the composer,
5337
+ * and through a local draft picked up later — so everything shown above the
5338
+ * editor is fetched when it is needed. A post that has since been deleted
5339
+ * simply drops the link rather than blocking the composer.
5340
+ *
5341
+ * @param postId - Thread root the new post translates
5342
+ * @param options.seed - Also take the original's shape: its format, and the
5343
+ * citation fields that name a source rather than say anything in a
5344
+ * language. Only for a composer opened fresh on this translation; a
5345
+ * restored draft already carries the author's own answers.
5346
+ */
5347
+ private async _loadTranslationSource(
5348
+ postId: string,
5349
+ options: { seed?: boolean } = {},
5350
+ ): Promise<void> {
5351
+ try {
5352
+ const response = await fetch(publicPath(`/api/posts/${postId}`), {
5353
+ credentials: "same-origin",
5354
+ });
5355
+ if (!response.ok) return;
5356
+ const post = (await response.json()) as ComposePostResponse & {
5357
+ displayTitle?: string;
5358
+ };
5359
+ // `displayTitle` is derived server-side for untitled notes; a slug is a
5360
+ // URL, not a name, and only stands in when there is nothing else.
5361
+ this._translationOf = {
5362
+ id: post.id,
5363
+ title: post.displayTitle || post.slug || "",
5364
+ href: post.slug ? publicPath(`/${post.slug}`) : "",
5365
+ previewHtml: await this._fetchTranslationPreview(post.id),
5366
+ };
5367
+
5368
+ if (options.seed) await this._seedFromTranslationSource(post);
5369
+ } catch {
5370
+ // Offline or the post is gone. The composer still works; the author is
5371
+ // writing a post, just not a linked one.
5372
+ }
5373
+ }
5374
+
5375
+ /**
5376
+ * The original rendered as it renders anywhere else on the site.
5377
+ *
5378
+ * Server-side rather than rebuilt here: a Quote's attribution and a Link's
5379
+ * card are not `bodyHtml`, and reproducing each format's markup in the
5380
+ * composer would be a second renderer to keep in step with the first.
5381
+ *
5382
+ * @param postId - Thread root the new post translates
5383
+ * @returns The post's markup, or "" when it cannot be fetched
5384
+ */
5385
+ private async _fetchTranslationPreview(postId: string): Promise<string> {
5386
+ try {
5387
+ const response = await fetch(
5388
+ publicPath(`/_/post-preview/${encodeURIComponent(postId)}`),
5389
+ { headers: { Accept: "text/html" }, credentials: "same-origin" },
5390
+ );
5391
+ if (!response.ok) return "";
5392
+ return await response.text();
5393
+ } catch {
5394
+ return "";
5395
+ }
5396
+ }
5397
+
5398
+ /**
5399
+ * Start the translation in the shape of its original.
5400
+ *
5401
+ * A quote stays a quote — the format is a property of what was said, not of
5402
+ * the language it was said in — and the citation travels with it: the URL a
5403
+ * quote or a link points at is the same source whichever language describes
5404
+ * it. Collections, visibility and rating come along for the same reason:
5405
+ * they describe the post's place and its subject, neither of which changes
5406
+ * when the words do. The prose is the part the author is here to write, so
5407
+ * that is all that starts empty.
5408
+ *
5409
+ * Skipped the moment the author has typed anything: the fetch runs after the
5410
+ * composer opens, and overwriting their first sentence to save them a format
5411
+ * click is a bad trade.
5412
+ */
5413
+ private async _seedFromTranslationSource(
5414
+ post: ComposePostResponse,
5415
+ ): Promise<void> {
5416
+ if (this._hasContent() || this._editPostId || this._draftSourceId) return;
5417
+
5418
+ const format = post.format;
5419
+ this._format = format;
5420
+ if (post.collectionIds?.length) {
5421
+ this._collectionIds = [...post.collectionIds];
5422
+ }
5423
+ if (post.visibility) this._visibility = post.visibility;
5424
+ await this.updateComplete;
5425
+
5426
+ this._editor?.populate({
5427
+ format,
5428
+ url:
5429
+ format === "quote"
5430
+ ? (post.sourceUrl ?? undefined)
5431
+ : (post.url ?? undefined),
5432
+ quoteAuthor:
5433
+ format === "quote" ? (post.sourceName ?? undefined) : undefined,
5434
+ rating: post.rating ?? undefined,
5435
+ });
5436
+ await this.updateComplete;
5437
+ // The author has still typed nothing, so none of this counts as a change
5438
+ // they would be asked to discard on close.
5439
+ this._seededFromSource = true;
5440
+ this._captureInitialSnapshot();
5441
+ }
5442
+
5443
+ /**
5444
+ * Plain text of the post being written, for the language suggestion.
5445
+ *
5446
+ * Read on demand rather than tracked: the panel is something the author
5447
+ * opens deliberately, so reading the editor at that moment is both cheaper
5448
+ * than watching every keystroke and more accurate than a cached value.
5449
+ */
5450
+ private _composeTextForDetection(): string {
5451
+ const editor = this._editor;
5452
+ if (!editor) return "";
5453
+ const data = editor.getData();
5454
+ const bodyText = data.body ? (extractBodyText(data.body) ?? "") : "";
5455
+ return [data.title, data.quoteText, bodyText].filter(Boolean).join(" ");
5456
+ }
5457
+
5458
+ /**
5459
+ * The language of the page the composer opened from, as the automatic
5460
+ * choice's default. Falls back to the primary language when the page's
5461
+ * language is unknown or not one the site publishes.
5462
+ */
5463
+ private _contextLanguageTag(): string | null {
5464
+ const tags = this.languages.map((language) => language.tag);
5465
+ if (tags.length === 0) return null;
5466
+ return tags.includes(this.contextLanguage)
5467
+ ? this.contextLanguage
5468
+ : (tags[0] as string);
5469
+ }
5470
+
5471
+ /**
5472
+ * What detection actually reads out of the text as it stands, or null when
5473
+ * the text does not say yet.
5474
+ *
5475
+ * Null is a distinct answer from "the page's language", and the "Detect" row
5476
+ * has to tell them apart: a row offering to read what you write cannot claim
5477
+ * to have read something out of two words. It also ignores an explicit
5478
+ * choice — the row describes what picking it would do, not what the author
5479
+ * picked instead.
5480
+ */
5481
+ private _readLanguage(): string | null {
5482
+ return readContentLanguage(this._composeTextForDetection(), {
5483
+ languages: this.languages.map((language) => language.tag),
5484
+ });
5485
+ }
5486
+
5487
+ /** The language this post would be saved with if submitted right now. */
5488
+ private _effectiveLanguage(): string | null {
5489
+ return this._language ?? this._readLanguage() ?? this._contextLanguageTag();
5490
+ }
5491
+
5492
+ private _renderLanguageRow(tag: string | null, label: string, hint: string) {
5493
+ const selected = this._language === tag;
5494
+
5495
+ return html`
5496
+ <button
5497
+ type="button"
5498
+ class=${classMap({
5499
+ "compose-sheet-row": true,
5500
+ "compose-sheet-row-selected": selected,
5501
+ })}
5502
+ role="radio"
5503
+ aria-checked=${selected ? "true" : "false"}
5504
+ lang=${ifDefined(tag ?? undefined)}
5505
+ data-compose-language-row
5506
+ @click=${() => {
5507
+ this._language = tag;
5508
+ this._scheduleDraftSave();
5509
+ // Choosing is the whole job of this list, so it closes behind the
5510
+ // choice — the same as picking a visibility in the options sheet.
5511
+ this._closeLanguagePicker(true);
5512
+ }}
5513
+ >
5514
+ <span class="compose-sheet-main">
5515
+ <span class="compose-sheet-title">${label}</span>
5516
+ ${
5517
+ hint
5518
+ ? html`<span class="compose-sheet-sub">${hint}</span>`
5519
+ : nothing
5520
+ }
5521
+ </span>
5522
+ ${
5523
+ selected
5524
+ ? html`<span class="compose-sheet-check" aria-hidden="true">
5525
+ ${renderComposePublishActionIcon(
5526
+ COMPOSE_PUBLISH_ACTION_ICONS.check,
5527
+ "compose-sheet-check-icon",
5528
+ )}
5529
+ </span>`
5530
+ : nothing
5531
+ }
4889
5532
  </button>
4890
5533
  `;
4891
5534
  }
4892
5535
 
5536
+ private _closeLanguagePicker(restoreFocus = false) {
5537
+ if (!this._showLanguagePicker) return;
5538
+ this._showLanguagePicker = false;
5539
+ if (!restoreFocus) return;
5540
+ this.updateComplete.then(() => {
5541
+ this.querySelector<HTMLElement>(
5542
+ "[data-compose-language-trigger]",
5543
+ )?.focus();
5544
+ });
5545
+ }
5546
+
5547
+ private _toggleLanguagePicker() {
5548
+ const nextOpen = !this._showLanguagePicker;
5549
+ if (!nextOpen) {
5550
+ this._closeLanguagePicker(true);
5551
+ return;
5552
+ }
5553
+ this._showCollection = false;
5554
+ this._collectionSearch = "";
5555
+ this._showPublishPanel = false;
5556
+ this._postMetaIndex = null;
5557
+ this._showLanguagePicker = true;
5558
+ this.updateComplete.then(() => {
5559
+ const rows = this.querySelectorAll<HTMLElement>(
5560
+ "[data-compose-language-row]",
5561
+ );
5562
+ const selected = Array.from(rows).find(
5563
+ (row) => row.getAttribute("aria-checked") === "true",
5564
+ );
5565
+ (selected ?? rows[0])?.focus();
5566
+ });
5567
+ }
5568
+
5569
+ /**
5570
+ * The post's content language, offered only once the site publishes more
5571
+ * than one. A single-language author never meets this control — that is the
5572
+ * point of the whole feature being opt-in.
5573
+ *
5574
+ * It sits next to Post rather than inside the options sheet because it is
5575
+ * not a setting so much as a statement about what is being written: the pill
5576
+ * names the language this would publish in right now, so the answer is
5577
+ * visible before the button that makes it public, not two clicks behind it.
5578
+ *
5579
+ * It only *names* it when there is something to say, though. Writing from
5580
+ * /ja in Japanese, the globe alone is the whole message — the answer is the
5581
+ * page you are standing on. The name appears when the answer stops being
5582
+ * obvious: detection has moved it somewhere else, or the author has pinned
5583
+ * it themselves. So a language that changes under you announces itself by
5584
+ * growing a word, and one that never changes never speaks.
5585
+ *
5586
+ * Replies are excluded: a Thread is written in one language, and a reply
5587
+ * takes the root's server-side.
5588
+ */
5589
+ private _renderLanguageControl() {
5590
+ if (this._replyToId || this.languages.length < 2) return nothing;
5591
+
5592
+ const effective = this._effectiveLanguage();
5593
+ const effectiveLabel =
5594
+ this.languages.find((language) => language.tag === effective)?.label ??
5595
+ "";
5596
+ const chosen = this._language !== null;
5597
+ const named = chosen || effective !== this._contextLanguageTag();
5598
+ const open = this._showLanguagePicker;
5599
+ const accessibleLabel = this.labels.languageTriggerLabel.replace(
5600
+ "{language}",
5601
+ effectiveLabel,
5602
+ );
5603
+
5604
+ return html`
5605
+ <div
5606
+ class=${classMap({
5607
+ "compose-language": true,
5608
+ "compose-language-open": open,
5609
+ })}
5610
+ >
5611
+ ${
5612
+ open
5613
+ ? html`<div
5614
+ class="compose-dropdown-backdrop"
5615
+ @click=${() => this._closeLanguagePicker(true)}
5616
+ ></div>`
5617
+ : nothing
5618
+ }
5619
+ <button
5620
+ type="button"
5621
+ class=${classMap({
5622
+ "compose-language-trigger": true,
5623
+ "compose-language-trigger-bare": !named,
5624
+ })}
5625
+ data-compose-language-trigger
5626
+ data-open=${open ? "true" : nothing}
5627
+ data-chosen=${chosen ? "true" : nothing}
5628
+ ?disabled=${this._loading}
5629
+ aria-haspopup="dialog"
5630
+ aria-expanded=${open ? "true" : "false"}
5631
+ aria-label=${accessibleLabel}
5632
+ title=${accessibleLabel}
5633
+ @click=${() => this._toggleLanguagePicker()}
5634
+ >
5635
+ <svg
5636
+ class="compose-language-trigger-svg"
5637
+ width="24"
5638
+ height="24"
5639
+ viewBox="0 0 24 24"
5640
+ fill="none"
5641
+ stroke="currentColor"
5642
+ stroke-width="1.9"
5643
+ stroke-linecap="round"
5644
+ stroke-linejoin="round"
5645
+ aria-hidden="true"
5646
+ >
5647
+ ${unsafeSVG(COMPOSE_LANGUAGE_ICON)}
5648
+ </svg>
5649
+ ${
5650
+ named
5651
+ ? html`<span
5652
+ class="compose-language-label"
5653
+ lang=${ifDefined(effective ?? undefined)}
5654
+ >${effectiveLabel}</span
5655
+ >`
5656
+ : nothing
5657
+ }
5658
+ </button>
5659
+ ${open ? this._renderLanguagePicker() : nothing}
5660
+ </div>
5661
+ `;
5662
+ }
5663
+
5664
+ private _renderLanguagePicker() {
5665
+ const labelOf = (tag: string | null) =>
5666
+ this.languages.find((language) => language.tag === tag)?.label ?? "";
5667
+ // Two states, and the row has to be straight about which one it is in: it
5668
+ // has read a language, or it has not read one yet and the page's language
5669
+ // is standing in until it does.
5670
+ const read = this._readLanguage();
5671
+ const pendingLabel = labelOf(this._contextLanguageTag());
5672
+ const autoHint = read
5673
+ ? this.labels.languageAutoDetected.replace("{language}", labelOf(read))
5674
+ : pendingLabel
5675
+ ? this.labels.languageAutoPending.replace("{language}", pendingLabel)
5676
+ : this.labels.languageAutoHint;
5677
+
5678
+ return html`
5679
+ <div
5680
+ class="compose-language-popover"
5681
+ role="dialog"
5682
+ aria-label=${this.labels.languageLabel}
5683
+ >
5684
+ <p class="compose-sheet-label">${this.labels.languageLabel}</p>
5685
+ ${
5686
+ this._translationOf
5687
+ ? html`<p class="compose-sheet-note">
5688
+ ${this.labels.translationOf.replace(
5689
+ "{title}",
5690
+ this._translationOf.title,
5691
+ )}
5692
+ </p>`
5693
+ : nothing
5694
+ }
5695
+ <div role="radiogroup" aria-label=${this.labels.languageLabel}>
5696
+ ${this._renderLanguageRow(null, this.labels.languageAuto, autoHint)}
5697
+ ${this.languages.map((language) =>
5698
+ this._renderLanguageRow(language.tag, language.label, ""),
5699
+ )}
5700
+ </div>
5701
+ </div>
5702
+ `;
5703
+ }
5704
+
4893
5705
  /**
4894
5706
  * The label above a field, with its reset on the right. The reset only shows
4895
5707
  * once there is something to reset, so the default state is a bare label.
@@ -4982,17 +5794,19 @@ export class JantComposeDialog extends LitElement {
4982
5794
  )}
4983
5795
  </button>
4984
5796
  </div>
4985
- ${publishedAtError
4986
- ? html`<p
4987
- class="compose-publish-date-status compose-publish-date-status-error"
4988
- >
4989
- ${publishedAtError}
4990
- </p>`
4991
- : hasValue
4992
- ? nothing
4993
- : html`<p class="compose-sheet-hint">
4994
- ${this.labels.publishDateHint}
4995
- </p>`}
5797
+ ${
5798
+ publishedAtError
5799
+ ? html`<p
5800
+ class="compose-publish-date-status compose-publish-date-status-error"
5801
+ >
5802
+ ${publishedAtError}
5803
+ </p>`
5804
+ : hasValue
5805
+ ? nothing
5806
+ : html`<p class="compose-sheet-hint">
5807
+ ${this.labels.publishDateHint}
5808
+ </p>`
5809
+ }
4996
5810
  </div>
4997
5811
  `;
4998
5812
  }
@@ -5038,64 +5852,68 @@ export class JantComposeDialog extends LitElement {
5038
5852
  @input=${(e: Event) => this._onSlugInput(e, index)}
5039
5853
  />
5040
5854
  </div>
5041
- ${showSuggestion
5042
- ? html`
5043
- <button
5044
- type="button"
5045
- class="compose-slug-suggestion"
5046
- @click=${() => this._useSuggestedSlug()}
5047
- >
5048
- <span class="compose-slug-suggestion-label"
5049
- >${this.labels.publishSlugSuggested}</span
5050
- >
5051
- <span class="compose-slug-suggestion-value"
5052
- >/${this._suggestedSlug}</span
5855
+ ${
5856
+ showSuggestion
5857
+ ? html`
5858
+ <button
5859
+ type="button"
5860
+ class="compose-slug-suggestion"
5861
+ @click=${() => this._useSuggestedSlug()}
5053
5862
  >
5054
- <span class="compose-slug-suggestion-icon" aria-hidden="true">
5055
- <svg
5056
- width="13"
5057
- height="13"
5058
- viewBox="0 0 14 14"
5059
- fill="none"
5060
- stroke="currentColor"
5061
- stroke-width="1.4"
5062
- stroke-linecap="round"
5063
- stroke-linejoin="round"
5863
+ <span class="compose-slug-suggestion-label"
5864
+ >${this.labels.publishSlugSuggested}</span
5064
5865
  >
5065
- <path d="M3 7h8" />
5066
- <path d="m8 3 4 4-4 4" />
5067
- </svg>
5068
- </span>
5069
- </button>
5070
- `
5071
- : nothing}
5072
- ${slugStatus
5073
- ? html`<p
5074
- class=${classMap({
5075
- "compose-publish-slug-status": true,
5076
- "compose-publish-slug-status-error": Boolean(slugError),
5077
- })}
5078
- data-compose-slug-error=${slugError ? "true" : nothing}
5079
- >
5080
- ${slugStatus}
5081
- </p>`
5082
- : slugPreview
5866
+ <span class="compose-slug-suggestion-value"
5867
+ >/${this._suggestedSlug}</span
5868
+ >
5869
+ <span class="compose-slug-suggestion-icon" aria-hidden="true">
5870
+ <svg
5871
+ width="13"
5872
+ height="13"
5873
+ viewBox="0 0 14 14"
5874
+ fill="none"
5875
+ stroke="currentColor"
5876
+ stroke-width="1.4"
5877
+ stroke-linecap="round"
5878
+ stroke-linejoin="round"
5879
+ >
5880
+ <path d="M3 7h8" />
5881
+ <path d="m8 3 4 4-4 4" />
5882
+ </svg>
5883
+ </span>
5884
+ </button>
5885
+ `
5886
+ : nothing
5887
+ }
5888
+ ${
5889
+ slugStatus
5083
5890
  ? html`<p
5084
- class="compose-publish-slug-preview"
5085
- data-compose-slug-preview
5086
- title=${slugPreview.full}
5891
+ class=${classMap({
5892
+ "compose-publish-slug-status": true,
5893
+ "compose-publish-slug-status-error": Boolean(slugError),
5894
+ })}
5895
+ data-compose-slug-error=${slugError ? "true" : nothing}
5087
5896
  >
5088
- <span class="compose-publish-slug-preview-origin"
5089
- >${slugPreview.origin}</span
5090
- ><span class="compose-publish-slug-preview-path"
5091
- >${slugPreview.path}</span
5092
- >
5897
+ ${slugStatus}
5093
5898
  </p>`
5094
- : this._hasPostManualSlug(index)
5095
- ? nothing
5096
- : html`<p class="compose-sheet-hint">
5097
- ${this.labels.publishSlugHint}
5098
- </p>`}
5899
+ : slugPreview
5900
+ ? html`<p
5901
+ class="compose-publish-slug-preview"
5902
+ data-compose-slug-preview
5903
+ title=${slugPreview.full}
5904
+ >
5905
+ <span class="compose-publish-slug-preview-origin"
5906
+ >${slugPreview.origin}</span
5907
+ ><span class="compose-publish-slug-preview-path"
5908
+ >${slugPreview.path}</span
5909
+ >
5910
+ </p>`
5911
+ : this._hasPostManualSlug(index)
5912
+ ? nothing
5913
+ : html`<p class="compose-sheet-hint">
5914
+ ${this.labels.publishSlugHint}
5915
+ </p>`
5916
+ }
5099
5917
  </div>
5100
5918
  `;
5101
5919
  }
@@ -5295,12 +6113,14 @@ export class JantComposeDialog extends LitElement {
5295
6113
  "compose-post-meta-open": open,
5296
6114
  })}
5297
6115
  >
5298
- ${open
5299
- ? html`<div
5300
- class="compose-dropdown-backdrop"
5301
- @click=${() => this._closePostMeta()}
5302
- ></div>`
5303
- : nothing}
6116
+ ${
6117
+ open
6118
+ ? html`<div
6119
+ class="compose-dropdown-backdrop"
6120
+ @click=${() => this._closePostMeta()}
6121
+ ></div>`
6122
+ : nothing
6123
+ }
5304
6124
  <button
5305
6125
  type="button"
5306
6126
  class="compose-post-meta-pill"
@@ -5312,6 +6132,7 @@ export class JantComposeDialog extends LitElement {
5312
6132
  if (open) return this._closePostMeta();
5313
6133
  this._showPublishPanel = false;
5314
6134
  this._showCollection = false;
6135
+ this._showLanguagePicker = false;
5315
6136
  this._postMetaIndex = index;
5316
6137
  if (isRoot) this._scheduleSuggestedSlugRefresh(true);
5317
6138
  // Focus the panel, not its first field: a focused `type="date"`
@@ -5334,28 +6155,30 @@ export class JantComposeDialog extends LitElement {
5334
6155
  >${this._getPostSlugRowValue(index)}</span
5335
6156
  >
5336
6157
  </button>
5337
- ${open
5338
- ? html`<div
5339
- class="compose-post-meta-panel"
5340
- role="dialog"
5341
- aria-label=${this.labels.publishSettings}
5342
- tabindex="-1"
5343
- data-compose-post-meta-panel
5344
- >
5345
- ${this._renderPublishDateSection(index)}
5346
- ${this._renderPublishSlugSection(index)}
5347
- <div class="compose-post-meta-footer">
5348
- <button
5349
- type="button"
5350
- class="compose-publish-done"
5351
- data-compose-post-meta-done
5352
- @click=${() => this._closePostMeta()}
5353
- >
5354
- ${this.labels.done}
5355
- </button>
5356
- </div>
5357
- </div>`
5358
- : nothing}
6158
+ ${
6159
+ open
6160
+ ? html`<div
6161
+ class="compose-post-meta-panel"
6162
+ role="dialog"
6163
+ aria-label=${this.labels.publishSettings}
6164
+ tabindex="-1"
6165
+ data-compose-post-meta-panel
6166
+ >
6167
+ ${this._renderPublishDateSection(index)}
6168
+ ${this._renderPublishSlugSection(index)}
6169
+ <div class="compose-post-meta-footer">
6170
+ <button
6171
+ type="button"
6172
+ class="compose-publish-done"
6173
+ data-compose-post-meta-done
6174
+ @click=${() => this._closePostMeta()}
6175
+ >
6176
+ ${this.labels.done}
6177
+ </button>
6178
+ </div>
6179
+ </div>`
6180
+ : nothing
6181
+ }
5359
6182
  </div>
5360
6183
  `;
5361
6184
  }
@@ -5513,8 +6336,7 @@ export class JantComposeDialog extends LitElement {
5513
6336
  }
5514
6337
  return (
5515
6338
  this._renderSaveDraftRow() !== nothing ||
5516
- this._renderDraftsRow() !== nothing ||
5517
- this._renderCloseComposeRow() !== nothing
6339
+ this._renderDraftsRow() !== nothing
5518
6340
  );
5519
6341
  }
5520
6342
 
@@ -5529,46 +6351,50 @@ export class JantComposeDialog extends LitElement {
5529
6351
  ></div>`;
5530
6352
  const saveDraftRow = this._renderSaveDraftRow();
5531
6353
  const draftsRow = this._renderDraftsRow();
5532
- const closeRow = this._renderCloseComposeRow();
5533
- const hasSessionRows =
5534
- saveDraftRow !== nothing || draftsRow !== nothing || closeRow !== nothing;
6354
+ const hasSessionRows = saveDraftRow !== nothing || draftsRow !== nothing;
5535
6355
 
5536
6356
  return html`
5537
6357
  <div class="compose-sheet">
5538
- ${this._visibilityLocked
5539
- ? nothing
5540
- : html`
5541
- <p class="compose-sheet-label">
5542
- ${this.labels.publishVisibilityLabel}
5543
- </p>
5544
- <div
5545
- role="radiogroup"
5546
- aria-label=${this.labels.publishVisibilityLabel}
5547
- >
5548
- ${this._renderVisibilityRow(
5549
- "public",
5550
- this.labels.publishVisibilityPublic,
5551
- this.labels.publishVisibilityPublicHint,
5552
- )}
5553
- ${this._renderVisibilityRow(
5554
- "latest_hidden",
5555
- this.labels.publishVisibilityHiddenFromLatest,
5556
- this.labels.publishVisibilityHiddenFromLatestHint,
5557
- )}
5558
- ${this._renderVisibilityRow(
5559
- "private",
5560
- this.labels.publishVisibilityPrivate,
5561
- this.labels.publishVisibilityPrivateHint,
5562
- )}
5563
- </div>
5564
- `}
5565
- ${this._replyToId
5566
- ? html`${this._visibilityLocked ? nothing : divider}
5567
- ${this._renderQuietReplySection()}`
5568
- : nothing}
5569
- ${hasSessionRows
5570
- ? html`${divider} ${saveDraftRow} ${draftsRow} ${closeRow}`
5571
- : nothing}
6358
+ ${
6359
+ this._visibilityLocked
6360
+ ? nothing
6361
+ : html`
6362
+ <p class="compose-sheet-label">
6363
+ ${this.labels.publishVisibilityLabel}
6364
+ </p>
6365
+ <div
6366
+ role="radiogroup"
6367
+ aria-label=${this.labels.publishVisibilityLabel}
6368
+ >
6369
+ ${this._renderVisibilityRow(
6370
+ "public",
6371
+ this.labels.publishVisibilityPublic,
6372
+ this.labels.publishVisibilityPublicHint,
6373
+ )}
6374
+ ${this._renderVisibilityRow(
6375
+ "latest_hidden",
6376
+ this.labels.publishVisibilityHiddenFromLatest,
6377
+ this.labels.publishVisibilityHiddenFromLatestHint,
6378
+ )}
6379
+ ${this._renderVisibilityRow(
6380
+ "private",
6381
+ this.labels.publishVisibilityPrivate,
6382
+ this.labels.publishVisibilityPrivateHint,
6383
+ )}
6384
+ </div>
6385
+ `
6386
+ }
6387
+ ${
6388
+ this._replyToId
6389
+ ? html`${this._visibilityLocked ? nothing : divider}
6390
+ ${this._renderQuietReplySection()}`
6391
+ : nothing
6392
+ }
6393
+ ${
6394
+ hasSessionRows
6395
+ ? html`${divider} ${saveDraftRow} ${draftsRow}`
6396
+ : nothing
6397
+ }
5572
6398
  </div>
5573
6399
  `;
5574
6400
  }
@@ -5580,7 +6406,7 @@ export class JantComposeDialog extends LitElement {
5580
6406
  * there is something to save, both are offered and each does one thing.
5581
6407
  */
5582
6408
  private _renderSaveDraftRow() {
5583
- if (this._editPostId || !this._hasContent()) return nothing;
6409
+ if (this._editPostId || !this._hasWorkToLose()) return nothing;
5584
6410
 
5585
6411
  return html`
5586
6412
  <button
@@ -5635,31 +6461,41 @@ export class JantComposeDialog extends LitElement {
5635
6461
  }
5636
6462
 
5637
6463
  /**
5638
- * A thread's posts each spend their × on removing themselves, so this is the
5639
- * only visible exit there — and the only one at all on a phone, which has no
5640
- * Escape key and no backdrop to tap. It routes through `requestClose`, so
5641
- * unsaved work still gets the "Save to drafts?" prompt.
6464
+ * A thread's posts each spend their × on removing themselves, so the exit
6465
+ * cannot live in a post header there — and a phone has no Escape key and no
6466
+ * backdrop to tap, so it has to be visible somewhere.
6467
+ *
6468
+ * It goes at the near edge of the quick actions row, the strip that already
6469
+ * carries thread-wide switches: leaving is thread-wide too, and the row's
6470
+ * near half was empty. Diagonally opposite Publish, and in the muted weight
6471
+ * of the switches beside it rather than Publish's — an exit should neither
6472
+ * compete with the thing it is an exit from nor sit next to it.
6473
+ *
6474
+ * The word is "Close", not "Cancel": leaving may keep the work as a draft, so
6475
+ * nothing is cancelled, and a thread puts N posts on screen that each own a ×
6476
+ * — "Cancel" would not say which of them it meant. It also keeps this out of
6477
+ * a Cancel/Post pair, which would read as Publish's peer rather than the
6478
+ * quiet way out. Same word as the single-post ×, which is the same action.
6479
+ *
6480
+ * It routes through `requestClose`, so unsaved work still gets the "Save to
6481
+ * drafts?" prompt.
6482
+ *
6483
+ * Page mode sits this out for the same reason the × does: the page's own
6484
+ * Back link is the exit there, and it already routes through the same
6485
+ * prompt. The row would be the wrong place to argue otherwise — page mode
6486
+ * pins the action row to the viewport bottom and leaves this strip below it.
5642
6487
  */
5643
- private _renderCloseComposeRow() {
5644
- if (this._threadItems.length === 0) return nothing;
6488
+ private _renderCloseComposeQuickAction() {
6489
+ if (this.pageMode || this._threadItems.length === 0) return nothing;
5645
6490
 
5646
6491
  return html`
5647
6492
  <button
5648
6493
  type="button"
5649
- class="compose-sheet-row"
6494
+ class="compose-quick-actions-close"
5650
6495
  ?disabled=${this._loading}
5651
- @click=${() => {
5652
- this._closePublishPanel(false);
5653
- this.requestClose();
5654
- }}
6496
+ @click=${() => this.requestClose()}
5655
6497
  >
5656
- ${renderComposePublishActionIcon(
5657
- COMPOSE_PUBLISH_ACTION_ICONS.close,
5658
- "compose-sheet-row-icon",
5659
- )}
5660
- <span class="compose-sheet-main">
5661
- <span class="compose-sheet-title">${this.labels.closeCompose}</span>
5662
- </span>
6498
+ ${this.labels.close}
5663
6499
  </button>
5664
6500
  `;
5665
6501
  }
@@ -5792,12 +6628,14 @@ export class JantComposeDialog extends LitElement {
5792
6628
  "compose-publish-group-open": this._showPublishPanel,
5793
6629
  })}
5794
6630
  >
5795
- ${this._showPublishPanel && !this._publishPanelFullscreen
5796
- ? html`<div
5797
- class="compose-dropdown-backdrop"
5798
- @click=${() => this._closePublishPanel(true)}
5799
- ></div>`
5800
- : nothing}
6631
+ ${
6632
+ this._showPublishPanel && !this._publishPanelFullscreen
6633
+ ? html`<div
6634
+ class="compose-dropdown-backdrop"
6635
+ @click=${() => this._closePublishPanel(true)}
6636
+ ></div>`
6637
+ : nothing
6638
+ }
5801
6639
  <div
5802
6640
  role="group"
5803
6641
  class=${classMap({
@@ -5820,35 +6658,37 @@ export class JantComposeDialog extends LitElement {
5820
6658
  <!-- Options sits past Publish as its own control rather than a
5821
6659
  chevron welded to it: a split button reads as one button, which
5822
6660
  is why nobody found the settings. -->
5823
- ${this._hasPublishPanelContent()
5824
- ? html`<button
5825
- type="button"
5826
- class=${classMap({
5827
- "compose-options-trigger": true,
5828
- "compose-options-trigger-open": this._showPublishPanel,
5829
- })}
5830
- ?disabled=${this._loading}
5831
- aria-haspopup="dialog"
5832
- aria-expanded=${this._showPublishPanel ? "true" : "false"}
5833
- aria-label=${this.labels.publishSettings}
5834
- title=${this.labels.publishSettings}
5835
- @click=${() => this._togglePublishPanel()}
5836
- >
5837
- <svg
5838
- class="compose-options-trigger-icon"
5839
- width="22"
5840
- height="22"
5841
- viewBox="0 0 24 24"
5842
- fill="none"
5843
- stroke="currentColor"
5844
- stroke-linecap="round"
5845
- stroke-linejoin="round"
5846
- aria-hidden="true"
6661
+ ${
6662
+ this._hasPublishPanelContent()
6663
+ ? html`<button
6664
+ type="button"
6665
+ class=${classMap({
6666
+ "compose-options-trigger": true,
6667
+ "compose-options-trigger-open": this._showPublishPanel,
6668
+ })}
6669
+ ?disabled=${this._loading}
6670
+ aria-haspopup="dialog"
6671
+ aria-expanded=${this._showPublishPanel ? "true" : "false"}
6672
+ aria-label=${this.labels.publishSettings}
6673
+ title=${this.labels.publishSettings}
6674
+ @click=${() => this._togglePublishPanel()}
5847
6675
  >
5848
- ${unsafeSVG(COMPOSE_PUBLISH_ACTION_ICONS.options)}
5849
- </svg>
5850
- </button>`
5851
- : nothing}
6676
+ <svg
6677
+ class="compose-options-trigger-icon"
6678
+ width="22"
6679
+ height="22"
6680
+ viewBox="0 0 24 24"
6681
+ fill="none"
6682
+ stroke="currentColor"
6683
+ stroke-linecap="round"
6684
+ stroke-linejoin="round"
6685
+ aria-hidden="true"
6686
+ >
6687
+ ${unsafeSVG(COMPOSE_PUBLISH_ACTION_ICONS.options)}
6688
+ </svg>
6689
+ </button>`
6690
+ : nothing
6691
+ }
5852
6692
  ${this._renderDesktopPublishPanel()}
5853
6693
  </div>
5854
6694
  </div>
@@ -5861,12 +6701,19 @@ export class JantComposeDialog extends LitElement {
5861
6701
  * mirrors of a row inside it, not a second source of truth.
5862
6702
  */
5863
6703
  private _renderQuickActionsRow() {
6704
+ const close = this._renderCloseComposeQuickAction();
5864
6705
  const hideFromLatest = this._renderHideFromLatestQuickToggle();
5865
6706
  const quietReply = this._renderQuietReplyQuickToggle();
5866
- if (hideFromLatest === nothing && quietReply === nothing) return nothing;
6707
+ if (
6708
+ close === nothing &&
6709
+ hideFromLatest === nothing &&
6710
+ quietReply === nothing
6711
+ ) {
6712
+ return nothing;
6713
+ }
5867
6714
  return html`
5868
6715
  <div class="compose-quick-actions-row">
5869
- ${hideFromLatest} ${quietReply}
6716
+ ${close} ${hideFromLatest} ${quietReply}
5870
6717
  </div>
5871
6718
  `;
5872
6719
  }
@@ -5974,70 +6821,21 @@ export class JantComposeDialog extends LitElement {
5974
6821
  : this._format;
5975
6822
 
5976
6823
  if (this._threadItems.length === 0) {
5977
- // Entering thread mode: snapshot current single editor's state
5978
- const currentEditor = this._editor;
5979
- const editorState = currentEditor?.getEditorState() ?? null;
5980
- const editorData = currentEditor?.getData();
5981
- const bodyJson = currentEditor?.getNormalizedBodyJson() ?? null;
5982
-
6824
+ // Entering thread mode renders the post in a new editor element, so the
6825
+ // single-post editor hands it everything once the swap has rendered.
6826
+ // Reading the old element then, not now, picks up any upload that
6827
+ // finished during the render.
6828
+ const singleEditor = this._editor;
5983
6829
  this._threadItems = [
5984
6830
  { id: randomUUID(), format: this._format },
5985
6831
  { id: randomUUID(), format: lastFormat },
5986
6832
  ];
5987
-
5988
- // Capture rating state before re-render (these can't change asynchronously)
5989
- const capturedRating = currentEditor?._rating ?? 0;
5990
- const capturedShowRating = currentEditor?._showRating ?? false;
5991
-
5992
- // Restore first thread item's content from the snapshot
5993
- if (editorState || editorData) {
5994
- this.updateComplete.then(() => {
5995
- const editors = this.querySelectorAll<JantComposeEditor>(
5996
- "jant-compose-editor",
5997
- );
5998
- const firstEditor = editors[0];
5999
- if (!firstEditor) return;
6000
- if (editorState) {
6001
- firstEditor.setEditorState(
6002
- editorState.json,
6003
- editorState.title,
6004
- editorState.showTitle,
6005
- editorState.selection,
6006
- );
6007
- }
6008
- if (editorData) {
6009
- if (this._format === "link" && editorData.url) {
6010
- firstEditor._url = editorData.url;
6011
- } else if (this._format === "quote") {
6012
- if (editorData.quoteText)
6013
- firstEditor._quoteText = editorData.quoteText;
6014
- if (editorData.quoteAuthor)
6015
- firstEditor._quoteAuthor = editorData.quoteAuthor;
6016
- }
6017
- if (bodyJson) {
6018
- firstEditor._bodyJson = bodyJson;
6019
- }
6020
- }
6021
- // Read attachment state from the old editor NOW (after re-render) so
6022
- // we get the latest mediaId for any uploads that completed during the
6023
- // render cycle. The old editor element is still in memory even though
6024
- // it has been removed from the DOM.
6025
- const latestAttachments = currentEditor?._attachments ?? [];
6026
- const latestAttachmentOrder = currentEditor?._attachmentOrder ?? [];
6027
- const latestAttachedTexts = currentEditor?._attachedTexts ?? [];
6028
- if (latestAttachments.length > 0) {
6029
- firstEditor._attachments = [...latestAttachments];
6030
- firstEditor._attachmentOrder = [...latestAttachmentOrder];
6031
- }
6032
- if (latestAttachedTexts.length > 0) {
6033
- firstEditor._attachedTexts = [...latestAttachedTexts];
6034
- }
6035
- if (capturedRating > 0) {
6036
- firstEditor._rating = capturedRating;
6037
- firstEditor._showRating = capturedShowRating;
6038
- }
6039
- });
6040
- }
6833
+ this.updateComplete.then(() => {
6834
+ const rootEditor = this.querySelector<JantComposeEditor>(
6835
+ "jant-compose-editor",
6836
+ );
6837
+ if (singleEditor && rootEditor) rootEditor.takeOver(singleEditor);
6838
+ });
6041
6839
  } else {
6042
6840
  this._threadItems = [
6043
6841
  ...this._threadItems,
@@ -6062,67 +6860,32 @@ export class JantComposeDialog extends LitElement {
6062
6860
  if (this._threadItems.length <= 1) return;
6063
6861
  const newItems = this._threadItems.filter((_, i) => i !== index);
6064
6862
 
6863
+ // The root's permalink and date live on the dialog rather than on its
6864
+ // thread item, so removing the root has to move the next post's own
6865
+ // values up — otherwise the removed post's permalink and date stay behind
6866
+ // on the post that replaces it, and that post's own are dropped.
6867
+ if (index === 0) {
6868
+ const promoted = newItems[0];
6869
+ this._slug = promoted.slug ?? "";
6870
+ this._slugTaken = promoted.slugTaken ?? false;
6871
+ this._publishedAtInput = promoted.publishedAtInput ?? "";
6872
+ this._publishedAtTimeMinutes = promoted.publishedAtTimeMinutes ?? null;
6873
+ }
6874
+
6065
6875
  if (newItems.length === 1) {
6066
- // Exiting thread mode: capture remaining thread editor's state and restore
6067
- // it to the single-post editor after thread mode is cleared.
6068
- const editors = this.querySelectorAll<JantComposeEditor>(
6069
- "jant-compose-editor",
6876
+ // Leaving thread mode renders the last post in the single-post editor,
6877
+ // which takes everything over from the thread editor it replaces.
6878
+ const remainingEditor = this.querySelector<JantComposeEditor>(
6879
+ `jant-compose-editor[data-thread-id="${newItems[0].id}"]`,
6070
6880
  );
6071
- const remainingIndex = index === 0 ? 1 : 0;
6072
- const remainingEditor = editors[remainingIndex] ?? null;
6073
- const editorState = remainingEditor?.getEditorState() ?? null;
6074
- const editorData = remainingEditor?.getData();
6075
- const bodyJson = remainingEditor?.getNormalizedBodyJson() ?? null;
6076
- const remainingFormat = newItems[0].format;
6077
- // Capture rating state before re-render (can't change asynchronously)
6078
- const capturedRating = remainingEditor?._rating ?? 0;
6079
- const capturedShowRating = remainingEditor?._showRating ?? false;
6080
-
6081
6881
  this._threadItems = [];
6082
6882
  this._focusedThreadIndex = 0;
6083
- this._format = remainingFormat;
6883
+ this._format = newItems[0].format;
6084
6884
 
6085
6885
  this.updateComplete.then(() => {
6086
6886
  const singleEditor = this._editor;
6087
6887
  if (!singleEditor) return;
6088
- singleEditor.format = remainingFormat;
6089
- if (editorState) {
6090
- singleEditor.setEditorState(
6091
- editorState.json,
6092
- editorState.title,
6093
- editorState.showTitle,
6094
- editorState.selection,
6095
- );
6096
- }
6097
- if (editorData) {
6098
- if (remainingFormat === "link" && editorData.url) {
6099
- singleEditor._url = editorData.url;
6100
- } else if (remainingFormat === "quote") {
6101
- if (editorData.quoteText)
6102
- singleEditor._quoteText = editorData.quoteText;
6103
- if (editorData.quoteAuthor)
6104
- singleEditor._quoteAuthor = editorData.quoteAuthor;
6105
- }
6106
- if (bodyJson) {
6107
- singleEditor._bodyJson = bodyJson;
6108
- }
6109
- }
6110
- // Read attachment state from the old editor NOW so we capture any
6111
- // mediaIds set by uploads that completed during the render cycle.
6112
- const latestAttachments = remainingEditor?._attachments ?? [];
6113
- const latestAttachmentOrder = remainingEditor?._attachmentOrder ?? [];
6114
- const latestAttachedTexts = remainingEditor?._attachedTexts ?? [];
6115
- if (latestAttachments.length > 0) {
6116
- singleEditor._attachments = [...latestAttachments];
6117
- singleEditor._attachmentOrder = [...latestAttachmentOrder];
6118
- }
6119
- if (latestAttachedTexts.length > 0) {
6120
- singleEditor._attachedTexts = [...latestAttachedTexts];
6121
- }
6122
- if (capturedRating > 0) {
6123
- singleEditor._rating = capturedRating;
6124
- singleEditor._showRating = capturedShowRating;
6125
- }
6888
+ if (remainingEditor) singleEditor.takeOver(remainingEditor);
6126
6889
  singleEditor.focusInput();
6127
6890
  });
6128
6891
  } else {
@@ -6180,6 +6943,11 @@ export class JantComposeDialog extends LitElement {
6180
6943
  }}
6181
6944
  @jant:format-change=${(e: CustomEvent<{ format: ComposeFormat }>) => {
6182
6945
  e.stopPropagation();
6946
+ this._convertForFormatSwitch(
6947
+ e.target as JantComposeEditor,
6948
+ item.format,
6949
+ e.detail.format,
6950
+ );
6183
6951
  this._threadItems = this._threadItems.map((it, i) =>
6184
6952
  i === index ? { ...it, format: e.detail.format } : it,
6185
6953
  );
@@ -6257,7 +7025,11 @@ export class JantComposeDialog extends LitElement {
6257
7025
  })}
6258
7026
  @jant:compose-content-changed=${() => this._scheduleDraftSave()}
6259
7027
  >
6260
- ${isReply ? this._renderReplyContext() : nothing}
7028
+ ${
7029
+ isReply
7030
+ ? this._renderReplyContext()
7031
+ : this._renderTranslationContext()
7032
+ }
6261
7033
  <!-- Keyed by row id, not by position: an editor holds its own content,
6262
7034
  so an unkeyed list would reuse elements by position and drop the
6263
7035
  last one when a middle post is removed — taking that post's text
@@ -6287,7 +7059,7 @@ export class JantComposeDialog extends LitElement {
6287
7059
  // placeholder sits where the next post will land instead of floating out at
6288
7060
  // the dialog's own left edge, a rail's width clear of everything above it.
6289
7061
  private _renderAddThreadTrigger(onRail: boolean) {
6290
- const disabled = !this._hasContent();
7062
+ const disabled = !this._hasWorkToLose();
6291
7063
  const button = html`
6292
7064
  <button
6293
7065
  type="button"
@@ -6358,39 +7130,46 @@ export class JantComposeDialog extends LitElement {
6358
7130
  ?inert=${this._addCollectionPanelOpen}
6359
7131
  >
6360
7132
  <div class="compose-scroll">
6361
- ${isOpeningEdit
6362
- ? this._renderEditLoadingState()
6363
- : isThreadMode
6364
- ? this._renderThreadComposeLayout()
6365
- : isReply
6366
- ? html`
6367
- <div
6368
- class="compose-thread-layout compose-reply-compose-layout"
6369
- >
6370
- ${this._renderReplyContext()}
6371
- <div class="compose-editor-row is-current">
6372
- <div class="compose-thread-dot"></div>
6373
- ${editor}
7133
+ ${
7134
+ isOpeningEdit
7135
+ ? this._renderEditLoadingState()
7136
+ : isThreadMode
7137
+ ? this._renderThreadComposeLayout()
7138
+ : isReply
7139
+ ? html`
7140
+ <div
7141
+ class="compose-thread-layout compose-reply-compose-layout"
7142
+ >
7143
+ ${this._renderReplyContext()}
7144
+ <div class="compose-editor-row is-current">
7145
+ <div class="compose-thread-dot"></div>
7146
+ ${editor}
7147
+ </div>
7148
+ ${addThreadRow}
6374
7149
  </div>
6375
- ${addThreadRow}
6376
- </div>
6377
- `
6378
- : html`${editor}${addThreadRow}`}
7150
+ `
7151
+ : html`${this._renderTranslationContext()}${editor}${addThreadRow}`
7152
+ }
6379
7153
  </div>
6380
- ${isOpeningEdit
6381
- ? nothing
6382
- : html`<div
6383
- class=${classMap({
6384
- "compose-action-row": true,
6385
- "compose-action-row-without-collection": !!this._replyToId,
6386
- "compose-action-row-overlay-open":
6387
- this._showPublishPanel || this._showCollection,
6388
- })}
6389
- >
6390
- ${this._replyToId ? nothing : this._renderCollectionSelector()}
6391
- ${this._renderPublishButton()}
6392
- </div>
6393
- ${this._renderQuickActionsRow()}`}
7154
+ ${
7155
+ isOpeningEdit
7156
+ ? nothing
7157
+ : html`<div
7158
+ class=${classMap({
7159
+ "compose-action-row": true,
7160
+ "compose-action-row-without-collection": !!this._replyToId,
7161
+ "compose-action-row-overlay-open":
7162
+ this._showPublishPanel ||
7163
+ this._showCollection ||
7164
+ this._showLanguagePicker,
7165
+ })}
7166
+ >
7167
+ ${this._replyToId ? nothing : this._renderCollectionSelector()}
7168
+ ${this._renderLanguageControl()}
7169
+ ${this._renderPublishButton()}
7170
+ </div>
7171
+ ${this._renderQuickActionsRow()}`
7172
+ }
6394
7173
  ${this._renderMobilePublishPanel()} ${this._renderAttachedPanel()}
6395
7174
  ${this._renderAltPanel()} ${this._renderDraftsPanel()}
6396
7175
  ${this._renderConfirmPanel()}