@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,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
+ }
@@ -9,7 +9,7 @@
9
9
  import type {
10
10
  NavManagerUpdateDetail,
11
11
  NavManagerDeleteDetail,
12
- } from "../ui/components/nav-manager-types.js";
12
+ } from "./components/nav-manager-types.js";
13
13
  import { showToast } from "./toast.js";
14
14
 
15
15
  document.addEventListener("jant:nav-update", async (event: Event) => {
@@ -6,8 +6,11 @@
6
6
  * - `jant:post-load-media` → fetch media picker HTML and manage selections
7
7
  */
8
8
 
9
- import type { PostSubmitDetail } from "../ui/components/post-form-types.js";
10
- import type { JantPostForm } from "../ui/components/jant-post-form.js";
9
+ import type {
10
+ PostSubmitDetail,
11
+ PostFormLabels,
12
+ } from "./components/post-form-types.js";
13
+ import type { JantPostForm } from "./components/jant-post-form.js";
11
14
  import { showToast } from "./toast.js";
12
15
 
13
16
  function findPostForm(
@@ -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 =
@@ -9,18 +9,11 @@
9
9
  import type {
10
10
  SettingsSaveDetail,
11
11
  AvatarRemoveDetail,
12
- } from "../ui/components/settings-types.js";
13
- import type { JantSettingsGeneral } from "../ui/components/jant-settings-general.js";
14
- import type { JantSettingsAvatar } from "../ui/components/jant-settings-avatar.js";
12
+ } from "./components/settings-types.js";
13
+ import type { JantSettingsGeneral } from "./components/jant-settings-general.js";
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();
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Thread Context Interactions
3
+ *
4
+ * 1. Expand/collapse faded ancestor context via toggle button
5
+ * 2. Auto-scroll to current post on thread detail pages
6
+ */
7
+
8
+ function parsePixelValue(value: string, fallback: number): number {
9
+ const parsed = Number.parseFloat(value);
10
+ return Number.isFinite(parsed) ? parsed : fallback;
11
+ }
12
+
13
+ function getCollapsedMaxHeight(container: HTMLElement): number {
14
+ const value = getComputedStyle(container).getPropertyValue(
15
+ "--site-thread-context-max-height",
16
+ );
17
+ return parsePixelValue(value, 188);
18
+ }
19
+
20
+ function getPendingImages(container: HTMLElement): HTMLImageElement[] {
21
+ return Array.from(container.querySelectorAll("img")).filter(
22
+ (image) => !image.complete,
23
+ );
24
+ }
25
+
26
+ function waitForContentToSettle(
27
+ container: HTMLElement,
28
+ callback: () => void,
29
+ ): void {
30
+ const pendingImages = getPendingImages(container);
31
+ if (pendingImages.length === 0) {
32
+ requestAnimationFrame(() => {
33
+ requestAnimationFrame(callback);
34
+ });
35
+ return;
36
+ }
37
+
38
+ let remaining = pendingImages.length;
39
+ const handleDone = (): void => {
40
+ remaining -= 1;
41
+ if (remaining === 0) {
42
+ callback();
43
+ }
44
+ };
45
+
46
+ pendingImages.forEach((image) => {
47
+ image.addEventListener("load", handleDone, { once: true });
48
+ image.addEventListener("error", handleDone, { once: true });
49
+ });
50
+ }
51
+
52
+ function updateThreadContextState(
53
+ container: HTMLElement,
54
+ toggle: HTMLElement,
55
+ allowExpand: boolean,
56
+ ): void {
57
+ const collapsedMaxHeight = getCollapsedMaxHeight(container);
58
+ const isExpanded = container.classList.contains("expanded");
59
+ const overflows = container.scrollHeight > collapsedMaxHeight + 1;
60
+ const showMoreLabel = toggle.dataset.labelMore ?? "Show more";
61
+ const showLessLabel = toggle.dataset.labelLess ?? "Show less";
62
+
63
+ if (!overflows) {
64
+ if (allowExpand) {
65
+ container.classList.remove(
66
+ "thread-context-collapsed",
67
+ "thread-context-faded",
68
+ "expanded",
69
+ );
70
+ } else {
71
+ container.classList.add("thread-context-collapsed");
72
+ container.classList.remove("thread-context-faded", "expanded");
73
+ }
74
+ toggle.classList.add("hidden");
75
+ toggle.textContent = showMoreLabel;
76
+ toggle.setAttribute("aria-expanded", "false");
77
+ return;
78
+ }
79
+
80
+ container.classList.add("thread-context-collapsed");
81
+ container.classList.add("thread-context-faded");
82
+ toggle.classList.remove("hidden");
83
+ toggle.textContent = isExpanded ? showLessLabel : showMoreLabel;
84
+ toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false");
85
+ }
86
+
87
+ function setupThreadContext(group: HTMLElement): void {
88
+ const container = group.querySelector<HTMLElement>("[data-thread-context]");
89
+ const toggle = group.querySelector<HTMLElement>(
90
+ "[data-thread-context-toggle]",
91
+ );
92
+ if (!container || !toggle) return;
93
+
94
+ let allowExpand = false;
95
+ updateThreadContextState(container, toggle, allowExpand);
96
+
97
+ waitForContentToSettle(container, () => {
98
+ allowExpand = true;
99
+ updateThreadContextState(container, toggle, allowExpand);
100
+ });
101
+
102
+ if ("ResizeObserver" in globalThis) {
103
+ const observer = new globalThis.ResizeObserver(() => {
104
+ updateThreadContextState(container, toggle, allowExpand);
105
+ });
106
+ observer.observe(container);
107
+ }
108
+ }
109
+
110
+ // Expand/collapse: event delegation on toggle buttons
111
+ document.addEventListener("click", (e) => {
112
+ const toggle = (e.target as HTMLElement).closest<HTMLElement>(
113
+ "[data-thread-context-toggle]",
114
+ );
115
+ if (!toggle) return;
116
+
117
+ const container = toggle
118
+ .closest(".thread-group")
119
+ ?.querySelector<HTMLElement>("[data-thread-context]");
120
+ if (!container) return;
121
+
122
+ container.classList.toggle("expanded");
123
+ updateThreadContextState(container, toggle, true);
124
+ });
125
+
126
+ // Auto-scroll to current post on detail pages
127
+ document.addEventListener("DOMContentLoaded", () => {
128
+ document.querySelectorAll(".thread-group").forEach((group) => {
129
+ if (group instanceof HTMLElement) {
130
+ setupThreadContext(group);
131
+ }
132
+ });
133
+
134
+ const current = document.querySelector("[data-post-current]");
135
+ if (!current) return;
136
+
137
+ requestAnimationFrame(() => {
138
+ current.scrollIntoView({ behavior: "smooth", block: "center" });
139
+ });
140
+ });