@jant/core 0.3.27 → 0.3.28

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 (313) hide show
  1. package/dist/client/client.css +1 -0
  2. package/dist/client/client.js +31561 -0
  3. package/dist/index.js +15209 -15
  4. package/package.json +21 -15
  5. package/src/__tests__/helpers/app.ts +19 -3
  6. package/src/__tests__/helpers/db.ts +44 -0
  7. package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
  8. package/src/app.tsx +111 -174
  9. package/src/client.ts +13 -0
  10. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  11. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  12. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  13. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  14. package/src/db/schema.ts +24 -4
  15. package/src/i18n/locales/en.po +810 -385
  16. package/src/i18n/locales/en.ts +1 -1
  17. package/src/i18n/locales/zh-Hans.po +733 -522
  18. package/src/i18n/locales/zh-Hans.ts +1 -1
  19. package/src/i18n/locales/zh-Hant.po +733 -522
  20. package/src/i18n/locales/zh-Hant.ts +1 -1
  21. package/src/i18n/middleware.ts +7 -11
  22. package/src/index.ts +1 -1
  23. package/src/lib/__tests__/icons.test.ts +178 -0
  24. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  25. package/src/lib/__tests__/schemas.test.ts +12 -6
  26. package/src/lib/__tests__/theme.test.ts +62 -0
  27. package/src/lib/__tests__/timezones.test.ts +1 -1
  28. package/src/lib/__tests__/url.test.ts +12 -0
  29. package/src/lib/__tests__/view.test.ts +1 -5
  30. package/src/lib/avatar-upload.ts +18 -10
  31. package/src/lib/collection-form-bridge.ts +52 -0
  32. package/src/lib/collections-reorder.ts +28 -0
  33. package/src/lib/compose-bridge.ts +251 -0
  34. package/src/lib/errors.ts +116 -0
  35. package/src/lib/excerpt.ts +1 -1
  36. package/src/lib/favicon.ts +3 -5
  37. package/src/lib/html.ts +22 -0
  38. package/src/lib/icon-catalog.ts +181 -0
  39. package/src/lib/icons.ts +202 -0
  40. package/src/lib/navigation.ts +18 -33
  41. package/src/lib/pagination.ts +3 -2
  42. package/src/lib/post-form-bridge.ts +136 -0
  43. package/src/lib/render.tsx +11 -4
  44. package/src/lib/resolve-config.ts +157 -0
  45. package/src/lib/schemas.ts +76 -12
  46. package/src/lib/settings-bridge.ts +139 -0
  47. package/src/lib/storage.ts +37 -16
  48. package/src/lib/theme.ts +5 -7
  49. package/src/lib/timeline.ts +4 -8
  50. package/src/lib/toast.ts +134 -0
  51. package/src/lib/upload.ts +71 -0
  52. package/src/lib/url.ts +9 -1
  53. package/src/lib/version.ts +16 -0
  54. package/src/lib/view.ts +9 -10
  55. package/src/middleware/__tests__/auth.test.ts +6 -28
  56. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  57. package/src/middleware/auth.ts +6 -12
  58. package/src/middleware/config.ts +51 -0
  59. package/src/middleware/error-handler.ts +56 -0
  60. package/src/middleware/onboarding.ts +1 -1
  61. package/src/preset.css +6 -0
  62. package/src/routes/__tests__/compose.test.ts +104 -17
  63. package/src/routes/api/__tests__/collections.test.ts +93 -2
  64. package/src/routes/api/__tests__/posts.test.ts +2 -1
  65. package/src/routes/api/__tests__/settings.test.ts +1 -1
  66. package/src/routes/api/collections.ts +64 -68
  67. package/src/routes/api/nav-items.ts +21 -59
  68. package/src/routes/api/pages.ts +18 -46
  69. package/src/routes/api/posts.ts +64 -86
  70. package/src/routes/api/search.ts +6 -4
  71. package/src/routes/api/settings.ts +8 -24
  72. package/src/routes/api/upload.ts +55 -53
  73. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  74. package/src/routes/auth/reset.tsx +17 -66
  75. package/src/routes/auth/setup.tsx +67 -11
  76. package/src/routes/auth/signin.tsx +44 -8
  77. package/src/routes/compose.tsx +194 -0
  78. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  79. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  80. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  81. package/src/routes/dash/appearance.tsx +173 -0
  82. package/src/routes/dash/collections.tsx +80 -14
  83. package/src/routes/dash/index.tsx +12 -14
  84. package/src/routes/dash/media.tsx +46 -49
  85. package/src/routes/dash/pages.tsx +85 -37
  86. package/src/routes/dash/posts.tsx +60 -23
  87. package/src/routes/dash/redirects.tsx +43 -33
  88. package/src/routes/dash/settings.tsx +234 -214
  89. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  90. package/src/routes/feed/rss.ts +11 -16
  91. package/src/routes/feed/sitemap.ts +15 -9
  92. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  93. package/src/routes/pages/archive.tsx +2 -2
  94. package/src/routes/pages/collection.tsx +76 -9
  95. package/src/routes/pages/collections.tsx +3 -1
  96. package/src/routes/pages/featured.tsx +2 -2
  97. package/src/routes/pages/home.tsx +3 -3
  98. package/src/routes/pages/latest.tsx +2 -2
  99. package/src/routes/pages/page.tsx +2 -2
  100. package/src/routes/pages/post.tsx +2 -2
  101. package/src/routes/pages/search.tsx +2 -2
  102. package/src/services/__tests__/collection.test.ts +324 -34
  103. package/src/services/__tests__/media.test.ts +1 -1
  104. package/src/services/__tests__/page.test.ts +116 -1
  105. package/src/services/auth.ts +88 -0
  106. package/src/services/collection.ts +169 -30
  107. package/src/services/index.ts +8 -3
  108. package/src/services/media.ts +39 -12
  109. package/src/services/navigation.ts +17 -5
  110. package/src/services/page.ts +24 -4
  111. package/src/services/post.ts +87 -19
  112. package/src/services/search.ts +0 -1
  113. package/src/services/settings.ts +21 -13
  114. package/src/style.css +3 -0
  115. package/src/styles/components.css +42 -1
  116. package/src/styles/tokens.css +4 -0
  117. package/src/styles/ui.css +902 -73
  118. package/src/types/app-context.ts +25 -0
  119. package/src/types/bindings.ts +1 -0
  120. package/src/types/config.ts +60 -23
  121. package/src/types/entities.ts +12 -2
  122. package/src/types/lingui-react-macro.d.ts +3 -3
  123. package/src/types/operations.ts +2 -4
  124. package/src/types/views.ts +1 -3
  125. package/src/ui/__tests__/font-themes.test.ts +27 -8
  126. package/src/ui/color-themes.ts +1 -1
  127. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  128. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  129. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  130. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  131. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  132. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  133. package/src/ui/components/collection-types.ts +45 -0
  134. package/src/ui/components/compose-types.ts +75 -0
  135. package/src/ui/components/jant-collection-form.ts +512 -0
  136. package/src/ui/components/jant-compose-dialog.ts +494 -0
  137. package/src/ui/components/jant-compose-editor.ts +799 -0
  138. package/src/ui/components/jant-post-form.ts +290 -0
  139. package/src/ui/components/jant-settings-avatar.ts +231 -0
  140. package/src/ui/components/jant-settings-general.ts +436 -0
  141. package/src/ui/components/post-form-template.ts +260 -0
  142. package/src/ui/components/post-form-types.ts +87 -0
  143. package/src/ui/components/settings-types.ts +62 -0
  144. package/src/ui/compose/ComposeDialog.tsx +141 -385
  145. package/src/ui/compose/ComposePrompt.tsx +3 -3
  146. package/src/ui/dash/PostList.tsx +55 -61
  147. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  148. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  149. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  150. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  151. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  152. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  153. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  154. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  155. package/src/ui/dash/index.ts +1 -1
  156. package/src/ui/dash/posts/PostForm.tsx +248 -0
  157. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  158. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  159. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  160. package/src/ui/font-themes.ts +115 -32
  161. package/src/ui/layouts/BaseLayout.tsx +49 -19
  162. package/src/ui/layouts/DashLayout.tsx +14 -9
  163. package/src/ui/layouts/SiteLayout.tsx +38 -23
  164. package/src/ui/pages/CollectionPage.tsx +12 -2
  165. package/src/ui/pages/CollectionsPage.tsx +27 -27
  166. package/src/ui/pages/HomePage.tsx +15 -6
  167. package/src/ui/pages/SearchPage.tsx +1 -2
  168. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  169. package/src/ui/shared/Pagination.tsx +2 -2
  170. package/dist/app.js +0 -267
  171. package/dist/auth.js +0 -39
  172. package/dist/client.js +0 -13
  173. package/dist/db/index.js +0 -10
  174. package/dist/db/schema.js +0 -224
  175. package/dist/i18n/Trans.js +0 -24
  176. package/dist/i18n/context.js +0 -58
  177. package/dist/i18n/detect.js +0 -26
  178. package/dist/i18n/i18n.js +0 -49
  179. package/dist/i18n/index.js +0 -44
  180. package/dist/i18n/locales/en.js +0 -1
  181. package/dist/i18n/locales/zh-Hans.js +0 -1
  182. package/dist/i18n/locales/zh-Hant.js +0 -1
  183. package/dist/i18n/locales.js +0 -13
  184. package/dist/i18n/middleware.js +0 -30
  185. package/dist/lib/avatar-upload.js +0 -134
  186. package/dist/lib/config.js +0 -143
  187. package/dist/lib/constants.js +0 -50
  188. package/dist/lib/excerpt.js +0 -76
  189. package/dist/lib/favicon.js +0 -102
  190. package/dist/lib/feed.js +0 -123
  191. package/dist/lib/image-processor.js +0 -187
  192. package/dist/lib/image.js +0 -97
  193. package/dist/lib/index.js +0 -7
  194. package/dist/lib/markdown.js +0 -83
  195. package/dist/lib/media-helpers.js +0 -49
  196. package/dist/lib/media-upload.js +0 -104
  197. package/dist/lib/nav-reorder.js +0 -27
  198. package/dist/lib/navigation.js +0 -79
  199. package/dist/lib/pagination.js +0 -44
  200. package/dist/lib/render.js +0 -53
  201. package/dist/lib/schemas.js +0 -174
  202. package/dist/lib/sqid.js +0 -72
  203. package/dist/lib/sse.js +0 -218
  204. package/dist/lib/storage.js +0 -164
  205. package/dist/lib/theme.js +0 -65
  206. package/dist/lib/time.js +0 -159
  207. package/dist/lib/timeline.js +0 -95
  208. package/dist/lib/timezones.js +0 -388
  209. package/dist/lib/url.js +0 -89
  210. package/dist/lib/view.js +0 -217
  211. package/dist/middleware/auth.js +0 -52
  212. package/dist/middleware/onboarding.js +0 -41
  213. package/dist/routes/api/collections.js +0 -124
  214. package/dist/routes/api/nav-items.js +0 -104
  215. package/dist/routes/api/pages.js +0 -91
  216. package/dist/routes/api/posts.js +0 -218
  217. package/dist/routes/api/search.js +0 -48
  218. package/dist/routes/api/settings.js +0 -68
  219. package/dist/routes/api/upload.js +0 -246
  220. package/dist/routes/auth/reset.js +0 -221
  221. package/dist/routes/auth/setup.js +0 -194
  222. package/dist/routes/auth/signin.js +0 -176
  223. package/dist/routes/compose.js +0 -48
  224. package/dist/routes/dash/collections.js +0 -115
  225. package/dist/routes/dash/index.js +0 -118
  226. package/dist/routes/dash/media.js +0 -106
  227. package/dist/routes/dash/pages.js +0 -294
  228. package/dist/routes/dash/posts.js +0 -244
  229. package/dist/routes/dash/redirects.js +0 -257
  230. package/dist/routes/dash/settings.js +0 -379
  231. package/dist/routes/feed/rss.js +0 -62
  232. package/dist/routes/feed/sitemap.js +0 -49
  233. package/dist/routes/pages/archive.js +0 -62
  234. package/dist/routes/pages/collection.js +0 -34
  235. package/dist/routes/pages/collections.js +0 -28
  236. package/dist/routes/pages/featured.js +0 -36
  237. package/dist/routes/pages/home.js +0 -64
  238. package/dist/routes/pages/latest.js +0 -45
  239. package/dist/routes/pages/page.js +0 -68
  240. package/dist/routes/pages/post.js +0 -44
  241. package/dist/routes/pages/search.js +0 -54
  242. package/dist/services/collection.js +0 -109
  243. package/dist/services/index.js +0 -24
  244. package/dist/services/media.js +0 -117
  245. package/dist/services/navigation.js +0 -91
  246. package/dist/services/page.js +0 -84
  247. package/dist/services/post.js +0 -229
  248. package/dist/services/redirect.js +0 -48
  249. package/dist/services/search.js +0 -67
  250. package/dist/services/settings.js +0 -68
  251. package/dist/types/bindings.js +0 -3
  252. package/dist/types/config.js +0 -147
  253. package/dist/types/constants.js +0 -27
  254. package/dist/types/entities.js +0 -3
  255. package/dist/types/lingui-react-macro.d.js +0 -9
  256. package/dist/types/operations.js +0 -3
  257. package/dist/types/props.js +0 -3
  258. package/dist/types/sortablejs.d.js +0 -5
  259. package/dist/types/views.js +0 -5
  260. package/dist/types.js +0 -11
  261. package/dist/ui/color-themes.js +0 -268
  262. package/dist/ui/compose/ComposeDialog.js +0 -467
  263. package/dist/ui/compose/ComposePrompt.js +0 -55
  264. package/dist/ui/dash/ActionButtons.js +0 -46
  265. package/dist/ui/dash/CrudPageHeader.js +0 -22
  266. package/dist/ui/dash/DangerZone.js +0 -36
  267. package/dist/ui/dash/FormatBadge.js +0 -27
  268. package/dist/ui/dash/ListItemRow.js +0 -21
  269. package/dist/ui/dash/PageForm.js +0 -195
  270. package/dist/ui/dash/PostForm.js +0 -395
  271. package/dist/ui/dash/PostList.js +0 -83
  272. package/dist/ui/dash/StatusBadge.js +0 -46
  273. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  274. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  275. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  276. package/dist/ui/dash/index.js +0 -10
  277. package/dist/ui/dash/media/MediaListContent.js +0 -166
  278. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  279. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  280. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  281. package/dist/ui/dash/settings/AccountContent.js +0 -209
  282. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  283. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  284. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  285. package/dist/ui/feed/LinkCard.js +0 -72
  286. package/dist/ui/feed/NoteCard.js +0 -58
  287. package/dist/ui/feed/QuoteCard.js +0 -63
  288. package/dist/ui/feed/ThreadPreview.js +0 -48
  289. package/dist/ui/feed/TimelineFeed.js +0 -41
  290. package/dist/ui/feed/TimelineItem.js +0 -27
  291. package/dist/ui/font-themes.js +0 -36
  292. package/dist/ui/layouts/BaseLayout.js +0 -153
  293. package/dist/ui/layouts/DashLayout.js +0 -141
  294. package/dist/ui/layouts/SiteLayout.js +0 -169
  295. package/dist/ui/pages/ArchivePage.js +0 -143
  296. package/dist/ui/pages/CollectionPage.js +0 -70
  297. package/dist/ui/pages/CollectionsPage.js +0 -76
  298. package/dist/ui/pages/FeaturedPage.js +0 -24
  299. package/dist/ui/pages/HomePage.js +0 -24
  300. package/dist/ui/pages/PostPage.js +0 -55
  301. package/dist/ui/pages/SearchPage.js +0 -122
  302. package/dist/ui/pages/SinglePage.js +0 -23
  303. package/dist/ui/shared/EmptyState.js +0 -27
  304. package/dist/ui/shared/MediaGallery.js +0 -35
  305. package/dist/ui/shared/Pagination.js +0 -195
  306. package/dist/ui/shared/ThreadView.js +0 -108
  307. package/dist/ui/shared/index.js +0 -5
  308. package/dist/vendor/datastar.js +0 -1606
  309. package/src/lib/__tests__/config.test.ts +0 -192
  310. package/src/lib/config.ts +0 -167
  311. package/src/routes/compose.ts +0 -63
  312. package/src/ui/dash/PostForm.tsx +0 -360
  313. package/src/ui/dash/settings/AppearanceContent.tsx +0 -254
@@ -3,25 +3,23 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
+ import { z } from "zod";
6
7
  import type { Bindings } from "../../types.js";
7
- import type { AppVariables } from "../../app.js";
8
+ import type { AppVariables } from "../../types/app-context.js";
8
9
  import { requireAuthApi } from "../../middleware/auth.js";
9
- import { z } from "zod";
10
- import { StatusSchema } from "../../lib/schemas.js";
10
+ import {
11
+ CreatePageSchema,
12
+ StatusSchema,
13
+ parseValidated,
14
+ } from "../../lib/schemas.js";
15
+ import { assertFound, parseIntParam, NotFoundError } from "../../lib/errors.js";
11
16
 
12
17
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
18
 
14
19
  export const pagesApiRoutes = new Hono<Env>();
15
20
 
16
- const CreatePageSchema = z.object({
17
- slug: z.string().min(1),
18
- title: z.string().optional(),
19
- body: z.string().optional(),
20
- status: StatusSchema.optional(),
21
- });
22
-
23
- const UpdatePageSchema = z.object({
24
- slug: z.string().min(1).optional(),
21
+ // API update schema extends shared schema with nullable fields for explicit clearing
22
+ const UpdatePageSchema = CreatePageSchema.partial().extend({
25
23
  title: z.string().nullable().optional(),
26
24
  body: z.string().nullable().optional(),
27
25
  status: StatusSchema.optional(),
@@ -35,28 +33,14 @@ pagesApiRoutes.get("/", async (c) => {
35
33
 
36
34
  // Get single page
37
35
  pagesApiRoutes.get("/:id", async (c) => {
38
- const id = parseInt(c.req.param("id"), 10);
39
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
40
-
41
- const page = await c.var.services.pages.getById(id);
42
- if (!page) return c.json({ error: "Not found" }, 404);
43
-
36
+ const id = parseIntParam(c.req.param("id"));
37
+ const page = assertFound(await c.var.services.pages.getById(id), "Page");
44
38
  return c.json(page);
45
39
  });
46
40
 
47
41
  // Create page (requires auth)
48
42
  pagesApiRoutes.post("/", requireAuthApi(), async (c) => {
49
- const rawBody = await c.req.json();
50
-
51
- const parseResult = CreatePageSchema.safeParse(rawBody);
52
- if (!parseResult.success) {
53
- return c.json(
54
- { error: "Validation failed", details: parseResult.error.flatten() },
55
- 400,
56
- );
57
- }
58
-
59
- const body = parseResult.data;
43
+ const body = parseValidated(CreatePageSchema, await c.req.json());
60
44
 
61
45
  const page = await c.var.services.pages.create({
62
46
  slug: body.slug,
@@ -70,32 +54,20 @@ pagesApiRoutes.post("/", requireAuthApi(), async (c) => {
70
54
 
71
55
  // Update page (requires auth)
72
56
  pagesApiRoutes.put("/:id", requireAuthApi(), async (c) => {
73
- const id = parseInt(c.req.param("id"), 10);
74
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
75
-
76
- const rawBody = await c.req.json();
77
-
78
- const parseResult = UpdatePageSchema.safeParse(rawBody);
79
- if (!parseResult.success) {
80
- return c.json(
81
- { error: "Validation failed", details: parseResult.error.flatten() },
82
- 400,
83
- );
84
- }
57
+ const id = parseIntParam(c.req.param("id"));
58
+ const body = parseValidated(UpdatePageSchema, await c.req.json());
85
59
 
86
- const page = await c.var.services.pages.update(id, parseResult.data);
87
- if (!page) return c.json({ error: "Not found" }, 404);
60
+ const page = assertFound(await c.var.services.pages.update(id, body), "Page");
88
61
 
89
62
  return c.json(page);
90
63
  });
91
64
 
92
65
  // Delete page (requires auth)
93
66
  pagesApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
94
- const id = parseInt(c.req.param("id"), 10);
95
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
67
+ const id = parseIntParam(c.req.param("id"));
96
68
 
97
69
  const success = await c.var.services.pages.delete(id);
98
- if (!success) return c.json({ error: "Not found" }, 404);
70
+ if (!success) throw new NotFoundError("Page");
99
71
 
100
72
  return c.json({ success: true });
101
73
  });
@@ -4,12 +4,13 @@
4
4
 
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings, Format, Status, Media } from "../../types.js";
7
- import type { AppVariables } from "../../app.js";
7
+ import type { AppVariables } from "../../types/app-context.js";
8
8
  import * as sqid from "../../lib/sqid.js";
9
9
  import {
10
10
  CreatePostSchema,
11
11
  UpdatePostSchema,
12
12
  validateMediaCount,
13
+ parseValidated,
13
14
  } from "../../lib/schemas.js";
14
15
  import { requireAuthApi } from "../../middleware/auth.js";
15
16
  import {
@@ -17,6 +18,11 @@ import {
17
18
  getImageUrl,
18
19
  getPublicUrlForProvider,
19
20
  } from "../../lib/image.js";
21
+ import {
22
+ assertFound,
23
+ NotFoundError,
24
+ ValidationError,
25
+ } from "../../lib/errors.js";
20
26
 
21
27
  type Env = { Bindings: Bindings; Variables: AppVariables };
22
28
 
@@ -57,6 +63,24 @@ function toMediaAttachment(
57
63
  };
58
64
  }
59
65
 
66
+ /**
67
+ * Validates media IDs: checks count limit and verifies all IDs exist.
68
+ */
69
+ async function validateMediaIds(
70
+ mediaIds: string[],
71
+ getByIds: (ids: string[]) => Promise<Media[]>,
72
+ ): Promise<void> {
73
+ const countError = validateMediaCount(mediaIds);
74
+ if (countError) throw new ValidationError(countError);
75
+
76
+ if (mediaIds.length > 0) {
77
+ const existing = await getByIds(mediaIds);
78
+ if (existing.length !== mediaIds.length) {
79
+ throw new ValidationError("One or more media IDs are invalid");
80
+ }
81
+ }
82
+ }
83
+
60
84
  // List posts
61
85
  postsApiRoutes.get("/", async (c) => {
62
86
  const format = c.req.query("format") as Format | undefined;
@@ -74,9 +98,7 @@ postsApiRoutes.get("/", async (c) => {
74
98
  // Batch load media for all posts
75
99
  const postIds = posts.map((p) => p.id);
76
100
  const mediaMap = await c.var.services.media.getByPostIds(postIds);
77
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
78
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
79
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
101
+ const { r2PublicUrl, imageTransformUrl, s3PublicUrl } = c.var.appConfig;
80
102
 
81
103
  return c.json({
82
104
  posts: posts.map((p) => ({
@@ -97,15 +119,12 @@ postsApiRoutes.get("/", async (c) => {
97
119
  // Get single post
98
120
  postsApiRoutes.get("/:id", async (c) => {
99
121
  const id = sqid.decode(c.req.param("id"));
100
- if (!id) return c.json({ error: "Invalid ID" }, 400);
122
+ if (!id) throw new ValidationError("Invalid ID");
101
123
 
102
- const post = await c.var.services.posts.getById(id);
103
- if (!post) return c.json({ error: "Not found" }, 404);
124
+ const post = assertFound(await c.var.services.posts.getById(id), "Post");
104
125
 
105
126
  const mediaList = await c.var.services.media.getByPostId(post.id);
106
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
107
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
108
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
127
+ const { r2PublicUrl, imageTransformUrl, s3PublicUrl } = c.var.appConfig;
109
128
 
110
129
  return c.json({
111
130
  ...post,
@@ -118,33 +137,13 @@ postsApiRoutes.get("/:id", async (c) => {
118
137
 
119
138
  // Create post (requires auth)
120
139
  postsApiRoutes.post("/", requireAuthApi(), async (c) => {
121
- const rawBody = await c.req.json();
122
-
123
- // Validate request body
124
- const parseResult = CreatePostSchema.safeParse(rawBody);
125
- if (!parseResult.success) {
126
- return c.json(
127
- { error: "Validation failed", details: parseResult.error.flatten() },
128
- 400,
129
- );
130
- }
140
+ const body = parseValidated(CreatePostSchema, await c.req.json());
131
141
 
132
- const body = parseResult.data;
133
-
134
- // Validate media count
142
+ // Validate media IDs
135
143
  if (body.mediaIds) {
136
- const mediaError = validateMediaCount(body.mediaIds);
137
- if (mediaError) {
138
- return c.json({ error: mediaError }, 400);
139
- }
140
-
141
- // Verify all media IDs exist
142
- if (body.mediaIds.length > 0) {
143
- const existing = await c.var.services.media.getByIds(body.mediaIds);
144
- if (existing.length !== body.mediaIds.length) {
145
- return c.json({ error: "One or more media IDs are invalid" }, 400);
146
- }
147
- }
144
+ await validateMediaIds(body.mediaIds, (ids) =>
145
+ c.var.services.media.getByIds(ids),
146
+ );
148
147
  }
149
148
 
150
149
  const post = await c.var.services.posts.create({
@@ -158,7 +157,7 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
158
157
  url: body.url || undefined,
159
158
  quoteText: body.quoteText,
160
159
  rating: body.rating || undefined,
161
- collectionId: body.collectionId || undefined,
160
+ collectionIds: body.collectionIds?.length ? body.collectionIds : undefined,
162
161
  replyToId: body.replyToId
163
162
  ? (sqid.decode(body.replyToId) ?? undefined)
164
163
  : undefined,
@@ -171,9 +170,7 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
171
170
  }
172
171
 
173
172
  const mediaList = await c.var.services.media.getByPostId(post.id);
174
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
175
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
176
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
173
+ const { r2PublicUrl, imageTransformUrl, s3PublicUrl } = c.var.appConfig;
177
174
 
178
175
  return c.json(
179
176
  {
@@ -190,53 +187,36 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
190
187
  // Update post (requires auth)
191
188
  postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
192
189
  const id = sqid.decode(c.req.param("id"));
193
- if (!id) return c.json({ error: "Invalid ID" }, 400);
194
-
195
- const rawBody = await c.req.json();
196
-
197
- // Validate request body
198
- const parseResult = UpdatePostSchema.safeParse(rawBody);
199
- if (!parseResult.success) {
200
- return c.json(
201
- { error: "Validation failed", details: parseResult.error.flatten() },
202
- 400,
203
- );
204
- }
190
+ if (!id) throw new ValidationError("Invalid ID");
205
191
 
206
- const body = parseResult.data;
192
+ const body = parseValidated(UpdatePostSchema, await c.req.json());
207
193
 
208
- // Validate media count if mediaIds is provided
194
+ // Validate media IDs if provided
209
195
  if (body.mediaIds !== undefined) {
210
- const mediaError = validateMediaCount(body.mediaIds);
211
- if (mediaError) {
212
- return c.json({ error: mediaError }, 400);
213
- }
214
-
215
- // Verify all media IDs exist
216
- if (body.mediaIds.length > 0) {
217
- const existing = await c.var.services.media.getByIds(body.mediaIds);
218
- if (existing.length !== body.mediaIds.length) {
219
- return c.json({ error: "One or more media IDs are invalid" }, 400);
220
- }
221
- }
196
+ await validateMediaIds(body.mediaIds, (ids) =>
197
+ c.var.services.media.getByIds(ids),
198
+ );
222
199
  }
223
200
 
224
- const post = await c.var.services.posts.update(id, {
225
- format: body.format,
226
- title: body.title,
227
- body: body.body,
228
- path: body.path,
229
- status: body.status,
230
- featured: body.featured,
231
- pinned: body.pinned,
232
- url: body.url,
233
- quoteText: body.quoteText,
234
- rating: body.rating || undefined,
235
- collectionId: body.collectionId || undefined,
236
- publishedAt: body.publishedAt,
237
- });
238
-
239
- if (!post) return c.json({ error: "Not found" }, 404);
201
+ const post = assertFound(
202
+ await c.var.services.posts.update(id, {
203
+ format: body.format,
204
+ title: body.title,
205
+ body: body.body,
206
+ path: body.path,
207
+ status: body.status,
208
+ featured: body.featured,
209
+ pinned: body.pinned,
210
+ url: body.url,
211
+ quoteText: body.quoteText,
212
+ rating: body.rating || undefined,
213
+ collectionIds: body.collectionIds?.length
214
+ ? body.collectionIds
215
+ : undefined,
216
+ publishedAt: body.publishedAt,
217
+ }),
218
+ "Post",
219
+ );
240
220
 
241
221
  // Update media attachments if provided (including empty array to clear)
242
222
  if (body.mediaIds !== undefined) {
@@ -244,9 +224,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
244
224
  }
245
225
 
246
226
  const mediaList = await c.var.services.media.getByPostId(post.id);
247
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
248
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
249
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
227
+ const { r2PublicUrl, imageTransformUrl, s3PublicUrl } = c.var.appConfig;
250
228
 
251
229
  return c.json({
252
230
  ...post,
@@ -260,13 +238,13 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
260
238
  // Delete post (requires auth)
261
239
  postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
262
240
  const id = sqid.decode(c.req.param("id"));
263
- if (!id) return c.json({ error: "Invalid ID" }, 400);
241
+ if (!id) throw new ValidationError("Invalid ID");
264
242
 
265
243
  // Detach media before deleting
266
244
  await c.var.services.media.detachFromPost(id);
267
245
 
268
246
  const success = await c.var.services.posts.delete(id);
269
- if (!success) return c.json({ error: "Not found" }, 404);
247
+ if (!success) throw new NotFoundError("Post");
270
248
 
271
249
  return c.json({ success: true });
272
250
  });
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
- import type { AppVariables } from "../../app.js";
7
+ import type { AppVariables } from "../../types/app-context.js";
8
8
  import * as sqid from "../../lib/sqid.js";
9
+ import { ValidationError, ExternalServiceError } from "../../lib/errors.js";
9
10
 
10
11
  type Env = { Bindings: Bindings; Variables: AppVariables };
11
12
 
@@ -16,11 +17,11 @@ searchApiRoutes.get("/", async (c) => {
16
17
  const query = c.req.query("q");
17
18
 
18
19
  if (!query || query.trim().length === 0) {
19
- return c.json({ error: "Query parameter 'q' is required" }, 400);
20
+ throw new ValidationError("Query parameter 'q' is required");
20
21
  }
21
22
 
22
23
  if (query.length > 200) {
23
- return c.json({ error: "Query too long" }, 400);
24
+ throw new ValidationError("Query too long");
24
25
  }
25
26
 
26
27
  const limitParam = c.req.query("limit");
@@ -46,8 +47,9 @@ searchApiRoutes.get("/", async (c) => {
46
47
  count: results.length,
47
48
  });
48
49
  } catch (err) {
50
+ if (err instanceof ValidationError) throw err;
49
51
  // eslint-disable-next-line no-console -- Error logging is intentional
50
52
  console.error("Search error:", err);
51
- return c.json({ error: "Search failed" }, 500);
53
+ throw new ExternalServiceError("Search failed");
52
54
  }
53
55
  });
@@ -4,10 +4,12 @@
4
4
 
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
- import type { AppVariables } from "../../app.js";
7
+ import type { AppVariables } from "../../types/app-context.js";
8
8
  import { requireAuthApi } from "../../middleware/auth.js";
9
9
  import { CONFIG_FIELDS, type ConfigKey } from "../../types.js";
10
10
  import { z } from "zod";
11
+ import { parseValidated } from "../../lib/schemas.js";
12
+ import { ValidationError } from "../../lib/errors.js";
11
13
 
12
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
15
 
@@ -35,17 +37,7 @@ settingsApiRoutes.get("/", requireAuthApi(), async (c) => {
35
37
 
36
38
  // Update settings (requires auth)
37
39
  settingsApiRoutes.put("/", requireAuthApi(), async (c) => {
38
- const rawBody = await c.req.json();
39
-
40
- const parseResult = UpdateSettingsSchema.safeParse(rawBody);
41
- if (!parseResult.success) {
42
- return c.json(
43
- { error: "Validation failed", details: parseResult.error.flatten() },
44
- 400,
45
- );
46
- }
47
-
48
- const updates = parseResult.data;
40
+ const updates = parseValidated(UpdateSettingsSchema, await c.req.json());
49
41
 
50
42
  // Filter to only editable keys
51
43
  const filteredUpdates: Partial<Record<ConfigKey, string>> = {};
@@ -60,21 +52,13 @@ settingsApiRoutes.put("/", requireAuthApi(), async (c) => {
60
52
  }
61
53
 
62
54
  if (rejectedKeys.length > 0 && Object.keys(filteredUpdates).length === 0) {
63
- return c.json(
64
- {
65
- error: "None of the provided keys are editable",
66
- rejectedKeys,
67
- },
68
- 400,
69
- );
55
+ throw new ValidationError("None of the provided keys are editable", {
56
+ rejectedKeys,
57
+ });
70
58
  }
71
59
 
72
60
  if (Object.keys(filteredUpdates).length > 0) {
73
- // Settings service expects SettingsKey, but our ConfigKeys that are
74
- // editable (SITE_NAME, SITE_DESCRIPTION, SITE_LANGUAGE) are valid SettingsKeys
75
- for (const [key, value] of Object.entries(filteredUpdates)) {
76
- await c.var.services.settings.set(key as never, value as string);
77
- }
61
+ await c.var.services.settings.setMany(filteredUpdates as never);
78
62
  }
79
63
 
80
64
  // Return updated state
@@ -7,9 +7,9 @@
7
7
 
8
8
  import { Hono, type Context } from "hono";
9
9
  import { html } from "hono/html";
10
- import { uuidv7 } from "uuidv7";
10
+ import { msg } from "@lingui/core/macro";
11
11
  import type { Bindings } from "../../types.js";
12
- import type { AppVariables } from "../../app.js";
12
+ import type { AppVariables } from "../../types/app-context.js";
13
13
  import { requireAuthApi } from "../../middleware/auth.js";
14
14
  import {
15
15
  getMediaUrl,
@@ -17,6 +17,9 @@ import {
17
17
  getPublicUrlForProvider,
18
18
  } from "../../lib/image.js";
19
19
  import { sse } from "../../lib/sse.js";
20
+ import { validateUploadFile, generateStorageKey } from "../../lib/upload.js";
21
+ import { assertFound } from "../../lib/errors.js";
22
+ import { getI18n } from "../../i18n/index.js";
20
23
 
21
24
  type Env = { Bindings: Bindings; Variables: AppVariables };
22
25
 
@@ -130,56 +133,48 @@ function sseUploadError(c: Context<Env>, message: string): Response {
130
133
 
131
134
  // Upload a file
132
135
  uploadApiRoutes.post("/", async (c) => {
136
+ const i18n = getI18n(c);
133
137
  const storage = c.var.storage;
134
138
  if (!storage) {
139
+ const errorText = i18n._(
140
+ msg({
141
+ message: "Storage not configured",
142
+ comment: "@context: Error when file storage is not set up",
143
+ }),
144
+ );
135
145
  if (wantsSSE(c)) {
136
- return sseUploadError(c, "Storage not configured");
146
+ return sseUploadError(c, errorText);
137
147
  }
138
- return c.json({ error: "Storage not configured" }, 500);
148
+ return c.json({ error: errorText }, 500);
139
149
  }
140
150
 
141
151
  const formData = await c.req.formData();
142
152
  const file = formData.get("file") as File | null;
143
153
 
144
154
  if (!file) {
155
+ const errorText = i18n._(
156
+ msg({
157
+ message: "No file provided",
158
+ comment: "@context: Error when no file was selected for upload",
159
+ }),
160
+ );
145
161
  if (wantsSSE(c)) {
146
- return sseUploadError(c, "No file provided");
147
- }
148
- return c.json({ error: "No file provided" }, 400);
149
- }
150
-
151
- // Validate file type
152
- const allowedTypes = [
153
- "image/jpeg",
154
- "image/png",
155
- "image/gif",
156
- "image/webp",
157
- "image/svg+xml",
158
- ];
159
- if (!allowedTypes.includes(file.type)) {
160
- if (wantsSSE(c)) {
161
- return sseUploadError(c, "File type not allowed");
162
+ return sseUploadError(c, errorText);
162
163
  }
163
- return c.json({ error: "File type not allowed" }, 400);
164
+ return c.json({ error: errorText }, 400);
164
165
  }
165
166
 
166
- // Validate file size (max 10MB)
167
- const maxSize = 10 * 1024 * 1024;
168
- if (file.size > maxSize) {
167
+ // Validate file type and size
168
+ const uploadError = validateUploadFile(file);
169
+ if (uploadError) {
169
170
  if (wantsSSE(c)) {
170
- return sseUploadError(c, "File too large (max 10MB)");
171
+ return sseUploadError(c, uploadError);
171
172
  }
172
- return c.json({ error: "File too large (max 10MB)" }, 400);
173
+ return c.json({ error: uploadError }, 400);
173
174
  }
174
175
 
175
176
  // Generate unique filename using UUIDv7
176
- const ext = file.name.split(".").pop() || "bin";
177
- const id = uuidv7();
178
- const date = new Date();
179
- const year = date.getUTCFullYear();
180
- const month = String(date.getUTCMonth() + 1).padStart(2, "0");
181
- const filename = `${id}.${ext}`;
182
- const storageKey = `media/${year}/${month}/${filename}`;
177
+ const { id, filename, storageKey } = generateStorageKey(file.name);
183
178
 
184
179
  try {
185
180
  // Upload to storage
@@ -195,21 +190,20 @@ uploadApiRoutes.post("/", async (c) => {
195
190
  mimeType: file.type,
196
191
  size: file.size,
197
192
  storageKey,
198
- provider: c.env.STORAGE_DRIVER || "r2",
193
+ provider: c.var.appConfig.storageDriver,
199
194
  });
200
195
 
201
196
  // SSE response for Datastar
202
197
  if (wantsSSE(c)) {
203
- const provider = c.env.STORAGE_DRIVER || "r2";
204
198
  const mediaPublicUrl = getPublicUrlForProvider(
205
- provider,
206
- c.env.R2_PUBLIC_URL,
207
- c.env.S3_PUBLIC_URL,
199
+ c.var.appConfig.storageDriver,
200
+ c.var.appConfig.r2PublicUrl,
201
+ c.var.appConfig.s3PublicUrl,
208
202
  );
209
203
  const cardHtml = renderMediaCard(
210
204
  media,
211
205
  mediaPublicUrl,
212
- c.env.IMAGE_TRANSFORM_URL,
206
+ c.var.appConfig.imageTransformUrl,
213
207
  );
214
208
 
215
209
  return sse(c, async (stream) => {
@@ -218,16 +212,22 @@ uploadApiRoutes.post("/", async (c) => {
218
212
  mode: "outer",
219
213
  selector: "#upload-placeholder",
220
214
  });
221
- await stream.toast("Upload successful!");
215
+ await stream.toast(
216
+ i18n._(
217
+ msg({
218
+ message: "Upload successful!",
219
+ comment: "@context: Toast after successful file upload",
220
+ }),
221
+ ),
222
+ );
222
223
  });
223
224
  }
224
225
 
225
226
  // JSON response for API clients
226
- const provider = c.env.STORAGE_DRIVER || "r2";
227
227
  const mediaPublicUrl = getPublicUrlForProvider(
228
- provider,
229
- c.env.R2_PUBLIC_URL,
230
- c.env.S3_PUBLIC_URL,
228
+ c.var.appConfig.storageDriver,
229
+ c.var.appConfig.r2PublicUrl,
230
+ c.var.appConfig.s3PublicUrl,
231
231
  );
232
232
  const publicUrl = getMediaUrl(storageKey, mediaPublicUrl);
233
233
  return c.json({
@@ -241,22 +241,27 @@ uploadApiRoutes.post("/", async (c) => {
241
241
  // eslint-disable-next-line no-console -- Error logging is intentional
242
242
  console.error("Upload error:", err);
243
243
 
244
+ const errorText = i18n._(
245
+ msg({
246
+ message: "Upload failed. Please try again.",
247
+ comment: "@context: Error when file upload fails",
248
+ }),
249
+ );
244
250
  if (wantsSSE(c)) {
245
251
  return sse(c, async (stream) => {
246
252
  await stream.remove("#upload-placeholder");
247
- await stream.toast("Upload failed. Please try again.", "error");
253
+ await stream.toast(errorText, "error");
248
254
  });
249
255
  }
250
- return c.json({ error: "Upload failed" }, 500);
256
+ return c.json({ error: errorText }, 500);
251
257
  }
252
258
  });
253
259
 
254
260
  // List uploaded files (JSON only)
255
261
  uploadApiRoutes.get("/", async (c) => {
256
262
  const limit = parseInt(c.req.query("limit") ?? "50", 10);
257
- const mediaList = await c.var.services.media.list(limit);
258
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
259
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
263
+ const mediaList = await c.var.services.media.list({ limit });
264
+ const { r2PublicUrl, s3PublicUrl } = c.var.appConfig;
260
265
 
261
266
  return c.json({
262
267
  media: mediaList.map((m) => ({
@@ -276,10 +281,7 @@ uploadApiRoutes.get("/", async (c) => {
276
281
  // Delete a file
277
282
  uploadApiRoutes.delete("/:id", async (c) => {
278
283
  const id = c.req.param("id");
279
- const media = await c.var.services.media.getById(id);
280
- if (!media) {
281
- return c.json({ error: "Not found" }, 404);
282
- }
284
+ const media = assertFound(await c.var.services.media.getById(id), "Media");
283
285
 
284
286
  // Delete from storage
285
287
  const storage = c.var.storage;