@jant/core 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -1,4 +1,5 @@
1
1
  import type { JantComposeDialog } from "./components/jant-compose-dialog.js";
2
+ import { ensureCompose } from "./lazy-entries.js";
2
3
  import { showToast } from "./toast.js";
3
4
 
4
5
  interface ReplyToData {
@@ -17,12 +18,64 @@ export interface ComposeOpenOptions {
17
18
  initialFormat?: "note" | "link" | "quote";
18
19
  }
19
20
 
21
+ /**
22
+ * The composer element the server rendered, upgraded or not.
23
+ *
24
+ * Use it to ask whether the page has a composer; to call it, go through
25
+ * {@link ensureComposeDialog}.
26
+ *
27
+ * @returns The element, or null when the page has no composer
28
+ */
20
29
  export function getComposeDialog(): JantComposeDialog | null {
21
30
  return document.querySelector(
22
31
  "jant-compose-dialog",
23
32
  ) as JantComposeDialog | null;
24
33
  }
25
34
 
35
+ /**
36
+ * The composer, ready to take calls.
37
+ *
38
+ * The composer ships in its own bundle, loaded on first use, so the element
39
+ * the server rendered may still be an unupgraded tag when a button is
40
+ * clicked. This loads the bundle — which defines the element and upgrades it
41
+ * in place — before handing the element back.
42
+ *
43
+ * @returns The upgraded composer, or null when the page has no composer
44
+ * @example
45
+ * ```ts
46
+ * const dialog = await ensureComposeDialog();
47
+ * await dialog?.openTranslation(threadId, "zh-Hans");
48
+ * ```
49
+ */
50
+ export async function ensureComposeDialog(): Promise<JantComposeDialog | null> {
51
+ const dialog = getComposeDialog();
52
+ if (!dialog) return null;
53
+ await ensureCompose();
54
+ return dialog;
55
+ }
56
+
57
+ /**
58
+ * The composer's translated strings, readable before its bundle has loaded.
59
+ *
60
+ * The post menu borrows labels the server rendered onto the composer for its
61
+ * inline collection form. Until the element upgrades, Lit has not parsed the
62
+ * `labels` attribute into a property, so read the attribute directly then.
63
+ *
64
+ * @returns The labels, or null when the page has no composer or no labels
65
+ */
66
+ export function readComposeDialogLabels(): JantComposeDialog["labels"] | null {
67
+ const dialog = getComposeDialog();
68
+ if (!dialog) return null;
69
+ if (dialog.labels) return dialog.labels;
70
+ const raw = dialog.getAttribute("labels");
71
+ if (!raw) return null;
72
+ try {
73
+ return JSON.parse(raw) as JantComposeDialog["labels"];
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
26
79
  export function getActiveCollectionId(): string | undefined {
27
80
  return (
28
81
  document.querySelector<HTMLElement>(
@@ -133,14 +186,35 @@ function getReplyData(article: HTMLElement): ReplyToData {
133
186
  export async function openNewCompose(
134
187
  options?: ComposeOpenOptions,
135
188
  ): Promise<void> {
136
- await getComposeDialog()?.openNew(options);
189
+ const dialog = await ensureComposeDialog();
190
+ await dialog?.openNew(options);
191
+ }
192
+
193
+ /**
194
+ * Open a post in the composer for editing.
195
+ *
196
+ * @param postId - The post to edit
197
+ */
198
+ export async function openEditForPost(postId: string): Promise<void> {
199
+ const dialog = await ensureComposeDialog();
200
+ await dialog?.openEdit(postId);
201
+ }
202
+
203
+ /**
204
+ * Open a draft in the composer, from the draft preview page.
205
+ *
206
+ * @param postId - The draft to continue
207
+ */
208
+ export async function openDraftForPost(postId: string): Promise<void> {
209
+ const dialog = await ensureComposeDialog();
210
+ await dialog?.openDraft(postId);
137
211
  }
138
212
 
139
213
  export async function openReplyForArticle(article: HTMLElement): Promise<void> {
140
214
  const postId = article.dataset.postId;
141
215
  if (!postId) return;
142
216
 
143
- const dialog = getComposeDialog();
217
+ const dialog = await ensureComposeDialog();
144
218
  if (!dialog) return;
145
219
 
146
220
  // Only surfaces that hide the trailing draft redirect to it — the feed hands
@@ -3,6 +3,8 @@ import {
3
3
  getComposeDialog,
4
4
  getCurrentDetailPostArticle,
5
5
  getReplyTargetArticle,
6
+ openDraftForPost,
7
+ openEditForPost,
6
8
  openNewCompose,
7
9
  openReplyForArticle,
8
10
  } from "./compose-launch.js";
@@ -64,14 +66,13 @@ function openEditFromQueryParam() {
64
66
 
65
67
  const article = getCurrentDetailPostArticle();
66
68
  const postId = article?.dataset.postId;
67
- const composeEl = getComposeDialog();
68
- if (!postId || !composeEl) return;
69
+ if (!postId || !getComposeDialog()) return;
69
70
 
70
71
  consumeEditQueryParam();
71
72
  if (document.querySelector("[data-preview-status]")) {
72
- void composeEl.openDraft(postId);
73
+ void openDraftForPost(postId);
73
74
  } else {
74
- void composeEl.openEdit(postId);
75
+ void openEditForPost(postId);
75
76
  }
76
77
  }
77
78
 
@@ -141,10 +142,7 @@ document.addEventListener("keydown", (event: globalThis.KeyboardEvent) => {
141
142
  const postId = article.dataset.postId;
142
143
  if (!postId) return;
143
144
  event.preventDefault();
144
- const composeEl = getComposeDialog();
145
- if (composeEl) {
146
- void composeEl.openEdit(postId);
147
- }
145
+ void openEditForPost(postId);
148
146
  return;
149
147
  }
150
148
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Server-rendered buttons that open the composer.
3
+ *
4
+ * The composer ships in its own bundle, loaded on first use, so markup the
5
+ * server renders must not call the element directly: an unupgraded
6
+ * `<jant-compose-dialog>` has no methods yet. Buttons carry a data attribute
7
+ * instead, and this module — always on a signed-in page — turns the click
8
+ * into a call through `compose-launch.ts`, which loads the bundle first.
9
+ *
10
+ * - `[data-compose-open]`, with an optional `data-compose-collection-id`:
11
+ * start a new post
12
+ * - `[data-reply-trigger]` inside `article[data-post]`: reply to that post
13
+ * - `[data-draft-continue]`: reopen an inline draft in the editor
14
+ */
15
+
16
+ import {
17
+ openEditForPost,
18
+ openNewCompose,
19
+ openReplyForArticle,
20
+ } from "./compose-launch.js";
21
+
22
+ document.addEventListener("click", (event: MouseEvent) => {
23
+ const origin =
24
+ event.target instanceof globalThis.Element ? event.target : null;
25
+ if (!origin) return;
26
+
27
+ const composeOpen = origin.closest<HTMLElement>("[data-compose-open]");
28
+ if (composeOpen) {
29
+ const collectionId = composeOpen.dataset.composeCollectionId;
30
+ void openNewCompose(collectionId ? { collectionId } : undefined);
31
+ return;
32
+ }
33
+
34
+ const replyTrigger = origin.closest<HTMLElement>("[data-reply-trigger]");
35
+ if (replyTrigger) {
36
+ const article = replyTrigger.closest<HTMLElement>("article[data-post]");
37
+ if (article) void openReplyForArticle(article);
38
+ return;
39
+ }
40
+
41
+ // The badge on an inline draft doubles as its most likely next action.
42
+ // Publish and Delete live in the post menu with every other post action.
43
+ const draftContinue = origin.closest<HTMLElement>("[data-draft-continue]");
44
+ if (draftContinue) {
45
+ const postId =
46
+ draftContinue.dataset.postId ??
47
+ draftContinue.closest<HTMLElement>("article[data-post]")?.dataset.postId;
48
+ if (postId) void openEditForPost(postId);
49
+ }
50
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Copy field behavior.
3
+ *
4
+ * Enhances the read-only address fields rendered by `ui/shared/CopyField.tsx`
5
+ * and by the feed URL rows in General settings. Delegated from the document, so
6
+ * fields that appear later — the settings page renders its rows from Lit after
7
+ * load — are covered without re-running anything.
8
+ *
9
+ * Two behaviors carry the weight here and must both stay:
10
+ *
11
+ * - The button writes the address to the clipboard and reports either way.
12
+ * - Clicking or focusing the input selects the whole address. The Clipboard API
13
+ * is absent on insecure origins and can be denied by permission, so manual
14
+ * selection is what keeps the address obtainable when the button cannot work.
15
+ *
16
+ * The server renders the button `hidden` — without this script it would be an
17
+ * affordance that does nothing — and it is revealed here. The input alone is
18
+ * the no-JavaScript state, and it is a usable one.
19
+ */
20
+
21
+ import {
22
+ COPY_FIELD_BUTTON_ATTR,
23
+ COPY_FIELD_FAILED_ATTR,
24
+ COPY_FIELD_VALUE_ATTR,
25
+ } from "../lib/copy-field.js";
26
+ import { showToast } from "./toast.js";
27
+
28
+ const BUTTON_SELECTOR = `[${COPY_FIELD_BUTTON_ATTR}]`;
29
+ const VALUE_SELECTOR = `[${COPY_FIELD_VALUE_ATTR}]`;
30
+
31
+ /**
32
+ * Find the address a copy button belongs to.
33
+ *
34
+ * @param button - The copy button that was activated
35
+ * @returns The input holding the address, or null when the field is malformed
36
+ */
37
+ function findValueInput(button: HTMLElement): HTMLInputElement | null {
38
+ return (
39
+ button.parentElement?.querySelector<HTMLInputElement>(VALUE_SELECTOR) ??
40
+ button
41
+ .closest("[data-copy-field-root]")
42
+ ?.querySelector<HTMLInputElement>(VALUE_SELECTOR) ??
43
+ null
44
+ );
45
+ }
46
+
47
+ async function copy(button: HTMLElement): Promise<void> {
48
+ const input = findValueInput(button);
49
+ if (!input) return;
50
+
51
+ const failedMessage = button.getAttribute(COPY_FIELD_FAILED_ATTR) ?? "";
52
+
53
+ try {
54
+ if (!globalThis.navigator.clipboard?.writeText) {
55
+ throw new Error("Clipboard unavailable");
56
+ }
57
+
58
+ await globalThis.navigator.clipboard.writeText(input.value);
59
+ showToast(button.getAttribute(COPY_FIELD_BUTTON_ATTR) || "");
60
+ } catch {
61
+ showToast(failedMessage, "error");
62
+ }
63
+ }
64
+
65
+ /** Reveal buttons the server rendered hidden. */
66
+ function revealButtons(root: globalThis.ParentNode = document): void {
67
+ for (const button of Array.from(
68
+ root.querySelectorAll<HTMLElement>(`${BUTTON_SELECTOR}[hidden]`),
69
+ )) {
70
+ button.hidden = false;
71
+ }
72
+ }
73
+
74
+ document.addEventListener("click", (event: globalThis.MouseEvent) => {
75
+ const target = event.target;
76
+ if (!(target instanceof globalThis.Element)) return;
77
+
78
+ const button = target.closest<HTMLElement>(BUTTON_SELECTOR);
79
+ if (button) {
80
+ event.preventDefault();
81
+ void copy(button);
82
+ return;
83
+ }
84
+
85
+ const input = target.closest<HTMLInputElement>(VALUE_SELECTOR);
86
+ if (input) input.select();
87
+ });
88
+
89
+ // Focus does not bubble; its capturing counterpart does.
90
+ document.addEventListener(
91
+ "focus",
92
+ (event: globalThis.FocusEvent) => {
93
+ const target = event.target;
94
+ if (!(target instanceof globalThis.Element)) return;
95
+
96
+ const input = target.closest<HTMLInputElement>(VALUE_SELECTOR);
97
+ if (input) input.select();
98
+ },
99
+ true,
100
+ );
101
+
102
+ // Module scripts are deferred, so by the time this runs the server's fields are
103
+ // already parsed — but the listener is registered unconditionally rather than
104
+ // branching on readyState, so a field that arrives late is revealed too.
105
+ revealButtons();
106
+ document.addEventListener("DOMContentLoaded", () => revealButtons(), {
107
+ once: true,
108
+ });
@@ -1,10 +1,23 @@
1
1
  /**
2
2
  * Custom URL action menus on the settings page.
3
3
  *
4
- * Keeps row menus mutually exclusive and dismisses them on outside click
5
- * and Escape.
4
+ * Keeps row menus mutually exclusive and dismisses them on any click
5
+ * outside the trigger and the menu — including the rest of their own row —
6
+ * and on Escape.
7
+ *
8
+ * The row list rounds its corners with `overflow: hidden`, and no stacking
9
+ * order escapes an ancestor's clip. So an open menu is promoted to the top
10
+ * layer, which takes it out of the clip entirely and leaves it positioned
11
+ * against the viewport — hence the anchoring done here in script. Browsers
12
+ * without the popover API fall back to the absolutely positioned menu, which
13
+ * is clipped but still usable.
6
14
  */
7
15
 
16
+ /** Space between the trigger and the menu, in pixels. */
17
+ const MENU_GAP = 6;
18
+ /** Closest the menu may sit to a viewport edge, in pixels. */
19
+ const VIEWPORT_MARGIN = 8;
20
+
8
21
  /**
9
22
  * Initialize custom URL action menus within a root.
10
23
  *
@@ -30,8 +43,39 @@ export function initCustomUrlMenus(
30
43
 
31
44
  if (!(trigger instanceof HTMLButtonElement) || !menu) return;
32
45
 
46
+ const topLayer =
47
+ typeof menu.showPopover === "function" && menu.hasAttribute("popover");
48
+
49
+ /** Anchor the top-layer menu under the trigger, flipping above it when
50
+ * the viewport bottom is closer than the menu is tall. */
51
+ const anchorToTrigger = () => {
52
+ const anchor = trigger.getBoundingClientRect();
53
+ const { width, height } = menu.getBoundingClientRect();
54
+ const below = anchor.bottom + MENU_GAP;
55
+ const above = anchor.top - MENU_GAP - height;
56
+ const top =
57
+ below + height <= window.innerHeight - VIEWPORT_MARGIN ||
58
+ above < VIEWPORT_MARGIN
59
+ ? below
60
+ : above;
61
+
62
+ menu.style.top = `${top}px`;
63
+ menu.style.left = `${Math.max(
64
+ VIEWPORT_MARGIN,
65
+ Math.min(
66
+ anchor.right - width,
67
+ window.innerWidth - VIEWPORT_MARGIN - width,
68
+ ),
69
+ )}px`;
70
+ };
71
+
33
72
  const close = (focusTrigger = false) => {
34
73
  if (menu.hidden) return;
74
+ if (topLayer) {
75
+ window.removeEventListener("resize", anchorToTrigger);
76
+ window.removeEventListener("scroll", anchorToTrigger, true);
77
+ if (menu.matches(":popover-open")) menu.hidePopover();
78
+ }
35
79
  menu.hidden = true;
36
80
  trigger.setAttribute("aria-expanded", "false");
37
81
  if (focusTrigger) trigger.focus();
@@ -44,6 +88,14 @@ export function initCustomUrlMenus(
44
88
  }),
45
89
  );
46
90
  menu.hidden = false;
91
+ if (topLayer) {
92
+ if (!menu.matches(":popover-open")) menu.showPopover();
93
+ anchorToTrigger();
94
+ window.addEventListener("resize", anchorToTrigger);
95
+ // Capture phase: the menu follows a scroll of any ancestor, not
96
+ // only the document.
97
+ window.addEventListener("scroll", anchorToTrigger, true);
98
+ }
47
99
  trigger.setAttribute("aria-expanded", "true");
48
100
  if (focusFirstItem) {
49
101
  const firstItem =
@@ -105,11 +157,15 @@ export function initCustomUrlMenus(
105
157
  }
106
158
  });
107
159
 
160
+ // The row is the action root, but it is not the menu's dismiss region:
161
+ // the rest of the row is blank space like any other, so only the
162
+ // trigger and the menu itself count as a click inside.
108
163
  document.addEventListener("click", (event) => {
109
164
  if (!(event.target instanceof Node)) return;
110
- if (!menuRoot.contains(event.target)) {
111
- close(false);
165
+ if (trigger.contains(event.target) || menu.contains(event.target)) {
166
+ return;
112
167
  }
168
+ close(false);
113
169
  });
114
170
 
115
171
  document.addEventListener("jant:custom-url-menu", (event) => {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Inline SVG for the few Lucide icons client components render themselves.
3
+ *
4
+ * Server-rendered UI goes through `lib/icons.ts`, which indexes the whole
5
+ * `lucide-static` set (1,700+ icons, ≈1 MB minified). Client code must not
6
+ * import that module — one icon lookup would ship the entire set to the
7
+ * browser — so the handful of icons a component needs at runtime live here as
8
+ * literal markup, copied from `lucide-static` with the project's `icon-fine`
9
+ * stroke class appended.
10
+ */
11
+
12
+ /** Lucide `funnel`, the marker for a smart collection. */
13
+ export const FUNNEL_ICON_SVG =
14
+ '<svg class="lucide lucide-funnel icon-fine" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z"/></svg>';
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Image processing plan and worker protocol.
3
+ *
4
+ * The pure decisions shared by the `ImageProcessor` facade on the main thread
5
+ * and the `image-worker` that decodes and encodes off it. Nothing here touches
6
+ * the DOM, so both sides — and the tests — import it without a canvas.
7
+ */
8
+
9
+ /** Cap for the shorter image side — the side that determines text sharpness. */
10
+ export const MAX_SHORT_SIDE = 1920;
11
+
12
+ /**
13
+ * Largest long side we can still redraw on a canvas. A canvas bounded by
14
+ * MAX_SHORT_SIDE × MAX_LONG_SIDE (1920 × 8192 ≈ 15.7M px) stays under the
15
+ * ~16.7M-pixel area limit older mobile Safari enforces. Anything longer
16
+ * can't be re-encoded, so it uploads as-is.
17
+ */
18
+ export const MAX_LONG_SIDE = 8192;
19
+
20
+ /** Longest side of the tiny canvas a blurhash is sampled from. */
21
+ export const BLURHASH_MAX_SIDE = 32;
22
+
23
+ export interface ImageProcessOptions {
24
+ maxShortSide: number;
25
+ maxLongSide: number;
26
+ quality: number;
27
+ mimeType: "image/webp" | "image/jpeg" | "image/png";
28
+ }
29
+
30
+ export const DEFAULT_IMAGE_PROCESS_OPTIONS: ImageProcessOptions = {
31
+ maxShortSide: MAX_SHORT_SIDE,
32
+ maxLongSide: MAX_LONG_SIDE,
33
+ quality: 0.85,
34
+ mimeType: "image/webp",
35
+ };
36
+
37
+ export interface ImageProcessPlan {
38
+ /** When true, upload the original file untouched (too large to re-encode). */
39
+ passthrough: boolean;
40
+ /** Target dimensions — equal to the source dimensions when `passthrough`. */
41
+ width: number;
42
+ height: number;
43
+ }
44
+
45
+ /**
46
+ * Decide how to handle an image given its source dimensions.
47
+ *
48
+ * - Long side over `maxLongSide` → `passthrough` (canvas can't redraw it).
49
+ * - Short side within `maxShortSide` → keep dimensions, just re-encode.
50
+ * - Otherwise → scale down so the short side hits `maxShortSide`.
51
+ *
52
+ * @param sourceWidth - Natural image width in pixels
53
+ * @param sourceHeight - Natural image height in pixels
54
+ * @param options - `maxShortSide` and `maxLongSide` caps
55
+ * @returns The processing plan
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * planImageProcessing(1080, 6000, { maxShortSide: 1920, maxLongSide: 8192 });
60
+ * // { passthrough: false, width: 1080, height: 6000 }
61
+ * ```
62
+ */
63
+ export function planImageProcessing(
64
+ sourceWidth: number,
65
+ sourceHeight: number,
66
+ options: { maxShortSide: number; maxLongSide: number },
67
+ ): ImageProcessPlan {
68
+ const longSide = Math.max(sourceWidth, sourceHeight);
69
+ if (longSide > options.maxLongSide) {
70
+ return { passthrough: true, width: sourceWidth, height: sourceHeight };
71
+ }
72
+
73
+ const shortSide = Math.min(sourceWidth, sourceHeight);
74
+ if (shortSide <= options.maxShortSide) {
75
+ return { passthrough: false, width: sourceWidth, height: sourceHeight };
76
+ }
77
+
78
+ const scale = options.maxShortSide / shortSide;
79
+ return {
80
+ passthrough: false,
81
+ width: Math.round(sourceWidth * scale),
82
+ height: Math.round(sourceHeight * scale),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Settle the encoding format after a probe encode.
88
+ *
89
+ * A browser that cannot encode the requested format does not fail — it
90
+ * silently hands back a PNG (Safari has no WebP encoder). PNG ignores the
91
+ * quality parameter and produces oversized files, so anything other than the
92
+ * format asked for means "encode as JPEG", which every engine can do lossily.
93
+ * Probing a 1×1 canvas once beats encoding every photo twice.
94
+ *
95
+ * @param requested - The MIME type asked of the encoder
96
+ * @param produced - The MIME type of the blob it returned
97
+ * @returns The MIME type to encode real images with
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * resolveEncodedMimeType("image/webp", "image/png"); // "image/jpeg"
102
+ * resolveEncodedMimeType("image/webp", "image/webp"); // "image/webp"
103
+ * ```
104
+ */
105
+ export function resolveEncodedMimeType(
106
+ requested: string,
107
+ produced: string,
108
+ ): string {
109
+ return produced === requested ? requested : "image/jpeg";
110
+ }
111
+
112
+ /**
113
+ * Size of the canvas a blurhash is sampled from: the image scaled so its
114
+ * longest side is at most {@link BLURHASH_MAX_SIDE}, never below 1px.
115
+ *
116
+ * @param width - Source width in pixels
117
+ * @param height - Source height in pixels
118
+ * @returns The sample canvas dimensions
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * blurhashDimensions(4032, 3024); // { width: 32, height: 24 }
123
+ * ```
124
+ */
125
+ export function blurhashDimensions(
126
+ width: number,
127
+ height: number,
128
+ ): { width: number; height: number } {
129
+ const scale = Math.min(
130
+ BLURHASH_MAX_SIDE / width,
131
+ BLURHASH_MAX_SIDE / height,
132
+ 1,
133
+ );
134
+ return {
135
+ width: Math.max(Math.round(width * scale), 1),
136
+ height: Math.max(Math.round(height * scale), 1),
137
+ };
138
+ }
139
+
140
+ // ── Worker protocol ──────────────────────────────────────────────────────
141
+
142
+ export interface ImageWorkerRequest {
143
+ id: number;
144
+ file: File;
145
+ options: ImageProcessOptions;
146
+ }
147
+
148
+ export interface ImageWorkerSuccess {
149
+ id: number;
150
+ ok: true;
151
+ blob: Blob;
152
+ width: number;
153
+ height: number;
154
+ /** False when `blob` is the untouched original (too large to re-encode). */
155
+ processed: boolean;
156
+ blurhash?: string;
157
+ }
158
+
159
+ export interface ImageWorkerFailure {
160
+ id: number;
161
+ ok: false;
162
+ error: string;
163
+ }
164
+
165
+ export type ImageWorkerResponse = ImageWorkerSuccess | ImageWorkerFailure;