@jant/core 0.3.22 → 0.3.24

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 (178) hide show
  1. package/dist/app.js +23 -5
  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 -6
  7. package/dist/lib/constants.js +1 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/navigation.js +4 -5
  11. package/dist/lib/render.js +1 -1
  12. package/dist/lib/schemas.js +80 -38
  13. package/dist/lib/theme-components.js +8 -11
  14. package/dist/lib/time.js +56 -1
  15. package/dist/lib/timeline.js +119 -0
  16. package/dist/lib/view.js +62 -73
  17. package/dist/routes/api/posts.js +29 -35
  18. package/dist/routes/api/search.js +5 -6
  19. package/dist/routes/api/upload.js +13 -13
  20. package/dist/routes/dash/collections.js +22 -40
  21. package/dist/routes/dash/index.js +2 -2
  22. package/dist/routes/dash/navigation.js +25 -24
  23. package/dist/routes/dash/pages.js +42 -57
  24. package/dist/routes/dash/posts.js +27 -35
  25. package/dist/routes/feed/rss.js +2 -4
  26. package/dist/routes/feed/sitemap.js +10 -7
  27. package/dist/routes/pages/archive.js +12 -11
  28. package/dist/routes/pages/collection.js +11 -5
  29. package/dist/routes/pages/home.js +53 -61
  30. package/dist/routes/pages/page.js +60 -29
  31. package/dist/routes/pages/post.js +5 -12
  32. package/dist/routes/pages/search.js +3 -4
  33. package/dist/services/collection.js +52 -64
  34. package/dist/services/index.js +5 -3
  35. package/dist/services/navigation.js +29 -53
  36. package/dist/services/page.js +80 -0
  37. package/dist/services/post.js +68 -69
  38. package/dist/services/search.js +24 -18
  39. package/dist/theme/components/MediaGallery.js +19 -91
  40. package/dist/theme/components/PageForm.js +15 -15
  41. package/dist/theme/components/PostForm.js +136 -129
  42. package/dist/theme/components/PostList.js +13 -8
  43. package/dist/theme/components/ThreadView.js +3 -3
  44. package/dist/theme/components/TypeBadge.js +3 -14
  45. package/dist/theme/components/VisibilityBadge.js +33 -23
  46. package/dist/theme/components/index.js +0 -2
  47. package/dist/theme/index.js +10 -16
  48. package/dist/theme/layouts/index.js +0 -1
  49. package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
  50. package/dist/themes/threads/index.js +81 -0
  51. package/dist/{theme → themes/threads}/pages/ArchivePage.js +31 -47
  52. package/dist/themes/threads/pages/CollectionPage.js +65 -0
  53. package/dist/{theme → themes/threads}/pages/HomePage.js +4 -5
  54. package/dist/{theme → themes/threads}/pages/PostPage.js +10 -8
  55. package/dist/{theme → themes/threads}/pages/SearchPage.js +8 -8
  56. package/dist/{theme → themes/threads}/pages/SinglePage.js +5 -6
  57. package/dist/{theme/components → themes/threads}/timeline/LinkCard.js +20 -11
  58. package/dist/themes/threads/timeline/NoteCard.js +53 -0
  59. package/dist/themes/threads/timeline/QuoteCard.js +59 -0
  60. package/dist/{theme/components → themes/threads}/timeline/ThreadPreview.js +5 -6
  61. package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
  62. package/dist/{theme/components → themes/threads}/timeline/TimelineItem.js +8 -17
  63. package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
  64. package/dist/themes/threads/timeline/groupByDate.js +22 -0
  65. package/dist/themes/threads/timeline/timelineMore.js +107 -0
  66. package/dist/types.js +24 -40
  67. package/package.json +2 -1
  68. package/src/__tests__/helpers/app.ts +4 -0
  69. package/src/__tests__/helpers/db.ts +51 -74
  70. package/src/app.tsx +27 -6
  71. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  72. package/src/db/migrations/meta/_journal.json +7 -0
  73. package/src/db/schema.ts +63 -46
  74. package/src/i18n/locales/en.po +216 -164
  75. package/src/i18n/locales/en.ts +1 -1
  76. package/src/i18n/locales/zh-Hans.po +216 -164
  77. package/src/i18n/locales/zh-Hans.ts +1 -1
  78. package/src/i18n/locales/zh-Hant.po +216 -164
  79. package/src/i18n/locales/zh-Hant.ts +1 -1
  80. package/src/index.ts +30 -15
  81. package/src/lib/__tests__/excerpt.test.ts +125 -0
  82. package/src/lib/__tests__/schemas.test.ts +166 -105
  83. package/src/lib/__tests__/theme-components.test.ts +4 -25
  84. package/src/lib/__tests__/time.test.ts +62 -0
  85. package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
  86. package/src/lib/__tests__/view.test.ts +217 -67
  87. package/src/lib/constants.ts +1 -4
  88. package/src/lib/excerpt.ts +87 -0
  89. package/src/lib/feed.ts +22 -7
  90. package/src/lib/navigation.ts +6 -7
  91. package/src/lib/render.tsx +1 -1
  92. package/src/lib/schemas.ts +118 -52
  93. package/src/lib/theme-components.ts +10 -13
  94. package/src/lib/time.ts +64 -0
  95. package/src/lib/timeline.ts +170 -0
  96. package/src/lib/view.ts +81 -83
  97. package/src/preset.css +45 -0
  98. package/src/routes/api/__tests__/posts.test.ts +50 -108
  99. package/src/routes/api/__tests__/search.test.ts +2 -3
  100. package/src/routes/api/posts.ts +30 -30
  101. package/src/routes/api/search.ts +4 -4
  102. package/src/routes/api/upload.ts +16 -6
  103. package/src/routes/dash/collections.tsx +18 -40
  104. package/src/routes/dash/index.tsx +2 -2
  105. package/src/routes/dash/navigation.tsx +27 -26
  106. package/src/routes/dash/pages.tsx +45 -60
  107. package/src/routes/dash/posts.tsx +44 -52
  108. package/src/routes/feed/rss.ts +2 -1
  109. package/src/routes/feed/sitemap.ts +14 -4
  110. package/src/routes/pages/archive.tsx +14 -10
  111. package/src/routes/pages/collection.tsx +17 -6
  112. package/src/routes/pages/home.tsx +56 -81
  113. package/src/routes/pages/page.tsx +64 -27
  114. package/src/routes/pages/post.tsx +5 -14
  115. package/src/routes/pages/search.tsx +2 -2
  116. package/src/services/__tests__/collection.test.ts +257 -158
  117. package/src/services/__tests__/media.test.ts +18 -18
  118. package/src/services/__tests__/navigation.test.ts +161 -87
  119. package/src/services/__tests__/post-timeline.test.ts +92 -88
  120. package/src/services/__tests__/post.test.ts +342 -206
  121. package/src/services/__tests__/search.test.ts +19 -25
  122. package/src/services/collection.ts +71 -113
  123. package/src/services/index.ts +9 -8
  124. package/src/services/navigation.ts +38 -71
  125. package/src/services/page.ts +124 -0
  126. package/src/services/post.ts +93 -103
  127. package/src/services/search.ts +38 -27
  128. package/src/styles/components.css +0 -54
  129. package/src/theme/components/MediaGallery.tsx +27 -96
  130. package/src/theme/components/PageForm.tsx +21 -21
  131. package/src/theme/components/PostForm.tsx +122 -118
  132. package/src/theme/components/PostList.tsx +58 -49
  133. package/src/theme/components/ThreadView.tsx +6 -3
  134. package/src/theme/components/TypeBadge.tsx +9 -17
  135. package/src/theme/components/VisibilityBadge.tsx +40 -23
  136. package/src/theme/components/index.ts +0 -13
  137. package/src/theme/index.ts +10 -16
  138. package/src/theme/layouts/index.ts +0 -1
  139. package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
  140. package/src/themes/threads/index.ts +100 -0
  141. package/src/{theme → themes/threads}/pages/ArchivePage.tsx +52 -55
  142. package/src/themes/threads/pages/CollectionPage.tsx +61 -0
  143. package/src/{theme → themes/threads}/pages/HomePage.tsx +5 -6
  144. package/src/{theme → themes/threads}/pages/PostPage.tsx +11 -8
  145. package/src/{theme → themes/threads}/pages/SearchPage.tsx +9 -13
  146. package/src/themes/threads/pages/SinglePage.tsx +23 -0
  147. package/src/themes/threads/style.css +336 -0
  148. package/src/{theme/components → themes/threads}/timeline/LinkCard.tsx +21 -13
  149. package/src/themes/threads/timeline/NoteCard.tsx +58 -0
  150. package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
  151. package/src/{theme/components → themes/threads}/timeline/ThreadPreview.tsx +6 -6
  152. package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
  153. package/src/{theme/components → themes/threads}/timeline/TimelineItem.tsx +9 -20
  154. package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
  155. package/src/themes/threads/timeline/groupByDate.ts +30 -0
  156. package/src/themes/threads/timeline/timelineMore.tsx +130 -0
  157. package/src/types.ts +242 -98
  158. package/dist/routes/api/timeline.js +0 -120
  159. package/dist/theme/components/timeline/ArticleCard.js +0 -46
  160. package/dist/theme/components/timeline/ImageCard.js +0 -83
  161. package/dist/theme/components/timeline/NoteCard.js +0 -34
  162. package/dist/theme/components/timeline/QuoteCard.js +0 -48
  163. package/dist/theme/components/timeline/TimelineFeed.js +0 -46
  164. package/dist/theme/components/timeline/index.js +0 -8
  165. package/dist/theme/layouts/SiteLayout.js +0 -131
  166. package/dist/theme/pages/CollectionPage.js +0 -63
  167. package/dist/theme/pages/index.js +0 -11
  168. package/src/routes/api/timeline.tsx +0 -159
  169. package/src/theme/components/timeline/ArticleCard.tsx +0 -45
  170. package/src/theme/components/timeline/ImageCard.tsx +0 -70
  171. package/src/theme/components/timeline/NoteCard.tsx +0 -34
  172. package/src/theme/components/timeline/QuoteCard.tsx +0 -48
  173. package/src/theme/components/timeline/TimelineFeed.tsx +0 -56
  174. package/src/theme/components/timeline/index.ts +0 -8
  175. package/src/theme/layouts/SiteLayout.tsx +0 -132
  176. package/src/theme/pages/CollectionPage.tsx +0 -60
  177. package/src/theme/pages/SinglePage.tsx +0 -24
  178. package/src/theme/pages/index.ts +0 -13
@@ -2,39 +2,70 @@ 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 slugs.
6
+ * This is a catch-all route mounted at "/" - must be registered last.
6
7
  */ import { Hono } from "hono";
7
- import { SinglePage as DefaultSinglePage } from "../../theme/pages/SinglePage.js";
8
+ import { SinglePage as DefaultSinglePage } from "../../themes/threads/pages/SinglePage.js";
9
+ import { PostPage as DefaultPostPage } from "../../themes/threads/pages/PostPage.js";
8
10
  import { getNavigationData } from "../../lib/navigation.js";
9
11
  import { renderPublicPage } from "../../lib/render.js";
10
- import { createMediaContext, toPostViewFromPost } from "../../lib/view.js";
12
+ import { buildMediaMap } from "../../lib/media-helpers.js";
13
+ import { createMediaContext, toPageView, toPostView } from "../../lib/view.js";
11
14
  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();
15
+ // Catch-all for custom page paths and post slugs
16
+ pageRoutes.get("/:slug", async (c)=>{
17
+ const slug = c.req.param("slug");
18
+ // First, try to find a page by slug
19
+ const page = await c.var.services.pages.getBySlug(slug);
20
+ if (page) {
21
+ // Don't show draft pages
22
+ if (page.status === "draft") {
23
+ return c.notFound();
24
+ }
25
+ const navData = await getNavigationData(c);
26
+ const pageView = toPageView(page);
27
+ const components = c.var.config.theme?.components;
28
+ const Page = components?.SinglePage ?? DefaultSinglePage;
29
+ return renderPublicPage(c, {
30
+ title: `${page.title || slug} - ${navData.siteName}`,
31
+ description: page.body?.slice(0, 160),
32
+ navData,
33
+ content: /*#__PURE__*/ _jsx(Page, {
34
+ page: pageView,
35
+ theme: components
36
+ })
37
+ });
20
38
  }
21
- // Don't show drafts
22
- if (page.visibility === "draft") {
23
- return c.notFound();
39
+ // Then, try to find a post by slug
40
+ const post = await c.var.services.posts.getBySlug(slug);
41
+ if (post) {
42
+ // Don't show draft posts
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
+ const components = c.var.config.theme?.components;
59
+ const PostPage = components?.PostPage ?? DefaultPostPage;
60
+ return renderPublicPage(c, {
61
+ title,
62
+ description: post.body?.slice(0, 160),
63
+ navData,
64
+ content: /*#__PURE__*/ _jsx(PostPage, {
65
+ post: postView,
66
+ theme: components
67
+ })
68
+ });
24
69
  }
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
- });
70
+ return c.notFound();
40
71
  });
@@ -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 "../../theme/pages/PostPage.js";
5
+ import { PostPage as DefaultPostPage } from "../../themes/threads/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
@@ -44,7 +37,7 @@ postRoutes.get("/:id", async (c)=>{
44
37
  const Page = components?.PostPage ?? DefaultPostPage;
45
38
  return renderPublicPage(c, {
46
39
  title,
47
- description: post.content?.slice(0, 160),
40
+ description: post.body?.slice(0, 160),
48
41
  navData,
49
42
  content: /*#__PURE__*/ _jsx(Page, {
50
43
  post: postView,
@@ -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 "../../theme/pages/SearchPage.js";
5
+ import { SearchPage as DefaultSearchPage } from "../../themes/threads/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;
@@ -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,80 @@
1
+ /**
2
+ * Page Service
3
+ *
4
+ * CRUD operations for standalone pages (about, now, etc.)
5
+ */ import { eq, desc } 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 create (data) {
36
+ const timestamp = now();
37
+ const bodyHtml = data.body ? renderMarkdown(data.body) : null;
38
+ const result = await db.insert(pages).values({
39
+ slug: data.slug,
40
+ title: data.title ?? null,
41
+ body: data.body ?? null,
42
+ bodyHtml,
43
+ status: data.status ?? "published",
44
+ createdAt: timestamp,
45
+ updatedAt: timestamp
46
+ }).returning();
47
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
48
+ return toPage(result[0]);
49
+ },
50
+ async update (id, data) {
51
+ const existing = await this.getById(id);
52
+ if (!existing) return null;
53
+ const timestamp = now();
54
+ const updates = {
55
+ updatedAt: timestamp
56
+ };
57
+ if (data.slug !== undefined) updates.slug = data.slug;
58
+ if (data.title !== undefined) updates.title = data.title;
59
+ if (data.status !== undefined) updates.status = data.status;
60
+ if (data.body !== undefined) {
61
+ updates.body = data.body;
62
+ updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
63
+ }
64
+ // If slug changed, update related nav_items
65
+ if (data.slug !== undefined && data.slug !== existing.slug) {
66
+ await db.update(navItems).set({
67
+ url: `/${data.slug}`,
68
+ updatedAt: timestamp
69
+ }).where(eq(navItems.pageId, id));
70
+ }
71
+ const result = await db.update(pages).set(updates).where(eq(pages.id, id)).returning();
72
+ return result[0] ? toPage(result[0]) : null;
73
+ },
74
+ async delete (id) {
75
+ // nav_items with page_id FK have ON DELETE CASCADE, so they auto-delete
76
+ const result = await db.delete(pages).where(eq(pages.id, id)).returning();
77
+ return result.length > 0;
78
+ }
79
+ };
80
+ }