@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
@@ -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
- });