@jant/core 0.3.23 → 0.3.25

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 (248) hide show
  1. package/dist/app.js +50 -26
  2. package/dist/db/schema.js +72 -47
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +5 -11
  7. package/dist/lib/constants.js +2 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/nav-reorder.js +1 -1
  11. package/dist/lib/navigation.js +30 -6
  12. package/dist/lib/pagination.js +44 -0
  13. package/dist/lib/render.js +7 -11
  14. package/dist/lib/schemas.js +80 -38
  15. package/dist/lib/theme.js +4 -4
  16. package/dist/lib/time.js +56 -1
  17. package/dist/lib/timeline.js +95 -0
  18. package/dist/lib/view.js +61 -72
  19. package/dist/routes/api/collections.js +124 -0
  20. package/dist/routes/api/nav-items.js +104 -0
  21. package/dist/routes/api/pages.js +91 -0
  22. package/dist/routes/api/posts.js +27 -33
  23. package/dist/routes/api/search.js +4 -5
  24. package/dist/routes/api/settings.js +68 -0
  25. package/dist/routes/api/upload.js +13 -13
  26. package/dist/routes/compose.js +48 -0
  27. package/dist/routes/dash/collections.js +24 -42
  28. package/dist/routes/dash/index.js +3 -3
  29. package/dist/routes/dash/media.js +2 -2
  30. package/dist/routes/dash/pages.js +440 -106
  31. package/dist/routes/dash/posts.js +27 -37
  32. package/dist/routes/dash/redirects.js +2 -2
  33. package/dist/routes/dash/settings.js +79 -5
  34. package/dist/routes/feed/rss.js +4 -6
  35. package/dist/routes/feed/sitemap.js +11 -8
  36. package/dist/routes/pages/archive.js +13 -15
  37. package/dist/routes/pages/collection.js +12 -9
  38. package/dist/routes/pages/collections.js +28 -0
  39. package/dist/routes/pages/featured.js +32 -0
  40. package/dist/routes/pages/home.js +19 -68
  41. package/dist/routes/pages/page.js +57 -29
  42. package/dist/routes/pages/post.js +7 -17
  43. package/dist/routes/pages/search.js +5 -9
  44. package/dist/services/collection.js +52 -64
  45. package/dist/services/index.js +5 -3
  46. package/dist/services/navigation.js +29 -53
  47. package/dist/services/page.js +84 -0
  48. package/dist/services/post.js +102 -69
  49. package/dist/services/search.js +24 -18
  50. package/dist/types.js +24 -40
  51. package/dist/ui/compose/ComposeDialog.js +452 -0
  52. package/dist/ui/compose/ComposePrompt.js +55 -0
  53. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +3 -15
  54. package/dist/{theme/components → ui/dash}/PageForm.js +15 -15
  55. package/dist/{theme/components → ui/dash}/PostForm.js +117 -137
  56. package/dist/{theme/components → ui/dash}/PostList.js +18 -13
  57. package/dist/ui/dash/StatusBadge.js +46 -0
  58. package/dist/{theme/components → ui/dash}/index.js +3 -6
  59. package/dist/ui/feed/LinkCard.js +72 -0
  60. package/dist/ui/feed/NoteCard.js +58 -0
  61. package/dist/{themes/minimal/timeline → ui/feed}/QuoteCard.js +29 -14
  62. package/dist/{themes/minimal/timeline → ui/feed}/ThreadPreview.js +20 -18
  63. package/dist/ui/feed/TimelineFeed.js +41 -0
  64. package/dist/ui/feed/TimelineItem.js +27 -0
  65. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  66. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  67. package/dist/ui/layouts/SiteLayout.js +141 -0
  68. package/dist/{themes/minimal → ui}/pages/ArchivePage.js +37 -50
  69. package/dist/ui/pages/CollectionPage.js +70 -0
  70. package/dist/ui/pages/CollectionsPage.js +76 -0
  71. package/dist/ui/pages/FeaturedPage.js +24 -0
  72. package/dist/ui/pages/HomePage.js +24 -0
  73. package/dist/{themes/minimal → ui}/pages/PostPage.js +20 -12
  74. package/dist/{themes/minimal → ui}/pages/SearchPage.js +19 -18
  75. package/dist/{themes/minimal → ui}/pages/SinglePage.js +5 -4
  76. package/dist/ui/shared/MediaGallery.js +35 -0
  77. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  78. package/dist/{theme/components → ui/shared}/ThreadView.js +3 -3
  79. package/dist/ui/shared/index.js +5 -0
  80. package/package.json +2 -9
  81. package/src/__tests__/helpers/app.ts +4 -0
  82. package/src/__tests__/helpers/db.ts +53 -73
  83. package/src/app.tsx +56 -28
  84. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  85. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  86. package/src/db/migrations/meta/_journal.json +14 -0
  87. package/src/db/schema.ts +63 -46
  88. package/src/i18n/locales/en.po +443 -240
  89. package/src/i18n/locales/en.ts +1 -1
  90. package/src/i18n/locales/zh-Hans.po +443 -240
  91. package/src/i18n/locales/zh-Hans.ts +1 -1
  92. package/src/i18n/locales/zh-Hant.po +443 -240
  93. package/src/i18n/locales/zh-Hant.ts +1 -1
  94. package/src/index.ts +29 -42
  95. package/src/lib/__tests__/excerpt.test.ts +125 -0
  96. package/src/lib/__tests__/schemas.test.ts +201 -99
  97. package/src/lib/__tests__/time.test.ts +62 -0
  98. package/src/{routes/api → lib}/__tests__/timeline.test.ts +81 -75
  99. package/src/lib/__tests__/view.test.ts +204 -50
  100. package/src/lib/constants.ts +2 -4
  101. package/src/lib/excerpt.ts +87 -0
  102. package/src/lib/feed.ts +22 -7
  103. package/src/lib/nav-reorder.ts +1 -1
  104. package/src/lib/navigation.ts +45 -8
  105. package/src/lib/pagination.ts +50 -0
  106. package/src/lib/render.tsx +7 -14
  107. package/src/lib/schemas.ts +119 -51
  108. package/src/lib/theme.ts +5 -5
  109. package/src/lib/time.ts +64 -0
  110. package/src/lib/timeline.ts +141 -0
  111. package/src/lib/view.ts +80 -82
  112. package/src/preset.css +46 -0
  113. package/src/routes/__tests__/compose.test.ts +199 -0
  114. package/src/routes/api/__tests__/collections.test.ts +249 -0
  115. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  116. package/src/routes/api/__tests__/pages.test.ts +218 -0
  117. package/src/routes/api/__tests__/posts.test.ts +50 -108
  118. package/src/routes/api/__tests__/search.test.ts +2 -3
  119. package/src/routes/api/__tests__/settings.test.ts +132 -0
  120. package/src/routes/api/collections.ts +143 -0
  121. package/src/routes/api/nav-items.ts +115 -0
  122. package/src/routes/api/pages.ts +101 -0
  123. package/src/routes/api/posts.ts +28 -28
  124. package/src/routes/api/search.ts +3 -3
  125. package/src/routes/api/settings.ts +91 -0
  126. package/src/routes/api/upload.ts +16 -6
  127. package/src/routes/compose.ts +63 -0
  128. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  129. package/src/routes/dash/collections.tsx +20 -42
  130. package/src/routes/dash/index.tsx +3 -3
  131. package/src/routes/dash/media.tsx +2 -2
  132. package/src/routes/dash/pages.tsx +480 -122
  133. package/src/routes/dash/posts.tsx +42 -54
  134. package/src/routes/dash/redirects.tsx +2 -2
  135. package/src/routes/dash/settings.tsx +83 -5
  136. package/src/routes/feed/rss.ts +4 -3
  137. package/src/routes/feed/sitemap.ts +15 -5
  138. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  139. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  140. package/src/routes/pages/archive.tsx +15 -15
  141. package/src/routes/pages/collection.tsx +16 -9
  142. package/src/routes/pages/collections.tsx +36 -0
  143. package/src/routes/pages/featured.tsx +38 -0
  144. package/src/routes/pages/home.tsx +21 -92
  145. package/src/routes/pages/page.tsx +62 -27
  146. package/src/routes/pages/post.tsx +6 -18
  147. package/src/routes/pages/search.tsx +3 -7
  148. package/src/services/__tests__/collection.test.ts +257 -158
  149. package/src/services/__tests__/media.test.ts +18 -18
  150. package/src/services/__tests__/navigation.test.ts +161 -87
  151. package/src/services/__tests__/page.test.ts +106 -0
  152. package/src/services/__tests__/post-timeline.test.ts +92 -88
  153. package/src/services/__tests__/post.test.ts +432 -197
  154. package/src/services/__tests__/search.test.ts +19 -25
  155. package/src/services/collection.ts +71 -113
  156. package/src/services/index.ts +9 -8
  157. package/src/services/navigation.ts +38 -71
  158. package/src/services/page.ts +136 -0
  159. package/src/services/post.ts +141 -101
  160. package/src/services/search.ts +38 -27
  161. package/src/styles/tokens.css +47 -0
  162. package/src/styles/ui.css +491 -0
  163. package/src/types.ts +212 -198
  164. package/src/ui/compose/ComposeDialog.tsx +395 -0
  165. package/src/ui/compose/ComposePrompt.tsx +55 -0
  166. package/src/ui/dash/FormatBadge.tsx +28 -0
  167. package/src/{theme/components → ui/dash}/PageForm.tsx +21 -21
  168. package/src/{theme/components → ui/dash}/PostForm.tsx +110 -131
  169. package/src/ui/dash/PostList.tsx +101 -0
  170. package/src/ui/dash/StatusBadge.tsx +61 -0
  171. package/src/ui/dash/index.ts +10 -0
  172. package/src/ui/feed/LinkCard.tsx +72 -0
  173. package/src/ui/feed/NoteCard.tsx +63 -0
  174. package/src/ui/feed/QuoteCard.tsx +68 -0
  175. package/src/ui/feed/ThreadPreview.tsx +48 -0
  176. package/src/ui/feed/TimelineFeed.tsx +49 -0
  177. package/src/ui/feed/TimelineItem.tsx +45 -0
  178. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  179. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  180. package/src/ui/layouts/SiteLayout.tsx +150 -0
  181. package/src/ui/pages/ArchivePage.tsx +162 -0
  182. package/src/ui/pages/CollectionPage.tsx +70 -0
  183. package/src/ui/pages/CollectionsPage.tsx +73 -0
  184. package/src/ui/pages/FeaturedPage.tsx +31 -0
  185. package/src/ui/pages/HomePage.tsx +37 -0
  186. package/src/ui/pages/PostPage.tsx +56 -0
  187. package/src/{themes/minimal → ui}/pages/SearchPage.tsx +24 -20
  188. package/src/{themes/minimal → ui}/pages/SinglePage.tsx +5 -5
  189. package/src/ui/shared/MediaGallery.tsx +59 -0
  190. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  191. package/src/{theme/components → ui/shared}/ThreadView.tsx +6 -3
  192. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  193. package/src/ui/shared/index.ts +12 -0
  194. package/bin/jant.js +0 -185
  195. package/dist/lib/theme-components.js +0 -49
  196. package/dist/routes/api/timeline.js +0 -120
  197. package/dist/routes/dash/navigation.js +0 -288
  198. package/dist/theme/components/MediaGallery.js +0 -107
  199. package/dist/theme/components/VisibilityBadge.js +0 -37
  200. package/dist/theme/index.js +0 -18
  201. package/dist/theme/layouts/index.js +0 -2
  202. package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
  203. package/dist/themes/minimal/index.js +0 -65
  204. package/dist/themes/minimal/pages/CollectionPage.js +0 -65
  205. package/dist/themes/minimal/pages/HomePage.js +0 -25
  206. package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
  207. package/dist/themes/minimal/timeline/ImageCard.js +0 -67
  208. package/dist/themes/minimal/timeline/LinkCard.js +0 -47
  209. package/dist/themes/minimal/timeline/NoteCard.js +0 -34
  210. package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
  211. package/dist/themes/minimal/timeline/TimelineItem.js +0 -44
  212. package/src/lib/__tests__/theme-components.test.ts +0 -126
  213. package/src/lib/theme-components.ts +0 -68
  214. package/src/routes/api/timeline.tsx +0 -159
  215. package/src/routes/dash/navigation.tsx +0 -316
  216. package/src/theme/components/MediaGallery.tsx +0 -128
  217. package/src/theme/components/PostList.tsx +0 -92
  218. package/src/theme/components/TypeBadge.tsx +0 -37
  219. package/src/theme/components/VisibilityBadge.tsx +0 -45
  220. package/src/theme/components/index.ts +0 -23
  221. package/src/theme/index.ts +0 -22
  222. package/src/theme/layouts/index.ts +0 -7
  223. package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
  224. package/src/themes/minimal/index.ts +0 -83
  225. package/src/themes/minimal/pages/ArchivePage.tsx +0 -157
  226. package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
  227. package/src/themes/minimal/pages/HomePage.tsx +0 -41
  228. package/src/themes/minimal/pages/PostPage.tsx +0 -43
  229. package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
  230. package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
  231. package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
  232. package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
  233. package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
  234. package/src/themes/minimal/timeline/ThreadPreview.tsx +0 -47
  235. package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
  236. package/src/themes/minimal/timeline/TimelineItem.tsx +0 -75
  237. /package/dist/{theme → ui}/color-themes.js +0 -0
  238. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  239. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  240. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  241. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  242. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  243. /package/src/{theme → ui}/color-themes.ts +0 -0
  244. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  245. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  246. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  247. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  248. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -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
+ });
@@ -3,13 +3,13 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import type { Bindings, PostType, Visibility, Media } from "../../types.js";
6
+ import type { Bindings, Format, Status, Media } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
8
  import * as sqid from "../../lib/sqid.js";
9
9
  import {
10
10
  CreatePostSchema,
11
11
  UpdatePostSchema,
12
- validateMediaForPostType,
12
+ validateMediaCount,
13
13
  } from "../../lib/schemas.js";
14
14
  import { requireAuthApi } from "../../middleware/auth.js";
15
15
  import {
@@ -59,14 +59,14 @@ function toMediaAttachment(
59
59
 
60
60
  // List posts
61
61
  postsApiRoutes.get("/", async (c) => {
62
- const type = c.req.query("type") as PostType | undefined;
63
- const visibility = c.req.query("visibility") as Visibility | undefined;
62
+ const format = c.req.query("format") as Format | undefined;
63
+ const status = c.req.query("status") as Status | undefined;
64
64
  const cursor = c.req.query("cursor");
65
65
  const limit = parseInt(c.req.query("limit") ?? "100", 10);
66
66
 
67
67
  const posts = await c.var.services.posts.list({
68
- type,
69
- visibility: visibility ? [visibility] : ["featured", "quiet"],
68
+ format,
69
+ status: status ?? "published",
70
70
  cursor: cursor ? (sqid.decode(cursor) ?? undefined) : undefined,
71
71
  limit,
72
72
  });
@@ -131,9 +131,9 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
131
131
 
132
132
  const body = parseResult.data;
133
133
 
134
- // Validate media for post type
134
+ // Validate media count
135
135
  if (body.mediaIds) {
136
- const mediaError = validateMediaForPostType(body.type, body.mediaIds);
136
+ const mediaError = validateMediaCount(body.mediaIds);
137
137
  if (mediaError) {
138
138
  return c.json({ error: mediaError }, 400);
139
139
  }
@@ -148,13 +148,17 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
148
148
  }
149
149
 
150
150
  const post = await c.var.services.posts.create({
151
- type: body.type,
151
+ format: body.format,
152
152
  title: body.title,
153
- content: body.content,
154
- visibility: body.visibility,
155
- sourceUrl: body.sourceUrl || undefined,
156
- sourceName: body.sourceName,
153
+ body: body.body,
157
154
  path: body.path || undefined,
155
+ status: body.status,
156
+ featured: body.featured,
157
+ pinned: body.pinned,
158
+ url: body.url || undefined,
159
+ quoteText: body.quoteText,
160
+ rating: body.rating || undefined,
161
+ collectionId: body.collectionId || undefined,
158
162
  replyToId: body.replyToId
159
163
  ? (sqid.decode(body.replyToId) ?? undefined)
160
164
  : undefined,
@@ -201,17 +205,9 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
201
205
 
202
206
  const body = parseResult.data;
203
207
 
204
- // Validate media for post type if mediaIds is provided
208
+ // Validate media count if mediaIds is provided
205
209
  if (body.mediaIds !== undefined) {
206
- // Need the post type — use the new type if provided, else fetch existing
207
- let postType = body.type;
208
- if (!postType) {
209
- const existing = await c.var.services.posts.getById(id);
210
- if (!existing) return c.json({ error: "Not found" }, 404);
211
- postType = existing.type;
212
- }
213
-
214
- const mediaError = validateMediaForPostType(postType, body.mediaIds);
210
+ const mediaError = validateMediaCount(body.mediaIds);
215
211
  if (mediaError) {
216
212
  return c.json({ error: mediaError }, 400);
217
213
  }
@@ -226,13 +222,17 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
226
222
  }
227
223
 
228
224
  const post = await c.var.services.posts.update(id, {
229
- type: body.type,
225
+ format: body.format,
230
226
  title: body.title,
231
- content: body.content,
232
- visibility: body.visibility,
233
- sourceUrl: body.sourceUrl,
234
- sourceName: body.sourceName,
227
+ body: body.body,
235
228
  path: body.path,
229
+ status: body.status,
230
+ featured: body.featured,
231
+ pinned: body.pinned,
232
+ url: body.url,
233
+ quoteText: body.quoteText,
234
+ rating: body.rating || undefined,
235
+ collectionId: body.collectionId || undefined,
236
236
  publishedAt: body.publishedAt,
237
237
  });
238
238
 
@@ -29,19 +29,19 @@ searchApiRoutes.get("/", async (c) => {
29
29
  try {
30
30
  const results = await c.var.services.search.search(query, {
31
31
  limit,
32
- visibility: ["featured", "quiet"],
32
+ status: ["published"],
33
33
  });
34
34
 
35
35
  return c.json({
36
36
  query,
37
37
  results: results.map((r) => ({
38
38
  id: sqid.encode(r.post.id),
39
- type: r.post.type,
39
+ format: r.post.format,
40
40
  title: r.post.title,
41
41
  path: r.post.path,
42
42
  snippet: r.snippet,
43
43
  publishedAt: r.post.publishedAt,
44
- url: `/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
+ });
@@ -5,7 +5,7 @@
5
5
  * Supports both JSON and SSE (Datastar) responses.
6
6
  */
7
7
 
8
- import { Hono } from "hono";
8
+ import { Hono, type Context } from "hono";
9
9
  import { html } from "hono/html";
10
10
  import { uuidv7 } from "uuidv7";
11
11
  import type { Bindings } from "../../types.js";
@@ -16,7 +16,7 @@ import {
16
16
  getImageUrl,
17
17
  getPublicUrlForProvider,
18
18
  } from "../../lib/image.js";
19
- import { sse, dsSignals } from "../../lib/sse.js";
19
+ import { sse } from "../../lib/sse.js";
20
20
 
21
21
  type Env = { Bindings: Bindings; Variables: AppVariables };
22
22
 
@@ -118,12 +118,22 @@ function wantsSSE(c: {
118
118
  return accept.includes("text/event-stream");
119
119
  }
120
120
 
121
+ /**
122
+ * Return an SSE error response that removes the upload placeholder and shows a toast
123
+ */
124
+ function sseUploadError(c: Context<Env>, message: string): Response {
125
+ return sse(c, async (stream) => {
126
+ await stream.remove("#upload-placeholder");
127
+ await stream.toast(message, "error");
128
+ });
129
+ }
130
+
121
131
  // Upload a file
122
132
  uploadApiRoutes.post("/", async (c) => {
123
133
  const storage = c.var.storage;
124
134
  if (!storage) {
125
135
  if (wantsSSE(c)) {
126
- return dsSignals({ _uploadError: "Storage not configured" });
136
+ return sseUploadError(c, "Storage not configured");
127
137
  }
128
138
  return c.json({ error: "Storage not configured" }, 500);
129
139
  }
@@ -133,7 +143,7 @@ uploadApiRoutes.post("/", async (c) => {
133
143
 
134
144
  if (!file) {
135
145
  if (wantsSSE(c)) {
136
- return dsSignals({ _uploadError: "No file provided" });
146
+ return sseUploadError(c, "No file provided");
137
147
  }
138
148
  return c.json({ error: "No file provided" }, 400);
139
149
  }
@@ -148,7 +158,7 @@ uploadApiRoutes.post("/", async (c) => {
148
158
  ];
149
159
  if (!allowedTypes.includes(file.type)) {
150
160
  if (wantsSSE(c)) {
151
- return dsSignals({ _uploadError: "File type not allowed" });
161
+ return sseUploadError(c, "File type not allowed");
152
162
  }
153
163
  return c.json({ error: "File type not allowed" }, 400);
154
164
  }
@@ -157,7 +167,7 @@ uploadApiRoutes.post("/", async (c) => {
157
167
  const maxSize = 10 * 1024 * 1024;
158
168
  if (file.size > maxSize) {
159
169
  if (wantsSSE(c)) {
160
- return dsSignals({ _uploadError: "File too large (max 10MB)" });
170
+ return sseUploadError(c, "File too large (max 10MB)");
161
171
  }
162
172
  return c.json({ error: "File too large (max 10MB)" }, 400);
163
173
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Compose Route
3
+ *
4
+ * Handles post creation from the public-site compose dialog.
5
+ * Returns dsRedirect to the new post's permalink (Datastar form pattern).
6
+ */
7
+
8
+ import { Hono } from "hono";
9
+ import type { Bindings } from "../types.js";
10
+ import type { AppVariables } from "../app.js";
11
+ import { requireAuth } from "../middleware/auth.js";
12
+ import { CreatePostSchema, validateMediaCount } from "../lib/schemas.js";
13
+ import * as sqid from "../lib/sqid.js";
14
+ import { dsRedirect, dsToast } from "../lib/sse.js";
15
+
16
+ type Env = { Bindings: Bindings; Variables: AppVariables };
17
+
18
+ export const composeRoutes = new Hono<Env>();
19
+
20
+ // All compose routes require authentication
21
+ composeRoutes.use("*", requireAuth());
22
+
23
+ composeRoutes.post("/", async (c) => {
24
+ const raw = await c.req.json();
25
+
26
+ const result = CreatePostSchema.safeParse(raw);
27
+ if (!result.success) {
28
+ const firstError = result.error.issues[0]?.message ?? "Invalid input";
29
+ return dsToast(firstError, "error");
30
+ }
31
+
32
+ const data = result.data;
33
+
34
+ // Validate media count
35
+ if (data.mediaIds) {
36
+ const mediaError = validateMediaCount(data.mediaIds);
37
+ if (mediaError) {
38
+ return dsToast(mediaError, "error");
39
+ }
40
+ }
41
+
42
+ const post = await c.var.services.posts.create({
43
+ format: data.format,
44
+ title: data.title || undefined,
45
+ body: data.body || undefined,
46
+ status: data.status ?? "published",
47
+ featured: data.featured,
48
+ pinned: data.pinned,
49
+ url: data.url || undefined,
50
+ quoteText: data.quoteText || undefined,
51
+ rating: data.rating || undefined,
52
+ collectionId: data.collectionId || undefined,
53
+ });
54
+
55
+ // Attach media if provided
56
+ if (data.mediaIds && data.mediaIds.length > 0) {
57
+ await c.var.services.media.attachToPost(post.id, data.mediaIds);
58
+ }
59
+
60
+ // Redirect to the new post's permalink
61
+ const permalink = post.path ? `/${post.path}` : `/p/${sqid.encode(post.id)}`;
62
+ return dsRedirect(permalink);
63
+ });