@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
@@ -7,11 +7,12 @@ vi.mock("../../upload-with-metadata.js", () => ({
7
7
  import type { Editor, JSONContent } from "@tiptap/core";
8
8
  import type { Slice } from "@tiptap/pm/model";
9
9
  import * as ProseMirrorView from "@tiptap/pm/view";
10
- import type { ComposeLabels } from "../compose-types.js";
10
+ import type { ComposeAttachment, ComposeLabels } from "../compose-types.js";
11
11
  import { renderTiptapJson } from "../../../lib/tiptap-render.js";
12
12
  import "../jant-compose-editor.js";
13
13
  import type { JantComposeEditor } from "../jant-compose-editor.js";
14
14
  import { uploadWithMetadata } from "../../upload-with-metadata.js";
15
+ import Sortable from "sortablejs";
15
16
 
16
17
  function requireElement<T extends globalThis.Element>(
17
18
  element: T | null,
@@ -108,6 +109,57 @@ function triggerEditorPaste(el: JantComposeEditor, files: File[]) {
108
109
  return { handled, event };
109
110
  }
110
111
 
112
+ /**
113
+ * Drops files on the element itself, the way a drag onto the composer chrome
114
+ * (rather than into the ProseMirror body) arrives.
115
+ */
116
+ function triggerEditorDrop(el: JantComposeEditor, files: File[]) {
117
+ const event = new Event("drop", { bubbles: true, cancelable: true });
118
+ Object.defineProperty(event, "dataTransfer", {
119
+ value: {
120
+ items: files.map((file) => ({
121
+ kind: "file",
122
+ type: file.type,
123
+ getAsFile: () => file,
124
+ })),
125
+ files,
126
+ },
127
+ });
128
+ el.dispatchEvent(event);
129
+ return event;
130
+ }
131
+
132
+ /** Every image src currently in the editor's document, in document order. */
133
+ function imageSrcs(el: JantComposeEditor): string[] {
134
+ const srcs: string[] = [];
135
+ requireEditor(el).state.doc.descendants((node) => {
136
+ if (node.type.name === "image") srcs.push(node.attrs.src as string);
137
+ });
138
+ return srcs;
139
+ }
140
+
141
+ /** The same, read off the JSON the composer would submit. */
142
+ function bodyImageSrcs(json: unknown): string[] {
143
+ const srcs: string[] = [];
144
+ const walk = (node: unknown) => {
145
+ if (!node || typeof node !== "object") return;
146
+ const candidate = node as {
147
+ type?: string;
148
+ attrs?: { src?: unknown };
149
+ content?: unknown[];
150
+ };
151
+ if (
152
+ candidate.type === "image" &&
153
+ typeof candidate.attrs?.src === "string"
154
+ ) {
155
+ srcs.push(candidate.attrs.src);
156
+ }
157
+ for (const child of candidate.content ?? []) walk(child);
158
+ };
159
+ walk(json);
160
+ return srcs;
161
+ }
162
+
111
163
  function parsePastedText(el: JantComposeEditor, text: string): Slice | null {
112
164
  const editor = requireEditor(el);
113
165
  let slice: Slice | null = null;
@@ -152,6 +204,7 @@ const parseFromClipboard = (
152
204
 
153
205
  const labels: ComposeLabels = {
154
206
  cancel: "Cancel",
207
+ close: "Close",
155
208
  note: "Note",
156
209
  link: "Link",
157
210
  quote: "Quote",
@@ -190,7 +243,11 @@ const labels: ComposeLabels = {
190
243
  altHint: "Alt text improves accessibility",
191
244
  addMore: "Add",
192
245
  removeAttachment: "Remove attachment",
246
+ moveAttachmentEarlier: "Move attachment earlier",
247
+ moveAttachmentLater: "Move attachment later",
193
248
  uploading: "Uploading...",
249
+ publishing: "Publishing...",
250
+ saving: "Saving...",
194
251
  loadingPost: "Loading post...",
195
252
  loadPostFailed: "Couldn't load this post. Try again.",
196
253
  published: "Published!",
@@ -201,7 +258,7 @@ const labels: ComposeLabels = {
201
258
  confirmCloseTitle: "Save to drafts?",
202
259
  confirmCloseSubtitle: "Save to drafts to edit and post at a later time.",
203
260
  confirmCloseSave: "Save",
204
- confirmCloseCancel: "Cancel",
261
+ confirmCloseCancel: "Keep editing",
205
262
  confirmCloseDiscard: "Don't save",
206
263
  confirmAttachedTitle: "Save text attachment?",
207
264
  confirmAttachedSubtitle:
@@ -220,14 +277,32 @@ const labels: ComposeLabels = {
220
277
  deleteDraft: "Delete Draft",
221
278
  draftDeleted: "Draft deleted.",
222
279
  publishFailedDraft: "Couldn't publish. Saved as draft.",
280
+ publishFailed: "Couldn't publish. Try again.",
281
+ publishFailedOffline: "Couldn't reach the server. Try again.",
223
282
  uploadFailedDraft: "Some uploads failed. Saved as draft.",
224
283
  addCollection: "Add Collection",
225
284
  collectionCountLabel: "%name% + %count% more",
226
285
  draftRestored: "Draft restored.",
286
+ draftStoreFailed: "This browser can't keep a local copy. Save as a draft.",
227
287
  reply: "Reply",
228
288
  publishHideFromLatest: "Hide from Latest",
229
289
  publishPrivate: "Post as Private",
230
290
  publishSettings: "Publish settings",
291
+ languageLabel: "Language",
292
+ languageAuto: "Detect",
293
+ languageAutoHint: "Read from what you write",
294
+ languageAutoDetected: "Read from what you write — looks like {language}",
295
+ languageAutoPending: "Not enough text to tell yet — publishes in {language}",
296
+ languageTriggerLabel: "Language: {language}",
297
+ translationOf: "Translation of “{title}”",
298
+ translationContext: "Translating",
299
+ translationContextInLanguage: "Translating into {language}",
300
+ translationContextOpen: "Open the original in a new tab",
301
+ translationContextOriginal: "The original",
302
+ translationContextHide: "Hide",
303
+ translationContextHideLong: "Hide the original",
304
+ translationContextShow: "Show",
305
+ translationContextShowLong: "Show the original",
231
306
  publishVisibilityLabel: "Visibility",
232
307
  publishVisibilityPublic: "Public",
233
308
  publishVisibilityPublicHint: "Appears in Latest.",
@@ -265,7 +340,6 @@ const labels: ComposeLabels = {
265
340
  threadLimitReached: "Threads can include up to 20 posts.",
266
341
  showMore: "Show more",
267
342
  showLess: "Show less",
268
- closeCompose: "Close compose",
269
343
  editing: "Editing",
270
344
  composeDialogLabel: "Compose",
271
345
  slashHint: "Type / for commands",
@@ -295,13 +369,6 @@ const labels: ComposeLabels = {
295
369
  quickHint: "More options are available after you create it.",
296
370
  quickSubmitLabel: "Done",
297
371
  createdLabel: "Collection created.",
298
- descriptionLabel: "Description (optional)",
299
- descriptionPlaceholder: "What's this collection about?",
300
- sortOrderLabel: "Sort Order",
301
- sortNewest: "Newest first",
302
- sortOldest: "Oldest first",
303
- sortRatingDesc: "Highest rated",
304
- submitLabel: "Save",
305
372
  cancelLabel: "Cancel",
306
373
  },
307
374
  };
@@ -321,6 +388,58 @@ async function createElement(
321
388
  return el;
322
389
  }
323
390
 
391
+ function mediaAttachment(clientId: string, mediaId: string): ComposeAttachment {
392
+ const blob = new Blob(["fake"], { type: "image/png" });
393
+ return {
394
+ clientId,
395
+ file: new File([blob], `${clientId}.png`, { type: "image/png" }),
396
+ previewUrl: URL.createObjectURL(blob),
397
+ status: "done",
398
+ progress: null,
399
+ mediaId,
400
+ alt: "",
401
+ error: null,
402
+ posterUrl: null,
403
+ remoteUrl: null,
404
+ summary: null,
405
+ chars: null,
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Forces the primary-pointer media query, and returns a restore function.
411
+ * Every call site restores in a `finally` so one test cannot leak into another.
412
+ */
413
+ function stubPrimaryPointer(kind: "coarse" | "fine"): () => void {
414
+ const original = window.matchMedia;
415
+ // Overrides `matches` on a real MediaQueryList, so the component still gets
416
+ // a live object to subscribe to.
417
+ window.matchMedia = (query: string) => {
418
+ const list = original.call(window, query);
419
+ Object.defineProperty(list, "matches", {
420
+ configurable: true,
421
+ value: query.includes(`pointer: ${kind}`),
422
+ });
423
+ return list;
424
+ };
425
+ return () => {
426
+ window.matchMedia = original;
427
+ };
428
+ }
429
+
430
+ function moveButton(
431
+ el: JantComposeEditor,
432
+ clientId: string,
433
+ direction: "earlier" | "later",
434
+ ): HTMLButtonElement {
435
+ return requireElement(
436
+ el.querySelector<HTMLButtonElement>(
437
+ `[data-attachment-id="${clientId}"] [data-attachment-move="${direction}"]`,
438
+ ),
439
+ `expected ${direction} move button for ${clientId}`,
440
+ );
441
+ }
442
+
324
443
  function tiptapDoc(...content: JSONContent[]): JSONContent {
325
444
  return { type: "doc", content };
326
445
  }
@@ -340,6 +459,22 @@ function tiptapParagraph(text: string): JSONContent {
340
459
  };
341
460
  }
342
461
 
462
+ /**
463
+ * Replays a pointer gesture. The browser retargets a click to the nearest
464
+ * common ancestor of the press and the release, so `clickTarget` is separate
465
+ * from the two — a drag that starts on the text and ends over a layout wrapper
466
+ * reports the wrapper.
467
+ */
468
+ function pressAndClick(
469
+ pressTarget: HTMLElement,
470
+ releaseTarget: HTMLElement,
471
+ clickTarget: HTMLElement = releaseTarget,
472
+ ) {
473
+ pressTarget.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
474
+ releaseTarget.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
475
+ clickTarget.dispatchEvent(new MouseEvent("click", { bubbles: true }));
476
+ }
477
+
343
478
  async function toggleEmojiPicker(el: JantComposeEditor) {
344
479
  (
345
480
  el as unknown as {
@@ -1262,37 +1397,9 @@ describe("JantComposeEditor", () => {
1262
1397
 
1263
1398
  it("moves attachments later with keyboard controls", async () => {
1264
1399
  const el = await createElement("note");
1265
- const blob = new Blob(["fake"], { type: "image/png" });
1266
- const file = new File([blob], "test.png", { type: "image/png" });
1267
1400
  el._attachments = [
1268
- {
1269
- clientId: "a1",
1270
- file,
1271
- previewUrl: URL.createObjectURL(blob),
1272
- status: "done",
1273
- progress: null,
1274
- mediaId: "m1",
1275
- alt: "",
1276
- error: null,
1277
- posterUrl: null,
1278
- remoteUrl: null,
1279
- summary: null,
1280
- chars: null,
1281
- },
1282
- {
1283
- clientId: "a2",
1284
- file,
1285
- previewUrl: URL.createObjectURL(blob),
1286
- status: "done",
1287
- progress: null,
1288
- mediaId: "m2",
1289
- alt: "",
1290
- error: null,
1291
- posterUrl: null,
1292
- remoteUrl: null,
1293
- summary: null,
1294
- chars: null,
1295
- },
1401
+ mediaAttachment("a1", "m1"),
1402
+ mediaAttachment("a2", "m2"),
1296
1403
  ];
1297
1404
  el._attachmentOrder = ["a1", "a2"];
1298
1405
  await el.updateComplete;
@@ -1314,6 +1421,127 @@ describe("JantComposeEditor", () => {
1314
1421
  expect(el._attachmentOrder).toEqual(["a2", "a1"]);
1315
1422
  });
1316
1423
 
1424
+ it("reorders attachments with the move buttons", async () => {
1425
+ const el = await createElement("note");
1426
+ el._attachments = [
1427
+ mediaAttachment("a1", "m1"),
1428
+ mediaAttachment("a2", "m2"),
1429
+ mediaAttachment("a3", "m3"),
1430
+ ];
1431
+ el._attachmentOrder = ["a1", "a2", "a3"];
1432
+ await el.updateComplete;
1433
+
1434
+ moveButton(el, "a1", "later").click();
1435
+ await el.updateComplete;
1436
+ expect(el._attachmentOrder).toEqual(["a2", "a1", "a3"]);
1437
+
1438
+ moveButton(el, "a1", "earlier").click();
1439
+ await el.updateComplete;
1440
+ expect(el._attachmentOrder).toEqual(["a1", "a2", "a3"]);
1441
+ });
1442
+
1443
+ it("disables the move button at each end of the strip", async () => {
1444
+ const el = await createElement("note");
1445
+ el._attachments = [
1446
+ mediaAttachment("a1", "m1"),
1447
+ mediaAttachment("a2", "m2"),
1448
+ ];
1449
+ el._attachmentOrder = ["a1", "a2"];
1450
+ await el.updateComplete;
1451
+
1452
+ expect(moveButton(el, "a1", "earlier").disabled).toBe(true);
1453
+ expect(moveButton(el, "a1", "later").disabled).toBe(false);
1454
+ expect(moveButton(el, "a2", "earlier").disabled).toBe(false);
1455
+ expect(moveButton(el, "a2", "later").disabled).toBe(true);
1456
+ });
1457
+
1458
+ it("keeps focus on the attachment that moved", async () => {
1459
+ const el = await createElement("note");
1460
+ el._attachments = [
1461
+ mediaAttachment("a1", "m1"),
1462
+ mediaAttachment("a2", "m2"),
1463
+ mediaAttachment("a3", "m3"),
1464
+ ];
1465
+ el._attachmentOrder = ["a1", "a2", "a3"];
1466
+ await el.updateComplete;
1467
+
1468
+ const later = moveButton(el, "a1", "later");
1469
+ later.focus();
1470
+ later.click();
1471
+ await el.updateComplete;
1472
+
1473
+ expect(document.activeElement).toBe(moveButton(el, "a1", "later"));
1474
+ });
1475
+
1476
+ it("falls back to the opposite button when a move reaches the end", async () => {
1477
+ const el = await createElement("note");
1478
+ el._attachments = [
1479
+ mediaAttachment("a1", "m1"),
1480
+ mediaAttachment("a2", "m2"),
1481
+ ];
1482
+ el._attachmentOrder = ["a1", "a2"];
1483
+ await el.updateComplete;
1484
+
1485
+ const later = moveButton(el, "a1", "later");
1486
+ later.focus();
1487
+ later.click();
1488
+ await el.updateComplete;
1489
+
1490
+ expect(el._attachmentOrder).toEqual(["a2", "a1"]);
1491
+ expect(moveButton(el, "a1", "later").disabled).toBe(true);
1492
+ expect(document.activeElement).toBe(moveButton(el, "a1", "earlier"));
1493
+ });
1494
+
1495
+ it("omits move buttons when there is nothing to reorder", async () => {
1496
+ const el = await createElement("note");
1497
+ el._attachments = [mediaAttachment("a1", "m1")];
1498
+ el._attachmentOrder = ["a1"];
1499
+ await el.updateComplete;
1500
+
1501
+ expect(el.querySelector("[data-attachment-move]")).toBeNull();
1502
+ expect(el.querySelector(".compose-attachment-alt")).not.toBeNull();
1503
+ });
1504
+
1505
+ it("leaves drag reordering off on a touch device", async () => {
1506
+ const create = vi.spyOn(Sortable, "create");
1507
+ const restorePointer = stubPrimaryPointer("coarse");
1508
+ try {
1509
+ const el = await createElement("note");
1510
+ el._attachments = [
1511
+ mediaAttachment("a1", "m1"),
1512
+ mediaAttachment("a2", "m2"),
1513
+ ];
1514
+ el._attachmentOrder = ["a1", "a2"];
1515
+ await el.updateComplete;
1516
+
1517
+ expect(create).not.toHaveBeenCalled();
1518
+ // The buttons are the whole reordering path there, so they must stay.
1519
+ expect(moveButton(el, "a1", "later").disabled).toBe(false);
1520
+ } finally {
1521
+ restorePointer();
1522
+ create.mockRestore();
1523
+ }
1524
+ });
1525
+
1526
+ it("keeps drag reordering on a pointer device", async () => {
1527
+ const create = vi.spyOn(Sortable, "create");
1528
+ const restorePointer = stubPrimaryPointer("fine");
1529
+ try {
1530
+ const el = await createElement("note");
1531
+ el._attachments = [
1532
+ mediaAttachment("a1", "m1"),
1533
+ mediaAttachment("a2", "m2"),
1534
+ ];
1535
+ el._attachmentOrder = ["a1", "a2"];
1536
+ await el.updateComplete;
1537
+
1538
+ expect(create).toHaveBeenCalledTimes(1);
1539
+ } finally {
1540
+ restorePointer();
1541
+ create.mockRestore();
1542
+ }
1543
+ });
1544
+
1317
1545
  it("preserves mixed attachment order when populate provides one", async () => {
1318
1546
  const el = await createElement("note");
1319
1547
 
@@ -1427,4 +1655,280 @@ describe("JantComposeEditor", () => {
1427
1655
  "clipboard.png",
1428
1656
  ]);
1429
1657
  });
1658
+
1659
+ it("pastes images as attachments in a link, title or no title", async () => {
1660
+ const uploadWithMetadataMock = vi.mocked(uploadWithMetadata);
1661
+ const el = await createElement("link");
1662
+ // A link's title is required to publish, so it is filled in every link
1663
+ // worth pasting into — it can't be the signal that picks inline.
1664
+ el._title = "An article worth linking";
1665
+ await el.updateComplete;
1666
+
1667
+ const image = new File(["image"], "clipboard.png", { type: "image/png" });
1668
+ const { handled } = triggerEditorPaste(el, [image]);
1669
+ await el.updateComplete;
1670
+
1671
+ expect(handled).toBe(true);
1672
+ expect(uploadWithMetadataMock).not.toHaveBeenCalled();
1673
+ expect(el._attachments.map((attachment) => attachment.file.name)).toEqual([
1674
+ "clipboard.png",
1675
+ ]);
1676
+ });
1677
+
1678
+ it("pastes images as attachments in a quote carrying a title from a format switch", async () => {
1679
+ const uploadWithMetadataMock = vi.mocked(uploadWithMetadata);
1680
+ const el = await createElement("quote");
1681
+ // A quote has no title field, but switching over from a note leaves one
1682
+ // behind. Nothing the author can see may decide where the image lands.
1683
+ el._title = "Essay";
1684
+ await el.updateComplete;
1685
+
1686
+ const image = new File(["image"], "clipboard.png", { type: "image/png" });
1687
+ const { handled } = triggerEditorPaste(el, [image]);
1688
+ await el.updateComplete;
1689
+
1690
+ expect(handled).toBe(true);
1691
+ expect(uploadWithMetadataMock).not.toHaveBeenCalled();
1692
+ expect(el._attachments.map((attachment) => attachment.file.name)).toEqual([
1693
+ "clipboard.png",
1694
+ ]);
1695
+ });
1696
+
1697
+ it("carries an in-flight inline upload across a format switch", async () => {
1698
+ const uploadWithMetadataMock = vi.mocked(uploadWithMetadata);
1699
+ let finishUpload: (value: { url: string; id: string }) => void = () => {};
1700
+ uploadWithMetadataMock.mockReturnValue(
1701
+ new Promise((resolve) => {
1702
+ finishUpload = resolve;
1703
+ }),
1704
+ );
1705
+
1706
+ const el = await createElement("note");
1707
+ el._title = "Essay";
1708
+ await el.updateComplete;
1709
+
1710
+ const image = new File(["image"], "clipboard.png", { type: "image/png" });
1711
+ triggerEditorPaste(el, [image]);
1712
+ await el.updateComplete;
1713
+ expect(imageSrcs(el).every((src) => src.startsWith("blob:"))).toBe(true);
1714
+ expect(imageSrcs(el)).toHaveLength(1);
1715
+
1716
+ // The body survives the switch, so the upload watching it has to survive
1717
+ // too — otherwise the blob: placeholder is what gets published.
1718
+ el.format = "quote";
1719
+ await el.updateComplete;
1720
+ await el.updateComplete;
1721
+
1722
+ finishUpload({
1723
+ url: "https://example.test/clipboard.webp",
1724
+ id: "med_test",
1725
+ });
1726
+ await vi.waitFor(() => {
1727
+ expect(imageSrcs(el)).toEqual(["https://example.test/clipboard.webp"]);
1728
+ });
1729
+ expect(bodyImageSrcs(el._bodyJson)).toEqual([
1730
+ "https://example.test/clipboard.webp",
1731
+ ]);
1732
+ });
1733
+
1734
+ it("drops images into a link as attachments, same as pasting them", async () => {
1735
+ const uploadWithMetadataMock = vi.mocked(uploadWithMetadata);
1736
+ const el = await createElement("link");
1737
+ el._title = "An article worth linking";
1738
+ await el.updateComplete;
1739
+
1740
+ const image = new File(["image"], "dropped.png", { type: "image/png" });
1741
+ triggerEditorDrop(el, [image]);
1742
+ await el.updateComplete;
1743
+
1744
+ expect(uploadWithMetadataMock).not.toHaveBeenCalled();
1745
+ expect(el._attachments.map((attachment) => attachment.file.name)).toEqual([
1746
+ "dropped.png",
1747
+ ]);
1748
+ });
1749
+
1750
+ it("focuses the body end when blank space around the editor is clicked", async () => {
1751
+ const el = await createElement("note");
1752
+ const editor = requireEditor(el);
1753
+ editor.commands.setContent(
1754
+ tiptapDoc(tiptapParagraph("One two three"), tiptapParagraph("Tail")),
1755
+ );
1756
+ editor.commands.setTextSelection(1);
1757
+
1758
+ const blank = requireElement(
1759
+ el.querySelector<HTMLElement>(".compose-tiptap-wrap"),
1760
+ "expected compose tiptap wrapper",
1761
+ );
1762
+ pressAndClick(blank, blank);
1763
+
1764
+ expect(editor.state.selection.empty).toBe(true);
1765
+ expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1);
1766
+ });
1767
+
1768
+ it("keeps a drag-selection that lifts off the text onto blank space", async () => {
1769
+ const el = await createElement("note");
1770
+ const editor = requireEditor(el);
1771
+ editor.commands.setContent(
1772
+ tiptapDoc(
1773
+ {
1774
+ type: "blockquote",
1775
+ content: [tiptapParagraph("One two three four five six")],
1776
+ },
1777
+ tiptapParagraph("Tail after the quote"),
1778
+ ),
1779
+ );
1780
+ // Right-to-left drag across the quote: anchor after "six", head before "One".
1781
+ editor.commands.setTextSelection({ from: 2, to: 29 });
1782
+
1783
+ const quote = requireElement(
1784
+ el.querySelector<HTMLElement>(".compose-tiptap-body blockquote p"),
1785
+ "expected blockquote paragraph",
1786
+ );
1787
+ const blank = requireElement(
1788
+ el.querySelector<HTMLElement>(".compose-tiptap-wrap"),
1789
+ "expected compose tiptap wrapper",
1790
+ );
1791
+ // The pointer went down on the text and came up over the wrapper, so the
1792
+ // browser retargets the click to their common ancestor.
1793
+ pressAndClick(quote, blank, blank);
1794
+
1795
+ expect(editor.state.selection.from).toBe(2);
1796
+ expect(editor.state.selection.to).toBe(29);
1797
+ });
1798
+ });
1799
+
1800
+ describe("JantComposeEditor.takeOver", () => {
1801
+ beforeEach(() => {
1802
+ document.body.innerHTML = "";
1803
+ vi.clearAllMocks();
1804
+ });
1805
+
1806
+ /** An editor the dialog is about to render in place of another one. */
1807
+ function unrenderedEditor(format: "note" | "link" | "quote") {
1808
+ const el = document.createElement(
1809
+ "jant-compose-editor",
1810
+ ) as JantComposeEditor;
1811
+ el.format = format;
1812
+ el.labels = labels;
1813
+ document.body.appendChild(el);
1814
+ return el;
1815
+ }
1816
+
1817
+ it("hands every post field to the editor that replaces it", async () => {
1818
+ // One sample per field the editor holds for its post. The first assertion
1819
+ // keeps this table complete: a field the editor gains without a way across
1820
+ // is how a quote's source link used to vanish on "Add to thread".
1821
+ const samples: Record<string, unknown> = {
1822
+ _title: "Left behind by a format switch",
1823
+ _showTitle: false,
1824
+ _bodyJson: tiptapDoc(tiptapParagraph("My thoughts on it")),
1825
+ _url: "https://example.com/source",
1826
+ _quoteText: "A borrowed sentence.",
1827
+ _quoteAuthor: "Someone Else",
1828
+ _rating: 4,
1829
+ _showRating: true,
1830
+ _attachedTexts: [
1831
+ {
1832
+ clientId: "text-1",
1833
+ bodyJson: tiptapDoc(tiptapParagraph("Attached")),
1834
+ bodyHtml: "",
1835
+ summary: "Attached",
1836
+ },
1837
+ ],
1838
+ _attachments: [mediaAttachment("media-1", "med_1")],
1839
+ _attachmentOrder: ["text-1", "media-1"],
1840
+ };
1841
+ const contentProps = (
1842
+ customElements.get("jant-compose-editor") as unknown as {
1843
+ _CONTENT_PROPS: Set<string>;
1844
+ }
1845
+ )._CONTENT_PROPS;
1846
+ expect(Object.keys(samples).sort()).toEqual(
1847
+ [...contentProps, "_attachments"].sort(),
1848
+ );
1849
+
1850
+ const previous = await createElement("quote");
1851
+ for (const [key, value] of Object.entries(samples)) {
1852
+ if (key === "_bodyJson") continue;
1853
+ (previous as unknown as Record<string, unknown>)[key] = value;
1854
+ }
1855
+ requireEditor(previous).commands.setContent(
1856
+ samples._bodyJson as JSONContent,
1857
+ );
1858
+ await previous.updateComplete;
1859
+ // The dialog swaps elements before handing over, so the old one has
1860
+ // already left the DOM — and taken its TipTap instance with it.
1861
+ previous.remove();
1862
+
1863
+ const next = await createElement("quote");
1864
+ next.takeOver(previous);
1865
+ await next.updateComplete;
1866
+
1867
+ for (const [key, value] of Object.entries(samples)) {
1868
+ if (key === "_bodyJson") continue;
1869
+ expect((next as unknown as Record<string, unknown>)[key], key).toEqual(
1870
+ value,
1871
+ );
1872
+ }
1873
+ expect(next.getNormalizedBodyJson()).toEqual(
1874
+ previous.getNormalizedBodyJson(),
1875
+ );
1876
+ expect(requireEditor(next).getText()).toBe("My thoughts on it");
1877
+ expect(next.getData().url).toBe("https://example.com/source");
1878
+ });
1879
+
1880
+ it("keeps a handed-over title toggle through its first render", async () => {
1881
+ const previous = await createElement("note", { titleByDefault: true });
1882
+ previous._showTitle = false;
1883
+ previous.remove();
1884
+
1885
+ const next = unrenderedEditor("note");
1886
+ next.titleByDefault = true;
1887
+ next.takeOver(previous);
1888
+ await next.updateComplete;
1889
+
1890
+ expect(next._showTitle).toBe(false);
1891
+ });
1892
+
1893
+ it.each([
1894
+ ["after", true],
1895
+ ["before", false],
1896
+ ])(
1897
+ "carries an in-flight inline upload to an editor taking over %s its first render",
1898
+ async (_when, renderFirst) => {
1899
+ const uploadWithMetadataMock = vi.mocked(uploadWithMetadata);
1900
+ let finishUpload: (value: { url: string; id: string }) => void = () => {};
1901
+ uploadWithMetadataMock.mockReturnValue(
1902
+ new Promise((resolve) => {
1903
+ finishUpload = resolve;
1904
+ }),
1905
+ );
1906
+
1907
+ const previous = await createElement("note");
1908
+ previous._title = "Essay";
1909
+ await previous.updateComplete;
1910
+ const image = new File(["image"], "clipboard.png", { type: "image/png" });
1911
+ triggerEditorPaste(previous, [image]);
1912
+ await previous.updateComplete;
1913
+ expect(imageSrcs(previous)).toHaveLength(1);
1914
+ expect(imageSrcs(previous)[0]).toMatch(/^blob:/);
1915
+ previous.remove();
1916
+
1917
+ const next = unrenderedEditor("note");
1918
+ if (renderFirst) await next.updateComplete;
1919
+ next.takeOver(previous);
1920
+ await next.updateComplete;
1921
+ expect(imageSrcs(next)[0]).toMatch(/^blob:/);
1922
+
1923
+ finishUpload({ url: "https://example.test/clipboard.webp", id: "med_1" });
1924
+ await vi.waitFor(() => {
1925
+ expect(imageSrcs(next)).toEqual([
1926
+ "https://example.test/clipboard.webp",
1927
+ ]);
1928
+ });
1929
+ expect(bodyImageSrcs(next._bodyJson)).toEqual([
1930
+ "https://example.test/clipboard.webp",
1931
+ ]);
1932
+ },
1933
+ );
1430
1934
  });
@@ -18,7 +18,6 @@ const labels = {
18
18
  bodyPlaceholder: "What's on your mind...",
19
19
  showMore: "Show more",
20
20
  showLess: "Show less",
21
- closeCompose: "Close compose",
22
21
  editing: "Editing",
23
22
  composeDialogLabel: "Compose",
24
23
  } as ComposeLabels;