@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
@@ -2,39 +2,67 @@ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
2
2
  /**
3
3
  * Custom Page Route
4
4
  *
5
- * Catch-all route for custom pages accessible via their path field
5
+ * Serves pages from the pages table and posts with custom paths.
6
+ * This is a catch-all route mounted at "/" - must be registered last.
7
+ * Supports multi-level paths (e.g. /2024/my-post) for posts.
6
8
  */ import { Hono } from "hono";
7
- import { SinglePage as DefaultSinglePage } from "../../themes/minimal/pages/SinglePage.js";
9
+ import { SinglePage } from "../../ui/pages/SinglePage.js";
10
+ import { PostPage } from "../../ui/pages/PostPage.js";
8
11
  import { getNavigationData } from "../../lib/navigation.js";
9
12
  import { renderPublicPage } from "../../lib/render.js";
10
- import { createMediaContext, toPostViewFromPost } from "../../lib/view.js";
13
+ import { buildMediaMap } from "../../lib/media-helpers.js";
14
+ import { createMediaContext, toPageView, toPostView } from "../../lib/view.js";
11
15
  export const pageRoutes = new Hono();
12
- // Catch-all for custom page paths
13
- pageRoutes.get("/:path", async (c)=>{
14
- const path = c.req.param("path");
15
- // Look up page by path
16
- const page = await c.var.services.posts.getByPath(path);
17
- // Not found or not a page
18
- if (!page || page.type !== "page") {
19
- return c.notFound();
16
+ // Catch-all for custom page slugs and post paths (including multi-level)
17
+ pageRoutes.get("/*", async (c)=>{
18
+ const fullPath = c.req.path.slice(1); // Remove leading /
19
+ if (!fullPath) return c.notFound();
20
+ const isMultiSegment = fullPath.includes("/");
21
+ // Pages only have single-level slugs; skip page lookup for multi-segment paths
22
+ if (!isMultiSegment) {
23
+ const page = await c.var.services.pages.getBySlug(fullPath);
24
+ if (page) {
25
+ if (page.status === "draft") {
26
+ return c.notFound();
27
+ }
28
+ const navData = await getNavigationData(c);
29
+ const pageView = toPageView(page);
30
+ return renderPublicPage(c, {
31
+ title: `${page.title || fullPath} - ${navData.siteName}`,
32
+ description: page.body?.slice(0, 160),
33
+ navData,
34
+ content: /*#__PURE__*/ _jsx(SinglePage, {
35
+ page: pageView
36
+ })
37
+ });
38
+ }
20
39
  }
21
- // Don't show drafts
22
- if (page.visibility === "draft") {
23
- return c.notFound();
40
+ // Posts support multi-level paths
41
+ const post = await c.var.services.posts.getByPath(fullPath);
42
+ if (post) {
43
+ if (post.status === "draft") {
44
+ return c.notFound();
45
+ }
46
+ // Load media attachments
47
+ const rawMediaMap = await c.var.services.media.getByPostIds([
48
+ post.id
49
+ ]);
50
+ const mediaCtx = createMediaContext(c);
51
+ const mediaMap = buildMediaMap(rawMediaMap, mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl);
52
+ const postView = toPostView({
53
+ ...post,
54
+ mediaAttachments: mediaMap.get(post.id) ?? []
55
+ }, mediaCtx);
56
+ const navData = await getNavigationData(c);
57
+ const title = post.title || navData.siteName;
58
+ return renderPublicPage(c, {
59
+ title,
60
+ description: post.body?.slice(0, 160),
61
+ navData,
62
+ content: /*#__PURE__*/ _jsx(PostPage, {
63
+ post: postView
64
+ })
65
+ });
24
66
  }
25
- const navData = await getNavigationData(c);
26
- // Transform to View Model
27
- const mediaCtx = createMediaContext(c);
28
- const pageView = toPostViewFromPost(page, mediaCtx);
29
- const components = c.var.config.theme?.components;
30
- const Page = components?.SinglePage ?? DefaultSinglePage;
31
- return renderPublicPage(c, {
32
- title: `${page.title} - ${navData.siteName}`,
33
- description: page.content?.slice(0, 160),
34
- navData,
35
- content: /*#__PURE__*/ _jsx(Page, {
36
- page: pageView,
37
- theme: components
38
- })
39
- });
67
+ return c.notFound();
40
68
  });
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
2
2
  /**
3
3
  * Single Post Page Route
4
4
  */ import { Hono } from "hono";
5
- import { PostPage as DefaultPostPage } from "../../themes/minimal/pages/PostPage.js";
5
+ import { PostPage } from "../../ui/pages/PostPage.js";
6
6
  import * as sqid from "../../lib/sqid.js";
7
7
  import { getNavigationData } from "../../lib/navigation.js";
8
8
  import { renderPublicPage } from "../../lib/render.js";
@@ -11,20 +11,13 @@ import { createMediaContext, toPostView } from "../../lib/view.js";
11
11
  export const postRoutes = new Hono();
12
12
  postRoutes.get("/:id", async (c)=>{
13
13
  const paramId = c.req.param("id");
14
- // Try to decode as sqid first
15
- let id = sqid.decode(paramId);
16
- // If not a valid sqid, try to find by path
17
- if (!id) {
18
- const post = await c.var.services.posts.getByPath(paramId);
19
- if (post) {
20
- id = post.id;
21
- }
22
- }
14
+ // Decode sqid to numeric ID
15
+ const id = sqid.decode(paramId);
23
16
  if (!id) return c.notFound();
24
17
  const post = await c.var.services.posts.getById(id);
25
18
  if (!post) return c.notFound();
26
19
  // Don't show drafts on public site
27
- if (post.visibility === "draft") {
20
+ if (post.status === "draft") {
28
21
  return c.notFound();
29
22
  }
30
23
  // Batch load media attachments
@@ -40,15 +33,12 @@ postRoutes.get("/:id", async (c)=>{
40
33
  }, mediaCtx);
41
34
  const navData = await getNavigationData(c);
42
35
  const title = post.title || navData.siteName;
43
- const components = c.var.config.theme?.components;
44
- const Page = components?.PostPage ?? DefaultPostPage;
45
36
  return renderPublicPage(c, {
46
37
  title,
47
- description: post.content?.slice(0, 160),
38
+ description: post.body?.slice(0, 160),
48
39
  navData,
49
- content: /*#__PURE__*/ _jsx(Page, {
50
- post: postView,
51
- theme: components
40
+ content: /*#__PURE__*/ _jsx(PostPage, {
41
+ post: postView
52
42
  })
53
43
  });
54
44
  });
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
2
2
  /**
3
3
  * Search Page Route
4
4
  */ import { Hono } from "hono";
5
- import { SearchPage as DefaultSearchPage } from "../../themes/minimal/pages/SearchPage.js";
5
+ import { SearchPage } from "../../ui/pages/SearchPage.js";
6
6
  import { getNavigationData } from "../../lib/navigation.js";
7
7
  import { renderPublicPage } from "../../lib/render.js";
8
8
  import { createMediaContext, toSearchResultViews } from "../../lib/view.js";
@@ -23,9 +23,8 @@ searchRoutes.get("/", async (c)=>{
23
23
  results = await c.var.services.search.search(query, {
24
24
  limit: PAGE_SIZE + 1,
25
25
  offset: (page - 1) * PAGE_SIZE,
26
- visibility: [
27
- "featured",
28
- "quiet"
26
+ status: [
27
+ "published"
29
28
  ]
30
29
  });
31
30
  hasMore = results.length > PAGE_SIZE;
@@ -41,18 +40,15 @@ searchRoutes.get("/", async (c)=>{
41
40
  // Transform to View Models
42
41
  const mediaCtx = createMediaContext(c);
43
42
  const resultViews = toSearchResultViews(results, mediaCtx);
44
- const components = c.var.config.theme?.components;
45
- const Page = components?.SearchPage ?? DefaultSearchPage;
46
43
  return renderPublicPage(c, {
47
44
  title: query ? `Search: ${query} - ${navData.siteName}` : `Search - ${navData.siteName}`,
48
45
  navData,
49
- content: /*#__PURE__*/ _jsx(Page, {
46
+ content: /*#__PURE__*/ _jsx(SearchPage, {
50
47
  query: query,
51
48
  results: resultViews,
52
49
  error: error,
53
50
  hasMore: hasMore,
54
- page: page,
55
- theme: components
51
+ page: page
56
52
  })
57
53
  });
58
54
  });
@@ -1,37 +1,21 @@
1
1
  /**
2
- * Collection Service
2
+ * Collection Service (v2)
3
3
  *
4
- * Manages collections and post-collection relationships
5
- */ import { eq, desc, and } from "drizzle-orm";
6
- import { collections, postCollections, posts } from "../db/schema.js";
4
+ * Manages collections. Posts belong to collections via posts.collection_id (1:M).
5
+ */ import { eq, asc, sql, desc } from "drizzle-orm";
6
+ import { collections, posts } from "../db/schema.js";
7
7
  import { now } from "../lib/time.js";
8
8
  export function createCollectionService(db) {
9
9
  function toCollection(row) {
10
10
  return {
11
11
  id: row.id,
12
+ slug: row.slug,
12
13
  title: row.title,
13
- path: row.path,
14
14
  description: row.description,
15
- createdAt: row.createdAt,
16
- updatedAt: row.updatedAt
17
- };
18
- }
19
- function toPost(row) {
20
- return {
21
- id: row.id,
22
- type: row.type,
23
- visibility: row.visibility,
24
- title: row.title,
25
- path: row.path,
26
- content: row.content,
27
- contentHtml: row.contentHtml,
28
- sourceUrl: row.sourceUrl,
29
- sourceName: row.sourceName,
30
- sourceDomain: row.sourceDomain,
31
- replyToId: row.replyToId,
32
- threadId: row.threadId,
33
- deletedAt: row.deletedAt,
34
- publishedAt: row.publishedAt,
15
+ icon: row.icon,
16
+ sortOrder: row.sortOrder,
17
+ position: row.position,
18
+ showDivider: row.showDivider,
35
19
  createdAt: row.createdAt,
36
20
  updatedAt: row.updatedAt
37
21
  };
@@ -41,20 +25,32 @@ export function createCollectionService(db) {
41
25
  const result = await db.select().from(collections).where(eq(collections.id, id)).limit(1);
42
26
  return result[0] ? toCollection(result[0]) : null;
43
27
  },
44
- async getByPath (path) {
45
- const result = await db.select().from(collections).where(eq(collections.path, path)).limit(1);
28
+ async getBySlug (slug) {
29
+ const result = await db.select().from(collections).where(eq(collections.slug, slug)).limit(1);
46
30
  return result[0] ? toCollection(result[0]) : null;
47
31
  },
48
32
  async list () {
49
- const rows = await db.select().from(collections).orderBy(desc(collections.createdAt));
33
+ const rows = await db.select().from(collections).orderBy(asc(collections.position), desc(collections.createdAt));
50
34
  return rows.map(toCollection);
51
35
  },
52
36
  async create (data) {
53
37
  const timestamp = now();
38
+ let position = data.position;
39
+ if (position === undefined) {
40
+ const maxResult = await db.select({
41
+ maxPos: sql`COALESCE(MAX(position), -1)`
42
+ }).from(collections);
43
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
44
+ position = maxResult[0].maxPos + 1;
45
+ }
54
46
  const result = await db.insert(collections).values({
47
+ slug: data.slug,
55
48
  title: data.title,
56
- path: data.path || null,
57
49
  description: data.description ?? null,
50
+ icon: data.icon ?? null,
51
+ sortOrder: data.sortOrder ?? "newest",
52
+ position,
53
+ showDivider: data.showDivider ? 1 : 0,
58
54
  createdAt: timestamp,
59
55
  updatedAt: timestamp
60
56
  }).returning();
@@ -69,53 +65,45 @@ export function createCollectionService(db) {
69
65
  updatedAt: timestamp
70
66
  };
71
67
  if (data.title !== undefined) updates.title = data.title;
72
- if (data.path !== undefined) updates.path = data.path;
68
+ if (data.slug !== undefined) updates.slug = data.slug;
73
69
  if (data.description !== undefined) updates.description = data.description;
70
+ if (data.icon !== undefined) updates.icon = data.icon;
71
+ if (data.sortOrder !== undefined) updates.sortOrder = data.sortOrder;
72
+ if (data.position !== undefined) updates.position = data.position;
73
+ if (data.showDivider !== undefined) updates.showDivider = data.showDivider ? 1 : 0;
74
74
  const result = await db.update(collections).set(updates).where(eq(collections.id, id)).returning();
75
75
  return result[0] ? toCollection(result[0]) : null;
76
76
  },
77
77
  async delete (id) {
78
- // Delete all post-collection relationships first
79
- await db.delete(postCollections).where(eq(postCollections.collectionId, id));
78
+ // Clear collection_id on posts that belong to this collection
79
+ await db.update(posts).set({
80
+ collectionId: null
81
+ }).where(eq(posts.collectionId, id));
80
82
  const result = await db.delete(collections).where(eq(collections.id, id)).returning();
81
83
  return result.length > 0;
82
84
  },
83
- async addPost (collectionId, postId) {
85
+ async reorder (ids) {
84
86
  const timestamp = now();
85
- // Upsert the relationship
86
- await db.insert(postCollections).values({
87
- postId,
88
- collectionId,
89
- addedAt: timestamp
90
- }).onConflictDoNothing();
91
- },
92
- async removePost (collectionId, postId) {
93
- await db.delete(postCollections).where(and(eq(postCollections.collectionId, collectionId), eq(postCollections.postId, postId)));
94
- },
95
- async getPosts (collectionId) {
96
- const rows = await db.select({
97
- post: posts
98
- }).from(postCollections).innerJoin(posts, eq(postCollections.postId, posts.id)).where(eq(postCollections.collectionId, collectionId)).orderBy(desc(postCollections.addedAt));
99
- return rows.map((r)=>toPost(r.post));
87
+ for(let i = 0; i < ids.length; i++){
88
+ await db.update(collections).set({
89
+ position: i,
90
+ updatedAt: timestamp
91
+ })// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loop index guarantees element exists
92
+ .where(eq(collections.id, ids[i]));
93
+ }
100
94
  },
101
- async getCollectionsForPost (postId) {
95
+ async getPostCounts () {
102
96
  const rows = await db.select({
103
- collection: collections
104
- }).from(postCollections).innerJoin(collections, eq(postCollections.collectionId, collections.id)).where(eq(postCollections.postId, postId));
105
- return rows.map((r)=>toCollection(r.collection));
106
- },
107
- async syncPostCollections (postId, collectionIds) {
108
- const current = await this.getCollectionsForPost(postId);
109
- const currentIds = new Set(current.map((c)=>c.id));
110
- const desiredIds = new Set(collectionIds);
111
- const toAdd = collectionIds.filter((id)=>!currentIds.has(id));
112
- const toRemove = current.map((c)=>c.id).filter((id)=>!desiredIds.has(id));
113
- for (const collectionId of toAdd){
114
- await this.addPost(collectionId, postId);
115
- }
116
- for (const collectionId of toRemove){
117
- await this.removePost(collectionId, postId);
97
+ collectionId: posts.collectionId,
98
+ count: sql`count(*)`.as("count")
99
+ }).from(posts).where(sql`${posts.collectionId} IS NOT NULL AND ${posts.deletedAt} IS NULL`).groupBy(posts.collectionId);
100
+ const counts = new Map();
101
+ for (const row of rows){
102
+ if (row.collectionId !== null) {
103
+ counts.set(row.collectionId, row.count);
104
+ }
118
105
  }
106
+ return counts;
119
107
  }
120
108
  };
121
109
  }
@@ -1,22 +1,24 @@
1
1
  /**
2
- * Services
2
+ * Services (v2)
3
3
  *
4
4
  * Business logic layer
5
5
  */ import { createSettingsService } from "./settings.js";
6
6
  import { createPostService } from "./post.js";
7
+ import { createPageService } from "./page.js";
7
8
  import { createRedirectService } from "./redirect.js";
8
9
  import { createMediaService } from "./media.js";
9
10
  import { createCollectionService } from "./collection.js";
10
11
  import { createSearchService } from "./search.js";
11
- import { createNavigationLinkService } from "./navigation.js";
12
+ import { createNavItemService } from "./navigation.js";
12
13
  export function createServices(db, d1) {
13
14
  return {
14
15
  settings: createSettingsService(db),
15
16
  posts: createPostService(db),
17
+ pages: createPageService(db),
16
18
  redirects: createRedirectService(db),
17
19
  media: createMediaService(db),
18
20
  collections: createCollectionService(db),
19
21
  search: createSearchService(d1),
20
- navigationLinks: createNavigationLinkService(db)
22
+ navItems: createNavItemService(db)
21
23
  };
22
24
  }
@@ -1,16 +1,18 @@
1
1
  /**
2
- * Navigation Link Service
2
+ * Nav Item Service (v2)
3
3
  *
4
- * Manages navigation links displayed on public pages
4
+ * Manages navigation items (page links and external links)
5
5
  */ import { eq, asc, sql } from "drizzle-orm";
6
- import { navigationLinks } from "../db/schema.js";
6
+ import { navItems } from "../db/schema.js";
7
7
  import { now } from "../lib/time.js";
8
- export function createNavigationLinkService(db) {
9
- function toNavigationLink(row) {
8
+ export function createNavItemService(db) {
9
+ function toNavItem(row) {
10
10
  return {
11
11
  id: row.id,
12
+ type: row.type,
12
13
  label: row.label,
13
14
  url: row.url,
15
+ pageId: row.pageId,
14
16
  position: row.position,
15
17
  createdAt: row.createdAt,
16
18
  updatedAt: row.updatedAt
@@ -18,12 +20,12 @@ export function createNavigationLinkService(db) {
18
20
  }
19
21
  return {
20
22
  async list () {
21
- const rows = await db.select().from(navigationLinks).orderBy(asc(navigationLinks.position));
22
- return rows.map(toNavigationLink);
23
+ const rows = await db.select().from(navItems).orderBy(asc(navItems.position));
24
+ return rows.map(toNavItem);
23
25
  },
24
26
  async getById (id) {
25
- const result = await db.select().from(navigationLinks).where(eq(navigationLinks.id, id)).limit(1);
26
- return result[0] ? toNavigationLink(result[0]) : null;
27
+ const result = await db.select().from(navItems).where(eq(navItems.id, id)).limit(1);
28
+ return result[0] ? toNavItem(result[0]) : null;
27
29
  },
28
30
  async create (data) {
29
31
  const timestamp = now();
@@ -31,85 +33,59 @@ export function createNavigationLinkService(db) {
31
33
  if (position === undefined) {
32
34
  const maxResult = await db.select({
33
35
  maxPos: sql`COALESCE(MAX(position), -1)`
34
- }).from(navigationLinks);
36
+ }).from(navItems);
35
37
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
36
38
  position = maxResult[0].maxPos + 1;
37
39
  }
38
- const result = await db.insert(navigationLinks).values({
40
+ const result = await db.insert(navItems).values({
41
+ type: data.type,
39
42
  label: data.label,
40
43
  url: data.url,
44
+ pageId: data.pageId ?? null,
41
45
  position,
42
46
  createdAt: timestamp,
43
47
  updatedAt: timestamp
44
48
  }).returning();
45
49
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
46
- return toNavigationLink(result[0]);
50
+ return toNavItem(result[0]);
47
51
  },
48
52
  async update (id, data) {
49
- const existing = await db.select().from(navigationLinks).where(eq(navigationLinks.id, id)).limit(1);
53
+ const existing = await db.select().from(navItems).where(eq(navItems.id, id)).limit(1);
50
54
  if (!existing[0]) return null;
51
55
  const timestamp = now();
52
- const result = await db.update(navigationLinks).set({
56
+ const result = await db.update(navItems).set({
57
+ ...data.type !== undefined && {
58
+ type: data.type
59
+ },
53
60
  ...data.label !== undefined && {
54
61
  label: data.label
55
62
  },
56
63
  ...data.url !== undefined && {
57
64
  url: data.url
58
65
  },
66
+ ...data.pageId !== undefined && {
67
+ pageId: data.pageId
68
+ },
59
69
  ...data.position !== undefined && {
60
70
  position: data.position
61
71
  },
62
72
  updatedAt: timestamp
63
- }).where(eq(navigationLinks.id, id)).returning();
64
- return result[0] ? toNavigationLink(result[0]) : null;
73
+ }).where(eq(navItems.id, id)).returning();
74
+ return result[0] ? toNavItem(result[0]) : null;
65
75
  },
66
76
  async delete (id) {
67
- const result = await db.delete(navigationLinks).where(eq(navigationLinks.id, id)).returning();
77
+ const result = await db.delete(navItems).where(eq(navItems.id, id)).returning();
68
78
  return result.length > 0;
69
79
  },
70
80
  async reorder (ids) {
71
81
  const timestamp = now();
72
82
  for(let i = 0; i < ids.length; i++){
73
- await db.update(navigationLinks).set({
83
+ await db.update(navItems).set({
74
84
  position: i,
75
85
  updatedAt: timestamp
76
86
  })// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loop index guarantees element exists
77
- .where(eq(navigationLinks.id, ids[i]));
78
- }
79
- },
80
- async ensureDefaults () {
81
- const existing = await db.select().from(navigationLinks).limit(1);
82
- if (existing.length > 0) {
83
- const rows = await db.select().from(navigationLinks).orderBy(asc(navigationLinks.position));
84
- return rows.map(toNavigationLink);
85
- }
86
- const timestamp = now();
87
- const defaults = [
88
- {
89
- label: "Home",
90
- url: "/",
91
- position: 0
92
- },
93
- {
94
- label: "Archive",
95
- url: "/archive",
96
- position: 1
97
- },
98
- {
99
- label: "RSS",
100
- url: "/feed",
101
- position: 2
102
- }
103
- ];
104
- for (const link of defaults){
105
- await db.insert(navigationLinks).values({
106
- ...link,
107
- createdAt: timestamp,
108
- updatedAt: timestamp
109
- });
87
+ .where(eq(navItems.id, ids[i]));
110
88
  }
111
- const rows = await db.select().from(navigationLinks).orderBy(asc(navigationLinks.position));
112
- return rows.map(toNavigationLink);
113
89
  }
114
90
  };
115
91
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Page Service
3
+ *
4
+ * CRUD operations for standalone pages (about, now, etc.)
5
+ */ import { eq, desc, sql } from "drizzle-orm";
6
+ import { pages, navItems } from "../db/schema.js";
7
+ import { now } from "../lib/time.js";
8
+ import { render as renderMarkdown } from "../lib/markdown.js";
9
+ export function createPageService(db) {
10
+ function toPage(row) {
11
+ return {
12
+ id: row.id,
13
+ slug: row.slug,
14
+ title: row.title,
15
+ body: row.body,
16
+ bodyHtml: row.bodyHtml,
17
+ status: row.status,
18
+ createdAt: row.createdAt,
19
+ updatedAt: row.updatedAt
20
+ };
21
+ }
22
+ return {
23
+ async getById (id) {
24
+ const result = await db.select().from(pages).where(eq(pages.id, id)).limit(1);
25
+ return result[0] ? toPage(result[0]) : null;
26
+ },
27
+ async getBySlug (slug) {
28
+ const result = await db.select().from(pages).where(eq(pages.slug, slug)).limit(1);
29
+ return result[0] ? toPage(result[0]) : null;
30
+ },
31
+ async list () {
32
+ const rows = await db.select().from(pages).orderBy(desc(pages.createdAt));
33
+ return rows.map(toPage);
34
+ },
35
+ async listNotInNav () {
36
+ const rows = await db.select().from(pages).where(sql`${pages.id} NOT IN (SELECT ${navItems.pageId} FROM ${navItems} WHERE ${navItems.pageId} IS NOT NULL)`).orderBy(desc(pages.createdAt));
37
+ return rows.map(toPage);
38
+ },
39
+ async create (data) {
40
+ const timestamp = now();
41
+ const bodyHtml = data.body ? renderMarkdown(data.body) : null;
42
+ const result = await db.insert(pages).values({
43
+ slug: data.slug,
44
+ title: data.title ?? null,
45
+ body: data.body ?? null,
46
+ bodyHtml,
47
+ status: data.status ?? "published",
48
+ createdAt: timestamp,
49
+ updatedAt: timestamp
50
+ }).returning();
51
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
52
+ return toPage(result[0]);
53
+ },
54
+ async update (id, data) {
55
+ const existing = await this.getById(id);
56
+ if (!existing) return null;
57
+ const timestamp = now();
58
+ const updates = {
59
+ updatedAt: timestamp
60
+ };
61
+ if (data.slug !== undefined) updates.slug = data.slug;
62
+ if (data.title !== undefined) updates.title = data.title;
63
+ if (data.status !== undefined) updates.status = data.status;
64
+ if (data.body !== undefined) {
65
+ updates.body = data.body;
66
+ updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
67
+ }
68
+ // If slug changed, update related nav_items
69
+ if (data.slug !== undefined && data.slug !== existing.slug) {
70
+ await db.update(navItems).set({
71
+ url: `/${data.slug}`,
72
+ updatedAt: timestamp
73
+ }).where(eq(navItems.pageId, id));
74
+ }
75
+ const result = await db.update(pages).set(updates).where(eq(pages.id, id)).returning();
76
+ return result[0] ? toPage(result[0]) : null;
77
+ },
78
+ async delete (id) {
79
+ // nav_items with page_id FK have ON DELETE CASCADE, so they auto-delete
80
+ const result = await db.delete(pages).where(eq(pages.id, id)).returning();
81
+ return result.length > 0;
82
+ }
83
+ };
84
+ }