@jant/core 0.3.23 → 0.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/dist/app.js +50 -26
  2. package/dist/db/schema.js +72 -47
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +5 -11
  7. package/dist/lib/constants.js +2 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/nav-reorder.js +1 -1
  11. package/dist/lib/navigation.js +30 -6
  12. package/dist/lib/pagination.js +44 -0
  13. package/dist/lib/render.js +7 -11
  14. package/dist/lib/schemas.js +80 -38
  15. package/dist/lib/theme.js +4 -4
  16. package/dist/lib/time.js +56 -1
  17. package/dist/lib/timeline.js +95 -0
  18. package/dist/lib/view.js +61 -72
  19. package/dist/routes/api/collections.js +124 -0
  20. package/dist/routes/api/nav-items.js +104 -0
  21. package/dist/routes/api/pages.js +91 -0
  22. package/dist/routes/api/posts.js +27 -33
  23. package/dist/routes/api/search.js +4 -5
  24. package/dist/routes/api/settings.js +68 -0
  25. package/dist/routes/api/upload.js +13 -13
  26. package/dist/routes/compose.js +48 -0
  27. package/dist/routes/dash/collections.js +24 -42
  28. package/dist/routes/dash/index.js +3 -3
  29. package/dist/routes/dash/media.js +2 -2
  30. package/dist/routes/dash/pages.js +440 -106
  31. package/dist/routes/dash/posts.js +27 -37
  32. package/dist/routes/dash/redirects.js +2 -2
  33. package/dist/routes/dash/settings.js +79 -5
  34. package/dist/routes/feed/rss.js +4 -6
  35. package/dist/routes/feed/sitemap.js +11 -8
  36. package/dist/routes/pages/archive.js +13 -15
  37. package/dist/routes/pages/collection.js +12 -9
  38. package/dist/routes/pages/collections.js +28 -0
  39. package/dist/routes/pages/featured.js +32 -0
  40. package/dist/routes/pages/home.js +19 -68
  41. package/dist/routes/pages/page.js +57 -29
  42. package/dist/routes/pages/post.js +7 -17
  43. package/dist/routes/pages/search.js +5 -9
  44. package/dist/services/collection.js +52 -64
  45. package/dist/services/index.js +5 -3
  46. package/dist/services/navigation.js +29 -53
  47. package/dist/services/page.js +84 -0
  48. package/dist/services/post.js +102 -69
  49. package/dist/services/search.js +24 -18
  50. package/dist/types.js +24 -40
  51. package/dist/ui/compose/ComposeDialog.js +452 -0
  52. package/dist/ui/compose/ComposePrompt.js +55 -0
  53. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +3 -15
  54. package/dist/{theme/components → ui/dash}/PageForm.js +15 -15
  55. package/dist/{theme/components → ui/dash}/PostForm.js +117 -137
  56. package/dist/{theme/components → ui/dash}/PostList.js +18 -13
  57. package/dist/ui/dash/StatusBadge.js +46 -0
  58. package/dist/{theme/components → ui/dash}/index.js +3 -6
  59. package/dist/ui/feed/LinkCard.js +72 -0
  60. package/dist/ui/feed/NoteCard.js +58 -0
  61. package/dist/{themes/minimal/timeline → ui/feed}/QuoteCard.js +29 -14
  62. package/dist/{themes/minimal/timeline → ui/feed}/ThreadPreview.js +20 -18
  63. package/dist/ui/feed/TimelineFeed.js +41 -0
  64. package/dist/ui/feed/TimelineItem.js +27 -0
  65. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  66. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  67. package/dist/ui/layouts/SiteLayout.js +141 -0
  68. package/dist/{themes/minimal → ui}/pages/ArchivePage.js +37 -50
  69. package/dist/ui/pages/CollectionPage.js +70 -0
  70. package/dist/ui/pages/CollectionsPage.js +76 -0
  71. package/dist/ui/pages/FeaturedPage.js +24 -0
  72. package/dist/ui/pages/HomePage.js +24 -0
  73. package/dist/{themes/minimal → ui}/pages/PostPage.js +20 -12
  74. package/dist/{themes/minimal → ui}/pages/SearchPage.js +19 -18
  75. package/dist/{themes/minimal → ui}/pages/SinglePage.js +5 -4
  76. package/dist/ui/shared/MediaGallery.js +35 -0
  77. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  78. package/dist/{theme/components → ui/shared}/ThreadView.js +3 -3
  79. package/dist/ui/shared/index.js +5 -0
  80. package/package.json +2 -9
  81. package/src/__tests__/helpers/app.ts +4 -0
  82. package/src/__tests__/helpers/db.ts +53 -73
  83. package/src/app.tsx +56 -28
  84. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  85. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  86. package/src/db/migrations/meta/_journal.json +14 -0
  87. package/src/db/schema.ts +63 -46
  88. package/src/i18n/locales/en.po +443 -240
  89. package/src/i18n/locales/en.ts +1 -1
  90. package/src/i18n/locales/zh-Hans.po +443 -240
  91. package/src/i18n/locales/zh-Hans.ts +1 -1
  92. package/src/i18n/locales/zh-Hant.po +443 -240
  93. package/src/i18n/locales/zh-Hant.ts +1 -1
  94. package/src/index.ts +29 -42
  95. package/src/lib/__tests__/excerpt.test.ts +125 -0
  96. package/src/lib/__tests__/schemas.test.ts +201 -99
  97. package/src/lib/__tests__/time.test.ts +62 -0
  98. package/src/{routes/api → lib}/__tests__/timeline.test.ts +81 -75
  99. package/src/lib/__tests__/view.test.ts +204 -50
  100. package/src/lib/constants.ts +2 -4
  101. package/src/lib/excerpt.ts +87 -0
  102. package/src/lib/feed.ts +22 -7
  103. package/src/lib/nav-reorder.ts +1 -1
  104. package/src/lib/navigation.ts +45 -8
  105. package/src/lib/pagination.ts +50 -0
  106. package/src/lib/render.tsx +7 -14
  107. package/src/lib/schemas.ts +119 -51
  108. package/src/lib/theme.ts +5 -5
  109. package/src/lib/time.ts +64 -0
  110. package/src/lib/timeline.ts +141 -0
  111. package/src/lib/view.ts +80 -82
  112. package/src/preset.css +46 -0
  113. package/src/routes/__tests__/compose.test.ts +199 -0
  114. package/src/routes/api/__tests__/collections.test.ts +249 -0
  115. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  116. package/src/routes/api/__tests__/pages.test.ts +218 -0
  117. package/src/routes/api/__tests__/posts.test.ts +50 -108
  118. package/src/routes/api/__tests__/search.test.ts +2 -3
  119. package/src/routes/api/__tests__/settings.test.ts +132 -0
  120. package/src/routes/api/collections.ts +143 -0
  121. package/src/routes/api/nav-items.ts +115 -0
  122. package/src/routes/api/pages.ts +101 -0
  123. package/src/routes/api/posts.ts +28 -28
  124. package/src/routes/api/search.ts +3 -3
  125. package/src/routes/api/settings.ts +91 -0
  126. package/src/routes/api/upload.ts +16 -6
  127. package/src/routes/compose.ts +63 -0
  128. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  129. package/src/routes/dash/collections.tsx +20 -42
  130. package/src/routes/dash/index.tsx +3 -3
  131. package/src/routes/dash/media.tsx +2 -2
  132. package/src/routes/dash/pages.tsx +480 -122
  133. package/src/routes/dash/posts.tsx +42 -54
  134. package/src/routes/dash/redirects.tsx +2 -2
  135. package/src/routes/dash/settings.tsx +83 -5
  136. package/src/routes/feed/rss.ts +4 -3
  137. package/src/routes/feed/sitemap.ts +15 -5
  138. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  139. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  140. package/src/routes/pages/archive.tsx +15 -15
  141. package/src/routes/pages/collection.tsx +16 -9
  142. package/src/routes/pages/collections.tsx +36 -0
  143. package/src/routes/pages/featured.tsx +38 -0
  144. package/src/routes/pages/home.tsx +21 -92
  145. package/src/routes/pages/page.tsx +62 -27
  146. package/src/routes/pages/post.tsx +6 -18
  147. package/src/routes/pages/search.tsx +3 -7
  148. package/src/services/__tests__/collection.test.ts +257 -158
  149. package/src/services/__tests__/media.test.ts +18 -18
  150. package/src/services/__tests__/navigation.test.ts +161 -87
  151. package/src/services/__tests__/page.test.ts +106 -0
  152. package/src/services/__tests__/post-timeline.test.ts +92 -88
  153. package/src/services/__tests__/post.test.ts +432 -197
  154. package/src/services/__tests__/search.test.ts +19 -25
  155. package/src/services/collection.ts +71 -113
  156. package/src/services/index.ts +9 -8
  157. package/src/services/navigation.ts +38 -71
  158. package/src/services/page.ts +136 -0
  159. package/src/services/post.ts +141 -101
  160. package/src/services/search.ts +38 -27
  161. package/src/styles/tokens.css +47 -0
  162. package/src/styles/ui.css +491 -0
  163. package/src/types.ts +212 -198
  164. package/src/ui/compose/ComposeDialog.tsx +395 -0
  165. package/src/ui/compose/ComposePrompt.tsx +55 -0
  166. package/src/ui/dash/FormatBadge.tsx +28 -0
  167. package/src/{theme/components → ui/dash}/PageForm.tsx +21 -21
  168. package/src/{theme/components → ui/dash}/PostForm.tsx +110 -131
  169. package/src/ui/dash/PostList.tsx +101 -0
  170. package/src/ui/dash/StatusBadge.tsx +61 -0
  171. package/src/ui/dash/index.ts +10 -0
  172. package/src/ui/feed/LinkCard.tsx +72 -0
  173. package/src/ui/feed/NoteCard.tsx +63 -0
  174. package/src/ui/feed/QuoteCard.tsx +68 -0
  175. package/src/ui/feed/ThreadPreview.tsx +48 -0
  176. package/src/ui/feed/TimelineFeed.tsx +49 -0
  177. package/src/ui/feed/TimelineItem.tsx +45 -0
  178. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  179. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  180. package/src/ui/layouts/SiteLayout.tsx +150 -0
  181. package/src/ui/pages/ArchivePage.tsx +162 -0
  182. package/src/ui/pages/CollectionPage.tsx +70 -0
  183. package/src/ui/pages/CollectionsPage.tsx +73 -0
  184. package/src/ui/pages/FeaturedPage.tsx +31 -0
  185. package/src/ui/pages/HomePage.tsx +37 -0
  186. package/src/ui/pages/PostPage.tsx +56 -0
  187. package/src/{themes/minimal → ui}/pages/SearchPage.tsx +24 -20
  188. package/src/{themes/minimal → ui}/pages/SinglePage.tsx +5 -5
  189. package/src/ui/shared/MediaGallery.tsx +59 -0
  190. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  191. package/src/{theme/components → ui/shared}/ThreadView.tsx +6 -3
  192. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  193. package/src/ui/shared/index.ts +12 -0
  194. package/bin/jant.js +0 -185
  195. package/dist/lib/theme-components.js +0 -49
  196. package/dist/routes/api/timeline.js +0 -120
  197. package/dist/routes/dash/navigation.js +0 -288
  198. package/dist/theme/components/MediaGallery.js +0 -107
  199. package/dist/theme/components/VisibilityBadge.js +0 -37
  200. package/dist/theme/index.js +0 -18
  201. package/dist/theme/layouts/index.js +0 -2
  202. package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
  203. package/dist/themes/minimal/index.js +0 -65
  204. package/dist/themes/minimal/pages/CollectionPage.js +0 -65
  205. package/dist/themes/minimal/pages/HomePage.js +0 -25
  206. package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
  207. package/dist/themes/minimal/timeline/ImageCard.js +0 -67
  208. package/dist/themes/minimal/timeline/LinkCard.js +0 -47
  209. package/dist/themes/minimal/timeline/NoteCard.js +0 -34
  210. package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
  211. package/dist/themes/minimal/timeline/TimelineItem.js +0 -44
  212. package/src/lib/__tests__/theme-components.test.ts +0 -126
  213. package/src/lib/theme-components.ts +0 -68
  214. package/src/routes/api/timeline.tsx +0 -159
  215. package/src/routes/dash/navigation.tsx +0 -316
  216. package/src/theme/components/MediaGallery.tsx +0 -128
  217. package/src/theme/components/PostList.tsx +0 -92
  218. package/src/theme/components/TypeBadge.tsx +0 -37
  219. package/src/theme/components/VisibilityBadge.tsx +0 -45
  220. package/src/theme/components/index.ts +0 -23
  221. package/src/theme/index.ts +0 -22
  222. package/src/theme/layouts/index.ts +0 -7
  223. package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
  224. package/src/themes/minimal/index.ts +0 -83
  225. package/src/themes/minimal/pages/ArchivePage.tsx +0 -157
  226. package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
  227. package/src/themes/minimal/pages/HomePage.tsx +0 -41
  228. package/src/themes/minimal/pages/PostPage.tsx +0 -43
  229. package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
  230. package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
  231. package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
  232. package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
  233. package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
  234. package/src/themes/minimal/timeline/ThreadPreview.tsx +0 -47
  235. package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
  236. package/src/themes/minimal/timeline/TimelineItem.tsx +0 -75
  237. /package/dist/{theme → ui}/color-themes.js +0 -0
  238. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  239. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  240. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  241. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  242. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  243. /package/src/{theme → ui}/color-themes.ts +0 -0
  244. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  245. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  246. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  247. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  248. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Page Service
3
+ *
4
+ * CRUD operations for standalone pages (about, now, etc.)
5
+ */
6
+
7
+ import { eq, desc, sql } 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
+ listNotInNav(): Promise<Page[]>;
19
+ create(data: CreatePage): Promise<Page>;
20
+ update(id: number, data: UpdatePage): Promise<Page | null>;
21
+ delete(id: number): Promise<boolean>;
22
+ }
23
+
24
+ export function createPageService(db: Database): PageService {
25
+ function toPage(row: typeof pages.$inferSelect): Page {
26
+ return {
27
+ id: row.id,
28
+ slug: row.slug,
29
+ title: row.title,
30
+ body: row.body,
31
+ bodyHtml: row.bodyHtml,
32
+ status: row.status as Status,
33
+ createdAt: row.createdAt,
34
+ updatedAt: row.updatedAt,
35
+ };
36
+ }
37
+
38
+ return {
39
+ async getById(id) {
40
+ const result = await db
41
+ .select()
42
+ .from(pages)
43
+ .where(eq(pages.id, id))
44
+ .limit(1);
45
+ return result[0] ? toPage(result[0]) : null;
46
+ },
47
+
48
+ async getBySlug(slug) {
49
+ const result = await db
50
+ .select()
51
+ .from(pages)
52
+ .where(eq(pages.slug, slug))
53
+ .limit(1);
54
+ return result[0] ? toPage(result[0]) : null;
55
+ },
56
+
57
+ async list() {
58
+ const rows = await db.select().from(pages).orderBy(desc(pages.createdAt));
59
+ return rows.map(toPage);
60
+ },
61
+
62
+ async listNotInNav() {
63
+ const rows = await db
64
+ .select()
65
+ .from(pages)
66
+ .where(
67
+ sql`${pages.id} NOT IN (SELECT ${navItems.pageId} FROM ${navItems} WHERE ${navItems.pageId} IS NOT NULL)`,
68
+ )
69
+ .orderBy(desc(pages.createdAt));
70
+ return rows.map(toPage);
71
+ },
72
+
73
+ async create(data) {
74
+ const timestamp = now();
75
+
76
+ const bodyHtml = data.body ? renderMarkdown(data.body) : null;
77
+
78
+ const result = await db
79
+ .insert(pages)
80
+ .values({
81
+ slug: data.slug,
82
+ title: data.title ?? null,
83
+ body: data.body ?? null,
84
+ bodyHtml,
85
+ status: data.status ?? "published",
86
+ createdAt: timestamp,
87
+ updatedAt: timestamp,
88
+ })
89
+ .returning();
90
+
91
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
92
+ return toPage(result[0]!);
93
+ },
94
+
95
+ async update(id, data) {
96
+ const existing = await this.getById(id);
97
+ if (!existing) return null;
98
+
99
+ const timestamp = now();
100
+ const updates: Partial<typeof pages.$inferInsert> = {
101
+ updatedAt: timestamp,
102
+ };
103
+
104
+ if (data.slug !== undefined) updates.slug = data.slug;
105
+ if (data.title !== undefined) updates.title = data.title;
106
+ if (data.status !== undefined) updates.status = data.status;
107
+
108
+ if (data.body !== undefined) {
109
+ updates.body = data.body;
110
+ updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
111
+ }
112
+
113
+ // If slug changed, update related nav_items
114
+ if (data.slug !== undefined && data.slug !== existing.slug) {
115
+ await db
116
+ .update(navItems)
117
+ .set({ url: `/${data.slug}`, updatedAt: timestamp })
118
+ .where(eq(navItems.pageId, id));
119
+ }
120
+
121
+ const result = await db
122
+ .update(pages)
123
+ .set(updates)
124
+ .where(eq(pages.id, id))
125
+ .returning();
126
+
127
+ return result[0] ? toPage(result[0]) : null;
128
+ },
129
+
130
+ async delete(id) {
131
+ // nav_items with page_id FK have ON DELETE CASCADE, so they auto-delete
132
+ const result = await db.delete(pages).where(eq(pages.id, id)).returning();
133
+ return result.length > 0;
134
+ },
135
+ };
136
+ }
@@ -1,54 +1,48 @@
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
28
+ offset?: number; // offset for page-based pagination
41
29
  }
42
30
 
43
31
  export interface PostService {
44
32
  getById(id: number): Promise<Post | null>;
45
33
  getByPath(path: string): Promise<Post | null>;
46
34
  list(filters?: PostFilters): Promise<Post[]>;
35
+ /** Count posts matching filters (ignores cursor, offset, limit) */
36
+ count(filters?: PostFilters): Promise<number>;
47
37
  create(data: CreatePost): Promise<Post>;
48
38
  update(id: number, data: UpdatePost): Promise<Post | null>;
49
39
  delete(id: number): Promise<boolean>;
50
40
  getThread(rootId: number): Promise<Post[]>;
51
- updateThreadVisibility(rootId: number, visibility: Visibility): Promise<void>;
41
+ updateThreadStatusAndFeatured(
42
+ rootId: number,
43
+ status: Status,
44
+ featured: boolean,
45
+ ): Promise<void>;
52
46
  /** Get reply counts for multiple posts */
53
47
  getReplyCounts(postIds: number[]): Promise<Map<number, number>>;
54
48
  /** Get preview replies for multiple thread roots */
@@ -59,19 +53,21 @@ export interface PostService {
59
53
  }
60
54
 
61
55
  export function createPostService(db: Database): PostService {
62
- // Helper to map DB row to Post type
63
56
  function toPost(row: typeof posts.$inferSelect): Post {
64
57
  return {
65
58
  id: row.id,
66
- type: row.type as PostType,
67
- visibility: row.visibility as Visibility,
68
- title: row.title,
59
+ format: row.format as Format,
60
+ status: row.status as Status,
61
+ featured: row.featured,
62
+ pinned: row.pinned,
69
63
  path: row.path,
70
- content: row.content,
71
- contentHtml: row.contentHtml,
72
- sourceUrl: row.sourceUrl,
73
- sourceName: row.sourceName,
74
- sourceDomain: row.sourceDomain,
64
+ title: row.title,
65
+ url: row.url,
66
+ body: row.body,
67
+ bodyHtml: row.bodyHtml,
68
+ quoteText: row.quoteText,
69
+ rating: row.rating,
70
+ collectionId: row.collectionId,
75
71
  replyToId: row.replyToId,
76
72
  threadId: row.threadId,
77
73
  deletedAt: row.deletedAt,
@@ -103,82 +99,121 @@ export function createPostService(db: Database): PostService {
103
99
  async list(filters = {}) {
104
100
  const conditions = [];
105
101
 
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
- }
102
+ if (filters.status) {
103
+ conditions.push(eq(posts.status, filters.status));
104
+ }
105
+
106
+ if (filters.featured !== undefined) {
107
+ conditions.push(eq(posts.featured, filters.featured ? 1 : 0));
113
108
  }
114
109
 
115
- // Type filter
116
- if (filters.type) {
117
- conditions.push(eq(posts.type, filters.type));
110
+ if (filters.pinned !== undefined) {
111
+ conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
118
112
  }
119
113
 
120
- // Exclude types filter
121
- if (filters.excludeTypes && filters.excludeTypes.length > 0) {
122
- conditions.push(notInArray(posts.type, filters.excludeTypes));
114
+ if (filters.format) {
115
+ conditions.push(eq(posts.format, filters.format));
116
+ }
117
+
118
+ if (filters.collectionId !== undefined) {
119
+ conditions.push(eq(posts.collectionId, filters.collectionId));
123
120
  }
124
121
 
125
- // Thread filter
126
122
  if (filters.threadId) {
127
123
  conditions.push(eq(posts.threadId, filters.threadId));
128
124
  }
129
125
 
130
- // Exclude replies (posts that are part of a thread but not the root)
131
126
  if (filters.excludeReplies) {
132
127
  conditions.push(isNull(posts.threadId));
133
128
  }
134
129
 
135
- // Exclude deleted unless specified
136
130
  if (!filters.includeDeleted) {
137
131
  conditions.push(isNull(posts.deletedAt));
138
132
  }
139
133
 
140
- // Cursor pagination
141
134
  if (filters.cursor) {
142
135
  conditions.push(sql`${posts.id} < ${filters.cursor}`);
143
136
  }
144
137
 
145
- const query = db
138
+ let query = db
146
139
  .select()
147
140
  .from(posts)
148
141
  .where(conditions.length > 0 ? and(...conditions) : undefined)
149
142
  .orderBy(desc(posts.publishedAt), desc(posts.id))
150
143
  .limit(filters.limit ?? 100);
151
144
 
145
+ if (filters.offset !== undefined) {
146
+ query = query.offset(filters.offset) as typeof query;
147
+ }
148
+
152
149
  const rows = await query;
153
150
  return rows.map(toPost);
154
151
  },
155
152
 
153
+ async count(filters = {}) {
154
+ const conditions = [];
155
+
156
+ if (filters.status) {
157
+ conditions.push(eq(posts.status, filters.status));
158
+ }
159
+
160
+ if (filters.featured !== undefined) {
161
+ conditions.push(eq(posts.featured, filters.featured ? 1 : 0));
162
+ }
163
+
164
+ if (filters.pinned !== undefined) {
165
+ conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
166
+ }
167
+
168
+ if (filters.format) {
169
+ conditions.push(eq(posts.format, filters.format));
170
+ }
171
+
172
+ if (filters.collectionId !== undefined) {
173
+ conditions.push(eq(posts.collectionId, filters.collectionId));
174
+ }
175
+
176
+ if (filters.threadId) {
177
+ conditions.push(eq(posts.threadId, filters.threadId));
178
+ }
179
+
180
+ if (filters.excludeReplies) {
181
+ conditions.push(isNull(posts.threadId));
182
+ }
183
+
184
+ if (!filters.includeDeleted) {
185
+ conditions.push(isNull(posts.deletedAt));
186
+ }
187
+
188
+ const result = await db
189
+ .select({ count: sql<number>`count(*)`.as("count") })
190
+ .from(posts)
191
+ .where(conditions.length > 0 ? and(...conditions) : undefined);
192
+
193
+ return result[0]?.count ?? 0;
194
+ },
195
+
156
196
  async create(data) {
157
197
  const timestamp = now();
158
198
 
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;
199
+ const bodyHtml = data.body ? renderMarkdown(data.body) : null;
166
200
 
167
201
  // Handle thread relationship
168
202
  let threadId: number | null = null;
169
- let visibility = data.visibility ?? "quiet";
203
+ let status: Status = data.status ?? "published";
204
+ let featured = data.featured ?? false;
170
205
 
171
206
  if (data.replyToId) {
172
207
  const parent = await this.getById(data.replyToId);
173
208
  if (parent) {
174
- // thread_id = parent's thread_id or parent's id (if parent is root)
175
209
  threadId = parent.threadId ?? parent.id;
176
- // Inherit visibility from root
210
+ // Inherit status and featured from root
177
211
  const root = parent.threadId
178
212
  ? await this.getById(parent.threadId)
179
213
  : parent;
180
214
  if (root) {
181
- visibility = root.visibility;
215
+ status = root.status as Status;
216
+ featured = root.featured === 1;
182
217
  }
183
218
  }
184
219
  }
@@ -186,15 +221,18 @@ export function createPostService(db: Database): PostService {
186
221
  const result = await db
187
222
  .insert(posts)
188
223
  .values({
189
- type: data.type,
190
- visibility,
191
- title: data.title ?? null,
224
+ format: data.format,
225
+ status,
226
+ featured: featured ? 1 : 0,
227
+ pinned: data.pinned ? 1 : 0,
192
228
  path: data.path ?? null,
193
- content: data.content ?? null,
194
- contentHtml,
195
- sourceUrl: data.sourceUrl ?? null,
196
- sourceName: data.sourceName ?? null,
197
- sourceDomain,
229
+ title: data.title ?? null,
230
+ url: data.url ?? null,
231
+ body: data.body ?? null,
232
+ bodyHtml,
233
+ quoteText: data.quoteText ?? null,
234
+ rating: data.rating ?? null,
235
+ collectionId: data.collectionId ?? null,
198
236
  replyToId: data.replyToId ?? null,
199
237
  threadId,
200
238
  publishedAt: data.publishedAt ?? timestamp,
@@ -216,36 +254,40 @@ export function createPostService(db: Database): PostService {
216
254
  updatedAt: timestamp,
217
255
  };
218
256
 
219
- if (data.type !== undefined) updates.type = data.type;
220
- if (data.title !== undefined) updates.title = data.title;
257
+ if (data.format !== undefined) updates.format = data.format;
221
258
  if (data.path !== undefined) updates.path = data.path;
259
+ if (data.title !== undefined) updates.title = data.title;
260
+ if (data.url !== undefined) updates.url = data.url;
261
+ if (data.quoteText !== undefined) updates.quoteText = data.quoteText;
262
+ if (data.rating !== undefined) updates.rating = data.rating;
263
+ if (data.collectionId !== undefined)
264
+ updates.collectionId = data.collectionId;
222
265
  if (data.publishedAt !== undefined)
223
266
  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;
267
+ if (data.pinned !== undefined) updates.pinned = data.pinned ? 1 : 0;
231
268
 
232
- if (data.content !== undefined) {
233
- updates.content = data.content;
234
- updates.contentHtml = data.content
235
- ? renderMarkdown(data.content)
236
- : null;
269
+ if (data.body !== undefined) {
270
+ updates.body = data.body;
271
+ updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
237
272
  }
238
273
 
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
- }
274
+ // Handle status/featured change - cascade to thread if this is root
275
+ const statusChanged =
276
+ data.status !== undefined && data.status !== existing.status;
277
+ const featuredChanged =
278
+ data.featured !== undefined &&
279
+ (data.featured ? 1 : 0) !== existing.featured;
280
+
281
+ if (statusChanged) updates.status = data.status;
282
+ if (featuredChanged) updates.featured = data.featured ? 1 : 0;
283
+
284
+ // If this is a root post and status/featured changed, cascade to thread
285
+ if ((statusChanged || featuredChanged) && !existing.threadId) {
286
+ await this.updateThreadStatusAndFeatured(
287
+ id,
288
+ data.status ?? (existing.status as Status),
289
+ data.featured !== undefined ? data.featured : existing.featured === 1,
290
+ );
249
291
  }
250
292
 
251
293
  const result = await db
@@ -270,7 +312,6 @@ export function createPostService(db: Database): PostService {
270
312
  .set({ deletedAt: timestamp, updatedAt: timestamp })
271
313
  .where(or(eq(posts.id, id), eq(posts.threadId, id)));
272
314
  } else {
273
- // Just delete this single post
274
315
  await db
275
316
  .update(posts)
276
317
  .set({ deletedAt: timestamp, updatedAt: timestamp })
@@ -295,11 +336,11 @@ export function createPostService(db: Database): PostService {
295
336
  return rows.map(toPost);
296
337
  },
297
338
 
298
- async updateThreadVisibility(rootId, visibility) {
339
+ async updateThreadStatusAndFeatured(rootId, status, featured) {
299
340
  const timestamp = now();
300
341
  await db
301
342
  .update(posts)
302
- .set({ visibility, updatedAt: timestamp })
343
+ .set({ status, featured: featured ? 1 : 0, updatedAt: timestamp })
303
344
  .where(eq(posts.threadId, rootId));
304
345
  },
305
346
 
@@ -333,7 +374,6 @@ export function createPostService(db: Database): PostService {
333
374
  .where(and(inArray(posts.threadId, rootIds), isNull(posts.deletedAt)))
334
375
  .orderBy(posts.threadId, posts.createdAt);
335
376
 
336
- // Partition by threadId, take first previewCount per thread
337
377
  const result = new Map<number, Post[]>();
338
378
  for (const row of rows) {
339
379
  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
- title: string | null;
28
+ format: string;
29
+ status: string;
30
+ featured: number;
31
+ pinned: number;
29
32
  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;
33
+ title: 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"],
92
- title: row.title,
98
+ format: row.format as Post["format"],
99
+ status: row.status as Post["status"],
100
+ featured: row.featured,
101
+ pinned: row.pinned,
93
102
  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,
103
+ title: row.title,
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,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Design Tokens
3
+ *
4
+ * CSS custom properties for all visual aspects of the UI.
5
+ * These are the stable customization API — override in custom CSS
6
+ * to change typography, layout, surfaces, and element sizing.
7
+ */
8
+
9
+ :root {
10
+ /* Typography */
11
+ --font-body: system-ui, sans-serif;
12
+ --font-heading: var(--font-body);
13
+ --font-mono: ui-monospace, monospace;
14
+ --text-sm: 0.8125rem;
15
+ --text-base: 0.9375rem;
16
+ --text-lg: 1.0625rem;
17
+ --leading: 1.5;
18
+
19
+ /* Layout */
20
+ --site-width: 640px;
21
+ --site-padding: 1.5rem;
22
+ --content-gap: 1rem;
23
+ --space-xl: 2rem;
24
+
25
+ /* Surfaces */
26
+ --card-bg: var(--card);
27
+ --card-radius: 0;
28
+ --card-padding: 1rem;
29
+ --card-border-width: 0;
30
+ --card-shadow: none;
31
+
32
+ /* Elements */
33
+ --avatar-size: 36px;
34
+ --avatar-radius: 50%;
35
+ --media-radius: 0.5rem;
36
+
37
+ /* Derived color tokens (from BaseCoat variables) */
38
+ --site-column-outline: var(--border);
39
+ --site-threadline: var(--border);
40
+ --site-page-bg: var(--background);
41
+ --site-elevated-bg: var(--background);
42
+ --site-nav-hover-bg: var(--accent);
43
+ --site-text-primary: var(--foreground);
44
+ --site-text-secondary: var(--muted-foreground);
45
+ --site-media-outline: var(--border);
46
+ --site-divider: var(--border);
47
+ }