@jant/core 0.3.24 → 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 (206) hide show
  1. package/dist/app.js +50 -25
  2. package/dist/db/schema.js +1 -1
  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 +3 -9
  7. package/dist/lib/constants.js +1 -0
  8. package/dist/lib/nav-reorder.js +1 -1
  9. package/dist/lib/navigation.js +26 -1
  10. package/dist/lib/pagination.js +44 -0
  11. package/dist/lib/render.js +7 -11
  12. package/dist/lib/schemas.js +3 -3
  13. package/dist/lib/theme.js +4 -4
  14. package/dist/lib/timeline.js +24 -48
  15. package/dist/lib/view.js +2 -2
  16. package/dist/routes/api/collections.js +124 -0
  17. package/dist/routes/api/nav-items.js +104 -0
  18. package/dist/routes/api/pages.js +91 -0
  19. package/dist/routes/api/posts.js +2 -2
  20. package/dist/routes/api/search.js +2 -2
  21. package/dist/routes/api/settings.js +68 -0
  22. package/dist/routes/compose.js +48 -0
  23. package/dist/routes/dash/collections.js +2 -2
  24. package/dist/routes/dash/index.js +1 -1
  25. package/dist/routes/dash/media.js +2 -2
  26. package/dist/routes/dash/pages.js +411 -62
  27. package/dist/routes/dash/posts.js +3 -5
  28. package/dist/routes/dash/redirects.js +2 -2
  29. package/dist/routes/dash/settings.js +79 -5
  30. package/dist/routes/feed/rss.js +2 -2
  31. package/dist/routes/feed/sitemap.js +1 -1
  32. package/dist/routes/pages/archive.js +3 -6
  33. package/dist/routes/pages/collection.js +3 -6
  34. package/dist/routes/pages/collections.js +28 -0
  35. package/dist/routes/pages/featured.js +32 -0
  36. package/dist/routes/pages/home.js +9 -50
  37. package/dist/routes/pages/page.js +29 -32
  38. package/dist/routes/pages/post.js +3 -6
  39. package/dist/routes/pages/search.js +3 -6
  40. package/dist/services/page.js +5 -1
  41. package/dist/services/post.js +40 -6
  42. package/dist/services/search.js +1 -1
  43. package/dist/ui/compose/ComposeDialog.js +452 -0
  44. package/dist/ui/compose/ComposePrompt.js +55 -0
  45. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
  46. package/dist/{theme/components → ui/dash}/PostForm.js +0 -27
  47. package/dist/{theme/components → ui/dash}/PostList.js +6 -6
  48. package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
  49. package/dist/{theme/components → ui/dash}/index.js +3 -6
  50. package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
  51. package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
  52. package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
  53. package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
  54. package/dist/ui/feed/TimelineFeed.js +41 -0
  55. package/dist/ui/feed/TimelineItem.js +27 -0
  56. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  57. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  58. package/dist/ui/layouts/SiteLayout.js +141 -0
  59. package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
  60. package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
  61. package/dist/ui/pages/CollectionsPage.js +76 -0
  62. package/dist/ui/pages/FeaturedPage.js +24 -0
  63. package/dist/ui/pages/HomePage.js +24 -0
  64. package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
  65. package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
  66. package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
  67. package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
  68. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  69. package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
  70. package/dist/ui/shared/index.js +5 -0
  71. package/package.json +1 -9
  72. package/src/__tests__/helpers/db.ts +3 -0
  73. package/src/app.tsx +57 -27
  74. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  75. package/src/db/migrations/meta/_journal.json +7 -0
  76. package/src/db/schema.ts +1 -1
  77. package/src/i18n/locales/en.po +332 -181
  78. package/src/i18n/locales/en.ts +1 -1
  79. package/src/i18n/locales/zh-Hans.po +332 -181
  80. package/src/i18n/locales/zh-Hans.ts +1 -1
  81. package/src/i18n/locales/zh-Hant.po +332 -181
  82. package/src/i18n/locales/zh-Hant.ts +1 -1
  83. package/src/index.ts +7 -36
  84. package/src/lib/__tests__/schemas.test.ts +60 -19
  85. package/src/lib/__tests__/timeline.test.ts +45 -81
  86. package/src/lib/__tests__/view.test.ts +13 -7
  87. package/src/lib/constants.ts +1 -0
  88. package/src/lib/nav-reorder.ts +1 -1
  89. package/src/lib/navigation.ts +40 -2
  90. package/src/lib/pagination.ts +50 -0
  91. package/src/lib/render.tsx +7 -14
  92. package/src/lib/schemas.ts +8 -6
  93. package/src/lib/theme.ts +5 -5
  94. package/src/lib/timeline.ts +28 -57
  95. package/src/lib/view.ts +2 -2
  96. package/src/preset.css +2 -1
  97. package/src/routes/__tests__/compose.test.ts +199 -0
  98. package/src/routes/api/__tests__/collections.test.ts +249 -0
  99. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  100. package/src/routes/api/__tests__/pages.test.ts +218 -0
  101. package/src/routes/api/__tests__/settings.test.ts +132 -0
  102. package/src/routes/api/collections.ts +143 -0
  103. package/src/routes/api/nav-items.ts +115 -0
  104. package/src/routes/api/pages.ts +101 -0
  105. package/src/routes/api/posts.ts +2 -2
  106. package/src/routes/api/search.ts +2 -2
  107. package/src/routes/api/settings.ts +91 -0
  108. package/src/routes/compose.ts +63 -0
  109. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  110. package/src/routes/dash/collections.tsx +2 -2
  111. package/src/routes/dash/index.tsx +1 -1
  112. package/src/routes/dash/media.tsx +2 -2
  113. package/src/routes/dash/pages.tsx +443 -70
  114. package/src/routes/dash/posts.tsx +3 -7
  115. package/src/routes/dash/redirects.tsx +2 -2
  116. package/src/routes/dash/settings.tsx +83 -5
  117. package/src/routes/feed/rss.ts +2 -2
  118. package/src/routes/feed/sitemap.ts +1 -1
  119. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  120. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  121. package/src/routes/pages/archive.tsx +2 -6
  122. package/src/routes/pages/collection.tsx +2 -6
  123. package/src/routes/pages/collections.tsx +36 -0
  124. package/src/routes/pages/featured.tsx +38 -0
  125. package/src/routes/pages/home.tsx +9 -55
  126. package/src/routes/pages/page.tsx +28 -30
  127. package/src/routes/pages/post.tsx +2 -5
  128. package/src/routes/pages/search.tsx +2 -6
  129. package/src/services/__tests__/page.test.ts +106 -0
  130. package/src/services/__tests__/post.test.ts +114 -15
  131. package/src/services/page.ts +13 -1
  132. package/src/services/post.ts +57 -7
  133. package/src/services/search.ts +2 -2
  134. package/src/styles/tokens.css +47 -0
  135. package/src/styles/ui.css +491 -0
  136. package/src/types.ts +29 -159
  137. package/src/ui/compose/ComposeDialog.tsx +395 -0
  138. package/src/ui/compose/ComposePrompt.tsx +55 -0
  139. package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
  140. package/src/{theme/components → ui/dash}/PostForm.tsx +0 -25
  141. package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
  142. package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
  143. package/src/ui/dash/index.ts +10 -0
  144. package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
  145. package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
  146. package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
  147. package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
  148. package/src/ui/feed/TimelineFeed.tsx +49 -0
  149. package/src/ui/feed/TimelineItem.tsx +45 -0
  150. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  151. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  152. package/src/ui/layouts/SiteLayout.tsx +150 -0
  153. package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
  154. package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
  155. package/src/ui/pages/CollectionsPage.tsx +73 -0
  156. package/src/ui/pages/FeaturedPage.tsx +31 -0
  157. package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
  158. package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
  159. package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
  160. package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
  161. package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
  162. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  163. package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
  164. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  165. package/src/ui/shared/index.ts +12 -0
  166. package/bin/jant.js +0 -185
  167. package/dist/lib/theme-components.js +0 -46
  168. package/dist/routes/dash/navigation.js +0 -289
  169. package/dist/theme/index.js +0 -18
  170. package/dist/theme/layouts/index.js +0 -2
  171. package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
  172. package/dist/themes/threads/index.js +0 -81
  173. package/dist/themes/threads/pages/HomePage.js +0 -25
  174. package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
  175. package/dist/themes/threads/timeline/TimelineItem.js +0 -36
  176. package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
  177. package/dist/themes/threads/timeline/groupByDate.js +0 -22
  178. package/dist/themes/threads/timeline/timelineMore.js +0 -107
  179. package/src/lib/__tests__/theme-components.test.ts +0 -105
  180. package/src/lib/theme-components.ts +0 -65
  181. package/src/routes/dash/navigation.tsx +0 -317
  182. package/src/theme/components/index.ts +0 -23
  183. package/src/theme/index.ts +0 -22
  184. package/src/theme/layouts/index.ts +0 -7
  185. package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
  186. package/src/themes/threads/index.ts +0 -100
  187. package/src/themes/threads/style.css +0 -336
  188. package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
  189. package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
  190. package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
  191. package/src/themes/threads/timeline/groupByDate.ts +0 -30
  192. package/src/themes/threads/timeline/timelineMore.tsx +0 -130
  193. /package/dist/{theme → ui}/color-themes.js +0 -0
  194. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  195. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  196. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  197. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  198. /package/dist/{theme/components → ui/dash}/PageForm.js +0 -0
  199. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  200. /package/src/{theme → ui}/color-themes.ts +0 -0
  201. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  202. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  203. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  204. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  205. /package/src/{theme/components → ui/dash}/PageForm.tsx +0 -0
  206. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -2,44 +2,51 @@
2
2
  * Timeline Data Assembly
3
3
  *
4
4
  * Shared helper for assembling timeline items with media and thread previews.
5
- * Used by both full-page rendering and load-more SSE responses.
5
+ * Used by page rendering with page-based pagination.
6
6
  */ import { buildMediaMap } from "./media-helpers.js";
7
7
  import { createMediaContext, toPostView, toPostViews } from "./view.js";
8
8
  const DEFAULT_PAGE_SIZE = 20;
9
9
  /**
10
10
  * Assembles a page of timeline items with media attachments and thread previews.
11
11
  *
12
- * Fetches posts, batch-loads media, identifies threads, and returns
13
- * render-ready `TimelineItemView[]` with pagination info.
12
+ * Fetches posts using offset-based pagination, batch-loads media, identifies
13
+ * threads, and returns render-ready `TimelineItemView[]` with page info.
14
14
  *
15
15
  * @param c - Hono context (provides services + env)
16
- * @param options - Optional cursor for pagination
16
+ * @param options - Optional page number (1-indexed, defaults to 1)
17
17
  * @returns Assembled timeline items with pagination info
18
18
  *
19
19
  * @example
20
20
  * ```ts
21
- * const { items, hasMore, nextCursor } = await assembleTimeline(c);
22
- * const { items, hasMore, nextCursor } = await assembleTimeline(c, { cursor: 42 });
21
+ * const { items, currentPage, totalPages } = await assembleTimeline(c);
22
+ * const { items, currentPage, totalPages } = await assembleTimeline(c, { page: 2 });
23
23
  * ```
24
24
  */ export async function assembleTimeline(c, options) {
25
25
  const pageSize = parseInt(c.env.PAGE_SIZE ?? String(DEFAULT_PAGE_SIZE), 10) || DEFAULT_PAGE_SIZE;
26
- // Fetch one extra to determine if there are more
26
+ const page = Math.max(1, options?.page ?? 1);
27
+ const offset = (page - 1) * pageSize;
28
+ // Get total count for pagination
29
+ const totalCount = await c.var.services.posts.count({
30
+ status: "published",
31
+ excludeReplies: true
32
+ });
33
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
34
+ // Fetch posts for the current page
27
35
  const posts = await c.var.services.posts.list({
28
36
  status: "published",
29
37
  excludeReplies: true,
30
- limit: pageSize + 1,
31
- cursor: options?.cursor
38
+ limit: pageSize,
39
+ offset
32
40
  });
33
- const hasMore = posts.length > pageSize;
34
- const displayPosts = hasMore ? posts.slice(0, pageSize) : posts;
35
- if (displayPosts.length === 0) {
41
+ if (posts.length === 0) {
36
42
  return {
37
43
  items: [],
38
- hasMore: false
44
+ currentPage: page,
45
+ totalPages
39
46
  };
40
47
  }
41
48
  // Batch load media attachments
42
- const postIds = displayPosts.map((p)=>p.id);
49
+ const postIds = posts.map((p)=>p.id);
43
50
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
44
51
  const mediaCtx = createMediaContext(c);
45
52
  const mediaMap = buildMediaMap(rawMediaMap, mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl);
@@ -57,7 +64,7 @@ const DEFAULT_PAGE_SIZE = 20;
57
64
  }
58
65
  const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl) : new Map();
59
66
  // Assemble timeline items with View Models
60
- const items = displayPosts.map((post)=>{
67
+ const items = posts.map((post)=>{
61
68
  const postView = toPostView({
62
69
  ...post,
63
70
  mediaAttachments: mediaMap.get(post.id) ?? []
@@ -80,40 +87,9 @@ const DEFAULT_PAGE_SIZE = 20;
80
87
  post: postView
81
88
  };
82
89
  });
83
- // Determine next cursor
84
- const lastPost = displayPosts[displayPosts.length - 1];
85
- const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
86
90
  return {
87
91
  items,
88
- hasMore,
89
- nextCursor
92
+ currentPage: page,
93
+ totalPages
90
94
  };
91
95
  }
92
- /**
93
- * Groups timeline items by their publication date (YYYY-MM-DD).
94
- *
95
- * @param items - Timeline items to group
96
- * @returns Array of date groups, each containing items published on the same day
97
- *
98
- * @example
99
- * ```ts
100
- * const groups = groupByDate(items);
101
- * // [{ dateKey: "2024-02-01", label: "Feb 1, 2024", items: [...] }, ...]
102
- * ```
103
- */ export function groupByDate(items) {
104
- const groups = [];
105
- let current = null;
106
- for (const item of items){
107
- const dateKey = item.post.publishedAt.slice(0, 10);
108
- if (!current || current.dateKey !== dateKey) {
109
- current = {
110
- dateKey,
111
- label: item.post.publishedAtFormatted,
112
- items: []
113
- };
114
- groups.push(current);
115
- }
116
- current.items.push(item);
117
- }
118
- return groups;
119
- }
package/dist/lib/view.js CHANGED
@@ -58,7 +58,7 @@ import { getHtmlExcerpt } from "./excerpt.js";
58
58
  * @param _ctx - Media context with URL configuration
59
59
  * @returns Render-ready PostView with pre-computed fields
60
60
  */ export function toPostView(post, _ctx) {
61
- const permalink = post.slug ? `/${post.slug}` : `/p/${encode(post.id)}`;
61
+ const permalink = post.path ? `/${post.path}` : `/p/${encode(post.id)}`;
62
62
  // Pre-compute excerpt from raw body
63
63
  let excerpt;
64
64
  if (post.body) {
@@ -85,7 +85,7 @@ import { getHtmlExcerpt } from "./excerpt.js";
85
85
  return {
86
86
  id: post.id,
87
87
  permalink,
88
- slug: post.slug ?? undefined,
88
+ path: post.path ?? undefined,
89
89
  title: post.title ?? undefined,
90
90
  bodyHtml: post.bodyHtml ?? undefined,
91
91
  excerpt,
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Collections API Routes
3
+ */ import { Hono } from "hono";
4
+ import { requireAuthApi } from "../../middleware/auth.js";
5
+ import { z } from "zod";
6
+ import { SORT_ORDERS } from "../../types.js";
7
+ export const collectionsApiRoutes = new Hono();
8
+ const SortOrderSchema = z.enum(SORT_ORDERS);
9
+ const CreateCollectionSchema = z.object({
10
+ slug: z.string().min(1),
11
+ title: z.string().min(1),
12
+ description: z.string().optional(),
13
+ icon: z.string().optional(),
14
+ sortOrder: SortOrderSchema.optional(),
15
+ position: z.number().int().min(0).optional(),
16
+ showDivider: z.boolean().optional()
17
+ });
18
+ const UpdateCollectionSchema = z.object({
19
+ slug: z.string().min(1).optional(),
20
+ title: z.string().min(1).optional(),
21
+ description: z.string().nullable().optional(),
22
+ icon: z.string().nullable().optional(),
23
+ sortOrder: SortOrderSchema.optional(),
24
+ position: z.number().int().min(0).optional(),
25
+ showDivider: z.boolean().optional()
26
+ });
27
+ const ReorderSchema = z.object({
28
+ ids: z.array(z.number().int().positive())
29
+ });
30
+ // List collections (includes post counts)
31
+ collectionsApiRoutes.get("/", async (c)=>{
32
+ const collections = await c.var.services.collections.list();
33
+ const postCounts = await c.var.services.collections.getPostCounts();
34
+ return c.json({
35
+ collections: collections.map((col)=>({
36
+ ...col,
37
+ postCount: postCounts.get(col.id) ?? 0
38
+ }))
39
+ });
40
+ });
41
+ // Get single collection
42
+ collectionsApiRoutes.get("/:id", async (c)=>{
43
+ const id = parseInt(c.req.param("id"), 10);
44
+ if (isNaN(id)) return c.json({
45
+ error: "Invalid ID"
46
+ }, 400);
47
+ const collection = await c.var.services.collections.getById(id);
48
+ if (!collection) return c.json({
49
+ error: "Not found"
50
+ }, 404);
51
+ return c.json(collection);
52
+ });
53
+ // Reorder collections (requires auth) — must be before /:id
54
+ collectionsApiRoutes.put("/reorder", requireAuthApi(), async (c)=>{
55
+ const rawBody = await c.req.json();
56
+ const parseResult = ReorderSchema.safeParse(rawBody);
57
+ if (!parseResult.success) {
58
+ return c.json({
59
+ error: "Validation failed",
60
+ details: parseResult.error.flatten()
61
+ }, 400);
62
+ }
63
+ await c.var.services.collections.reorder(parseResult.data.ids);
64
+ const collections = await c.var.services.collections.list();
65
+ return c.json({
66
+ collections
67
+ });
68
+ });
69
+ // Create collection (requires auth)
70
+ collectionsApiRoutes.post("/", requireAuthApi(), async (c)=>{
71
+ const rawBody = await c.req.json();
72
+ const parseResult = CreateCollectionSchema.safeParse(rawBody);
73
+ if (!parseResult.success) {
74
+ return c.json({
75
+ error: "Validation failed",
76
+ details: parseResult.error.flatten()
77
+ }, 400);
78
+ }
79
+ const body = parseResult.data;
80
+ const collection = await c.var.services.collections.create({
81
+ slug: body.slug,
82
+ title: body.title,
83
+ description: body.description,
84
+ icon: body.icon,
85
+ sortOrder: body.sortOrder,
86
+ position: body.position,
87
+ showDivider: body.showDivider
88
+ });
89
+ return c.json(collection, 201);
90
+ });
91
+ // Update collection (requires auth)
92
+ collectionsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
93
+ const id = parseInt(c.req.param("id"), 10);
94
+ if (isNaN(id)) return c.json({
95
+ error: "Invalid ID"
96
+ }, 400);
97
+ const rawBody = await c.req.json();
98
+ const parseResult = UpdateCollectionSchema.safeParse(rawBody);
99
+ if (!parseResult.success) {
100
+ return c.json({
101
+ error: "Validation failed",
102
+ details: parseResult.error.flatten()
103
+ }, 400);
104
+ }
105
+ const collection = await c.var.services.collections.update(id, parseResult.data);
106
+ if (!collection) return c.json({
107
+ error: "Not found"
108
+ }, 404);
109
+ return c.json(collection);
110
+ });
111
+ // Delete collection (requires auth)
112
+ collectionsApiRoutes.delete("/:id", requireAuthApi(), async (c)=>{
113
+ const id = parseInt(c.req.param("id"), 10);
114
+ if (isNaN(id)) return c.json({
115
+ error: "Invalid ID"
116
+ }, 400);
117
+ const success = await c.var.services.collections.delete(id);
118
+ if (!success) return c.json({
119
+ error: "Not found"
120
+ }, 404);
121
+ return c.json({
122
+ success: true
123
+ });
124
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Nav Items API Routes
3
+ */ import { Hono } from "hono";
4
+ import { requireAuthApi } from "../../middleware/auth.js";
5
+ import { z } from "zod";
6
+ export const navItemsApiRoutes = new Hono();
7
+ const NavItemTypeSchema = z.enum([
8
+ "link",
9
+ "page"
10
+ ]);
11
+ const CreateNavItemSchema = z.object({
12
+ type: NavItemTypeSchema,
13
+ label: z.string().min(1),
14
+ url: z.string().min(1),
15
+ pageId: z.number().int().positive().optional(),
16
+ position: z.number().int().min(0).optional()
17
+ });
18
+ const UpdateNavItemSchema = z.object({
19
+ type: NavItemTypeSchema.optional(),
20
+ label: z.string().min(1).optional(),
21
+ url: z.string().min(1).optional(),
22
+ pageId: z.number().int().positive().nullable().optional(),
23
+ position: z.number().int().min(0).optional()
24
+ });
25
+ const ReorderSchema = z.object({
26
+ ids: z.array(z.number().int().positive())
27
+ });
28
+ // List nav items
29
+ navItemsApiRoutes.get("/", async (c)=>{
30
+ const items = await c.var.services.navItems.list();
31
+ return c.json({
32
+ navItems: items
33
+ });
34
+ });
35
+ // Reorder nav items (requires auth) — must be before /:id
36
+ navItemsApiRoutes.put("/reorder", requireAuthApi(), async (c)=>{
37
+ const rawBody = await c.req.json();
38
+ const parseResult = ReorderSchema.safeParse(rawBody);
39
+ if (!parseResult.success) {
40
+ return c.json({
41
+ error: "Validation failed",
42
+ details: parseResult.error.flatten()
43
+ }, 400);
44
+ }
45
+ await c.var.services.navItems.reorder(parseResult.data.ids);
46
+ const items = await c.var.services.navItems.list();
47
+ return c.json({
48
+ navItems: items
49
+ });
50
+ });
51
+ // Create nav item (requires auth)
52
+ navItemsApiRoutes.post("/", requireAuthApi(), async (c)=>{
53
+ const rawBody = await c.req.json();
54
+ const parseResult = CreateNavItemSchema.safeParse(rawBody);
55
+ if (!parseResult.success) {
56
+ return c.json({
57
+ error: "Validation failed",
58
+ details: parseResult.error.flatten()
59
+ }, 400);
60
+ }
61
+ const body = parseResult.data;
62
+ const item = await c.var.services.navItems.create({
63
+ type: body.type,
64
+ label: body.label,
65
+ url: body.url,
66
+ pageId: body.pageId,
67
+ position: body.position
68
+ });
69
+ return c.json(item, 201);
70
+ });
71
+ // Update nav item (requires auth)
72
+ navItemsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
73
+ const id = parseInt(c.req.param("id"), 10);
74
+ if (isNaN(id)) return c.json({
75
+ error: "Invalid ID"
76
+ }, 400);
77
+ const rawBody = await c.req.json();
78
+ const parseResult = UpdateNavItemSchema.safeParse(rawBody);
79
+ if (!parseResult.success) {
80
+ return c.json({
81
+ error: "Validation failed",
82
+ details: parseResult.error.flatten()
83
+ }, 400);
84
+ }
85
+ const item = await c.var.services.navItems.update(id, parseResult.data);
86
+ if (!item) return c.json({
87
+ error: "Not found"
88
+ }, 404);
89
+ return c.json(item);
90
+ });
91
+ // Delete nav item (requires auth)
92
+ navItemsApiRoutes.delete("/:id", requireAuthApi(), async (c)=>{
93
+ const id = parseInt(c.req.param("id"), 10);
94
+ if (isNaN(id)) return c.json({
95
+ error: "Invalid ID"
96
+ }, 400);
97
+ const success = await c.var.services.navItems.delete(id);
98
+ if (!success) return c.json({
99
+ error: "Not found"
100
+ }, 404);
101
+ return c.json({
102
+ success: true
103
+ });
104
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Pages API Routes
3
+ */ import { Hono } from "hono";
4
+ import { requireAuthApi } from "../../middleware/auth.js";
5
+ import { z } from "zod";
6
+ import { StatusSchema } from "../../lib/schemas.js";
7
+ export const pagesApiRoutes = new Hono();
8
+ const CreatePageSchema = z.object({
9
+ slug: z.string().min(1),
10
+ title: z.string().optional(),
11
+ body: z.string().optional(),
12
+ status: StatusSchema.optional()
13
+ });
14
+ const UpdatePageSchema = z.object({
15
+ slug: z.string().min(1).optional(),
16
+ title: z.string().nullable().optional(),
17
+ body: z.string().nullable().optional(),
18
+ status: StatusSchema.optional()
19
+ });
20
+ // List pages
21
+ pagesApiRoutes.get("/", async (c)=>{
22
+ const pages = await c.var.services.pages.list();
23
+ return c.json({
24
+ pages
25
+ });
26
+ });
27
+ // Get single page
28
+ pagesApiRoutes.get("/:id", async (c)=>{
29
+ const id = parseInt(c.req.param("id"), 10);
30
+ if (isNaN(id)) return c.json({
31
+ error: "Invalid ID"
32
+ }, 400);
33
+ const page = await c.var.services.pages.getById(id);
34
+ if (!page) return c.json({
35
+ error: "Not found"
36
+ }, 404);
37
+ return c.json(page);
38
+ });
39
+ // Create page (requires auth)
40
+ pagesApiRoutes.post("/", requireAuthApi(), async (c)=>{
41
+ const rawBody = await c.req.json();
42
+ const parseResult = CreatePageSchema.safeParse(rawBody);
43
+ if (!parseResult.success) {
44
+ return c.json({
45
+ error: "Validation failed",
46
+ details: parseResult.error.flatten()
47
+ }, 400);
48
+ }
49
+ const body = parseResult.data;
50
+ const page = await c.var.services.pages.create({
51
+ slug: body.slug,
52
+ title: body.title,
53
+ body: body.body,
54
+ status: body.status
55
+ });
56
+ return c.json(page, 201);
57
+ });
58
+ // Update page (requires auth)
59
+ pagesApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
60
+ const id = parseInt(c.req.param("id"), 10);
61
+ if (isNaN(id)) return c.json({
62
+ error: "Invalid ID"
63
+ }, 400);
64
+ const rawBody = await c.req.json();
65
+ const parseResult = UpdatePageSchema.safeParse(rawBody);
66
+ if (!parseResult.success) {
67
+ return c.json({
68
+ error: "Validation failed",
69
+ details: parseResult.error.flatten()
70
+ }, 400);
71
+ }
72
+ const page = await c.var.services.pages.update(id, parseResult.data);
73
+ if (!page) return c.json({
74
+ error: "Not found"
75
+ }, 404);
76
+ return c.json(page);
77
+ });
78
+ // Delete page (requires auth)
79
+ pagesApiRoutes.delete("/:id", requireAuthApi(), async (c)=>{
80
+ const id = parseInt(c.req.param("id"), 10);
81
+ if (isNaN(id)) return c.json({
82
+ error: "Invalid ID"
83
+ }, 400);
84
+ const success = await c.var.services.pages.delete(id);
85
+ if (!success) return c.json({
86
+ error: "Not found"
87
+ }, 404);
88
+ return c.json({
89
+ success: true
90
+ });
91
+ });
@@ -110,7 +110,7 @@ postsApiRoutes.post("/", requireAuthApi(), async (c)=>{
110
110
  format: body.format,
111
111
  title: body.title,
112
112
  body: body.body,
113
- slug: body.slug || undefined,
113
+ path: body.path || undefined,
114
114
  status: body.status,
115
115
  featured: body.featured,
116
116
  pinned: body.pinned,
@@ -173,7 +173,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
173
173
  format: body.format,
174
174
  title: body.title,
175
175
  body: body.body,
176
- slug: body.slug,
176
+ path: body.path,
177
177
  status: body.status,
178
178
  featured: body.featured,
179
179
  pinned: body.pinned,
@@ -31,10 +31,10 @@ searchApiRoutes.get("/", async (c)=>{
31
31
  id: sqid.encode(r.post.id),
32
32
  format: r.post.format,
33
33
  title: r.post.title,
34
- slug: r.post.slug,
34
+ path: r.post.path,
35
35
  snippet: r.snippet,
36
36
  publishedAt: r.post.publishedAt,
37
- url: r.post.slug ? `/${r.post.slug}` : `/p/${sqid.encode(r.post.id)}`
37
+ url: r.post.path ? `/${r.post.path}` : `/p/${sqid.encode(r.post.id)}`
38
38
  })),
39
39
  count: results.length
40
40
  });
@@ -0,0 +1,68 @@
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
+ });
@@ -0,0 +1,48 @@
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
+ */ import { Hono } from "hono";
7
+ import { requireAuth } from "../middleware/auth.js";
8
+ import { CreatePostSchema, validateMediaCount } from "../lib/schemas.js";
9
+ import * as sqid from "../lib/sqid.js";
10
+ import { dsRedirect, dsToast } from "../lib/sse.js";
11
+ export const composeRoutes = new Hono();
12
+ // All compose routes require authentication
13
+ composeRoutes.use("*", requireAuth());
14
+ composeRoutes.post("/", async (c)=>{
15
+ const raw = await c.req.json();
16
+ const result = CreatePostSchema.safeParse(raw);
17
+ if (!result.success) {
18
+ const firstError = result.error.issues[0]?.message ?? "Invalid input";
19
+ return dsToast(firstError, "error");
20
+ }
21
+ const data = result.data;
22
+ // Validate media count
23
+ if (data.mediaIds) {
24
+ const mediaError = validateMediaCount(data.mediaIds);
25
+ if (mediaError) {
26
+ return dsToast(mediaError, "error");
27
+ }
28
+ }
29
+ const post = await c.var.services.posts.create({
30
+ format: data.format,
31
+ title: data.title || undefined,
32
+ body: data.body || undefined,
33
+ status: data.status ?? "published",
34
+ featured: data.featured,
35
+ pinned: data.pinned,
36
+ url: data.url || undefined,
37
+ quoteText: data.quoteText || undefined,
38
+ rating: data.rating || undefined,
39
+ collectionId: data.collectionId || undefined
40
+ });
41
+ // Attach media if provided
42
+ if (data.mediaIds && data.mediaIds.length > 0) {
43
+ await c.var.services.media.attachToPost(post.id, data.mediaIds);
44
+ }
45
+ // Redirect to the new post's permalink
46
+ const permalink = post.path ? `/${post.path}` : `/p/${sqid.encode(post.id)}`;
47
+ return dsRedirect(permalink);
48
+ });
@@ -4,8 +4,8 @@ import { getSiteName } from "../../lib/config.js";
4
4
  * Dashboard Collections Routes
5
5
  */ import { Hono } from "hono";
6
6
  import { useLingui as $_useLingui } from "@jant/core/i18n";
7
- import { DashLayout } from "../../theme/layouts/index.js";
8
- import { EmptyState, ListItemRow, ActionButtons, CrudPageHeader, DangerZone } from "../../theme/components/index.js";
7
+ import { DashLayout } from "../../ui/layouts/DashLayout.js";
8
+ import { EmptyState, ListItemRow, ActionButtons, CrudPageHeader, DangerZone } from "../../ui/dash/index.js";
9
9
  import * as sqid from "../../lib/sqid.js";
10
10
  import { dsRedirect } from "../../lib/sse.js";
11
11
  export const collectionsRoutes = new Hono();
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
6
6
  */ import { Hono } from "hono";
7
7
  import { useLingui as $_useLingui } from "@jant/core/i18n";
8
8
  import { Trans as Trans_ } from "@jant/core/i18n";
9
- import { DashLayout } from "../../theme/layouts/index.js";
9
+ import { DashLayout } from "../../ui/layouts/DashLayout.js";
10
10
  import { getSiteName } from "../../lib/config.js";
11
11
  export const dashIndexRoutes = new Hono();
12
12
  /**
@@ -7,8 +7,8 @@ import { getSiteName } from "../../lib/config.js";
7
7
  * Uses SSE for real-time UI updates without page reloads.
8
8
  */ import { Hono } from "hono";
9
9
  import { useLingui as $_useLingui } from "@jant/core/i18n";
10
- import { DashLayout } from "../../theme/layouts/index.js";
11
- import { EmptyState, DangerZone } from "../../theme/components/index.js";
10
+ import { DashLayout } from "../../ui/layouts/DashLayout.js";
11
+ import { EmptyState, DangerZone } from "../../ui/dash/index.js";
12
12
  import * as time from "../../lib/time.js";
13
13
  import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
14
14
  import { dsRedirect } from "../../lib/sse.js";