@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,1021 @@
1
+ /**
2
+ * Smart Collection Dialog
3
+ *
4
+ * Creating and editing a smart collection are the same surface. There is no
5
+ * editor page and no `/{path}/edit` route: a smart collection is a handful of
6
+ * fields plus a list of conditions, and the live count is what makes it
7
+ * possible to write conditions at all — without it this is a blind form that
8
+ * can be saved matching nothing.
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
+ * The condition rows are deliberately *not* field-operator-value the way
15
+ * Apple Notes and iTunes do it. That shape is built for an open condition
16
+ * space, and here it would promise two things the model cannot deliver: a
17
+ * second `Format is …` row (which would be an OR, and format is one column),
18
+ * and a `Match [all|any]` switch (everything is AND). This keeps the shape and
19
+ * drops the promises — one row per dimension, only the dimensions actually set,
20
+ * and a menu that offers only the ones still unused.
21
+ *
22
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
23
+ */
24
+
25
+ import { LitElement, html, nothing } from "lit";
26
+ import { getCollectionPagePath } from "../../lib/collection-paths.js";
27
+ import { getSlugValidationIssue, truncateSlug } from "../../lib/slug-format.js";
28
+ import { MAX_COLLECTION_SLUG_LENGTH } from "../../types/constants.js";
29
+ import { slugify } from "../lazy-slugify.js";
30
+ import { publicPath } from "../runtime-paths.js";
31
+ import { showToast } from "../toast.js";
32
+ import type {
33
+ SmartCollectionDialogLabels,
34
+ SmartCollectionDialogState,
35
+ SmartConditionRow,
36
+ } from "./smart-collection-dialog-types.js";
37
+ import {
38
+ DIMENSION_CONTROLS,
39
+ emptySelection,
40
+ selectionToRows,
41
+ rowsToSelection,
42
+ type DimensionKey,
43
+ } from "./smart-collection-conditions.js";
44
+
45
+ /** How long to wait after a keystroke before asking the server to count. */
46
+ const PREVIEW_DEBOUNCE_MS = 250;
47
+
48
+ /** Lucide `plus`, inline: the dialog is one component and owns its two icons. */
49
+ const PLUS_ICON = html`<svg
50
+ viewBox="0 0 24 24"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ stroke-width="2"
54
+ stroke-linecap="round"
55
+ stroke-linejoin="round"
56
+ aria-hidden="true"
57
+ >
58
+ <path d="M5 12h14" />
59
+ <path d="M12 5v14" />
60
+ </svg>`;
61
+
62
+ /**
63
+ * Lucide `circle-alert`.
64
+ *
65
+ * Not decoration: `.alert` lays its text out in the second grid column, and the
66
+ * first column only has a width when there is an icon in it.
67
+ */
68
+ const ALERT_ICON = html`<svg
69
+ viewBox="0 0 24 24"
70
+ fill="none"
71
+ stroke="currentColor"
72
+ stroke-width="2"
73
+ stroke-linecap="round"
74
+ stroke-linejoin="round"
75
+ aria-hidden="true"
76
+ >
77
+ <circle cx="12" cy="12" r="10" />
78
+ <line x1="12" x2="12" y1="8" y2="12" />
79
+ <line x1="12" x2="12.01" y1="16" y2="16" />
80
+ </svg>`;
81
+
82
+ /** Lucide `x`. A glyph, not a `&times;` character, so it scales with the icon. */
83
+ const X_ICON = html`<svg
84
+ viewBox="0 0 24 24"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ stroke-width="2"
88
+ stroke-linecap="round"
89
+ stroke-linejoin="round"
90
+ aria-hidden="true"
91
+ >
92
+ <path d="M18 6 6 18" />
93
+ <path d="m6 6 12 12" />
94
+ </svg>`;
95
+
96
+ export class JantSmartCollectionDialog extends LitElement {
97
+ static properties = {
98
+ labels: { type: Object },
99
+
100
+ _open: { state: true },
101
+ _mode: { state: true },
102
+ _id: { state: true },
103
+ _title: { state: true },
104
+ _slug: { state: true },
105
+ _slugEdited: { state: true },
106
+ _slugState: { state: true },
107
+ _slugOpen: { state: true },
108
+ _description: { state: true },
109
+ _rows: { state: true },
110
+ _sort: { state: true },
111
+ _layout: { state: true },
112
+ _preview: { state: true },
113
+ _addMenuOpen: { state: true },
114
+ _saving: { state: true },
115
+ _error: { state: true },
116
+ };
117
+
118
+ declare labels: SmartCollectionDialogLabels;
119
+
120
+ declare _open: boolean;
121
+ declare _mode: "create" | "edit";
122
+ declare _id: string | null;
123
+ declare _title: string;
124
+ declare _slug: string;
125
+ declare _slugEdited: boolean;
126
+ declare _slugState: "unknown" | "checking" | "available" | "taken";
127
+ declare _slugOpen: boolean;
128
+ declare _description: string;
129
+ declare _rows: SmartConditionRow[];
130
+ declare _sort: string;
131
+ declare _layout: string;
132
+ declare _preview: { count: number; baseline: number } | null;
133
+ declare _addMenuOpen: boolean;
134
+ declare _saving: boolean;
135
+ declare _error: string;
136
+
137
+ /** Set when the dialog was opened on an existing link that must not move. */
138
+ #originalSlug = "";
139
+ /** The link the current title would produce, for "Reset link". */
140
+ #suggestedSlug = "";
141
+ #resolve: ((changed: boolean) => void) | null = null;
142
+ #previewTimer: ReturnType<typeof setTimeout> | null = null;
143
+ #slugTimer: ReturnType<typeof setTimeout> | null = null;
144
+ #requestSeq = 0;
145
+
146
+ createRenderRoot() {
147
+ this.innerHTML = "";
148
+ return this;
149
+ }
150
+
151
+ constructor() {
152
+ super();
153
+ this.labels = {} as SmartCollectionDialogLabels;
154
+ this._open = false;
155
+ this._mode = "create";
156
+ this._id = null;
157
+ this._title = "";
158
+ this._slug = "";
159
+ this._slugEdited = false;
160
+ this._slugState = "unknown";
161
+ this._slugOpen = false;
162
+ this._description = "";
163
+ this._rows = [];
164
+ this._sort = "newest";
165
+ this._layout = "";
166
+ this._preview = null;
167
+ this._addMenuOpen = false;
168
+ this._saving = false;
169
+ this._error = "";
170
+ }
171
+
172
+ /**
173
+ * Open the dialog, and resolve once it closes.
174
+ *
175
+ * @param options.smartCollectionId - Edit this one; omit to create
176
+ * @param options.prefill - Starting values, for the "turn this into a smart
177
+ * collection" flows. Never saved without the author seeing them first.
178
+ * @returns Whether anything was created, changed, or deleted
179
+ */
180
+ async open(options: {
181
+ smartCollectionId?: string;
182
+ prefill?: SmartCollectionDialogState;
183
+ }): Promise<boolean> {
184
+ this.#reset();
185
+
186
+ if (options.smartCollectionId) {
187
+ const loaded = await this.#load(options.smartCollectionId);
188
+ if (!loaded) return false;
189
+ } else if (options.prefill) {
190
+ this._title = options.prefill.title ?? "";
191
+ this._description = options.prefill.description ?? "";
192
+ this._rows = selectionToRows(options.prefill.selection ?? {});
193
+ this._sort = options.prefill.sort ?? "newest";
194
+ this._layout = options.prefill.layout ?? "";
195
+ if (this._title) await this.#suggestSlug();
196
+ }
197
+
198
+ this._open = true;
199
+ await this.updateComplete;
200
+ this.#showDialog();
201
+ this.#schedulePreview();
202
+
203
+ return new Promise<boolean>((resolve) => {
204
+ this.#resolve = resolve;
205
+ });
206
+ }
207
+
208
+ #reset() {
209
+ this._mode = "create";
210
+ this._id = null;
211
+ this._title = "";
212
+ this._slug = "";
213
+ this._slugEdited = false;
214
+ this._slugState = "unknown";
215
+ this._slugOpen = false;
216
+ this._description = "";
217
+ this._rows = [];
218
+ this._sort = "newest";
219
+ this._layout = "";
220
+ this._preview = null;
221
+ this._addMenuOpen = false;
222
+ this._saving = false;
223
+ this._error = "";
224
+ this.#originalSlug = "";
225
+ }
226
+
227
+ async #load(id: string): Promise<boolean> {
228
+ try {
229
+ const res = await fetch(publicPath(`/api/smart-collections/${id}`));
230
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
231
+ const json = (await res.json()) as {
232
+ smartCollection: SmartCollectionDialogState & {
233
+ id: string;
234
+ slug: string;
235
+ };
236
+ };
237
+ const loaded = json.smartCollection;
238
+ this._mode = "edit";
239
+ this._id = loaded.id;
240
+ this._title = loaded.title ?? "";
241
+ this._slug = loaded.slug ?? "";
242
+ this.#originalSlug = loaded.slug ?? "";
243
+ this._slugEdited = true;
244
+ this._description = loaded.description ?? "";
245
+ this._rows = selectionToRows(loaded.selection ?? {});
246
+ this._sort = loaded.sort ?? "newest";
247
+ this._layout = loaded.layout ?? "";
248
+ return true;
249
+ } catch {
250
+ showToast(this.labels.loadFailed, "error");
251
+ return false;
252
+ }
253
+ }
254
+
255
+ #showDialog() {
256
+ const dialog = this.querySelector<HTMLDialogElement>(".collection-dialog");
257
+ if (!dialog) return;
258
+ if (!dialog.open) dialog.showModal();
259
+ // Initial focus lands on the first thing the author types into, not on the
260
+ // dialog shell.
261
+ this.querySelector<HTMLInputElement>("[data-field='title']")?.focus();
262
+ }
263
+
264
+ #close(changed: boolean) {
265
+ const dialog = this.querySelector<HTMLDialogElement>(".collection-dialog");
266
+ if (dialog?.open) dialog.close();
267
+ this._open = false;
268
+ this._addMenuOpen = false;
269
+ if (this.#previewTimer) clearTimeout(this.#previewTimer);
270
+ if (this.#slugTimer) clearTimeout(this.#slugTimer);
271
+ const resolve = this.#resolve;
272
+ this.#resolve = null;
273
+ resolve?.(changed);
274
+ }
275
+
276
+ // --- Conditions ----------------------------------------------------------
277
+
278
+ /** Dimensions not yet used. A dimension appears at most once, always. */
279
+ #availableDimensions(): DimensionKey[] {
280
+ const used = new Set(this._rows.map((row) => row.key));
281
+ return (Object.keys(DIMENSION_CONTROLS) as DimensionKey[]).filter(
282
+ (key) => !used.has(key),
283
+ );
284
+ }
285
+
286
+ #addRow(key: DimensionKey) {
287
+ this._addMenuOpen = false;
288
+ this._rows = [
289
+ ...this._rows,
290
+ { key, value: DIMENSION_CONTROLS[key].defaultValue },
291
+ ];
292
+ this.#schedulePreview();
293
+ }
294
+
295
+ // --- The add-condition menu ----------------------------------------------
296
+
297
+ #toggleAddMenu() {
298
+ this._addMenuOpen = !this._addMenuOpen;
299
+ if (!this._addMenuOpen) return;
300
+ void this.updateComplete.then(() => {
301
+ // The menu opens inside a scrolling body; a menu the author cannot see
302
+ // is the same as no menu.
303
+ this.#addMenu()?.scrollIntoView?.({ block: "nearest" });
304
+ this.#menuItems()[0]?.focus();
305
+ });
306
+ }
307
+
308
+ /** Close the menu, and hand focus back to what opened it. */
309
+ #closeAddMenu(returnFocus: boolean) {
310
+ if (!this._addMenuOpen) return;
311
+ this._addMenuOpen = false;
312
+ if (!returnFocus) return;
313
+ void this.updateComplete.then(() => {
314
+ this.querySelector<HTMLButtonElement>("[data-add-trigger]")?.focus();
315
+ });
316
+ }
317
+
318
+ #addMenu(): HTMLElement | null {
319
+ return this.querySelector<HTMLElement>("[data-add-menu]");
320
+ }
321
+
322
+ #menuItems(): HTMLButtonElement[] {
323
+ return [
324
+ ...this.querySelectorAll<HTMLButtonElement>(
325
+ "[data-add-menu] [role='menuitem']",
326
+ ),
327
+ ];
328
+ }
329
+
330
+ /** Arrow-key movement through the menu, wrapping at both ends. */
331
+ #moveMenuFocus(delta: 1 | -1) {
332
+ const items = this.#menuItems();
333
+ if (items.length === 0) return;
334
+ const current = items.indexOf(document.activeElement as HTMLButtonElement);
335
+ const next =
336
+ current < 0
337
+ ? delta > 0
338
+ ? 0
339
+ : items.length - 1
340
+ : (current + delta + items.length) % items.length;
341
+ items[next]?.focus();
342
+ }
343
+
344
+ #removeRow(key: DimensionKey) {
345
+ this._rows = this._rows.filter((row) => row.key !== key);
346
+ this.#schedulePreview();
347
+ }
348
+
349
+ #setRowValue(key: DimensionKey, value: string) {
350
+ this._rows = this._rows.map((row) =>
351
+ row.key === key ? { ...row, value } : row,
352
+ );
353
+ this.#schedulePreview();
354
+ }
355
+
356
+ // --- Live count ----------------------------------------------------------
357
+
358
+ #schedulePreview() {
359
+ if (this.#previewTimer) clearTimeout(this.#previewTimer);
360
+ this.#previewTimer = setTimeout(
361
+ () => void this.#preview(),
362
+ PREVIEW_DEBOUNCE_MS,
363
+ );
364
+ }
365
+
366
+ async #preview() {
367
+ const seq = ++this.#requestSeq;
368
+ try {
369
+ const res = await fetch(publicPath("/api/smart-collections/preview"), {
370
+ method: "POST",
371
+ headers: { "Content-Type": "application/json" },
372
+ body: JSON.stringify({ selection: rowsToSelection(this._rows) }),
373
+ });
374
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
375
+ const json = (await res.json()) as { count: number; baseline: number };
376
+ // A slower earlier request must not overwrite a newer answer.
377
+ if (seq !== this.#requestSeq) return;
378
+ this._preview = json;
379
+ } catch {
380
+ if (seq !== this.#requestSeq) return;
381
+ this._preview = null;
382
+ }
383
+ }
384
+
385
+ // --- Address -------------------------------------------------------------
386
+
387
+ #onTitleInput(value: string) {
388
+ this._title = value;
389
+ if (this._slugEdited) return;
390
+ void this.#suggestSlug();
391
+ }
392
+
393
+ async #suggestSlug() {
394
+ const title = this._title.trim();
395
+ if (!title) {
396
+ this.#suggestedSlug = "";
397
+ this._slug = "";
398
+ this._slugState = "unknown";
399
+ return;
400
+ }
401
+ // Local first so the field never lags behind typing; the server only
402
+ // resolves collisions.
403
+ this.#suggestedSlug = truncateSlug(
404
+ await slugify(title),
405
+ MAX_COLLECTION_SLUG_LENGTH,
406
+ );
407
+ this._slug = this.#suggestedSlug;
408
+ this.#scheduleSlugCheck();
409
+ }
410
+
411
+ #onSlugInput(value: string) {
412
+ this._slugEdited = true;
413
+ this._slug = value.trim().toLowerCase();
414
+ this.#scheduleSlugCheck();
415
+ }
416
+
417
+ #scheduleSlugCheck() {
418
+ if (this.#slugTimer) clearTimeout(this.#slugTimer);
419
+ this._slugState = "checking";
420
+ this.#slugTimer = setTimeout(
421
+ () => void this.#checkSlug(),
422
+ PREVIEW_DEBOUNCE_MS,
423
+ );
424
+ }
425
+
426
+ async #checkSlug() {
427
+ const slug = this._slug;
428
+ if (!slug) {
429
+ this._slugState = "unknown";
430
+ return;
431
+ }
432
+ // A link with bad characters is not "taken", and saying so sends the author
433
+ // hunting for a collision that does not exist.
434
+ if (this.#slugFormatIssue() !== null) {
435
+ this._slugState = "taken";
436
+ return;
437
+ }
438
+ try {
439
+ const params = new URLSearchParams({ mode: "check", slug });
440
+ if (this._id) params.set("smartCollectionId", this._id);
441
+ const res = await fetch(
442
+ publicPath(`/api/smart-collections/slug?${params.toString()}`),
443
+ );
444
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
445
+ const json = (await res.json()) as { available: boolean };
446
+ if (slug !== this._slug) return;
447
+ this._slugState = json.available ? "available" : "taken";
448
+ } catch {
449
+ if (slug !== this._slug) return;
450
+ this._slugState = "unknown";
451
+ }
452
+ }
453
+
454
+ // --- The collection link -------------------------------------------------
455
+
456
+ #slugFormatIssue(): "invalid" | "reserved" | "too_long" | null {
457
+ return getSlugValidationIssue(this._slug, {
458
+ maxLength: MAX_COLLECTION_SLUG_LENGTH,
459
+ });
460
+ }
461
+
462
+ /** Why this link is refused, or null when it is fine. */
463
+ #slugProblem(): string | null {
464
+ const issue = this.#slugFormatIssue();
465
+ if (issue === "invalid") return this.labels.linkInvalid;
466
+ if (issue === "reserved") return this.labels.linkReserved;
467
+ if (issue === "too_long") return this.labels.linkTooLong;
468
+ if (this._slugState === "taken") return this.labels.linkTaken;
469
+ return null;
470
+ }
471
+
472
+ /** The whole URL, because that is the thing being decided. */
473
+ #linkPreview(): string {
474
+ const path = publicPath(getCollectionPagePath(this._slug));
475
+ const origin =
476
+ globalThis.location?.origin && globalThis.location.origin !== "null"
477
+ ? globalThis.location.origin
478
+ : "http://localhost";
479
+ return new URL(path, `${origin}/`).toString();
480
+ }
481
+
482
+ #openSlugEditor() {
483
+ if (this._slugOpen) return;
484
+ this._slugOpen = true;
485
+ void this.updateComplete.then(() => {
486
+ const input = this.querySelector<HTMLInputElement>("[data-field='slug']");
487
+ input?.focus();
488
+ input?.select();
489
+ });
490
+ }
491
+
492
+ #resetSlug() {
493
+ if (!this.#suggestedSlug) return;
494
+ this._slug = this.#suggestedSlug;
495
+ this._slugEdited = false;
496
+ this.#scheduleSlugCheck();
497
+ }
498
+
499
+ /**
500
+ * The link, folded away until the author wants it.
501
+ *
502
+ * The same shape the quick-create collection dialog uses, because it is the
503
+ * same decision: the title already answers it, and the whole URL says more
504
+ * than a bare slug does.
505
+ */
506
+ #renderLink() {
507
+ if (!this._slug && !this._slugOpen) return nothing;
508
+
509
+ const problem = this.#slugProblem();
510
+ const moved = this._mode === "edit" && this._slug !== this.#originalSlug;
511
+
512
+ if (!this._slugOpen) {
513
+ return html`
514
+ <div class="collection-quick-link-box">
515
+ <div class="collection-quick-link-row">
516
+ <p
517
+ class="collection-quick-link-preview text-xs text-muted-foreground"
518
+ aria-live="polite"
519
+ >
520
+ ${this.#linkPreview()}
521
+ </p>
522
+ <button
523
+ type="button"
524
+ class="collection-quick-link-action"
525
+ @click=${() => this.#openSlugEditor()}
526
+ >
527
+ ${this.labels.editLink}
528
+ </button>
529
+ </div>
530
+ ${
531
+ problem
532
+ ? html`<p class="collection-dialog-link-problem">${problem}</p>`
533
+ : nothing
534
+ }
535
+ </div>
536
+ `;
537
+ }
538
+
539
+ const canReset =
540
+ Boolean(this.#suggestedSlug) && this._slug !== this.#suggestedSlug;
541
+
542
+ return html`
543
+ <div class="collection-quick-link-editor">
544
+ <div class="field">
545
+ <div class="collection-quick-link-row">
546
+ <label class="label mb-0" for="smart-collection-slug"
547
+ >${this.labels.link}</label
548
+ >
549
+ ${
550
+ canReset
551
+ ? html`
552
+ <button
553
+ type="button"
554
+ class="collection-quick-link-action"
555
+ @click=${() => this.#resetSlug()}
556
+ >
557
+ ${this.labels.resetLink}
558
+ </button>
559
+ `
560
+ : nothing
561
+ }
562
+ </div>
563
+ <input
564
+ id="smart-collection-slug"
565
+ class="input"
566
+ type="text"
567
+ data-field="slug"
568
+ required
569
+ maxlength=${MAX_COLLECTION_SLUG_LENGTH}
570
+ autocomplete="off"
571
+ autocapitalize="off"
572
+ spellcheck="false"
573
+ aria-invalid=${problem ? "true" : "false"}
574
+ .value=${this._slug}
575
+ @input=${(event: Event) =>
576
+ this.#onSlugInput((event.target as HTMLInputElement).value)}
577
+ />
578
+ ${
579
+ problem
580
+ ? html`<p class="collection-dialog-link-problem">${problem}</p>`
581
+ : this._slug
582
+ ? html`<p class="collection-dialog-link-preview">
583
+ ${this.#linkPreview()}
584
+ </p>`
585
+ : html`<p class="collection-dialog-link-help">
586
+ ${this.labels.linkHelp}
587
+ </p>`
588
+ }
589
+ ${
590
+ moved
591
+ ? html`<p class="collection-dialog-link-problem">
592
+ ${this.labels.linkMovesWarning}
593
+ </p>`
594
+ : nothing
595
+ }
596
+ </div>
597
+ </div>
598
+ `;
599
+ }
600
+
601
+ // --- Save ------------------------------------------------------------------
602
+
603
+ /**
604
+ * Why this cannot be saved yet, or null when it can.
605
+ *
606
+ * One answer for the Save button, the Enter key, and the error line: a button
607
+ * greyed out for a reason the author is never told is the usual way this
608
+ * goes wrong, so the reason is always the message they would have got.
609
+ */
610
+ #blockingIssue(): string | null {
611
+ if (!this._title.trim() || !this._slug) {
612
+ return this.labels.titleAndLinkRequired;
613
+ }
614
+ return this.#slugProblem();
615
+ }
616
+
617
+ async #save() {
618
+ if (this._saving) return;
619
+ const issue = this.#blockingIssue();
620
+ if (issue) {
621
+ this._error = issue;
622
+ return;
623
+ }
624
+ const title = this._title.trim();
625
+
626
+ this._saving = true;
627
+ this._error = "";
628
+
629
+ const body = {
630
+ slug: this._slug,
631
+ title,
632
+ description: this._description.trim() || null,
633
+ selection: rowsToSelection(this._rows),
634
+ sort: this._sort,
635
+ layout: this._layout || null,
636
+ };
637
+
638
+ try {
639
+ const res = await fetch(
640
+ publicPath(
641
+ this._id
642
+ ? `/api/smart-collections/${this._id}`
643
+ : "/api/smart-collections",
644
+ ),
645
+ {
646
+ method: this._id ? "PUT" : "POST",
647
+ headers: { "Content-Type": "application/json" },
648
+ body: JSON.stringify(body),
649
+ },
650
+ );
651
+ if (!res.ok) {
652
+ const json = (await res.json().catch(() => null)) as {
653
+ error?: string;
654
+ } | null;
655
+ this._error = json?.error ?? this.labels.saveFailed;
656
+ return;
657
+ }
658
+ showToast(this.labels.saved);
659
+ this.#close(true);
660
+ } catch {
661
+ this._error = this.labels.saveFailed;
662
+ } finally {
663
+ this._saving = false;
664
+ }
665
+ }
666
+
667
+ // --- Keyboard ------------------------------------------------------------
668
+
669
+ /**
670
+ * Escape and Enter, handled on the component rather than only on the
671
+ * `<dialog>`.
672
+ *
673
+ * A native `cancel` event is not reliable here: an inner element that
674
+ * intercepts `keydown` swallows Escape before the dialog ever sees it. The
675
+ * component-level handler is what actually holds.
676
+ */
677
+ #onKeydown = (event: KeyboardEvent) => {
678
+ if (!this._open) return;
679
+
680
+ if (event.key === "Escape") {
681
+ event.preventDefault();
682
+ event.stopPropagation();
683
+ if (this._addMenuOpen) {
684
+ this.#closeAddMenu(true);
685
+ return;
686
+ }
687
+ this.#close(false);
688
+ return;
689
+ }
690
+
691
+ if (
692
+ this._addMenuOpen &&
693
+ (event.key === "ArrowDown" || event.key === "ArrowUp")
694
+ ) {
695
+ event.preventDefault();
696
+ this.#moveMenuFocus(event.key === "ArrowDown" ? 1 : -1);
697
+ return;
698
+ }
699
+
700
+ if (event.key === "Enter") {
701
+ const target = event.target as HTMLElement | null;
702
+ // Enter inside a textarea is a newline, not a submit.
703
+ if (target?.tagName === "TEXTAREA") return;
704
+ // Enter on a menu item picks that condition, and saving nothing yet is
705
+ // the whole reason the menu was open.
706
+ if (target?.getAttribute("role") === "menuitem") return;
707
+ event.preventDefault();
708
+ void this.#save();
709
+ }
710
+ };
711
+
712
+ connectedCallback() {
713
+ super.connectedCallback();
714
+ this.addEventListener("keydown", this.#onKeydown);
715
+ }
716
+
717
+ disconnectedCallback() {
718
+ super.disconnectedCallback();
719
+ this.removeEventListener("keydown", this.#onKeydown);
720
+ if (this.#previewTimer) clearTimeout(this.#previewTimer);
721
+ if (this.#slugTimer) clearTimeout(this.#slugTimer);
722
+ }
723
+
724
+ // --- Render --------------------------------------------------------------
725
+
726
+ #renderConditionRow(row: SmartConditionRow) {
727
+ const control = DIMENSION_CONTROLS[row.key];
728
+ const labelText = this.labels.dimensions[row.key] ?? row.key;
729
+
730
+ return html`
731
+ <div class="smart-condition-row" data-condition=${row.key}>
732
+ <span class="smart-condition-label">${labelText}</span>
733
+ <div class="smart-condition-control">
734
+ ${control.render(
735
+ row.value,
736
+ (value: string) => this.#setRowValue(row.key, value),
737
+ this.labels,
738
+ )}
739
+ </div>
740
+ <button
741
+ type="button"
742
+ class="btn-sm-icon-ghost smart-condition-remove"
743
+ aria-label=${this.labels.removeCondition}
744
+ title=${this.labels.removeCondition}
745
+ @click=${() => this.#removeRow(row.key)}
746
+ >
747
+ ${X_ICON}
748
+ </button>
749
+ </div>
750
+ `;
751
+ }
752
+
753
+ #renderConditions() {
754
+ const available = this.#availableDimensions();
755
+ const hasRows = this._rows.length > 0;
756
+
757
+ return html`
758
+ <section class="smart-collection-section">
759
+ <div class="smart-collection-section-head">
760
+ <h3 class="smart-collection-section-title">
761
+ ${this.labels.conditionsHeading}
762
+ </h3>
763
+ <!-- The count belongs to the whole section, not to the last row:
764
+ it answers "what did that just do" from a fixed place. -->
765
+ <p class="smart-collection-count" aria-live="polite">
766
+ ${
767
+ this._preview
768
+ ? this.labels.countSummary
769
+ .replace("{count}", String(this._preview.count))
770
+ .replace("{total}", String(this._preview.baseline))
771
+ : this.labels.counting
772
+ }
773
+ </p>
774
+ </div>
775
+
776
+ <p class="smart-collection-section-hint">
777
+ ${hasRows ? this.labels.matchAllHint : this.labels.noConditions}
778
+ </p>
779
+
780
+ ${
781
+ hasRows
782
+ ? html`
783
+ <div class="smart-condition-rows">
784
+ ${this._rows.map((row) => this.#renderConditionRow(row))}
785
+ </div>
786
+ `
787
+ : nothing
788
+ }
789
+
790
+ <div class="smart-condition-add">
791
+ <button
792
+ type="button"
793
+ class="btn-sm-outline smart-condition-add-trigger"
794
+ data-add-trigger
795
+ ?disabled=${available.length === 0}
796
+ aria-haspopup="menu"
797
+ aria-expanded=${String(this._addMenuOpen)}
798
+ @click=${(event: Event) => {
799
+ event.stopPropagation();
800
+ this.#toggleAddMenu();
801
+ }}
802
+ >
803
+ ${PLUS_ICON}${this.labels.addCondition}
804
+ </button>
805
+ ${
806
+ this._addMenuOpen
807
+ ? html`
808
+ <div
809
+ class="collections-page-menu smart-condition-menu"
810
+ data-add-menu
811
+ role="menu"
812
+ @click=${(event: Event) => event.stopPropagation()}
813
+ >
814
+ ${available.map(
815
+ (key) => html`
816
+ <button
817
+ type="button"
818
+ class="collections-page-menu-item"
819
+ role="menuitem"
820
+ @click=${() => this.#addRow(key)}
821
+ >
822
+ ${this.labels.dimensions[key] ?? key}
823
+ </button>
824
+ `,
825
+ )}
826
+ </div>
827
+ `
828
+ : nothing
829
+ }
830
+ </div>
831
+ </section>
832
+ `;
833
+ }
834
+
835
+ render() {
836
+ if (!this._open) return nothing;
837
+
838
+ const heading =
839
+ this._mode === "edit"
840
+ ? this.labels.editHeading
841
+ : this.labels.createHeading;
842
+ return html`
843
+ <dialog
844
+ class="dialog collection-dialog"
845
+ @cancel=${(event: Event) => {
846
+ event.preventDefault();
847
+ this.#close(false);
848
+ }}
849
+ @click=${(event: Event) => {
850
+ if (event.target === event.currentTarget) this.#close(false);
851
+ this.#closeAddMenu(false);
852
+ }}
853
+ >
854
+ <div
855
+ class="collection-dialog-panel"
856
+ role="dialog"
857
+ aria-modal="true"
858
+ aria-labelledby="collection-dialog-title"
859
+ @click=${(event: Event) => {
860
+ event.stopPropagation();
861
+ // The trigger and the menu stop their own clicks, so anything that
862
+ // reaches here is a click elsewhere in the dialog — which dismisses.
863
+ this.#closeAddMenu(false);
864
+ }}
865
+ >
866
+ <header class="collection-dialog-header">
867
+ <h2 id="collection-dialog-title" class="collection-dialog-title">
868
+ ${heading}
869
+ </h2>
870
+ <!-- Said once, where it is needed: by the time anyone edits one
871
+ they know what it is. -->
872
+ ${
873
+ this._mode === "create"
874
+ ? html`<p class="collection-dialog-note">
875
+ ${this.labels.whatItIs}
876
+ </p>`
877
+ : nothing
878
+ }
879
+ </header>
880
+
881
+ <div
882
+ class="collection-dialog-body"
883
+ @input=${() => {
884
+ this._error = "";
885
+ }}
886
+ @change=${() => {
887
+ this._error = "";
888
+ }}
889
+ >
890
+ <div class="field">
891
+ <label for="smart-collection-title">${this.labels.title}</label>
892
+ <input
893
+ id="smart-collection-title"
894
+ class="input"
895
+ type="text"
896
+ data-field="title"
897
+ required
898
+ .value=${this._title}
899
+ @input=${(event: Event) =>
900
+ this.#onTitleInput((event.target as HTMLInputElement).value)}
901
+ />
902
+ </div>
903
+
904
+ ${this.#renderLink()}
905
+
906
+ <div class="field">
907
+ <label for="smart-collection-description"
908
+ >${this.labels.description}</label
909
+ >
910
+ <textarea
911
+ id="smart-collection-description"
912
+ class="textarea"
913
+ rows="2"
914
+ .value=${this._description}
915
+ @input=${(event: Event) => {
916
+ this._description = (
917
+ event.target as HTMLTextAreaElement
918
+ ).value;
919
+ }}
920
+ ></textarea>
921
+ </div>
922
+
923
+ ${this.#renderConditions()}
924
+
925
+ <section class="smart-collection-section">
926
+ <div class="smart-collection-section-head">
927
+ <h3 class="smart-collection-section-title">
928
+ ${this.labels.displayHeading}
929
+ </h3>
930
+ </div>
931
+ <div class="smart-collection-display">
932
+ <div class="field">
933
+ <label for="smart-collection-sort"
934
+ >${this.labels.orderBy}</label
935
+ >
936
+ <select
937
+ id="smart-collection-sort"
938
+ class="select"
939
+ .value=${this._sort}
940
+ @change=${(event: Event) => {
941
+ this._sort = (event.target as HTMLSelectElement).value;
942
+ }}
943
+ >
944
+ ${Object.entries(this.labels.sortOptions).map(
945
+ ([value, label]) =>
946
+ html`<option
947
+ value=${value}
948
+ ?selected=${value === this._sort}
949
+ >
950
+ ${label}
951
+ </option>`,
952
+ )}
953
+ </select>
954
+ </div>
955
+ <div class="field">
956
+ <label for="smart-collection-layout"
957
+ >${this.labels.layout}</label
958
+ >
959
+ <select
960
+ id="smart-collection-layout"
961
+ class="select"
962
+ .value=${this._layout}
963
+ @change=${(event: Event) => {
964
+ this._layout = (event.target as HTMLSelectElement).value;
965
+ }}
966
+ >
967
+ ${Object.entries(this.labels.layoutOptions).map(
968
+ ([value, label]) =>
969
+ html`<option
970
+ value=${value}
971
+ ?selected=${value === this._layout}
972
+ >
973
+ ${label}
974
+ </option>`,
975
+ )}
976
+ </select>
977
+ </div>
978
+ </div>
979
+ </section>
980
+
981
+ ${
982
+ this._error
983
+ ? html`<div class="alert-destructive" role="alert">
984
+ ${ALERT_ICON}
985
+ <section><p>${this._error}</p></section>
986
+ </div>`
987
+ : nothing
988
+ }
989
+ </div>
990
+
991
+ <footer class="collection-dialog-actions">
992
+ <button
993
+ type="button"
994
+ class="btn-outline"
995
+ @click=${() => this.#close(false)}
996
+ >
997
+ ${this.labels.cancel}
998
+ </button>
999
+ <button
1000
+ type="button"
1001
+ class="btn"
1002
+ ?disabled=${this._saving || this.#blockingIssue() !== null}
1003
+ @click=${() => void this.#save()}
1004
+ >
1005
+ ${this.labels.save}
1006
+ </button>
1007
+ </footer>
1008
+ </div>
1009
+ </dialog>
1010
+ `;
1011
+ }
1012
+ }
1013
+
1014
+ if (!customElements.get("jant-smart-collection-dialog")) {
1015
+ customElements.define(
1016
+ "jant-smart-collection-dialog",
1017
+ JantSmartCollectionDialog,
1018
+ );
1019
+ }
1020
+
1021
+ export { emptySelection };