@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Collection Dialog
3
+ *
4
+ * Creating and editing a collection are the same surface, the way they already
5
+ * are for a smart collection. There is no editor page: a collection is a
6
+ * title, an address, a description and an ordering, and sending the author to
7
+ * a separate page — and back again — to decide four things was the whole cost
8
+ * of the old flow.
9
+ *
10
+ * Delete is not here. Every menu that opens this dialog offers it one item
11
+ * away from Edit, and a destructive button sharing a row with Save is a
12
+ * misclick waiting to happen.
13
+ *
14
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
15
+ */
16
+
17
+ import { LitElement, html, nothing } from "lit";
18
+ import type { Editor } from "@tiptap/core";
19
+ import { getCollectionPagePath } from "../../lib/collection-paths.js";
20
+ import { getSlugValidationIssue, truncateSlug } from "../../lib/slug-format.js";
21
+ import {
22
+ COLLECTION_SORT_ORDERS,
23
+ MAX_COLLECTION_SLUG_LENGTH,
24
+ MAX_COLLECTION_TITLE_LENGTH,
25
+ } from "../../types/constants.js";
26
+ import type { Collection, CollectionSortOrder } from "../../types.js";
27
+ import { slugify } from "../lazy-slugify.js";
28
+ import { publicPath } from "../runtime-paths.js";
29
+ import { showToast } from "../toast.js";
30
+ import {
31
+ createSettingsEditor,
32
+ jsonToMarkdown,
33
+ } from "../tiptap/create-editor.js";
34
+ import type {
35
+ CollectionDialogLabels,
36
+ CollectionDialogResult,
37
+ } from "./collection-dialog-types.js";
38
+
39
+ /** How long to wait after a keystroke before asking whether an address is free. */
40
+ const SLUG_DEBOUNCE_MS = 250;
41
+
42
+ /**
43
+ * Lucide `circle-alert`.
44
+ *
45
+ * Not decoration: `.alert` lays its text out in the second grid column, and the
46
+ * first column only has a width when there is an icon in it.
47
+ */
48
+ const ALERT_ICON = html`<svg
49
+ viewBox="0 0 24 24"
50
+ fill="none"
51
+ stroke="currentColor"
52
+ stroke-width="2"
53
+ stroke-linecap="round"
54
+ stroke-linejoin="round"
55
+ aria-hidden="true"
56
+ >
57
+ <circle cx="12" cy="12" r="10" />
58
+ <line x1="12" x2="12" y1="8" y2="12" />
59
+ <line x1="12" x2="12.01" y1="16" y2="16" />
60
+ </svg>`;
61
+
62
+ export class JantCollectionDialog extends LitElement {
63
+ static properties = {
64
+ labels: { type: Object },
65
+
66
+ _open: { state: true },
67
+ _mode: { state: true },
68
+ _id: { state: true },
69
+ _title: { state: true },
70
+ _slug: { state: true },
71
+ _slugEdited: { state: true },
72
+ _slugState: { state: true },
73
+ _slugOpen: { state: true },
74
+ _sortOrder: { state: true },
75
+ _saving: { state: true },
76
+ _error: { state: true },
77
+ };
78
+
79
+ declare labels: CollectionDialogLabels;
80
+
81
+ declare _open: boolean;
82
+ declare _mode: "create" | "edit";
83
+ declare _id: string | null;
84
+ declare _title: string;
85
+ declare _slug: string;
86
+ declare _slugEdited: boolean;
87
+ declare _slugState: "unknown" | "checking" | "available" | "taken";
88
+ declare _slugOpen: boolean;
89
+ declare _sortOrder: CollectionSortOrder;
90
+ declare _saving: boolean;
91
+ declare _error: string;
92
+
93
+ /**
94
+ * The description, in markdown.
95
+ *
96
+ * Not reactive state: the editor owns the text once it is mounted, and a
97
+ * re-render on every keystroke would fight it for the DOM.
98
+ */
99
+ #description = "";
100
+ /** Set when the dialog was opened on an existing link that must not move. */
101
+ #originalSlug = "";
102
+ /** The link the current title would produce, for "Reset link". */
103
+ #suggestedSlug = "";
104
+ #resolve: ((result: CollectionDialogResult) => void) | null = null;
105
+ #slugTimer: ReturnType<typeof setTimeout> | null = null;
106
+ #editor: Editor | null = null;
107
+
108
+ createRenderRoot() {
109
+ this.innerHTML = "";
110
+ return this;
111
+ }
112
+
113
+ constructor() {
114
+ super();
115
+ this.labels = {} as CollectionDialogLabels;
116
+ this._open = false;
117
+ this._mode = "create";
118
+ this._id = null;
119
+ this._title = "";
120
+ this._slug = "";
121
+ this._slugEdited = false;
122
+ this._slugState = "unknown";
123
+ this._slugOpen = false;
124
+ this._sortOrder = "newest";
125
+ this._saving = false;
126
+ this._error = "";
127
+ }
128
+
129
+ /**
130
+ * Open the dialog, and resolve once it closes.
131
+ *
132
+ * @param options.collectionId - Edit this one; omit to create
133
+ * @returns What happened, and the collection when one was saved
134
+ */
135
+ async open(options: {
136
+ collectionId?: string;
137
+ }): Promise<CollectionDialogResult> {
138
+ this.#reset();
139
+
140
+ if (options.collectionId) {
141
+ const loaded = await this.#load(options.collectionId);
142
+ if (!loaded) return { changed: false };
143
+ }
144
+
145
+ this._open = true;
146
+ await this.updateComplete;
147
+ this.#mountEditor();
148
+ this.#showDialog();
149
+
150
+ return new Promise<CollectionDialogResult>((resolve) => {
151
+ this.#resolve = resolve;
152
+ });
153
+ }
154
+
155
+ #reset() {
156
+ this._mode = "create";
157
+ this._id = null;
158
+ this._title = "";
159
+ this._slug = "";
160
+ this._slugEdited = false;
161
+ this._slugState = "unknown";
162
+ this._slugOpen = false;
163
+ this._sortOrder = "newest";
164
+ this._saving = false;
165
+ this._error = "";
166
+ this.#description = "";
167
+ this.#originalSlug = "";
168
+ this.#suggestedSlug = "";
169
+ }
170
+
171
+ async #load(id: string): Promise<boolean> {
172
+ try {
173
+ const res = await fetch(publicPath(`/api/collections/${id}`));
174
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
175
+ const loaded = (await res.json()) as Collection;
176
+ this._mode = "edit";
177
+ this._id = loaded.id;
178
+ this._title = loaded.title ?? "";
179
+ this._slug = loaded.slug ?? "";
180
+ this.#originalSlug = loaded.slug ?? "";
181
+ this._slugEdited = true;
182
+ this.#description = loaded.description ?? "";
183
+ this._sortOrder = loaded.sortOrder ?? "newest";
184
+ return true;
185
+ } catch {
186
+ showToast(this.labels.loadFailed, "error");
187
+ return false;
188
+ }
189
+ }
190
+
191
+ #showDialog() {
192
+ const dialog = this.querySelector<HTMLDialogElement>(".collection-dialog");
193
+ if (!dialog) return;
194
+ if (!dialog.open) dialog.showModal();
195
+ // Initial focus lands on the first thing the author types into, not on the
196
+ // dialog shell.
197
+ this.querySelector<HTMLInputElement>("[data-field='title']")?.focus();
198
+ }
199
+
200
+ #close(result: CollectionDialogResult) {
201
+ const dialog = this.querySelector<HTMLDialogElement>(".collection-dialog");
202
+ if (dialog?.open) dialog.close();
203
+ this._open = false;
204
+ if (this.#slugTimer) clearTimeout(this.#slugTimer);
205
+ this.#destroyEditor();
206
+ const resolve = this.#resolve;
207
+ this.#resolve = null;
208
+ resolve?.(result);
209
+ }
210
+
211
+ // --- Description ---------------------------------------------------------
212
+
213
+ /**
214
+ * Mount the rich-text editor into the opened dialog.
215
+ *
216
+ * Built on open and destroyed on close rather than kept alive: the dialog is
217
+ * reused across collections, and an editor holding the previous one's text
218
+ * would need clearing anyway.
219
+ */
220
+ #mountEditor() {
221
+ const container = this.querySelector<HTMLElement>(
222
+ "[data-collection-description]",
223
+ );
224
+ if (!container || this.#editor) return;
225
+
226
+ this.#editor = createSettingsEditor({
227
+ element: container,
228
+ placeholder: this.labels.descriptionPlaceholder,
229
+ content: this.#description || undefined,
230
+ onUpdate: (markdown) => {
231
+ this.#description = markdown;
232
+ if (this._error) this._error = "";
233
+ },
234
+ });
235
+
236
+ // Normalize the loaded markdown through the same round trip a keystroke
237
+ // would take, so saving without touching it is not an edit.
238
+ this.#description = jsonToMarkdown(this.#editor.getJSON());
239
+ }
240
+
241
+ #destroyEditor() {
242
+ this.#editor?.destroy();
243
+ this.#editor = null;
244
+ }
245
+
246
+ // --- Address -------------------------------------------------------------
247
+
248
+ #onTitleInput(value: string) {
249
+ this._title = value;
250
+ if (this._slugEdited) return;
251
+ void this.#suggestSlug();
252
+ }
253
+
254
+ async #suggestSlug() {
255
+ const title = this._title.trim();
256
+ if (!title) {
257
+ this.#suggestedSlug = "";
258
+ this._slug = "";
259
+ this._slugState = "unknown";
260
+ return;
261
+ }
262
+ // Local first so the field never lags behind typing; the server only
263
+ // resolves collisions.
264
+ const suggested = truncateSlug(
265
+ await slugify(title),
266
+ MAX_COLLECTION_SLUG_LENGTH,
267
+ );
268
+ // Typing carried on while `slugify` loaded its dictionary; that later
269
+ // keystroke owns the address now.
270
+ if (this._slugEdited || this._title.trim() !== title) return;
271
+ this.#suggestedSlug = suggested;
272
+ this._slug = suggested;
273
+ this.#scheduleSlugCheck();
274
+ }
275
+
276
+ #onSlugInput(value: string) {
277
+ this._slugEdited = true;
278
+ this._slug = value.trim().toLowerCase();
279
+ this.#scheduleSlugCheck();
280
+ }
281
+
282
+ #scheduleSlugCheck() {
283
+ if (this.#slugTimer) clearTimeout(this.#slugTimer);
284
+ this._slugState = "checking";
285
+ this.#slugTimer = setTimeout(
286
+ () => void this.#checkSlug(),
287
+ SLUG_DEBOUNCE_MS,
288
+ );
289
+ }
290
+
291
+ async #checkSlug() {
292
+ const slug = this._slug;
293
+ if (!slug) {
294
+ this._slugState = "unknown";
295
+ return;
296
+ }
297
+ // A link with bad characters is not "taken", and saying so sends the author
298
+ // hunting for a collision that does not exist.
299
+ if (this.#slugFormatIssue() !== null) {
300
+ this._slugState = "taken";
301
+ return;
302
+ }
303
+ try {
304
+ const params = new URLSearchParams({ mode: "check", slug });
305
+ if (this._id) params.set("collectionId", this._id);
306
+ const res = await fetch(
307
+ publicPath(`/api/collections/slug?${params.toString()}`),
308
+ );
309
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
310
+ const json = (await res.json()) as { available: boolean };
311
+ if (slug !== this._slug) return;
312
+ this._slugState = json.available ? "available" : "taken";
313
+ } catch {
314
+ if (slug !== this._slug) return;
315
+ this._slugState = "unknown";
316
+ }
317
+ }
318
+
319
+ #slugFormatIssue(): "invalid" | "reserved" | "too_long" | null {
320
+ return getSlugValidationIssue(this._slug, {
321
+ maxLength: MAX_COLLECTION_SLUG_LENGTH,
322
+ });
323
+ }
324
+
325
+ /** Why this link is refused, or null when it is fine. */
326
+ #slugProblem(): string | null {
327
+ const issue = this.#slugFormatIssue();
328
+ if (issue === "invalid") return this.labels.linkInvalid;
329
+ if (issue === "reserved") return this.labels.linkReserved;
330
+ if (issue === "too_long") return this.labels.linkTooLong;
331
+ if (this._slugState === "taken") return this.labels.linkTaken;
332
+ return null;
333
+ }
334
+
335
+ /** The whole URL, because that is the thing being decided. */
336
+ #linkPreview(): string {
337
+ const path = publicPath(getCollectionPagePath(this._slug));
338
+ const origin =
339
+ globalThis.location?.origin && globalThis.location.origin !== "null"
340
+ ? globalThis.location.origin
341
+ : "http://localhost";
342
+ return new URL(path, `${origin}/`).toString();
343
+ }
344
+
345
+ #openSlugEditor() {
346
+ if (this._slugOpen) return;
347
+ this._slugOpen = true;
348
+ void this.updateComplete.then(() => {
349
+ const input = this.querySelector<HTMLInputElement>("[data-field='slug']");
350
+ input?.focus();
351
+ input?.select();
352
+ });
353
+ }
354
+
355
+ #resetSlug() {
356
+ if (!this.#suggestedSlug) return;
357
+ this._slug = this.#suggestedSlug;
358
+ this._slugEdited = false;
359
+ this.#scheduleSlugCheck();
360
+ }
361
+
362
+ /**
363
+ * The link, folded away until the author wants it.
364
+ *
365
+ * The title already answers it, and the whole URL says more than a bare slug
366
+ * does — the same shape the smart collection dialog uses.
367
+ */
368
+ #renderLink() {
369
+ if (!this._slug && !this._slugOpen) return nothing;
370
+
371
+ const problem = this.#slugProblem();
372
+ const moved = this._mode === "edit" && this._slug !== this.#originalSlug;
373
+
374
+ if (!this._slugOpen) {
375
+ return html`
376
+ <div class="collection-quick-link-box">
377
+ <div class="collection-quick-link-row">
378
+ <p
379
+ class="collection-quick-link-preview text-xs text-muted-foreground"
380
+ aria-live="polite"
381
+ >
382
+ ${this.#linkPreview()}
383
+ </p>
384
+ <button
385
+ type="button"
386
+ class="collection-quick-link-action"
387
+ @click=${() => this.#openSlugEditor()}
388
+ >
389
+ ${this.labels.editLink}
390
+ </button>
391
+ </div>
392
+ ${
393
+ problem
394
+ ? html`<p class="collection-dialog-link-problem">${problem}</p>`
395
+ : nothing
396
+ }
397
+ ${
398
+ moved
399
+ ? html`<p class="collection-dialog-link-problem">
400
+ ${this.labels.linkMovesWarning}
401
+ </p>`
402
+ : nothing
403
+ }
404
+ </div>
405
+ `;
406
+ }
407
+
408
+ const canReset =
409
+ Boolean(this.#suggestedSlug) && this._slug !== this.#suggestedSlug;
410
+
411
+ return html`
412
+ <div class="collection-quick-link-editor">
413
+ <div class="field">
414
+ <div class="collection-quick-link-row">
415
+ <label class="label mb-0" for="collection-dialog-slug"
416
+ >${this.labels.link}</label
417
+ >
418
+ ${
419
+ canReset
420
+ ? html`
421
+ <button
422
+ type="button"
423
+ class="collection-quick-link-action"
424
+ @click=${() => this.#resetSlug()}
425
+ >
426
+ ${this.labels.resetLink}
427
+ </button>
428
+ `
429
+ : nothing
430
+ }
431
+ </div>
432
+ <input
433
+ id="collection-dialog-slug"
434
+ class="input"
435
+ type="text"
436
+ data-field="slug"
437
+ required
438
+ maxlength=${MAX_COLLECTION_SLUG_LENGTH}
439
+ autocomplete="off"
440
+ autocapitalize="off"
441
+ spellcheck="false"
442
+ aria-invalid=${problem ? "true" : "false"}
443
+ .value=${this._slug}
444
+ @input=${(event: Event) =>
445
+ this.#onSlugInput((event.target as HTMLInputElement).value)}
446
+ />
447
+ ${
448
+ problem
449
+ ? html`<p class="collection-dialog-link-problem">${problem}</p>`
450
+ : this._slug
451
+ ? html`<p class="collection-dialog-link-preview">
452
+ ${this.#linkPreview()}
453
+ </p>`
454
+ : html`<p class="collection-dialog-link-help">
455
+ ${this.labels.linkHelp}
456
+ </p>`
457
+ }
458
+ ${
459
+ moved
460
+ ? html`<p class="collection-dialog-link-problem">
461
+ ${this.labels.linkMovesWarning}
462
+ </p>`
463
+ : nothing
464
+ }
465
+ </div>
466
+ </div>
467
+ `;
468
+ }
469
+
470
+ // --- Save ----------------------------------------------------------------
471
+
472
+ /**
473
+ * Why this cannot be saved yet, or null when it can.
474
+ *
475
+ * One answer for the Save button, the Enter key, and the error line: a button
476
+ * greyed out for a reason the author is never told is the usual way this
477
+ * goes wrong, so the reason is always the message they would have got.
478
+ */
479
+ #blockingIssue(): string | null {
480
+ if (!this._title.trim() || !this._slug) {
481
+ return this.labels.titleAndLinkRequired;
482
+ }
483
+ return this.#slugProblem();
484
+ }
485
+
486
+ async #save() {
487
+ if (this._saving) return;
488
+ const issue = this.#blockingIssue();
489
+ if (issue) {
490
+ this._error = issue;
491
+ return;
492
+ }
493
+
494
+ this._saving = true;
495
+ this._error = "";
496
+
497
+ const title = this._title.trim();
498
+ const description = this.#description.trim();
499
+ const body: Record<string, unknown> = {
500
+ slug: this._slug,
501
+ title,
502
+ sortOrder: this._sortOrder,
503
+ };
504
+ // Creating refuses an explicit null, and only an edit can be clearing a
505
+ // description that used to be there.
506
+ if (description) body.description = description;
507
+ else if (this._id) body.description = null;
508
+
509
+ try {
510
+ const res = await fetch(
511
+ publicPath(
512
+ this._id ? `/api/collections/${this._id}` : "/api/collections",
513
+ ),
514
+ {
515
+ method: this._id ? "PUT" : "POST",
516
+ headers: { "Content-Type": "application/json" },
517
+ body: JSON.stringify(body),
518
+ },
519
+ );
520
+ const json = (await res.json().catch(() => null)) as
521
+ (Partial<Collection> & { error?: string }) | null;
522
+
523
+ if (!res.ok) {
524
+ this._error = json?.error ?? this.labels.saveFailed;
525
+ return;
526
+ }
527
+
528
+ showToast(this.labels.saved);
529
+ this.#close({
530
+ changed: true,
531
+ collection: {
532
+ id: json?.id ?? this._id ?? "",
533
+ slug: json?.slug ?? this._slug,
534
+ title: json?.title ?? title,
535
+ },
536
+ });
537
+ } catch {
538
+ this._error = this.labels.saveFailed;
539
+ } finally {
540
+ this._saving = false;
541
+ }
542
+ }
543
+
544
+ // --- Keyboard ------------------------------------------------------------
545
+
546
+ /**
547
+ * Escape and Enter, handled on the component rather than only on the
548
+ * `<dialog>`.
549
+ *
550
+ * A native `cancel` event is not reliable here: the description editor
551
+ * intercepts `keydown`, so Escape inside it never reaches the dialog. The
552
+ * component-level handler is what actually holds.
553
+ */
554
+ #onKeydown = (event: KeyboardEvent) => {
555
+ if (!this._open) return;
556
+
557
+ if (event.key === "Escape") {
558
+ event.preventDefault();
559
+ event.stopPropagation();
560
+ this.#close({ changed: false });
561
+ return;
562
+ }
563
+
564
+ if (event.key === "Enter") {
565
+ const target = event.target as HTMLElement | null;
566
+ // Enter inside the description is a new paragraph, not a submit.
567
+ if (target?.isContentEditable) return;
568
+ if (target?.tagName === "TEXTAREA") return;
569
+ event.preventDefault();
570
+ void this.#save();
571
+ }
572
+ };
573
+
574
+ connectedCallback() {
575
+ super.connectedCallback();
576
+ this.addEventListener("keydown", this.#onKeydown);
577
+ }
578
+
579
+ disconnectedCallback() {
580
+ super.disconnectedCallback();
581
+ this.removeEventListener("keydown", this.#onKeydown);
582
+ if (this.#slugTimer) clearTimeout(this.#slugTimer);
583
+ this.#destroyEditor();
584
+ }
585
+
586
+ // --- Render --------------------------------------------------------------
587
+
588
+ render() {
589
+ if (!this._open) return nothing;
590
+
591
+ const heading =
592
+ this._mode === "edit"
593
+ ? this.labels.editHeading
594
+ : this.labels.createHeading;
595
+
596
+ return html`
597
+ <dialog
598
+ class="dialog collection-dialog"
599
+ @cancel=${(event: Event) => {
600
+ event.preventDefault();
601
+ this.#close({ changed: false });
602
+ }}
603
+ @click=${(event: Event) => {
604
+ if (event.target === event.currentTarget) {
605
+ this.#close({ changed: false });
606
+ }
607
+ }}
608
+ >
609
+ <div
610
+ class="collection-dialog-panel"
611
+ role="dialog"
612
+ aria-modal="true"
613
+ aria-labelledby="collection-dialog-title"
614
+ @click=${(event: Event) => event.stopPropagation()}
615
+ >
616
+ <header class="collection-dialog-header">
617
+ <h2 id="collection-dialog-title" class="collection-dialog-title">
618
+ ${heading}
619
+ </h2>
620
+ </header>
621
+
622
+ <div
623
+ class="collection-dialog-body"
624
+ @input=${() => {
625
+ this._error = "";
626
+ }}
627
+ @change=${() => {
628
+ this._error = "";
629
+ }}
630
+ >
631
+ <div class="field">
632
+ <label for="collection-dialog-title-field"
633
+ >${this.labels.title}</label
634
+ >
635
+ <input
636
+ id="collection-dialog-title-field"
637
+ class="input"
638
+ type="text"
639
+ data-field="title"
640
+ required
641
+ maxlength=${MAX_COLLECTION_TITLE_LENGTH}
642
+ placeholder=${
643
+ this._mode === "edit" ? nothing : this.labels.titlePlaceholder
644
+ }
645
+ .value=${this._title}
646
+ @input=${(event: Event) =>
647
+ this.#onTitleInput((event.target as HTMLInputElement).value)}
648
+ />
649
+ </div>
650
+
651
+ ${this.#renderLink()}
652
+
653
+ <div class="field">
654
+ <!-- The label carries no "for": the editor is a contenteditable
655
+ region, not a labelable control. -->
656
+ <label>${this.labels.description}</label>
657
+ <div
658
+ class="settings-tiptap-editor"
659
+ data-collection-description
660
+ ></div>
661
+ </div>
662
+
663
+ <div class="field">
664
+ <label for="collection-dialog-sort">${this.labels.orderBy}</label>
665
+ <select
666
+ id="collection-dialog-sort"
667
+ class="select"
668
+ data-field="sort"
669
+ .value=${this._sortOrder}
670
+ @change=${(event: Event) => {
671
+ this._sortOrder = (event.target as HTMLSelectElement)
672
+ .value as CollectionSortOrder;
673
+ }}
674
+ >
675
+ ${COLLECTION_SORT_ORDERS.map(
676
+ (value) =>
677
+ html`<option
678
+ value=${value}
679
+ ?selected=${value === this._sortOrder}
680
+ >
681
+ ${this.labels.sortOptions[value]}
682
+ </option>`,
683
+ )}
684
+ </select>
685
+ </div>
686
+
687
+ ${
688
+ this._error
689
+ ? html`<div class="alert-destructive" role="alert">
690
+ ${ALERT_ICON}
691
+ <section><p>${this._error}</p></section>
692
+ </div>`
693
+ : nothing
694
+ }
695
+ </div>
696
+
697
+ <footer class="collection-dialog-actions">
698
+ <button
699
+ type="button"
700
+ class="btn-outline"
701
+ @click=${() => this.#close({ changed: false })}
702
+ >
703
+ ${this.labels.cancel}
704
+ </button>
705
+ <button
706
+ type="button"
707
+ class="btn"
708
+ ?disabled=${this._saving || this.#blockingIssue() !== null}
709
+ @click=${() => void this.#save()}
710
+ >
711
+ ${this.labels.save}
712
+ </button>
713
+ </footer>
714
+ </div>
715
+ </dialog>
716
+ `;
717
+ }
718
+ }
719
+
720
+ if (!customElements.get("jant-collection-dialog")) {
721
+ customElements.define("jant-collection-dialog", JantCollectionDialog);
722
+ }