@jant/core 0.3.24 → 0.3.26

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 (277) hide show
  1. package/dist/app.js +101 -571
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +1 -1
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/index.js +3 -9
  8. package/dist/lib/avatar-upload.js +134 -0
  9. package/dist/lib/config.js +39 -0
  10. package/dist/lib/constants.js +10 -9
  11. package/dist/lib/favicon.js +102 -0
  12. package/dist/lib/image.js +13 -17
  13. package/dist/lib/media-helpers.js +2 -2
  14. package/dist/lib/nav-reorder.js +1 -1
  15. package/dist/lib/navigation.js +48 -3
  16. package/dist/lib/pagination.js +44 -0
  17. package/dist/lib/render.js +16 -11
  18. package/dist/lib/schemas.js +34 -3
  19. package/dist/lib/theme.js +4 -4
  20. package/dist/lib/timeline.js +24 -48
  21. package/dist/lib/timezones.js +388 -0
  22. package/dist/lib/view.js +3 -3
  23. package/dist/routes/api/collections.js +124 -0
  24. package/dist/routes/api/nav-items.js +104 -0
  25. package/dist/routes/api/pages.js +91 -0
  26. package/dist/routes/api/posts.js +3 -3
  27. package/dist/routes/api/search.js +2 -2
  28. package/dist/routes/api/settings.js +68 -0
  29. package/dist/routes/api/upload.js +3 -3
  30. package/dist/routes/auth/reset.js +221 -0
  31. package/dist/routes/auth/setup.js +194 -0
  32. package/dist/routes/auth/signin.js +176 -0
  33. package/dist/routes/compose.js +48 -0
  34. package/dist/routes/dash/collections.js +24 -416
  35. package/dist/routes/dash/index.js +1 -1
  36. package/dist/routes/dash/media.js +13 -393
  37. package/dist/routes/dash/pages.js +112 -86
  38. package/dist/routes/dash/posts.js +3 -5
  39. package/dist/routes/dash/redirects.js +20 -14
  40. package/dist/routes/dash/settings.js +213 -518
  41. package/dist/routes/feed/rss.js +4 -3
  42. package/dist/routes/feed/sitemap.js +5 -3
  43. package/dist/routes/pages/archive.js +3 -6
  44. package/dist/routes/pages/collection.js +3 -6
  45. package/dist/routes/pages/collections.js +28 -0
  46. package/dist/routes/pages/featured.js +36 -0
  47. package/dist/routes/pages/home.js +33 -49
  48. package/dist/routes/pages/latest.js +45 -0
  49. package/dist/routes/pages/page.js +29 -32
  50. package/dist/routes/pages/post.js +3 -6
  51. package/dist/routes/pages/search.js +3 -6
  52. package/dist/services/page.js +5 -1
  53. package/dist/services/post.js +45 -31
  54. package/dist/services/search.js +1 -1
  55. package/dist/types/bindings.js +3 -0
  56. package/dist/types/config.js +147 -0
  57. package/dist/types/constants.js +27 -0
  58. package/dist/types/entities.js +3 -0
  59. package/dist/types/operations.js +3 -0
  60. package/dist/types/props.js +3 -0
  61. package/dist/types/views.js +5 -0
  62. package/dist/types.js +8 -111
  63. package/dist/{theme → ui}/color-themes.js +33 -33
  64. package/dist/ui/compose/ComposeDialog.js +467 -0
  65. package/dist/ui/compose/ComposePrompt.js +55 -0
  66. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
  67. package/dist/{theme/components → ui/dash}/PageForm.js +21 -15
  68. package/dist/{theme/components → ui/dash}/PostForm.js +22 -43
  69. package/dist/{theme/components → ui/dash}/PostList.js +6 -6
  70. package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
  71. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  72. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  73. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  74. package/dist/{theme/components → ui/dash}/index.js +3 -6
  75. package/dist/ui/dash/media/MediaListContent.js +166 -0
  76. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  77. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  78. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  79. package/dist/ui/dash/settings/AccountContent.js +209 -0
  80. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  81. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  82. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  83. package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
  84. package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
  85. package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
  86. package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
  87. package/dist/ui/feed/TimelineFeed.js +41 -0
  88. package/dist/ui/feed/TimelineItem.js +27 -0
  89. package/dist/ui/font-themes.js +36 -0
  90. package/dist/{theme → ui}/layouts/BaseLayout.js +34 -2
  91. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  92. package/dist/ui/layouts/SiteLayout.js +169 -0
  93. package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
  94. package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
  95. package/dist/ui/pages/CollectionsPage.js +76 -0
  96. package/dist/ui/pages/FeaturedPage.js +24 -0
  97. package/dist/ui/pages/HomePage.js +24 -0
  98. package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
  99. package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
  100. package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
  101. package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
  102. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  103. package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
  104. package/dist/ui/shared/index.js +5 -0
  105. package/package.json +1 -9
  106. package/src/__tests__/helpers/db.ts +3 -0
  107. package/src/app.tsx +131 -561
  108. package/src/client.ts +1 -0
  109. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  110. package/src/db/migrations/meta/_journal.json +7 -0
  111. package/src/db/schema.ts +1 -1
  112. package/src/i18n/locales/en.po +477 -261
  113. package/src/i18n/locales/en.ts +1 -1
  114. package/src/i18n/locales/zh-Hans.po +477 -261
  115. package/src/i18n/locales/zh-Hans.ts +1 -1
  116. package/src/i18n/locales/zh-Hant.po +477 -261
  117. package/src/i18n/locales/zh-Hant.ts +1 -1
  118. package/src/index.ts +7 -36
  119. package/src/lib/__tests__/config.test.ts +192 -0
  120. package/src/lib/__tests__/favicon.test.ts +151 -0
  121. package/src/lib/__tests__/image.test.ts +2 -6
  122. package/src/lib/__tests__/schemas.test.ts +60 -19
  123. package/src/lib/__tests__/timeline.test.ts +45 -81
  124. package/src/lib/__tests__/timezones.test.ts +61 -0
  125. package/src/lib/__tests__/view.test.ts +15 -9
  126. package/src/lib/avatar-upload.ts +165 -0
  127. package/src/lib/config.ts +47 -0
  128. package/src/lib/constants.ts +19 -10
  129. package/src/lib/favicon.ts +115 -0
  130. package/src/lib/image.ts +13 -21
  131. package/src/lib/media-helpers.ts +2 -2
  132. package/src/lib/nav-reorder.ts +1 -1
  133. package/src/lib/navigation.ts +73 -4
  134. package/src/lib/pagination.ts +50 -0
  135. package/src/lib/render.tsx +22 -15
  136. package/src/lib/schemas.ts +47 -6
  137. package/src/lib/theme.ts +5 -5
  138. package/src/lib/timeline.ts +28 -57
  139. package/src/lib/timezones.ts +325 -0
  140. package/src/lib/view.ts +3 -3
  141. package/src/preset.css +2 -1
  142. package/src/routes/__tests__/compose.test.ts +199 -0
  143. package/src/routes/api/__tests__/collections.test.ts +249 -0
  144. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  145. package/src/routes/api/__tests__/pages.test.ts +218 -0
  146. package/src/routes/api/__tests__/settings.test.ts +132 -0
  147. package/src/routes/api/collections.ts +143 -0
  148. package/src/routes/api/nav-items.ts +115 -0
  149. package/src/routes/api/pages.ts +101 -0
  150. package/src/routes/api/posts.ts +3 -3
  151. package/src/routes/api/search.ts +2 -2
  152. package/src/routes/api/settings.ts +91 -0
  153. package/src/routes/api/upload.ts +2 -3
  154. package/src/routes/auth/reset.tsx +239 -0
  155. package/src/routes/auth/setup.tsx +189 -0
  156. package/src/routes/auth/signin.tsx +163 -0
  157. package/src/routes/compose.ts +63 -0
  158. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  159. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  160. package/src/routes/dash/collections.tsx +18 -367
  161. package/src/routes/dash/index.tsx +1 -1
  162. package/src/routes/dash/media.tsx +13 -415
  163. package/src/routes/dash/pages.tsx +131 -98
  164. package/src/routes/dash/posts.tsx +3 -7
  165. package/src/routes/dash/redirects.tsx +22 -16
  166. package/src/routes/dash/settings.tsx +265 -478
  167. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  168. package/src/routes/feed/rss.ts +5 -3
  169. package/src/routes/feed/sitemap.ts +5 -3
  170. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  171. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  172. package/src/routes/pages/archive.tsx +2 -6
  173. package/src/routes/pages/collection.tsx +2 -6
  174. package/src/routes/pages/collections.tsx +36 -0
  175. package/src/routes/pages/featured.tsx +44 -0
  176. package/src/routes/pages/home.tsx +30 -53
  177. package/src/routes/pages/latest.tsx +59 -0
  178. package/src/routes/pages/page.tsx +28 -30
  179. package/src/routes/pages/post.tsx +2 -5
  180. package/src/routes/pages/search.tsx +2 -6
  181. package/src/services/__tests__/page.test.ts +106 -0
  182. package/src/services/__tests__/post.test.ts +114 -15
  183. package/src/services/page.ts +13 -1
  184. package/src/services/post.ts +58 -40
  185. package/src/services/search.ts +2 -2
  186. package/src/styles/components.css +0 -65
  187. package/src/styles/tokens.css +47 -0
  188. package/src/styles/ui.css +475 -0
  189. package/src/types/bindings.ts +30 -0
  190. package/src/types/config.ts +183 -0
  191. package/src/types/constants.ts +26 -0
  192. package/src/types/entities.ts +109 -0
  193. package/src/types/operations.ts +88 -0
  194. package/src/types/props.ts +115 -0
  195. package/src/types/views.ts +172 -0
  196. package/src/types.ts +8 -774
  197. package/src/ui/__tests__/font-themes.test.ts +34 -0
  198. package/src/{theme → ui}/color-themes.ts +34 -34
  199. package/src/ui/compose/ComposeDialog.tsx +414 -0
  200. package/src/ui/compose/ComposePrompt.tsx +55 -0
  201. package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
  202. package/src/{theme/components → ui/dash}/PageForm.tsx +25 -19
  203. package/src/{theme/components → ui/dash}/PostForm.tsx +26 -45
  204. package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
  205. package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
  206. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  207. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  208. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  209. package/src/ui/dash/index.ts +10 -0
  210. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  211. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  212. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  213. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  214. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  215. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  216. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  217. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  218. package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
  219. package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
  220. package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
  221. package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
  222. package/src/ui/feed/TimelineFeed.tsx +49 -0
  223. package/src/ui/feed/TimelineItem.tsx +45 -0
  224. package/src/ui/font-themes.ts +54 -0
  225. package/src/{theme → ui}/layouts/BaseLayout.tsx +28 -1
  226. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  227. package/src/ui/layouts/SiteLayout.tsx +164 -0
  228. package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
  229. package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
  230. package/src/ui/pages/CollectionsPage.tsx +73 -0
  231. package/src/ui/pages/FeaturedPage.tsx +31 -0
  232. package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
  233. package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
  234. package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
  235. package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
  236. package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
  237. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  238. package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
  239. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  240. package/src/ui/shared/index.ts +12 -0
  241. package/bin/jant.js +0 -185
  242. package/dist/lib/theme-components.js +0 -46
  243. package/dist/routes/dash/navigation.js +0 -289
  244. package/dist/theme/index.js +0 -18
  245. package/dist/theme/layouts/index.js +0 -2
  246. package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
  247. package/dist/themes/threads/index.js +0 -81
  248. package/dist/themes/threads/pages/HomePage.js +0 -25
  249. package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
  250. package/dist/themes/threads/timeline/TimelineItem.js +0 -36
  251. package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
  252. package/dist/themes/threads/timeline/groupByDate.js +0 -22
  253. package/dist/themes/threads/timeline/timelineMore.js +0 -107
  254. package/src/lib/__tests__/theme-components.test.ts +0 -105
  255. package/src/lib/theme-components.ts +0 -65
  256. package/src/routes/dash/navigation.tsx +0 -317
  257. package/src/theme/components/index.ts +0 -23
  258. package/src/theme/index.ts +0 -22
  259. package/src/theme/layouts/index.ts +0 -7
  260. package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
  261. package/src/themes/threads/index.ts +0 -100
  262. package/src/themes/threads/style.css +0 -336
  263. package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
  264. package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
  265. package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
  266. package/src/themes/threads/timeline/groupByDate.ts +0 -30
  267. package/src/themes/threads/timeline/timelineMore.tsx +0 -130
  268. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  269. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  270. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  271. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  272. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  273. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  274. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  275. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  276. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  277. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { settingsApiRoutes } from "../settings.js";
4
+
5
+ describe("Settings API Routes", () => {
6
+ describe("GET /api/settings", () => {
7
+ it("returns 401 when not authenticated", async () => {
8
+ const { app } = createTestApp({ authenticated: false });
9
+ app.route("/api/settings", settingsApiRoutes);
10
+
11
+ const res = await app.request("/api/settings");
12
+ expect(res.status).toBe(401);
13
+ });
14
+
15
+ it("returns default settings when none are stored", async () => {
16
+ const { app } = createTestApp({ authenticated: true });
17
+ app.route("/api/settings", settingsApiRoutes);
18
+
19
+ const res = await app.request("/api/settings");
20
+ expect(res.status).toBe(200);
21
+
22
+ const body = await res.json();
23
+ expect(body.settings).toBeDefined();
24
+ expect(body.settings.SITE_NAME).toBe("Jant");
25
+ expect(body.settings.SITE_DESCRIPTION).toBe(
26
+ "A microblog powered by Jant",
27
+ );
28
+ expect(body.settings.SITE_LANGUAGE).toBe("en");
29
+ });
30
+
31
+ it("returns stored settings overriding defaults", async () => {
32
+ const { app, services } = createTestApp({ authenticated: true });
33
+ app.route("/api/settings", settingsApiRoutes);
34
+
35
+ await services.settings.set("SITE_NAME" as never, "My Blog");
36
+
37
+ const res = await app.request("/api/settings");
38
+ const body = await res.json();
39
+
40
+ expect(body.settings.SITE_NAME).toBe("My Blog");
41
+ });
42
+
43
+ it("does not include env-only settings", async () => {
44
+ const { app } = createTestApp({ authenticated: true });
45
+ app.route("/api/settings", settingsApiRoutes);
46
+
47
+ const res = await app.request("/api/settings");
48
+ const body = await res.json();
49
+
50
+ // Env-only keys should not be in the response
51
+ expect(body.settings.AUTH_SECRET).toBeUndefined();
52
+ expect(body.settings.SITE_URL).toBeUndefined();
53
+ });
54
+ });
55
+
56
+ describe("PUT /api/settings", () => {
57
+ it("returns 401 when not authenticated", async () => {
58
+ const { app } = createTestApp({ authenticated: false });
59
+ app.route("/api/settings", settingsApiRoutes);
60
+
61
+ const res = await app.request("/api/settings", {
62
+ method: "PUT",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ SITE_NAME: "New Name" }),
65
+ });
66
+
67
+ expect(res.status).toBe(401);
68
+ });
69
+
70
+ it("updates editable settings", async () => {
71
+ const { app } = createTestApp({ authenticated: true });
72
+ app.route("/api/settings", settingsApiRoutes);
73
+
74
+ const res = await app.request("/api/settings", {
75
+ method: "PUT",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify({ SITE_NAME: "Updated Blog" }),
78
+ });
79
+
80
+ expect(res.status).toBe(200);
81
+ const body = await res.json();
82
+ expect(body.settings.SITE_NAME).toBe("Updated Blog");
83
+ });
84
+
85
+ it("rejects env-only keys", async () => {
86
+ const { app } = createTestApp({ authenticated: true });
87
+ app.route("/api/settings", settingsApiRoutes);
88
+
89
+ const res = await app.request("/api/settings", {
90
+ method: "PUT",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ AUTH_SECRET: "should-not-work" }),
93
+ });
94
+
95
+ expect(res.status).toBe(400);
96
+ const body = await res.json();
97
+ expect(body.rejectedKeys).toContain("AUTH_SECRET");
98
+ });
99
+
100
+ it("partially applies when mixing editable and env-only keys", async () => {
101
+ const { app } = createTestApp({ authenticated: true });
102
+ app.route("/api/settings", settingsApiRoutes);
103
+
104
+ const res = await app.request("/api/settings", {
105
+ method: "PUT",
106
+ headers: { "Content-Type": "application/json" },
107
+ body: JSON.stringify({
108
+ SITE_NAME: "Mixed Update",
109
+ AUTH_SECRET: "ignored",
110
+ }),
111
+ });
112
+
113
+ expect(res.status).toBe(200);
114
+ const body = await res.json();
115
+ expect(body.settings.SITE_NAME).toBe("Mixed Update");
116
+ expect(body.rejectedKeys).toContain("AUTH_SECRET");
117
+ });
118
+
119
+ it("returns 400 for invalid body", async () => {
120
+ const { app } = createTestApp({ authenticated: true });
121
+ app.route("/api/settings", settingsApiRoutes);
122
+
123
+ const res = await app.request("/api/settings", {
124
+ method: "PUT",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify("not an object"),
127
+ });
128
+
129
+ expect(res.status).toBe(400);
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Collections API Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { Bindings, SortOrder } from "../../types.js";
7
+ import type { AppVariables } from "../../app.js";
8
+ import { requireAuthApi } from "../../middleware/auth.js";
9
+ import { z } from "zod";
10
+ import { SORT_ORDERS } from "../../types.js";
11
+
12
+ type Env = { Bindings: Bindings; Variables: AppVariables };
13
+
14
+ export const collectionsApiRoutes = new Hono<Env>();
15
+
16
+ const SortOrderSchema = z.enum(SORT_ORDERS);
17
+
18
+ const CreateCollectionSchema = z.object({
19
+ slug: z.string().min(1),
20
+ title: z.string().min(1),
21
+ description: z.string().optional(),
22
+ icon: z.string().optional(),
23
+ sortOrder: SortOrderSchema.optional(),
24
+ position: z.number().int().min(0).optional(),
25
+ showDivider: z.boolean().optional(),
26
+ });
27
+
28
+ const UpdateCollectionSchema = z.object({
29
+ slug: z.string().min(1).optional(),
30
+ title: z.string().min(1).optional(),
31
+ description: z.string().nullable().optional(),
32
+ icon: z.string().nullable().optional(),
33
+ sortOrder: SortOrderSchema.optional(),
34
+ position: z.number().int().min(0).optional(),
35
+ showDivider: z.boolean().optional(),
36
+ });
37
+
38
+ const ReorderSchema = z.object({
39
+ ids: z.array(z.number().int().positive()),
40
+ });
41
+
42
+ // List collections (includes post counts)
43
+ collectionsApiRoutes.get("/", async (c) => {
44
+ const collections = await c.var.services.collections.list();
45
+ const postCounts = await c.var.services.collections.getPostCounts();
46
+
47
+ return c.json({
48
+ collections: collections.map((col) => ({
49
+ ...col,
50
+ postCount: postCounts.get(col.id) ?? 0,
51
+ })),
52
+ });
53
+ });
54
+
55
+ // Get single collection
56
+ collectionsApiRoutes.get("/:id", async (c) => {
57
+ const id = parseInt(c.req.param("id"), 10);
58
+ if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
59
+
60
+ const collection = await c.var.services.collections.getById(id);
61
+ if (!collection) return c.json({ error: "Not found" }, 404);
62
+
63
+ return c.json(collection);
64
+ });
65
+
66
+ // Reorder collections (requires auth) — must be before /:id
67
+ collectionsApiRoutes.put("/reorder", requireAuthApi(), async (c) => {
68
+ const rawBody = await c.req.json();
69
+
70
+ const parseResult = ReorderSchema.safeParse(rawBody);
71
+ if (!parseResult.success) {
72
+ return c.json(
73
+ { error: "Validation failed", details: parseResult.error.flatten() },
74
+ 400,
75
+ );
76
+ }
77
+
78
+ await c.var.services.collections.reorder(parseResult.data.ids);
79
+ const collections = await c.var.services.collections.list();
80
+ return c.json({ collections });
81
+ });
82
+
83
+ // Create collection (requires auth)
84
+ collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
85
+ const rawBody = await c.req.json();
86
+
87
+ const parseResult = CreateCollectionSchema.safeParse(rawBody);
88
+ if (!parseResult.success) {
89
+ return c.json(
90
+ { error: "Validation failed", details: parseResult.error.flatten() },
91
+ 400,
92
+ );
93
+ }
94
+
95
+ const body = parseResult.data;
96
+
97
+ const collection = await c.var.services.collections.create({
98
+ slug: body.slug,
99
+ title: body.title,
100
+ description: body.description,
101
+ icon: body.icon,
102
+ sortOrder: body.sortOrder as SortOrder | undefined,
103
+ position: body.position,
104
+ showDivider: body.showDivider,
105
+ });
106
+
107
+ return c.json(collection, 201);
108
+ });
109
+
110
+ // Update collection (requires auth)
111
+ collectionsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
112
+ const id = parseInt(c.req.param("id"), 10);
113
+ if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
114
+
115
+ const rawBody = await c.req.json();
116
+
117
+ const parseResult = UpdateCollectionSchema.safeParse(rawBody);
118
+ if (!parseResult.success) {
119
+ return c.json(
120
+ { error: "Validation failed", details: parseResult.error.flatten() },
121
+ 400,
122
+ );
123
+ }
124
+
125
+ const collection = await c.var.services.collections.update(
126
+ id,
127
+ parseResult.data,
128
+ );
129
+ if (!collection) return c.json({ error: "Not found" }, 404);
130
+
131
+ return c.json(collection);
132
+ });
133
+
134
+ // Delete collection (requires auth)
135
+ collectionsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
136
+ const id = parseInt(c.req.param("id"), 10);
137
+ if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
138
+
139
+ const success = await c.var.services.collections.delete(id);
140
+ if (!success) return c.json({ error: "Not found" }, 404);
141
+
142
+ return c.json({ success: true });
143
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Nav Items API Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { Bindings, NavItemType } from "../../types.js";
7
+ import type { AppVariables } from "../../app.js";
8
+ import { requireAuthApi } from "../../middleware/auth.js";
9
+ import { z } from "zod";
10
+
11
+ type Env = { Bindings: Bindings; Variables: AppVariables };
12
+
13
+ export const navItemsApiRoutes = new Hono<Env>();
14
+
15
+ const NavItemTypeSchema = z.enum(["link", "page"]);
16
+
17
+ const CreateNavItemSchema = z.object({
18
+ type: NavItemTypeSchema,
19
+ label: z.string().min(1),
20
+ url: z.string().min(1),
21
+ pageId: z.number().int().positive().optional(),
22
+ position: z.number().int().min(0).optional(),
23
+ });
24
+
25
+ const UpdateNavItemSchema = z.object({
26
+ type: NavItemTypeSchema.optional(),
27
+ label: z.string().min(1).optional(),
28
+ url: z.string().min(1).optional(),
29
+ pageId: z.number().int().positive().nullable().optional(),
30
+ position: z.number().int().min(0).optional(),
31
+ });
32
+
33
+ const ReorderSchema = z.object({
34
+ ids: z.array(z.number().int().positive()),
35
+ });
36
+
37
+ // List nav items
38
+ navItemsApiRoutes.get("/", async (c) => {
39
+ const items = await c.var.services.navItems.list();
40
+ return c.json({ navItems: items });
41
+ });
42
+
43
+ // Reorder nav items (requires auth) — must be before /:id
44
+ navItemsApiRoutes.put("/reorder", requireAuthApi(), async (c) => {
45
+ const rawBody = await c.req.json();
46
+
47
+ const parseResult = ReorderSchema.safeParse(rawBody);
48
+ if (!parseResult.success) {
49
+ return c.json(
50
+ { error: "Validation failed", details: parseResult.error.flatten() },
51
+ 400,
52
+ );
53
+ }
54
+
55
+ await c.var.services.navItems.reorder(parseResult.data.ids);
56
+ const items = await c.var.services.navItems.list();
57
+ return c.json({ navItems: items });
58
+ });
59
+
60
+ // Create nav item (requires auth)
61
+ navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
62
+ const rawBody = await c.req.json();
63
+
64
+ const parseResult = CreateNavItemSchema.safeParse(rawBody);
65
+ if (!parseResult.success) {
66
+ return c.json(
67
+ { error: "Validation failed", details: parseResult.error.flatten() },
68
+ 400,
69
+ );
70
+ }
71
+
72
+ const body = parseResult.data;
73
+
74
+ const item = await c.var.services.navItems.create({
75
+ type: body.type as NavItemType,
76
+ label: body.label,
77
+ url: body.url,
78
+ pageId: body.pageId,
79
+ position: body.position,
80
+ });
81
+
82
+ return c.json(item, 201);
83
+ });
84
+
85
+ // Update nav item (requires auth)
86
+ navItemsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
87
+ const id = parseInt(c.req.param("id"), 10);
88
+ if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
89
+
90
+ const rawBody = await c.req.json();
91
+
92
+ const parseResult = UpdateNavItemSchema.safeParse(rawBody);
93
+ if (!parseResult.success) {
94
+ return c.json(
95
+ { error: "Validation failed", details: parseResult.error.flatten() },
96
+ 400,
97
+ );
98
+ }
99
+
100
+ const item = await c.var.services.navItems.update(id, parseResult.data);
101
+ if (!item) return c.json({ error: "Not found" }, 404);
102
+
103
+ return c.json(item);
104
+ });
105
+
106
+ // Delete nav item (requires auth)
107
+ navItemsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
108
+ const id = parseInt(c.req.param("id"), 10);
109
+ if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
110
+
111
+ const success = await c.var.services.navItems.delete(id);
112
+ if (!success) return c.json({ error: "Not found" }, 404);
113
+
114
+ return c.json({ success: true });
115
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Pages API Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { Bindings } from "../../types.js";
7
+ import type { AppVariables } from "../../app.js";
8
+ import { requireAuthApi } from "../../middleware/auth.js";
9
+ import { z } from "zod";
10
+ import { StatusSchema } from "../../lib/schemas.js";
11
+
12
+ type Env = { Bindings: Bindings; Variables: AppVariables };
13
+
14
+ export const pagesApiRoutes = new Hono<Env>();
15
+
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(),
25
+ title: z.string().nullable().optional(),
26
+ body: z.string().nullable().optional(),
27
+ status: StatusSchema.optional(),
28
+ });
29
+
30
+ // List pages
31
+ pagesApiRoutes.get("/", async (c) => {
32
+ const pages = await c.var.services.pages.list();
33
+ return c.json({ pages });
34
+ });
35
+
36
+ // Get single page
37
+ 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
+
44
+ return c.json(page);
45
+ });
46
+
47
+ // Create page (requires auth)
48
+ 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;
60
+
61
+ const page = await c.var.services.pages.create({
62
+ slug: body.slug,
63
+ title: body.title,
64
+ body: body.body,
65
+ status: body.status,
66
+ });
67
+
68
+ return c.json(page, 201);
69
+ });
70
+
71
+ // Update page (requires auth)
72
+ 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
+ }
85
+
86
+ const page = await c.var.services.pages.update(id, parseResult.data);
87
+ if (!page) return c.json({ error: "Not found" }, 404);
88
+
89
+ return c.json(page);
90
+ });
91
+
92
+ // Delete page (requires auth)
93
+ 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);
96
+
97
+ const success = await c.var.services.pages.delete(id);
98
+ if (!success) return c.json({ error: "Not found" }, 404);
99
+
100
+ return c.json({ success: true });
101
+ });
@@ -36,7 +36,7 @@ function toMediaAttachment(
36
36
  r2PublicUrl,
37
37
  s3PublicUrl,
38
38
  );
39
- const url = getMediaUrl(m.id, m.storageKey, publicUrl);
39
+ const url = getMediaUrl(m.storageKey, publicUrl);
40
40
  const previewUrl = getImageUrl(url, imageTransformUrl, {
41
41
  width: 400,
42
42
  quality: 80,
@@ -151,7 +151,7 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
151
151
  format: body.format,
152
152
  title: body.title,
153
153
  body: body.body,
154
- slug: body.slug || undefined,
154
+ path: body.path || undefined,
155
155
  status: body.status,
156
156
  featured: body.featured,
157
157
  pinned: body.pinned,
@@ -225,7 +225,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
225
225
  format: body.format,
226
226
  title: body.title,
227
227
  body: body.body,
228
- slug: body.slug,
228
+ path: body.path,
229
229
  status: body.status,
230
230
  featured: body.featured,
231
231
  pinned: body.pinned,
@@ -38,10 +38,10 @@ searchApiRoutes.get("/", async (c) => {
38
38
  id: sqid.encode(r.post.id),
39
39
  format: r.post.format,
40
40
  title: r.post.title,
41
- slug: r.post.slug,
41
+ path: r.post.path,
42
42
  snippet: r.snippet,
43
43
  publishedAt: r.post.publishedAt,
44
- url: r.post.slug ? `/${r.post.slug}` : `/p/${sqid.encode(r.post.id)}`,
44
+ url: r.post.path ? `/${r.post.path}` : `/p/${sqid.encode(r.post.id)}`,
45
45
  })),
46
46
  count: results.length,
47
47
  });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Settings API Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { Bindings } from "../../types.js";
7
+ import type { AppVariables } from "../../app.js";
8
+ import { requireAuthApi } from "../../middleware/auth.js";
9
+ import { CONFIG_FIELDS, type ConfigKey } from "../../types.js";
10
+ import { z } from "zod";
11
+
12
+ type Env = { Bindings: Bindings; Variables: AppVariables };
13
+
14
+ export const settingsApiRoutes = new Hono<Env>();
15
+
16
+ /** Config keys that can be modified via the settings API */
17
+ const editableKeys = Object.entries(CONFIG_FIELDS)
18
+ .filter(([, field]) => !field.envOnly)
19
+ .map(([key]) => key as ConfigKey);
20
+
21
+ const UpdateSettingsSchema = z.record(z.string(), z.string());
22
+
23
+ // Get all settings (requires auth)
24
+ settingsApiRoutes.get("/", requireAuthApi(), async (c) => {
25
+ const allSettings = await c.var.services.settings.getAll();
26
+
27
+ // Include default values for editable keys not yet stored in DB
28
+ const result: Record<string, string> = {};
29
+ for (const key of editableKeys) {
30
+ result[key] = allSettings[key] ?? CONFIG_FIELDS[key].defaultValue;
31
+ }
32
+
33
+ return c.json({ settings: result });
34
+ });
35
+
36
+ // Update settings (requires auth)
37
+ 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;
49
+
50
+ // Filter to only editable keys
51
+ const filteredUpdates: Partial<Record<ConfigKey, string>> = {};
52
+ const rejectedKeys: string[] = [];
53
+
54
+ for (const [key, value] of Object.entries(updates)) {
55
+ if (editableKeys.includes(key as ConfigKey)) {
56
+ filteredUpdates[key as ConfigKey] = value;
57
+ } else {
58
+ rejectedKeys.push(key);
59
+ }
60
+ }
61
+
62
+ 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
+ );
70
+ }
71
+
72
+ 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
+ }
78
+ }
79
+
80
+ // Return updated state
81
+ const allSettings = await c.var.services.settings.getAll();
82
+ const result: Record<string, string> = {};
83
+ for (const key of editableKeys) {
84
+ result[key] = allSettings[key] ?? CONFIG_FIELDS[key].defaultValue;
85
+ }
86
+
87
+ return c.json({
88
+ settings: result,
89
+ ...(rejectedKeys.length > 0 && { rejectedKeys }),
90
+ });
91
+ });
@@ -40,7 +40,7 @@ function renderMediaCard(
40
40
  publicUrl?: string,
41
41
  imageTransformUrl?: string,
42
42
  ): string {
43
- const fullUrl = getMediaUrl(media.id, media.storageKey, publicUrl);
43
+ const fullUrl = getMediaUrl(media.storageKey, publicUrl);
44
44
  const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
45
45
  width: 300,
46
46
  quality: 80,
@@ -229,7 +229,7 @@ uploadApiRoutes.post("/", async (c) => {
229
229
  c.env.R2_PUBLIC_URL,
230
230
  c.env.S3_PUBLIC_URL,
231
231
  );
232
- const publicUrl = getMediaUrl(media.id, storageKey, mediaPublicUrl);
232
+ const publicUrl = getMediaUrl(storageKey, mediaPublicUrl);
233
233
  return c.json({
234
234
  id: media.id,
235
235
  filename: media.filename,
@@ -263,7 +263,6 @@ uploadApiRoutes.get("/", async (c) => {
263
263
  id: m.id,
264
264
  filename: m.filename,
265
265
  url: getMediaUrl(
266
- m.id,
267
266
  m.storageKey,
268
267
  getPublicUrlForProvider(m.provider, r2PublicUrl, s3PublicUrl),
269
268
  ),