@jant/core 0.3.35 → 0.3.37

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 (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -0,0 +1,583 @@
1
+ /**
2
+ * Compose Bridge
3
+ *
4
+ * Handles server communication between the Lit compose dialog and the server.
5
+ * Manages file uploads, deferred submit flow, and toast notifications.
6
+ */
7
+
8
+ import type { ComposeSubmitDetail } from "./components/compose-types.js";
9
+ import type { ComposeAttachment } from "./components/compose-types.js";
10
+ import type { JantComposeDialog } from "./components/jant-compose-dialog.js";
11
+ import type { JantComposeEditor } from "./components/jant-compose-editor.js";
12
+ import { AudioProcessor } from "./audio-processor.js";
13
+ import { ImageProcessor } from "./image-processor.js";
14
+ import { VideoProcessor } from "./video-processor.js";
15
+ import {
16
+ extractMediaMetadata,
17
+ extractAudioWaveform,
18
+ } from "./media-metadata.js";
19
+ import {
20
+ showToast,
21
+ showPersistentToast,
22
+ replaceWithAutoClose,
23
+ } from "./toast.js";
24
+ import { MULTIPART_THRESHOLD, uploadMultipart } from "./multipart-upload.js";
25
+ import { getMediaCategory } from "../lib/upload.js";
26
+
27
+ function getComposeEditorFromEventTarget(
28
+ target: globalThis.EventTarget | null,
29
+ ): JantComposeEditor | null {
30
+ return target instanceof globalThis.Element
31
+ ? (target.closest("jant-compose-editor") as JantComposeEditor | null)
32
+ : null;
33
+ }
34
+
35
+ function getComposeDialogFromEventTarget(
36
+ target: globalThis.EventTarget | null,
37
+ ): JantComposeDialog | null {
38
+ return target instanceof globalThis.Element
39
+ ? (target.closest("jant-compose-dialog") as JantComposeDialog | null)
40
+ : null;
41
+ }
42
+
43
+ // ── Upload manager ──────────────────────────────────────────────────
44
+
45
+ /** Track in-flight upload promises keyed by clientId */
46
+ const uploadPromises = new Map<string, Promise<string | null>>();
47
+
48
+ /** Track attachments removed while their upload is still in flight */
49
+ const removedClientIds = new Set<string>();
50
+
51
+ /**
52
+ * Upload a single file: process with ImageProcessor, then POST to /api/upload.
53
+ * Returns the mediaId on success, null on failure.
54
+ */
55
+ async function uploadFile(
56
+ file: File,
57
+ clientId: string,
58
+ editor: JantComposeEditor | null,
59
+ ): Promise<string | null> {
60
+ try {
61
+ let toUpload: File;
62
+ let width: number | undefined;
63
+ let height: number | undefined;
64
+ let blurhash: string | undefined;
65
+ let waveform: string | undefined;
66
+ let poster: Blob | undefined;
67
+
68
+ if (file.type.startsWith("video/")) {
69
+ // Video: transcode with mediabunny (requires WebCodecs)
70
+ if (!VideoProcessor.isSupported()) {
71
+ editor?.updateAttachmentStatus(
72
+ clientId,
73
+ "error",
74
+ null,
75
+ "Your browser doesn't support video processing. Use Chrome or Edge to upload videos.",
76
+ );
77
+ return null;
78
+ }
79
+
80
+ editor?.updateAttachmentStatus(clientId, "processing", null, null);
81
+ const result = await VideoProcessor.processToFile(file, (progress) => {
82
+ editor?.updateAttachmentProgress(clientId, progress);
83
+ });
84
+ toUpload = result.file;
85
+ width = result.width;
86
+ height = result.height;
87
+ blurhash = result.blurhash;
88
+ poster = result.poster;
89
+ } else if (file.type.startsWith("audio/")) {
90
+ // Audio: transcode to AAC (.m4a) (requires WebCodecs)
91
+ if (!AudioProcessor.isSupported()) {
92
+ editor?.updateAttachmentStatus(
93
+ clientId,
94
+ "error",
95
+ null,
96
+ "Your browser doesn't support audio processing. Use Chrome or Edge to upload audio.",
97
+ );
98
+ return null;
99
+ }
100
+
101
+ // Extract waveform from the original file before AudioProcessor runs
102
+ try {
103
+ waveform = await extractAudioWaveform(file);
104
+ } catch {
105
+ // Waveform extraction is best-effort
106
+ }
107
+
108
+ editor?.updateAttachmentStatus(clientId, "processing", null, null);
109
+ const result = await AudioProcessor.processToFile(file, (progress) => {
110
+ editor?.updateAttachmentProgress(clientId, progress);
111
+ });
112
+ toUpload = result.file;
113
+ } else if (
114
+ file.type.startsWith("image/") ||
115
+ /\.heic$/i.test(file.name) ||
116
+ /\.heif$/i.test(file.name)
117
+ ) {
118
+ // Image: convert HEIC/HEIF if needed, then resize + convert to WebP
119
+ let imageFile = file;
120
+ try {
121
+ const { isHeic, heicTo } = await import("heic-to");
122
+ if (await isHeic(file)) {
123
+ editor?.updateAttachmentStatus(clientId, "processing", null, null);
124
+ const blob = await heicTo({
125
+ blob: file,
126
+ type: "image/jpeg",
127
+ quality: 0.92,
128
+ });
129
+ imageFile = new File([blob], file.name.replace(/\.heic$/i, ".jpg"), {
130
+ type: "image/jpeg",
131
+ });
132
+ editor?.updateAttachmentPreview(clientId, imageFile);
133
+ }
134
+ const result = await ImageProcessor.processToFile(imageFile);
135
+ toUpload = result.file;
136
+ width = result.width;
137
+ height = result.height;
138
+ } catch {
139
+ editor?.removeAttachment(clientId);
140
+ showToast("Image format not supported.", "error");
141
+ return null;
142
+ }
143
+ } else {
144
+ toUpload = file;
145
+ }
146
+
147
+ // Update status to uploading
148
+ editor?.updateAttachmentStatus(clientId, "uploading", null, null);
149
+
150
+ // Extract metadata for non-video files (video metadata comes from VideoProcessor)
151
+ // Audio waveform is already extracted above (before AudioProcessor runs).
152
+ if (!file.type.startsWith("video/")) {
153
+ const meta = await extractMediaMetadata(toUpload);
154
+ width ??= meta.width;
155
+ height ??= meta.height;
156
+ blurhash ??= meta.blurhash;
157
+ waveform ??= meta.waveform;
158
+ poster ??= meta.poster;
159
+ }
160
+
161
+ // Large files: use multipart upload to avoid Worker body size limit
162
+ if (toUpload.size >= MULTIPART_THRESHOLD) {
163
+ const result = await uploadMultipart({
164
+ file: toUpload,
165
+ metadata: { width, height, blurhash, waveform, poster },
166
+ onProgress: (p) => editor?.updateAttachmentProgress(clientId, p),
167
+ });
168
+ editor?.updateAttachmentStatus(clientId, "done", result.id, null);
169
+ return result.id;
170
+ }
171
+
172
+ // For text-category files, read content and include summary
173
+ let summary: string | undefined;
174
+ const category = getMediaCategory(file.type);
175
+ if (category === "text" && file.type !== "text/x-tiptap+json") {
176
+ try {
177
+ const textContent = await file.text();
178
+ const trimmed = textContent.replace(/\s+/g, " ").trim();
179
+ summary =
180
+ trimmed.length <= 100 ? trimmed : trimmed.slice(0, 100) + "\u2026";
181
+ } catch {
182
+ // Ignore — summary is optional
183
+ }
184
+ }
185
+
186
+ // Small files: existing single-request upload
187
+ const formData = new FormData();
188
+ formData.append("file", toUpload);
189
+ if (width) formData.append("width", String(width));
190
+ if (height) formData.append("height", String(height));
191
+ if (blurhash) formData.append("blurhash", blurhash);
192
+ if (waveform) formData.append("waveform", waveform);
193
+ if (poster) formData.append("poster", poster, "poster.webp");
194
+ if (summary) formData.append("summary", summary);
195
+
196
+ const res = await fetch("/api/upload", {
197
+ method: "POST",
198
+ body: formData,
199
+ });
200
+
201
+ if (!res.ok) {
202
+ const data = await res.json();
203
+ const error = data.error ?? "Upload failed";
204
+ editor?.updateAttachmentStatus(clientId, "error", null, error);
205
+ showToast(error, "error");
206
+ return null;
207
+ }
208
+
209
+ const data = await res.json();
210
+ const mediaId = data.id as string;
211
+ editor?.updateAttachmentStatus(clientId, "done", mediaId, null);
212
+ return mediaId;
213
+ } catch {
214
+ editor?.updateAttachmentStatus(clientId, "error", null, "Upload failed");
215
+ showToast("Upload failed", "error");
216
+ return null;
217
+ }
218
+ }
219
+
220
+ // ── Attachment removal handler ───────────────────────────────────────
221
+
222
+ document.addEventListener("jant:attachment-removed", (e: Event) => {
223
+ const { clientId, mediaId } = (
224
+ e as CustomEvent<{ clientId: string; mediaId: string | null }>
225
+ ).detail;
226
+
227
+ if (mediaId) {
228
+ // Upload already finished — fire-and-forget delete
229
+ fetch(`/api/upload/${mediaId}`, { method: "DELETE" }).catch(() => {});
230
+ } else {
231
+ // Upload still in flight — mark for cleanup after it finishes
232
+ removedClientIds.add(clientId);
233
+ }
234
+ });
235
+
236
+ // ── File selection handler ──────────────────────────────────────────
237
+
238
+ document.addEventListener("jant:files-selected", (e: Event) => {
239
+ const event = e as CustomEvent<{
240
+ files: { file: File; clientId: string }[];
241
+ }>;
242
+ const editor = getComposeEditorFromEventTarget(event.target);
243
+
244
+ for (const { file, clientId } of event.detail.files) {
245
+ const promise = uploadFile(file, clientId, editor).then((mediaId) => {
246
+ // If the attachment was removed while uploading, delete it immediately
247
+ if (removedClientIds.has(clientId)) {
248
+ removedClientIds.delete(clientId);
249
+ if (mediaId) {
250
+ fetch(`/api/upload/${mediaId}`, { method: "DELETE" }).catch(() => {});
251
+ }
252
+ return null;
253
+ }
254
+ return mediaId;
255
+ });
256
+ uploadPromises.set(clientId, promise);
257
+ promise.finally(() => uploadPromises.delete(clientId));
258
+ }
259
+ });
260
+
261
+ // ── Reply trigger handler ───────────────────────────────────────────
262
+
263
+ document.addEventListener("click", (e: MouseEvent) => {
264
+ const trigger = (e.target as HTMLElement).closest<HTMLButtonElement>(
265
+ "[data-reply-trigger]",
266
+ );
267
+ if (!trigger) return;
268
+
269
+ const article = trigger.closest<HTMLElement>("article[data-post]");
270
+ if (!article) return;
271
+
272
+ const postId = article.dataset.postId;
273
+ if (!postId) return;
274
+
275
+ // Capture rendered content from the DOM — reuses server-rendered cards
276
+ // (NoteCard, LinkCard, QuoteCard) with all formats, media, and attachments
277
+ const clone = article.cloneNode(true) as HTMLElement;
278
+ clone.querySelector("[data-post-meta]")?.remove();
279
+ clone.querySelector(".post-status-badges")?.remove();
280
+ const contentHtml = clone.innerHTML;
281
+
282
+ const timeEl = article.querySelector<HTMLElement>("time.dt-published");
283
+ const dateText = timeEl?.textContent?.trim() ?? "";
284
+
285
+ const dialog = document.querySelector(
286
+ "jant-compose-dialog",
287
+ ) as JantComposeDialog | null;
288
+ dialog?.openReply(postId, { contentHtml, dateText });
289
+ });
290
+
291
+ // ── Submit handler ──────────────────────────────────────────────────
292
+
293
+ /** Build the JSON body for both create and update requests */
294
+ function buildPostBody(detail: ComposeSubmitDetail) {
295
+ return {
296
+ format: detail.format,
297
+ title: detail.title || undefined,
298
+ body: detail.body || undefined,
299
+ url: detail.url || undefined,
300
+ quoteText: detail.quoteText || undefined,
301
+ status: detail.status,
302
+ visibility: detail.visibility || undefined,
303
+ featured: detail.featured || undefined,
304
+ rating: detail.rating || undefined,
305
+ collectionIds:
306
+ detail.collectionIds.length > 0 ? detail.collectionIds : undefined,
307
+ mediaIds: detail.mediaIds.length > 0 ? detail.mediaIds : undefined,
308
+ mediaAlts:
309
+ Object.keys(detail.mediaAlts).length > 0 ? detail.mediaAlts : undefined,
310
+ replyToId: detail.replyToId || undefined,
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Upload text attachments as files to /api/upload.
316
+ * Returns a map of clientId → mediaId for newly uploaded items.
317
+ */
318
+ async function uploadTextAttachments(
319
+ attachedTexts: ComposeSubmitDetail["attachedTexts"],
320
+ ): Promise<Map<string, string>> {
321
+ const clientIdToMediaId = new Map<string, string>();
322
+
323
+ for (const item of attachedTexts) {
324
+ // Always re-upload text attachments with content (content may have been edited)
325
+ if (item.bodyJson === null) {
326
+ // No content — keep existing mediaId if present
327
+ if (item.mediaId) {
328
+ clientIdToMediaId.set(item.clientId, item.mediaId);
329
+ }
330
+ continue;
331
+ }
332
+
333
+ const envelope = { json: item.bodyJson, html: item.bodyHtml ?? "" };
334
+ const blob = new Blob([JSON.stringify(envelope)], {
335
+ type: "text/x-tiptap+json",
336
+ });
337
+ const formData = new FormData();
338
+ formData.append("file", blob, "attached-text.json");
339
+ formData.append("summary", item.summary);
340
+
341
+ try {
342
+ const res = await fetch("/api/upload", {
343
+ method: "POST",
344
+ body: formData,
345
+ });
346
+ if (res.ok) {
347
+ const data = await res.json();
348
+ clientIdToMediaId.set(item.clientId, data.id as string);
349
+ }
350
+ } catch {
351
+ // Upload failed — skip this item
352
+ }
353
+ }
354
+
355
+ return clientIdToMediaId;
356
+ }
357
+
358
+ // ── Deferred submit handler ─────────────────────────────────────────
359
+
360
+ interface DeferredDetail extends ComposeSubmitDetail {
361
+ pendingAttachments: ComposeAttachment[];
362
+ }
363
+
364
+ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
365
+ const event = e as CustomEvent<DeferredDetail>;
366
+ const detail = event.detail;
367
+ const composeEl =
368
+ getComposeDialogFromEventTarget(event.target) ??
369
+ (document.querySelector("jant-compose-dialog") as JantComposeDialog | null);
370
+ const isPageMode = !!composeEl?.pageMode;
371
+
372
+ // Get labels for toast messages
373
+ const labels = composeEl?.labels;
374
+ const uploadingMsg = labels?.uploading ?? "Uploading...";
375
+ const hasPending = detail.pendingAttachments.length > 0;
376
+
377
+ // Show persistent toast only when uploads are still in flight
378
+ if (hasPending) {
379
+ showPersistentToast("compose-deferred", uploadingMsg);
380
+ }
381
+
382
+ /** Show result toast — replaces persistent toast if one exists, otherwise shows a new one */
383
+ const toastMsg = (msg: string, type: "success" | "error" = "success") => {
384
+ if (hasPending) {
385
+ replaceWithAutoClose("compose-deferred", msg, type);
386
+ } else {
387
+ showToast(msg, type);
388
+ }
389
+ };
390
+ const resetPageCompose = () => {
391
+ if (!isPageMode || !composeEl) return;
392
+ composeEl.reset();
393
+ composeEl.updateComplete.then(() => {
394
+ composeEl
395
+ .querySelector<JantComposeEditor>("jant-compose-editor")
396
+ ?.focusInput();
397
+ });
398
+ };
399
+ const clearPageLoading = () => {
400
+ if (!isPageMode || !composeEl) return;
401
+ composeEl.loading = false;
402
+ };
403
+ const leavePageAfterConfirmSave = () => {
404
+ if (!isPageMode || !composeEl) return false;
405
+ if (!composeEl.consumePageLeaveRequest()) return false;
406
+ composeEl.preparePageLeave();
407
+ globalThis.location.assign(composeEl.closeHref || "/");
408
+ return true;
409
+ };
410
+ const isEdit = !!detail.editPostId;
411
+ let draftFallback: "upload" | "server" | null = null;
412
+
413
+ try {
414
+ // Wait for all pending uploads to complete
415
+ const pendingClientIds = detail.pendingAttachments.map((a) => a.clientId);
416
+ const pendingPromises = pendingClientIds
417
+ .map((id) => uploadPromises.get(id))
418
+ .filter((p): p is Promise<string | null> => p !== undefined);
419
+
420
+ const results = await Promise.all(pendingPromises);
421
+
422
+ // If any pending upload failed:
423
+ // - For new publishes: filter out failed uploads and save as draft
424
+ // - Otherwise: abort
425
+ const failedCount = results.filter((id) => id === null).length;
426
+ if (failedCount > 0) {
427
+ if (detail.status === "published" && !isEdit) {
428
+ draftFallback = "upload";
429
+ } else {
430
+ clearPageLoading();
431
+ toastMsg("Upload failed. Post not created.", "error");
432
+ return;
433
+ }
434
+ }
435
+
436
+ // Merge newly completed mediaIds with already-done ones
437
+ const newMediaIds = results.filter((id): id is string => id !== null);
438
+
439
+ // Build clientId → mediaId map for file attachments
440
+ const mediaClientIdMap = new Map<string, string>();
441
+ for (const att of detail.pendingAttachments) {
442
+ const idx = pendingClientIds.indexOf(att.clientId);
443
+ const mediaId = results[idx];
444
+ if (mediaId) mediaClientIdMap.set(att.clientId, mediaId);
445
+ }
446
+ // Upload text attachments as files
447
+ const textMediaMap = await uploadTextAttachments(detail.attachedTexts);
448
+
449
+ // Merge alt text: for pending attachments that just uploaded,
450
+ // map their clientId → mediaId and include their alt text
451
+ const mediaAlts = { ...detail.mediaAlts };
452
+ for (const att of detail.pendingAttachments) {
453
+ if (att.alt) {
454
+ const mediaId = mediaClientIdMap.get(att.clientId);
455
+ if (mediaId) {
456
+ mediaAlts[mediaId] = att.alt;
457
+ }
458
+ }
459
+ }
460
+
461
+ // Build clientId → mediaId for all file attachments.
462
+ // Uses mediaClientMap captured at submit time (editor may be reset by now).
463
+ const fileClientIdMap = new Map<string, string>(mediaClientIdMap);
464
+ for (const [cid, mid] of Object.entries(detail.mediaClientMap ?? {})) {
465
+ fileClientIdMap.set(cid, mid);
466
+ }
467
+
468
+ // Build final ordered list from attachmentOrder
469
+ let allMediaIds: string[];
470
+ if (detail.attachmentOrder && detail.attachmentOrder.length > 0) {
471
+ allMediaIds = detail.attachmentOrder
472
+ .map((clientId) => {
473
+ // Check file attachments
474
+ const fileId = fileClientIdMap.get(clientId);
475
+ if (fileId) return fileId;
476
+ // Check text attachments
477
+ const textId = textMediaMap.get(clientId);
478
+ if (textId) return textId;
479
+ return null;
480
+ })
481
+ .filter((id): id is string => id !== null);
482
+ } else {
483
+ // Fallback: combine in order
484
+ allMediaIds = [
485
+ ...detail.mediaIds,
486
+ ...newMediaIds,
487
+ ...Array.from(textMediaMap.values()),
488
+ ];
489
+ }
490
+
491
+ const endpoint = isEdit ? `/api/posts/${detail.editPostId}` : "/compose";
492
+ const method = isEdit ? "PUT" : "POST";
493
+
494
+ const bodyPayload = buildPostBody({
495
+ ...detail,
496
+ status: draftFallback ? "draft" : detail.status,
497
+ mediaIds: allMediaIds,
498
+ mediaAlts,
499
+ });
500
+
501
+ const res = await fetch(endpoint, {
502
+ method,
503
+ headers: {
504
+ "Content-Type": "application/json",
505
+ Accept: "application/json",
506
+ },
507
+ body: JSON.stringify(bodyPayload),
508
+ });
509
+
510
+ if (!res.ok) {
511
+ // Server error on a new publish: retry as draft
512
+ if (detail.status === "published" && !isEdit && !draftFallback) {
513
+ const retryPayload = { ...bodyPayload, status: "draft" };
514
+ const retryRes = await fetch(endpoint, {
515
+ method,
516
+ headers: {
517
+ "Content-Type": "application/json",
518
+ Accept: "application/json",
519
+ },
520
+ body: JSON.stringify(retryPayload),
521
+ });
522
+
523
+ if (retryRes.ok) {
524
+ draftFallback = "server";
525
+ const retryData = await retryRes.json();
526
+ const fallbackMsg =
527
+ labels?.publishFailedDraft ?? "Couldn't publish. Saved as draft.";
528
+ if (!leavePageAfterConfirmSave()) {
529
+ resetPageCompose();
530
+ }
531
+ toastMsg(fallbackMsg);
532
+ if (retryData.toast) toastMsg(retryData.toast);
533
+ return;
534
+ }
535
+ }
536
+
537
+ const data = await res.json();
538
+ clearPageLoading();
539
+ toastMsg(data.error ?? "Something went wrong", "error");
540
+ return;
541
+ }
542
+
543
+ if (isEdit) {
544
+ toastMsg("Post updated.");
545
+ if (isPageMode) {
546
+ globalThis.location.assign(globalThis.location.pathname);
547
+ } else {
548
+ globalThis.location.reload();
549
+ }
550
+ return;
551
+ }
552
+
553
+ // Upload fallback: show specific message instead of normal flow
554
+ if (draftFallback === "upload") {
555
+ const fallbackMsg =
556
+ labels?.uploadFailedDraft ?? "Some uploads failed. Saved as draft.";
557
+ resetPageCompose();
558
+ toastMsg(fallbackMsg);
559
+ return;
560
+ }
561
+
562
+ const data = await res.json();
563
+
564
+ if (data.status === "published") {
565
+ if (isPageMode && data.permalink) {
566
+ composeEl?.preparePageLeave?.();
567
+ globalThis.location.assign(data.permalink);
568
+ } else {
569
+ // Reload the page so the timeline picks up the new post via a
570
+ // full assembleTimeline() pass (correct thread previews, filters, etc.)
571
+ globalThis.location.reload();
572
+ }
573
+ } else {
574
+ if (!leavePageAfterConfirmSave()) {
575
+ resetPageCompose();
576
+ }
577
+ toastMsg(data.toast ?? "Draft saved.");
578
+ }
579
+ } catch {
580
+ clearPageLoading();
581
+ toastMsg("Something went wrong", "error");
582
+ }
583
+ });
@@ -128,13 +128,25 @@ function calculateDimensions(
128
128
  };
129
129
  }
130
130
 
131
+ export interface ProcessResult {
132
+ blob: Blob;
133
+ width: number;
134
+ height: number;
135
+ }
136
+
137
+ export interface ProcessToFileResult {
138
+ file: File;
139
+ width: number;
140
+ height: number;
141
+ }
142
+
131
143
  /**
132
144
  * Process image file
133
145
  */
134
146
  async function process(
135
147
  file: File,
136
148
  options: ProcessOptions = {},
137
- ): Promise<Blob> {
149
+ ): Promise<ProcessResult> {
138
150
  const opts = { ...DEFAULT_OPTIONS, ...options };
139
151
 
140
152
  // Read file buffer for EXIF
@@ -185,11 +197,11 @@ async function process(
185
197
  ctx.restore();
186
198
 
187
199
  // Export as WebP
188
- return new Promise((resolve, reject) => {
200
+ const blob = await new Promise<Blob>((resolve, reject) => {
189
201
  canvas.toBlob(
190
- (blob) => {
191
- if (blob) {
192
- resolve(blob);
202
+ (b) => {
203
+ if (b) {
204
+ resolve(b);
193
205
  } else {
194
206
  reject(new Error("Failed to create blob"));
195
207
  }
@@ -198,6 +210,8 @@ async function process(
198
210
  opts.quality,
199
211
  );
200
212
  });
213
+
214
+ return { blob, width, height };
201
215
  }
202
216
 
203
217
  /**
@@ -206,14 +220,18 @@ async function process(
206
220
  async function processToFile(
207
221
  file: File,
208
222
  options: ProcessOptions = {},
209
- ): Promise<File> {
210
- const blob = await process(file, options);
223
+ ): Promise<ProcessToFileResult> {
224
+ const { blob, width, height } = await process(file, options);
211
225
 
212
226
  // Generate new filename with .webp extension
213
227
  const originalName = file.name.replace(/\.[^.]+$/, "");
214
228
  const newName = `${originalName}.webp`;
215
229
 
216
- return new File([blob], newName, { type: "image/webp" });
230
+ return {
231
+ file: new File([blob], newName, { type: "image/webp" }),
232
+ width,
233
+ height,
234
+ };
217
235
  }
218
236
 
219
237
  export const ImageProcessor = { process, processToFile };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Lazy-loaded slug generation
3
+ *
4
+ * Wraps the `slugify` function from `url.ts` behind a dynamic import so
5
+ * `limax` (used for i18n-aware transliteration) doesn't bloat the main
6
+ * client bundle. Vite code-splits it into a separate chunk.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { slugify, preloadSlug } from "./lazy-slugify.js";
11
+ *
12
+ * preloadSlug(); // start loading in background
13
+ * const s = await slugify("你好世界"); // "ni-hao-shi-jie"
14
+ * ```
15
+ */
16
+
17
+ type SlugifyFn = (text: string) => string;
18
+
19
+ let slugifyFn: SlugifyFn | undefined;
20
+ let loadingPromise: Promise<SlugifyFn> | undefined;
21
+
22
+ function load(): Promise<SlugifyFn> {
23
+ if (slugifyFn) return Promise.resolve(slugifyFn);
24
+ if (!loadingPromise) {
25
+ loadingPromise = import("../lib/url.js").then((mod) => {
26
+ slugifyFn = mod.slugify;
27
+ return mod.slugify;
28
+ });
29
+ }
30
+ return loadingPromise;
31
+ }
32
+
33
+ /**
34
+ * Start loading the slug library in the background.
35
+ * Call this early (e.g. when a form mounts) so `slugify()` resolves instantly later.
36
+ */
37
+ export function preloadSlug(): void {
38
+ load();
39
+ }
40
+
41
+ /**
42
+ * Generate a URL-safe slug from the given text.
43
+ * Handles CJK scripts via pinyin transliteration.
44
+ *
45
+ * @param text - The input string to slugify
46
+ * @returns A lowercased, hyphen-separated slug
47
+ */
48
+ export async function slugify(text: string): Promise<string> {
49
+ const fn = await load();
50
+ return fn(text);
51
+ }