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