@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,534 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { Hono } from "hono";
3
+ import type { Bindings } from "../../../types.js";
4
+ import type { AppVariables } from "../../../types/app-context.js";
5
+ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
6
+ import { createMediaService } from "../../../services/media.js";
7
+ import { createSettingsService } from "../../../services/settings.js";
8
+ import { createPostService } from "../../../services/post.js";
9
+ import { createPathService } from "../../../services/path.js";
10
+ import { createCustomUrlService } from "../../../services/custom-url.js";
11
+ import { createCollectionService } from "../../../services/collection.js";
12
+ import { createSearchService } from "../../../services/search.js";
13
+ import { createNavItemService } from "../../../services/navigation.js";
14
+ import { createAuthService } from "../../../services/auth.js";
15
+ import { errorHandler } from "../../../middleware/error-handler.js";
16
+ import { createI18n } from "../../../i18n/i18n.js";
17
+ import { resolveConfig } from "../../../lib/resolve-config.js";
18
+ import type { Database } from "../../../db/index.js";
19
+ import type { StorageDriver, UploadedPart } from "../../../lib/storage.js";
20
+ import { multipartUploadApiRoutes } from "../upload-multipart.js";
21
+ import type BetterSqlite3 from "better-sqlite3";
22
+
23
+ type Env = { Bindings: Bindings; Variables: AppVariables };
24
+
25
+ /** Creates a mock storage driver that supports multipart uploads */
26
+ function createMockMultipartStorage(): StorageDriver & {
27
+ uploads: Map<string, { key: string; parts: Map<number, ArrayBuffer> }>;
28
+ completed: Map<string, UploadedPart[]>;
29
+ aborted: Set<string>;
30
+ files: Map<string, { body: Uint8Array; contentType?: string }>;
31
+ } {
32
+ const uploads = new Map<
33
+ string,
34
+ { key: string; parts: Map<number, ArrayBuffer> }
35
+ >();
36
+ const completed = new Map<string, UploadedPart[]>();
37
+ const aborted = new Set<string>();
38
+ const files = new Map<string, { body: Uint8Array; contentType?: string }>();
39
+ let uploadCounter = 0;
40
+
41
+ return {
42
+ uploads,
43
+ completed,
44
+ aborted,
45
+ files,
46
+
47
+ async put(key, body, opts) {
48
+ let bytes: Uint8Array;
49
+ if (body instanceof Uint8Array) {
50
+ bytes = body;
51
+ } else {
52
+ const reader = body.getReader();
53
+ const chunks: Uint8Array[] = [];
54
+ for (;;) {
55
+ const { done, value } = await reader.read();
56
+ if (done) break;
57
+ chunks.push(value);
58
+ }
59
+ let totalLength = 0;
60
+ for (const chunk of chunks) totalLength += chunk.length;
61
+ bytes = new Uint8Array(totalLength);
62
+ let offset = 0;
63
+ for (const chunk of chunks) {
64
+ bytes.set(chunk, offset);
65
+ offset += chunk.length;
66
+ }
67
+ }
68
+ files.set(key, { body: bytes, contentType: opts?.contentType });
69
+ },
70
+
71
+ async get(key) {
72
+ const file = files.get(key);
73
+ if (!file) return null;
74
+ const stream = new ReadableStream({
75
+ start(controller) {
76
+ controller.enqueue(file.body);
77
+ controller.close();
78
+ },
79
+ });
80
+ return { body: stream, contentType: file.contentType };
81
+ },
82
+
83
+ async delete(key) {
84
+ files.delete(key);
85
+ },
86
+
87
+ async createMultipartUpload(key, _opts) {
88
+ const uploadId = `upload-${++uploadCounter}`;
89
+ uploads.set(uploadId, { key, parts: new Map() });
90
+ return { uploadId, key };
91
+ },
92
+
93
+ async uploadPart(key, uploadId, partNumber, body) {
94
+ const upload = uploads.get(uploadId);
95
+ if (!upload) throw new Error(`No upload with id ${uploadId}`);
96
+ const buffer =
97
+ body instanceof ArrayBuffer
98
+ ? body
99
+ : body instanceof Uint8Array
100
+ ? body.buffer.slice(
101
+ body.byteOffset,
102
+ body.byteOffset + body.byteLength,
103
+ )
104
+ : await new Response(body as ReadableStream).arrayBuffer();
105
+ upload.parts.set(partNumber, buffer);
106
+ const etag = `etag-${key}-${partNumber}`;
107
+ return { partNumber, etag };
108
+ },
109
+
110
+ async completeMultipartUpload(_key, uploadId, parts) {
111
+ completed.set(uploadId, parts);
112
+ uploads.delete(uploadId);
113
+ },
114
+
115
+ async abortMultipartUpload(_key, uploadId) {
116
+ aborted.add(uploadId);
117
+ uploads.delete(uploadId);
118
+ },
119
+ };
120
+ }
121
+
122
+ function createMockD1(sqliteDb: BetterSqlite3.Database) {
123
+ return {
124
+ prepare(query: string) {
125
+ return {
126
+ bind(...params: unknown[]) {
127
+ return {
128
+ async all<T>() {
129
+ const stmt = sqliteDb.prepare(query);
130
+ const rows = stmt.all(...(params as never[])) as T[];
131
+ return { results: rows };
132
+ },
133
+ };
134
+ },
135
+ };
136
+ },
137
+ } as unknown as D1Database;
138
+ }
139
+
140
+ function createTestAppWithStorage(options: {
141
+ authenticated?: boolean;
142
+ storage: StorageDriver | null;
143
+ }) {
144
+ const testDb = createTestDatabase();
145
+ const db = testDb.db as unknown as Database;
146
+ const sqlite = testDb.sqlite;
147
+ const mockD1 = createMockD1(sqlite);
148
+
149
+ const settingsService = createSettingsService(db);
150
+ const pathService = createPathService(db);
151
+ const services = {
152
+ paths: pathService,
153
+ posts: createPostService(db, { slugIdLength: 5 }, pathService),
154
+ settings: settingsService,
155
+ customUrls: createCustomUrlService(db, pathService),
156
+ media: createMediaService(db),
157
+ collections: createCollectionService(db, pathService),
158
+ search: createSearchService(mockD1),
159
+ navItems: createNavItemService(db),
160
+ auth: createAuthService(db, settingsService),
161
+ };
162
+
163
+ const app = new Hono<Env>();
164
+ app.onError(errorHandler);
165
+
166
+ app.use("*", async (c, next) => {
167
+ c.env = {
168
+ SITE_URL: "http://localhost:9020",
169
+ } as AppVariables["services"] extends never ? never : Bindings;
170
+
171
+ c.set("services", services as AppVariables["services"]);
172
+ const allSettings = await services.settings.getAll();
173
+ c.set("allSettings", allSettings);
174
+ c.set("appConfig", resolveConfig(c.env, allSettings));
175
+ c.set("storage", options.storage);
176
+
177
+ const i18n = createI18n("en");
178
+ c.set("lang", "en");
179
+ c.set("i18n", i18n);
180
+
181
+ if (options.authenticated) {
182
+ c.set("auth", {
183
+ api: {
184
+ getSession: async () => ({
185
+ user: { id: "test-user", email: "test@test.com", name: "Test" },
186
+ session: { id: "test-session" },
187
+ }),
188
+ },
189
+ } as AppVariables["auth"]);
190
+ } else {
191
+ c.set("auth", {
192
+ api: {
193
+ getSession: async () => null,
194
+ },
195
+ } as AppVariables["auth"]);
196
+ }
197
+
198
+ await next();
199
+ });
200
+
201
+ return { app, services, db, sqlite };
202
+ }
203
+
204
+ describe("multipart upload API routes", () => {
205
+ describe("auth", () => {
206
+ it("returns 401 when not authenticated", async () => {
207
+ const { app } = createTestAppWithStorage({
208
+ authenticated: false,
209
+ storage: createMockMultipartStorage(),
210
+ });
211
+ app.route("/api/upload/multipart", multipartUploadApiRoutes);
212
+
213
+ const res = await app.request("/api/upload/multipart", {
214
+ method: "POST",
215
+ headers: { "Content-Type": "application/json" },
216
+ body: JSON.stringify({
217
+ filename: "test.mp4",
218
+ contentType: "video/mp4",
219
+ size: 100_000_000,
220
+ }),
221
+ });
222
+
223
+ expect(res.status).toBe(401);
224
+ });
225
+ });
226
+
227
+ describe("storage support", () => {
228
+ it("returns 500 when storage is null", async () => {
229
+ const { app } = createTestAppWithStorage({
230
+ authenticated: true,
231
+ storage: null,
232
+ });
233
+ app.route("/api/upload/multipart", multipartUploadApiRoutes);
234
+
235
+ const res = await app.request("/api/upload/multipart", {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({
239
+ filename: "test.mp4",
240
+ contentType: "video/mp4",
241
+ size: 100_000_000,
242
+ }),
243
+ });
244
+
245
+ expect(res.status).toBe(500);
246
+ const data = await res.json();
247
+ expect(data.error).toContain("multipart");
248
+ });
249
+
250
+ it("returns 500 when storage lacks multipart methods", async () => {
251
+ const basicStorage: StorageDriver = {
252
+ async put() {},
253
+ async get() {
254
+ return null;
255
+ },
256
+ async delete() {},
257
+ };
258
+ const { app } = createTestAppWithStorage({
259
+ authenticated: true,
260
+ storage: basicStorage,
261
+ });
262
+ app.route("/api/upload/multipart", multipartUploadApiRoutes);
263
+
264
+ const res = await app.request("/api/upload/multipart", {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: JSON.stringify({
268
+ filename: "test.mp4",
269
+ contentType: "video/mp4",
270
+ size: 100_000_000,
271
+ }),
272
+ });
273
+
274
+ expect(res.status).toBe(500);
275
+ });
276
+ });
277
+
278
+ describe("initiate", () => {
279
+ it("accepts any file type", async () => {
280
+ const { app } = createTestAppWithStorage({
281
+ authenticated: true,
282
+ storage: createMockMultipartStorage(),
283
+ });
284
+ app.route("/api/upload/multipart", multipartUploadApiRoutes);
285
+
286
+ const res = await app.request("/api/upload/multipart", {
287
+ method: "POST",
288
+ headers: { "Content-Type": "application/json" },
289
+ body: JSON.stringify({
290
+ filename: "file.exe",
291
+ contentType: "application/x-msdownload",
292
+ size: 100_000_000,
293
+ }),
294
+ });
295
+
296
+ expect(res.status).toBe(200);
297
+ });
298
+
299
+ it("returns id, uploadId, storageKey, filename on success", async () => {
300
+ const { app } = createTestAppWithStorage({
301
+ authenticated: true,
302
+ storage: createMockMultipartStorage(),
303
+ });
304
+ app.route("/api/upload/multipart", multipartUploadApiRoutes);
305
+
306
+ const res = await app.request("/api/upload/multipart", {
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/json" },
309
+ body: JSON.stringify({
310
+ filename: "big-video.mp4",
311
+ contentType: "video/mp4",
312
+ size: 100_000_000,
313
+ }),
314
+ });
315
+
316
+ expect(res.status).toBe(200);
317
+ const data = await res.json();
318
+ expect(data.id).toBeDefined();
319
+ expect(data.uploadId).toBeDefined();
320
+ expect(data.storageKey).toContain("media/");
321
+ expect(data.filename).toBeDefined();
322
+ expect(data.originalName).toBe("big-video.mp4");
323
+ });
324
+ });
325
+
326
+ describe("full flow", () => {
327
+ let app: ReturnType<typeof createTestAppWithStorage>["app"];
328
+ let services: ReturnType<typeof createTestAppWithStorage>["services"];
329
+ let storage: ReturnType<typeof createMockMultipartStorage>;
330
+
331
+ beforeEach(() => {
332
+ storage = createMockMultipartStorage();
333
+ const result = createTestAppWithStorage({
334
+ authenticated: true,
335
+ storage,
336
+ });
337
+ app = result.app;
338
+ services = result.services;
339
+ app.route("/api/upload/multipart", multipartUploadApiRoutes);
340
+ });
341
+
342
+ it("initiate → upload part → complete creates a DB record", async () => {
343
+ // Initiate
344
+ const initRes = await app.request("/api/upload/multipart", {
345
+ method: "POST",
346
+ headers: { "Content-Type": "application/json" },
347
+ body: JSON.stringify({
348
+ filename: "large-file.mp4",
349
+ contentType: "video/mp4",
350
+ size: 100_000_000,
351
+ }),
352
+ });
353
+ expect(initRes.status).toBe(200);
354
+ const { id, uploadId, storageKey, filename, originalName } =
355
+ (await initRes.json()) as {
356
+ id: string;
357
+ uploadId: string;
358
+ storageKey: string;
359
+ filename: string;
360
+ originalName: string;
361
+ };
362
+
363
+ // Upload a part
364
+ const partBody = new Uint8Array(1024).fill(0xaa);
365
+ const partRes = await app.request(
366
+ `/api/upload/multipart/${id}/part?partNumber=1&storageKey=${encodeURIComponent(storageKey)}&uploadId=${encodeURIComponent(uploadId)}`,
367
+ {
368
+ method: "PUT",
369
+ body: partBody,
370
+ },
371
+ );
372
+ expect(partRes.status).toBe(200);
373
+ const partData = (await partRes.json()) as {
374
+ partNumber: number;
375
+ etag: string;
376
+ };
377
+ expect(partData.partNumber).toBe(1);
378
+ expect(partData.etag).toBeDefined();
379
+
380
+ // Complete
381
+ const completeRes = await app.request(
382
+ `/api/upload/multipart/${id}/complete`,
383
+ {
384
+ method: "POST",
385
+ headers: { "Content-Type": "application/json" },
386
+ body: JSON.stringify({
387
+ storageKey,
388
+ uploadId,
389
+ parts: [{ partNumber: partData.partNumber, etag: partData.etag }],
390
+ filename,
391
+ originalName,
392
+ contentType: "video/mp4",
393
+ size: 100_000_000,
394
+ width: 1920,
395
+ height: 1080,
396
+ }),
397
+ },
398
+ );
399
+ expect(completeRes.status).toBe(200);
400
+ const result = (await completeRes.json()) as {
401
+ id: string;
402
+ filename: string;
403
+ mimeType: string;
404
+ size: number;
405
+ };
406
+ expect(result.id).toBe(id);
407
+ expect(result.mimeType).toBe("video/mp4");
408
+ expect(result.size).toBe(100_000_000);
409
+
410
+ // Verify DB record
411
+ const media = await services.media.getById(id);
412
+ expect(media).toMatchObject({
413
+ mimeType: "video/mp4",
414
+ width: 1920,
415
+ height: 1080,
416
+ });
417
+ });
418
+
419
+ it("abort cleans up R2", async () => {
420
+ // Initiate
421
+ const initRes = await app.request("/api/upload/multipart", {
422
+ method: "POST",
423
+ headers: { "Content-Type": "application/json" },
424
+ body: JSON.stringify({
425
+ filename: "cancelled.mp4",
426
+ contentType: "video/mp4",
427
+ size: 200_000_000,
428
+ }),
429
+ });
430
+ const { uploadId, storageKey } = (await initRes.json()) as {
431
+ id: string;
432
+ uploadId: string;
433
+ storageKey: string;
434
+ };
435
+
436
+ // Abort
437
+ const abortRes = await app.request(`/api/upload/multipart/unused/abort`, {
438
+ method: "POST",
439
+ headers: { "Content-Type": "application/json" },
440
+ body: JSON.stringify({ storageKey, uploadId }),
441
+ });
442
+ expect(abortRes.status).toBe(200);
443
+
444
+ // Verify R2 abort was called
445
+ expect(storage.aborted.size).toBe(1);
446
+ });
447
+
448
+ it("returns 400 for part upload with missing storageKey/uploadId", async () => {
449
+ const res = await app.request(
450
+ "/api/upload/multipart/some-id/part?partNumber=1",
451
+ {
452
+ method: "PUT",
453
+ body: new Uint8Array(10),
454
+ },
455
+ );
456
+ expect(res.status).toBe(400);
457
+ });
458
+
459
+ it("poster upload returns posterKey", async () => {
460
+ // Initiate
461
+ const initRes = await app.request("/api/upload/multipart", {
462
+ method: "POST",
463
+ headers: { "Content-Type": "application/json" },
464
+ body: JSON.stringify({
465
+ filename: "video-with-poster.mp4",
466
+ contentType: "video/mp4",
467
+ size: 100_000_000,
468
+ }),
469
+ });
470
+ const { id, uploadId, storageKey, filename, originalName } =
471
+ (await initRes.json()) as {
472
+ id: string;
473
+ uploadId: string;
474
+ storageKey: string;
475
+ filename: string;
476
+ originalName: string;
477
+ };
478
+
479
+ // Upload poster
480
+ const posterBlob = new Blob([new Uint8Array(100)], {
481
+ type: "image/webp",
482
+ });
483
+ const formData = new FormData();
484
+ formData.append("poster", posterBlob, "poster.webp");
485
+
486
+ const posterRes = await app.request(
487
+ `/api/upload/multipart/${id}/poster`,
488
+ {
489
+ method: "PUT",
490
+ body: formData,
491
+ },
492
+ );
493
+ expect(posterRes.status).toBe(200);
494
+ const posterData = (await posterRes.json()) as { posterKey: string };
495
+ expect(posterData.posterKey).toContain("poster.webp");
496
+
497
+ // Verify poster was stored
498
+ expect(storage.files.has(posterData.posterKey)).toBe(true);
499
+
500
+ // Upload a part and complete to verify posterKey is in the DB record
501
+ const partRes = await app.request(
502
+ `/api/upload/multipart/${id}/part?partNumber=1&storageKey=${encodeURIComponent(storageKey)}&uploadId=${encodeURIComponent(uploadId)}`,
503
+ {
504
+ method: "PUT",
505
+ body: new Uint8Array(1024),
506
+ },
507
+ );
508
+ const partData = (await partRes.json()) as {
509
+ partNumber: number;
510
+ etag: string;
511
+ };
512
+
513
+ await app.request(`/api/upload/multipart/${id}/complete`, {
514
+ method: "POST",
515
+ headers: { "Content-Type": "application/json" },
516
+ body: JSON.stringify({
517
+ storageKey,
518
+ uploadId,
519
+ parts: [{ partNumber: partData.partNumber, etag: partData.etag }],
520
+ filename,
521
+ originalName,
522
+ contentType: "video/mp4",
523
+ size: 100_000_000,
524
+ posterKey: posterData.posterKey,
525
+ }),
526
+ });
527
+
528
+ const media = await services.media.getById(id);
529
+ expect(media).not.toBeNull();
530
+ expect(media).toHaveProperty("posterKey");
531
+ expect(String(media?.posterKey)).toContain("poster.webp");
532
+ });
533
+ });
534
+ });
@@ -12,7 +12,7 @@ import {
12
12
  SortOrderSchema,
13
13
  parseValidated,
14
14
  } from "../../lib/schemas.js";
15
- import { assertFound, parseIntParam, NotFoundError } from "../../lib/errors.js";
15
+ import { assertFound, parseIdParam, NotFoundError } from "../../lib/errors.js";
16
16
 
17
17
  type Env = { Bindings: Bindings; Variables: AppVariables };
18
18
 
@@ -23,35 +23,75 @@ const UpdateCollectionSchema = CreateCollectionSchema.partial().extend({
23
23
  description: z.string().nullable().optional(),
24
24
  icon: z.string().nullable().optional(),
25
25
  sortOrder: SortOrderSchema.optional(),
26
- position: z.number().int().min(0).optional(),
27
26
  });
28
27
 
29
- // Route-specific schemas (not shared domain schemas)
30
- const CollectionReorderSchema = z.object({
31
- ids: z.array(z.number().int().positive()).optional(),
32
- items: z.array(z.string().regex(/^[cd]-\d+$/)).optional(),
28
+ const PostAssignSchema = z.object({
29
+ postId: z.string().min(1),
33
30
  });
34
31
 
35
- const PostAssignSchema = z.object({
36
- postId: z.number().int().positive(),
32
+ const MoveSchema = z.object({
33
+ after: z.string().nullable().optional(),
34
+ before: z.string().nullable().optional(),
37
35
  });
38
36
 
39
- // List collections (includes post counts)
37
+ // List collections (includes post counts and sidebar items)
40
38
  collectionsApiRoutes.get("/", async (c) => {
41
- const collections = await c.var.services.collections.list();
42
- const postCounts = await c.var.services.collections.getPostCounts();
39
+ const [collections, sidebarItems, postCounts] = await Promise.all([
40
+ c.var.services.collections.list(),
41
+ c.var.services.collections.listSidebarItems(),
42
+ c.var.services.collections.getPostCounts(),
43
+ ]);
43
44
 
44
45
  return c.json({
45
46
  collections: collections.map((col) => ({
46
47
  ...col,
47
48
  postCount: postCounts.get(col.id) ?? 0,
48
49
  })),
50
+ sidebarItems,
49
51
  });
50
52
  });
51
53
 
54
+ // Create sidebar item (divider) — must be before /:id
55
+ collectionsApiRoutes.post("/sidebar-items", requireAuthApi(), async (c) => {
56
+ const item = await c.var.services.collections.createSidebarItem("divider");
57
+ return c.json(item, 201);
58
+ });
59
+
60
+ // Move sidebar item — must be before /:id
61
+ collectionsApiRoutes.put(
62
+ "/sidebar-items/:id/move",
63
+ requireAuthApi(),
64
+ async (c) => {
65
+ const id = parseIdParam(c.req.param("id"));
66
+ const body = parseValidated(MoveSchema, await c.req.json());
67
+
68
+ const item = assertFound(
69
+ await c.var.services.collections.moveSidebarItem(
70
+ id,
71
+ body.after ?? null,
72
+ body.before ?? null,
73
+ ),
74
+ "Sidebar item",
75
+ );
76
+
77
+ return c.json(item);
78
+ },
79
+ );
80
+
81
+ // Delete sidebar item — must be before /:id
82
+ collectionsApiRoutes.delete(
83
+ "/sidebar-items/:id",
84
+ requireAuthApi(),
85
+ async (c) => {
86
+ const id = parseIdParam(c.req.param("id"));
87
+ await c.var.services.collections.deleteSidebarItem(id);
88
+ return c.json({ success: true });
89
+ },
90
+ );
91
+
52
92
  // Get single collection
53
93
  collectionsApiRoutes.get("/:id", async (c) => {
54
- const id = parseIntParam(c.req.param("id"));
94
+ const id = parseIdParam(c.req.param("id"));
55
95
  const collection = assertFound(
56
96
  await c.var.services.collections.getById(id),
57
97
  "Collection",
@@ -59,19 +99,6 @@ collectionsApiRoutes.get("/:id", async (c) => {
59
99
  return c.json(collection);
60
100
  });
61
101
 
62
- // Reorder collections (requires auth) — must be before /:id
63
- collectionsApiRoutes.put("/reorder", requireAuthApi(), async (c) => {
64
- const body = parseValidated(CollectionReorderSchema, await c.req.json());
65
-
66
- if (body.items) {
67
- await c.var.services.collections.reorderAll(body.items);
68
- } else if (body.ids) {
69
- await c.var.services.collections.reorder(body.ids);
70
- }
71
- const collections = await c.var.services.collections.list();
72
- return c.json({ collections });
73
- });
74
-
75
102
  // Create collection (requires auth)
76
103
  collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
77
104
  const body = parseValidated(CreateCollectionSchema, await c.req.json());
@@ -82,7 +109,6 @@ collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
82
109
  description: body.description,
83
110
  icon: body.icon,
84
111
  sortOrder: body.sortOrder as SortOrder | undefined,
85
- position: body.position,
86
112
  });
87
113
 
88
114
  return c.json(collection, 201);
@@ -90,7 +116,7 @@ collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
90
116
 
91
117
  // Update collection (requires auth)
92
118
  collectionsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
93
- const id = parseIntParam(c.req.param("id"));
119
+ const id = parseIdParam(c.req.param("id"));
94
120
  const body = parseValidated(UpdateCollectionSchema, await c.req.json());
95
121
 
96
122
  const collection = assertFound(
@@ -103,7 +129,7 @@ collectionsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
103
129
 
104
130
  // Delete collection (requires auth)
105
131
  collectionsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
106
- const id = parseIntParam(c.req.param("id"));
132
+ const id = parseIdParam(c.req.param("id"));
107
133
 
108
134
  const success = await c.var.services.collections.delete(id);
109
135
  if (!success) throw new NotFoundError("Collection");
@@ -113,7 +139,7 @@ collectionsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
113
139
 
114
140
  // Add a post to a collection (requires auth)
115
141
  collectionsApiRoutes.post("/:id/posts", requireAuthApi(), async (c) => {
116
- const id = parseIntParam(c.req.param("id"));
142
+ const id = parseIdParam(c.req.param("id"));
117
143
  assertFound(await c.var.services.collections.getById(id), "Collection");
118
144
 
119
145
  const body = parseValidated(PostAssignSchema, await c.req.json());
@@ -129,8 +155,8 @@ collectionsApiRoutes.delete(
129
155
  "/:id/posts/:postId",
130
156
  requireAuthApi(),
131
157
  async (c) => {
132
- const id = parseIntParam(c.req.param("id"));
133
- const postId = parseIntParam(c.req.param("postId"));
158
+ const id = parseIdParam(c.req.param("id"));
159
+ const postId = parseIdParam(c.req.param("postId"));
134
160
 
135
161
  await c.var.services.collections.removePost(id, postId);
136
162