@jant/core 0.3.26 → 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 (314) 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 +112 -173
  9. package/src/auth.ts +4 -1
  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 -265
  172. package/dist/auth.js +0 -36
  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
@@ -1,218 +0,0 @@
1
- /**
2
- * Posts API Routes
3
- */ import { Hono } from "hono";
4
- import * as sqid from "../../lib/sqid.js";
5
- import { CreatePostSchema, UpdatePostSchema, validateMediaCount } from "../../lib/schemas.js";
6
- import { requireAuthApi } from "../../middleware/auth.js";
7
- import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
8
- export const postsApiRoutes = new Hono();
9
- /**
10
- * Converts a Media record to a MediaAttachment API response shape.
11
- */ function toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl) {
12
- const publicUrl = getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl);
13
- const url = getMediaUrl(m.storageKey, publicUrl);
14
- const previewUrl = getImageUrl(url, imageTransformUrl, {
15
- width: 400,
16
- quality: 80,
17
- format: "auto",
18
- fit: "cover"
19
- });
20
- return {
21
- id: m.id,
22
- url,
23
- previewUrl,
24
- alt: m.alt,
25
- blurhash: m.blurhash,
26
- width: m.width,
27
- height: m.height,
28
- position: m.position,
29
- mimeType: m.mimeType
30
- };
31
- }
32
- // List posts
33
- postsApiRoutes.get("/", async (c)=>{
34
- const format = c.req.query("format");
35
- const status = c.req.query("status");
36
- const cursor = c.req.query("cursor");
37
- const limit = parseInt(c.req.query("limit") ?? "100", 10);
38
- const posts = await c.var.services.posts.list({
39
- format,
40
- status: status ?? "published",
41
- cursor: cursor ? sqid.decode(cursor) ?? undefined : undefined,
42
- limit
43
- });
44
- // Batch load media for all posts
45
- const postIds = posts.map((p)=>p.id);
46
- const mediaMap = await c.var.services.media.getByPostIds(postIds);
47
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
48
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
49
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
50
- return c.json({
51
- posts: posts.map((p)=>({
52
- ...p,
53
- sqid: sqid.encode(p.id),
54
- mediaAttachments: (mediaMap.get(p.id) ?? []).map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
55
- })),
56
- nextCursor: posts.length === limit ? sqid.encode(posts[posts.length - 1]?.id ?? 0) : null
57
- });
58
- });
59
- // Get single post
60
- postsApiRoutes.get("/:id", async (c)=>{
61
- const id = sqid.decode(c.req.param("id"));
62
- if (!id) return c.json({
63
- error: "Invalid ID"
64
- }, 400);
65
- const post = await c.var.services.posts.getById(id);
66
- if (!post) return c.json({
67
- error: "Not found"
68
- }, 404);
69
- const mediaList = await c.var.services.media.getByPostId(post.id);
70
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
71
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
72
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
73
- return c.json({
74
- ...post,
75
- sqid: sqid.encode(post.id),
76
- mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
77
- });
78
- });
79
- // Create post (requires auth)
80
- postsApiRoutes.post("/", requireAuthApi(), async (c)=>{
81
- const rawBody = await c.req.json();
82
- // Validate request body
83
- const parseResult = CreatePostSchema.safeParse(rawBody);
84
- if (!parseResult.success) {
85
- return c.json({
86
- error: "Validation failed",
87
- details: parseResult.error.flatten()
88
- }, 400);
89
- }
90
- const body = parseResult.data;
91
- // Validate media count
92
- if (body.mediaIds) {
93
- const mediaError = validateMediaCount(body.mediaIds);
94
- if (mediaError) {
95
- return c.json({
96
- error: mediaError
97
- }, 400);
98
- }
99
- // Verify all media IDs exist
100
- if (body.mediaIds.length > 0) {
101
- const existing = await c.var.services.media.getByIds(body.mediaIds);
102
- if (existing.length !== body.mediaIds.length) {
103
- return c.json({
104
- error: "One or more media IDs are invalid"
105
- }, 400);
106
- }
107
- }
108
- }
109
- const post = await c.var.services.posts.create({
110
- format: body.format,
111
- title: body.title,
112
- body: body.body,
113
- path: body.path || undefined,
114
- status: body.status,
115
- featured: body.featured,
116
- pinned: body.pinned,
117
- url: body.url || undefined,
118
- quoteText: body.quoteText,
119
- rating: body.rating || undefined,
120
- collectionId: body.collectionId || undefined,
121
- replyToId: body.replyToId ? sqid.decode(body.replyToId) ?? undefined : undefined,
122
- publishedAt: body.publishedAt
123
- });
124
- // Attach media
125
- if (body.mediaIds && body.mediaIds.length > 0) {
126
- await c.var.services.media.attachToPost(post.id, body.mediaIds);
127
- }
128
- const mediaList = await c.var.services.media.getByPostId(post.id);
129
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
130
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
131
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
132
- return c.json({
133
- ...post,
134
- sqid: sqid.encode(post.id),
135
- mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
136
- }, 201);
137
- });
138
- // Update post (requires auth)
139
- postsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
140
- const id = sqid.decode(c.req.param("id"));
141
- if (!id) return c.json({
142
- error: "Invalid ID"
143
- }, 400);
144
- const rawBody = await c.req.json();
145
- // Validate request body
146
- const parseResult = UpdatePostSchema.safeParse(rawBody);
147
- if (!parseResult.success) {
148
- return c.json({
149
- error: "Validation failed",
150
- details: parseResult.error.flatten()
151
- }, 400);
152
- }
153
- const body = parseResult.data;
154
- // Validate media count if mediaIds is provided
155
- if (body.mediaIds !== undefined) {
156
- const mediaError = validateMediaCount(body.mediaIds);
157
- if (mediaError) {
158
- return c.json({
159
- error: mediaError
160
- }, 400);
161
- }
162
- // Verify all media IDs exist
163
- if (body.mediaIds.length > 0) {
164
- const existing = await c.var.services.media.getByIds(body.mediaIds);
165
- if (existing.length !== body.mediaIds.length) {
166
- return c.json({
167
- error: "One or more media IDs are invalid"
168
- }, 400);
169
- }
170
- }
171
- }
172
- const post = await c.var.services.posts.update(id, {
173
- format: body.format,
174
- title: body.title,
175
- body: body.body,
176
- path: body.path,
177
- status: body.status,
178
- featured: body.featured,
179
- pinned: body.pinned,
180
- url: body.url,
181
- quoteText: body.quoteText,
182
- rating: body.rating || undefined,
183
- collectionId: body.collectionId || undefined,
184
- publishedAt: body.publishedAt
185
- });
186
- if (!post) return c.json({
187
- error: "Not found"
188
- }, 404);
189
- // Update media attachments if provided (including empty array to clear)
190
- if (body.mediaIds !== undefined) {
191
- await c.var.services.media.attachToPost(post.id, body.mediaIds);
192
- }
193
- const mediaList = await c.var.services.media.getByPostId(post.id);
194
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
195
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
196
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
197
- return c.json({
198
- ...post,
199
- sqid: sqid.encode(post.id),
200
- mediaAttachments: mediaList.map((m)=>toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl))
201
- });
202
- });
203
- // Delete post (requires auth)
204
- postsApiRoutes.delete("/:id", requireAuthApi(), async (c)=>{
205
- const id = sqid.decode(c.req.param("id"));
206
- if (!id) return c.json({
207
- error: "Invalid ID"
208
- }, 400);
209
- // Detach media before deleting
210
- await c.var.services.media.detachFromPost(id);
211
- const success = await c.var.services.posts.delete(id);
212
- if (!success) return c.json({
213
- error: "Not found"
214
- }, 404);
215
- return c.json({
216
- success: true
217
- });
218
- });
@@ -1,48 +0,0 @@
1
- /**
2
- * Search API Routes
3
- */ import { Hono } from "hono";
4
- import * as sqid from "../../lib/sqid.js";
5
- export const searchApiRoutes = new Hono();
6
- // Search posts
7
- searchApiRoutes.get("/", async (c)=>{
8
- const query = c.req.query("q");
9
- if (!query || query.trim().length === 0) {
10
- return c.json({
11
- error: "Query parameter 'q' is required"
12
- }, 400);
13
- }
14
- if (query.length > 200) {
15
- return c.json({
16
- error: "Query too long"
17
- }, 400);
18
- }
19
- const limitParam = c.req.query("limit");
20
- const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 20, 50) : 20;
21
- try {
22
- const results = await c.var.services.search.search(query, {
23
- limit,
24
- status: [
25
- "published"
26
- ]
27
- });
28
- return c.json({
29
- query,
30
- results: results.map((r)=>({
31
- id: sqid.encode(r.post.id),
32
- format: r.post.format,
33
- title: r.post.title,
34
- path: r.post.path,
35
- snippet: r.snippet,
36
- publishedAt: r.post.publishedAt,
37
- url: r.post.path ? `/${r.post.path}` : `/p/${sqid.encode(r.post.id)}`
38
- })),
39
- count: results.length
40
- });
41
- } catch (err) {
42
- // eslint-disable-next-line no-console -- Error logging is intentional
43
- console.error("Search error:", err);
44
- return c.json({
45
- error: "Search failed"
46
- }, 500);
47
- }
48
- });
@@ -1,68 +0,0 @@
1
- /**
2
- * Settings API Routes
3
- */ import { Hono } from "hono";
4
- import { requireAuthApi } from "../../middleware/auth.js";
5
- import { CONFIG_FIELDS } from "../../types.js";
6
- import { z } from "zod";
7
- export const settingsApiRoutes = new Hono();
8
- /** Config keys that can be modified via the settings API */ const editableKeys = Object.entries(CONFIG_FIELDS).filter(([, field])=>!field.envOnly).map(([key])=>key);
9
- const UpdateSettingsSchema = z.record(z.string(), z.string());
10
- // Get all settings (requires auth)
11
- settingsApiRoutes.get("/", requireAuthApi(), async (c)=>{
12
- const allSettings = await c.var.services.settings.getAll();
13
- // Include default values for editable keys not yet stored in DB
14
- const result = {};
15
- for (const key of editableKeys){
16
- result[key] = allSettings[key] ?? CONFIG_FIELDS[key].defaultValue;
17
- }
18
- return c.json({
19
- settings: result
20
- });
21
- });
22
- // Update settings (requires auth)
23
- settingsApiRoutes.put("/", requireAuthApi(), async (c)=>{
24
- const rawBody = await c.req.json();
25
- const parseResult = UpdateSettingsSchema.safeParse(rawBody);
26
- if (!parseResult.success) {
27
- return c.json({
28
- error: "Validation failed",
29
- details: parseResult.error.flatten()
30
- }, 400);
31
- }
32
- const updates = parseResult.data;
33
- // Filter to only editable keys
34
- const filteredUpdates = {};
35
- const rejectedKeys = [];
36
- for (const [key, value] of Object.entries(updates)){
37
- if (editableKeys.includes(key)) {
38
- filteredUpdates[key] = value;
39
- } else {
40
- rejectedKeys.push(key);
41
- }
42
- }
43
- if (rejectedKeys.length > 0 && Object.keys(filteredUpdates).length === 0) {
44
- return c.json({
45
- error: "None of the provided keys are editable",
46
- rejectedKeys
47
- }, 400);
48
- }
49
- if (Object.keys(filteredUpdates).length > 0) {
50
- // Settings service expects SettingsKey, but our ConfigKeys that are
51
- // editable (SITE_NAME, SITE_DESCRIPTION, SITE_LANGUAGE) are valid SettingsKeys
52
- for (const [key, value] of Object.entries(filteredUpdates)){
53
- await c.var.services.settings.set(key, value);
54
- }
55
- }
56
- // Return updated state
57
- const allSettings = await c.var.services.settings.getAll();
58
- const result = {};
59
- for (const key of editableKeys){
60
- result[key] = allSettings[key] ?? CONFIG_FIELDS[key].defaultValue;
61
- }
62
- return c.json({
63
- settings: result,
64
- ...rejectedKeys.length > 0 && {
65
- rejectedKeys
66
- }
67
- });
68
- });
@@ -1,246 +0,0 @@
1
- /**
2
- * Upload API Routes
3
- *
4
- * Handles file uploads to R2 storage.
5
- * Supports both JSON and SSE (Datastar) responses.
6
- */ import { Hono } from "hono";
7
- import { html } from "hono/html";
8
- import { uuidv7 } from "uuidv7";
9
- import { requireAuthApi } from "../../middleware/auth.js";
10
- import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
11
- import { sse } from "../../lib/sse.js";
12
- export const uploadApiRoutes = new Hono();
13
- // Require auth for all upload routes
14
- uploadApiRoutes.use("*", requireAuthApi());
15
- /**
16
- * Render a media card HTML string for SSE response
17
- */ function renderMediaCard(media, publicUrl, imageTransformUrl) {
18
- const fullUrl = getMediaUrl(media.storageKey, publicUrl);
19
- const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
20
- width: 300,
21
- quality: 80,
22
- format: "auto",
23
- fit: "cover"
24
- });
25
- const isImage = media.mimeType.startsWith("image/");
26
- const displayName = media.alt || media.originalName;
27
- const sizeStr = formatSize(media.size);
28
- if (isImage) {
29
- return html`
30
- <div class="group relative" data-media-id="${media.id}">
31
- <button
32
- type="button"
33
- class="block w-full aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary cursor-pointer"
34
- onclick="document.getElementById('lightbox-img').src = '${fullUrl}'; document.getElementById('lightbox').showModal()"
35
- >
36
- <img
37
- src="${thumbnailUrl}"
38
- alt="${displayName}"
39
- class="w-full h-full object-cover"
40
- loading="lazy"
41
- />
42
- </button>
43
- <a
44
- href="/dash/media/${media.id}"
45
- class="block mt-2 text-xs truncate hover:underline"
46
- title="${media.originalName}"
47
- >
48
- ${media.originalName}
49
- </a>
50
- <div class="text-xs text-muted-foreground">${sizeStr}</div>
51
- </div>
52
- `.toString();
53
- }
54
- return html`
55
- <div class="group relative" data-media-id="${media.id}">
56
- <a
57
- href="/dash/media/${media.id}"
58
- class="block aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary"
59
- >
60
- <div
61
- class="w-full h-full flex items-center justify-center text-muted-foreground"
62
- >
63
- <span class="text-xs">${media.mimeType}</span>
64
- </div>
65
- </a>
66
- <a
67
- href="/dash/media/${media.id}"
68
- class="block mt-2 text-xs truncate hover:underline"
69
- title="${media.originalName}"
70
- >
71
- ${media.originalName}
72
- </a>
73
- <div class="text-xs text-muted-foreground">${sizeStr}</div>
74
- </div>
75
- `.toString();
76
- }
77
- function formatSize(bytes) {
78
- if (bytes < 1024) return `${bytes} B`;
79
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
80
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
81
- }
82
- /**
83
- * Check if request wants SSE response (from Datastar)
84
- */ function wantsSSE(c) {
85
- const accept = c.req.header("accept") || "";
86
- return accept.includes("text/event-stream");
87
- }
88
- /**
89
- * Return an SSE error response that removes the upload placeholder and shows a toast
90
- */ function sseUploadError(c, message) {
91
- return sse(c, async (stream)=>{
92
- await stream.remove("#upload-placeholder");
93
- await stream.toast(message, "error");
94
- });
95
- }
96
- // Upload a file
97
- uploadApiRoutes.post("/", async (c)=>{
98
- const storage = c.var.storage;
99
- if (!storage) {
100
- if (wantsSSE(c)) {
101
- return sseUploadError(c, "Storage not configured");
102
- }
103
- return c.json({
104
- error: "Storage not configured"
105
- }, 500);
106
- }
107
- const formData = await c.req.formData();
108
- const file = formData.get("file");
109
- if (!file) {
110
- if (wantsSSE(c)) {
111
- return sseUploadError(c, "No file provided");
112
- }
113
- return c.json({
114
- error: "No file provided"
115
- }, 400);
116
- }
117
- // Validate file type
118
- const allowedTypes = [
119
- "image/jpeg",
120
- "image/png",
121
- "image/gif",
122
- "image/webp",
123
- "image/svg+xml"
124
- ];
125
- if (!allowedTypes.includes(file.type)) {
126
- if (wantsSSE(c)) {
127
- return sseUploadError(c, "File type not allowed");
128
- }
129
- return c.json({
130
- error: "File type not allowed"
131
- }, 400);
132
- }
133
- // Validate file size (max 10MB)
134
- const maxSize = 10 * 1024 * 1024;
135
- if (file.size > maxSize) {
136
- if (wantsSSE(c)) {
137
- return sseUploadError(c, "File too large (max 10MB)");
138
- }
139
- return c.json({
140
- error: "File too large (max 10MB)"
141
- }, 400);
142
- }
143
- // Generate unique filename using UUIDv7
144
- const ext = file.name.split(".").pop() || "bin";
145
- const id = uuidv7();
146
- const date = new Date();
147
- const year = date.getUTCFullYear();
148
- const month = String(date.getUTCMonth() + 1).padStart(2, "0");
149
- const filename = `${id}.${ext}`;
150
- const storageKey = `media/${year}/${month}/${filename}`;
151
- try {
152
- // Upload to storage
153
- await storage.put(storageKey, file.stream(), {
154
- contentType: file.type
155
- });
156
- // Save to database
157
- const media = await c.var.services.media.create({
158
- id,
159
- filename,
160
- originalName: file.name,
161
- mimeType: file.type,
162
- size: file.size,
163
- storageKey,
164
- provider: c.env.STORAGE_DRIVER || "r2"
165
- });
166
- // SSE response for Datastar
167
- if (wantsSSE(c)) {
168
- const provider = c.env.STORAGE_DRIVER || "r2";
169
- const mediaPublicUrl = getPublicUrlForProvider(provider, c.env.R2_PUBLIC_URL, c.env.S3_PUBLIC_URL);
170
- const cardHtml = renderMediaCard(media, mediaPublicUrl, c.env.IMAGE_TRANSFORM_URL);
171
- return sse(c, async (stream)=>{
172
- // Replace placeholder with real media card
173
- await stream.patchElements(cardHtml, {
174
- mode: "outer",
175
- selector: "#upload-placeholder"
176
- });
177
- await stream.toast("Upload successful!");
178
- });
179
- }
180
- // JSON response for API clients
181
- const provider = c.env.STORAGE_DRIVER || "r2";
182
- const mediaPublicUrl = getPublicUrlForProvider(provider, c.env.R2_PUBLIC_URL, c.env.S3_PUBLIC_URL);
183
- const publicUrl = getMediaUrl(storageKey, mediaPublicUrl);
184
- return c.json({
185
- id: media.id,
186
- filename: media.filename,
187
- url: publicUrl,
188
- mimeType: media.mimeType,
189
- size: media.size
190
- });
191
- } catch (err) {
192
- // eslint-disable-next-line no-console -- Error logging is intentional
193
- console.error("Upload error:", err);
194
- if (wantsSSE(c)) {
195
- return sse(c, async (stream)=>{
196
- await stream.remove("#upload-placeholder");
197
- await stream.toast("Upload failed. Please try again.", "error");
198
- });
199
- }
200
- return c.json({
201
- error: "Upload failed"
202
- }, 500);
203
- }
204
- });
205
- // List uploaded files (JSON only)
206
- uploadApiRoutes.get("/", async (c)=>{
207
- const limit = parseInt(c.req.query("limit") ?? "50", 10);
208
- const mediaList = await c.var.services.media.list(limit);
209
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
210
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
211
- return c.json({
212
- media: mediaList.map((m)=>({
213
- id: m.id,
214
- filename: m.filename,
215
- url: getMediaUrl(m.storageKey, getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl)),
216
- mimeType: m.mimeType,
217
- size: m.size,
218
- createdAt: m.createdAt
219
- }))
220
- });
221
- });
222
- // Delete a file
223
- uploadApiRoutes.delete("/:id", async (c)=>{
224
- const id = c.req.param("id");
225
- const media = await c.var.services.media.getById(id);
226
- if (!media) {
227
- return c.json({
228
- error: "Not found"
229
- }, 404);
230
- }
231
- // Delete from storage
232
- const storage = c.var.storage;
233
- if (storage) {
234
- try {
235
- await storage.delete(media.storageKey);
236
- } catch (err) {
237
- // eslint-disable-next-line no-console -- Error logging is intentional
238
- console.error("Storage delete error:", err);
239
- }
240
- }
241
- // Delete from database
242
- await c.var.services.media.delete(id);
243
- return c.json({
244
- success: true
245
- });
246
- });