@jant/core 0.3.36 → 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 (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
@@ -5,6 +5,13 @@
5
5
  * Appends a temporary notification to `#toast-container`.
6
6
  */
7
7
 
8
+ /** Ensure the toast container is in the top layer (above <dialog> etc.) */
9
+ function ensureTopLayer(container: HTMLElement): void {
10
+ if (!container.matches(":popover-open")) {
11
+ container.showPopover();
12
+ }
13
+ }
14
+
8
15
  const TOAST_ICONS = {
9
16
  success:
10
17
  '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg>',
@@ -12,6 +19,26 @@ const TOAST_ICONS = {
12
19
  '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg>',
13
20
  };
14
21
 
22
+ /** Build toast inner content using safe DOM APIs (icon is trusted, text uses textContent). */
23
+ function setToastContent(
24
+ toast: HTMLElement,
25
+ type: "success" | "error",
26
+ message: string,
27
+ action?: { label: string; href: string },
28
+ ): void {
29
+ toast.innerHTML = TOAST_ICONS[type];
30
+ const span = document.createElement("span");
31
+ span.textContent = message;
32
+ toast.appendChild(span);
33
+ if (action) {
34
+ const a = document.createElement("a");
35
+ a.href = action.href;
36
+ a.className = "toast-action";
37
+ a.textContent = action.label;
38
+ toast.appendChild(a);
39
+ }
40
+ }
41
+
15
42
  /**
16
43
  * Show a toast notification.
17
44
  *
@@ -31,9 +58,44 @@ export function showToast(
31
58
  const container = document.getElementById("toast-container");
32
59
  if (!container) return;
33
60
 
61
+ ensureTopLayer(container);
62
+
34
63
  const toast = document.createElement("div");
35
64
  toast.className = `toast toast-${type}`;
36
- toast.innerHTML = `${TOAST_ICONS[type]}<span>${message}</span>`;
65
+ setToastContent(toast, type, message);
66
+ container.appendChild(toast);
67
+
68
+ setTimeout(() => {
69
+ toast.classList.add("toast-out");
70
+ toast.addEventListener("animationend", () => toast.remove());
71
+ }, 3000);
72
+ }
73
+
74
+ /**
75
+ * Show a toast with an action link.
76
+ *
77
+ * @param message - Text to display
78
+ * @param action - Action link with label and href
79
+ * @param type - Visual style: "success" (default) or "error"
80
+ *
81
+ * @example
82
+ * showToastWithAction("Post published.", { label: "View", href: "/p/abc" });
83
+ */
84
+ export function showToastWithAction(
85
+ message: string,
86
+ action: { label: string; href: string },
87
+ type: "success" | "error" = "success",
88
+ ): void {
89
+ if (!message) return;
90
+
91
+ const container = document.getElementById("toast-container");
92
+ if (!container) return;
93
+
94
+ ensureTopLayer(container);
95
+
96
+ const toast = document.createElement("div");
97
+ toast.className = `toast toast-${type}`;
98
+ setToastContent(toast, type, message, action);
37
99
  container.appendChild(toast);
38
100
 
39
101
  setTimeout(() => {
@@ -61,10 +123,12 @@ export function showPersistentToast(
61
123
  const container = document.getElementById("toast-container");
62
124
  if (!container) return null;
63
125
 
126
+ ensureTopLayer(container);
127
+
64
128
  const toast = document.createElement("div");
65
129
  toast.className = `toast toast-${type}`;
66
130
  toast.id = `toast-${id}`;
67
- toast.innerHTML = `${TOAST_ICONS[type]}<span>${message}</span>`;
131
+ setToastContent(toast, type, message);
68
132
  container.appendChild(toast);
69
133
 
70
134
  return toast;
@@ -125,7 +189,41 @@ export function replaceWithAutoClose(
125
189
  }
126
190
 
127
191
  toast.className = `toast toast-${type}`;
128
- toast.innerHTML = `${TOAST_ICONS[type]}<span>${message}</span>`;
192
+ toast.replaceChildren();
193
+ setToastContent(toast, type, message);
194
+
195
+ setTimeout(() => {
196
+ toast.classList.add("toast-out");
197
+ toast.addEventListener("animationend", () => toast.remove());
198
+ }, 3000);
199
+ }
200
+
201
+ /**
202
+ * Replace a persistent toast with an auto-dismissing one that has an action link.
203
+ *
204
+ * @param id - The toast identifier
205
+ * @param message - New message text
206
+ * @param action - Action link with label and href
207
+ * @param type - Visual style: "success" (default) or "error"
208
+ *
209
+ * @example
210
+ * replaceWithAutoCloseAction("upload", "Post published.", { label: "View", href: "/p/abc" });
211
+ */
212
+ export function replaceWithAutoCloseAction(
213
+ id: string,
214
+ message: string,
215
+ action: { label: string; href: string },
216
+ type: "success" | "error" = "success",
217
+ ): void {
218
+ const toast = document.getElementById(`toast-${id}`);
219
+ if (!toast) {
220
+ showToastWithAction(message, action, type);
221
+ return;
222
+ }
223
+
224
+ toast.className = `toast toast-${type}`;
225
+ toast.replaceChildren();
226
+ setToastContent(toast, type, message, action);
129
227
 
130
228
  setTimeout(() => {
131
229
  toast.classList.add("toast-out");
@@ -13,8 +13,23 @@ declare module "sortablejs" {
13
13
 
14
14
  interface SortableOptions {
15
15
  animation?: number;
16
+ bubbleScroll?: boolean;
17
+ chosenClass?: string;
18
+ direction?: "horizontal" | "vertical";
19
+ dragClass?: string;
20
+ fallbackTolerance?: number;
21
+ filter?: string;
22
+ forceAutoScrollFallback?: boolean;
23
+ ghostClass?: string;
16
24
  handle?: string;
25
+ onChoose?: (event: SortableEvent) => void;
26
+ onStart?: (event: SortableEvent) => void;
27
+ onUnchoose?: (event: SortableEvent) => void;
17
28
  onEnd?: (event: SortableEvent) => void;
29
+ preventOnFilter?: boolean;
30
+ scroll?: boolean | HTMLElement;
31
+ scrollSensitivity?: number;
32
+ scrollSpeed?: number;
18
33
  }
19
34
 
20
35
  interface SortableInstance {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shared Upload Helper with Metadata
3
+ *
4
+ * Processes images via ImageProcessor, extracts dimensions + blurhash,
5
+ * and uploads with metadata attached to the FormData.
6
+ * Used by paste-image, image-node replace, and fullscreen compose.
7
+ */
8
+
9
+ import { ImageProcessor } from "./image-processor.js";
10
+ import { extractImageMetadata } from "./media-metadata.js";
11
+
12
+ /**
13
+ * Process an image file and upload it with dimension/blurhash metadata.
14
+ *
15
+ * @returns The server response with url and id
16
+ */
17
+ export async function uploadWithMetadata(
18
+ file: File,
19
+ ): Promise<{ url: string; id: string }> {
20
+ // Process image (resize, convert to WebP)
21
+ const {
22
+ file: processed,
23
+ width,
24
+ height,
25
+ } = await ImageProcessor.processToFile(file);
26
+
27
+ // Extract blurhash from the processed file
28
+ let blurhash: string | undefined;
29
+ try {
30
+ const meta = await extractImageMetadata(processed);
31
+ blurhash = meta.blurhash;
32
+ } catch {
33
+ // Blurhash extraction failed — upload without it
34
+ }
35
+
36
+ const formData = new FormData();
37
+ formData.append("file", processed);
38
+ formData.append("width", String(width));
39
+ formData.append("height", String(height));
40
+ if (blurhash) {
41
+ formData.append("blurhash", blurhash);
42
+ }
43
+
44
+ const response = await fetch("/api/upload", {
45
+ method: "POST",
46
+ body: formData,
47
+ });
48
+
49
+ if (!response.ok) {
50
+ throw new Error(`Upload failed: ${response.status}`);
51
+ }
52
+
53
+ return (await response.json()) as { url: string; id: string };
54
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Client-side Video Processor
3
+ *
4
+ * Processes videos before upload using mediabunny:
5
+ * - Transcodes to H.264/AAC MP4 (universal playback)
6
+ * - Resizes to max 1920×1080
7
+ * - Extracts poster frame + blurhash during processing
8
+ *
9
+ * Requires WebCodecs API support — check `isSupported()` before use.
10
+ */
11
+
12
+ import {
13
+ Input,
14
+ Output,
15
+ Mp4OutputFormat,
16
+ BufferTarget,
17
+ BlobSource,
18
+ CanvasSink,
19
+ Conversion,
20
+ QUALITY_HIGH,
21
+ ALL_FORMATS,
22
+ } from "mediabunny";
23
+ import { encode } from "blurhash";
24
+
25
+ const MAX_WIDTH = 1920;
26
+ const MAX_HEIGHT = 1080;
27
+ const POSTER_WIDTH = 640;
28
+ const BLURHASH_SIZE = 32;
29
+
30
+ export interface VideoProcessResult {
31
+ file: File;
32
+ width: number;
33
+ height: number;
34
+ poster?: Blob;
35
+ blurhash?: string;
36
+ }
37
+
38
+ /**
39
+ * Check if the browser supports WebCodecs-based video processing.
40
+ *
41
+ * @returns `true` if `VideoEncoder` is available in the current environment
42
+ */
43
+ function isSupported(): boolean {
44
+ return typeof VideoEncoder !== "undefined";
45
+ }
46
+
47
+ /**
48
+ * Extract a poster frame, blurhash, and source dimensions from a video file.
49
+ * Seeks to `min(duration × 0.1, 3s)` and captures the frame.
50
+ * Also returns the original video dimensions so the caller can compute
51
+ * the correct output size without opening a second Input instance.
52
+ *
53
+ * @param file - Source video file
54
+ * @returns Poster blob (640px-wide WebP), blurhash string, and source dimensions
55
+ */
56
+ async function extractPoster(file: File): Promise<{
57
+ poster?: Blob;
58
+ blurhash?: string;
59
+ sourceWidth?: number;
60
+ sourceHeight?: number;
61
+ }> {
62
+ const input = new Input({
63
+ source: new BlobSource(file),
64
+ formats: ALL_FORMATS,
65
+ });
66
+ try {
67
+ const videoTrack = await input.getPrimaryVideoTrack();
68
+ if (!videoTrack) return {};
69
+
70
+ const sourceWidth = videoTrack.displayWidth;
71
+ const sourceHeight = videoTrack.displayHeight;
72
+
73
+ const duration = await input.computeDuration();
74
+ const seekTime = Math.min(duration * 0.1, 3);
75
+
76
+ const sink = new CanvasSink(videoTrack);
77
+ const wrapped = await sink.getCanvas(seekTime);
78
+ if (!wrapped) return { sourceWidth, sourceHeight };
79
+
80
+ const canvas = wrapped.canvas as HTMLCanvasElement;
81
+
82
+ // Poster: 640px wide WebP
83
+ const srcW = canvas.width;
84
+ const srcH = canvas.height;
85
+ const posterScale = Math.min(POSTER_WIDTH / srcW, 1);
86
+ const pw = Math.round(srcW * posterScale);
87
+ const ph = Math.round(srcH * posterScale);
88
+
89
+ const posterCanvas = document.createElement("canvas");
90
+ posterCanvas.width = pw;
91
+ posterCanvas.height = ph;
92
+ const pCtx = posterCanvas.getContext("2d");
93
+ if (!pCtx) return { sourceWidth, sourceHeight };
94
+ pCtx.drawImage(canvas, 0, 0, pw, ph);
95
+
96
+ const poster = await new Promise<Blob | undefined>((resolve) => {
97
+ posterCanvas.toBlob(
98
+ (blob) => resolve(blob ?? undefined),
99
+ "image/webp",
100
+ 0.8,
101
+ );
102
+ });
103
+
104
+ // Blurhash: 32px canvas, 4×3 components
105
+ const bhScale = Math.min(BLURHASH_SIZE / srcW, BLURHASH_SIZE / srcH, 1);
106
+ const bw = Math.max(Math.round(srcW * bhScale), 1);
107
+ const bh = Math.max(Math.round(srcH * bhScale), 1);
108
+
109
+ const bhCanvas = document.createElement("canvas");
110
+ bhCanvas.width = bw;
111
+ bhCanvas.height = bh;
112
+ const bhCtx = bhCanvas.getContext("2d");
113
+ if (!bhCtx) return { poster, sourceWidth, sourceHeight };
114
+ bhCtx.drawImage(canvas, 0, 0, bw, bh);
115
+
116
+ const imageData = bhCtx.getImageData(0, 0, bw, bh);
117
+ const blurhash = encode(imageData.data, bw, bh, 4, 3);
118
+
119
+ return { poster, blurhash, sourceWidth, sourceHeight };
120
+ } catch {
121
+ return {};
122
+ } finally {
123
+ input.dispose();
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Process a video file: transcode to H.264/AAC MP4, resize to fit within
129
+ * 1920×1080, and extract poster frame + blurhash.
130
+ *
131
+ * @param file - Source video file
132
+ * @param onProgress - Optional callback receiving progress from 0 to 1
133
+ * @returns Processed MP4 file with dimensions, poster, and blurhash
134
+ */
135
+ async function processToFile(
136
+ file: File,
137
+ onProgress?: (progress: number) => void,
138
+ ): Promise<VideoProcessResult> {
139
+ // Extract poster + blurhash + source dimensions (separate Input instance,
140
+ // so the transcoding Input below starts with clean demuxer state).
141
+ const { poster, blurhash, sourceWidth, sourceHeight } =
142
+ await extractPoster(file);
143
+
144
+ // Compute output size preserving the original aspect ratio
145
+ let width = MAX_WIDTH;
146
+ let height = MAX_HEIGHT;
147
+ if (sourceWidth && sourceHeight) {
148
+ const scale = Math.min(
149
+ MAX_WIDTH / sourceWidth,
150
+ MAX_HEIGHT / sourceHeight,
151
+ 1,
152
+ );
153
+ width = Math.round(sourceWidth * scale);
154
+ height = Math.round(sourceHeight * scale);
155
+ }
156
+ // H.264 requires even dimensions
157
+ width += width % 2;
158
+ height += height % 2;
159
+
160
+ // Transcode to MP4 H.264/AAC (fresh Input — not shared with extractPoster)
161
+ const input = new Input({
162
+ source: new BlobSource(file),
163
+ formats: ALL_FORMATS,
164
+ });
165
+ const target = new BufferTarget();
166
+ const output = new Output({
167
+ format: new Mp4OutputFormat({ fastStart: "in-memory" }),
168
+ target,
169
+ });
170
+
171
+ try {
172
+ const conversion = await Conversion.init({
173
+ input,
174
+ output,
175
+ video: {
176
+ codec: "avc",
177
+ width,
178
+ height,
179
+ fit: "contain",
180
+ bitrate: QUALITY_HIGH,
181
+ },
182
+ audio: {
183
+ codec: "aac",
184
+ },
185
+ });
186
+
187
+ if (onProgress) {
188
+ conversion.onProgress = onProgress;
189
+ }
190
+
191
+ await conversion.execute();
192
+
193
+ const buffer = target.buffer;
194
+ if (!buffer) throw new Error("Video processing produced no output");
195
+
196
+ const originalName = file.name.replace(/\.[^.]+$/, "");
197
+ const mp4File = new File([buffer], `${originalName}.mp4`, {
198
+ type: "video/mp4",
199
+ });
200
+
201
+ return { file: mp4File, width, height, poster, blurhash };
202
+ } finally {
203
+ input.dispose();
204
+ }
205
+ }
206
+
207
+ export const VideoProcessor = { isSupported, processToFile };
package/src/client.ts CHANGED
@@ -10,7 +10,6 @@
10
10
  import "./vendor/datastar.js";
11
11
  import "basecoat-css/all";
12
12
  import "./client/image-processor.js";
13
- import "./client/media-upload.js";
14
13
  import "./client/avatar-upload.js";
15
14
 
16
15
  // Lit Web Components (and their bridge modules)
@@ -30,7 +29,11 @@ import "./client/components/jant-collection-sidebar.js";
30
29
  import "./client/collection-form-bridge.js";
31
30
  import "./client/components/jant-post-form.js";
32
31
  import "./client/post-form-bridge.js";
33
- import "./client/page-slug-bridge.js";
34
32
  import "./client/components/jant-nav-manager.js";
35
33
  import "./client/nav-manager-bridge.js";
34
+ import "./client/audio-player.js";
36
35
  import "./client/components/jant-media-lightbox.js";
36
+ import "./client/components/jant-text-preview.js";
37
+ import "./client/components/jant-post-menu.js";
38
+ import "./client/thread-context.js";
39
+ import "./client/archive-nav.js";
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Migration Integrity Tests
3
+ *
4
+ * Ensures every migration SQL file is tracked in the Drizzle journal.
5
+ * Hand-written migrations bypass drizzle-kit and won't have journal entries,
6
+ * which breaks `drizzle-kit generate` for future schema changes.
7
+ *
8
+ * Fix: always run `mise run db-generate` instead of writing SQL by hand.
9
+ */
10
+
11
+ import { describe, it, expect } from "vitest";
12
+ import { readdirSync, readFileSync } from "fs";
13
+ import { resolve } from "path";
14
+
15
+ const MIGRATIONS_DIR = resolve(import.meta.dirname, "../migrations");
16
+ const JOURNAL_PATH = resolve(MIGRATIONS_DIR, "meta/_journal.json");
17
+
18
+ interface JournalEntry {
19
+ idx: number;
20
+ version: string;
21
+ when: number;
22
+ tag: string;
23
+ breakpoints: boolean;
24
+ }
25
+
26
+ interface Journal {
27
+ version: string;
28
+ dialect: string;
29
+ entries: JournalEntry[];
30
+ }
31
+
32
+ function readJournal(): Journal {
33
+ return JSON.parse(readFileSync(JOURNAL_PATH, "utf-8"));
34
+ }
35
+
36
+ function listMigrationFiles(): string[] {
37
+ return readdirSync(MIGRATIONS_DIR)
38
+ .filter((f) => f.endsWith(".sql"))
39
+ .sort();
40
+ }
41
+
42
+ describe("migration integrity", () => {
43
+ it("every SQL file has a corresponding journal entry", () => {
44
+ const journal = readJournal();
45
+ const tags = new Set(journal.entries.map((e) => e.tag));
46
+ const sqlFiles = listMigrationFiles();
47
+
48
+ const untracked = sqlFiles
49
+ .map((f) => f.replace(".sql", ""))
50
+ .filter((tag) => !tags.has(tag));
51
+
52
+ expect(
53
+ untracked,
54
+ [
55
+ "These migration files are not tracked in meta/_journal.json.",
56
+ "This usually means they were hand-written instead of generated with `mise run db-generate`.",
57
+ "Fix: update src/db/schema.ts first, then run `mise run db-generate`.",
58
+ `Untracked files: ${untracked.map((t) => `${t}.sql`).join(", ")}`,
59
+ ].join("\n"),
60
+ ).toEqual([]);
61
+ });
62
+
63
+ it("every journal entry has a corresponding SQL file", () => {
64
+ const journal = readJournal();
65
+ const sqlFiles = new Set(
66
+ listMigrationFiles().map((f) => f.replace(".sql", "")),
67
+ );
68
+
69
+ const missing = journal.entries
70
+ .map((e) => e.tag)
71
+ .filter((tag) => !sqlFiles.has(tag));
72
+
73
+ expect(
74
+ missing,
75
+ [
76
+ "These journal entries have no matching SQL file.",
77
+ `Missing files: ${missing.map((t) => `${t}.sql`).join(", ")}`,
78
+ ].join("\n"),
79
+ ).toEqual([]);
80
+ });
81
+
82
+ it("journal entries have sequential idx values", () => {
83
+ const journal = readJournal();
84
+ for (let i = 0; i < journal.entries.length; i++) {
85
+ const entry = journal.entries[i];
86
+ if (entry) expect(entry.idx).toBe(i);
87
+ }
88
+ });
89
+
90
+ it("latest migration has a snapshot file", () => {
91
+ const journal = readJournal();
92
+ const lastEntry = journal.entries[journal.entries.length - 1];
93
+ if (!lastEntry) return;
94
+
95
+ const prefix = lastEntry.tag.split("_")[0];
96
+ const snapshotPath = resolve(
97
+ MIGRATIONS_DIR,
98
+ `meta/${prefix}_snapshot.json`,
99
+ );
100
+
101
+ let exists = false;
102
+ try {
103
+ readFileSync(snapshotPath);
104
+ exists = true;
105
+ } catch {
106
+ // file doesn't exist
107
+ }
108
+
109
+ expect(
110
+ exists,
111
+ [
112
+ `Missing snapshot for latest migration: meta/${prefix}_snapshot.json`,
113
+ "This means the migration was not generated by drizzle-kit.",
114
+ "Fix: run `mise run db-generate` to regenerate it properly.",
115
+ ].join("\n"),
116
+ ).toBe(true);
117
+ });
118
+ });
package/src/db/index.ts CHANGED
@@ -12,3 +12,55 @@ export function createDatabase(d1: D1Database) {
12
12
  }
13
13
 
14
14
  export { schema };
15
+
16
+ /**
17
+ * D1 enforces a lower SQL variable limit than standard SQLite (~999).
18
+ * Keep batch size well under the limit to leave room for other
19
+ * query parameters besides the IN-list.
20
+ */
21
+ const BATCH_SIZE = 50;
22
+
23
+ /**
24
+ * Run a query function in batches to avoid SQLite's variable limit.
25
+ * Splits `items` into chunks, calls `fn` for each chunk, and merges
26
+ * the resulting Maps.
27
+ *
28
+ * @param items - Array of IDs to batch
29
+ * @param fn - Async function that takes a chunk and returns a Map
30
+ * @returns Merged Map from all batches
31
+ */
32
+ export async function batchQuery<K, V>(
33
+ items: K[],
34
+ fn: (chunk: K[]) => Promise<Map<K, V>>,
35
+ ): Promise<Map<K, V>> {
36
+ if (items.length <= BATCH_SIZE) return fn(items);
37
+
38
+ const result = new Map<K, V>();
39
+ for (let i = 0; i < items.length; i += BATCH_SIZE) {
40
+ const chunk = items.slice(i, i + BATCH_SIZE);
41
+ const partial = await fn(chunk);
42
+ for (const [k, v] of partial) {
43
+ result.set(k, v);
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+
49
+ /**
50
+ * Like `batchQuery` but for functions that return an array of rows
51
+ * rather than a Map.
52
+ */
53
+ export async function batchQueryRows<K, R>(
54
+ items: K[],
55
+ fn: (chunk: K[]) => Promise<R[]>,
56
+ ): Promise<R[]> {
57
+ if (items.length <= BATCH_SIZE) return fn(items);
58
+
59
+ const result: R[] = [];
60
+ for (let i = 0; i < items.length; i += BATCH_SIZE) {
61
+ const chunk = items.slice(i, i + BATCH_SIZE);
62
+ const partial = await fn(chunk);
63
+ result.push(...partial);
64
+ }
65
+ return result;
66
+ }