@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
package/dist/lib/view.js CHANGED
@@ -1,22 +1,17 @@
1
1
  /**
2
- * View Model Conversions
2
+ * View Model Conversions (v2)
3
3
  *
4
4
  * Transforms raw database models into render-ready View types.
5
- * Theme components receive only View types no lib/ imports needed.
5
+ * Theme components receive only View types -- no lib/ imports needed.
6
6
  */ import { encode } from "./sqid.js";
7
- import { toISOString, formatDate } from "./time.js";
7
+ import { toISOString, formatDate, formatTime, formatRelativeTime } from "./time.js";
8
8
  import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
9
+ import { getHtmlExcerpt } from "./excerpt.js";
9
10
  /**
10
11
  * Creates a MediaContext from Hono context environment variables.
11
12
  *
12
13
  * @param c - Hono context
13
14
  * @returns MediaContext with env values
14
- *
15
- * @example
16
- * ```ts
17
- * const mediaCtx = createMediaContext(c);
18
- * const postView = toPostView(post, mediaCtx);
19
- * ```
20
15
  */ export function createMediaContext(c) {
21
16
  return {
22
17
  r2PublicUrl: c.env.R2_PUBLIC_URL,
@@ -60,20 +55,22 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
60
55
  * Converts a PostWithMedia to a render-ready PostView.
61
56
  *
62
57
  * @param post - Post with media attachments from database
63
- * @param ctx - Media context with URL configuration
58
+ * @param _ctx - Media context with URL configuration
64
59
  * @returns Render-ready PostView with pre-computed fields
65
- *
66
- * @example
67
- * ```ts
68
- * const mediaCtx = createMediaContext(c);
69
- * const postView = toPostView({ ...post, mediaAttachments: [...] }, mediaCtx);
70
- * ```
71
60
  */ export function toPostView(post, _ctx) {
72
- const permalink = `/p/${encode(post.id)}`;
73
- // Pre-compute excerpt from raw content
61
+ const permalink = post.path ? `/${post.path}` : `/p/${encode(post.id)}`;
62
+ // Pre-compute excerpt from raw body
74
63
  let excerpt;
75
- if (post.content) {
76
- excerpt = post.content.length > 160 ? post.content.slice(0, 160) + "..." : post.content;
64
+ if (post.body) {
65
+ excerpt = post.body.length > 160 ? post.body.slice(0, 160) + "..." : post.body;
66
+ }
67
+ // Pre-compute HTML summary for article-style posts (with title)
68
+ let summaryHtml;
69
+ let summaryHasMore;
70
+ if (post.title && post.bodyHtml) {
71
+ const result = getHtmlExcerpt(post.bodyHtml);
72
+ summaryHtml = result.excerpt;
73
+ summaryHasMore = result.hasMore;
77
74
  }
78
75
  // Convert media attachments
79
76
  const media = post.mediaAttachments.map((m)=>({
@@ -88,39 +85,38 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
88
85
  return {
89
86
  id: post.id,
90
87
  permalink,
88
+ path: post.path ?? undefined,
91
89
  title: post.title ?? undefined,
92
- contentHtml: post.contentHtml ?? undefined,
90
+ bodyHtml: post.bodyHtml ?? undefined,
93
91
  excerpt,
94
- type: post.type,
95
- visibility: post.visibility,
96
- path: post.path ?? undefined,
92
+ summaryHtml,
93
+ summaryHasMore,
94
+ url: post.url ?? undefined,
95
+ quoteText: post.quoteText ?? undefined,
96
+ format: post.format,
97
+ status: post.status,
98
+ featured: post.featured === 1,
99
+ pinned: post.pinned === 1,
100
+ rating: post.rating ?? undefined,
101
+ collectionId: post.collectionId ?? undefined,
97
102
  publishedAt: toISOString(post.publishedAt),
98
103
  publishedAtFormatted: formatDate(post.publishedAt),
104
+ publishedAtTime: formatTime(post.publishedAt),
105
+ publishedAtRelative: formatRelativeTime(post.publishedAt),
99
106
  updatedAt: toISOString(post.updatedAt),
100
- sourceUrl: post.sourceUrl ?? undefined,
101
- sourceName: post.sourceName ?? undefined,
102
- sourceDomain: post.sourceDomain ?? undefined,
103
107
  media,
104
108
  replyToId: post.replyToId ?? undefined,
105
109
  threadRootId: post.threadId ?? undefined,
106
- content: post.content ?? undefined
110
+ body: post.body ?? undefined
107
111
  };
108
112
  }
109
113
  /**
110
114
  * Batch converts PostWithMedia[] to PostView[].
111
- *
112
- * @param posts - Array of posts with media
113
- * @param ctx - Media context
114
- * @returns Array of PostView
115
115
  */ export function toPostViews(posts, ctx) {
116
116
  return posts.map((p)=>toPostView(p, ctx));
117
117
  }
118
118
  /**
119
119
  * Converts a bare Post (no media) to a PostView with empty media array.
120
- *
121
- * @param post - Post without media
122
- * @param ctx - Media context (unused but kept for consistency)
123
- * @returns PostView with empty media
124
120
  */ export function toPostViewFromPost(post, ctx) {
125
121
  return toPostView({
126
122
  ...post,
@@ -129,58 +125,60 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
129
125
  }
130
126
  /**
131
127
  * Batch converts Post[] (no media) to PostView[].
132
- *
133
- * @param posts - Array of posts without media
134
- * @param ctx - Media context
135
- * @returns Array of PostView
136
128
  */ export function toPostViewsFromPosts(posts, ctx) {
137
129
  return posts.map((p)=>toPostViewFromPost(p, ctx));
138
130
  }
139
131
  // =============================================================================
132
+ // Page Conversions
133
+ // =============================================================================
134
+ /**
135
+ * Converts a Page to a render-ready PageView.
136
+ */ export function toPageView(page) {
137
+ return {
138
+ id: page.id,
139
+ slug: page.slug,
140
+ title: page.title ?? undefined,
141
+ bodyHtml: page.bodyHtml ?? undefined,
142
+ status: page.status,
143
+ createdAt: toISOString(page.createdAt),
144
+ updatedAt: toISOString(page.updatedAt)
145
+ };
146
+ }
147
+ // =============================================================================
140
148
  // Navigation Conversions
141
149
  // =============================================================================
142
150
  /**
143
- * Converts a NavigationLink to a NavLinkView with pre-computed state.
144
- *
145
- * @param link - Raw navigation link from database
146
- * @param currentPath - Current page path for active state computation
147
- * @returns NavLinkView with isActive and isExternal pre-computed
148
- */ export function toNavLinkView(link, currentPath) {
149
- const isExternal = link.url.startsWith("http://") || link.url.startsWith("https://");
151
+ * Converts a NavItem to a NavItemView with pre-computed state.
152
+ */ export function toNavItemView(item, currentPath) {
153
+ const isExternal = item.url.startsWith("http://") || item.url.startsWith("https://");
150
154
  let isActive = false;
151
155
  if (!isExternal) {
152
- if (link.url === "/") {
156
+ if (item.url === "/") {
153
157
  isActive = currentPath === "/";
154
158
  } else {
155
- isActive = currentPath === link.url || currentPath.startsWith(link.url + "/");
159
+ isActive = currentPath === item.url || currentPath.startsWith(item.url + "/");
156
160
  }
157
161
  }
158
162
  return {
159
- id: link.id,
160
- label: link.label,
161
- url: link.url,
163
+ id: item.id,
164
+ type: item.type,
165
+ label: item.label,
166
+ url: item.url,
167
+ pageId: item.pageId ?? undefined,
162
168
  isActive,
163
169
  isExternal
164
170
  };
165
171
  }
166
172
  /**
167
- * Batch converts NavigationLink[] to NavLinkView[].
168
- *
169
- * @param links - Raw navigation links
170
- * @param currentPath - Current page path
171
- * @returns Array of NavLinkView
172
- */ export function toNavLinkViews(links, currentPath) {
173
- return links.map((l)=>toNavLinkView(l, currentPath));
173
+ * Batch converts NavItem[] to NavItemView[].
174
+ */ export function toNavItemViews(items, currentPath) {
175
+ return items.map((item)=>toNavItemView(item, currentPath));
174
176
  }
175
177
  // =============================================================================
176
178
  // Search Result Conversions
177
179
  // =============================================================================
178
180
  /**
179
181
  * Converts a SearchResult to a SearchResultView with PostView.
180
- *
181
- * @param result - Raw search result
182
- * @param ctx - Media context
183
- * @returns SearchResultView with PostView
184
182
  */ export function toSearchResultView(result, ctx) {
185
183
  return {
186
184
  post: toPostViewFromPost(result.post, ctx),
@@ -190,10 +188,6 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
190
188
  }
191
189
  /**
192
190
  * Batch converts SearchResult[] to SearchResultView[].
193
- *
194
- * @param results - Raw search results
195
- * @param ctx - Media context
196
- * @returns Array of SearchResultView
197
191
  */ export function toSearchResultViews(results, ctx) {
198
192
  return results.map((r)=>toSearchResultView(r, ctx));
199
193
  }
@@ -202,16 +196,11 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
202
196
  // =============================================================================
203
197
  /**
204
198
  * Converts a grouped post map to typed ArchiveGroup[].
205
- *
206
- * @param grouped - Map of "YYYY-MM" keys to Post arrays
207
- * @param ctx - Media context
208
- * @returns Array of ArchiveGroup with pre-formatted labels
209
199
  */ export function toArchiveGroups(grouped, ctx) {
210
200
  const groups = [];
211
201
  for (const [yearMonth, posts] of grouped){
212
202
  const [year, month] = yearMonth.split("-");
213
203
  if (!year || !month) continue;
214
- // Format label like "February 2024"
215
204
  const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
216
205
  const label = date.toLocaleDateString("en-US", {
217
206
  year: "numeric",
@@ -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
+ });
@@ -2,7 +2,7 @@
2
2
  * Posts API Routes
3
3
  */ import { Hono } from "hono";
4
4
  import * as sqid from "../../lib/sqid.js";
5
- import { CreatePostSchema, UpdatePostSchema, validateMediaForPostType } from "../../lib/schemas.js";
5
+ import { CreatePostSchema, UpdatePostSchema, validateMediaCount } from "../../lib/schemas.js";
6
6
  import { requireAuthApi } from "../../middleware/auth.js";
7
7
  import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../../lib/image.js";
8
8
  export const postsApiRoutes = new Hono();
@@ -31,18 +31,13 @@ export const postsApiRoutes = new Hono();
31
31
  }
32
32
  // List posts
33
33
  postsApiRoutes.get("/", async (c)=>{
34
- const type = c.req.query("type");
35
- const visibility = c.req.query("visibility");
34
+ const format = c.req.query("format");
35
+ const status = c.req.query("status");
36
36
  const cursor = c.req.query("cursor");
37
37
  const limit = parseInt(c.req.query("limit") ?? "100", 10);
38
38
  const posts = await c.var.services.posts.list({
39
- type,
40
- visibility: visibility ? [
41
- visibility
42
- ] : [
43
- "featured",
44
- "quiet"
45
- ],
39
+ format,
40
+ status: status ?? "published",
46
41
  cursor: cursor ? sqid.decode(cursor) ?? undefined : undefined,
47
42
  limit
48
43
  });
@@ -93,9 +88,9 @@ postsApiRoutes.post("/", requireAuthApi(), async (c)=>{
93
88
  }, 400);
94
89
  }
95
90
  const body = parseResult.data;
96
- // Validate media for post type
91
+ // Validate media count
97
92
  if (body.mediaIds) {
98
- const mediaError = validateMediaForPostType(body.type, body.mediaIds);
93
+ const mediaError = validateMediaCount(body.mediaIds);
99
94
  if (mediaError) {
100
95
  return c.json({
101
96
  error: mediaError
@@ -112,13 +107,17 @@ postsApiRoutes.post("/", requireAuthApi(), async (c)=>{
112
107
  }
113
108
  }
114
109
  const post = await c.var.services.posts.create({
115
- type: body.type,
110
+ format: body.format,
116
111
  title: body.title,
117
- content: body.content,
118
- visibility: body.visibility,
119
- sourceUrl: body.sourceUrl || undefined,
120
- sourceName: body.sourceName,
112
+ body: body.body,
121
113
  path: body.path || undefined,
114
+ status: body.status,
115
+ featured: body.featured,
116
+ pinned: body.pinned,
117
+ url: body.url || undefined,
118
+ quoteText: body.quoteText,
119
+ rating: body.rating || undefined,
120
+ collectionId: body.collectionId || undefined,
122
121
  replyToId: body.replyToId ? sqid.decode(body.replyToId) ?? undefined : undefined,
123
122
  publishedAt: body.publishedAt
124
123
  });
@@ -152,18 +151,9 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
152
151
  }, 400);
153
152
  }
154
153
  const body = parseResult.data;
155
- // Validate media for post type if mediaIds is provided
154
+ // Validate media count if mediaIds is provided
156
155
  if (body.mediaIds !== undefined) {
157
- // Need the post type — use the new type if provided, else fetch existing
158
- let postType = body.type;
159
- if (!postType) {
160
- const existing = await c.var.services.posts.getById(id);
161
- if (!existing) return c.json({
162
- error: "Not found"
163
- }, 404);
164
- postType = existing.type;
165
- }
166
- const mediaError = validateMediaForPostType(postType, body.mediaIds);
156
+ const mediaError = validateMediaCount(body.mediaIds);
167
157
  if (mediaError) {
168
158
  return c.json({
169
159
  error: mediaError
@@ -180,13 +170,17 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c)=>{
180
170
  }
181
171
  }
182
172
  const post = await c.var.services.posts.update(id, {
183
- type: body.type,
173
+ format: body.format,
184
174
  title: body.title,
185
- content: body.content,
186
- visibility: body.visibility,
187
- sourceUrl: body.sourceUrl,
188
- sourceName: body.sourceName,
175
+ body: body.body,
189
176
  path: body.path,
177
+ status: body.status,
178
+ featured: body.featured,
179
+ pinned: body.pinned,
180
+ url: body.url,
181
+ quoteText: body.quoteText,
182
+ rating: body.rating || undefined,
183
+ collectionId: body.collectionId || undefined,
190
184
  publishedAt: body.publishedAt
191
185
  });
192
186
  if (!post) return c.json({
@@ -21,21 +21,20 @@ searchApiRoutes.get("/", async (c)=>{
21
21
  try {
22
22
  const results = await c.var.services.search.search(query, {
23
23
  limit,
24
- visibility: [
25
- "featured",
26
- "quiet"
24
+ status: [
25
+ "published"
27
26
  ]
28
27
  });
29
28
  return c.json({
30
29
  query,
31
30
  results: results.map((r)=>({
32
31
  id: sqid.encode(r.post.id),
33
- type: r.post.type,
32
+ format: r.post.format,
34
33
  title: r.post.title,
35
34
  path: r.post.path,
36
35
  snippet: r.snippet,
37
36
  publishedAt: r.post.publishedAt,
38
- url: `/p/${sqid.encode(r.post.id)}`
37
+ url: r.post.path ? `/${r.post.path}` : `/p/${sqid.encode(r.post.id)}`
39
38
  })),
40
39
  count: results.length
41
40
  });