@jant/core 0.3.36 → 0.3.38

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 (271) 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/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -9,12 +9,36 @@ import type { ComposeSubmitDetail } from "./components/compose-types.js";
9
9
  import type { ComposeAttachment } from "./components/compose-types.js";
10
10
  import type { JantComposeDialog } from "./components/jant-compose-dialog.js";
11
11
  import type { JantComposeEditor } from "./components/jant-compose-editor.js";
12
+ import { AudioProcessor } from "./audio-processor.js";
12
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";
13
19
  import {
14
20
  showToast,
15
21
  showPersistentToast,
16
22
  replaceWithAutoClose,
17
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
+ }
18
42
 
19
43
  // ── Upload manager ──────────────────────────────────────────────────
20
44
 
@@ -34,17 +58,140 @@ async function uploadFile(
34
58
  editor: JantComposeEditor | null,
35
59
  ): Promise<string | null> {
36
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
+
37
147
  // Update status to uploading
38
148
  editor?.updateAttachmentStatus(clientId, "uploading", null, null);
39
149
 
40
- // Process images (resize, convert to WebP); upload non-images as-is
41
- const toUpload = file.type.startsWith("image/")
42
- ? await ImageProcessor.processToFile(file)
43
- : file;
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
+ }
44
185
 
45
- // Upload to server
186
+ // Small files: existing single-request upload
46
187
  const formData = new FormData();
47
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);
48
195
 
49
196
  const res = await fetch("/api/upload", {
50
197
  method: "POST",
@@ -55,6 +202,7 @@ async function uploadFile(
55
202
  const data = await res.json();
56
203
  const error = data.error ?? "Upload failed";
57
204
  editor?.updateAttachmentStatus(clientId, "error", null, error);
205
+ showToast(error, "error");
58
206
  return null;
59
207
  }
60
208
 
@@ -64,14 +212,11 @@ async function uploadFile(
64
212
  return mediaId;
65
213
  } catch {
66
214
  editor?.updateAttachmentStatus(clientId, "error", null, "Upload failed");
215
+ showToast("Upload failed", "error");
67
216
  return null;
68
217
  }
69
218
  }
70
219
 
71
- function getEditor(): JantComposeEditor | null {
72
- return document.querySelector("jant-compose-editor");
73
- }
74
-
75
220
  // ── Attachment removal handler ───────────────────────────────────────
76
221
 
77
222
  document.addEventListener("jant:attachment-removed", (e: Event) => {
@@ -94,7 +239,7 @@ document.addEventListener("jant:files-selected", (e: Event) => {
94
239
  const event = e as CustomEvent<{
95
240
  files: { file: File; clientId: string }[];
96
241
  }>;
97
- const editor = getEditor();
242
+ const editor = getComposeEditorFromEventTarget(event.target);
98
243
 
99
244
  for (const { file, clientId } of event.detail.files) {
100
245
  const promise = uploadFile(file, clientId, editor).then((mediaId) => {
@@ -113,74 +258,102 @@ document.addEventListener("jant:files-selected", (e: Event) => {
113
258
  }
114
259
  });
115
260
 
116
- // ── Submit handler ──────────────────────────────────────────────────
261
+ // ── Reply trigger handler ───────────────────────────────────────────
117
262
 
118
- document.addEventListener("jant:compose-submit", async (e: Event) => {
119
- const event = e as CustomEvent<ComposeSubmitDetail>;
120
- const detail = event.detail;
121
- const dialog = document.getElementById(
122
- "compose-dialog",
123
- ) as HTMLDialogElement | null;
124
- const composeEl = document.querySelector(
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(
125
286
  "jant-compose-dialog",
126
287
  ) as JantComposeDialog | null;
288
+ dialog?.openReply(postId, { contentHtml, dateText });
289
+ });
127
290
 
128
- if (!composeEl) return;
129
- composeEl.loading = true;
291
+ // ── Submit handler ──────────────────────────────────────────────────
130
292
 
131
- try {
132
- const res = await fetch("/compose", {
133
- method: "POST",
134
- headers: {
135
- "Content-Type": "application/json",
136
- Accept: "application/json",
137
- },
138
- body: JSON.stringify({
139
- format: detail.format,
140
- title: detail.title || undefined,
141
- body: detail.body || undefined,
142
- url: detail.url || undefined,
143
- quoteText: detail.quoteText || undefined,
144
- status: detail.status,
145
- rating: detail.rating || undefined,
146
- collectionIds:
147
- detail.collectionIds.length > 0 ? detail.collectionIds : undefined,
148
- mediaIds: detail.mediaIds.length > 0 ? detail.mediaIds : undefined,
149
- mediaAlts:
150
- Object.keys(detail.mediaAlts).length > 0
151
- ? detail.mediaAlts
152
- : undefined,
153
- }),
154
- });
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
+ }
155
313
 
156
- if (!res.ok) {
157
- const data = await res.json();
158
- showToast(data.error ?? "Something went wrong", "error");
159
- return;
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;
160
331
  }
161
332
 
162
- const data = await res.json();
163
-
164
- if (data.status === "draft") {
165
- showToast(data.toast ?? "Draft saved.");
166
- } else if (data.status === "published" && data.cardHtml) {
167
- const timeline = document.getElementById("timeline-items");
168
- if (timeline) {
169
- document.getElementById("empty-timeline")?.remove();
170
- timeline.insertAdjacentHTML("afterbegin", data.cardHtml);
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);
171
349
  }
350
+ } catch {
351
+ // Upload failed — skip this item
172
352
  }
173
-
174
- dialog?.close();
175
- // Prevent browser from restoring focus to the trigger button
176
- (document.activeElement as HTMLElement)?.blur();
177
- composeEl.reset();
178
- } catch {
179
- showToast("Something went wrong", "error");
180
- } finally {
181
- composeEl.loading = false;
182
353
  }
183
- });
354
+
355
+ return clientIdToMediaId;
356
+ }
184
357
 
185
358
  // ── Deferred submit handler ─────────────────────────────────────────
186
359
 
@@ -191,17 +364,51 @@ interface DeferredDetail extends ComposeSubmitDetail {
191
364
  document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
192
365
  const event = e as CustomEvent<DeferredDetail>;
193
366
  const detail = event.detail;
194
- const composeEl = document.querySelector(
195
- "jant-compose-dialog",
196
- ) as JantComposeDialog | null;
367
+ const composeEl =
368
+ getComposeDialogFromEventTarget(event.target) ??
369
+ (document.querySelector("jant-compose-dialog") as JantComposeDialog | null);
370
+ const isPageMode = !!composeEl?.pageMode;
197
371
 
198
372
  // Get labels for toast messages
199
373
  const labels = composeEl?.labels;
200
374
  const uploadingMsg = labels?.uploading ?? "Uploading...";
201
- const publishedMsg = labels?.published ?? "Published!";
375
+ const hasPending = detail.pendingAttachments.length > 0;
202
376
 
203
- // Show persistent toast
204
- showPersistentToast("compose-deferred", uploadingMsg);
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;
205
412
 
206
413
  try {
207
414
  // Wait for all pending uploads to complete
@@ -212,71 +419,165 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
212
419
 
213
420
  const results = await Promise.all(pendingPromises);
214
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
+
215
436
  // Merge newly completed mediaIds with already-done ones
216
437
  const newMediaIds = results.filter((id): id is string => id !== null);
217
- const allMediaIds = [...detail.mediaIds, ...newMediaIds];
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);
218
448
 
219
449
  // Merge alt text: for pending attachments that just uploaded,
220
450
  // map their clientId → mediaId and include their alt text
221
451
  const mediaAlts = { ...detail.mediaAlts };
222
452
  for (const att of detail.pendingAttachments) {
223
453
  if (att.alt) {
224
- // Find the mediaId from the upload result by matching clientId position
225
- const idx = pendingClientIds.indexOf(att.clientId);
226
- const mediaId = results[idx];
454
+ const mediaId = mediaClientIdMap.get(att.clientId);
227
455
  if (mediaId) {
228
456
  mediaAlts[mediaId] = att.alt;
229
457
  }
230
458
  }
231
459
  }
232
460
 
233
- // POST to /compose
234
- const res = await fetch("/compose", {
235
- method: "POST",
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,
236
503
  headers: {
237
504
  "Content-Type": "application/json",
238
505
  Accept: "application/json",
239
506
  },
240
- body: JSON.stringify({
241
- format: detail.format,
242
- title: detail.title || undefined,
243
- body: detail.body || undefined,
244
- url: detail.url || undefined,
245
- quoteText: detail.quoteText || undefined,
246
- status: detail.status,
247
- rating: detail.rating || undefined,
248
- collectionIds:
249
- detail.collectionIds.length > 0 ? detail.collectionIds : undefined,
250
- mediaIds: allMediaIds.length > 0 ? allMediaIds : undefined,
251
- mediaAlts: Object.keys(mediaAlts).length > 0 ? mediaAlts : undefined,
252
- }),
507
+ body: JSON.stringify(bodyPayload),
253
508
  });
254
509
 
255
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
+
256
537
  const data = await res.json();
257
- replaceWithAutoClose(
258
- "compose-deferred",
259
- data.error ?? "Something went wrong",
260
- "error",
261
- );
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);
262
559
  return;
263
560
  }
264
561
 
265
562
  const data = await res.json();
266
563
 
267
- if (data.status === "published" && data.cardHtml) {
268
- const timeline = document.getElementById("timeline-items");
269
- if (timeline) {
270
- document.getElementById("empty-timeline")?.remove();
271
- timeline.insertAdjacentHTML("afterbegin", data.cardHtml);
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();
272
572
  }
573
+ } else {
574
+ if (!leavePageAfterConfirmSave()) {
575
+ resetPageCompose();
576
+ }
577
+ toastMsg(data.toast ?? "Draft saved.");
273
578
  }
274
-
275
- replaceWithAutoClose(
276
- "compose-deferred",
277
- data.status === "draft" ? (data.toast ?? "Draft saved.") : publishedMsg,
278
- );
279
579
  } catch {
280
- replaceWithAutoClose("compose-deferred", "Something went wrong", "error");
580
+ clearPageLoading();
581
+ toastMsg("Something went wrong", "error");
281
582
  }
282
583
  });