@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
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * General Settings Component
3
3
  *
4
- * Main container for the General settings page. Saveable groups track dirty
5
- * state independently, while checkbox-only sections save immediately.
4
+ * Main container for the General settings page. Typed fields are grouped
5
+ * behind a Save button and track their dirty state independently; every
6
+ * control whose value is complete the moment it is set — checkbox, radio,
7
+ * select — saves on change.
6
8
  *
7
9
  * Light DOM only — BaseCoat and Tailwind classes apply directly.
8
10
  */
@@ -11,40 +13,55 @@ import { LitElement, html, nothing } from "lit";
11
13
  import type { Editor } from "@tiptap/core";
12
14
  import { MAX_SITE_NAME_LENGTH } from "../../types.js";
13
15
  import {
14
- getSupportedLocaleEntries,
15
- getOrBuildEntry,
16
- type LocaleEntry,
17
- } from "../../i18n/supported-locales.js";
16
+ discoverIntroRuns,
17
+ resolveDiscoverMode,
18
+ type DiscoverPageUrls,
19
+ } from "../../lib/discover.js";
18
20
  import type {
19
21
  SettingsInitialData,
20
22
  SettingsLabels,
21
23
  SettingsTimezone,
22
- SettingsCjkFont,
23
- SettingsDashboardLanguage,
24
24
  SettingsAboutPageStatus,
25
25
  } from "./settings-types.js";
26
- import { showToast } from "../toast.js";
26
+ import {
27
+ COPY_FIELD_BUTTON_CLASS,
28
+ COPY_FIELD_CLASS,
29
+ COPY_FIELD_CONTROL_CLASS,
30
+ COPY_FIELD_INPUT_CLASS,
31
+ } from "../../lib/copy-field.js";
27
32
  import {
28
33
  createSettingsEditor,
29
34
  jsonToMarkdown,
30
35
  } from "../tiptap/create-editor.js";
31
36
 
37
+ /**
38
+ * Spacing every section shares, whatever its position.
39
+ *
40
+ * The container's `divide-y` draws the rule under each section but the last;
41
+ * this pads both sides of each rule and drops the padding at either end. The
42
+ * gap between a section's own children is left to the section.
43
+ */
44
+ const SECTION_CLASS = "flex flex-col py-8 first:pt-0 last:pb-0";
45
+
32
46
  export class JantSettingsGeneral extends LitElement {
33
47
  static properties = {
34
48
  labels: { type: Object },
35
49
  timezones: { type: Array },
36
- cjkFonts: { type: Array, attribute: "cjk-fonts" },
37
50
  siteNameFallback: { type: String, attribute: "sitename-fallback" },
38
51
  siteDescriptionFallback: {
39
52
  type: String,
40
53
  attribute: "sitedescription-fallback",
41
54
  },
42
- dashboardLanguages: { type: Array, attribute: "dashboard-languages" },
43
55
  demoMode: { type: Boolean, attribute: "demo-mode" },
56
+ discoverDefault: { type: String, attribute: "discover-default" },
57
+ discoverPages: { type: Object, attribute: "discover-pages" },
58
+ discoverStatus: { type: String, attribute: "discover-status" },
59
+ feedsEnabled: { type: Boolean, attribute: "feeds-enabled" },
44
60
  mainFeedUrl: { type: String, attribute: "main-feed-url" },
45
61
  latestFeedUrl: { type: String, attribute: "latest-feed-url" },
46
62
  featuredFeedUrl: { type: String, attribute: "featured-feed-url" },
47
63
  archiveFeedUrl: { type: String, attribute: "archive-feed-url" },
64
+ feedsDocsUrl: { type: String, attribute: "feeds-docs-url" },
48
65
  aboutPage: { type: Object, attribute: "about-page" },
49
66
  aboutEditUrl: { type: String, attribute: "about-edit-url" },
50
67
  aboutCreateUrl: {
@@ -60,12 +77,7 @@ export class JantSettingsGeneral extends LitElement {
60
77
  _siteDirty: { state: true },
61
78
  _siteLoading: { state: true },
62
79
 
63
- // Language, CJK & time group
64
- _siteLanguage: { state: true },
65
- _dashboardLanguage: { state: true },
66
- _localeOpen: { state: true },
67
- _localeQuery: { state: true },
68
- _cjkSerifFont: { state: true },
80
+ // Time group
69
81
  _timeZone: { state: true },
70
82
  _origLocale: { state: true },
71
83
  _localeDirty: { state: true },
@@ -74,7 +86,6 @@ export class JantSettingsGeneral extends LitElement {
74
86
  // Feed group
75
87
  _mainRssFeed: { state: true },
76
88
  _origMainRssFeed: { state: true },
77
- _feedDirty: { state: true },
78
89
  _feedLoading: { state: true },
79
90
 
80
91
  // Home auto-save
@@ -86,19 +97,40 @@ export class JantSettingsGeneral extends LitElement {
86
97
  _noindex: { state: true },
87
98
  _origNoindex: { state: true },
88
99
  _searchLoading: { state: true },
100
+
101
+ // Discover group
102
+ _discover: { state: true },
103
+ _origDiscover: { state: true },
104
+ _discoverLoading: { state: true },
89
105
  };
90
106
 
91
107
  declare labels: SettingsLabels;
92
108
  declare timezones: SettingsTimezone[];
93
- declare cjkFonts: SettingsCjkFont[];
94
- declare dashboardLanguages: SettingsDashboardLanguage[];
95
109
  declare siteNameFallback: string;
96
110
  declare siteDescriptionFallback: string;
97
111
  declare demoMode: boolean;
112
+ /**
113
+ * The deployment's own answer, unresolved: `""` when it has none.
114
+ *
115
+ * What the site actually declares is derived from this and from the controls
116
+ * around it — see `_effectiveDiscoverMode`.
117
+ */
118
+ declare discoverDefault: string;
119
+ /** The directory's own pages, or `null` when none is configured. */
120
+ declare discoverPages: DiscoverPageUrls | null;
121
+ /**
122
+ * JSON status block, already translated by the server.
123
+ *
124
+ * The sentences carry runtime numbers, so they are built where the values
125
+ * are. This component only decides where they go.
126
+ */
127
+ declare discoverStatus: string;
128
+ declare feedsEnabled: boolean;
98
129
  declare mainFeedUrl: string;
99
130
  declare latestFeedUrl: string;
100
131
  declare featuredFeedUrl: string;
101
132
  declare archiveFeedUrl: string;
133
+ declare feedsDocsUrl: string;
102
134
  declare aboutPage: SettingsAboutPageStatus;
103
135
  declare aboutEditUrl: string;
104
136
  declare aboutCreateUrl: string;
@@ -115,20 +147,10 @@ export class JantSettingsGeneral extends LitElement {
115
147
  declare _siteDirty: boolean;
116
148
  declare _siteLoading: boolean;
117
149
 
118
- // Language, CJK & time
119
- declare _siteLanguage: string;
120
- /** Admin dashboard UI locale (one of the translated catalog locales). */
121
- declare _dashboardLanguage: string;
122
- /** Whether the locale combobox dropdown is currently open. */
123
- declare _localeOpen: boolean;
124
- /** Search query inside the locale combobox. */
125
- declare _localeQuery: string;
126
- declare _cjkSerifFont: string;
150
+ // Time
151
+ /** IANA time zone name, such as `"America/New_York"`. */
127
152
  declare _timeZone: string;
128
153
  declare _origLocale: {
129
- siteLanguage: string;
130
- dashboardLanguage: string;
131
- cjkSerifFont: string;
132
154
  timeZone: string;
133
155
  };
134
156
  declare _localeDirty: boolean;
@@ -137,7 +159,6 @@ export class JantSettingsGeneral extends LitElement {
137
159
  // Feed
138
160
  declare _mainRssFeed: string;
139
161
  declare _origMainRssFeed: string;
140
- declare _feedDirty: boolean;
141
162
  declare _feedLoading: boolean;
142
163
 
143
164
  // Home
@@ -150,6 +171,12 @@ export class JantSettingsGeneral extends LitElement {
150
171
  declare _origNoindex: boolean;
151
172
  declare _searchLoading: boolean;
152
173
 
174
+ // Discover. "" means the owner has never used the control, which is not the
175
+ // same as "off" — an untouched site still follows the default.
176
+ declare _discover: string;
177
+ declare _origDiscover: string;
178
+ declare _discoverLoading: boolean;
179
+
153
180
  // TipTap editor instances
154
181
  private _descEditor: Editor | null = null;
155
182
  private _footerEditor: Editor | null = null;
@@ -163,8 +190,6 @@ export class JantSettingsGeneral extends LitElement {
163
190
  super();
164
191
  this.labels = {} as SettingsLabels;
165
192
  this.timezones = [];
166
- this.cjkFonts = [];
167
- this.dashboardLanguages = [];
168
193
  this.siteNameFallback = "";
169
194
  this.siteDescriptionFallback = "";
170
195
  this.demoMode = false;
@@ -172,6 +197,7 @@ export class JantSettingsGeneral extends LitElement {
172
197
  this.latestFeedUrl = "/latest/feed";
173
198
  this.featuredFeedUrl = "/featured/feed";
174
199
  this.archiveFeedUrl = "/archive/feed";
200
+ this.feedsDocsUrl = "";
175
201
  this.aboutPage = {
176
202
  state: "missing",
177
203
  path: "/about",
@@ -190,25 +216,14 @@ export class JantSettingsGeneral extends LitElement {
190
216
  this._siteDirty = false;
191
217
  this._siteLoading = false;
192
218
 
193
- this._siteLanguage = "en";
194
- this._dashboardLanguage = "en";
195
- this._localeOpen = false;
196
- this._localeQuery = "";
197
- this._cjkSerifFont = "off";
198
219
  this._timeZone = "UTC";
199
- this._origLocale = {
200
- siteLanguage: "en",
201
- dashboardLanguage: "en",
202
- cjkSerifFont: "off",
203
- timeZone: "UTC",
204
- };
220
+ this._origLocale = { timeZone: "UTC" };
205
221
 
206
222
  this._localeDirty = false;
207
223
  this._localeLoading = false;
208
224
 
209
225
  this._mainRssFeed = "featured";
210
226
  this._origMainRssFeed = "featured";
211
- this._feedDirty = false;
212
227
  this._feedLoading = false;
213
228
 
214
229
  this._noindex = false;
@@ -217,18 +232,22 @@ export class JantSettingsGeneral extends LitElement {
217
232
  this._origShowJantBrandingOnHome = false;
218
233
  this._homeLoading = false;
219
234
  this._searchLoading = false;
235
+
236
+ this.discoverDefault = "";
237
+ this.discoverPages = null;
238
+ this.discoverStatus = "";
239
+ this.feedsEnabled = false;
240
+ this._discover = "";
241
+ this._origDiscover = "";
242
+ this._discoverLoading = false;
220
243
  }
221
244
 
222
245
  connectedCallback() {
223
246
  super.connectedCallback();
224
- document.addEventListener("click", this._onLocalePickerDocumentClick);
225
- document.addEventListener("keydown", this._onLocalePickerKeydown);
226
247
  }
227
248
 
228
249
  disconnectedCallback() {
229
250
  super.disconnectedCallback();
230
- document.removeEventListener("click", this._onLocalePickerDocumentClick);
231
- document.removeEventListener("keydown", this._onLocalePickerKeydown);
232
251
  this._descEditor?.destroy();
233
252
  this._descEditor = null;
234
253
  this._footerEditor?.destroy();
@@ -241,16 +260,8 @@ export class JantSettingsGeneral extends LitElement {
241
260
  this._siteDescription = data.siteDescription;
242
261
  this._siteFooter = data.siteFooter;
243
262
 
244
- this._siteLanguage = data.siteLanguage;
245
- this._dashboardLanguage = data.dashboardLanguage;
246
- this._cjkSerifFont = data.cjkSerifFont;
247
263
  this._timeZone = data.timeZone;
248
- this._origLocale = {
249
- siteLanguage: data.siteLanguage,
250
- dashboardLanguage: data.dashboardLanguage,
251
- cjkSerifFont: data.cjkSerifFont,
252
- timeZone: data.timeZone,
253
- };
264
+ this._origLocale = { timeZone: data.timeZone };
254
265
 
255
266
  this._mainRssFeed = data.mainRssFeed;
256
267
  this._origMainRssFeed = data.mainRssFeed;
@@ -261,6 +272,9 @@ export class JantSettingsGeneral extends LitElement {
261
272
  this._noindex = data.noindex;
262
273
  this._origNoindex = data.noindex;
263
274
 
275
+ this._discover = data.discover;
276
+ this._origDiscover = data.discover;
277
+
264
278
  // Defer editor init to after Lit renders the containers
265
279
  this.updateComplete.then(() => {
266
280
  this._initEditors();
@@ -283,18 +297,12 @@ export class JantSettingsGeneral extends LitElement {
283
297
  };
284
298
  this._siteDirty = false;
285
299
  this._siteLoading = false;
286
- } else if (section === "language-time") {
287
- this._origLocale = {
288
- siteLanguage: this._siteLanguage,
289
- dashboardLanguage: this._dashboardLanguage,
290
- cjkSerifFont: this._cjkSerifFont,
291
- timeZone: this._timeZone,
292
- };
300
+ } else if (section === "time") {
301
+ this._origLocale = { timeZone: this._timeZone };
293
302
  this._localeDirty = false;
294
303
  this._localeLoading = false;
295
304
  } else if (section === "feeds") {
296
305
  this._origMainRssFeed = this._mainRssFeed;
297
- this._feedDirty = false;
298
306
  this._feedLoading = false;
299
307
  } else if (section === "home") {
300
308
  this._origShowJantBrandingOnHome = this._showJantBrandingOnHome;
@@ -302,6 +310,9 @@ export class JantSettingsGeneral extends LitElement {
302
310
  } else if (section === "search") {
303
311
  this._origNoindex = this._noindex;
304
312
  this._searchLoading = false;
313
+ } else if (section === "discover") {
314
+ this._origDiscover = this._discover;
315
+ this._discoverLoading = false;
305
316
  }
306
317
  }
307
318
 
@@ -309,9 +320,10 @@ export class JantSettingsGeneral extends LitElement {
309
320
  sectionError(section: string) {
310
321
  if (section === "site") {
311
322
  this._siteLoading = false;
312
- } else if (section === "language-time") {
323
+ } else if (section === "time") {
313
324
  this._localeLoading = false;
314
325
  } else if (section === "feeds") {
326
+ this._mainRssFeed = this._origMainRssFeed;
315
327
  this._feedLoading = false;
316
328
  } else if (section === "home") {
317
329
  this._showJantBrandingOnHome = this._origShowJantBrandingOnHome;
@@ -319,6 +331,9 @@ export class JantSettingsGeneral extends LitElement {
319
331
  } else if (section === "search") {
320
332
  this._noindex = this._origNoindex;
321
333
  this._searchLoading = false;
334
+ } else if (section === "discover") {
335
+ this._discover = this._origDiscover;
336
+ this._discoverLoading = false;
322
337
  }
323
338
  }
324
339
 
@@ -408,14 +423,10 @@ export class JantSettingsGeneral extends LitElement {
408
423
  );
409
424
  }
410
425
 
411
- // ── Language & time group helpers ────────────────────────────────
426
+ // ── Time group helpers ────────────────────────────────────────────
412
427
 
413
428
  private _syncLocaleDirty() {
414
- this._localeDirty =
415
- this._siteLanguage !== this._origLocale.siteLanguage ||
416
- this._dashboardLanguage !== this._origLocale.dashboardLanguage ||
417
- this._cjkSerifFont !== this._origLocale.cjkSerifFont ||
418
- this._timeZone !== this._origLocale.timeZone;
429
+ this._localeDirty = this._timeZone !== this._origLocale.timeZone;
419
430
  }
420
431
 
421
432
  private _saveLocale() {
@@ -425,157 +436,19 @@ export class JantSettingsGeneral extends LitElement {
425
436
  new CustomEvent("jant:settings-save", {
426
437
  bubbles: true,
427
438
  detail: {
428
- endpoint: "/settings/general/language-time",
429
- data: {
430
- siteLanguage: this._siteLanguage,
431
- dashboardLanguage: this._dashboardLanguage,
432
- cjkSerifFont: this._cjkSerifFont,
433
- timeZone: this._timeZone,
434
- },
435
- section: "language-time",
439
+ endpoint: "/settings/general/time",
440
+ data: { timeZone: this._timeZone },
441
+ section: "time",
436
442
  },
437
443
  }),
438
444
  );
439
445
  }
440
446
 
441
- // ── Locale combobox ────────────────────────────────────────────────
442
-
443
- private _filteredLocaleEntries(): LocaleEntry[] {
444
- const all = getSupportedLocaleEntries();
445
- const query = this._localeQuery.trim().toLowerCase();
446
- if (!query) return all;
447
- return all.filter(
448
- (e) =>
449
- e.tag.toLowerCase().includes(query) ||
450
- e.native.toLowerCase().includes(query) ||
451
- e.english.toLowerCase().includes(query),
452
- );
453
- }
454
-
455
- private _toggleLocalePicker = () => {
456
- this._localeOpen = !this._localeOpen;
457
- if (!this._localeOpen) {
458
- this._localeQuery = "";
459
- } else {
460
- // Focus the search input on next paint.
461
- this.updateComplete.then(() => {
462
- const input = this.querySelector<HTMLInputElement>(
463
- "[data-locale-search]",
464
- );
465
- input?.focus();
466
- });
467
- }
468
- };
469
-
470
- private _selectLocale(tag: string) {
471
- this._siteLanguage = tag;
472
- this._localeOpen = false;
473
- this._localeQuery = "";
474
- this._syncLocaleDirty();
475
- }
476
-
477
- private _onLocalePickerDocumentClick = (e: Event) => {
478
- if (!this._localeOpen) return;
479
- const target = e.target as Node | null;
480
- const picker = this.querySelector("[data-locale-picker]");
481
- if (picker && target && !picker.contains(target)) {
482
- this._localeOpen = false;
483
- }
484
- };
447
+ // ── Feed auto-save helpers ────────────────────────────────────────
485
448
 
486
- private _onLocalePickerKeydown = (e: KeyboardEvent) => {
487
- if (e.isComposing || e.keyCode === 229) return;
488
- if (e.key === "Escape" && this._localeOpen) {
489
- this._localeOpen = false;
490
- this._localeQuery = "";
491
- }
492
- };
493
-
494
- private _renderLanguagePicker() {
495
- const current = getOrBuildEntry(this._siteLanguage || "en");
496
- const filtered = this._filteredLocaleEntries();
497
- const searchPlaceholder =
498
- this.labels.siteLanguageSearchPlaceholder || "Search…";
499
- const noMatches = this.labels.siteLanguageNoMatches || "No matches.";
500
-
501
- return html`
502
- <div class="relative w-fit max-w-full" data-locale-picker>
503
- <button
504
- type="button"
505
- class="flex h-9 w-full cursor-pointer items-center rounded-md border border-input bg-transparent bg-[image:var(--chevron-down-icon-50)] bg-position-[center_right_0.75rem] bg-size-[1rem] bg-no-repeat py-2 pl-3 pr-9 text-left text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
506
- aria-expanded=${this._localeOpen ? "true" : "false"}
507
- aria-haspopup="listbox"
508
- aria-labelledby="site-language-label"
509
- @click=${this._toggleLocalePicker}
510
- >
511
- <span class="min-w-0 truncate">${current.native}</span>
512
- </button>
513
-
514
- ${this._localeOpen
515
- ? html`
516
- <div
517
- class="absolute left-0 top-full z-10 mt-1 w-80 min-w-full max-w-[calc(100vw-2rem)] max-h-72 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md"
518
- >
519
- <div class="border-b p-2">
520
- <input
521
- type="text"
522
- class="input w-full"
523
- data-locale-search
524
- placeholder=${searchPlaceholder}
525
- autocomplete="off"
526
- spellcheck="false"
527
- .value=${this._localeQuery}
528
- @input=${(e: Event) => {
529
- this._localeQuery = (e.target as HTMLInputElement).value;
530
- }}
531
- />
532
- </div>
533
- <div role="listbox" class="max-h-56 overflow-auto py-1">
534
- ${filtered.length === 0
535
- ? html`
536
- <div class="px-3 py-2 text-sm text-muted-foreground">
537
- ${noMatches}
538
- </div>
539
- `
540
- : filtered.map(
541
- (entry) => html`
542
- <button
543
- type="button"
544
- role="option"
545
- aria-selected=${entry.tag === this._siteLanguage
546
- ? "true"
547
- : "false"}
548
- class=${[
549
- "flex w-full flex-col px-3 py-2 text-left text-sm hover:bg-accent",
550
- entry.tag === this._siteLanguage
551
- ? "bg-accent/60"
552
- : "",
553
- ].join(" ")}
554
- @click=${() => this._selectLocale(entry.tag)}
555
- >
556
- <span>${entry.native}</span>
557
- <span class="text-xs text-muted-foreground">
558
- ${entry.english}
559
- </span>
560
- </button>
561
- `,
562
- )}
563
- </div>
564
- </div>
565
- `
566
- : nothing}
567
- </div>
568
- `;
569
- }
570
-
571
- // ── Feed group helpers ────────────────────────────────────────────
572
-
573
- private _syncFeedDirty() {
574
- this._feedDirty = this._mainRssFeed !== this._origMainRssFeed;
575
- }
576
-
577
- private _saveFeeds() {
578
- if (this._feedLoading || !this._feedDirty) return;
449
+ private _saveMainRssFeed(value: string) {
450
+ if (this._feedLoading || value === this._mainRssFeed) return;
451
+ this._mainRssFeed = value;
579
452
  this._feedLoading = true;
580
453
  this.dispatchEvent(
581
454
  new CustomEvent("jant:settings-save", {
@@ -583,7 +456,7 @@ export class JantSettingsGeneral extends LitElement {
583
456
  detail: {
584
457
  endpoint: "/settings/general/feeds",
585
458
  data: {
586
- mainRssFeed: this._mainRssFeed,
459
+ mainRssFeed: value,
587
460
  },
588
461
  section: "feeds",
589
462
  },
@@ -631,6 +504,65 @@ export class JantSettingsGeneral extends LitElement {
631
504
  );
632
505
  }
633
506
 
507
+ // ── Discover helpers ──────────────────────────────────────────────
508
+
509
+ /**
510
+ * What this site's feeds declare, as of the controls on screen right now.
511
+ *
512
+ * The same `resolveDiscoverMode` the server runs, over the same inputs, so
513
+ * the checkbox cannot disagree with the feed. It has to be computed here
514
+ * rather than sent down resolved because two of its inputs — search
515
+ * indexing and the feed switch — are controls on this page: a value resolved
516
+ * on the server is correct until the first click and wrong from then until
517
+ * the next page load, which is exactly the state an owner reads it in.
518
+ */
519
+ private _effectiveDiscoverMode(overrides: { noindex?: boolean } = {}) {
520
+ return resolveDiscoverMode({
521
+ // "" is never chosen, which is what lets the rules below decide.
522
+ storedValue: this._discover || null,
523
+ defaultValue: this.discoverDefault || null,
524
+ demoMode: this.demoMode,
525
+ noindex: overrides.noindex ?? this._noindex,
526
+ rssFeedsEnabled: this.feedsEnabled,
527
+ });
528
+ }
529
+
530
+ /**
531
+ * On writes `latest`: the directory may read any public post, and decides
532
+ * itself which list each one reaches — featured posts on its home, links
533
+ * and quotes on their own lists.
534
+ *
535
+ * A site that stored `featured` under an older release still reads as
536
+ * ticked, and stays `featured` until its owner touches the box: widening
537
+ * what the directory may show is the owner's to do, not an upgrade's.
538
+ */
539
+ private _onDiscoverToggle(enabled: boolean) {
540
+ this._saveDiscover(enabled ? "latest" : "off");
541
+ }
542
+
543
+ /**
544
+ * Store the choice the owner just made.
545
+ *
546
+ * The control is disabled while a save is in flight, so a second call
547
+ * cannot arrive before the first has answered; the guard covers the event
548
+ * that is already queued when that happens.
549
+ */
550
+ private _saveDiscover(value: "latest" | "off") {
551
+ if (this._discoverLoading) return;
552
+ this._discover = value;
553
+ this._discoverLoading = true;
554
+ this.dispatchEvent(
555
+ new CustomEvent("jant:settings-save", {
556
+ bubbles: true,
557
+ detail: {
558
+ endpoint: "/settings/general/discover",
559
+ data: { discover: value },
560
+ section: "discover",
561
+ },
562
+ }),
563
+ );
564
+ }
565
+
634
566
  /** Submit on Enter from non-textarea fields */
635
567
  private _onKeydown(
636
568
  e: globalThis.KeyboardEvent,
@@ -666,21 +598,23 @@ export class JantSettingsGeneral extends LitElement {
666
598
  ?disabled=${loading || !dirty}
667
599
  @click=${onSave}
668
600
  >
669
- ${loading
670
- ? html`<svg
671
- class="animate-spin size-4"
672
- xmlns="http://www.w3.org/2000/svg"
673
- viewBox="0 0 24 24"
674
- fill="none"
675
- stroke="currentColor"
676
- stroke-width="2"
677
- stroke-linecap="round"
678
- stroke-linejoin="round"
679
- role="status"
680
- >
681
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
682
- </svg>`
683
- : nothing}
601
+ ${
602
+ loading
603
+ ? html`<svg
604
+ class="animate-spin size-4"
605
+ xmlns="http://www.w3.org/2000/svg"
606
+ viewBox="0 0 24 24"
607
+ fill="none"
608
+ stroke="currentColor"
609
+ stroke-width="2"
610
+ stroke-linecap="round"
611
+ stroke-linejoin="round"
612
+ role="status"
613
+ >
614
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
615
+ </svg>`
616
+ : nothing
617
+ }
684
618
  ${this.labels.save}
685
619
  </button>
686
620
  </div>
@@ -711,10 +645,8 @@ export class JantSettingsGeneral extends LitElement {
711
645
  value=${value}
712
646
  class="mt-1"
713
647
  .checked=${checked}
714
- @change=${() => {
715
- this._mainRssFeed = value;
716
- this._syncFeedDirty();
717
- }}
648
+ ?disabled=${this._feedLoading}
649
+ @change=${() => this._saveMainRssFeed(value)}
718
650
  />
719
651
  <div>
720
652
  <div class="font-medium">${title}</div>
@@ -724,47 +656,39 @@ export class JantSettingsGeneral extends LitElement {
724
656
  `;
725
657
  }
726
658
 
727
- private async _copyFeedUrl(value: string) {
728
- try {
729
- if (!globalThis.navigator.clipboard?.writeText) {
730
- throw new Error("Clipboard unavailable");
731
- }
732
-
733
- await globalThis.navigator.clipboard.writeText(value);
734
- showToast(this.labels.feedUrlCopied);
735
- } catch {
736
- showToast(this.labels.copyFailed, "error");
737
- }
738
- }
739
-
740
- private _renderFeedInfoRow(
741
- label: string,
742
- value: string,
743
- description?: string,
744
- ) {
659
+ /**
660
+ * One feed address with a copy button.
661
+ *
662
+ * The same field `ui/shared/CopyField.tsx` renders on the public subscribe
663
+ * page, driven by the same `client/copy-field.ts` enhancer — hence the shared
664
+ * classes. lit-html cannot interpolate attribute names, so the enhancer's
665
+ * hooks are spelled out; `jant-settings-general.test.ts` asserts they match.
666
+ * Unlike the public page the button is not rendered hidden: settings is
667
+ * behind auth, where the client bundle is always present.
668
+ */
669
+ private _renderFeedUrl(label: string, value: string, description?: string) {
745
670
  return html`
746
- <div class="flex min-w-0 flex-col gap-1">
671
+ <div class=${COPY_FIELD_CLASS} data-copy-field-root>
747
672
  <p class="text-sm font-medium">${label}</p>
748
- ${description
749
- ? html`<p class="text-sm text-muted-foreground">${description}</p>`
750
- : ""}
751
- <div class="relative">
673
+ ${
674
+ description
675
+ ? html`<p class="text-sm text-muted-foreground">${description}</p>`
676
+ : ""
677
+ }
678
+ <div class=${COPY_FIELD_CONTROL_CLASS}>
752
679
  <input
753
680
  type="text"
754
- class="input w-full pr-20 font-mono text-sm"
681
+ class=${COPY_FIELD_INPUT_CLASS}
755
682
  .value=${value}
756
683
  readonly
757
684
  aria-label=${label}
758
- @click=${(e: Event) =>
759
- (e.currentTarget as HTMLInputElement).select()}
760
- @focus=${(e: Event) =>
761
- (e.currentTarget as HTMLInputElement).select()}
685
+ data-copy-field-value
762
686
  />
763
687
  <button
764
688
  type="button"
765
- class="btn-sm-outline absolute right-2 top-1/2 h-7 -translate-y-1/2 px-2.5 text-xs"
766
- data-copy-feed-url
767
- @click=${() => void this._copyFeedUrl(value)}
689
+ class=${COPY_FIELD_BUTTON_CLASS}
690
+ data-copy-field=${this.labels.feedUrlCopied}
691
+ data-copy-field-failed=${this.labels.copyFailed}
768
692
  >
769
693
  ${this.labels.copy}
770
694
  </button>
@@ -779,319 +703,469 @@ export class JantSettingsGeneral extends LitElement {
779
703
  return html`
780
704
  <div class="mt-2 text-sm text-muted-foreground" data-about-page-row>
781
705
  ${this.labels.aboutPagePrompt}
782
- ${status.state === "ready"
783
- ? html`
784
- <a
785
- class="font-medium text-foreground underline-offset-4 hover:underline"
786
- href=${this.aboutEditUrl}
787
- >
788
- ${this.labels.editAboutPage}
789
- </a>
790
- `
791
- : status.state === "missing"
706
+ ${
707
+ status.state === "ready"
792
708
  ? html`
793
- <form
794
- class="inline"
795
- method="post"
796
- action=${this.aboutCreateUrl}
709
+ <a
710
+ class="font-medium text-foreground underline-offset-4 hover:underline"
711
+ href=${this.aboutEditUrl}
797
712
  >
798
- <button
799
- type="submit"
800
- class="inline cursor-pointer border-0 bg-transparent p-0 font-medium text-foreground underline-offset-4 hover:underline"
801
- >
802
- ${this.labels.createAboutPage}
803
- </button>
804
- </form>
713
+ ${this.labels.editAboutPage}
714
+ </a>
805
715
  `
806
- : html`
807
- <span class="text-destructive"
808
- >${this.labels.aboutPageConflict}</span
809
- >
810
- `}
716
+ : status.state === "missing"
717
+ ? html`
718
+ <form
719
+ class="inline"
720
+ method="post"
721
+ action=${this.aboutCreateUrl}
722
+ >
723
+ <button
724
+ type="submit"
725
+ class="inline cursor-pointer border-0 bg-transparent p-0 font-medium text-foreground underline-offset-4 hover:underline"
726
+ >
727
+ ${this.labels.createAboutPage}
728
+ </button>
729
+ </form>
730
+ `
731
+ : html`
732
+ <span class="text-destructive"
733
+ >${this.labels.aboutPageConflict}</span
734
+ >
735
+ `
736
+ }
811
737
  </div>
812
738
  `;
813
739
  }
814
740
 
815
- private _renderGeneralForm() {
741
+ // ── Sections, in the order `render()` lists them ──────────────────
742
+ //
743
+ // Each section carries only its own spacing (`SECTION_CLASS`); the rules
744
+ // between them are drawn by the container, so reordering is a change to
745
+ // `render()` alone.
746
+
747
+ private _renderSiteSection() {
816
748
  return html`
817
- <div class="flex flex-col gap-8">
818
- <div>
819
- <h2 class="text-lg font-semibold">${this.labels.general}</h2>
749
+ <section
750
+ class="${SECTION_CLASS} gap-4"
751
+ @keydown=${(e: globalThis.KeyboardEvent) =>
752
+ this._onKeydown(
753
+ e,
754
+ () => this._saveSite(),
755
+ this._siteDirty,
756
+ this._siteLoading,
757
+ )}
758
+ >
759
+ ${this._renderSectionTitle(this.labels.site)}
760
+ <div class="field">
761
+ <label class="label">${this.labels.siteName}</label>
762
+ <input
763
+ type="text"
764
+ class="input"
765
+ maxlength=${MAX_SITE_NAME_LENGTH}
766
+ .value=${this._siteName}
767
+ placeholder=${this.siteNameFallback}
768
+ @input=${(e: Event) => {
769
+ this._siteName = (e.target as HTMLInputElement).value;
770
+ this._syncSiteDirty();
771
+ }}
772
+ />
820
773
  </div>
821
774
 
822
- <section
823
- class="flex flex-col gap-4"
824
- @keydown=${(e: globalThis.KeyboardEvent) =>
825
- this._onKeydown(
826
- e,
827
- () => this._saveSite(),
828
- this._siteDirty,
829
- this._siteLoading,
830
- )}
831
- >
832
- ${this._renderSectionTitle(this.labels.site)}
833
- <div class="field">
834
- <label class="label">${this.labels.siteName}</label>
835
- <input
836
- type="text"
837
- class="input"
838
- maxlength=${MAX_SITE_NAME_LENGTH}
839
- .value=${this._siteName}
840
- placeholder=${this.siteNameFallback}
841
- @input=${(e: Event) => {
842
- this._siteName = (e.target as HTMLInputElement).value;
843
- this._syncSiteDirty();
844
- }}
845
- />
846
- </div>
775
+ <div class="field">
776
+ <label class="label">${this.labels.aboutBlog}</label>
777
+ <div class="settings-tiptap-editor" data-settings-desc-editor></div>
778
+ <p class="text-sm text-muted-foreground mt-1">
779
+ ${this.labels.aboutBlogHelp}
780
+ </p>
781
+ ${this._renderAboutPageRow()}
782
+ </div>
847
783
 
848
- <div class="field">
849
- <label class="label">${this.labels.aboutBlog}</label>
850
- <div class="settings-tiptap-editor" data-settings-desc-editor></div>
851
- <p class="text-sm text-muted-foreground mt-1">
852
- ${this.labels.aboutBlogHelp}
853
- </p>
854
- ${this._renderAboutPageRow()}
855
- </div>
784
+ <div class="field">
785
+ <label class="label">${this.labels.siteFooter}</label>
786
+ <div class="settings-tiptap-editor" data-settings-footer-editor></div>
787
+ <p class="text-sm text-muted-foreground mt-1">
788
+ ${this.labels.footerHelp}
789
+ </p>
790
+ </div>
856
791
 
857
- <div class="field">
858
- <label class="label">${this.labels.siteFooter}</label>
859
- <div
860
- class="settings-tiptap-editor"
861
- data-settings-footer-editor
862
- ></div>
863
- <p class="text-sm text-muted-foreground mt-1">
864
- ${this.labels.footerHelp}
865
- </p>
866
- </div>
792
+ ${this._renderSaveAction(this._siteLoading, this._siteDirty, () =>
793
+ this._saveSite(),
794
+ )}
795
+ </section>
796
+ `;
797
+ }
867
798
 
868
- ${this._renderSaveAction(this._siteLoading, this._siteDirty, () =>
869
- this._saveSite(),
870
- )}
871
- </section>
872
-
873
- <section
874
- class="flex flex-col gap-4 border-t pt-8"
875
- @keydown=${(e: globalThis.KeyboardEvent) =>
876
- this._onKeydown(
877
- e,
878
- () => this._saveLocale(),
879
- this._localeDirty,
880
- this._localeLoading,
881
- )}
882
- >
883
- ${this._renderSectionTitle(this.labels.languageAndTime)}
884
- <div class="field">
885
- <label id="site-language-label" class="label"
886
- >${this.labels.siteLanguage}</label
887
- >
888
- ${this._renderLanguagePicker()}
889
- <p class="text-sm text-muted-foreground mt-1">
890
- ${this.labels.siteLanguageHelp}
891
- </p>
892
- <p class="text-sm text-muted-foreground mt-1">
893
- ${this.labels.contentLanguagePreview}
894
- <code class="rounded bg-muted px-1.5 py-0.5 text-xs"
895
- >${`<html lang="${this._siteLanguage || "en"}">`}</code
896
- >
897
- </p>
898
- </div>
799
+ /**
800
+ * Search engine indexing and Jant Discover.
801
+ *
802
+ * The two groups sit further apart than a section's heading sits from its
803
+ * first control, so the Discover help text does not read as belonging to
804
+ * the indexing checkbox.
805
+ */
806
+ private _renderVisibilitySection() {
807
+ return html`
808
+ <section class="${SECTION_CLASS} gap-6">
809
+ ${this._renderSectionTitle(this.labels.siteVisibility)}
810
+ <div class="flex flex-col gap-2">
811
+ <label class="flex items-center gap-2 cursor-pointer">
812
+ <input
813
+ type="checkbox"
814
+ class="checkbox"
815
+ .checked=${!this._noindex}
816
+ ?disabled=${this.demoMode || this._searchLoading}
817
+ @change=${(e: Event) =>
818
+ this._saveSearchToggle((e.target as HTMLInputElement).checked)}
819
+ />
820
+ <span>${this.labels.allowIndexing}</span>
821
+ </label>
822
+ ${
823
+ this.demoMode
824
+ ? html`<p class="text-sm text-muted-foreground">
825
+ ${this.labels.demoSeoLocked}
826
+ </p>`
827
+ : nothing
828
+ }
829
+ </div>
830
+ ${this._renderDiscoverForm()}
831
+ </section>
832
+ `;
833
+ }
899
834
 
900
- <div class="field">
901
- <label id="dashboard-language-label" class="label"
902
- >${this.labels.dashboardLanguage}</label
903
- >
904
- <select
905
- class="select"
906
- aria-labelledby="dashboard-language-label"
907
- @change=${(e: Event) => {
908
- this._dashboardLanguage = (e.target as HTMLSelectElement).value;
909
- this._syncLocaleDirty();
910
- }}
911
- >
912
- ${this.dashboardLanguages.map(
913
- (lang) => html`
914
- <option
915
- value=${lang.value}
916
- ?selected=${this._dashboardLanguage === lang.value}
917
- >
918
- ${lang.label}
919
- </option>
920
- `,
921
- )}
922
- </select>
923
- <p class="text-sm text-muted-foreground mt-1">
924
- ${this.labels.dashboardLanguageHelp}
925
- </p>
835
+ private _renderFeedsSection() {
836
+ return html`
837
+ <section class="${SECTION_CLASS} gap-4">
838
+ ${this._renderSectionTitle(this.labels.feeds)}
839
+ <div class="field">
840
+ <p class="label">${this.labels.mainRssFeed}</p>
841
+ <p class="text-sm text-muted-foreground mt-1">
842
+ ${this.labels.mainRssFeedHelp}
843
+ </p>
844
+ <div class="mt-3 flex flex-col gap-2">
845
+ ${this._renderMainRssFeedOption(
846
+ "featured",
847
+ this.labels.featuredFeedOption,
848
+ this.labels.featuredFeedOptionDescription,
849
+ )}
850
+ ${this._renderMainRssFeedOption(
851
+ "latest",
852
+ this.labels.latestFeedOption,
853
+ this.labels.latestFeedOptionDescription,
854
+ )}
926
855
  </div>
856
+ <p class="text-sm text-muted-foreground mt-2">
857
+ ${this.labels.mainRssFeedWarning}
858
+ </p>
859
+ </div>
927
860
 
928
- <div class="field">
929
- <label class="label">${this.labels.cjkFont}</label>
930
- <select
931
- class="select"
932
- @change=${(e: Event) => {
933
- this._cjkSerifFont = (e.target as HTMLSelectElement).value;
934
- this._syncLocaleDirty();
935
- }}
936
- >
937
- ${this.cjkFonts.map(
938
- (font) => html`
939
- <option
940
- value=${font.value}
941
- ?selected=${this._cjkSerifFont === font.value}
942
- >
943
- ${font.label}
944
- </option>
945
- `,
946
- )}
947
- </select>
948
- <p class="text-sm text-muted-foreground mt-1">
949
- ${this.labels.cjkFontHelp}
950
- </p>
951
- </div>
861
+ <div class="rounded-xl border border-border/70 bg-muted/30 p-4">
862
+ <div class="flex flex-col gap-4">
863
+ <div class="flex flex-col gap-1">
864
+ <p class="text-sm font-medium">
865
+ ${this.labels.availableFeedUrls}
866
+ </p>
867
+ <p class="text-sm text-muted-foreground">
868
+ ${this.labels.availableFeedUrlsHelp}
869
+ ${
870
+ this.feedsDocsUrl
871
+ ? html`
872
+ <a
873
+ href=${this.feedsDocsUrl}
874
+ target="_blank"
875
+ rel="noopener noreferrer"
876
+ class="underline hover:text-foreground transition-colors"
877
+ >${this.labels.feedsDocs}</a
878
+ >
879
+ `
880
+ : ""
881
+ }
882
+ </p>
883
+ </div>
952
884
 
953
- <div class="field">
954
- <label class="label">${this.labels.timeZone}</label>
955
- <select
956
- class="select"
957
- @change=${(e: Event) => {
958
- this._timeZone = (e.target as HTMLSelectElement).value;
959
- this._syncLocaleDirty();
960
- }}
961
- >
962
- ${this.timezones.map(
963
- (tz) => html`
964
- <option
965
- value=${tz.value}
966
- ?selected=${this._timeZone === tz.value}
967
- >
968
- ${tz.label}
969
- </option>
970
- `,
971
- )}
972
- </select>
885
+ ${this._renderFeedUrl(this.labels.mainFeedUrl, this.mainFeedUrl)}
886
+ ${this._renderFeedUrl(
887
+ this.labels.latestFeedUrl,
888
+ this.latestFeedUrl,
889
+ )}
890
+ ${this._renderFeedUrl(
891
+ this.labels.featuredFeedUrl,
892
+ this.featuredFeedUrl,
893
+ )}
894
+ ${this._renderFeedUrl(
895
+ this.labels.archiveFeedUrl,
896
+ this.archiveFeedUrl,
897
+ this.labels.archiveFeedUrlHelp,
898
+ )}
973
899
  </div>
900
+ </div>
901
+ </section>
902
+ `;
903
+ }
974
904
 
975
- ${this._renderSaveAction(this._localeLoading, this._localeDirty, () =>
976
- this._saveLocale(),
905
+ private _renderTimeSection() {
906
+ return html`
907
+ <section
908
+ class="${SECTION_CLASS} gap-4"
909
+ @keydown=${(e: globalThis.KeyboardEvent) =>
910
+ this._onKeydown(
911
+ e,
912
+ () => this._saveLocale(),
913
+ this._localeDirty,
914
+ this._localeLoading,
977
915
  )}
978
- </section>
979
-
980
- <section
981
- class="flex flex-col gap-4 border-t pt-8"
982
- @keydown=${(e: globalThis.KeyboardEvent) =>
983
- this._onKeydown(
984
- e,
985
- () => this._saveFeeds(),
986
- this._feedDirty,
987
- this._feedLoading,
916
+ >
917
+ ${this._renderSectionTitle(this.labels.timeSection)}
918
+ <div class="field">
919
+ <label class="label">${this.labels.timeZone}</label>
920
+ <select
921
+ class="select"
922
+ @change=${(e: Event) => {
923
+ this._timeZone = (e.target as HTMLSelectElement).value;
924
+ this._syncLocaleDirty();
925
+ }}
926
+ >
927
+ ${this.timezones.map(
928
+ (tz) => html`
929
+ <option
930
+ value=${tz.value}
931
+ ?selected=${this._timeZone === tz.value}
932
+ >
933
+ ${tz.label}
934
+ </option>
935
+ `,
988
936
  )}
989
- >
990
- ${this._renderSectionTitle(this.labels.feeds)}
991
- <div class="field">
992
- <p class="label">${this.labels.mainRssFeed}</p>
993
- <p class="text-sm text-muted-foreground mt-1">
994
- ${this.labels.mainRssFeedHelp}
995
- </p>
996
- <div class="mt-3 flex flex-col gap-2">
997
- ${this._renderMainRssFeedOption(
998
- "featured",
999
- this.labels.featuredFeedOption,
1000
- this.labels.featuredFeedOptionDescription,
1001
- )}
1002
- ${this._renderMainRssFeedOption(
1003
- "latest",
1004
- this.labels.latestFeedOption,
1005
- this.labels.latestFeedOptionDescription,
1006
- )}
1007
- </div>
1008
- <p class="text-sm text-muted-foreground mt-2">
1009
- ${this.labels.mainRssFeedWarning}
1010
- </p>
1011
- </div>
937
+ </select>
938
+ </div>
1012
939
 
1013
- <div class="rounded-xl border border-border/70 bg-muted/30 p-4">
1014
- <div class="flex flex-col gap-4">
1015
- <div class="flex flex-col gap-1">
1016
- <p class="text-sm font-medium">
1017
- ${this.labels.availableFeedUrls}
1018
- </p>
1019
- <p class="text-sm text-muted-foreground">
1020
- ${this.labels.availableFeedUrlsHelp}
1021
- </p>
1022
- </div>
1023
-
1024
- ${this._renderFeedInfoRow(
1025
- this.labels.mainFeedUrl,
1026
- this.mainFeedUrl,
1027
- )}
1028
- ${this._renderFeedInfoRow(
1029
- this.labels.latestFeedUrl,
1030
- this.latestFeedUrl,
1031
- )}
1032
- ${this._renderFeedInfoRow(
1033
- this.labels.featuredFeedUrl,
1034
- this.featuredFeedUrl,
1035
- )}
1036
- ${this._renderFeedInfoRow(
1037
- this.labels.archiveFeedUrl,
1038
- this.archiveFeedUrl,
1039
- this.labels.archiveFeedUrlHelp,
1040
- )}
1041
- </div>
1042
- </div>
940
+ ${this._renderSaveAction(this._localeLoading, this._localeDirty, () =>
941
+ this._saveLocale(),
942
+ )}
943
+ </section>
944
+ `;
945
+ }
1043
946
 
1044
- ${this._renderSaveAction(this._feedLoading, this._feedDirty, () =>
1045
- this._saveFeeds(),
1046
- )}
1047
- </section>
947
+ private _renderHomeSection() {
948
+ return html`
949
+ <section class="${SECTION_CLASS} gap-4">
950
+ ${this._renderSectionTitle(this.labels.home)}
951
+ <label class="flex items-center gap-2 cursor-pointer">
952
+ <input
953
+ type="checkbox"
954
+ class="checkbox"
955
+ .checked=${this._showJantBrandingOnHome}
956
+ ?disabled=${this._homeLoading}
957
+ @change=${(e: Event) =>
958
+ this._saveHomeToggle((e.target as HTMLInputElement).checked)}
959
+ />
960
+ <span>${this.labels.showJantBrandingOnHome}</span>
961
+ </label>
962
+ </section>
963
+ `;
964
+ }
1048
965
 
1049
- <section class="flex flex-col gap-4 border-t pt-8 pb-6">
1050
- ${this._renderSectionTitle(this.labels.home)}
966
+ /**
967
+ * Jant Discover.
968
+ *
969
+ * Saves on change, like the indexing checkbox next to it: the box is a
970
+ * complete answer on its own, so there is nothing a Save button would be
971
+ * waiting for. One box and no sub-choice — which of a blog's posts reach
972
+ * which list is the directory's own rule, stated on the directory.
973
+ *
974
+ * Ticking the box is also what announces a self-hosted site to the
975
+ * directory.
976
+ */
977
+ private _renderDiscoverForm() {
978
+ const effective = this._effectiveDiscoverMode();
979
+ const enabled = effective !== "none";
980
+ const locked = this.demoMode || !this.feedsEnabled;
981
+ // The box unticks itself when search indexing goes off, and a control that
982
+ // moves on its own has to say why. Asked as a counterfactual rather than
983
+ // read off `noindex`, so the line appears only where it is the reason: a
984
+ // site the deployment would have listed, held back by that one setting.
985
+ // A site nobody would list either way is not being held back by anything,
986
+ // and an owner who ticked the box is not affected at all.
987
+ const heldBackBySearch =
988
+ !locked &&
989
+ !enabled &&
990
+ this._effectiveDiscoverMode({ noindex: false }) !== "none";
991
+
992
+ return html`
993
+ <div class="flex flex-col gap-3">
994
+ <div class="flex flex-col gap-2">
1051
995
  <label class="flex items-center gap-2 cursor-pointer">
1052
996
  <input
1053
997
  type="checkbox"
1054
998
  class="checkbox"
1055
- .checked=${this._showJantBrandingOnHome}
1056
- ?disabled=${this._homeLoading}
999
+ .checked=${enabled}
1000
+ ?disabled=${locked || this._discoverLoading}
1057
1001
  @change=${(e: Event) =>
1058
- this._saveHomeToggle((e.target as HTMLInputElement).checked)}
1002
+ this._onDiscoverToggle((e.target as HTMLInputElement).checked)}
1059
1003
  />
1060
- <span>${this.labels.showJantBrandingOnHome}</span>
1004
+ <span>${this.labels.discoverEnabled}</span>
1061
1005
  </label>
1062
- </section>
1006
+ <p class="text-sm text-muted-foreground">
1007
+ ${
1008
+ locked
1009
+ ? this.demoMode
1010
+ ? this.labels.discoverDemoLocked
1011
+ : this.labels.discoverFeedsOffLocked
1012
+ : this._renderDiscoverIntro()
1013
+ }
1014
+ </p>
1015
+ ${
1016
+ heldBackBySearch
1017
+ ? html`<p class="text-sm text-muted-foreground">
1018
+ ${this.labels.discoverSearchOff}
1019
+ </p>`
1020
+ : nothing
1021
+ }
1022
+ </div>
1023
+ ${locked ? nothing : this._renderDiscoverStatus()}
1063
1024
  </div>
1064
1025
  `;
1065
1026
  }
1066
1027
 
1067
- private _renderSearchForm() {
1028
+ /**
1029
+ * The help line, with each page it names linking to that page.
1030
+ *
1031
+ * Which words link where is `discoverIntroRuns`'s to decide, so the setup
1032
+ * screen, which shows the same line, links the same words.
1033
+ */
1034
+ private _renderDiscoverIntro() {
1035
+ const runs = discoverIntroRuns(
1036
+ this.labels.discoverIntro ?? "",
1037
+ {
1038
+ name: this.labels.discoverName ?? "",
1039
+ rules: this.labels.discoverRules ?? "",
1040
+ },
1041
+ this.discoverPages,
1042
+ );
1043
+
1044
+ return runs.map((run) =>
1045
+ run.href
1046
+ ? html`<a
1047
+ href=${run.href}
1048
+ target="_blank"
1049
+ rel="noopener noreferrer"
1050
+ class="underline hover:text-foreground transition-colors"
1051
+ >${run.text}</a
1052
+ >`
1053
+ : run.text,
1054
+ );
1055
+ }
1056
+
1057
+ /**
1058
+ * What is left to say once the controls above have spoken.
1059
+ *
1060
+ * Not a status report — a site whose Discover setting is doing exactly what
1061
+ * it says gets nothing here, and no heading announces a block that is
1062
+ * usually absent. The server sends a line only for a problem, a task, or an
1063
+ * answer still outstanding; deliberately all local evidence, because the
1064
+ * directory takes no status queries and cannot be asked whether a person has
1065
+ * moderated the site.
1066
+ *
1067
+ * The announce button appears when the directory has not heard from this
1068
+ * site; the manual form only when an announcement actually failed — beside a
1069
+ * working one it would read as a normal route in rather than the recovery it
1070
+ * is. The lines are what the server sent with the page, so ticking the box
1071
+ * does not rewrite them; the next load does.
1072
+ */
1073
+ private _renderDiscoverStatus() {
1074
+ const status = this._parsedDiscoverStatus();
1075
+ if (!status) return nothing;
1076
+ // The server wrote these for the mode the page loaded with. A blog that
1077
+ // has just switched itself off is not the blog they describe.
1078
+ if (this._effectiveDiscoverMode() === "none") return nothing;
1079
+ if (status.lines.length === 0 && !status.showAnnounce) return nothing;
1080
+
1068
1081
  return html`
1069
- <section class="flex flex-col gap-4 border-t pt-8">
1070
- ${this._renderSectionTitle(this.labels.search)}
1071
- <label class="flex items-center gap-2 cursor-pointer">
1072
- <input
1073
- type="checkbox"
1074
- class="checkbox"
1075
- .checked=${!this._noindex}
1076
- ?disabled=${this.demoMode || this._searchLoading}
1077
- @change=${(e: Event) =>
1078
- this._saveSearchToggle((e.target as HTMLInputElement).checked)}
1079
- />
1080
- <span>${this.labels.allowIndexing}</span>
1081
- </label>
1082
- ${this.demoMode
1083
- ? html`<p class="text-sm text-muted-foreground">
1084
- ${this.labels.demoSeoLocked}
1085
- </p>`
1086
- : nothing}
1087
- </section>
1082
+ <div class="flex flex-col gap-1 border-t pt-3">
1083
+ ${status.lines.map(
1084
+ (line) => html`<p class="text-sm text-muted-foreground">${line}</p>`,
1085
+ )}
1086
+ ${
1087
+ status.showAnnounce
1088
+ ? html`
1089
+ <div class="flex flex-wrap items-center gap-3 mt-1">
1090
+ <button
1091
+ type="button"
1092
+ class="btn-outline"
1093
+ ?disabled=${this._discoverLoading}
1094
+ @click=${() => this._announce()}
1095
+ >
1096
+ ${this.labels.discoverAnnounce}
1097
+ </button>
1098
+ ${
1099
+ status.submitUrl
1100
+ ? html`<a
1101
+ class="text-sm underline hover:text-foreground transition-colors"
1102
+ href=${status.submitUrl}
1103
+ target="_blank"
1104
+ rel="noopener noreferrer"
1105
+ >${this.labels.discoverAnnounceManual}</a
1106
+ >`
1107
+ : nothing
1108
+ }
1109
+ </div>
1110
+ `
1111
+ : nothing
1112
+ }
1113
+ </div>
1088
1114
  `;
1089
1115
  }
1090
1116
 
1117
+ private _parsedDiscoverStatus(): {
1118
+ lines: string[];
1119
+ showAnnounce: boolean;
1120
+ submitUrl: string | null;
1121
+ } | null {
1122
+ if (!this.discoverStatus) return null;
1123
+ try {
1124
+ const parsed: unknown = JSON.parse(this.discoverStatus);
1125
+ if (typeof parsed !== "object" || parsed === null) return null;
1126
+ const value = parsed as Record<string, unknown>;
1127
+ const lines = Array.isArray(value["lines"])
1128
+ ? value["lines"].filter(
1129
+ (line): line is string => typeof line === "string",
1130
+ )
1131
+ : [];
1132
+ return {
1133
+ lines,
1134
+ showAnnounce: value["showAnnounce"] === true,
1135
+ submitUrl:
1136
+ typeof value["submitUrl"] === "string" ? value["submitUrl"] : null,
1137
+ };
1138
+ } catch {
1139
+ // A settings page that renders without its status block is far better
1140
+ // than one that does not render.
1141
+ return null;
1142
+ }
1143
+ }
1144
+
1145
+ private _announce() {
1146
+ if (this._discoverLoading) return;
1147
+ this._discoverLoading = true;
1148
+ this.dispatchEvent(
1149
+ new CustomEvent("jant:settings-save", {
1150
+ bubbles: true,
1151
+ detail: {
1152
+ endpoint: "/settings/general/discover/announce",
1153
+ data: {},
1154
+ section: "discover",
1155
+ },
1156
+ }),
1157
+ );
1158
+ }
1159
+
1091
1160
  render() {
1092
1161
  return html`
1093
- <div class="flex flex-col">
1094
- ${this._renderGeneralForm()} ${this._renderSearchForm()}
1162
+ <div class="flex flex-col gap-8">
1163
+ <h2 class="text-lg font-semibold">${this.labels.general}</h2>
1164
+ <div class="flex flex-col divide-y">
1165
+ ${this._renderSiteSection()} ${this._renderVisibilitySection()}
1166
+ ${this._renderFeedsSection()} ${this._renderTimeSection()}
1167
+ ${this._renderHomeSection()}
1168
+ </div>
1095
1169
  </div>
1096
1170
  `;
1097
1171
  }