@jant/core 0.3.27 → 0.3.29

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 (314) hide show
  1. package/bin/reset-password.js +22 -0
  2. package/dist/client/client.css +1 -0
  3. package/dist/client/client.js +31561 -0
  4. package/dist/index.js +15209 -15
  5. package/package.json +25 -15
  6. package/src/__tests__/helpers/app.ts +19 -3
  7. package/src/__tests__/helpers/db.ts +44 -0
  8. package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
  9. package/src/app.tsx +111 -174
  10. package/src/client.ts +13 -0
  11. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  12. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  13. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  14. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  15. package/src/db/schema.ts +24 -4
  16. package/src/i18n/locales/en.po +810 -385
  17. package/src/i18n/locales/en.ts +1 -1
  18. package/src/i18n/locales/zh-Hans.po +733 -522
  19. package/src/i18n/locales/zh-Hans.ts +1 -1
  20. package/src/i18n/locales/zh-Hant.po +733 -522
  21. package/src/i18n/locales/zh-Hant.ts +1 -1
  22. package/src/i18n/middleware.ts +7 -11
  23. package/src/index.ts +1 -1
  24. package/src/lib/__tests__/icons.test.ts +178 -0
  25. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  26. package/src/lib/__tests__/schemas.test.ts +12 -6
  27. package/src/lib/__tests__/theme.test.ts +62 -0
  28. package/src/lib/__tests__/timezones.test.ts +1 -1
  29. package/src/lib/__tests__/url.test.ts +12 -0
  30. package/src/lib/__tests__/view.test.ts +1 -5
  31. package/src/lib/avatar-upload.ts +18 -10
  32. package/src/lib/collection-form-bridge.ts +52 -0
  33. package/src/lib/collections-reorder.ts +28 -0
  34. package/src/lib/compose-bridge.ts +251 -0
  35. package/src/lib/errors.ts +116 -0
  36. package/src/lib/excerpt.ts +1 -1
  37. package/src/lib/favicon.ts +3 -5
  38. package/src/lib/html.ts +22 -0
  39. package/src/lib/icon-catalog.ts +181 -0
  40. package/src/lib/icons.ts +202 -0
  41. package/src/lib/navigation.ts +18 -33
  42. package/src/lib/pagination.ts +3 -2
  43. package/src/lib/post-form-bridge.ts +136 -0
  44. package/src/lib/render.tsx +11 -4
  45. package/src/lib/resolve-config.ts +157 -0
  46. package/src/lib/schemas.ts +76 -12
  47. package/src/lib/settings-bridge.ts +139 -0
  48. package/src/lib/storage.ts +37 -16
  49. package/src/lib/theme.ts +5 -7
  50. package/src/lib/timeline.ts +4 -8
  51. package/src/lib/toast.ts +134 -0
  52. package/src/lib/upload.ts +71 -0
  53. package/src/lib/url.ts +9 -1
  54. package/src/lib/version.ts +16 -0
  55. package/src/lib/view.ts +9 -10
  56. package/src/middleware/__tests__/auth.test.ts +6 -28
  57. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  58. package/src/middleware/auth.ts +6 -12
  59. package/src/middleware/config.ts +51 -0
  60. package/src/middleware/error-handler.ts +56 -0
  61. package/src/middleware/onboarding.ts +1 -1
  62. package/src/preset.css +6 -0
  63. package/src/routes/__tests__/compose.test.ts +104 -17
  64. package/src/routes/api/__tests__/collections.test.ts +93 -2
  65. package/src/routes/api/__tests__/posts.test.ts +2 -1
  66. package/src/routes/api/__tests__/settings.test.ts +1 -1
  67. package/src/routes/api/collections.ts +64 -68
  68. package/src/routes/api/nav-items.ts +21 -59
  69. package/src/routes/api/pages.ts +18 -46
  70. package/src/routes/api/posts.ts +64 -86
  71. package/src/routes/api/search.ts +6 -4
  72. package/src/routes/api/settings.ts +8 -24
  73. package/src/routes/api/upload.ts +55 -53
  74. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  75. package/src/routes/auth/reset.tsx +17 -66
  76. package/src/routes/auth/setup.tsx +67 -11
  77. package/src/routes/auth/signin.tsx +44 -8
  78. package/src/routes/compose.tsx +194 -0
  79. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  80. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  81. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  82. package/src/routes/dash/appearance.tsx +173 -0
  83. package/src/routes/dash/collections.tsx +80 -14
  84. package/src/routes/dash/index.tsx +12 -14
  85. package/src/routes/dash/media.tsx +46 -49
  86. package/src/routes/dash/pages.tsx +85 -37
  87. package/src/routes/dash/posts.tsx +60 -23
  88. package/src/routes/dash/redirects.tsx +43 -33
  89. package/src/routes/dash/settings.tsx +234 -214
  90. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  91. package/src/routes/feed/rss.ts +11 -16
  92. package/src/routes/feed/sitemap.ts +15 -9
  93. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  94. package/src/routes/pages/archive.tsx +2 -2
  95. package/src/routes/pages/collection.tsx +76 -9
  96. package/src/routes/pages/collections.tsx +3 -1
  97. package/src/routes/pages/featured.tsx +2 -2
  98. package/src/routes/pages/home.tsx +3 -3
  99. package/src/routes/pages/latest.tsx +2 -2
  100. package/src/routes/pages/page.tsx +2 -2
  101. package/src/routes/pages/post.tsx +2 -2
  102. package/src/routes/pages/search.tsx +2 -2
  103. package/src/services/__tests__/collection.test.ts +324 -34
  104. package/src/services/__tests__/media.test.ts +1 -1
  105. package/src/services/__tests__/page.test.ts +116 -1
  106. package/src/services/auth.ts +88 -0
  107. package/src/services/collection.ts +169 -30
  108. package/src/services/index.ts +8 -3
  109. package/src/services/media.ts +39 -12
  110. package/src/services/navigation.ts +17 -5
  111. package/src/services/page.ts +24 -4
  112. package/src/services/post.ts +87 -19
  113. package/src/services/search.ts +0 -1
  114. package/src/services/settings.ts +21 -13
  115. package/src/style.css +3 -0
  116. package/src/styles/components.css +42 -1
  117. package/src/styles/tokens.css +4 -0
  118. package/src/styles/ui.css +902 -73
  119. package/src/types/app-context.ts +25 -0
  120. package/src/types/bindings.ts +1 -0
  121. package/src/types/config.ts +60 -23
  122. package/src/types/entities.ts +12 -2
  123. package/src/types/lingui-react-macro.d.ts +3 -3
  124. package/src/types/operations.ts +2 -4
  125. package/src/types/views.ts +1 -3
  126. package/src/ui/__tests__/font-themes.test.ts +27 -8
  127. package/src/ui/color-themes.ts +1 -1
  128. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  129. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  130. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  131. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  132. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  133. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  134. package/src/ui/components/collection-types.ts +45 -0
  135. package/src/ui/components/compose-types.ts +75 -0
  136. package/src/ui/components/jant-collection-form.ts +512 -0
  137. package/src/ui/components/jant-compose-dialog.ts +494 -0
  138. package/src/ui/components/jant-compose-editor.ts +799 -0
  139. package/src/ui/components/jant-post-form.ts +290 -0
  140. package/src/ui/components/jant-settings-avatar.ts +231 -0
  141. package/src/ui/components/jant-settings-general.ts +436 -0
  142. package/src/ui/components/post-form-template.ts +260 -0
  143. package/src/ui/components/post-form-types.ts +87 -0
  144. package/src/ui/components/settings-types.ts +62 -0
  145. package/src/ui/compose/ComposeDialog.tsx +141 -385
  146. package/src/ui/compose/ComposePrompt.tsx +3 -3
  147. package/src/ui/dash/PostList.tsx +55 -61
  148. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  149. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  150. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  151. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  152. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  153. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  154. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  155. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  156. package/src/ui/dash/index.ts +1 -1
  157. package/src/ui/dash/posts/PostForm.tsx +248 -0
  158. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  159. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  160. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  161. package/src/ui/font-themes.ts +115 -32
  162. package/src/ui/layouts/BaseLayout.tsx +49 -19
  163. package/src/ui/layouts/DashLayout.tsx +14 -9
  164. package/src/ui/layouts/SiteLayout.tsx +38 -23
  165. package/src/ui/pages/CollectionPage.tsx +12 -2
  166. package/src/ui/pages/CollectionsPage.tsx +27 -27
  167. package/src/ui/pages/HomePage.tsx +15 -6
  168. package/src/ui/pages/SearchPage.tsx +1 -2
  169. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  170. package/src/ui/shared/Pagination.tsx +2 -2
  171. package/dist/app.js +0 -267
  172. package/dist/auth.js +0 -39
  173. package/dist/client.js +0 -13
  174. package/dist/db/index.js +0 -10
  175. package/dist/db/schema.js +0 -224
  176. package/dist/i18n/Trans.js +0 -24
  177. package/dist/i18n/context.js +0 -58
  178. package/dist/i18n/detect.js +0 -26
  179. package/dist/i18n/i18n.js +0 -49
  180. package/dist/i18n/index.js +0 -44
  181. package/dist/i18n/locales/en.js +0 -1
  182. package/dist/i18n/locales/zh-Hans.js +0 -1
  183. package/dist/i18n/locales/zh-Hant.js +0 -1
  184. package/dist/i18n/locales.js +0 -13
  185. package/dist/i18n/middleware.js +0 -30
  186. package/dist/lib/avatar-upload.js +0 -134
  187. package/dist/lib/config.js +0 -143
  188. package/dist/lib/constants.js +0 -50
  189. package/dist/lib/excerpt.js +0 -76
  190. package/dist/lib/favicon.js +0 -102
  191. package/dist/lib/feed.js +0 -123
  192. package/dist/lib/image-processor.js +0 -187
  193. package/dist/lib/image.js +0 -97
  194. package/dist/lib/index.js +0 -7
  195. package/dist/lib/markdown.js +0 -83
  196. package/dist/lib/media-helpers.js +0 -49
  197. package/dist/lib/media-upload.js +0 -104
  198. package/dist/lib/nav-reorder.js +0 -27
  199. package/dist/lib/navigation.js +0 -79
  200. package/dist/lib/pagination.js +0 -44
  201. package/dist/lib/render.js +0 -53
  202. package/dist/lib/schemas.js +0 -174
  203. package/dist/lib/sqid.js +0 -72
  204. package/dist/lib/sse.js +0 -218
  205. package/dist/lib/storage.js +0 -164
  206. package/dist/lib/theme.js +0 -65
  207. package/dist/lib/time.js +0 -159
  208. package/dist/lib/timeline.js +0 -95
  209. package/dist/lib/timezones.js +0 -388
  210. package/dist/lib/url.js +0 -89
  211. package/dist/lib/view.js +0 -217
  212. package/dist/middleware/auth.js +0 -52
  213. package/dist/middleware/onboarding.js +0 -41
  214. package/dist/routes/api/collections.js +0 -124
  215. package/dist/routes/api/nav-items.js +0 -104
  216. package/dist/routes/api/pages.js +0 -91
  217. package/dist/routes/api/posts.js +0 -218
  218. package/dist/routes/api/search.js +0 -48
  219. package/dist/routes/api/settings.js +0 -68
  220. package/dist/routes/api/upload.js +0 -246
  221. package/dist/routes/auth/reset.js +0 -221
  222. package/dist/routes/auth/setup.js +0 -194
  223. package/dist/routes/auth/signin.js +0 -176
  224. package/dist/routes/compose.js +0 -48
  225. package/dist/routes/dash/collections.js +0 -115
  226. package/dist/routes/dash/index.js +0 -118
  227. package/dist/routes/dash/media.js +0 -106
  228. package/dist/routes/dash/pages.js +0 -294
  229. package/dist/routes/dash/posts.js +0 -244
  230. package/dist/routes/dash/redirects.js +0 -257
  231. package/dist/routes/dash/settings.js +0 -379
  232. package/dist/routes/feed/rss.js +0 -62
  233. package/dist/routes/feed/sitemap.js +0 -49
  234. package/dist/routes/pages/archive.js +0 -62
  235. package/dist/routes/pages/collection.js +0 -34
  236. package/dist/routes/pages/collections.js +0 -28
  237. package/dist/routes/pages/featured.js +0 -36
  238. package/dist/routes/pages/home.js +0 -64
  239. package/dist/routes/pages/latest.js +0 -45
  240. package/dist/routes/pages/page.js +0 -68
  241. package/dist/routes/pages/post.js +0 -44
  242. package/dist/routes/pages/search.js +0 -54
  243. package/dist/services/collection.js +0 -109
  244. package/dist/services/index.js +0 -24
  245. package/dist/services/media.js +0 -117
  246. package/dist/services/navigation.js +0 -91
  247. package/dist/services/page.js +0 -84
  248. package/dist/services/post.js +0 -229
  249. package/dist/services/redirect.js +0 -48
  250. package/dist/services/search.js +0 -67
  251. package/dist/services/settings.js +0 -68
  252. package/dist/types/bindings.js +0 -3
  253. package/dist/types/config.js +0 -147
  254. package/dist/types/constants.js +0 -27
  255. package/dist/types/entities.js +0 -3
  256. package/dist/types/lingui-react-macro.d.js +0 -9
  257. package/dist/types/operations.js +0 -3
  258. package/dist/types/props.js +0 -3
  259. package/dist/types/sortablejs.d.js +0 -5
  260. package/dist/types/views.js +0 -5
  261. package/dist/types.js +0 -11
  262. package/dist/ui/color-themes.js +0 -268
  263. package/dist/ui/compose/ComposeDialog.js +0 -467
  264. package/dist/ui/compose/ComposePrompt.js +0 -55
  265. package/dist/ui/dash/ActionButtons.js +0 -46
  266. package/dist/ui/dash/CrudPageHeader.js +0 -22
  267. package/dist/ui/dash/DangerZone.js +0 -36
  268. package/dist/ui/dash/FormatBadge.js +0 -27
  269. package/dist/ui/dash/ListItemRow.js +0 -21
  270. package/dist/ui/dash/PageForm.js +0 -195
  271. package/dist/ui/dash/PostForm.js +0 -395
  272. package/dist/ui/dash/PostList.js +0 -83
  273. package/dist/ui/dash/StatusBadge.js +0 -46
  274. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  275. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  276. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  277. package/dist/ui/dash/index.js +0 -10
  278. package/dist/ui/dash/media/MediaListContent.js +0 -166
  279. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  280. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  281. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  282. package/dist/ui/dash/settings/AccountContent.js +0 -209
  283. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  284. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  285. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  286. package/dist/ui/feed/LinkCard.js +0 -72
  287. package/dist/ui/feed/NoteCard.js +0 -58
  288. package/dist/ui/feed/QuoteCard.js +0 -63
  289. package/dist/ui/feed/ThreadPreview.js +0 -48
  290. package/dist/ui/feed/TimelineFeed.js +0 -41
  291. package/dist/ui/feed/TimelineItem.js +0 -27
  292. package/dist/ui/font-themes.js +0 -36
  293. package/dist/ui/layouts/BaseLayout.js +0 -153
  294. package/dist/ui/layouts/DashLayout.js +0 -141
  295. package/dist/ui/layouts/SiteLayout.js +0 -169
  296. package/dist/ui/pages/ArchivePage.js +0 -143
  297. package/dist/ui/pages/CollectionPage.js +0 -70
  298. package/dist/ui/pages/CollectionsPage.js +0 -76
  299. package/dist/ui/pages/FeaturedPage.js +0 -24
  300. package/dist/ui/pages/HomePage.js +0 -24
  301. package/dist/ui/pages/PostPage.js +0 -55
  302. package/dist/ui/pages/SearchPage.js +0 -122
  303. package/dist/ui/pages/SinglePage.js +0 -23
  304. package/dist/ui/shared/EmptyState.js +0 -27
  305. package/dist/ui/shared/MediaGallery.js +0 -35
  306. package/dist/ui/shared/Pagination.js +0 -195
  307. package/dist/ui/shared/ThreadView.js +0 -108
  308. package/dist/ui/shared/index.js +0 -5
  309. package/dist/vendor/datastar.js +0 -1606
  310. package/src/lib/__tests__/config.test.ts +0 -192
  311. package/src/lib/config.ts +0 -167
  312. package/src/routes/compose.ts +0 -63
  313. package/src/ui/dash/PostForm.tsx +0 -360
  314. 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;