@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
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Page Service
3
+ *
4
+ * CRUD operations for standalone pages (about, now, etc.)
5
+ */
6
+
7
+ import { eq, desc } from "drizzle-orm";
8
+ import type { Database } from "../db/index.js";
9
+ import { pages, navItems } from "../db/schema.js";
10
+ import { now } from "../lib/time.js";
11
+ import { render as renderMarkdown } from "../lib/markdown.js";
12
+ import type { Page, Status, CreatePage, UpdatePage } from "../types.js";
13
+
14
+ export interface PageService {
15
+ getById(id: number): Promise<Page | null>;
16
+ getBySlug(slug: string): Promise<Page | null>;
17
+ list(): Promise<Page[]>;
18
+ create(data: CreatePage): Promise<Page>;
19
+ update(id: number, data: UpdatePage): Promise<Page | null>;
20
+ delete(id: number): Promise<boolean>;
21
+ }
22
+
23
+ export function createPageService(db: Database): PageService {
24
+ function toPage(row: typeof pages.$inferSelect): Page {
25
+ return {
26
+ id: row.id,
27
+ slug: row.slug,
28
+ title: row.title,
29
+ body: row.body,
30
+ bodyHtml: row.bodyHtml,
31
+ status: row.status as Status,
32
+ createdAt: row.createdAt,
33
+ updatedAt: row.updatedAt,
34
+ };
35
+ }
36
+
37
+ return {
38
+ async getById(id) {
39
+ const result = await db
40
+ .select()
41
+ .from(pages)
42
+ .where(eq(pages.id, id))
43
+ .limit(1);
44
+ return result[0] ? toPage(result[0]) : null;
45
+ },
46
+
47
+ async getBySlug(slug) {
48
+ const result = await db
49
+ .select()
50
+ .from(pages)
51
+ .where(eq(pages.slug, slug))
52
+ .limit(1);
53
+ return result[0] ? toPage(result[0]) : null;
54
+ },
55
+
56
+ async list() {
57
+ const rows = await db.select().from(pages).orderBy(desc(pages.createdAt));
58
+ return rows.map(toPage);
59
+ },
60
+
61
+ async create(data) {
62
+ const timestamp = now();
63
+
64
+ const bodyHtml = data.body ? renderMarkdown(data.body) : null;
65
+
66
+ const result = await db
67
+ .insert(pages)
68
+ .values({
69
+ slug: data.slug,
70
+ title: data.title ?? null,
71
+ body: data.body ?? null,
72
+ bodyHtml,
73
+ status: data.status ?? "published",
74
+ createdAt: timestamp,
75
+ updatedAt: timestamp,
76
+ })
77
+ .returning();
78
+
79
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
80
+ return toPage(result[0]!);
81
+ },
82
+
83
+ async update(id, data) {
84
+ const existing = await this.getById(id);
85
+ if (!existing) return null;
86
+
87
+ const timestamp = now();
88
+ const updates: Partial<typeof pages.$inferInsert> = {
89
+ updatedAt: timestamp,
90
+ };
91
+
92
+ if (data.slug !== undefined) updates.slug = data.slug;
93
+ if (data.title !== undefined) updates.title = data.title;
94
+ if (data.status !== undefined) updates.status = data.status;
95
+
96
+ if (data.body !== undefined) {
97
+ updates.body = data.body;
98
+ updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
99
+ }
100
+
101
+ // If slug changed, update related nav_items
102
+ if (data.slug !== undefined && data.slug !== existing.slug) {
103
+ await db
104
+ .update(navItems)
105
+ .set({ url: `/${data.slug}`, updatedAt: timestamp })
106
+ .where(eq(navItems.pageId, id));
107
+ }
108
+
109
+ const result = await db
110
+ .update(pages)
111
+ .set(updates)
112
+ .where(eq(pages.id, id))
113
+ .returning();
114
+
115
+ return result[0] ? toPage(result[0]) : null;
116
+ },
117
+
118
+ async delete(id) {
119
+ // nav_items with page_id FK have ON DELETE CASCADE, so they auto-delete
120
+ const result = await db.delete(pages).where(eq(pages.id, id)).returning();
121
+ return result.length > 0;
122
+ },
123
+ };
124
+ }
@@ -1,54 +1,45 @@
1
1
  /**
2
- * Post Service
2
+ * Post Service (v2)
3
3
  *
4
- * CRUD operations for posts with Thread support
4
+ * CRUD operations for posts with Thread support.
5
+ * Posts have format (note/link/quote), status (draft/published),
6
+ * featured flag, and pinned flag.
5
7
  */
6
8
 
7
- import {
8
- eq,
9
- and,
10
- isNull,
11
- desc,
12
- or,
13
- inArray,
14
- notInArray,
15
- sql,
16
- } from "drizzle-orm";
9
+ import { eq, and, isNull, desc, or, inArray, sql } from "drizzle-orm";
17
10
  import type { Database } from "../db/index.js";
18
11
  import { posts } from "../db/schema.js";
19
12
  import { now } from "../lib/time.js";
20
- import { extractDomain } from "../lib/url.js";
21
13
  import { render as renderMarkdown } from "../lib/markdown.js";
22
- import type {
23
- PostType,
24
- Visibility,
25
- Post,
26
- CreatePost,
27
- UpdatePost,
28
- } from "../types.js";
14
+ import type { Format, Status, Post, CreatePost, UpdatePost } from "../types.js";
29
15
 
30
16
  export interface PostFilters {
31
- type?: PostType;
32
- /** Exclude specific post types (e.g. ["page"]) */
33
- excludeTypes?: PostType[];
34
- visibility?: Visibility | Visibility[];
35
- includeDeleted?: boolean;
36
- threadId?: number;
17
+ format?: Format;
18
+ status?: Status;
19
+ featured?: boolean;
20
+ pinned?: boolean;
21
+ collectionId?: number;
37
22
  /** Exclude posts that are replies (have threadId set) */
38
23
  excludeReplies?: boolean;
24
+ includeDeleted?: boolean;
25
+ threadId?: number;
39
26
  limit?: number;
40
27
  cursor?: number; // post id for cursor pagination
41
28
  }
42
29
 
43
30
  export interface PostService {
44
31
  getById(id: number): Promise<Post | null>;
45
- getByPath(path: string): Promise<Post | null>;
32
+ getBySlug(slug: string): Promise<Post | null>;
46
33
  list(filters?: PostFilters): Promise<Post[]>;
47
34
  create(data: CreatePost): Promise<Post>;
48
35
  update(id: number, data: UpdatePost): Promise<Post | null>;
49
36
  delete(id: number): Promise<boolean>;
50
37
  getThread(rootId: number): Promise<Post[]>;
51
- updateThreadVisibility(rootId: number, visibility: Visibility): Promise<void>;
38
+ updateThreadStatusAndFeatured(
39
+ rootId: number,
40
+ status: Status,
41
+ featured: boolean,
42
+ ): Promise<void>;
52
43
  /** Get reply counts for multiple posts */
53
44
  getReplyCounts(postIds: number[]): Promise<Map<number, number>>;
54
45
  /** Get preview replies for multiple thread roots */
@@ -59,19 +50,21 @@ export interface PostService {
59
50
  }
60
51
 
61
52
  export function createPostService(db: Database): PostService {
62
- // Helper to map DB row to Post type
63
53
  function toPost(row: typeof posts.$inferSelect): Post {
64
54
  return {
65
55
  id: row.id,
66
- type: row.type as PostType,
67
- visibility: row.visibility as Visibility,
56
+ format: row.format as Format,
57
+ status: row.status as Status,
58
+ featured: row.featured,
59
+ pinned: row.pinned,
60
+ slug: row.slug,
68
61
  title: row.title,
69
- path: row.path,
70
- content: row.content,
71
- contentHtml: row.contentHtml,
72
- sourceUrl: row.sourceUrl,
73
- sourceName: row.sourceName,
74
- sourceDomain: row.sourceDomain,
62
+ url: row.url,
63
+ body: row.body,
64
+ bodyHtml: row.bodyHtml,
65
+ quoteText: row.quoteText,
66
+ rating: row.rating,
67
+ collectionId: row.collectionId,
75
68
  replyToId: row.replyToId,
76
69
  threadId: row.threadId,
77
70
  deletedAt: row.deletedAt,
@@ -91,11 +84,11 @@ export function createPostService(db: Database): PostService {
91
84
  return result[0] ? toPost(result[0]) : null;
92
85
  },
93
86
 
94
- async getByPath(path) {
87
+ async getBySlug(slug) {
95
88
  const result = await db
96
89
  .select()
97
90
  .from(posts)
98
- .where(and(eq(posts.path, path), isNull(posts.deletedAt)))
91
+ .where(and(eq(posts.slug, slug), isNull(posts.deletedAt)))
99
92
  .limit(1);
100
93
  return result[0] ? toPost(result[0]) : null;
101
94
  },
@@ -103,41 +96,38 @@ export function createPostService(db: Database): PostService {
103
96
  async list(filters = {}) {
104
97
  const conditions = [];
105
98
 
106
- // Visibility filter
107
- if (filters.visibility) {
108
- if (Array.isArray(filters.visibility)) {
109
- conditions.push(inArray(posts.visibility, filters.visibility));
110
- } else {
111
- conditions.push(eq(posts.visibility, filters.visibility));
112
- }
99
+ if (filters.status) {
100
+ conditions.push(eq(posts.status, filters.status));
113
101
  }
114
102
 
115
- // Type filter
116
- if (filters.type) {
117
- conditions.push(eq(posts.type, filters.type));
103
+ if (filters.featured !== undefined) {
104
+ conditions.push(eq(posts.featured, filters.featured ? 1 : 0));
118
105
  }
119
106
 
120
- // Exclude types filter
121
- if (filters.excludeTypes && filters.excludeTypes.length > 0) {
122
- conditions.push(notInArray(posts.type, filters.excludeTypes));
107
+ if (filters.pinned !== undefined) {
108
+ conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
109
+ }
110
+
111
+ if (filters.format) {
112
+ conditions.push(eq(posts.format, filters.format));
113
+ }
114
+
115
+ if (filters.collectionId !== undefined) {
116
+ conditions.push(eq(posts.collectionId, filters.collectionId));
123
117
  }
124
118
 
125
- // Thread filter
126
119
  if (filters.threadId) {
127
120
  conditions.push(eq(posts.threadId, filters.threadId));
128
121
  }
129
122
 
130
- // Exclude replies (posts that are part of a thread but not the root)
131
123
  if (filters.excludeReplies) {
132
124
  conditions.push(isNull(posts.threadId));
133
125
  }
134
126
 
135
- // Exclude deleted unless specified
136
127
  if (!filters.includeDeleted) {
137
128
  conditions.push(isNull(posts.deletedAt));
138
129
  }
139
130
 
140
- // Cursor pagination
141
131
  if (filters.cursor) {
142
132
  conditions.push(sql`${posts.id} < ${filters.cursor}`);
143
133
  }
@@ -156,29 +146,24 @@ export function createPostService(db: Database): PostService {
156
146
  async create(data) {
157
147
  const timestamp = now();
158
148
 
159
- // Process content
160
- const contentHtml = data.content ? renderMarkdown(data.content) : null;
161
-
162
- // Extract domain from source URL
163
- const sourceDomain = data.sourceUrl
164
- ? extractDomain(data.sourceUrl)
165
- : null;
149
+ const bodyHtml = data.body ? renderMarkdown(data.body) : null;
166
150
 
167
151
  // Handle thread relationship
168
152
  let threadId: number | null = null;
169
- let visibility = data.visibility ?? "quiet";
153
+ let status: Status = data.status ?? "published";
154
+ let featured = data.featured ?? false;
170
155
 
171
156
  if (data.replyToId) {
172
157
  const parent = await this.getById(data.replyToId);
173
158
  if (parent) {
174
- // thread_id = parent's thread_id or parent's id (if parent is root)
175
159
  threadId = parent.threadId ?? parent.id;
176
- // Inherit visibility from root
160
+ // Inherit status and featured from root
177
161
  const root = parent.threadId
178
162
  ? await this.getById(parent.threadId)
179
163
  : parent;
180
164
  if (root) {
181
- visibility = root.visibility;
165
+ status = root.status as Status;
166
+ featured = root.featured === 1;
182
167
  }
183
168
  }
184
169
  }
@@ -186,15 +171,18 @@ export function createPostService(db: Database): PostService {
186
171
  const result = await db
187
172
  .insert(posts)
188
173
  .values({
189
- type: data.type,
190
- visibility,
174
+ format: data.format,
175
+ status,
176
+ featured: featured ? 1 : 0,
177
+ pinned: data.pinned ? 1 : 0,
178
+ slug: data.slug ?? null,
191
179
  title: data.title ?? null,
192
- path: data.path ?? null,
193
- content: data.content ?? null,
194
- contentHtml,
195
- sourceUrl: data.sourceUrl ?? null,
196
- sourceName: data.sourceName ?? null,
197
- sourceDomain,
180
+ url: data.url ?? null,
181
+ body: data.body ?? null,
182
+ bodyHtml,
183
+ quoteText: data.quoteText ?? null,
184
+ rating: data.rating ?? null,
185
+ collectionId: data.collectionId ?? null,
198
186
  replyToId: data.replyToId ?? null,
199
187
  threadId,
200
188
  publishedAt: data.publishedAt ?? timestamp,
@@ -216,36 +204,40 @@ export function createPostService(db: Database): PostService {
216
204
  updatedAt: timestamp,
217
205
  };
218
206
 
219
- if (data.type !== undefined) updates.type = data.type;
207
+ if (data.format !== undefined) updates.format = data.format;
208
+ if (data.slug !== undefined) updates.slug = data.slug;
220
209
  if (data.title !== undefined) updates.title = data.title;
221
- if (data.path !== undefined) updates.path = data.path;
210
+ if (data.url !== undefined) updates.url = data.url;
211
+ if (data.quoteText !== undefined) updates.quoteText = data.quoteText;
212
+ if (data.rating !== undefined) updates.rating = data.rating;
213
+ if (data.collectionId !== undefined)
214
+ updates.collectionId = data.collectionId;
222
215
  if (data.publishedAt !== undefined)
223
216
  updates.publishedAt = data.publishedAt;
224
- if (data.sourceUrl !== undefined) {
225
- updates.sourceUrl = data.sourceUrl;
226
- updates.sourceDomain = data.sourceUrl
227
- ? extractDomain(data.sourceUrl)
228
- : null;
229
- }
230
- if (data.sourceName !== undefined) updates.sourceName = data.sourceName;
217
+ if (data.pinned !== undefined) updates.pinned = data.pinned ? 1 : 0;
231
218
 
232
- if (data.content !== undefined) {
233
- updates.content = data.content;
234
- updates.contentHtml = data.content
235
- ? renderMarkdown(data.content)
236
- : null;
219
+ if (data.body !== undefined) {
220
+ updates.body = data.body;
221
+ updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
237
222
  }
238
223
 
239
- // Handle visibility change - cascade to thread if this is root
240
- if (
241
- data.visibility !== undefined &&
242
- data.visibility !== existing.visibility
243
- ) {
244
- updates.visibility = data.visibility;
245
- // If this is a root post, cascade visibility to all thread posts
246
- if (!existing.threadId) {
247
- await this.updateThreadVisibility(id, data.visibility);
248
- }
224
+ // Handle status/featured change - cascade to thread if this is root
225
+ const statusChanged =
226
+ data.status !== undefined && data.status !== existing.status;
227
+ const featuredChanged =
228
+ data.featured !== undefined &&
229
+ (data.featured ? 1 : 0) !== existing.featured;
230
+
231
+ if (statusChanged) updates.status = data.status;
232
+ if (featuredChanged) updates.featured = data.featured ? 1 : 0;
233
+
234
+ // If this is a root post and status/featured changed, cascade to thread
235
+ if ((statusChanged || featuredChanged) && !existing.threadId) {
236
+ await this.updateThreadStatusAndFeatured(
237
+ id,
238
+ data.status ?? (existing.status as Status),
239
+ data.featured !== undefined ? data.featured : existing.featured === 1,
240
+ );
249
241
  }
250
242
 
251
243
  const result = await db
@@ -270,7 +262,6 @@ export function createPostService(db: Database): PostService {
270
262
  .set({ deletedAt: timestamp, updatedAt: timestamp })
271
263
  .where(or(eq(posts.id, id), eq(posts.threadId, id)));
272
264
  } else {
273
- // Just delete this single post
274
265
  await db
275
266
  .update(posts)
276
267
  .set({ deletedAt: timestamp, updatedAt: timestamp })
@@ -295,11 +286,11 @@ export function createPostService(db: Database): PostService {
295
286
  return rows.map(toPost);
296
287
  },
297
288
 
298
- async updateThreadVisibility(rootId, visibility) {
289
+ async updateThreadStatusAndFeatured(rootId, status, featured) {
299
290
  const timestamp = now();
300
291
  await db
301
292
  .update(posts)
302
- .set({ visibility, updatedAt: timestamp })
293
+ .set({ status, featured: featured ? 1 : 0, updatedAt: timestamp })
303
294
  .where(eq(posts.threadId, rootId));
304
295
  },
305
296
 
@@ -333,7 +324,6 @@ export function createPostService(db: Database): PostService {
333
324
  .where(and(inArray(posts.threadId, rootIds), isNull(posts.deletedAt)))
334
325
  .orderBy(posts.threadId, posts.createdAt);
335
326
 
336
- // Partition by threadId, take first previewCount per thread
337
327
  const result = new Map<number, Post[]>();
338
328
  for (const row of rows) {
339
329
  const post = toPost(row);
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Search Service
2
+ * Search Service (v2)
3
3
  *
4
4
  * Full-text search using FTS5
5
5
  */
6
6
 
7
- import type { Post, Visibility, SearchResult } from "../types.js";
7
+ import type { Post, Status, Format, SearchResult } from "../types.js";
8
8
 
9
9
  export type { SearchResult };
10
10
 
@@ -13,8 +13,10 @@ export interface SearchOptions {
13
13
  limit?: number;
14
14
  /** Offset for pagination */
15
15
  offset?: number;
16
- /** Filter by visibility */
17
- visibility?: Visibility[];
16
+ /** Filter by status */
17
+ status?: Status[];
18
+ /** Filter by format */
19
+ format?: Format;
18
20
  }
19
21
 
20
22
  export interface SearchService {
@@ -23,15 +25,18 @@ export interface SearchService {
23
25
 
24
26
  interface RawSearchRow {
25
27
  id: number;
26
- type: string;
27
- visibility: string;
28
+ format: string;
29
+ status: string;
30
+ featured: number;
31
+ pinned: number;
32
+ slug: string | null;
28
33
  title: string | null;
29
- path: string | null;
30
- content: string | null;
31
- content_html: string | null;
32
- source_url: string | null;
33
- source_name: string | null;
34
- source_domain: string | null;
34
+ url: string | null;
35
+ body: string | null;
36
+ body_html: string | null;
37
+ quote_text: string | null;
38
+ rating: number | null;
39
+ collection_id: number | null;
35
40
  reply_to_id: number | null;
36
41
  thread_id: number | null;
37
42
  deleted_at: number | null;
@@ -47,10 +52,9 @@ export function createSearchService(d1: D1Database): SearchService {
47
52
  async search(query, options = {}) {
48
53
  const limit = options.limit ?? 20;
49
54
  const offset = options.offset ?? 0;
50
- const visibility = options.visibility ?? ["featured", "quiet"];
55
+ const status = options.status ?? ["published"];
51
56
 
52
57
  // Escape and prepare the query for FTS5
53
- // FTS5 uses * for prefix matching
54
58
  const ftsQuery = query
55
59
  .trim()
56
60
  .split(/\s+/)
@@ -62,10 +66,13 @@ export function createSearchService(d1: D1Database): SearchService {
62
66
  return [];
63
67
  }
64
68
 
65
- // Build visibility placeholders
66
- const visibilityPlaceholders = visibility.map(() => "?").join(", ");
69
+ // Build status placeholders
70
+ const statusPlaceholders = status.map(() => "?").join(", ");
71
+
72
+ // Build format filter
73
+ const formatFilter = options.format ? "AND posts.format = ?" : "";
74
+ const formatParams = options.format ? [options.format] : [];
67
75
 
68
- // Query FTS5 table and join with posts using raw D1 query
69
76
  const stmt = d1.prepare(`
70
77
  SELECT
71
78
  posts.*,
@@ -75,27 +82,31 @@ export function createSearchService(d1: D1Database): SearchService {
75
82
  JOIN posts ON posts.id = posts_fts.rowid
76
83
  WHERE posts_fts MATCH ?
77
84
  AND posts.deleted_at IS NULL
78
- AND posts.visibility IN (${visibilityPlaceholders})
85
+ AND posts.status IN (${statusPlaceholders})
86
+ ${formatFilter}
79
87
  ORDER BY posts_fts.rank
80
88
  LIMIT ? OFFSET ?
81
89
  `);
82
90
 
83
91
  const { results } = await stmt
84
- .bind(ftsQuery, ...visibility, limit, offset)
92
+ .bind(ftsQuery, ...status, ...formatParams, limit, offset)
85
93
  .all<RawSearchRow>();
86
94
 
87
95
  return (results || []).map((row) => ({
88
96
  post: {
89
97
  id: row.id,
90
- type: row.type as Post["type"],
91
- visibility: row.visibility as Post["visibility"],
98
+ format: row.format as Post["format"],
99
+ status: row.status as Post["status"],
100
+ featured: row.featured,
101
+ pinned: row.pinned,
102
+ slug: row.slug,
92
103
  title: row.title,
93
- path: row.path,
94
- content: row.content,
95
- contentHtml: row.content_html,
96
- sourceUrl: row.source_url,
97
- sourceName: row.source_name,
98
- sourceDomain: row.source_domain,
104
+ url: row.url,
105
+ body: row.body,
106
+ bodyHtml: row.body_html,
107
+ quoteText: row.quote_text,
108
+ rating: row.rating,
109
+ collectionId: row.collection_id,
99
110
  replyToId: row.reply_to_id,
100
111
  threadId: row.thread_id,
101
112
  deletedAt: row.deleted_at,