@jant/core 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -0,0 +1,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
+ }