@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.
- package/dist/app.js +50 -26
- package/dist/db/schema.js +72 -47
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/index.js +5 -11
- package/dist/lib/constants.js +2 -4
- package/dist/lib/excerpt.js +76 -0
- package/dist/lib/feed.js +18 -7
- package/dist/lib/nav-reorder.js +1 -1
- package/dist/lib/navigation.js +30 -6
- package/dist/lib/pagination.js +44 -0
- package/dist/lib/render.js +7 -11
- package/dist/lib/schemas.js +80 -38
- package/dist/lib/theme.js +4 -4
- package/dist/lib/time.js +56 -1
- package/dist/lib/timeline.js +95 -0
- package/dist/lib/view.js +61 -72
- package/dist/routes/api/collections.js +124 -0
- package/dist/routes/api/nav-items.js +104 -0
- package/dist/routes/api/pages.js +91 -0
- package/dist/routes/api/posts.js +27 -33
- package/dist/routes/api/search.js +4 -5
- package/dist/routes/api/settings.js +68 -0
- package/dist/routes/api/upload.js +13 -13
- package/dist/routes/compose.js +48 -0
- package/dist/routes/dash/collections.js +24 -42
- package/dist/routes/dash/index.js +3 -3
- package/dist/routes/dash/media.js +2 -2
- package/dist/routes/dash/pages.js +440 -106
- package/dist/routes/dash/posts.js +27 -37
- package/dist/routes/dash/redirects.js +2 -2
- package/dist/routes/dash/settings.js +79 -5
- package/dist/routes/feed/rss.js +4 -6
- package/dist/routes/feed/sitemap.js +11 -8
- package/dist/routes/pages/archive.js +13 -15
- package/dist/routes/pages/collection.js +12 -9
- package/dist/routes/pages/collections.js +28 -0
- package/dist/routes/pages/featured.js +32 -0
- package/dist/routes/pages/home.js +19 -68
- package/dist/routes/pages/page.js +57 -29
- package/dist/routes/pages/post.js +7 -17
- package/dist/routes/pages/search.js +5 -9
- package/dist/services/collection.js +52 -64
- package/dist/services/index.js +5 -3
- package/dist/services/navigation.js +29 -53
- package/dist/services/page.js +84 -0
- package/dist/services/post.js +102 -69
- package/dist/services/search.js +24 -18
- package/dist/types.js +24 -40
- package/dist/ui/compose/ComposeDialog.js +452 -0
- package/dist/ui/compose/ComposePrompt.js +55 -0
- package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +3 -15
- package/dist/{theme/components → ui/dash}/PageForm.js +15 -15
- package/dist/{theme/components → ui/dash}/PostForm.js +117 -137
- package/dist/{theme/components → ui/dash}/PostList.js +18 -13
- package/dist/ui/dash/StatusBadge.js +46 -0
- package/dist/{theme/components → ui/dash}/index.js +3 -6
- package/dist/ui/feed/LinkCard.js +72 -0
- package/dist/ui/feed/NoteCard.js +58 -0
- package/dist/{themes/minimal/timeline → ui/feed}/QuoteCard.js +29 -14
- package/dist/{themes/minimal/timeline → ui/feed}/ThreadPreview.js +20 -18
- package/dist/ui/feed/TimelineFeed.js +41 -0
- package/dist/ui/feed/TimelineItem.js +27 -0
- package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
- package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
- package/dist/ui/layouts/SiteLayout.js +141 -0
- package/dist/{themes/minimal → ui}/pages/ArchivePage.js +37 -50
- package/dist/ui/pages/CollectionPage.js +70 -0
- package/dist/ui/pages/CollectionsPage.js +76 -0
- package/dist/ui/pages/FeaturedPage.js +24 -0
- package/dist/ui/pages/HomePage.js +24 -0
- package/dist/{themes/minimal → ui}/pages/PostPage.js +20 -12
- package/dist/{themes/minimal → ui}/pages/SearchPage.js +19 -18
- package/dist/{themes/minimal → ui}/pages/SinglePage.js +5 -4
- package/dist/ui/shared/MediaGallery.js +35 -0
- package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
- package/dist/{theme/components → ui/shared}/ThreadView.js +3 -3
- package/dist/ui/shared/index.js +5 -0
- package/package.json +2 -9
- package/src/__tests__/helpers/app.ts +4 -0
- package/src/__tests__/helpers/db.ts +53 -73
- package/src/app.tsx +56 -28
- package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
- package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
- package/src/db/migrations/meta/_journal.json +14 -0
- package/src/db/schema.ts +63 -46
- package/src/i18n/locales/en.po +443 -240
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +443 -240
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +443 -240
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +29 -42
- package/src/lib/__tests__/excerpt.test.ts +125 -0
- package/src/lib/__tests__/schemas.test.ts +201 -99
- package/src/lib/__tests__/time.test.ts +62 -0
- package/src/{routes/api → lib}/__tests__/timeline.test.ts +81 -75
- package/src/lib/__tests__/view.test.ts +204 -50
- package/src/lib/constants.ts +2 -4
- package/src/lib/excerpt.ts +87 -0
- package/src/lib/feed.ts +22 -7
- package/src/lib/nav-reorder.ts +1 -1
- package/src/lib/navigation.ts +45 -8
- package/src/lib/pagination.ts +50 -0
- package/src/lib/render.tsx +7 -14
- package/src/lib/schemas.ts +119 -51
- package/src/lib/theme.ts +5 -5
- package/src/lib/time.ts +64 -0
- package/src/lib/timeline.ts +141 -0
- package/src/lib/view.ts +80 -82
- package/src/preset.css +46 -0
- package/src/routes/__tests__/compose.test.ts +199 -0
- package/src/routes/api/__tests__/collections.test.ts +249 -0
- package/src/routes/api/__tests__/nav-items.test.ts +222 -0
- package/src/routes/api/__tests__/pages.test.ts +218 -0
- package/src/routes/api/__tests__/posts.test.ts +50 -108
- package/src/routes/api/__tests__/search.test.ts +2 -3
- package/src/routes/api/__tests__/settings.test.ts +132 -0
- package/src/routes/api/collections.ts +143 -0
- package/src/routes/api/nav-items.ts +115 -0
- package/src/routes/api/pages.ts +101 -0
- package/src/routes/api/posts.ts +28 -28
- package/src/routes/api/search.ts +3 -3
- package/src/routes/api/settings.ts +91 -0
- package/src/routes/api/upload.ts +16 -6
- package/src/routes/compose.ts +63 -0
- package/src/routes/dash/__tests__/pages.test.ts +225 -0
- package/src/routes/dash/collections.tsx +20 -42
- package/src/routes/dash/index.tsx +3 -3
- package/src/routes/dash/media.tsx +2 -2
- package/src/routes/dash/pages.tsx +480 -122
- package/src/routes/dash/posts.tsx +42 -54
- package/src/routes/dash/redirects.tsx +2 -2
- package/src/routes/dash/settings.tsx +83 -5
- package/src/routes/feed/rss.ts +4 -3
- package/src/routes/feed/sitemap.ts +15 -5
- package/src/routes/pages/__tests__/collections.test.ts +94 -0
- package/src/routes/pages/__tests__/featured.test.ts +94 -0
- package/src/routes/pages/archive.tsx +15 -15
- package/src/routes/pages/collection.tsx +16 -9
- package/src/routes/pages/collections.tsx +36 -0
- package/src/routes/pages/featured.tsx +38 -0
- package/src/routes/pages/home.tsx +21 -92
- package/src/routes/pages/page.tsx +62 -27
- package/src/routes/pages/post.tsx +6 -18
- package/src/routes/pages/search.tsx +3 -7
- package/src/services/__tests__/collection.test.ts +257 -158
- package/src/services/__tests__/media.test.ts +18 -18
- package/src/services/__tests__/navigation.test.ts +161 -87
- package/src/services/__tests__/page.test.ts +106 -0
- package/src/services/__tests__/post-timeline.test.ts +92 -88
- package/src/services/__tests__/post.test.ts +432 -197
- package/src/services/__tests__/search.test.ts +19 -25
- package/src/services/collection.ts +71 -113
- package/src/services/index.ts +9 -8
- package/src/services/navigation.ts +38 -71
- package/src/services/page.ts +136 -0
- package/src/services/post.ts +141 -101
- package/src/services/search.ts +38 -27
- package/src/styles/tokens.css +47 -0
- package/src/styles/ui.css +491 -0
- package/src/types.ts +212 -198
- package/src/ui/compose/ComposeDialog.tsx +395 -0
- package/src/ui/compose/ComposePrompt.tsx +55 -0
- package/src/ui/dash/FormatBadge.tsx +28 -0
- package/src/{theme/components → ui/dash}/PageForm.tsx +21 -21
- package/src/{theme/components → ui/dash}/PostForm.tsx +110 -131
- package/src/ui/dash/PostList.tsx +101 -0
- package/src/ui/dash/StatusBadge.tsx +61 -0
- package/src/ui/dash/index.ts +10 -0
- package/src/ui/feed/LinkCard.tsx +72 -0
- package/src/ui/feed/NoteCard.tsx +63 -0
- package/src/ui/feed/QuoteCard.tsx +68 -0
- package/src/ui/feed/ThreadPreview.tsx +48 -0
- package/src/ui/feed/TimelineFeed.tsx +49 -0
- package/src/ui/feed/TimelineItem.tsx +45 -0
- package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
- package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
- package/src/ui/layouts/SiteLayout.tsx +150 -0
- package/src/ui/pages/ArchivePage.tsx +162 -0
- package/src/ui/pages/CollectionPage.tsx +70 -0
- package/src/ui/pages/CollectionsPage.tsx +73 -0
- package/src/ui/pages/FeaturedPage.tsx +31 -0
- package/src/ui/pages/HomePage.tsx +37 -0
- package/src/ui/pages/PostPage.tsx +56 -0
- package/src/{themes/minimal → ui}/pages/SearchPage.tsx +24 -20
- package/src/{themes/minimal → ui}/pages/SinglePage.tsx +5 -5
- package/src/ui/shared/MediaGallery.tsx +59 -0
- package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
- package/src/{theme/components → ui/shared}/ThreadView.tsx +6 -3
- package/src/ui/shared/__tests__/pagination.test.ts +46 -0
- package/src/ui/shared/index.ts +12 -0
- package/bin/jant.js +0 -185
- package/dist/lib/theme-components.js +0 -49
- package/dist/routes/api/timeline.js +0 -120
- package/dist/routes/dash/navigation.js +0 -288
- package/dist/theme/components/MediaGallery.js +0 -107
- package/dist/theme/components/VisibilityBadge.js +0 -37
- package/dist/theme/index.js +0 -18
- package/dist/theme/layouts/index.js +0 -2
- package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
- package/dist/themes/minimal/index.js +0 -65
- package/dist/themes/minimal/pages/CollectionPage.js +0 -65
- package/dist/themes/minimal/pages/HomePage.js +0 -25
- package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
- package/dist/themes/minimal/timeline/ImageCard.js +0 -67
- package/dist/themes/minimal/timeline/LinkCard.js +0 -47
- package/dist/themes/minimal/timeline/NoteCard.js +0 -34
- package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
- package/dist/themes/minimal/timeline/TimelineItem.js +0 -44
- package/src/lib/__tests__/theme-components.test.ts +0 -126
- package/src/lib/theme-components.ts +0 -68
- package/src/routes/api/timeline.tsx +0 -159
- package/src/routes/dash/navigation.tsx +0 -316
- package/src/theme/components/MediaGallery.tsx +0 -128
- package/src/theme/components/PostList.tsx +0 -92
- package/src/theme/components/TypeBadge.tsx +0 -37
- package/src/theme/components/VisibilityBadge.tsx +0 -45
- package/src/theme/components/index.ts +0 -23
- package/src/theme/index.ts +0 -22
- package/src/theme/layouts/index.ts +0 -7
- package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
- package/src/themes/minimal/index.ts +0 -83
- package/src/themes/minimal/pages/ArchivePage.tsx +0 -157
- package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
- package/src/themes/minimal/pages/HomePage.tsx +0 -41
- package/src/themes/minimal/pages/PostPage.tsx +0 -43
- package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
- package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
- package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
- package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
- package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
- package/src/themes/minimal/timeline/ThreadPreview.tsx +0 -47
- package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
- package/src/themes/minimal/timeline/TimelineItem.tsx +0 -75
- /package/dist/{theme → ui}/color-themes.js +0 -0
- /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
- /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
- /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
- /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
- /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
- /package/src/{theme → ui}/color-themes.ts +0 -0
- /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
- /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
- /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
- /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
- /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
|
+
}
|
package/src/services/post.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
224
|
+
format: data.format,
|
|
225
|
+
status,
|
|
226
|
+
featured: featured ? 1 : 0,
|
|
227
|
+
pinned: data.pinned ? 1 : 0,
|
|
192
228
|
path: data.path ?? null,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
233
|
-
updates.
|
|
234
|
-
updates.
|
|
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
|
|
240
|
-
|
|
241
|
-
data.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
339
|
+
async updateThreadStatusAndFeatured(rootId, status, featured) {
|
|
299
340
|
const timestamp = now();
|
|
300
341
|
await db
|
|
301
342
|
.update(posts)
|
|
302
|
-
.set({
|
|
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);
|
package/src/services/search.ts
CHANGED
|
@@ -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,
|
|
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
|
|
17
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
format: string;
|
|
29
|
+
status: string;
|
|
30
|
+
featured: number;
|
|
31
|
+
pinned: number;
|
|
29
32
|
path: string | null;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
66
|
-
const
|
|
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.
|
|
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, ...
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
}
|