@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,245 @@
1
+ /**
2
+ * Multipart Upload API Routes
3
+ *
4
+ * Handles chunked file uploads for files that exceed the Cloudflare Workers
5
+ * 100MB request body limit. Uses R2's native multipart upload API.
6
+ *
7
+ * Protocol:
8
+ * 1. POST / — Initiate: validate metadata, start R2 multipart upload
9
+ * 2. PUT /:id/part — Upload a single chunk (raw body, not FormData)
10
+ * 3. POST /:id/complete — Finalize: combine parts in R2, create DB record
11
+ * 4. POST /:id/abort — Cancel: discard uploaded parts
12
+ * 5. PUT /:id/poster — Upload poster frame (video thumbnails, small FormData)
13
+ */
14
+
15
+ import { Hono } from "hono";
16
+ import { z } from "zod";
17
+ import type { Bindings } from "../../types.js";
18
+ import type { AppVariables } from "../../types/app-context.js";
19
+ import { requireAuthApi } from "../../middleware/auth.js";
20
+ import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
21
+ import {
22
+ validateUploadFileMetadata,
23
+ generateStorageKey,
24
+ } from "../../lib/upload.js";
25
+ import { supportsMultipart } from "../../lib/storage.js";
26
+ import { ValidationError } from "../../lib/errors.js";
27
+ import { parseValidated } from "../../lib/schemas.js";
28
+
29
+ type Env = { Bindings: Bindings; Variables: AppVariables };
30
+
31
+ // ── Schemas ──────────────────────────────────────────────────────────
32
+
33
+ const InitiateSchema = z.object({
34
+ filename: z.string().min(1),
35
+ contentType: z.string().min(1),
36
+ size: z.number().int().positive(),
37
+ });
38
+
39
+ const UploadPartSchema = z.object({
40
+ storageKey: z.string().min(1),
41
+ uploadId: z.string().min(1),
42
+ });
43
+
44
+ const CompleteSchema = z.object({
45
+ storageKey: z.string().min(1),
46
+ uploadId: z.string().min(1),
47
+ parts: z.array(
48
+ z.object({
49
+ partNumber: z.number().int().positive(),
50
+ etag: z.string().min(1),
51
+ }),
52
+ ),
53
+ filename: z.string().min(1),
54
+ originalName: z.string().min(1),
55
+ contentType: z.string().min(1),
56
+ size: z.number().int().positive(),
57
+ width: z.number().int().positive().optional(),
58
+ height: z.number().int().positive().optional(),
59
+ blurhash: z.string().max(200).optional(),
60
+ waveform: z.string().max(2000).optional(),
61
+ posterKey: z.string().optional(),
62
+ });
63
+
64
+ const AbortSchema = z.object({
65
+ storageKey: z.string().min(1),
66
+ uploadId: z.string().min(1),
67
+ });
68
+
69
+ // ── Routes ───────────────────────────────────────────────────────────
70
+
71
+ export const multipartUploadApiRoutes = new Hono<Env>();
72
+
73
+ // Require auth for all multipart routes
74
+ multipartUploadApiRoutes.use("*", requireAuthApi());
75
+
76
+ // POST / — Initiate a multipart upload
77
+ multipartUploadApiRoutes.post("/", async (c) => {
78
+ const storage = c.var.storage;
79
+ if (!storage || !supportsMultipart(storage)) {
80
+ return c.json({ error: "Storage doesn't support multipart uploads." }, 500);
81
+ }
82
+
83
+ const body = await c.req.json();
84
+ const data = parseValidated(InitiateSchema, body);
85
+
86
+ // Validate file type and size
87
+ const error = validateUploadFileMetadata(data.contentType, data.size, {
88
+ maxFileSizeMB: c.var.appConfig.uploadMaxFileSize,
89
+ });
90
+ if (error) {
91
+ throw new ValidationError(error);
92
+ }
93
+
94
+ const { id, filename, storageKey } = generateStorageKey(data.filename);
95
+
96
+ const upload = await storage.createMultipartUpload(storageKey, {
97
+ contentType: data.contentType,
98
+ });
99
+
100
+ return c.json({
101
+ id,
102
+ uploadId: upload.uploadId,
103
+ storageKey,
104
+ filename,
105
+ originalName: data.filename,
106
+ });
107
+ });
108
+
109
+ // PUT /:id/part?partNumber=N&storageKey=...&uploadId=... — Upload a single part
110
+ multipartUploadApiRoutes.put("/:id/part", async (c) => {
111
+ const storage = c.var.storage;
112
+ if (!storage || !supportsMultipart(storage)) {
113
+ return c.json({ error: "Storage doesn't support multipart uploads." }, 500);
114
+ }
115
+
116
+ const storageKey = c.req.query("storageKey");
117
+ const uploadId = c.req.query("uploadId");
118
+ if (!storageKey || !uploadId) {
119
+ throw new ValidationError(
120
+ "storageKey and uploadId query parameters are required",
121
+ );
122
+ }
123
+ parseValidated(UploadPartSchema, { storageKey, uploadId });
124
+
125
+ const partNumberRaw = c.req.query("partNumber");
126
+ if (!partNumberRaw) {
127
+ throw new ValidationError("partNumber query parameter is required");
128
+ }
129
+ const partNumber = parseInt(partNumberRaw, 10);
130
+ if (isNaN(partNumber) || partNumber < 1) {
131
+ throw new ValidationError("partNumber must be a positive integer");
132
+ }
133
+
134
+ const body = await c.req.arrayBuffer();
135
+ const part = await storage.uploadPart(storageKey, uploadId, partNumber, body);
136
+
137
+ return c.json({ partNumber: part.partNumber, etag: part.etag });
138
+ });
139
+
140
+ // POST /:id/complete — Finalize the upload
141
+ multipartUploadApiRoutes.post("/:id/complete", async (c) => {
142
+ const storage = c.var.storage;
143
+ if (!storage || !supportsMultipart(storage)) {
144
+ return c.json({ error: "Storage doesn't support multipart uploads." }, 500);
145
+ }
146
+
147
+ const id = c.req.param("id");
148
+ const body = await c.req.json();
149
+ const data = parseValidated(CompleteSchema, body);
150
+
151
+ // Validate file type and size
152
+ const validationError = validateUploadFileMetadata(
153
+ data.contentType,
154
+ data.size,
155
+ { maxFileSizeMB: c.var.appConfig.uploadMaxFileSize },
156
+ );
157
+ if (validationError) {
158
+ throw new ValidationError(validationError);
159
+ }
160
+
161
+ // Complete the R2 multipart upload
162
+ await storage.completeMultipartUpload(
163
+ data.storageKey,
164
+ data.uploadId,
165
+ data.parts,
166
+ );
167
+
168
+ // Create the DB record
169
+ const media = await c.var.services.media.create({
170
+ id,
171
+ filename: data.filename,
172
+ originalName: data.originalName,
173
+ mimeType: data.contentType,
174
+ size: data.size,
175
+ storageKey: data.storageKey,
176
+ provider: c.var.appConfig.storageDriver,
177
+ width: data.width && data.width > 0 ? data.width : undefined,
178
+ height: data.height && data.height > 0 ? data.height : undefined,
179
+ blurhash: data.blurhash,
180
+ waveform: data.waveform,
181
+ posterKey: data.posterKey,
182
+ });
183
+
184
+ const mediaPublicUrl = getPublicUrlForProvider(
185
+ c.var.appConfig.storageDriver,
186
+ c.var.appConfig.r2PublicUrl,
187
+ c.var.appConfig.s3PublicUrl,
188
+ );
189
+ const publicUrl = getMediaUrl(data.storageKey, mediaPublicUrl);
190
+
191
+ return c.json({
192
+ id: media.id,
193
+ filename: media.filename,
194
+ url: publicUrl,
195
+ mimeType: media.mimeType,
196
+ size: media.size,
197
+ });
198
+ });
199
+
200
+ // POST /:id/abort — Cancel the upload
201
+ multipartUploadApiRoutes.post("/:id/abort", async (c) => {
202
+ const storage = c.var.storage;
203
+ if (!storage || !supportsMultipart(storage)) {
204
+ return c.json({ error: "Storage doesn't support multipart uploads." }, 500);
205
+ }
206
+
207
+ const body = await c.req.json();
208
+ const data = parseValidated(AbortSchema, body);
209
+
210
+ await storage.abortMultipartUpload(data.storageKey, data.uploadId);
211
+
212
+ return c.json({ success: true });
213
+ });
214
+
215
+ // PUT /:id/poster — Upload poster frame (video thumbnails)
216
+ multipartUploadApiRoutes.put("/:id/poster", async (c) => {
217
+ const storage = c.var.storage;
218
+ if (!storage) {
219
+ return c.json({ error: "Storage not configured." }, 500);
220
+ }
221
+
222
+ const id = c.req.param("id");
223
+ const formData = await c.req.formData();
224
+ const posterFile = formData.get("poster") as File | null;
225
+ if (!posterFile) {
226
+ throw new ValidationError("No poster file provided");
227
+ }
228
+
229
+ if (!posterFile.type.startsWith("image/")) {
230
+ throw new ValidationError(
231
+ `Invalid file type "${posterFile.type}". Only image files are accepted for poster frames.`,
232
+ );
233
+ }
234
+
235
+ const date = new Date();
236
+ const year = date.getUTCFullYear();
237
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
238
+ const posterKey = `media/${year}/${month}/${id}-poster.webp`;
239
+
240
+ await storage.put(posterKey, posterFile.stream(), {
241
+ contentType: "image/webp",
242
+ });
243
+
244
+ return c.json({ posterKey });
245
+ });
@@ -60,7 +60,7 @@ function renderMediaCard(
60
60
  <button
61
61
  type="button"
62
62
  class="block w-full aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary cursor-pointer"
63
- onclick="document.getElementById('lightbox-img').src = '${fullUrl}'; document.getElementById('lightbox').showModal()"
63
+ data-on:click="document.getElementById('lightbox-img').src = '${fullUrl}'; document.getElementById('lightbox').showModal()"
64
64
  >
65
65
  <img
66
66
  src="${thumbnailUrl}"
@@ -69,13 +69,9 @@ function renderMediaCard(
69
69
  loading="lazy"
70
70
  />
71
71
  </button>
72
- <a
73
- href="/dash/media/${media.id}"
74
- class="block mt-2 text-xs truncate hover:underline"
75
- title="${media.originalName}"
76
- >
72
+ <span class="block mt-2 text-xs truncate" title="${media.originalName}">
77
73
  ${media.originalName}
78
- </a>
74
+ </span>
79
75
  <div class="text-xs text-muted-foreground">${sizeStr}</div>
80
76
  </div>
81
77
  `.toString();
@@ -83,23 +79,18 @@ function renderMediaCard(
83
79
 
84
80
  return html`
85
81
  <div class="group relative" data-media-id="${media.id}">
86
- <a
87
- href="/dash/media/${media.id}"
88
- class="block aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary"
82
+ <div
83
+ class="block aspect-square bg-muted rounded-lg overflow-hidden border"
89
84
  >
90
85
  <div
91
86
  class="w-full h-full flex items-center justify-center text-muted-foreground"
92
87
  >
93
88
  <span class="text-xs">${media.mimeType}</span>
94
89
  </div>
95
- </a>
96
- <a
97
- href="/dash/media/${media.id}"
98
- class="block mt-2 text-xs truncate hover:underline"
99
- title="${media.originalName}"
100
- >
90
+ </div>
91
+ <span class="block mt-2 text-xs truncate" title="${media.originalName}">
101
92
  ${media.originalName}
102
- </a>
93
+ </span>
103
94
  <div class="text-xs text-muted-foreground">${sizeStr}</div>
104
95
  </div>
105
96
  `.toString();
@@ -138,7 +129,7 @@ uploadApiRoutes.post("/", async (c) => {
138
129
  if (!storage) {
139
130
  const errorText = i18n._(
140
131
  msg({
141
- message: "Storage not configured",
132
+ message: "File storage isn't set up. Check your server config.",
142
133
  comment: "@context: Error when file storage is not set up",
143
134
  }),
144
135
  );
@@ -154,7 +145,7 @@ uploadApiRoutes.post("/", async (c) => {
154
145
  if (!file) {
155
146
  const errorText = i18n._(
156
147
  msg({
157
- message: "No file provided",
148
+ message: "No file selected. Choose a file to upload.",
158
149
  comment: "@context: Error when no file was selected for upload",
159
150
  }),
160
151
  );
@@ -165,7 +156,9 @@ uploadApiRoutes.post("/", async (c) => {
165
156
  }
166
157
 
167
158
  // Validate file type and size
168
- const uploadError = validateUploadFile(file);
159
+ const uploadError = validateUploadFile(file, {
160
+ maxFileSizeMB: c.var.appConfig.uploadMaxFileSize,
161
+ });
169
162
  if (uploadError) {
170
163
  if (wantsSSE(c)) {
171
164
  return sseUploadError(c, uploadError);
@@ -177,11 +170,77 @@ uploadApiRoutes.post("/", async (c) => {
177
170
  const { id, filename, storageKey } = generateStorageKey(file.name);
178
171
 
179
172
  try {
180
- // Upload to storage
181
- await storage.put(storageKey, file.stream(), {
173
+ // Read optional summary (provided for text attachments)
174
+ let summary = (formData.get("summary") as string) || undefined;
175
+ let chars: number | undefined;
176
+ // Buffer for text files — file.stream() may not work after file.text()
177
+ let textBuffer: Uint8Array | undefined;
178
+
179
+ // Extract summary and char count BEFORE consuming the stream for storage,
180
+ // because file.text() may not work after file.stream() is consumed.
181
+ if (
182
+ file.type === "text/plain" ||
183
+ file.type === "text/markdown" ||
184
+ file.type === "text/csv"
185
+ ) {
186
+ try {
187
+ const textContent = await file.text();
188
+ textBuffer = new TextEncoder().encode(textContent);
189
+ chars = textContent.length;
190
+ if (!summary) {
191
+ summary = textContent.slice(0, 100).trim() || undefined;
192
+ }
193
+ } catch {
194
+ // Ignore — summary and chars are optional
195
+ }
196
+ } else if (file.type === "text/x-tiptap+json") {
197
+ try {
198
+ const raw = await file.text();
199
+ textBuffer = new TextEncoder().encode(raw);
200
+ const envelope = JSON.parse(raw) as {
201
+ json?: { content?: unknown[] };
202
+ html?: string;
203
+ };
204
+ // Walk the TipTap JSON tree to extract plain text
205
+ if (envelope.json) {
206
+ let text = "";
207
+ const walk = (node: Record<string, unknown>) => {
208
+ if (typeof node.text === "string") text += node.text;
209
+ if (Array.isArray(node.content))
210
+ (node.content as Record<string, unknown>[]).forEach(walk);
211
+ };
212
+ walk(envelope.json as Record<string, unknown>);
213
+ chars = text.length;
214
+ }
215
+ } catch {
216
+ // Ignore — chars is optional
217
+ }
218
+ }
219
+
220
+ // Upload to storage — use buffered bytes for text files (stream may be consumed)
221
+ await storage.put(storageKey, textBuffer ?? file.stream(), {
182
222
  contentType: file.type,
183
223
  });
184
224
 
225
+ // Read optional client-side metadata
226
+ const widthRaw = parseInt(formData.get("width") as string) || undefined;
227
+ const heightRaw = parseInt(formData.get("height") as string) || undefined;
228
+ const blurhashRaw = (formData.get("blurhash") as string) || undefined;
229
+ const waveformRaw = (formData.get("waveform") as string) || undefined;
230
+
231
+ // Upload poster frame for videos (if provided by client)
232
+ let posterKey: string | undefined;
233
+ const posterFile = formData.get("poster") as File | null;
234
+ if (posterFile && file.type.startsWith("video/")) {
235
+ const date = new Date();
236
+ const year = date.getUTCFullYear();
237
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
238
+ posterKey = `media/${year}/${month}/${id}-poster.webp`;
239
+ await storage.put(posterKey, posterFile.stream(), {
240
+ contentType: "image/webp",
241
+ });
242
+ }
243
+
185
244
  // Save to database
186
245
  const media = await c.var.services.media.create({
187
246
  id,
@@ -191,6 +250,15 @@ uploadApiRoutes.post("/", async (c) => {
191
250
  size: file.size,
192
251
  storageKey,
193
252
  provider: c.var.appConfig.storageDriver,
253
+ width: widthRaw && widthRaw > 0 ? widthRaw : undefined,
254
+ height: heightRaw && heightRaw > 0 ? heightRaw : undefined,
255
+ blurhash:
256
+ blurhashRaw && blurhashRaw.length < 200 ? blurhashRaw : undefined,
257
+ waveform:
258
+ waveformRaw && waveformRaw.length < 2000 ? waveformRaw : undefined,
259
+ posterKey,
260
+ summary,
261
+ chars,
194
262
  });
195
263
 
196
264
  // SSE response for Datastar
@@ -215,7 +283,7 @@ uploadApiRoutes.post("/", async (c) => {
215
283
  await stream.toast(
216
284
  i18n._(
217
285
  msg({
218
- message: "Upload successful!",
286
+ message: "File uploaded.",
219
287
  comment: "@context: Toast after successful file upload",
220
288
  }),
221
289
  ),
@@ -243,7 +311,7 @@ uploadApiRoutes.post("/", async (c) => {
243
311
 
244
312
  const errorText = i18n._(
245
313
  msg({
246
- message: "Upload failed. Please try again.",
314
+ message: "Upload didn't go through. Try again in a moment.",
247
315
  comment: "@context: Error when file upload fails",
248
316
  }),
249
317
  );
@@ -1,20 +1,16 @@
1
1
  import { describe, it, expect, beforeEach } from "vitest";
2
2
  import { createTestDatabase } from "../../../__tests__/helpers/db.js";
3
- import { createPageService } from "../../../services/page.js";
4
3
  import { createSettingsService } from "../../../services/settings.js";
5
4
  import { createNavItemService } from "../../../services/navigation.js";
6
- import { createPathRegistryService } from "../../../services/path-registry.js";
7
5
  import type { Database } from "../../../db/index.js";
8
- import type { PageService } from "../../../services/page.js";
9
6
  import type { SettingsService } from "../../../services/settings.js";
10
7
  import type { NavItemService } from "../../../services/navigation.js";
11
8
 
12
9
  /**
13
- * Reproduces the seed logic from POST /setup to verify the default About page
14
- * and navigation items are created correctly.
10
+ * Reproduces the seed logic from POST /setup to verify the default
11
+ * navigation items are created correctly.
15
12
  */
16
13
  async function runSetupSeed(services: {
17
- pages: PageService;
18
14
  settings: SettingsService;
19
15
  navItems: NavItemService;
20
16
  }) {
@@ -30,31 +26,20 @@ async function runSetupSeed(services: {
30
26
  label: "Archive",
31
27
  url: "/archive",
32
28
  });
33
-
34
- const aboutPage = await services.pages.create({
35
- slug: "about",
36
- title: "About",
37
- body: [
38
- "Welcome to my corner of the internet.",
39
- "",
40
- "This is a place where I share my thoughts, ideas, and things I find interesting. Feel free to look around and get to know what this site is all about.",
41
- "",
42
- "If you'd like to get in touch, don't hesitate to reach out.",
43
- ].join("\n"),
44
- status: "published",
29
+ await services.navItems.create({
30
+ type: "system",
31
+ label: "RSS",
32
+ url: "/feed",
45
33
  });
46
-
47
34
  await services.navItems.create({
48
- type: "page",
49
- label: "About",
50
- url: "/about",
51
- pageId: aboutPage.id,
35
+ type: "system",
36
+ label: "Settings",
37
+ url: "/settings",
52
38
  });
53
39
  }
54
40
 
55
41
  describe("Setup seed logic", () => {
56
42
  let services: {
57
- pages: PageService;
58
43
  settings: SettingsService;
59
44
  navItems: NavItemService;
60
45
  };
@@ -63,57 +48,32 @@ describe("Setup seed logic", () => {
63
48
  const testDb = createTestDatabase();
64
49
  const db = testDb.db as unknown as Database;
65
50
  services = {
66
- pages: createPageService(db, createPathRegistryService(db)),
67
51
  settings: createSettingsService(db),
68
52
  navItems: createNavItemService(db),
69
53
  };
70
54
  });
71
55
 
72
- it("creates a default About page with correct content", async () => {
73
- await runSetupSeed(services);
74
-
75
- const aboutPage = await services.pages.getBySlug("about");
76
- expect(aboutPage).not.toBeNull();
77
- expect(aboutPage?.title).toBe("About");
78
- expect(aboutPage?.status).toBe("published");
79
- expect(aboutPage?.body).toContain("Welcome to my corner of the internet");
80
- expect(aboutPage?.bodyHtml).toBeTruthy();
81
- });
82
-
83
- it("adds About page to navigation as a page-type nav item", async () => {
56
+ it("creates four nav items: Collections, Archive, RSS, Settings", async () => {
84
57
  await runSetupSeed(services);
85
58
 
86
- const aboutPage = await services.pages.getBySlug("about");
87
59
  const navItemsList = await services.navItems.list();
88
-
89
- const aboutNavItem = navItemsList.find(
90
- (item) => item.pageId === aboutPage?.id,
91
- );
92
- expect(aboutNavItem).toBeDefined();
93
- expect(aboutNavItem?.type).toBe("page");
94
- expect(aboutNavItem?.label).toBe("About");
95
- expect(aboutNavItem?.url).toBe("/about");
96
- });
97
-
98
- it("creates three nav items total: Collections, Archive, About", async () => {
99
- await runSetupSeed(services);
100
-
101
- const navItemsList = await services.navItems.list();
102
- expect(navItemsList).toHaveLength(3);
60
+ expect(navItemsList).toHaveLength(4);
103
61
 
104
62
  const labels = navItemsList.map((item) => item.label);
105
63
  expect(labels).toContain("Collections");
106
64
  expect(labels).toContain("Archive");
107
- expect(labels).toContain("About");
65
+ expect(labels).toContain("RSS");
66
+ expect(labels).toContain("Settings");
108
67
  });
109
68
 
110
- it("renders About page body as HTML", async () => {
69
+ it("creates link and system type nav items", async () => {
111
70
  await runSetupSeed(services);
112
71
 
113
- const aboutPage = await services.pages.getBySlug("about");
114
- expect(aboutPage?.bodyHtml).toContain("<p>");
115
- expect(aboutPage?.bodyHtml).toContain(
116
- "Welcome to my corner of the internet",
117
- );
72
+ const navItemsList = await services.navItems.list();
73
+ const linkItems = navItemsList.filter((item) => item.type === "link");
74
+ const systemItems = navItemsList.filter((item) => item.type === "system");
75
+
76
+ expect(linkItems).toHaveLength(2);
77
+ expect(systemItems).toHaveLength(2);
118
78
  });
119
79
  });
@@ -37,7 +37,7 @@ const ResetContent: FC<{ token: string }> = ({ token }) => {
37
37
  </h2>
38
38
  <p>
39
39
  {t({
40
- message: "Enter your new password.",
40
+ message: "Choose a new password.",
41
41
  comment: "@context: Password reset page description",
42
42
  })}
43
43
  </p>
@@ -118,7 +118,7 @@ const ResetErrorContent: FC = () => {
118
118
  <header>
119
119
  <h2>
120
120
  {t({
121
- message: "Invalid or Expired Link",
121
+ message: "This Link Has Expired",
122
122
  comment: "@context: Password reset error heading",
123
123
  })}
124
124
  </h2>
@@ -127,7 +127,7 @@ const ResetErrorContent: FC = () => {
127
127
  <p class="text-muted-foreground">
128
128
  {t({
129
129
  message:
130
- "This password reset link is invalid or has expired. Please generate a new one.",
130
+ "This reset link is no longer valid. Request a new one to continue.",
131
131
  comment: "@context: Password reset error description",
132
132
  })}
133
133
  </p>
@@ -175,7 +175,8 @@ resetRoutes.post("/reset", async (c) => {
175
175
  parsed.error.issues[0]?.message ??
176
176
  i18n._(
177
177
  msg({
178
- message: "Invalid input",
178
+ message:
179
+ "Something doesn't look right. Check the form and try again.",
179
180
  comment:
180
181
  "@context: Fallback validation error for password reset form",
181
182
  }),