@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
@@ -0,0 +1,1087 @@
1
+ /**
2
+ * Language Settings Component
3
+ *
4
+ * The single home for every language setting: the site's content language, the
5
+ * dashboard's UI language, and multilingual content.
6
+ *
7
+ * Unlike the General settings page, this component talks to the server itself
8
+ * rather than through the settings bridge. Its operations are not "save this
9
+ * form" — turning multilingual on stamps every existing post, removing a
10
+ * language can be refused, changing the primary language rewrites two settings
11
+ * at once — so each needs its own endpoint, its own confirmation, and its own
12
+ * error handling.
13
+ *
14
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
15
+ */
16
+
17
+ import { LitElement, html, nothing } from "lit";
18
+ import { showConfirmDialog } from "../confirm.js";
19
+ import { queueToastForNextPage, showToast } from "../toast.js";
20
+ import { getJsonString, readJsonObject } from "../json.js";
21
+ import { getJantDocsUrl } from "../../lib/jant-docs.js";
22
+ import {
23
+ LOCALE_PICKER_TRIGGER_CLASS,
24
+ type LocaleOption,
25
+ type LocalePickerLabels,
26
+ } from "./jant-locale-picker.js";
27
+
28
+ interface LanguageLabels {
29
+ siteSection: string;
30
+ dashboardSection: string;
31
+ contentLanguage: string;
32
+ contentLanguageHelp: string;
33
+ primaryLanguage: string;
34
+ primaryLanguageHelp: string;
35
+ languagesLabel: string;
36
+ primaryBadge: string;
37
+ makePrimary: string;
38
+ dashboardLanguage: string;
39
+ dashboardLanguageHelp: string;
40
+ followContent: string;
41
+ multilingual: string;
42
+ multilingualHelp: string;
43
+ multilingualDocs: string;
44
+ multilingualDocsHelp: string;
45
+ statusOn: string;
46
+ turnOn: string;
47
+ addMissingLanguage: string;
48
+ viewPosts: string;
49
+ otherLanguages: string;
50
+ addLanguage: string;
51
+ removeLanguage: string;
52
+ languageMenu: string;
53
+ enableTitle: string;
54
+ enableReassurance: string;
55
+ enableMarkTitle: string;
56
+ enableMarkWarning: string;
57
+ enableFixHint: string;
58
+ enableNeedsLanguage: string;
59
+ changePrimaryTitle: string;
60
+ changePrimaryBody: string;
61
+ changePrimaryConfirm: string;
62
+ disableTitle: string;
63
+ disableBody: string;
64
+ disableConfirm: string;
65
+ removeLastTitle: string;
66
+ removeLastBody: string;
67
+ removeLastConfirm: string;
68
+ cancel: string;
69
+ save: string;
70
+ saving: string;
71
+ searchPlaceholder: string;
72
+ noMatches: string;
73
+ }
74
+
75
+ interface LanguageInitialState {
76
+ contentLanguage: string;
77
+ dashboardLanguage: string;
78
+ multilingualEnabled: boolean;
79
+ additionalLanguages: string[];
80
+ unmarkedPostCount: number;
81
+ sitePathPrefix: string;
82
+ /**
83
+ * Docs link, resolved by the server. Vite's `define` does not reach modules
84
+ * the dev server hands the browser, so the environment's docs host is only
85
+ * known server-side.
86
+ */
87
+ multilingualDocsUrl: string;
88
+ }
89
+
90
+ /** Catalog locales the dashboard is translated into. */
91
+ const DASHBOARD_LOCALES = ["", "en", "zh-Hans", "zh-Hant"] as const;
92
+
93
+ /**
94
+ * The reference for everything this page cannot say in two lines: URL
95
+ * structure, per-language feeds, and how translation groups work. Same shape
96
+ * as the theming and API links elsewhere in settings. Used only if the server
97
+ * sent no link — it resolves the docs host for the deployed build, not for a
98
+ * dev server.
99
+ */
100
+ const MULTILINGUAL_DOCS_FALLBACK_URL = getJantDocsUrl("multilingual");
101
+
102
+ /**
103
+ * Fill the `{name}` slots the server deliberately left intact.
104
+ *
105
+ * Plurals and every other value are already resolved by Lingui server-side —
106
+ * only values that are not known until the author interacts (the language they
107
+ * picked, the prefix it maps to) reach here.
108
+ */
109
+ function interpolate(template: string, values: Record<string, string>): string {
110
+ // An unknown placeholder is left as-is rather than blanked: a copy change
111
+ // that renames a slot should read oddly, not silently drop a fact.
112
+ return template.replace(
113
+ /\{(\w+)\}/g,
114
+ (match, key: string) => values[key] ?? match,
115
+ );
116
+ }
117
+
118
+ export class JantSettingsLanguage extends LitElement {
119
+ static properties = {
120
+ labels: { type: Object },
121
+ locales: { type: Array },
122
+ _contentLanguage: { state: true },
123
+ _dashboardLanguage: { state: true },
124
+ _multilingualEnabled: { state: true },
125
+ _additional: { state: true },
126
+ _unmarkedPostCount: { state: true },
127
+ _sitePathPrefix: { state: true },
128
+ _savingSite: { state: true },
129
+ _savingDashboard: { state: true },
130
+ _removeError: { state: true },
131
+ _rowMenuOpen: { state: true },
132
+ _enableError: { state: true },
133
+ _enableOpen: { state: true },
134
+ _enableAdditional: { state: true },
135
+ _enablePrimary: { state: true },
136
+ _enableBusy: { state: true },
137
+ };
138
+
139
+ declare labels: LanguageLabels;
140
+ declare locales: LocaleOption[];
141
+ declare _contentLanguage: string;
142
+ declare _dashboardLanguage: string;
143
+ declare _multilingualEnabled: boolean;
144
+ declare _additional: string[];
145
+ declare _unmarkedPostCount: number;
146
+ declare _sitePathPrefix: string;
147
+ declare _savingSite: boolean;
148
+ declare _savingDashboard: boolean;
149
+ /**
150
+ * The refusal to remove a language, shown under that language's own row.
151
+ * Inline rather than a toast: the way out is a link to the posts, and a
152
+ * link belongs where the refusal happened.
153
+ */
154
+ declare _removeError: { tag: string; message: string } | null;
155
+ /** Language tag whose row menu (make primary / remove) is open. */
156
+ declare _rowMenuOpen: string | null;
157
+ /**
158
+ * Refusal from the enable dialog's save, shown inside the dialog. When the
159
+ * server names the language whose posts blocked the save, putting it back
160
+ * on the list is offered as one click.
161
+ */
162
+ declare _enableError: { message: string; language?: string } | null;
163
+ declare _enableOpen: boolean;
164
+ declare _enableAdditional: string[];
165
+ declare _enablePrimary: string;
166
+ declare _enableBusy: boolean;
167
+
168
+ /**
169
+ * Docs link for this page. Set from the server's initial state before the
170
+ * first render and never changed after, so it stays outside reactive state.
171
+ */
172
+ #multilingualDocsUrl = MULTILINGUAL_DOCS_FALLBACK_URL;
173
+
174
+ createRenderRoot() {
175
+ // Drop the server-rendered skeleton first. lit-html appends its parts
176
+ // rather than replacing existing children, so leaving it would stack the
177
+ // placeholder block above the real form.
178
+ this.innerHTML = "";
179
+ return this;
180
+ }
181
+
182
+ constructor() {
183
+ super();
184
+ this.labels = {} as LanguageLabels;
185
+ this.locales = [];
186
+ this._contentLanguage = "en";
187
+ this._dashboardLanguage = "";
188
+ this._multilingualEnabled = false;
189
+ this._additional = [];
190
+ this._unmarkedPostCount = 0;
191
+ this._sitePathPrefix = "";
192
+ this._savingSite = false;
193
+ this._savingDashboard = false;
194
+ this._removeError = null;
195
+ this._rowMenuOpen = null;
196
+ this._enableError = null;
197
+ this._enableOpen = false;
198
+ this._enableAdditional = [];
199
+ this._enablePrimary = "en";
200
+ this._enableBusy = false;
201
+ }
202
+
203
+ connectedCallback() {
204
+ super.connectedCallback();
205
+ this.#loadInitialState();
206
+ document.addEventListener("click", this.#onDocumentClick);
207
+ document.addEventListener("keydown", this.#onDocumentKeydown);
208
+ }
209
+
210
+ disconnectedCallback() {
211
+ super.disconnectedCallback();
212
+ document.removeEventListener("click", this.#onDocumentClick);
213
+ document.removeEventListener("keydown", this.#onDocumentKeydown);
214
+ }
215
+
216
+ #loadInitialState() {
217
+ const node = document.getElementById("language-settings-initial-data");
218
+ if (!node?.textContent) return;
219
+
220
+ let parsed: unknown;
221
+ try {
222
+ parsed = JSON.parse(node.textContent);
223
+ } catch {
224
+ return;
225
+ }
226
+ const state = parsed as Partial<LanguageInitialState>;
227
+
228
+ this._contentLanguage = state.contentLanguage ?? "en";
229
+ this._dashboardLanguage = state.dashboardLanguage ?? "";
230
+ this._multilingualEnabled = state.multilingualEnabled ?? false;
231
+ this._additional = Array.isArray(state.additionalLanguages)
232
+ ? [...state.additionalLanguages]
233
+ : [];
234
+ this._unmarkedPostCount = state.unmarkedPostCount ?? 0;
235
+ this._sitePathPrefix = state.sitePathPrefix ?? "";
236
+ this.#multilingualDocsUrl =
237
+ state.multilingualDocsUrl || MULTILINGUAL_DOCS_FALLBACK_URL;
238
+ }
239
+
240
+ // ── Presentation helpers ──────────────────────────────────────────
241
+
242
+ #entry(tag: string): LocaleOption {
243
+ return (
244
+ this.locales.find((locale) => locale.tag === tag) ?? {
245
+ tag,
246
+ native: tag,
247
+ english: tag,
248
+ coverage: 0,
249
+ }
250
+ );
251
+ }
252
+
253
+ #displayName(tag: string): string {
254
+ const entry = this.#entry(tag);
255
+ return entry.native || entry.tag;
256
+ }
257
+
258
+ #prefixFor(tag: string): string {
259
+ return `${this._sitePathPrefix}/${tag.toLowerCase()}`;
260
+ }
261
+
262
+ #endpoint(path: string): string {
263
+ return `${this._sitePathPrefix}/settings/language${path}`;
264
+ }
265
+
266
+ // ── Dismissal ─────────────────────────────────────────────────────
267
+
268
+ /** True while any locale picker on this page is showing its list. */
269
+ #pickerIsOpen(): boolean {
270
+ return this.querySelector("jant-locale-picker[open]") !== null;
271
+ }
272
+
273
+ #onDocumentClick = (event: Event) => {
274
+ if (!this._rowMenuOpen) return;
275
+ const target = event.target as globalThis.Element | null;
276
+ const inMenu =
277
+ target && this.contains(target) && target.closest?.("[data-row-menu]");
278
+ if (!inMenu) this._rowMenuOpen = null;
279
+ };
280
+
281
+ #onDocumentKeydown = (event: globalThis.KeyboardEvent) => {
282
+ if (event.key !== "Escape") return;
283
+ // An open picker handles its own Escape and stops the event there, so
284
+ // reaching this handler means no picker consumed it.
285
+ if (this._rowMenuOpen) {
286
+ event.stopPropagation();
287
+ this._rowMenuOpen = null;
288
+ return;
289
+ }
290
+ if (this._enableOpen) {
291
+ event.stopPropagation();
292
+ this.#closeEnableDialog();
293
+ }
294
+ };
295
+
296
+ #toggleRowMenu(tag: string) {
297
+ this._rowMenuOpen = this._rowMenuOpen === tag ? null : tag;
298
+ }
299
+
300
+ // ── Server calls ──────────────────────────────────────────────────
301
+
302
+ async #post(
303
+ path: string,
304
+ body: Record<string, unknown>,
305
+ ): Promise<{ ok: boolean; message?: string; language?: string }> {
306
+ try {
307
+ const res = await fetch(this.#endpoint(path), {
308
+ method: "POST",
309
+ headers: {
310
+ "Content-Type": "application/json",
311
+ Accept: "application/json",
312
+ },
313
+ body: JSON.stringify(body),
314
+ });
315
+ const json = await readJsonObject(res);
316
+ if (!res.ok) {
317
+ return {
318
+ ok: false,
319
+ message: getJsonString(json, "error") ?? `HTTP ${res.status}`,
320
+ // A language-in-use refusal names the language, so the dialog can
321
+ // offer putting it back as one click.
322
+ language: getJsonString(json, "language"),
323
+ };
324
+ }
325
+ return { ok: true, message: getJsonString(json, "toast") };
326
+ } catch {
327
+ return { ok: false, message: "Couldn't reach the server. Try again." };
328
+ }
329
+ }
330
+
331
+ async #selectPrimary(tag: string) {
332
+ this._removeError = null;
333
+ if (tag === this._contentLanguage) return;
334
+
335
+ // With multilingual off this is just "what language is this site in". With
336
+ // it on, the root URLs change hands, which subscribers notice — so say so.
337
+ if (this._multilingualEnabled) {
338
+ const previous = this._contentLanguage;
339
+ const confirmed = await showConfirmDialog({
340
+ title: this.labels.changePrimaryTitle,
341
+ message: interpolate(this.labels.changePrimaryBody, {
342
+ next: this.#displayName(tag),
343
+ previous: this.#displayName(previous),
344
+ prefix: this.#prefixFor(previous),
345
+ }),
346
+ confirmLabel: this.labels.changePrimaryConfirm,
347
+ cancelLabel: this.labels.cancel,
348
+ });
349
+ if (!confirmed) return;
350
+
351
+ this._savingSite = true;
352
+ const result = await this.#post("/primary", { language: tag });
353
+ this._savingSite = false;
354
+ if (!result.ok) {
355
+ showToast(result.message ?? "", "error");
356
+ return;
357
+ }
358
+ // The two lists swap in one server-side step; mirror that here so the
359
+ // page reads correctly without a reload.
360
+ this._additional = [
361
+ ...this._additional.filter((entry) => entry !== tag),
362
+ previous,
363
+ ];
364
+ this._contentLanguage = tag;
365
+ if (result.message) showToast(result.message);
366
+ return;
367
+ }
368
+
369
+ this._savingSite = true;
370
+ const previous = this._contentLanguage;
371
+ this._contentLanguage = tag;
372
+ const result = await this.#post("", { contentLanguage: tag });
373
+ this._savingSite = false;
374
+ if (!result.ok) {
375
+ this._contentLanguage = previous;
376
+ showToast(result.message ?? "", "error");
377
+ return;
378
+ }
379
+ if (result.message) showToast(result.message);
380
+ }
381
+
382
+ async #selectDashboardLanguage(value: string) {
383
+ const previous = this._dashboardLanguage;
384
+ this._dashboardLanguage = value;
385
+ this._savingDashboard = true;
386
+ const result = await this.#post("", { dashboardLanguage: value });
387
+ this._savingDashboard = false;
388
+ if (!result.ok) {
389
+ this._dashboardLanguage = previous;
390
+ showToast(result.message ?? "", "error");
391
+ return;
392
+ }
393
+ // The dashboard renders in the catalog that was active when the page
394
+ // loaded, so a reload is what actually applies the change.
395
+ window.location.reload();
396
+ }
397
+
398
+ async #addLanguage(tag: string) {
399
+ this._removeError = null;
400
+ const result = await this.#post("/add", { language: tag });
401
+ if (!result.ok) {
402
+ showToast(result.message ?? "", "error");
403
+ return;
404
+ }
405
+ this._additional = [...this._additional, tag];
406
+ if (result.message) showToast(result.message);
407
+ }
408
+
409
+ /**
410
+ * Remove a language, and with the last one the feature itself.
411
+ *
412
+ * Per-language views need a second language to mean anything, so the server
413
+ * turns multilingual content off when this empties the list. That is a bigger
414
+ * change than the menu item promises, hence the confirmation — asked before
415
+ * the request, which means it is also asked in the case the server then
416
+ * refuses (posts still written in that language). The refusal lands under the
417
+ * row as it always has; the alternative is a preflight round-trip on every
418
+ * removal to avoid one dialog in the rarer path.
419
+ */
420
+ async #removeLanguage(tag: string) {
421
+ this._removeError = null;
422
+ const isLast =
423
+ this._multilingualEnabled &&
424
+ this._additional.length === 1 &&
425
+ this._additional[0] === tag;
426
+
427
+ if (isLast) {
428
+ const confirmed = await showConfirmDialog({
429
+ title: this.labels.removeLastTitle,
430
+ message: interpolate(this.labels.removeLastBody, {
431
+ language: this.#displayName(tag),
432
+ prefix: this.#prefixFor(tag),
433
+ }),
434
+ confirmLabel: this.labels.removeLastConfirm,
435
+ cancelLabel: this.labels.cancel,
436
+ });
437
+ if (!confirmed) return;
438
+ }
439
+
440
+ const result = await this.#post("/remove", { language: tag });
441
+ if (!result.ok) {
442
+ this._removeError = { tag, message: result.message ?? "" };
443
+ return;
444
+ }
445
+ this._additional = this._additional.filter((entry) => entry !== tag);
446
+
447
+ if (isLast) {
448
+ this._multilingualEnabled = false;
449
+ // Same reload as turning it off directly: the header switcher and the
450
+ // composer's language control are server-rendered.
451
+ if (result.message) queueToastForNextPage(result.message);
452
+ window.location.reload();
453
+ return;
454
+ }
455
+ if (result.message) showToast(result.message);
456
+ }
457
+
458
+ async #turnOffMultilingual() {
459
+ this._removeError = null;
460
+ const confirmed = await showConfirmDialog({
461
+ title: this.labels.disableTitle,
462
+ message: interpolate(this.labels.disableBody, {
463
+ prefix: this.#prefixFor(this._additional[0] ?? "en"),
464
+ }),
465
+ confirmLabel: this.labels.disableConfirm,
466
+ cancelLabel: this.labels.cancel,
467
+ });
468
+ if (!confirmed) return;
469
+
470
+ const result = await this.#post("/disable", {});
471
+ if (!result.ok) {
472
+ showToast(result.message ?? "", "error");
473
+ return;
474
+ }
475
+ this._multilingualEnabled = false;
476
+ // Queued for the same reason as the enable path: the reload below happens
477
+ // before a toast raised here could be read.
478
+ if (result.message) queueToastForNextPage(result.message);
479
+ // The header's language switcher has to go away with the views.
480
+ window.location.reload();
481
+ }
482
+
483
+ // ── Enable dialog ─────────────────────────────────────────────────
484
+
485
+ #openEnableDialog() {
486
+ this._removeError = null;
487
+ this._enableError = null;
488
+ this._enablePrimary = this._contentLanguage;
489
+ this._enableAdditional = [...this._additional];
490
+ this._enableOpen = true;
491
+ void this.updateComplete.then(() => {
492
+ const dialog = this.querySelector<HTMLDialogElement>(
493
+ "[data-enable-dialog]",
494
+ );
495
+ if (dialog && !dialog.open) dialog.showModal();
496
+ dialog?.querySelector<HTMLElement>("[data-enable-panel]")?.focus();
497
+ });
498
+ }
499
+
500
+ #closeEnableDialog() {
501
+ const dialog = this.querySelector<HTMLDialogElement>(
502
+ "[data-enable-dialog]",
503
+ );
504
+ if (dialog?.open) dialog.close();
505
+ this._enableOpen = false;
506
+ }
507
+
508
+ async #confirmEnable() {
509
+ if (this._enableAdditional.length === 0 || this._enableBusy) return;
510
+
511
+ this._enableBusy = true;
512
+ this._enableError = null;
513
+ const result = await this.#post("/enable", {
514
+ primary: this._enablePrimary,
515
+ additional: this._enableAdditional,
516
+ });
517
+ this._enableBusy = false;
518
+ if (!result.ok) {
519
+ // Inside the dialog, where the refusal happened — a toast would sit
520
+ // beneath the modal's top layer and never be seen.
521
+ this._enableError = {
522
+ message: result.message ?? "",
523
+ ...(result.language ? { language: result.language } : {}),
524
+ };
525
+ return;
526
+ }
527
+
528
+ this._contentLanguage = this._enablePrimary;
529
+ this._additional = [...this._enableAdditional];
530
+ this._multilingualEnabled = true;
531
+ this._unmarkedPostCount = 0;
532
+ this.#closeEnableDialog();
533
+ // Queued rather than shown: the reload below would wipe a toast raised
534
+ // here, and this is the one confirmation that says how many posts were
535
+ // stamped — the number is gone from the page by the time it lands.
536
+ if (result.message) queueToastForNextPage(result.message);
537
+ // The page's own chrome changed with the setting — the language switcher
538
+ // exists now — and only the server can render it. Same pattern as the
539
+ // dashboard-language change above.
540
+ window.location.reload();
541
+ }
542
+
543
+ // ── Render ────────────────────────────────────────────────────────
544
+
545
+ /**
546
+ * Labels handed to every picker on the page.
547
+ *
548
+ * Built once: the picker takes them as a property, and a fresh object each
549
+ * render would make Lit see a changed property on every keystroke elsewhere
550
+ * in the form.
551
+ */
552
+ get #pickerLabels(): LocalePickerLabels {
553
+ this.#pickerLabelsCache ??= {
554
+ search: this.labels.searchPlaceholder,
555
+ empty: this.labels.noMatches,
556
+ };
557
+ return this.#pickerLabelsCache;
558
+ }
559
+
560
+ #pickerLabelsCache: LocalePickerLabels | null = null;
561
+
562
+ #renderLanguagePicker(options: {
563
+ labelId: string;
564
+ current?: string;
565
+ exclude: readonly string[];
566
+ triggerLabel?: string;
567
+ triggerClass: string;
568
+ onSelect: (tag: string) => void;
569
+ }) {
570
+ return html`
571
+ <jant-locale-picker
572
+ .locales=${this.locales}
573
+ .labels=${this.#pickerLabels}
574
+ .value=${options.current ?? ""}
575
+ .exclude=${[...options.exclude]}
576
+ .triggerLabel=${options.triggerLabel ?? ""}
577
+ .triggerClass=${options.triggerClass}
578
+ .labelledby=${options.labelId}
579
+ @locale-select=${(e: CustomEvent<{ tag: string }>) =>
580
+ options.onSelect(e.detail.tag)}
581
+ ></jant-locale-picker>
582
+ `;
583
+ }
584
+
585
+ /**
586
+ * The save refusal, rendered inside the dialog with its one-click fix.
587
+ *
588
+ * When the server names the language whose posts blocked the save, an
589
+ * "Add {language}" button puts it straight back on the list — the reader
590
+ * of the error should not have to find that language in a picker.
591
+ */
592
+ #renderEnableError(error: { message: string; language?: string }) {
593
+ return html`
594
+ <div class="flex flex-wrap items-center gap-2" role="alert">
595
+ <p class="text-sm text-destructive">${error.message}</p>
596
+ ${
597
+ error.language
598
+ ? html`
599
+ <button
600
+ type="button"
601
+ class="btn-sm-outline"
602
+ data-enable-add-back
603
+ @click=${() => this.#addBackLanguage(error.language ?? "")}
604
+ >
605
+ ${interpolate(this.labels.addMissingLanguage, {
606
+ language: this.#displayName(error.language),
607
+ })}
608
+ </button>
609
+ `
610
+ : nothing
611
+ }
612
+ </div>
613
+ `;
614
+ }
615
+
616
+ /** Put the language the refusal named back on the dialog's list. */
617
+ #addBackLanguage(tag: string) {
618
+ if (!tag) return;
619
+ this._enableError = null;
620
+ if (tag === this._enablePrimary || this._enableAdditional.includes(tag)) {
621
+ return;
622
+ }
623
+ this._enableAdditional = [...this._enableAdditional, tag];
624
+ }
625
+
626
+ /**
627
+ * The confirmation shown before multilingual content is switched on.
628
+ *
629
+ * BaseCoat's `.alert` is a two-column grid where only a title element and a
630
+ * `<section>` are placed in the readable column, so the warning uses that
631
+ * structure rather than bare paragraphs.
632
+ */
633
+ #renderEnableDialog() {
634
+ if (!this._enableOpen) return nothing;
635
+
636
+ // With nothing to stamp there is nothing to warn about — the alert only
637
+ // exists for the one-time marking of posts that predate the feature.
638
+ const warning =
639
+ this._unmarkedPostCount > 0
640
+ ? interpolate(this.labels.enableMarkWarning, {
641
+ language: this.#displayName(this._enablePrimary),
642
+ })
643
+ : null;
644
+
645
+ return html`
646
+ <dialog
647
+ class="dialog confirm-dialog"
648
+ data-enable-dialog
649
+ @cancel=${(e: Event) => {
650
+ e.preventDefault();
651
+ this.#closeEnableDialog();
652
+ }}
653
+ @click=${(e: Event) => {
654
+ if (e.target === e.currentTarget) this.#closeEnableDialog();
655
+ }}
656
+ @keydown=${(e: globalThis.KeyboardEvent) => {
657
+ if (e.key === "Enter" && !e.shiftKey && !this.#pickerIsOpen()) {
658
+ e.preventDefault();
659
+ void this.#confirmEnable();
660
+ }
661
+ }}
662
+ >
663
+ <div
664
+ class="confirm-dialog-panel"
665
+ data-enable-panel
666
+ tabindex="-1"
667
+ role="document"
668
+ aria-labelledby="language-enable-title"
669
+ >
670
+ <header class="confirm-dialog-header">
671
+ <h2 id="language-enable-title" class="confirm-dialog-title">
672
+ ${this.labels.enableTitle}
673
+ </h2>
674
+ </header>
675
+
676
+ <div class="flex flex-col gap-4 py-2">
677
+ <div class="field">
678
+ <span id="language-enable-primary-label" class="label"
679
+ >${this.labels.primaryLanguage}</span
680
+ >
681
+ ${this.#renderLanguagePicker({
682
+ labelId: "language-enable-primary-label",
683
+ current: this._enablePrimary,
684
+ exclude: this._enableAdditional,
685
+ triggerLabel: this.#displayName(this._enablePrimary),
686
+ triggerClass: LOCALE_PICKER_TRIGGER_CLASS,
687
+ onSelect: (tag) => {
688
+ this._enablePrimary = tag;
689
+ this._enableAdditional = this._enableAdditional.filter(
690
+ (entry) => entry !== tag,
691
+ );
692
+ },
693
+ })}
694
+ <p class="text-sm text-muted-foreground mt-1">
695
+ ${this.labels.primaryLanguageHelp}
696
+ </p>
697
+ </div>
698
+
699
+ <div class="field">
700
+ <span id="language-enable-add-label" class="label"
701
+ >${this.labels.otherLanguages}</span
702
+ >
703
+ <div class="flex flex-wrap items-center gap-2">
704
+ ${this._enableAdditional.map(
705
+ (tag) => html`
706
+ <span class="badge-secondary gap-1">
707
+ ${this.#displayName(tag)}
708
+ <button
709
+ type="button"
710
+ class="cursor-pointer opacity-70 hover:opacity-100"
711
+ aria-label=${interpolate(this.labels.removeLanguage, {
712
+ language: this.#displayName(tag),
713
+ })}
714
+ @click=${() => {
715
+ this._enableAdditional =
716
+ this._enableAdditional.filter(
717
+ (entry) => entry !== tag,
718
+ );
719
+ }}
720
+ >
721
+ ×
722
+ </button>
723
+ </span>
724
+ `,
725
+ )}
726
+ ${this.#renderLanguagePicker({
727
+ labelId: "language-enable-add-label",
728
+ exclude: [this._enablePrimary, ...this._enableAdditional],
729
+ triggerLabel: `+ ${this.labels.addLanguage}`,
730
+ triggerClass: "btn-sm-outline",
731
+ onSelect: (tag) => {
732
+ this._enableAdditional = [...this._enableAdditional, tag];
733
+ },
734
+ })}
735
+ </div>
736
+ </div>
737
+
738
+ ${
739
+ warning
740
+ ? html`
741
+ <div class="alert">
742
+ <svg
743
+ xmlns="http://www.w3.org/2000/svg"
744
+ viewBox="0 0 24 24"
745
+ fill="none"
746
+ stroke="currentColor"
747
+ stroke-width="2"
748
+ stroke-linecap="round"
749
+ stroke-linejoin="round"
750
+ aria-hidden="true"
751
+ >
752
+ <path
753
+ d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"
754
+ />
755
+ <path d="M12 9v4" />
756
+ <path d="M12 17h.01" />
757
+ </svg>
758
+ <strong>${this.labels.enableMarkTitle}</strong>
759
+ <section>
760
+ <p>${warning}</p>
761
+ <p>${this.labels.enableFixHint}</p>
762
+ </section>
763
+ </div>
764
+ `
765
+ : nothing
766
+ }
767
+
768
+ <p class="text-sm text-muted-foreground">
769
+ ${this.labels.enableReassurance}
770
+ </p>
771
+
772
+ ${
773
+ this._enableAdditional.length === 0
774
+ ? html`<p class="text-sm text-muted-foreground">
775
+ ${this.labels.enableNeedsLanguage}
776
+ </p>`
777
+ : nothing
778
+ }
779
+ ${
780
+ this._enableError
781
+ ? this.#renderEnableError(this._enableError)
782
+ : nothing
783
+ }
784
+ </div>
785
+
786
+ <footer class="confirm-dialog-actions">
787
+ <button
788
+ type="button"
789
+ class="btn-outline"
790
+ @click=${() => this.#closeEnableDialog()}
791
+ >
792
+ ${this.labels.cancel}
793
+ </button>
794
+ <button
795
+ type="button"
796
+ class="btn"
797
+ data-enable-confirm
798
+ ?disabled=${
799
+ this._enableAdditional.length === 0 || this._enableBusy
800
+ }
801
+ @click=${() => void this.#confirmEnable()}
802
+ >
803
+ ${this._enableBusy ? this.labels.saving : this.labels.save}
804
+ </button>
805
+ </footer>
806
+ </div>
807
+ </dialog>
808
+ `;
809
+ }
810
+
811
+ /**
812
+ * The folded actions of one non-primary language row.
813
+ *
814
+ * Making a language primary or removing it are once-a-year moves; a row of
815
+ * always-visible buttons would give them daily-driver prominence. A "⋯"
816
+ * menu keeps the list to what matters — language and address — and follows
817
+ * the dismissal rules every popover here follows.
818
+ */
819
+ #renderRowMenu(tag: string) {
820
+ const open = this._rowMenuOpen === tag;
821
+ const name = this.#displayName(tag);
822
+
823
+ return html`
824
+ <span class="relative ml-auto flex items-center" data-row-menu>
825
+ <button
826
+ type="button"
827
+ class="btn-sm-ghost text-muted-foreground"
828
+ aria-haspopup="menu"
829
+ aria-expanded=${open ? "true" : "false"}
830
+ aria-label=${interpolate(this.labels.languageMenu, {
831
+ language: name,
832
+ })}
833
+ data-language-menu
834
+ @click=${() => this.#toggleRowMenu(tag)}
835
+ >
836
+ ⋯
837
+ </button>
838
+ ${
839
+ open
840
+ ? html`
841
+ <div
842
+ role="menu"
843
+ class="absolute right-0 top-full z-20 mt-1 min-w-40 rounded-md border bg-popover py-1 text-popover-foreground shadow-md"
844
+ >
845
+ <button
846
+ type="button"
847
+ role="menuitem"
848
+ class="block w-full cursor-pointer px-3 py-2 text-left text-sm hover:bg-accent"
849
+ ?disabled=${this._savingSite}
850
+ @click=${() => {
851
+ this._rowMenuOpen = null;
852
+ void this.#selectPrimary(tag);
853
+ }}
854
+ >
855
+ ${this.labels.makePrimary}
856
+ </button>
857
+ <button
858
+ type="button"
859
+ role="menuitem"
860
+ class="block w-full cursor-pointer px-3 py-2 text-left text-sm text-destructive hover:bg-accent"
861
+ @click=${() => {
862
+ this._rowMenuOpen = null;
863
+ void this.#removeLanguage(tag);
864
+ }}
865
+ >
866
+ ${interpolate(this.labels.removeLanguage, {
867
+ language: name,
868
+ })}
869
+ </button>
870
+ </div>
871
+ `
872
+ : nothing
873
+ }
874
+ </span>
875
+ `;
876
+ }
877
+
878
+ /**
879
+ * The site's languages as one list, each with the address it is served at.
880
+ *
881
+ * One row per language keeps every fact and action in the same place —
882
+ * which language, which URL, which one owns the root, make another primary,
883
+ * remove one — instead of a picker, a chip row and a URL legend apart.
884
+ */
885
+ #renderLanguagesList() {
886
+ const languages = [this._contentLanguage, ...this._additional];
887
+
888
+ return html`
889
+ <div class="field">
890
+ <span id="language-list-label" class="label"
891
+ >${this.labels.languagesLabel}${
892
+ this._savingSite ? ` ${this.labels.saving}` : ""
893
+ }</span
894
+ >
895
+ <ul
896
+ class="flex flex-col rounded-md border divide-y"
897
+ aria-labelledby="language-list-label"
898
+ >
899
+ ${languages.map((tag) => {
900
+ const isPrimary = tag === this._contentLanguage;
901
+ const url = isPrimary
902
+ ? `${this._sitePathPrefix}/`
903
+ : this.#prefixFor(tag);
904
+ return html`
905
+ <li class="flex flex-col px-3 py-1.5 text-sm">
906
+ <div class="flex min-h-8 items-center gap-3">
907
+ <span class="min-w-0 truncate" lang=${tag}
908
+ >${this.#displayName(tag)}</span
909
+ >
910
+ <a
911
+ href=${url}
912
+ target="_blank"
913
+ rel="noopener noreferrer"
914
+ class="shrink-0"
915
+ >
916
+ <code
917
+ class="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
918
+ >${url}</code
919
+ >
920
+ </a>
921
+ ${
922
+ isPrimary
923
+ ? html`<span class="badge-secondary ml-auto"
924
+ >${this.labels.primaryBadge}</span
925
+ >`
926
+ : this.#renderRowMenu(tag)
927
+ }
928
+ </div>
929
+ ${
930
+ this._removeError?.tag === tag
931
+ ? html`
932
+ <p class="pb-1 text-sm text-destructive" role="alert">
933
+ ${this._removeError.message}${
934
+ this._multilingualEnabled
935
+ ? html`
936
+ <a
937
+ class="underline underline-offset-2"
938
+ href=${`${this.#prefixFor(tag)}/archive`}
939
+ target="_blank"
940
+ rel="noopener noreferrer"
941
+ >${this.labels.viewPosts}</a
942
+ >
943
+ `
944
+ : nothing
945
+ }
946
+ </p>
947
+ `
948
+ : nothing
949
+ }
950
+ </li>
951
+ `;
952
+ })}
953
+ </ul>
954
+ <div class="mt-2">
955
+ ${this.#renderLanguagePicker({
956
+ labelId: "language-list-label",
957
+ exclude: [this._contentLanguage, ...this._additional],
958
+ triggerLabel: `+ ${this.labels.addLanguage}`,
959
+ triggerClass: "btn-sm-outline",
960
+ onSelect: (tag) => void this.#addLanguage(tag),
961
+ })}
962
+ </div>
963
+ </div>
964
+ `;
965
+ }
966
+
967
+ render() {
968
+ if (!this.labels.contentLanguage) return nothing;
969
+
970
+ return html`
971
+ <div class="flex flex-col gap-8">
972
+ <section class="flex flex-col gap-4">
973
+ <h2 class="text-lg font-medium">${this.labels.siteSection}</h2>
974
+
975
+ ${
976
+ this._multilingualEnabled
977
+ ? this.#renderLanguagesList()
978
+ : html`
979
+ <div class="field">
980
+ <span id="language-primary-label" class="label"
981
+ >${this.labels.contentLanguage}</span
982
+ >
983
+ ${this.#renderLanguagePicker({
984
+ labelId: "language-primary-label",
985
+ current: this._contentLanguage,
986
+ exclude: [],
987
+ triggerLabel: this.#displayName(this._contentLanguage),
988
+ triggerClass: LOCALE_PICKER_TRIGGER_CLASS,
989
+ onSelect: (tag) => void this.#selectPrimary(tag),
990
+ })}
991
+ <p class="text-sm text-muted-foreground mt-1">
992
+ ${this.labels.contentLanguageHelp}${
993
+ this._savingSite ? ` ${this.labels.saving}` : ""
994
+ }
995
+ </p>
996
+ </div>
997
+ `
998
+ }
999
+ </section>
1000
+
1001
+ <section class="flex flex-col gap-3 border-t pt-8">
1002
+ <h2 class="text-lg font-medium">${this.labels.multilingual}</h2>
1003
+ <p class="text-sm text-muted-foreground">
1004
+ ${this.labels.multilingualHelp}
1005
+ </p>
1006
+ <p class="text-sm text-muted-foreground">
1007
+ <a
1008
+ href=${this.#multilingualDocsUrl}
1009
+ target="_blank"
1010
+ rel="noopener noreferrer"
1011
+ class="underline hover:text-foreground transition-colors"
1012
+ >${this.labels.multilingualDocs}</a
1013
+ >
1014
+ — ${this.labels.multilingualDocsHelp}
1015
+ </p>
1016
+ <div class="flex items-center gap-3">
1017
+ ${
1018
+ this._multilingualEnabled
1019
+ ? html`
1020
+ <span class="badge-secondary">${this.labels.statusOn}</span>
1021
+ <button
1022
+ type="button"
1023
+ class="btn-link h-auto p-0 text-sm text-muted-foreground"
1024
+ data-multilingual-off
1025
+ @click=${() => void this.#turnOffMultilingual()}
1026
+ >
1027
+ ${this.labels.disableConfirm}
1028
+ </button>
1029
+ `
1030
+ : html`
1031
+ <button
1032
+ type="button"
1033
+ class="btn-sm-outline"
1034
+ data-multilingual-setup
1035
+ @click=${() => this.#openEnableDialog()}
1036
+ >
1037
+ ${this.labels.turnOn}
1038
+ </button>
1039
+ `
1040
+ }
1041
+ </div>
1042
+ </section>
1043
+
1044
+ <section class="flex flex-col gap-4 border-t pt-8">
1045
+ <h2 class="text-lg font-medium">${this.labels.dashboardSection}</h2>
1046
+
1047
+ <div class="field">
1048
+ <label id="dashboard-language-label" class="label"
1049
+ >${this.labels.dashboardLanguage}</label
1050
+ >
1051
+ <select
1052
+ class="select"
1053
+ aria-labelledby="dashboard-language-label"
1054
+ ?disabled=${this._savingDashboard}
1055
+ @change=${(e: Event) =>
1056
+ void this.#selectDashboardLanguage(
1057
+ (e.target as HTMLSelectElement).value,
1058
+ )}
1059
+ >
1060
+ ${DASHBOARD_LOCALES.map(
1061
+ (tag) => html`
1062
+ <option
1063
+ value=${tag}
1064
+ ?selected=${this._dashboardLanguage === tag}
1065
+ >
1066
+ ${
1067
+ tag === ""
1068
+ ? this.labels.followContent
1069
+ : this.#displayName(tag)
1070
+ }
1071
+ </option>
1072
+ `,
1073
+ )}
1074
+ </select>
1075
+ <p class="text-sm text-muted-foreground mt-1">
1076
+ ${this.labels.dashboardLanguageHelp}
1077
+ </p>
1078
+ </div>
1079
+ </section>
1080
+ </div>
1081
+
1082
+ ${this.#renderEnableDialog()}
1083
+ `;
1084
+ }
1085
+ }
1086
+
1087
+ customElements.define("jant-settings-language", JantSettingsLanguage);