@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,80 @@
1
+ /**
2
+ * Custom URLs API Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { Bindings } from "../../types.js";
7
+ import type { AppVariables } from "../../types/app-context.js";
8
+ import { requireAuthApi } from "../../middleware/auth.js";
9
+ import { CreateCustomUrlSchema, parseValidated } from "../../lib/schemas.js";
10
+ import { parseIdParam, NotFoundError } from "../../lib/errors.js";
11
+ import { DEFAULT_PAGE_SIZE } from "../../lib/constants.js";
12
+
13
+ type Env = { Bindings: Bindings; Variables: AppVariables };
14
+
15
+ export const customUrlsApiRoutes = new Hono<Env>();
16
+
17
+ // List custom URLs (requires auth)
18
+ customUrlsApiRoutes.get("/", requireAuthApi(), async (c) => {
19
+ const pageParam = c.req.query("page");
20
+ const page = Math.max(1, parseInt(pageParam || "1", 10) || 1);
21
+
22
+ const [total, customUrls] = await Promise.all([
23
+ c.var.services.customUrls.count(),
24
+ c.var.services.customUrls.list({
25
+ limit: DEFAULT_PAGE_SIZE,
26
+ offset: (page - 1) * DEFAULT_PAGE_SIZE,
27
+ }),
28
+ ]);
29
+
30
+ const totalPages = Math.max(1, Math.ceil(total / DEFAULT_PAGE_SIZE));
31
+ return c.json({ customUrls, total, page, totalPages });
32
+ });
33
+
34
+ // Create custom URL (requires auth)
35
+ customUrlsApiRoutes.post("/", requireAuthApi(), async (c) => {
36
+ const body = parseValidated(CreateCustomUrlSchema, await c.req.json());
37
+
38
+ const redirectType = body.redirectType
39
+ ? (parseInt(body.redirectType, 10) as 301 | 302)
40
+ : undefined;
41
+
42
+ // Resolve slug → ID for post/collection targets
43
+ let targetId = body.targetId;
44
+ if (body.targetType === "post" && body.targetId) {
45
+ const post = await c.var.services.posts.getBySlug(body.targetId);
46
+ if (!post) {
47
+ throw new NotFoundError(`Post with slug "${body.targetId}" not found`);
48
+ }
49
+ targetId = post.id;
50
+ }
51
+ if (body.targetType === "collection" && body.targetId) {
52
+ const col = await c.var.services.collections.getBySlug(body.targetId);
53
+ if (!col) {
54
+ throw new NotFoundError(
55
+ `Collection with slug "${body.targetId}" not found`,
56
+ );
57
+ }
58
+ targetId = col.id;
59
+ }
60
+
61
+ const customUrl = await c.var.services.customUrls.create({
62
+ path: body.path,
63
+ targetType: body.targetType,
64
+ targetId,
65
+ toPath: body.toPath,
66
+ redirectType,
67
+ });
68
+
69
+ return c.json(customUrl, 201);
70
+ });
71
+
72
+ // Delete custom URL (requires auth)
73
+ customUrlsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
74
+ const id = parseIdParam(c.req.param("id"));
75
+
76
+ const success = await c.var.services.customUrls.delete(id);
77
+ if (!success) throw new NotFoundError("Custom URL");
78
+
79
+ return c.json({ success: true });
80
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Export API Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { Bindings } from "../../types.js";
7
+ import type { AppVariables } from "../../types/app-context.js";
8
+ import { requireAuthApi } from "../../middleware/auth.js";
9
+ import { createExportService } from "../../services/export.js";
10
+
11
+ type Env = { Bindings: Bindings; Variables: AppVariables };
12
+
13
+ export const exportApiRoutes = new Hono<Env>();
14
+
15
+ exportApiRoutes.post("/zola", requireAuthApi(), async (c) => {
16
+ const { services, appConfig } = c.var;
17
+ const exportService = createExportService(services, {
18
+ siteName: appConfig.siteName,
19
+ siteUrl: appConfig.siteUrl,
20
+ siteDescription: appConfig.siteDescription,
21
+ siteLanguage: appConfig.siteLanguage,
22
+ });
23
+ const zip = await exportService.generateZolaSite();
24
+ return new Response(zip, {
25
+ headers: {
26
+ "Content-Type": "application/zip",
27
+ "Content-Disposition": 'attachment; filename="jant-export.zip"',
28
+ "Content-Length": String(zip.byteLength),
29
+ },
30
+ });
31
+ });
@@ -7,20 +7,18 @@ import { z } from "zod";
7
7
  import type { Bindings, NavItemType } from "../../types.js";
8
8
  import type { AppVariables } from "../../types/app-context.js";
9
9
  import { requireAuthApi } from "../../middleware/auth.js";
10
- import {
11
- CreateNavItemSchema,
12
- ReorderSchema,
13
- parseValidated,
14
- } from "../../lib/schemas.js";
15
- import { assertFound, parseIntParam, NotFoundError } from "../../lib/errors.js";
10
+ import { CreateNavItemSchema, parseValidated } from "../../lib/schemas.js";
11
+ import { assertFound, parseIdParam, NotFoundError } from "../../lib/errors.js";
16
12
 
17
13
  type Env = { Bindings: Bindings; Variables: AppVariables };
18
14
 
19
15
  export const navItemsApiRoutes = new Hono<Env>();
20
16
 
21
- // API update schema extends shared schema with nullable pageId for explicit clearing
22
- const UpdateNavItemSchema = CreateNavItemSchema.partial().extend({
23
- pageId: z.number().int().positive().nullable().optional(),
17
+ const UpdateNavItemSchema = CreateNavItemSchema.partial();
18
+
19
+ const MoveSchema = z.object({
20
+ after: z.string().nullable().optional(),
21
+ before: z.string().nullable().optional(),
24
22
  });
25
23
 
26
24
  // List nav items
@@ -29,13 +27,21 @@ navItemsApiRoutes.get("/", async (c) => {
29
27
  return c.json({ navItems: items });
30
28
  });
31
29
 
32
- // Reorder nav items (requires auth) — must be before /:id
33
- navItemsApiRoutes.put("/reorder", requireAuthApi(), async (c) => {
34
- const body = parseValidated(ReorderSchema, await c.req.json());
30
+ // Move nav item (requires auth) — must be before /:id
31
+ navItemsApiRoutes.put("/:id/move", requireAuthApi(), async (c) => {
32
+ const id = parseIdParam(c.req.param("id"));
33
+ const body = parseValidated(MoveSchema, await c.req.json());
35
34
 
36
- await c.var.services.navItems.reorder(body.ids);
37
- const items = await c.var.services.navItems.list();
38
- return c.json({ navItems: items });
35
+ const item = assertFound(
36
+ await c.var.services.navItems.move(
37
+ id,
38
+ body.after ?? null,
39
+ body.before ?? null,
40
+ ),
41
+ "Nav item",
42
+ );
43
+
44
+ return c.json(item);
39
45
  });
40
46
 
41
47
  // Create nav item (requires auth)
@@ -46,8 +52,6 @@ navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
46
52
  type: body.type as NavItemType,
47
53
  label: body.label,
48
54
  url: body.url,
49
- pageId: body.pageId,
50
- position: body.position,
51
55
  });
52
56
 
53
57
  return c.json(item, 201);
@@ -55,7 +59,7 @@ navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
55
59
 
56
60
  // Update nav item (requires auth)
57
61
  navItemsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
58
- const id = parseIntParam(c.req.param("id"));
62
+ const id = parseIdParam(c.req.param("id"));
59
63
  const body = parseValidated(UpdateNavItemSchema, await c.req.json());
60
64
 
61
65
  const item = assertFound(
@@ -68,7 +72,7 @@ navItemsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
68
72
 
69
73
  // Delete nav item (requires auth)
70
74
  navItemsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
71
- const id = parseIntParam(c.req.param("id"));
75
+ const id = parseIdParam(c.req.param("id"));
72
76
 
73
77
  const success = await c.var.services.navItems.delete(id);
74
78
  if (!success) throw new NotFoundError("Nav item");
@@ -3,12 +3,14 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import type { Bindings, Format, Status, Media } from "../../types.js";
6
+ import type { Bindings, Media } from "../../types.js";
7
7
  import type { AppVariables } from "../../types/app-context.js";
8
- import * as sqid from "../../lib/sqid.js";
8
+ import { z } from "zod";
9
9
  import {
10
10
  CreatePostSchema,
11
11
  UpdatePostSchema,
12
+ FormatSchema,
13
+ StatusSchema,
12
14
  parseValidated,
13
15
  } from "../../lib/schemas.js";
14
16
  import { requireAuthApi } from "../../middleware/auth.js";
@@ -17,11 +19,7 @@ import {
17
19
  getImageUrl,
18
20
  getPublicUrlForProvider,
19
21
  } from "../../lib/image.js";
20
- import {
21
- assertFound,
22
- NotFoundError,
23
- ValidationError,
24
- } from "../../lib/errors.js";
22
+ import { assertFound, NotFoundError, parseIdParam } from "../../lib/errors.js";
25
23
 
26
24
  type Env = { Bindings: Bindings; Variables: AppVariables };
27
25
 
@@ -49,31 +47,41 @@ function toMediaAttachment(
49
47
  format: "auto",
50
48
  fit: "scale-down",
51
49
  });
50
+ const posterUrl = m.posterKey ? getMediaUrl(m.posterKey, publicUrl) : null;
52
51
 
53
52
  return {
54
53
  id: m.id,
55
54
  url,
56
55
  previewUrl,
56
+ posterUrl,
57
57
  alt: m.alt,
58
58
  blurhash: m.blurhash,
59
59
  width: m.width,
60
60
  height: m.height,
61
61
  position: m.position,
62
62
  mimeType: m.mimeType,
63
+ summary: m.summary,
63
64
  };
64
65
  }
65
66
 
66
- // List posts
67
- postsApiRoutes.get("/", async (c) => {
68
- const format = c.req.query("format") as Format | undefined;
69
- const status = c.req.query("status") as Status | undefined;
70
- const cursor = c.req.query("cursor");
71
- const limit = parseInt(c.req.query("limit") ?? "100", 10);
67
+ const ListPostsQuerySchema = z.object({
68
+ format: FormatSchema.optional(),
69
+ status: StatusSchema.optional(),
70
+ cursor: z.string().optional(),
71
+ limit: z.coerce.number().int().min(1).max(100).optional().default(100),
72
+ });
73
+
74
+ // List posts (requires auth)
75
+ postsApiRoutes.get("/", requireAuthApi(), async (c) => {
76
+ const { format, status, cursor, limit } = parseValidated(
77
+ ListPostsQuerySchema,
78
+ c.req.query(),
79
+ );
72
80
 
73
81
  const posts = await c.var.services.posts.list({
74
82
  format,
75
83
  status: status ?? "published",
76
- cursor: cursor ? (sqid.decode(cursor) ?? undefined) : undefined,
84
+ cursor: cursor ?? undefined,
77
85
  limit,
78
86
  });
79
87
 
@@ -85,32 +93,33 @@ postsApiRoutes.get("/", async (c) => {
85
93
  return c.json({
86
94
  posts: posts.map((p) => ({
87
95
  ...p,
88
- sqid: sqid.encode(p.id),
89
96
  mediaAttachments: (mediaMap.get(p.id) ?? []).map((m) =>
90
97
  toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
91
98
  ),
92
99
  })),
93
100
 
94
101
  nextCursor:
95
- posts.length === limit
96
- ? sqid.encode(posts[posts.length - 1]?.id ?? 0)
97
- : null,
102
+ posts.length === limit ? (posts[posts.length - 1]?.id ?? null) : null,
98
103
  });
99
104
  });
100
105
 
101
- // Get single post
102
- postsApiRoutes.get("/:id", async (c) => {
103
- const id = sqid.decode(c.req.param("id"));
104
- if (!id) throw new ValidationError("Invalid ID");
106
+ // Get single post (requires auth)
107
+ postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
108
+ const id = parseIdParam(c.req.param("id"));
105
109
 
106
110
  const post = assertFound(await c.var.services.posts.getById(id), "Post");
107
111
 
108
112
  const mediaList = await c.var.services.media.getByPostId(post.id);
109
113
  const { r2PublicUrl, imageTransformUrl, s3PublicUrl } = c.var.appConfig;
110
114
 
115
+ // Get collection IDs for this post
116
+ const postCollections =
117
+ await c.var.services.collections.getCollectionsByPostId(post.id);
118
+ const collectionIds = postCollections.map((col) => col.id);
119
+
111
120
  return c.json({
112
121
  ...post,
113
- sqid: sqid.encode(post.id),
122
+ collectionIds,
114
123
  mediaAttachments: mediaList.map((m) =>
115
124
  toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
116
125
  ),
@@ -131,19 +140,18 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
131
140
  format: body.format,
132
141
  title: body.title,
133
142
  body: body.body,
143
+ bodyMarkdown: body.bodyMarkdown,
144
+ slug: body.slug || undefined,
134
145
  path: body.path || undefined,
135
146
  status: body.status,
136
147
  visibility: body.visibility,
137
148
  pinned: body.pinned,
149
+ featured: body.featured,
138
150
  url: body.url || undefined,
139
151
  quoteText: body.quoteText,
140
152
  rating: body.rating || undefined,
141
- collectionIds: body.collectionIds?.length
142
- ? body.collectionIds
143
- : undefined,
144
- replyToId: body.replyToId
145
- ? (sqid.decode(body.replyToId) ?? undefined)
146
- : undefined,
153
+ collectionIds: body.collectionIds,
154
+ replyToId: body.replyToId,
147
155
  publishedAt: body.publishedAt,
148
156
  },
149
157
  {
@@ -163,7 +171,6 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
163
171
  return c.json(
164
172
  {
165
173
  ...post,
166
- sqid: sqid.encode(post.id),
167
174
  mediaAttachments: mediaList.map((m) =>
168
175
  toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
169
176
  ),
@@ -174,8 +181,7 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
174
181
 
175
182
  // Update post (requires auth)
176
183
  postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
177
- const id = sqid.decode(c.req.param("id"));
178
- if (!id) throw new ValidationError("Invalid ID");
184
+ const id = parseIdParam(c.req.param("id"));
179
185
 
180
186
  const body = parseValidated(UpdatePostSchema, await c.req.json());
181
187
 
@@ -191,16 +197,16 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
191
197
  format: body.format,
192
198
  title: body.title,
193
199
  body: body.body,
194
- path: body.path,
200
+ bodyMarkdown: body.bodyMarkdown,
201
+ slug: body.slug,
195
202
  status: body.status,
196
203
  visibility: body.visibility,
197
204
  pinned: body.pinned,
205
+ featured: body.featured,
198
206
  url: body.url,
199
207
  quoteText: body.quoteText,
200
208
  rating: body.rating || undefined,
201
- collectionIds: body.collectionIds?.length
202
- ? body.collectionIds
203
- : undefined,
209
+ collectionIds: body.collectionIds,
204
210
  publishedAt: body.publishedAt,
205
211
  },
206
212
  {
@@ -221,7 +227,6 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
221
227
 
222
228
  return c.json({
223
229
  ...post,
224
- sqid: sqid.encode(post.id),
225
230
  mediaAttachments: mediaList.map((m) =>
226
231
  toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
227
232
  ),
@@ -230,8 +235,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
230
235
 
231
236
  // Delete post (requires auth)
232
237
  postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
233
- const id = sqid.decode(c.req.param("id"));
234
- if (!id) throw new ValidationError("Invalid ID");
238
+ const id = parseIdParam(c.req.param("id"));
235
239
 
236
240
  const success = await c.var.services.posts.delete(id, {
237
241
  media: c.var.services.media,
@@ -5,7 +5,6 @@
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../types/app-context.js";
8
- import * as sqid from "../../lib/sqid.js";
9
8
  import { ValidationError, ExternalServiceError } from "../../lib/errors.js";
10
9
 
11
10
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -36,13 +35,13 @@ searchApiRoutes.get("/", async (c) => {
36
35
  return c.json({
37
36
  query,
38
37
  results: results.map((r) => ({
39
- id: sqid.encode(r.post.id),
38
+ id: r.post.id,
40
39
  format: r.post.format,
41
40
  title: r.post.title,
42
- path: r.post.path,
41
+ slug: r.post.slug,
43
42
  snippet: r.snippet,
44
43
  publishedAt: r.post.publishedAt,
45
- url: r.post.path ? `/${r.post.path}` : `/p/${sqid.encode(r.post.id)}`,
44
+ url: `/${r.post.slug}`,
46
45
  })),
47
46
  count: results.length,
48
47
  });
@@ -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
+ });