@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
@@ -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,247 @@
1
+ /**
2
+ * Client-side Media Metadata Extraction
3
+ *
4
+ * Extracts dimensions and blurhash from image/video files using
5
+ * Canvas API and the blurhash library.
6
+ */
7
+
8
+ import { encode } from "blurhash";
9
+
10
+ export interface MediaMetadata {
11
+ width?: number;
12
+ height?: number;
13
+ blurhash?: string;
14
+ waveform?: string;
15
+ poster?: Blob;
16
+ }
17
+
18
+ /**
19
+ * Extract metadata (width, height, blurhash) from an image file.
20
+ * Uses a small canvas (max 32px) for blurhash computation.
21
+ */
22
+ export async function extractImageMetadata(
23
+ file: File,
24
+ ): Promise<{ width: number; height: number; blurhash: string }> {
25
+ const img = await loadImage(file);
26
+ const { width, height } = img;
27
+
28
+ // Scale down for blurhash — max 32px on the longest side
29
+ const scale = Math.min(32 / width, 32 / height, 1);
30
+ const bw = Math.max(Math.round(width * scale), 1);
31
+ const bh = Math.max(Math.round(height * scale), 1);
32
+
33
+ const canvas = document.createElement("canvas");
34
+ canvas.width = bw;
35
+ canvas.height = bh;
36
+ const ctx = canvas.getContext("2d");
37
+ if (!ctx) throw new Error("Failed to get canvas context");
38
+ ctx.drawImage(img, 0, 0, bw, bh);
39
+
40
+ const imageData = ctx.getImageData(0, 0, bw, bh);
41
+ const blurhash = encode(imageData.data, bw, bh, 4, 3);
42
+
43
+ return { width, height, blurhash };
44
+ }
45
+
46
+ /**
47
+ * Extract metadata from a video file.
48
+ * Loads the video to get dimensions, then seeks to `min(duration * 0.1, 3)` and
49
+ * captures a frame for blurhash (32px canvas) and a poster image (640px WebP).
50
+ * Uses an 8s timeout — returns only dimensions if capture times out.
51
+ */
52
+ export async function extractVideoMetadata(file: File): Promise<{
53
+ width: number;
54
+ height: number;
55
+ blurhash?: string;
56
+ poster?: Blob;
57
+ }> {
58
+ const url = URL.createObjectURL(file);
59
+ try {
60
+ const video = document.createElement("video");
61
+ video.muted = true;
62
+ video.preload = "auto";
63
+
64
+ // Wait for metadata to load (includes duration)
65
+ const { width, height, duration } = await new Promise<{
66
+ width: number;
67
+ height: number;
68
+ duration: number;
69
+ }>((resolve, reject) => {
70
+ video.onloadedmetadata = () =>
71
+ resolve({
72
+ width: video.videoWidth,
73
+ height: video.videoHeight,
74
+ duration: video.duration,
75
+ });
76
+ video.onerror = () => reject(new Error("Failed to load video metadata"));
77
+ video.src = url;
78
+ });
79
+
80
+ // Try to capture frame for blurhash + poster (8s timeout)
81
+ let blurhash: string | undefined;
82
+ let poster: Blob | undefined;
83
+ try {
84
+ const seekTime = Math.min(duration * 0.1, 3);
85
+ const result = await Promise.race([
86
+ captureVideoFrameAndPoster(video, width, height, seekTime),
87
+ timeout(8000),
88
+ ]);
89
+ blurhash = result.blurhash;
90
+ poster = result.poster;
91
+ } catch {
92
+ // Timeout or capture failed — return dimensions only
93
+ }
94
+
95
+ return { width, height, blurhash, poster };
96
+ } finally {
97
+ URL.revokeObjectURL(url);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Extract waveform peak amplitudes from an audio file.
103
+ * Decodes via Web Audio API and returns a JSON string of ~100 normalized peak values (0–1).
104
+ *
105
+ * @param file - Audio file to extract peaks from
106
+ * @returns JSON string of peak values, e.g. "[0.2,0.8,0.5,...]"
107
+ */
108
+ export async function extractAudioWaveform(file: File): Promise<string> {
109
+ const buffer = await file.arrayBuffer();
110
+ const audioCtx = new AudioContext();
111
+
112
+ try {
113
+ const decoded = await audioCtx.decodeAudioData(buffer);
114
+ const raw = decoded.getChannelData(0);
115
+ const count = 100;
116
+ const step = Math.max(1, Math.floor(raw.length / count));
117
+ const peaks: number[] = new Array(count);
118
+
119
+ for (let i = 0; i < count; i++) {
120
+ let max = 0;
121
+ const start = i * step;
122
+ const end = Math.min(start + step, raw.length);
123
+ for (let j = start; j < end; j++) {
124
+ const v = Math.abs(raw[j]);
125
+ if (v > max) max = v;
126
+ }
127
+ peaks[i] = max;
128
+ }
129
+
130
+ let maxPeak = 0;
131
+ for (const p of peaks) if (p > maxPeak) maxPeak = p;
132
+ if (maxPeak > 0) {
133
+ for (let i = 0; i < count; i++)
134
+ peaks[i] = Math.round((peaks[i] / maxPeak) * 100) / 100;
135
+ }
136
+
137
+ return JSON.stringify(peaks);
138
+ } finally {
139
+ await audioCtx.close();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Extract metadata from any media file based on MIME type.
145
+ */
146
+ export async function extractMediaMetadata(file: File): Promise<MediaMetadata> {
147
+ try {
148
+ if (file.type.startsWith("image/")) {
149
+ return await extractImageMetadata(file);
150
+ }
151
+ if (file.type.startsWith("video/")) {
152
+ const result = await extractVideoMetadata(file);
153
+ return {
154
+ width: result.width,
155
+ height: result.height,
156
+ blurhash: result.blurhash,
157
+ poster: result.poster,
158
+ };
159
+ }
160
+ if (file.type.startsWith("audio/")) {
161
+ const waveform = await extractAudioWaveform(file);
162
+ return { waveform };
163
+ }
164
+ } catch {
165
+ // Extraction failed — return empty metadata
166
+ }
167
+ return {};
168
+ }
169
+
170
+ // --- Helpers ---
171
+
172
+ function loadImage(file: File): Promise<HTMLImageElement> {
173
+ return new Promise((resolve, reject) => {
174
+ const img = new Image();
175
+ const url = URL.createObjectURL(file);
176
+ img.onload = () => {
177
+ URL.revokeObjectURL(url);
178
+ resolve(img);
179
+ };
180
+ img.onerror = () => {
181
+ URL.revokeObjectURL(url);
182
+ reject(new Error("Failed to load image"));
183
+ };
184
+ img.src = url;
185
+ });
186
+ }
187
+
188
+ function captureVideoFrameAndPoster(
189
+ video: HTMLVideoElement,
190
+ width: number,
191
+ height: number,
192
+ seekTime: number,
193
+ ): Promise<{ blurhash: string; poster?: Blob }> {
194
+ return new Promise((resolve, reject) => {
195
+ video.currentTime = seekTime;
196
+ video.onseeked = () => {
197
+ try {
198
+ // Blurhash: small 32px canvas
199
+ const scale = Math.min(32 / width, 32 / height, 1);
200
+ const bw = Math.max(Math.round(width * scale), 1);
201
+ const bh = Math.max(Math.round(height * scale), 1);
202
+
203
+ const bhCanvas = document.createElement("canvas");
204
+ bhCanvas.width = bw;
205
+ bhCanvas.height = bh;
206
+ const bhCtx = bhCanvas.getContext("2d");
207
+ if (!bhCtx) throw new Error("Failed to get canvas context");
208
+ bhCtx.drawImage(video, 0, 0, bw, bh);
209
+
210
+ const imageData = bhCtx.getImageData(0, 0, bw, bh);
211
+ const blurhash = encode(imageData.data, bw, bh, 4, 3);
212
+
213
+ // Poster: 640px wide WebP
214
+ const posterScale = Math.min(640 / width, 1);
215
+ const pw = Math.round(width * posterScale);
216
+ const ph = Math.round(height * posterScale);
217
+
218
+ const posterCanvas = document.createElement("canvas");
219
+ posterCanvas.width = pw;
220
+ posterCanvas.height = ph;
221
+ const pCtx = posterCanvas.getContext("2d");
222
+ if (!pCtx) {
223
+ resolve({ blurhash });
224
+ return;
225
+ }
226
+ pCtx.drawImage(video, 0, 0, pw, ph);
227
+
228
+ posterCanvas.toBlob(
229
+ (blob) => {
230
+ resolve({ blurhash, poster: blob ?? undefined });
231
+ },
232
+ "image/webp",
233
+ 0.8,
234
+ );
235
+ } catch (err) {
236
+ reject(err);
237
+ }
238
+ };
239
+ video.onerror = () => reject(new Error("Video seek failed"));
240
+ });
241
+ }
242
+
243
+ function timeout(ms: number): Promise<never> {
244
+ return new Promise((_, reject) =>
245
+ setTimeout(() => reject(new Error("Timeout")), ms),
246
+ );
247
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Client-side Multipart Upload Helper
3
+ *
4
+ * Transparently handles chunked uploads for files that exceed the
5
+ * Cloudflare Workers 100MB request body limit. Used by compose-bridge
6
+ * when a file is larger than MULTIPART_THRESHOLD.
7
+ */
8
+
9
+ /** Files at or above this size use multipart upload (95MB, below 100MB Worker limit) */
10
+ export const MULTIPART_THRESHOLD = 95 * 1024 * 1024;
11
+
12
+ /** Size of each upload chunk (50MB) */
13
+ const CHUNK_SIZE = 50 * 1024 * 1024;
14
+
15
+ export interface MultipartUploadResult {
16
+ id: string;
17
+ filename: string;
18
+ url: string;
19
+ mimeType: string;
20
+ size: number;
21
+ }
22
+
23
+ export interface MultipartUploadOptions {
24
+ file: File;
25
+ metadata: {
26
+ width?: number;
27
+ height?: number;
28
+ blurhash?: string;
29
+ waveform?: string;
30
+ poster?: Blob;
31
+ };
32
+ onProgress?: (progress: number) => void;
33
+ }
34
+
35
+ /**
36
+ * Upload a large file using the multipart upload protocol.
37
+ *
38
+ * @param options - File, metadata, and optional progress callback
39
+ * @returns The uploaded media record
40
+ * @throws Error if any step of the upload fails
41
+ */
42
+ export async function uploadMultipart(
43
+ options: MultipartUploadOptions,
44
+ ): Promise<MultipartUploadResult> {
45
+ const { file, metadata, onProgress } = options;
46
+
47
+ // 1. Initiate the multipart upload
48
+ const initRes = await fetch("/api/upload/multipart", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({
52
+ filename: file.name,
53
+ contentType: file.type,
54
+ size: file.size,
55
+ }),
56
+ });
57
+
58
+ if (!initRes.ok) {
59
+ const data = await initRes.json();
60
+ throw new Error(data.error ?? "Failed to start upload");
61
+ }
62
+
63
+ const { id, uploadId, storageKey, filename, originalName } =
64
+ (await initRes.json()) as {
65
+ id: string;
66
+ uploadId: string;
67
+ storageKey: string;
68
+ filename: string;
69
+ originalName: string;
70
+ };
71
+
72
+ try {
73
+ // 2. Upload poster if present (small file, single request)
74
+ let posterKey: string | undefined;
75
+ if (metadata.poster) {
76
+ const posterForm = new FormData();
77
+ posterForm.append("poster", metadata.poster, "poster.webp");
78
+
79
+ const posterRes = await fetch(`/api/upload/multipart/${id}/poster`, {
80
+ method: "PUT",
81
+ body: posterForm,
82
+ });
83
+
84
+ if (!posterRes.ok) {
85
+ throw new Error("Failed to upload poster");
86
+ }
87
+
88
+ const posterData = (await posterRes.json()) as { posterKey: string };
89
+ posterKey = posterData.posterKey;
90
+ }
91
+
92
+ // 3. Slice file into chunks and upload each part
93
+ const totalSize = file.size;
94
+ const totalParts = Math.ceil(totalSize / CHUNK_SIZE);
95
+ const parts: { partNumber: number; etag: string }[] = [];
96
+ let uploadedBytes = 0;
97
+
98
+ for (let i = 0; i < totalParts; i++) {
99
+ const start = i * CHUNK_SIZE;
100
+ const end = Math.min(start + CHUNK_SIZE, totalSize);
101
+ const chunk = file.slice(start, end);
102
+ const partNumber = i + 1;
103
+
104
+ const partRes = await fetch(
105
+ `/api/upload/multipart/${id}/part?partNumber=${partNumber}&storageKey=${encodeURIComponent(storageKey)}&uploadId=${encodeURIComponent(uploadId)}`,
106
+ {
107
+ method: "PUT",
108
+ body: chunk,
109
+ },
110
+ );
111
+
112
+ if (!partRes.ok) {
113
+ throw new Error(`Failed to upload part ${partNumber}`);
114
+ }
115
+
116
+ const partData = (await partRes.json()) as {
117
+ partNumber: number;
118
+ etag: string;
119
+ };
120
+ parts.push(partData);
121
+
122
+ uploadedBytes += end - start;
123
+ onProgress?.(uploadedBytes / totalSize);
124
+ }
125
+
126
+ // 4. Complete the multipart upload — send all metadata for DB record
127
+ const completeRes = await fetch(`/api/upload/multipart/${id}/complete`, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json" },
130
+ body: JSON.stringify({
131
+ storageKey,
132
+ uploadId,
133
+ parts,
134
+ filename,
135
+ originalName,
136
+ contentType: file.type,
137
+ size: file.size,
138
+ width: metadata.width,
139
+ height: metadata.height,
140
+ blurhash: metadata.blurhash,
141
+ waveform: metadata.waveform,
142
+ posterKey,
143
+ }),
144
+ });
145
+
146
+ if (!completeRes.ok) {
147
+ throw new Error("Failed to complete upload");
148
+ }
149
+
150
+ return (await completeRes.json()) as MultipartUploadResult;
151
+ } catch (err) {
152
+ // Abort on any failure — fire-and-forget cleanup
153
+ fetch(`/api/upload/multipart/${id}/abort`, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({ storageKey, uploadId }),
157
+ }).catch(() => {});
158
+ throw err;
159
+ }
160
+ }
@@ -6,7 +6,10 @@
6
6
  * - `jant:post-load-media` → fetch media picker HTML and manage selections
7
7
  */
8
8
 
9
- import type { PostSubmitDetail } from "./components/post-form-types.js";
9
+ import type {
10
+ PostSubmitDetail,
11
+ PostFormLabels,
12
+ } from "./components/post-form-types.js";
10
13
  import type { JantPostForm } from "./components/jant-post-form.js";
11
14
  import { showToast } from "./toast.js";
12
15
 
@@ -57,16 +60,64 @@ async function handlePostSubmit(event: Event) {
57
60
  } catch {
58
61
  // Ignore JSON parse failure; keep fallback message.
59
62
  }
63
+
64
+ // Auto-save as draft when a new publish fails
65
+ if (detail.data.status === "published" && !detail.isEdit) {
66
+ try {
67
+ const retryRes = await fetch(detail.endpoint, {
68
+ method: "POST",
69
+ headers: {
70
+ "Content-Type": "application/json",
71
+ Accept: "application/json",
72
+ },
73
+ body: JSON.stringify({ ...detail.data, status: "draft" }),
74
+ });
75
+
76
+ if (retryRes.ok) {
77
+ const retryJson = await retryRes.json();
78
+ const labelsAttr = formEl.getAttribute("labels");
79
+ let fallbackMsg = "Couldn't publish. Saved as draft.";
80
+ if (labelsAttr) {
81
+ try {
82
+ const parsed = JSON.parse(
83
+ labelsAttr,
84
+ ) as Partial<PostFormLabels>;
85
+ if (parsed.draftFallbackMessage)
86
+ fallbackMsg = parsed.draftFallbackMessage;
87
+ } catch {
88
+ // Ignore parse failure; use default message
89
+ }
90
+ }
91
+ showToast(fallbackMsg);
92
+
93
+ if (
94
+ retryJson?.status === "redirect" &&
95
+ typeof retryJson.url === "string"
96
+ ) {
97
+ formEl.clearDirty();
98
+ window.location.href = retryJson.url;
99
+ return;
100
+ }
101
+ formEl.clearDirty();
102
+ return;
103
+ }
104
+ } catch {
105
+ // Retry failed — fall through to show original error
106
+ }
107
+ }
108
+
60
109
  throw new Error(message);
61
110
  }
62
111
 
63
112
  const json = await res.json();
64
113
 
65
114
  if (json?.status === "redirect" && typeof json.url === "string") {
115
+ formEl.clearDirty();
66
116
  window.location.href = json.url;
67
117
  return;
68
118
  }
69
119
 
120
+ formEl.clearDirty();
70
121
  showToast(detail.messages.success);
71
122
  } catch (err) {
72
123
  const message =
@@ -14,13 +14,6 @@ import type { JantSettingsGeneral } from "./components/jant-settings-general.js"
14
14
  import type { JantSettingsAvatar } from "./components/jant-settings-avatar.js";
15
15
  import { showToast } from "./toast.js";
16
16
 
17
- function updateSidebarSiteName(siteName: string) {
18
- const el = document.getElementById("site-name");
19
- if (el) el.textContent = siteName;
20
- const titleEl = document.querySelector("title");
21
- if (titleEl) titleEl.textContent = `Settings - ${siteName}`;
22
- }
23
-
24
17
  // ── Settings save handler ───────────────────────────────────────────
25
18
 
26
19
  document.addEventListener("jant:settings-save", async (e: Event) => {
@@ -59,11 +52,6 @@ document.addEventListener("jant:settings-save", async (e: Event) => {
59
52
  showToast(json.toast);
60
53
  }
61
54
 
62
- // Update sidebar site name when general settings are saved
63
- if (section === "general" && json.siteName) {
64
- updateSidebarSiteName(json.siteName);
65
- }
66
-
67
55
  // Notify the component that save succeeded
68
56
  if (section === "avatar-display") {
69
57
  avatarEl?.saved();