@jant/core 0.3.23 → 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 (169) hide show
  1. package/dist/app.js +4 -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 +3 -3
  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 +61 -72
  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/themes/threads/ThreadsSiteLayout.js +172 -0
  47. package/dist/themes/threads/index.js +81 -0
  48. package/dist/themes/{minimal → threads}/pages/ArchivePage.js +32 -47
  49. package/dist/themes/threads/pages/CollectionPage.js +65 -0
  50. package/dist/themes/{minimal → threads}/pages/HomePage.js +3 -3
  51. package/dist/themes/{minimal → threads}/pages/PostPage.js +12 -9
  52. package/dist/themes/{minimal → threads}/pages/SearchPage.js +13 -14
  53. package/dist/themes/{minimal → threads}/pages/SinglePage.js +4 -4
  54. package/dist/themes/threads/timeline/LinkCard.js +68 -0
  55. package/dist/themes/threads/timeline/NoteCard.js +53 -0
  56. package/dist/themes/threads/timeline/QuoteCard.js +59 -0
  57. package/dist/themes/{minimal → threads}/timeline/ThreadPreview.js +17 -13
  58. package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
  59. package/dist/themes/{minimal → threads}/timeline/TimelineItem.js +8 -16
  60. package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
  61. package/dist/themes/threads/timeline/groupByDate.js +22 -0
  62. package/dist/themes/threads/timeline/timelineMore.js +107 -0
  63. package/dist/types.js +24 -40
  64. package/package.json +2 -1
  65. package/src/__tests__/helpers/app.ts +4 -0
  66. package/src/__tests__/helpers/db.ts +51 -74
  67. package/src/app.tsx +4 -6
  68. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  69. package/src/db/migrations/meta/_journal.json +7 -0
  70. package/src/db/schema.ts +63 -46
  71. package/src/i18n/locales/en.po +216 -164
  72. package/src/i18n/locales/en.ts +1 -1
  73. package/src/i18n/locales/zh-Hans.po +216 -164
  74. package/src/i18n/locales/zh-Hans.ts +1 -1
  75. package/src/i18n/locales/zh-Hant.po +216 -164
  76. package/src/i18n/locales/zh-Hant.ts +1 -1
  77. package/src/index.ts +28 -12
  78. package/src/lib/__tests__/excerpt.test.ts +125 -0
  79. package/src/lib/__tests__/schemas.test.ts +166 -105
  80. package/src/lib/__tests__/theme-components.test.ts +4 -25
  81. package/src/lib/__tests__/time.test.ts +62 -0
  82. package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
  83. package/src/lib/__tests__/view.test.ts +199 -51
  84. package/src/lib/constants.ts +1 -4
  85. package/src/lib/excerpt.ts +87 -0
  86. package/src/lib/feed.ts +22 -7
  87. package/src/lib/navigation.ts +6 -7
  88. package/src/lib/render.tsx +1 -1
  89. package/src/lib/schemas.ts +118 -52
  90. package/src/lib/theme-components.ts +10 -13
  91. package/src/lib/time.ts +64 -0
  92. package/src/lib/timeline.ts +170 -0
  93. package/src/lib/view.ts +80 -82
  94. package/src/preset.css +45 -0
  95. package/src/routes/api/__tests__/posts.test.ts +50 -108
  96. package/src/routes/api/__tests__/search.test.ts +2 -3
  97. package/src/routes/api/posts.ts +30 -30
  98. package/src/routes/api/search.ts +4 -4
  99. package/src/routes/api/upload.ts +16 -6
  100. package/src/routes/dash/collections.tsx +18 -40
  101. package/src/routes/dash/index.tsx +2 -2
  102. package/src/routes/dash/navigation.tsx +27 -26
  103. package/src/routes/dash/pages.tsx +45 -60
  104. package/src/routes/dash/posts.tsx +44 -52
  105. package/src/routes/feed/rss.ts +2 -1
  106. package/src/routes/feed/sitemap.ts +14 -4
  107. package/src/routes/pages/archive.tsx +14 -10
  108. package/src/routes/pages/collection.tsx +17 -6
  109. package/src/routes/pages/home.tsx +56 -81
  110. package/src/routes/pages/page.tsx +64 -27
  111. package/src/routes/pages/post.tsx +5 -14
  112. package/src/routes/pages/search.tsx +2 -2
  113. package/src/services/__tests__/collection.test.ts +257 -158
  114. package/src/services/__tests__/media.test.ts +18 -18
  115. package/src/services/__tests__/navigation.test.ts +161 -87
  116. package/src/services/__tests__/post-timeline.test.ts +92 -88
  117. package/src/services/__tests__/post.test.ts +342 -206
  118. package/src/services/__tests__/search.test.ts +19 -25
  119. package/src/services/collection.ts +71 -113
  120. package/src/services/index.ts +9 -8
  121. package/src/services/navigation.ts +38 -71
  122. package/src/services/page.ts +124 -0
  123. package/src/services/post.ts +93 -103
  124. package/src/services/search.ts +38 -27
  125. package/src/theme/components/MediaGallery.tsx +27 -96
  126. package/src/theme/components/PageForm.tsx +21 -21
  127. package/src/theme/components/PostForm.tsx +122 -118
  128. package/src/theme/components/PostList.tsx +58 -49
  129. package/src/theme/components/ThreadView.tsx +6 -3
  130. package/src/theme/components/TypeBadge.tsx +9 -17
  131. package/src/theme/components/VisibilityBadge.tsx +40 -23
  132. package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
  133. package/src/themes/{minimal → threads}/index.ts +30 -13
  134. package/src/themes/{minimal → threads}/pages/ArchivePage.tsx +53 -53
  135. package/src/themes/threads/pages/CollectionPage.tsx +61 -0
  136. package/src/themes/{minimal → threads}/pages/HomePage.tsx +3 -3
  137. package/src/themes/{minimal → threads}/pages/PostPage.tsx +12 -8
  138. package/src/themes/{minimal → threads}/pages/SearchPage.tsx +15 -13
  139. package/src/themes/{minimal → threads}/pages/SinglePage.tsx +4 -4
  140. package/src/themes/threads/style.css +336 -0
  141. package/src/themes/threads/timeline/LinkCard.tsx +67 -0
  142. package/src/themes/threads/timeline/NoteCard.tsx +58 -0
  143. package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
  144. package/src/themes/{minimal → threads}/timeline/ThreadPreview.tsx +15 -13
  145. package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
  146. package/src/themes/{minimal → threads}/timeline/TimelineItem.tsx +9 -17
  147. package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
  148. package/src/themes/threads/timeline/groupByDate.ts +30 -0
  149. package/src/themes/threads/timeline/timelineMore.tsx +130 -0
  150. package/src/types.ts +242 -98
  151. package/dist/routes/api/timeline.js +0 -120
  152. package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
  153. package/dist/themes/minimal/index.js +0 -65
  154. package/dist/themes/minimal/pages/CollectionPage.js +0 -65
  155. package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
  156. package/dist/themes/minimal/timeline/ImageCard.js +0 -67
  157. package/dist/themes/minimal/timeline/LinkCard.js +0 -47
  158. package/dist/themes/minimal/timeline/NoteCard.js +0 -34
  159. package/dist/themes/minimal/timeline/QuoteCard.js +0 -48
  160. package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
  161. package/src/routes/api/timeline.tsx +0 -159
  162. package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
  163. package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
  164. package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
  165. package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
  166. package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
  167. package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
  168. package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
  169. package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
@@ -54,14 +54,12 @@ describe("SearchService", () => {
54
54
 
55
55
  it("finds posts by content", async () => {
56
56
  await postService.create({
57
- type: "note",
58
- content: "Hello world from jant",
59
- visibility: "featured",
57
+ format: "note",
58
+ body: "Hello world from jant",
60
59
  });
61
60
  await postService.create({
62
- type: "note",
63
- content: "Another post entirely",
64
- visibility: "featured",
61
+ format: "note",
62
+ body: "Another post entirely",
65
63
  });
66
64
 
67
65
  const d1 = createMockD1(sqlite);
@@ -69,15 +67,14 @@ describe("SearchService", () => {
69
67
 
70
68
  const results = await searchService.search("jant");
71
69
  expect(results.length).toBeGreaterThanOrEqual(1);
72
- expect(results[0]?.post.content).toContain("jant");
70
+ expect(results[0]?.post.body).toContain("jant");
73
71
  });
74
72
 
75
73
  it("finds posts by title", async () => {
76
74
  await postService.create({
77
- type: "article",
75
+ format: "note",
78
76
  title: "Introduction to TypeScript",
79
- content: "Some article body",
80
- visibility: "quiet",
77
+ body: "Some article body",
81
78
  });
82
79
 
83
80
  const d1 = createMockD1(sqlite);
@@ -88,33 +85,31 @@ describe("SearchService", () => {
88
85
  expect(results[0]?.post.title).toContain("TypeScript");
89
86
  });
90
87
 
91
- it("respects visibility filter", async () => {
88
+ it("respects status filter", async () => {
92
89
  await postService.create({
93
- type: "note",
94
- content: "visible post about testing",
95
- visibility: "featured",
90
+ format: "note",
91
+ body: "published post about testing",
96
92
  });
97
93
  await postService.create({
98
- type: "note",
99
- content: "draft post about testing",
100
- visibility: "draft",
94
+ format: "note",
95
+ body: "draft post about testing",
96
+ status: "draft",
101
97
  });
102
98
 
103
99
  const d1 = createMockD1(sqlite);
104
100
  const searchService = createSearchService(d1);
105
101
 
106
102
  const results = await searchService.search("testing", {
107
- visibility: ["featured"],
103
+ status: ["published"],
108
104
  });
109
105
 
110
- expect(results.every((r) => r.post.visibility === "featured")).toBe(true);
106
+ expect(results.every((r) => r.post.status === "published")).toBe(true);
111
107
  });
112
108
 
113
109
  it("excludes deleted posts", async () => {
114
110
  const post = await postService.create({
115
- type: "note",
116
- content: "deleted post with unique search term xyzzy",
117
- visibility: "featured",
111
+ format: "note",
112
+ body: "deleted post with unique search term xyzzy",
118
113
  });
119
114
  await postService.delete(post.id);
120
115
 
@@ -128,9 +123,8 @@ describe("SearchService", () => {
128
123
  it("supports limit and offset", async () => {
129
124
  for (let i = 0; i < 5; i++) {
130
125
  await postService.create({
131
- type: "note",
132
- content: `searchable post number ${i}`,
133
- visibility: "featured",
126
+ format: "note",
127
+ body: `searchable post number ${i}`,
134
128
  });
135
129
  }
136
130
 
@@ -1,69 +1,43 @@
1
1
  /**
2
- * Collection Service
2
+ * Collection Service (v2)
3
3
  *
4
- * Manages collections and post-collection relationships
4
+ * Manages collections. Posts belong to collections via posts.collection_id (1:M).
5
5
  */
6
6
 
7
- import { eq, desc, and } from "drizzle-orm";
7
+ import { eq, asc, sql, desc } from "drizzle-orm";
8
8
  import type { Database } from "../db/index.js";
9
- import { collections, postCollections, posts } from "../db/schema.js";
9
+ import { collections, posts } from "../db/schema.js";
10
10
  import { now } from "../lib/time.js";
11
- import type { Collection, Post } from "../types.js";
11
+ import type {
12
+ Collection,
13
+ CreateCollection,
14
+ UpdateCollection,
15
+ SortOrder,
16
+ } from "../types.js";
12
17
 
13
18
  export interface CollectionService {
14
19
  getById(id: number): Promise<Collection | null>;
15
- getByPath(path: string): Promise<Collection | null>;
20
+ getBySlug(slug: string): Promise<Collection | null>;
16
21
  list(): Promise<Collection[]>;
17
- create(data: CreateCollectionData): Promise<Collection>;
18
- update(id: number, data: UpdateCollectionData): Promise<Collection | null>;
22
+ create(data: CreateCollection): Promise<Collection>;
23
+ update(id: number, data: UpdateCollection): Promise<Collection | null>;
19
24
  delete(id: number): Promise<boolean>;
20
- addPost(collectionId: number, postId: number): Promise<void>;
21
- removePost(collectionId: number, postId: number): Promise<void>;
22
- getPosts(collectionId: number): Promise<Post[]>;
23
- getCollectionsForPost(postId: number): Promise<Collection[]>;
24
- syncPostCollections(postId: number, collectionIds: number[]): Promise<void>;
25
- }
26
-
27
- export interface CreateCollectionData {
28
- title: string;
29
- path?: string;
30
- description?: string;
31
- }
32
-
33
- export interface UpdateCollectionData {
34
- title?: string;
35
- path?: string | null;
36
- description?: string;
25
+ reorder(ids: number[]): Promise<void>;
26
+ /** Get post count per collection */
27
+ getPostCounts(): Promise<Map<number, number>>;
37
28
  }
38
29
 
39
30
  export function createCollectionService(db: Database): CollectionService {
40
31
  function toCollection(row: typeof collections.$inferSelect): Collection {
41
32
  return {
42
33
  id: row.id,
34
+ slug: row.slug,
43
35
  title: row.title,
44
- path: row.path,
45
36
  description: row.description,
46
- createdAt: row.createdAt,
47
- updatedAt: row.updatedAt,
48
- };
49
- }
50
-
51
- function toPost(row: typeof posts.$inferSelect): Post {
52
- return {
53
- id: row.id,
54
- type: row.type as Post["type"],
55
- visibility: row.visibility as Post["visibility"],
56
- title: row.title,
57
- path: row.path,
58
- content: row.content,
59
- contentHtml: row.contentHtml,
60
- sourceUrl: row.sourceUrl,
61
- sourceName: row.sourceName,
62
- sourceDomain: row.sourceDomain,
63
- replyToId: row.replyToId,
64
- threadId: row.threadId,
65
- deletedAt: row.deletedAt,
66
- publishedAt: row.publishedAt,
37
+ icon: row.icon,
38
+ sortOrder: row.sortOrder as SortOrder,
39
+ position: row.position,
40
+ showDivider: row.showDivider,
67
41
  createdAt: row.createdAt,
68
42
  updatedAt: row.updatedAt,
69
43
  };
@@ -79,11 +53,11 @@ export function createCollectionService(db: Database): CollectionService {
79
53
  return result[0] ? toCollection(result[0]) : null;
80
54
  },
81
55
 
82
- async getByPath(path) {
56
+ async getBySlug(slug) {
83
57
  const result = await db
84
58
  .select()
85
59
  .from(collections)
86
- .where(eq(collections.path, path))
60
+ .where(eq(collections.slug, slug))
87
61
  .limit(1);
88
62
  return result[0] ? toCollection(result[0]) : null;
89
63
  },
@@ -92,19 +66,32 @@ export function createCollectionService(db: Database): CollectionService {
92
66
  const rows = await db
93
67
  .select()
94
68
  .from(collections)
95
- .orderBy(desc(collections.createdAt));
69
+ .orderBy(asc(collections.position), desc(collections.createdAt));
96
70
  return rows.map(toCollection);
97
71
  },
98
72
 
99
73
  async create(data) {
100
74
  const timestamp = now();
101
75
 
76
+ let position = data.position;
77
+ if (position === undefined) {
78
+ const maxResult = await db
79
+ .select({ maxPos: sql<number>`COALESCE(MAX(position), -1)` })
80
+ .from(collections);
81
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
82
+ position = maxResult[0]!.maxPos + 1;
83
+ }
84
+
102
85
  const result = await db
103
86
  .insert(collections)
104
87
  .values({
88
+ slug: data.slug,
105
89
  title: data.title,
106
- path: data.path || null,
107
90
  description: data.description ?? null,
91
+ icon: data.icon ?? null,
92
+ sortOrder: data.sortOrder ?? "newest",
93
+ position,
94
+ showDivider: data.showDivider ? 1 : 0,
108
95
  createdAt: timestamp,
109
96
  updatedAt: timestamp,
110
97
  })
@@ -124,9 +111,14 @@ export function createCollectionService(db: Database): CollectionService {
124
111
  };
125
112
 
126
113
  if (data.title !== undefined) updates.title = data.title;
127
- if (data.path !== undefined) updates.path = data.path;
114
+ if (data.slug !== undefined) updates.slug = data.slug;
128
115
  if (data.description !== undefined)
129
116
  updates.description = data.description;
117
+ if (data.icon !== undefined) updates.icon = data.icon;
118
+ if (data.sortOrder !== undefined) updates.sortOrder = data.sortOrder;
119
+ if (data.position !== undefined) updates.position = data.position;
120
+ if (data.showDivider !== undefined)
121
+ updates.showDivider = data.showDivider ? 1 : 0;
130
122
 
131
123
  const result = await db
132
124
  .update(collections)
@@ -138,10 +130,11 @@ export function createCollectionService(db: Database): CollectionService {
138
130
  },
139
131
 
140
132
  async delete(id) {
141
- // Delete all post-collection relationships first
133
+ // Clear collection_id on posts that belong to this collection
142
134
  await db
143
- .delete(postCollections)
144
- .where(eq(postCollections.collectionId, id));
135
+ .update(posts)
136
+ .set({ collectionId: null })
137
+ .where(eq(posts.collectionId, id));
145
138
 
146
139
  const result = await db
147
140
  .delete(collections)
@@ -150,71 +143,36 @@ export function createCollectionService(db: Database): CollectionService {
150
143
  return result.length > 0;
151
144
  },
152
145
 
153
- async addPost(collectionId, postId) {
146
+ async reorder(ids) {
154
147
  const timestamp = now();
155
-
156
- // Upsert the relationship
157
- await db
158
- .insert(postCollections)
159
- .values({
160
- postId,
161
- collectionId,
162
- addedAt: timestamp,
163
- })
164
- .onConflictDoNothing();
165
- },
166
-
167
- async removePost(collectionId, postId) {
168
- await db
169
- .delete(postCollections)
170
- .where(
171
- and(
172
- eq(postCollections.collectionId, collectionId),
173
- eq(postCollections.postId, postId),
174
- ),
175
- );
176
- },
177
-
178
- async getPosts(collectionId) {
179
- const rows = await db
180
- .select({ post: posts })
181
- .from(postCollections)
182
- .innerJoin(posts, eq(postCollections.postId, posts.id))
183
- .where(eq(postCollections.collectionId, collectionId))
184
- .orderBy(desc(postCollections.addedAt));
185
-
186
- return rows.map((r) => toPost(r.post));
148
+ for (let i = 0; i < ids.length; i++) {
149
+ await db
150
+ .update(collections)
151
+ .set({ position: i, updatedAt: timestamp })
152
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loop index guarantees element exists
153
+ .where(eq(collections.id, ids[i]!));
154
+ }
187
155
  },
188
156
 
189
- async getCollectionsForPost(postId) {
157
+ async getPostCounts() {
190
158
  const rows = await db
191
- .select({ collection: collections })
192
- .from(postCollections)
193
- .innerJoin(
194
- collections,
195
- eq(postCollections.collectionId, collections.id),
159
+ .select({
160
+ collectionId: posts.collectionId,
161
+ count: sql<number>`count(*)`.as("count"),
162
+ })
163
+ .from(posts)
164
+ .where(
165
+ sql`${posts.collectionId} IS NOT NULL AND ${posts.deletedAt} IS NULL`,
196
166
  )
197
- .where(eq(postCollections.postId, postId));
198
-
199
- return rows.map((r) => toCollection(r.collection));
200
- },
201
-
202
- async syncPostCollections(postId, collectionIds) {
203
- const current = await this.getCollectionsForPost(postId);
204
- const currentIds = new Set(current.map((c) => c.id));
205
- const desiredIds = new Set(collectionIds);
167
+ .groupBy(posts.collectionId);
206
168
 
207
- const toAdd = collectionIds.filter((id) => !currentIds.has(id));
208
- const toRemove = current
209
- .map((c) => c.id)
210
- .filter((id) => !desiredIds.has(id));
211
-
212
- for (const collectionId of toAdd) {
213
- await this.addPost(collectionId, postId);
214
- }
215
- for (const collectionId of toRemove) {
216
- await this.removePost(collectionId, postId);
169
+ const counts = new Map<number, number>();
170
+ for (const row of rows) {
171
+ if (row.collectionId !== null) {
172
+ counts.set(row.collectionId, row.count);
173
+ }
217
174
  }
175
+ return counts;
218
176
  },
219
177
  };
220
178
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Services
2
+ * Services (v2)
3
3
  *
4
4
  * Business logic layer
5
5
  */
@@ -7,6 +7,7 @@
7
7
  import type { Database } from "../db/index.js";
8
8
  import { createSettingsService, type SettingsService } from "./settings.js";
9
9
  import { createPostService, type PostService } from "./post.js";
10
+ import { createPageService, type PageService } from "./page.js";
10
11
  import { createRedirectService, type RedirectService } from "./redirect.js";
11
12
  import { createMediaService, type MediaService } from "./media.js";
12
13
  import {
@@ -14,37 +15,37 @@ import {
14
15
  type CollectionService,
15
16
  } from "./collection.js";
16
17
  import { createSearchService, type SearchService } from "./search.js";
17
- import {
18
- createNavigationLinkService,
19
- type NavigationLinkService,
20
- } from "./navigation.js";
18
+ import { createNavItemService, type NavItemService } from "./navigation.js";
21
19
 
22
20
  export interface Services {
23
21
  settings: SettingsService;
24
22
  posts: PostService;
23
+ pages: PageService;
25
24
  redirects: RedirectService;
26
25
  media: MediaService;
27
26
  collections: CollectionService;
28
27
  search: SearchService;
29
- navigationLinks: NavigationLinkService;
28
+ navItems: NavItemService;
30
29
  }
31
30
 
32
31
  export function createServices(db: Database, d1: D1Database): Services {
33
32
  return {
34
33
  settings: createSettingsService(db),
35
34
  posts: createPostService(db),
35
+ pages: createPageService(db),
36
36
  redirects: createRedirectService(db),
37
37
  media: createMediaService(db),
38
38
  collections: createCollectionService(db),
39
39
  search: createSearchService(d1),
40
- navigationLinks: createNavigationLinkService(db),
40
+ navItems: createNavItemService(db),
41
41
  };
42
42
  }
43
43
 
44
44
  export type { SettingsService } from "./settings.js";
45
45
  export type { PostService, PostFilters } from "./post.js";
46
+ export type { PageService } from "./page.js";
46
47
  export type { RedirectService } from "./redirect.js";
47
48
  export type { MediaService } from "./media.js";
48
49
  export type { CollectionService } from "./collection.js";
49
50
  export type { SearchService, SearchResult, SearchOptions } from "./search.js";
50
- export type { NavigationLinkService } from "./navigation.js";
51
+ export type { NavItemService } from "./navigation.js";
@@ -1,42 +1,37 @@
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
  */
6
6
 
7
7
  import { eq, asc, sql } from "drizzle-orm";
8
8
  import type { Database } from "../db/index.js";
9
- import { navigationLinks } from "../db/schema.js";
9
+ import { navItems } from "../db/schema.js";
10
10
  import { now } from "../lib/time.js";
11
11
  import type {
12
- NavigationLink,
13
- CreateNavigationLink,
14
- UpdateNavigationLink,
12
+ NavItem,
13
+ NavItemType,
14
+ CreateNavItem,
15
+ UpdateNavItem,
15
16
  } from "../types.js";
16
17
 
17
- export interface NavigationLinkService {
18
- list(): Promise<NavigationLink[]>;
19
- getById(id: number): Promise<NavigationLink | null>;
20
- create(data: CreateNavigationLink): Promise<NavigationLink>;
21
- update(
22
- id: number,
23
- data: UpdateNavigationLink,
24
- ): Promise<NavigationLink | null>;
18
+ export interface NavItemService {
19
+ list(): Promise<NavItem[]>;
20
+ getById(id: number): Promise<NavItem | null>;
21
+ create(data: CreateNavItem): Promise<NavItem>;
22
+ update(id: number, data: UpdateNavItem): Promise<NavItem | null>;
25
23
  delete(id: number): Promise<boolean>;
26
24
  reorder(ids: number[]): Promise<void>;
27
- ensureDefaults(): Promise<NavigationLink[]>;
28
25
  }
29
26
 
30
- export function createNavigationLinkService(
31
- db: Database,
32
- ): NavigationLinkService {
33
- function toNavigationLink(
34
- row: typeof navigationLinks.$inferSelect,
35
- ): NavigationLink {
27
+ export function createNavItemService(db: Database): NavItemService {
28
+ function toNavItem(row: typeof navItems.$inferSelect): NavItem {
36
29
  return {
37
30
  id: row.id,
31
+ type: row.type as NavItemType,
38
32
  label: row.label,
39
33
  url: row.url,
34
+ pageId: row.pageId,
40
35
  position: row.position,
41
36
  createdAt: row.createdAt,
42
37
  updatedAt: row.updatedAt,
@@ -47,18 +42,18 @@ export function createNavigationLinkService(
47
42
  async list() {
48
43
  const rows = await db
49
44
  .select()
50
- .from(navigationLinks)
51
- .orderBy(asc(navigationLinks.position));
52
- return rows.map(toNavigationLink);
45
+ .from(navItems)
46
+ .orderBy(asc(navItems.position));
47
+ return rows.map(toNavItem);
53
48
  },
54
49
 
55
50
  async getById(id) {
56
51
  const result = await db
57
52
  .select()
58
- .from(navigationLinks)
59
- .where(eq(navigationLinks.id, id))
53
+ .from(navItems)
54
+ .where(eq(navItems.id, id))
60
55
  .limit(1);
61
- return result[0] ? toNavigationLink(result[0]) : null;
56
+ return result[0] ? toNavItem(result[0]) : null;
62
57
  },
63
58
 
64
59
  async create(data) {
@@ -68,16 +63,18 @@ export function createNavigationLinkService(
68
63
  if (position === undefined) {
69
64
  const maxResult = await db
70
65
  .select({ maxPos: sql<number>`COALESCE(MAX(position), -1)` })
71
- .from(navigationLinks);
66
+ .from(navItems);
72
67
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
73
68
  position = maxResult[0]!.maxPos + 1;
74
69
  }
75
70
 
76
71
  const result = await db
77
- .insert(navigationLinks)
72
+ .insert(navItems)
78
73
  .values({
74
+ type: data.type,
79
75
  label: data.label,
80
76
  url: data.url,
77
+ pageId: data.pageId ?? null,
81
78
  position,
82
79
  createdAt: timestamp,
83
80
  updatedAt: timestamp,
@@ -85,36 +82,38 @@ export function createNavigationLinkService(
85
82
  .returning();
86
83
 
87
84
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
88
- return toNavigationLink(result[0]!);
85
+ return toNavItem(result[0]!);
89
86
  },
90
87
 
91
88
  async update(id, data) {
92
89
  const existing = await db
93
90
  .select()
94
- .from(navigationLinks)
95
- .where(eq(navigationLinks.id, id))
91
+ .from(navItems)
92
+ .where(eq(navItems.id, id))
96
93
  .limit(1);
97
94
  if (!existing[0]) return null;
98
95
 
99
96
  const timestamp = now();
100
97
  const result = await db
101
- .update(navigationLinks)
98
+ .update(navItems)
102
99
  .set({
100
+ ...(data.type !== undefined && { type: data.type }),
103
101
  ...(data.label !== undefined && { label: data.label }),
104
102
  ...(data.url !== undefined && { url: data.url }),
103
+ ...(data.pageId !== undefined && { pageId: data.pageId }),
105
104
  ...(data.position !== undefined && { position: data.position }),
106
105
  updatedAt: timestamp,
107
106
  })
108
- .where(eq(navigationLinks.id, id))
107
+ .where(eq(navItems.id, id))
109
108
  .returning();
110
109
 
111
- return result[0] ? toNavigationLink(result[0]) : null;
110
+ return result[0] ? toNavItem(result[0]) : null;
112
111
  },
113
112
 
114
113
  async delete(id) {
115
114
  const result = await db
116
- .delete(navigationLinks)
117
- .where(eq(navigationLinks.id, id))
115
+ .delete(navItems)
116
+ .where(eq(navItems.id, id))
118
117
  .returning();
119
118
  return result.length > 0;
120
119
  },
@@ -123,43 +122,11 @@ export function createNavigationLinkService(
123
122
  const timestamp = now();
124
123
  for (let i = 0; i < ids.length; i++) {
125
124
  await db
126
- .update(navigationLinks)
125
+ .update(navItems)
127
126
  .set({ position: i, updatedAt: timestamp })
128
127
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loop index guarantees element exists
129
- .where(eq(navigationLinks.id, ids[i]!));
128
+ .where(eq(navItems.id, ids[i]!));
130
129
  }
131
130
  },
132
-
133
- async ensureDefaults() {
134
- const existing = await db.select().from(navigationLinks).limit(1);
135
- if (existing.length > 0) {
136
- const rows = await db
137
- .select()
138
- .from(navigationLinks)
139
- .orderBy(asc(navigationLinks.position));
140
- return rows.map(toNavigationLink);
141
- }
142
-
143
- const timestamp = now();
144
- const defaults = [
145
- { label: "Home", url: "/", position: 0 },
146
- { label: "Archive", url: "/archive", position: 1 },
147
- { label: "RSS", url: "/feed", position: 2 },
148
- ];
149
-
150
- for (const link of defaults) {
151
- await db.insert(navigationLinks).values({
152
- ...link,
153
- createdAt: timestamp,
154
- updatedAt: timestamp,
155
- });
156
- }
157
-
158
- const rows = await db
159
- .select()
160
- .from(navigationLinks)
161
- .orderBy(asc(navigationLinks.position));
162
- return rows.map(toNavigationLink);
163
- },
164
131
  };
165
132
  }