@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
@@ -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,24 +23,22 @@ 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 and dividers)
37
+ // List collections (includes post counts and sidebar items)
40
38
  collectionsApiRoutes.get("/", async (c) => {
41
- const [collections, dividers, postCounts] = await Promise.all([
39
+ const [collections, sidebarItems, postCounts] = await Promise.all([
42
40
  c.var.services.collections.list(),
43
- c.var.services.collections.listDividers(),
41
+ c.var.services.collections.listSidebarItems(),
44
42
  c.var.services.collections.getPostCounts(),
45
43
  ]);
46
44
 
@@ -49,26 +47,51 @@ collectionsApiRoutes.get("/", async (c) => {
49
47
  ...col,
50
48
  postCount: postCounts.get(col.id) ?? 0,
51
49
  })),
52
- dividers,
50
+ sidebarItems,
53
51
  });
54
52
  });
55
53
 
56
- // Create divider (requires auth) — must be before /:id
57
- collectionsApiRoutes.post("/dividers", requireAuthApi(), async (c) => {
58
- const divider = await c.var.services.collections.createDivider();
59
- return c.json(divider, 201);
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);
60
58
  });
61
59
 
62
- // Delete divider (requires auth) — must be before /:id
63
- collectionsApiRoutes.delete("/dividers/:id", requireAuthApi(), async (c) => {
64
- const id = parseIntParam(c.req.param("id"));
65
- await c.var.services.collections.deleteDivider(id);
66
- return c.json({ success: true });
67
- });
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
+ );
68
91
 
69
92
  // Get single collection
70
93
  collectionsApiRoutes.get("/:id", async (c) => {
71
- const id = parseIntParam(c.req.param("id"));
94
+ const id = parseIdParam(c.req.param("id"));
72
95
  const collection = assertFound(
73
96
  await c.var.services.collections.getById(id),
74
97
  "Collection",
@@ -76,19 +99,6 @@ collectionsApiRoutes.get("/:id", async (c) => {
76
99
  return c.json(collection);
77
100
  });
78
101
 
79
- // Reorder collections (requires auth) — must be before /:id
80
- collectionsApiRoutes.put("/reorder", requireAuthApi(), async (c) => {
81
- const body = parseValidated(CollectionReorderSchema, await c.req.json());
82
-
83
- if (body.items) {
84
- await c.var.services.collections.reorderAll(body.items);
85
- } else if (body.ids) {
86
- await c.var.services.collections.reorder(body.ids);
87
- }
88
- const collections = await c.var.services.collections.list();
89
- return c.json({ collections });
90
- });
91
-
92
102
  // Create collection (requires auth)
93
103
  collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
94
104
  const body = parseValidated(CreateCollectionSchema, await c.req.json());
@@ -99,7 +109,6 @@ collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
99
109
  description: body.description,
100
110
  icon: body.icon,
101
111
  sortOrder: body.sortOrder as SortOrder | undefined,
102
- position: body.position,
103
112
  });
104
113
 
105
114
  return c.json(collection, 201);
@@ -107,7 +116,7 @@ collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
107
116
 
108
117
  // Update collection (requires auth)
109
118
  collectionsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
110
- const id = parseIntParam(c.req.param("id"));
119
+ const id = parseIdParam(c.req.param("id"));
111
120
  const body = parseValidated(UpdateCollectionSchema, await c.req.json());
112
121
 
113
122
  const collection = assertFound(
@@ -120,7 +129,7 @@ collectionsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
120
129
 
121
130
  // Delete collection (requires auth)
122
131
  collectionsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
123
- const id = parseIntParam(c.req.param("id"));
132
+ const id = parseIdParam(c.req.param("id"));
124
133
 
125
134
  const success = await c.var.services.collections.delete(id);
126
135
  if (!success) throw new NotFoundError("Collection");
@@ -130,7 +139,7 @@ collectionsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
130
139
 
131
140
  // Add a post to a collection (requires auth)
132
141
  collectionsApiRoutes.post("/:id/posts", requireAuthApi(), async (c) => {
133
- const id = parseIntParam(c.req.param("id"));
142
+ const id = parseIdParam(c.req.param("id"));
134
143
  assertFound(await c.var.services.collections.getById(id), "Collection");
135
144
 
136
145
  const body = parseValidated(PostAssignSchema, await c.req.json());
@@ -146,8 +155,8 @@ collectionsApiRoutes.delete(
146
155
  "/:id/posts/:postId",
147
156
  requireAuthApi(),
148
157
  async (c) => {
149
- const id = parseIntParam(c.req.param("id"));
150
- const postId = parseIntParam(c.req.param("postId"));
158
+ const id = parseIdParam(c.req.param("id"));
159
+ const postId = parseIdParam(c.req.param("postId"));
151
160
 
152
161
  await c.var.services.collections.removePost(id, postId);
153
162