@jant/core 0.3.22 → 0.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +23 -5
- 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 -6
- package/dist/lib/constants.js +1 -4
- package/dist/lib/excerpt.js +76 -0
- package/dist/lib/feed.js +18 -7
- package/dist/lib/navigation.js +4 -5
- package/dist/lib/render.js +1 -1
- package/dist/lib/schemas.js +80 -38
- package/dist/lib/theme-components.js +8 -11
- package/dist/lib/time.js +56 -1
- package/dist/lib/timeline.js +119 -0
- package/dist/lib/view.js +62 -73
- package/dist/routes/api/posts.js +29 -35
- package/dist/routes/api/search.js +5 -6
- package/dist/routes/api/upload.js +13 -13
- package/dist/routes/dash/collections.js +22 -40
- package/dist/routes/dash/index.js +2 -2
- package/dist/routes/dash/navigation.js +25 -24
- package/dist/routes/dash/pages.js +42 -57
- package/dist/routes/dash/posts.js +27 -35
- package/dist/routes/feed/rss.js +2 -4
- package/dist/routes/feed/sitemap.js +10 -7
- package/dist/routes/pages/archive.js +12 -11
- package/dist/routes/pages/collection.js +11 -5
- package/dist/routes/pages/home.js +53 -61
- package/dist/routes/pages/page.js +60 -29
- package/dist/routes/pages/post.js +5 -12
- package/dist/routes/pages/search.js +3 -4
- 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 +80 -0
- package/dist/services/post.js +68 -69
- package/dist/services/search.js +24 -18
- package/dist/theme/components/MediaGallery.js +19 -91
- package/dist/theme/components/PageForm.js +15 -15
- package/dist/theme/components/PostForm.js +136 -129
- package/dist/theme/components/PostList.js +13 -8
- package/dist/theme/components/ThreadView.js +3 -3
- package/dist/theme/components/TypeBadge.js +3 -14
- package/dist/theme/components/VisibilityBadge.js +33 -23
- package/dist/theme/components/index.js +0 -2
- package/dist/theme/index.js +10 -16
- package/dist/theme/layouts/index.js +0 -1
- package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
- package/dist/themes/threads/index.js +81 -0
- package/dist/{theme → themes/threads}/pages/ArchivePage.js +31 -47
- package/dist/themes/threads/pages/CollectionPage.js +65 -0
- package/dist/{theme → themes/threads}/pages/HomePage.js +4 -5
- package/dist/{theme → themes/threads}/pages/PostPage.js +10 -8
- package/dist/{theme → themes/threads}/pages/SearchPage.js +8 -8
- package/dist/{theme → themes/threads}/pages/SinglePage.js +5 -6
- package/dist/{theme/components → themes/threads}/timeline/LinkCard.js +20 -11
- package/dist/themes/threads/timeline/NoteCard.js +53 -0
- package/dist/themes/threads/timeline/QuoteCard.js +59 -0
- package/dist/{theme/components → themes/threads}/timeline/ThreadPreview.js +5 -6
- package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
- package/dist/{theme/components → themes/threads}/timeline/TimelineItem.js +8 -17
- package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
- package/dist/themes/threads/timeline/groupByDate.js +22 -0
- package/dist/themes/threads/timeline/timelineMore.js +107 -0
- package/dist/types.js +24 -40
- package/package.json +2 -1
- package/src/__tests__/helpers/app.ts +4 -0
- package/src/__tests__/helpers/db.ts +51 -74
- package/src/app.tsx +27 -6
- package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/schema.ts +63 -46
- package/src/i18n/locales/en.po +216 -164
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +216 -164
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +216 -164
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +30 -15
- package/src/lib/__tests__/excerpt.test.ts +125 -0
- package/src/lib/__tests__/schemas.test.ts +166 -105
- package/src/lib/__tests__/theme-components.test.ts +4 -25
- package/src/lib/__tests__/time.test.ts +62 -0
- package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
- package/src/lib/__tests__/view.test.ts +217 -67
- package/src/lib/constants.ts +1 -4
- package/src/lib/excerpt.ts +87 -0
- package/src/lib/feed.ts +22 -7
- package/src/lib/navigation.ts +6 -7
- package/src/lib/render.tsx +1 -1
- package/src/lib/schemas.ts +118 -52
- package/src/lib/theme-components.ts +10 -13
- package/src/lib/time.ts +64 -0
- package/src/lib/timeline.ts +170 -0
- package/src/lib/view.ts +81 -83
- package/src/preset.css +45 -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/posts.ts +30 -30
- package/src/routes/api/search.ts +4 -4
- package/src/routes/api/upload.ts +16 -6
- package/src/routes/dash/collections.tsx +18 -40
- package/src/routes/dash/index.tsx +2 -2
- package/src/routes/dash/navigation.tsx +27 -26
- package/src/routes/dash/pages.tsx +45 -60
- package/src/routes/dash/posts.tsx +44 -52
- package/src/routes/feed/rss.ts +2 -1
- package/src/routes/feed/sitemap.ts +14 -4
- package/src/routes/pages/archive.tsx +14 -10
- package/src/routes/pages/collection.tsx +17 -6
- package/src/routes/pages/home.tsx +56 -81
- package/src/routes/pages/page.tsx +64 -27
- package/src/routes/pages/post.tsx +5 -14
- package/src/routes/pages/search.tsx +2 -2
- 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__/post-timeline.test.ts +92 -88
- package/src/services/__tests__/post.test.ts +342 -206
- 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 +124 -0
- package/src/services/post.ts +93 -103
- package/src/services/search.ts +38 -27
- package/src/styles/components.css +0 -54
- package/src/theme/components/MediaGallery.tsx +27 -96
- package/src/theme/components/PageForm.tsx +21 -21
- package/src/theme/components/PostForm.tsx +122 -118
- package/src/theme/components/PostList.tsx +58 -49
- package/src/theme/components/ThreadView.tsx +6 -3
- package/src/theme/components/TypeBadge.tsx +9 -17
- package/src/theme/components/VisibilityBadge.tsx +40 -23
- package/src/theme/components/index.ts +0 -13
- package/src/theme/index.ts +10 -16
- package/src/theme/layouts/index.ts +0 -1
- package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
- package/src/themes/threads/index.ts +100 -0
- package/src/{theme → themes/threads}/pages/ArchivePage.tsx +52 -55
- package/src/themes/threads/pages/CollectionPage.tsx +61 -0
- package/src/{theme → themes/threads}/pages/HomePage.tsx +5 -6
- package/src/{theme → themes/threads}/pages/PostPage.tsx +11 -8
- package/src/{theme → themes/threads}/pages/SearchPage.tsx +9 -13
- package/src/themes/threads/pages/SinglePage.tsx +23 -0
- package/src/themes/threads/style.css +336 -0
- package/src/{theme/components → themes/threads}/timeline/LinkCard.tsx +21 -13
- package/src/themes/threads/timeline/NoteCard.tsx +58 -0
- package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
- package/src/{theme/components → themes/threads}/timeline/ThreadPreview.tsx +6 -6
- package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
- package/src/{theme/components → themes/threads}/timeline/TimelineItem.tsx +9 -20
- package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
- package/src/themes/threads/timeline/groupByDate.ts +30 -0
- package/src/themes/threads/timeline/timelineMore.tsx +130 -0
- package/src/types.ts +242 -98
- package/dist/routes/api/timeline.js +0 -120
- package/dist/theme/components/timeline/ArticleCard.js +0 -46
- package/dist/theme/components/timeline/ImageCard.js +0 -83
- package/dist/theme/components/timeline/NoteCard.js +0 -34
- package/dist/theme/components/timeline/QuoteCard.js +0 -48
- package/dist/theme/components/timeline/TimelineFeed.js +0 -46
- package/dist/theme/components/timeline/index.js +0 -8
- package/dist/theme/layouts/SiteLayout.js +0 -131
- package/dist/theme/pages/CollectionPage.js +0 -63
- package/dist/theme/pages/index.js +0 -11
- package/src/routes/api/timeline.tsx +0 -159
- package/src/theme/components/timeline/ArticleCard.tsx +0 -45
- package/src/theme/components/timeline/ImageCard.tsx +0 -70
- package/src/theme/components/timeline/NoteCard.tsx +0 -34
- package/src/theme/components/timeline/QuoteCard.tsx +0 -48
- package/src/theme/components/timeline/TimelineFeed.tsx +0 -56
- package/src/theme/components/timeline/index.ts +0 -8
- package/src/theme/layouts/SiteLayout.tsx +0 -132
- package/src/theme/pages/CollectionPage.tsx +0 -60
- package/src/theme/pages/SinglePage.tsx +0 -24
- package/src/theme/pages/index.ts +0 -13
|
@@ -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
|
+
}
|
package/src/services/post.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
41
28
|
}
|
|
42
29
|
|
|
43
30
|
export interface PostService {
|
|
44
31
|
getById(id: number): Promise<Post | null>;
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
87
|
+
async getBySlug(slug) {
|
|
95
88
|
const result = await db
|
|
96
89
|
.select()
|
|
97
90
|
.from(posts)
|
|
98
|
-
.where(and(eq(posts.
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
233
|
-
updates.
|
|
234
|
-
updates.
|
|
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
|
|
240
|
-
|
|
241
|
-
data.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
289
|
+
async updateThreadStatusAndFeatured(rootId, status, featured) {
|
|
299
290
|
const timestamp = now();
|
|
300
291
|
await db
|
|
301
292
|
.update(posts)
|
|
302
|
-
.set({
|
|
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);
|
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
|
+
format: string;
|
|
29
|
+
status: string;
|
|
30
|
+
featured: number;
|
|
31
|
+
pinned: number;
|
|
32
|
+
slug: string | null;
|
|
28
33
|
title: string | null;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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,
|
|
@@ -124,60 +124,6 @@
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
/* Timeline cards */
|
|
128
|
-
@layer components {
|
|
129
|
-
.timeline-card {
|
|
130
|
-
@apply rounded-lg border p-4;
|
|
131
|
-
border-color: var(--color-border);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
.timeline-card-link {
|
|
135
|
-
border-left-width: 4px;
|
|
136
|
-
border-left-color: var(--color-primary);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
.timeline-card-quote {
|
|
140
|
-
border-left-width: 4px;
|
|
141
|
-
border-left-color: var(--color-muted-foreground);
|
|
142
|
-
background-color: var(--color-muted);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
.timeline-card-image {
|
|
146
|
-
@apply p-0 overflow-hidden;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
.timeline-card-image-gallery {
|
|
150
|
-
/* Remove default margin from MediaGallery inside image cards */
|
|
151
|
-
> div {
|
|
152
|
-
@apply mt-0;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
.timeline-card-compact {
|
|
157
|
-
@apply p-3 text-sm;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
.timeline-thread-replies {
|
|
161
|
-
@apply relative ml-5;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
.timeline-thread-reply {
|
|
165
|
-
@apply relative pl-5 mt-3;
|
|
166
|
-
|
|
167
|
-
&::after {
|
|
168
|
-
content: "";
|
|
169
|
-
@apply absolute left-0 top-0 bottom-0 w-px;
|
|
170
|
-
background-color: var(--color-border);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
&::before {
|
|
174
|
-
content: "";
|
|
175
|
-
@apply absolute left-0 top-4 h-px w-4;
|
|
176
|
-
background-color: var(--color-border);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
127
|
@keyframes toast-in {
|
|
182
128
|
from {
|
|
183
129
|
opacity: 0;
|