@jant/core 0.3.7 → 0.3.8
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 +4 -0
- package/dist/client.js +1 -0
- package/dist/db/schema.js +13 -0
- 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/lib/image.js +3 -3
- package/dist/lib/media-helpers.js +43 -0
- package/dist/lib/nav-reorder.js +27 -0
- package/dist/lib/navigation.js +35 -0
- package/dist/lib/theme-components.js +49 -0
- package/dist/routes/api/timeline.js +115 -0
- package/dist/routes/api/upload.js +9 -5
- package/dist/routes/dash/navigation.js +274 -0
- package/dist/routes/pages/archive.js +14 -27
- package/dist/routes/pages/collection.js +10 -19
- package/dist/routes/pages/home.js +83 -126
- package/dist/routes/pages/page.js +19 -38
- package/dist/routes/pages/post.js +38 -51
- package/dist/routes/pages/search.js +13 -26
- package/dist/services/index.js +3 -1
- package/dist/services/media.js +1 -1
- package/dist/services/navigation.js +115 -0
- package/dist/services/post.js +26 -1
- package/dist/theme/components/PostList.js +5 -0
- package/dist/theme/components/index.js +2 -0
- package/dist/theme/components/timeline/ArticleCard.js +50 -0
- package/dist/theme/components/timeline/ImageCard.js +86 -0
- package/dist/theme/components/timeline/LinkCard.js +62 -0
- package/dist/theme/components/timeline/NoteCard.js +37 -0
- package/dist/theme/components/timeline/QuoteCard.js +51 -0
- package/dist/theme/components/timeline/ThreadPreview.js +52 -0
- package/dist/theme/components/timeline/TimelineFeed.js +43 -0
- package/dist/theme/components/timeline/TimelineItem.js +25 -0
- package/dist/theme/components/timeline/index.js +8 -0
- package/dist/theme/layouts/DashLayout.js +8 -0
- package/dist/theme/layouts/SiteLayout.js +160 -0
- package/dist/theme/layouts/index.js +1 -0
- package/dist/types/sortablejs.d.js +5 -0
- package/package.json +3 -2
- package/src/__tests__/helpers/db.ts +10 -0
- package/src/app.tsx +4 -0
- package/src/client.ts +1 -0
- package/src/db/migrations/0003_add_navigation_links.sql +8 -0
- package/src/db/migrations/meta/0003_snapshot.json +821 -0
- package/src/db/migrations/meta/_journal.json +14 -0
- package/src/db/schema.ts +13 -0
- package/src/i18n/locales/en.po +100 -32
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +102 -55
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +102 -55
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +5 -0
- package/src/lib/__tests__/theme-components.test.ts +107 -0
- package/src/lib/image.ts +3 -3
- package/src/lib/media-helpers.ts +54 -0
- package/src/lib/nav-reorder.ts +26 -0
- package/src/lib/navigation.ts +46 -0
- package/src/lib/theme-components.ts +76 -0
- package/src/routes/api/__tests__/posts.test.ts +8 -8
- package/src/routes/api/__tests__/timeline.test.ts +242 -0
- package/src/routes/api/timeline.tsx +145 -0
- package/src/routes/api/upload.ts +9 -5
- package/src/routes/dash/navigation.tsx +306 -0
- package/src/routes/pages/archive.tsx +15 -23
- package/src/routes/pages/collection.tsx +8 -15
- package/src/routes/pages/home.tsx +111 -122
- package/src/routes/pages/page.tsx +17 -30
- package/src/routes/pages/post.tsx +33 -42
- package/src/routes/pages/search.tsx +18 -22
- package/src/services/__tests__/media.test.ts +34 -7
- package/src/services/__tests__/navigation.test.ts +213 -0
- package/src/services/__tests__/post-timeline.test.ts +220 -0
- package/src/services/index.ts +7 -0
- package/src/services/media.ts +2 -1
- package/src/services/navigation.ts +165 -0
- package/src/services/post.ts +48 -1
- package/src/styles/components.css +59 -0
- package/src/theme/components/PostList.tsx +7 -0
- package/src/theme/components/index.ts +12 -0
- package/src/theme/components/timeline/ArticleCard.tsx +57 -0
- package/src/theme/components/timeline/ImageCard.tsx +80 -0
- package/src/theme/components/timeline/LinkCard.tsx +66 -0
- package/src/theme/components/timeline/NoteCard.tsx +41 -0
- package/src/theme/components/timeline/QuoteCard.tsx +55 -0
- package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
- package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
- package/src/theme/components/timeline/TimelineItem.tsx +39 -0
- package/src/theme/components/timeline/index.ts +8 -0
- package/src/theme/layouts/DashLayout.tsx +10 -0
- package/src/theme/layouts/SiteLayout.tsx +184 -0
- package/src/theme/layouts/index.ts +1 -0
- package/src/types/sortablejs.d.ts +23 -0
- package/src/types.ts +61 -0
- package/dist/app.d.ts +0 -38
- package/dist/app.d.ts.map +0 -1
- package/dist/auth.d.ts +0 -25
- package/dist/auth.d.ts.map +0 -1
- package/dist/db/index.d.ts +0 -10
- package/dist/db/index.d.ts.map +0 -1
- package/dist/db/schema.d.ts +0 -1543
- package/dist/db/schema.d.ts.map +0 -1
- package/dist/i18n/Trans.d.ts +0 -25
- package/dist/i18n/Trans.d.ts.map +0 -1
- package/dist/i18n/context.d.ts +0 -69
- package/dist/i18n/context.d.ts.map +0 -1
- package/dist/i18n/detect.d.ts +0 -20
- package/dist/i18n/detect.d.ts.map +0 -1
- package/dist/i18n/i18n.d.ts +0 -32
- package/dist/i18n/i18n.d.ts.map +0 -1
- package/dist/i18n/index.d.ts +0 -41
- package/dist/i18n/index.d.ts.map +0 -1
- package/dist/i18n/locales/en.d.ts +0 -3
- package/dist/i18n/locales/en.d.ts.map +0 -1
- package/dist/i18n/locales/zh-Hans.d.ts +0 -3
- package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
- package/dist/i18n/locales/zh-Hant.d.ts +0 -3
- package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
- package/dist/i18n/locales.d.ts +0 -11
- package/dist/i18n/locales.d.ts.map +0 -1
- package/dist/i18n/middleware.d.ts +0 -21
- package/dist/i18n/middleware.d.ts.map +0 -1
- package/dist/index.d.ts +0 -16
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/config.d.ts +0 -83
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/constants.d.ts +0 -37
- package/dist/lib/constants.d.ts.map +0 -1
- package/dist/lib/image.d.ts +0 -73
- package/dist/lib/image.d.ts.map +0 -1
- package/dist/lib/index.d.ts +0 -9
- package/dist/lib/index.d.ts.map +0 -1
- package/dist/lib/markdown.d.ts +0 -60
- package/dist/lib/markdown.d.ts.map +0 -1
- package/dist/lib/schemas.d.ts +0 -130
- package/dist/lib/schemas.d.ts.map +0 -1
- package/dist/lib/sqid.d.ts +0 -60
- package/dist/lib/sqid.d.ts.map +0 -1
- package/dist/lib/sse.d.ts +0 -192
- package/dist/lib/sse.d.ts.map +0 -1
- package/dist/lib/theme.d.ts +0 -44
- package/dist/lib/theme.d.ts.map +0 -1
- package/dist/lib/time.d.ts +0 -90
- package/dist/lib/time.d.ts.map +0 -1
- package/dist/lib/url.d.ts +0 -82
- package/dist/lib/url.d.ts.map +0 -1
- package/dist/middleware/auth.d.ts +0 -24
- package/dist/middleware/auth.d.ts.map +0 -1
- package/dist/middleware/onboarding.d.ts +0 -26
- package/dist/middleware/onboarding.d.ts.map +0 -1
- package/dist/routes/api/posts.d.ts +0 -13
- package/dist/routes/api/posts.d.ts.map +0 -1
- package/dist/routes/api/search.d.ts +0 -13
- package/dist/routes/api/search.d.ts.map +0 -1
- package/dist/routes/api/upload.d.ts +0 -16
- package/dist/routes/api/upload.d.ts.map +0 -1
- package/dist/routes/dash/collections.d.ts +0 -13
- package/dist/routes/dash/collections.d.ts.map +0 -1
- package/dist/routes/dash/index.d.ts +0 -15
- package/dist/routes/dash/index.d.ts.map +0 -1
- package/dist/routes/dash/media.d.ts +0 -16
- package/dist/routes/dash/media.d.ts.map +0 -1
- package/dist/routes/dash/pages.d.ts +0 -15
- package/dist/routes/dash/pages.d.ts.map +0 -1
- package/dist/routes/dash/posts.d.ts +0 -13
- package/dist/routes/dash/posts.d.ts.map +0 -1
- package/dist/routes/dash/redirects.d.ts +0 -13
- package/dist/routes/dash/redirects.d.ts.map +0 -1
- package/dist/routes/dash/settings.d.ts +0 -15
- package/dist/routes/dash/settings.d.ts.map +0 -1
- package/dist/routes/feed/rss.d.ts +0 -13
- package/dist/routes/feed/rss.d.ts.map +0 -1
- package/dist/routes/feed/sitemap.d.ts +0 -13
- package/dist/routes/feed/sitemap.d.ts.map +0 -1
- package/dist/routes/pages/archive.d.ts +0 -15
- package/dist/routes/pages/archive.d.ts.map +0 -1
- package/dist/routes/pages/collection.d.ts +0 -13
- package/dist/routes/pages/collection.d.ts.map +0 -1
- package/dist/routes/pages/home.d.ts +0 -13
- package/dist/routes/pages/home.d.ts.map +0 -1
- package/dist/routes/pages/page.d.ts +0 -15
- package/dist/routes/pages/page.d.ts.map +0 -1
- package/dist/routes/pages/post.d.ts +0 -13
- package/dist/routes/pages/post.d.ts.map +0 -1
- package/dist/routes/pages/search.d.ts +0 -13
- package/dist/routes/pages/search.d.ts.map +0 -1
- package/dist/services/collection.d.ts +0 -32
- package/dist/services/collection.d.ts.map +0 -1
- package/dist/services/index.d.ts +0 -28
- package/dist/services/index.d.ts.map +0 -1
- package/dist/services/media.d.ts +0 -34
- package/dist/services/media.d.ts.map +0 -1
- package/dist/services/post.d.ts +0 -31
- package/dist/services/post.d.ts.map +0 -1
- package/dist/services/redirect.d.ts +0 -15
- package/dist/services/redirect.d.ts.map +0 -1
- package/dist/services/search.d.ts +0 -26
- package/dist/services/search.d.ts.map +0 -1
- package/dist/services/settings.d.ts +0 -18
- package/dist/services/settings.d.ts.map +0 -1
- package/dist/theme/color-themes.d.ts +0 -30
- package/dist/theme/color-themes.d.ts.map +0 -1
- package/dist/theme/components/ActionButtons.d.ts +0 -43
- package/dist/theme/components/ActionButtons.d.ts.map +0 -1
- package/dist/theme/components/CrudPageHeader.d.ts +0 -23
- package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
- package/dist/theme/components/DangerZone.d.ts +0 -36
- package/dist/theme/components/DangerZone.d.ts.map +0 -1
- package/dist/theme/components/EmptyState.d.ts +0 -27
- package/dist/theme/components/EmptyState.d.ts.map +0 -1
- package/dist/theme/components/ListItemRow.d.ts +0 -15
- package/dist/theme/components/ListItemRow.d.ts.map +0 -1
- package/dist/theme/components/MediaGallery.d.ts +0 -13
- package/dist/theme/components/MediaGallery.d.ts.map +0 -1
- package/dist/theme/components/PageForm.d.ts +0 -14
- package/dist/theme/components/PageForm.d.ts.map +0 -1
- package/dist/theme/components/Pagination.d.ts +0 -46
- package/dist/theme/components/Pagination.d.ts.map +0 -1
- package/dist/theme/components/PostForm.d.ts +0 -16
- package/dist/theme/components/PostForm.d.ts.map +0 -1
- package/dist/theme/components/PostList.d.ts +0 -10
- package/dist/theme/components/PostList.d.ts.map +0 -1
- package/dist/theme/components/ThreadView.d.ts +0 -15
- package/dist/theme/components/ThreadView.d.ts.map +0 -1
- package/dist/theme/components/TypeBadge.d.ts +0 -12
- package/dist/theme/components/TypeBadge.d.ts.map +0 -1
- package/dist/theme/components/VisibilityBadge.d.ts +0 -12
- package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
- package/dist/theme/components/index.d.ts +0 -14
- package/dist/theme/components/index.d.ts.map +0 -1
- package/dist/theme/index.d.ts +0 -21
- package/dist/theme/index.d.ts.map +0 -1
- package/dist/theme/layouts/BaseLayout.d.ts +0 -23
- package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
- package/dist/theme/layouts/DashLayout.d.ts +0 -17
- package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
- package/dist/theme/layouts/index.d.ts +0 -3
- package/dist/theme/layouts/index.d.ts.map +0 -1
- package/dist/types.d.ts +0 -237
- package/dist/types.d.ts.map +0 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
|
+
import { createNavigationLinkService } from "../navigation.js";
|
|
4
|
+
import type { Database } from "../../db/index.js";
|
|
5
|
+
|
|
6
|
+
describe("NavigationLinkService", () => {
|
|
7
|
+
let db: Database;
|
|
8
|
+
let navigationService: ReturnType<typeof createNavigationLinkService>;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
const testDb = createTestDatabase();
|
|
12
|
+
db = testDb.db as unknown as Database;
|
|
13
|
+
navigationService = createNavigationLinkService(db);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("create", () => {
|
|
17
|
+
it("creates a navigation link with auto-assigned position", async () => {
|
|
18
|
+
const link = await navigationService.create({
|
|
19
|
+
label: "Home",
|
|
20
|
+
url: "/",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(link.label).toBe("Home");
|
|
24
|
+
expect(link.url).toBe("/");
|
|
25
|
+
expect(link.position).toBe(0);
|
|
26
|
+
expect(link.id).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("auto-increments position for subsequent links", async () => {
|
|
30
|
+
await navigationService.create({ label: "Home", url: "/" });
|
|
31
|
+
const second = await navigationService.create({
|
|
32
|
+
label: "Archive",
|
|
33
|
+
url: "/archive",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(second.position).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("uses provided position when specified", async () => {
|
|
40
|
+
const link = await navigationService.create({
|
|
41
|
+
label: "Home",
|
|
42
|
+
url: "/",
|
|
43
|
+
position: 5,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(link.position).toBe(5);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("getById", () => {
|
|
51
|
+
it("returns a link by ID", async () => {
|
|
52
|
+
const created = await navigationService.create({
|
|
53
|
+
label: "Home",
|
|
54
|
+
url: "/",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const found = await navigationService.getById(created.id);
|
|
58
|
+
expect(found).not.toBeNull();
|
|
59
|
+
expect(found?.label).toBe("Home");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns null for non-existent ID", async () => {
|
|
63
|
+
const found = await navigationService.getById(9999);
|
|
64
|
+
expect(found).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("list", () => {
|
|
69
|
+
it("returns empty array when no links exist", async () => {
|
|
70
|
+
const links = await navigationService.list();
|
|
71
|
+
expect(links).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns links ordered by position", async () => {
|
|
75
|
+
await navigationService.create({
|
|
76
|
+
label: "C",
|
|
77
|
+
url: "/c",
|
|
78
|
+
position: 2,
|
|
79
|
+
});
|
|
80
|
+
await navigationService.create({
|
|
81
|
+
label: "A",
|
|
82
|
+
url: "/a",
|
|
83
|
+
position: 0,
|
|
84
|
+
});
|
|
85
|
+
await navigationService.create({
|
|
86
|
+
label: "B",
|
|
87
|
+
url: "/b",
|
|
88
|
+
position: 1,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const links = await navigationService.list();
|
|
92
|
+
expect(links).toHaveLength(3);
|
|
93
|
+
expect(links[0]?.label).toBe("A");
|
|
94
|
+
expect(links[1]?.label).toBe("B");
|
|
95
|
+
expect(links[2]?.label).toBe("C");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("update", () => {
|
|
100
|
+
it("updates a link's label", async () => {
|
|
101
|
+
const created = await navigationService.create({
|
|
102
|
+
label: "Home",
|
|
103
|
+
url: "/",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const updated = await navigationService.update(created.id, {
|
|
107
|
+
label: "Main Page",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(updated?.label).toBe("Main Page");
|
|
111
|
+
expect(updated?.url).toBe("/");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("updates a link's url", async () => {
|
|
115
|
+
const created = await navigationService.create({
|
|
116
|
+
label: "Blog",
|
|
117
|
+
url: "/blog",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const updated = await navigationService.update(created.id, {
|
|
121
|
+
url: "/posts",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(updated?.url).toBe("/posts");
|
|
125
|
+
expect(updated?.label).toBe("Blog");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns null for non-existent ID", async () => {
|
|
129
|
+
const result = await navigationService.update(9999, { label: "Nope" });
|
|
130
|
+
expect(result).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("delete", () => {
|
|
135
|
+
it("deletes a link by ID", async () => {
|
|
136
|
+
const link = await navigationService.create({
|
|
137
|
+
label: "Home",
|
|
138
|
+
url: "/",
|
|
139
|
+
});
|
|
140
|
+
const result = await navigationService.delete(link.id);
|
|
141
|
+
|
|
142
|
+
expect(result).toBe(true);
|
|
143
|
+
|
|
144
|
+
const found = await navigationService.getById(link.id);
|
|
145
|
+
expect(found).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns false for non-existent ID", async () => {
|
|
149
|
+
const result = await navigationService.delete(9999);
|
|
150
|
+
expect(result).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("reorder", () => {
|
|
155
|
+
it("updates positions to match array order", async () => {
|
|
156
|
+
const a = await navigationService.create({
|
|
157
|
+
label: "A",
|
|
158
|
+
url: "/a",
|
|
159
|
+
});
|
|
160
|
+
const b = await navigationService.create({
|
|
161
|
+
label: "B",
|
|
162
|
+
url: "/b",
|
|
163
|
+
});
|
|
164
|
+
const c = await navigationService.create({
|
|
165
|
+
label: "C",
|
|
166
|
+
url: "/c",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Reverse the order
|
|
170
|
+
await navigationService.reorder([c.id, b.id, a.id]);
|
|
171
|
+
|
|
172
|
+
const links = await navigationService.list();
|
|
173
|
+
expect(links[0]?.label).toBe("C");
|
|
174
|
+
expect(links[0]?.position).toBe(0);
|
|
175
|
+
expect(links[1]?.label).toBe("B");
|
|
176
|
+
expect(links[1]?.position).toBe(1);
|
|
177
|
+
expect(links[2]?.label).toBe("A");
|
|
178
|
+
expect(links[2]?.position).toBe(2);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("ensureDefaults", () => {
|
|
183
|
+
it("creates default links when table is empty", async () => {
|
|
184
|
+
const links = await navigationService.ensureDefaults();
|
|
185
|
+
|
|
186
|
+
expect(links).toHaveLength(3);
|
|
187
|
+
expect(links[0]?.label).toBe("Home");
|
|
188
|
+
expect(links[0]?.url).toBe("/");
|
|
189
|
+
expect(links[1]?.label).toBe("Archive");
|
|
190
|
+
expect(links[1]?.url).toBe("/archive");
|
|
191
|
+
expect(links[2]?.label).toBe("RSS");
|
|
192
|
+
expect(links[2]?.url).toBe("/feed");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns existing links without creating new ones", async () => {
|
|
196
|
+
await navigationService.create({ label: "Custom", url: "/custom" });
|
|
197
|
+
|
|
198
|
+
const links = await navigationService.ensureDefaults();
|
|
199
|
+
|
|
200
|
+
expect(links).toHaveLength(1);
|
|
201
|
+
expect(links[0]?.label).toBe("Custom");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("is idempotent - calling twice returns same result", async () => {
|
|
205
|
+
const first = await navigationService.ensureDefaults();
|
|
206
|
+
const second = await navigationService.ensureDefaults();
|
|
207
|
+
|
|
208
|
+
expect(first).toHaveLength(3);
|
|
209
|
+
expect(second).toHaveLength(3);
|
|
210
|
+
expect(first[0]?.id).toBe(second[0]?.id);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
|
+
import { createPostService } from "../post.js";
|
|
4
|
+
import type { Database } from "../../db/index.js";
|
|
5
|
+
|
|
6
|
+
describe("PostService - Timeline features", () => {
|
|
7
|
+
let db: Database;
|
|
8
|
+
let postService: ReturnType<typeof createPostService>;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
const testDb = createTestDatabase();
|
|
12
|
+
db = testDb.db as unknown as Database;
|
|
13
|
+
postService = createPostService(db);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("excludeTypes filter", () => {
|
|
17
|
+
it("excludes posts of specified types", async () => {
|
|
18
|
+
await postService.create({ type: "note", content: "a note" });
|
|
19
|
+
await postService.create({ type: "page", content: "a page" });
|
|
20
|
+
await postService.create({
|
|
21
|
+
type: "article",
|
|
22
|
+
content: "an article",
|
|
23
|
+
title: "Article",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const posts = await postService.list({ excludeTypes: ["page"] });
|
|
27
|
+
expect(posts).toHaveLength(2);
|
|
28
|
+
expect(posts.every((p) => p.type !== "page")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("excludes multiple types", async () => {
|
|
32
|
+
await postService.create({ type: "note", content: "a note" });
|
|
33
|
+
await postService.create({ type: "page", content: "a page" });
|
|
34
|
+
await postService.create({
|
|
35
|
+
type: "article",
|
|
36
|
+
content: "an article",
|
|
37
|
+
title: "Article",
|
|
38
|
+
});
|
|
39
|
+
await postService.create({
|
|
40
|
+
type: "link",
|
|
41
|
+
content: "a link",
|
|
42
|
+
sourceUrl: "https://example.com",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const posts = await postService.list({
|
|
46
|
+
excludeTypes: ["page", "link"],
|
|
47
|
+
});
|
|
48
|
+
expect(posts).toHaveLength(2);
|
|
49
|
+
expect(posts.every((p) => p.type !== "page" && p.type !== "link")).toBe(
|
|
50
|
+
true,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns all posts when excludeTypes is empty", async () => {
|
|
55
|
+
await postService.create({ type: "note", content: "a note" });
|
|
56
|
+
await postService.create({ type: "page", content: "a page" });
|
|
57
|
+
|
|
58
|
+
const posts = await postService.list({ excludeTypes: [] });
|
|
59
|
+
expect(posts).toHaveLength(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("works combined with other filters", async () => {
|
|
63
|
+
await postService.create({
|
|
64
|
+
type: "note",
|
|
65
|
+
content: "featured note",
|
|
66
|
+
visibility: "featured",
|
|
67
|
+
});
|
|
68
|
+
await postService.create({
|
|
69
|
+
type: "page",
|
|
70
|
+
content: "featured page",
|
|
71
|
+
visibility: "featured",
|
|
72
|
+
});
|
|
73
|
+
await postService.create({
|
|
74
|
+
type: "note",
|
|
75
|
+
content: "draft note",
|
|
76
|
+
visibility: "draft",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const posts = await postService.list({
|
|
80
|
+
excludeTypes: ["page"],
|
|
81
|
+
visibility: "featured",
|
|
82
|
+
});
|
|
83
|
+
expect(posts).toHaveLength(1);
|
|
84
|
+
expect(posts[0]?.type).toBe("note");
|
|
85
|
+
expect(posts[0]?.visibility).toBe("featured");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("getThreadPreviews", () => {
|
|
90
|
+
it("returns empty map for empty input", async () => {
|
|
91
|
+
const previews = await postService.getThreadPreviews([]);
|
|
92
|
+
expect(previews.size).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns preview replies for a thread root", async () => {
|
|
96
|
+
const root = await postService.create({
|
|
97
|
+
type: "note",
|
|
98
|
+
content: "root",
|
|
99
|
+
});
|
|
100
|
+
await postService.create({
|
|
101
|
+
type: "note",
|
|
102
|
+
content: "reply 1",
|
|
103
|
+
replyToId: root.id,
|
|
104
|
+
});
|
|
105
|
+
await postService.create({
|
|
106
|
+
type: "note",
|
|
107
|
+
content: "reply 2",
|
|
108
|
+
replyToId: root.id,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const previews = await postService.getThreadPreviews([root.id]);
|
|
112
|
+
const replies = previews.get(root.id);
|
|
113
|
+
expect(replies).toBeDefined();
|
|
114
|
+
expect(replies).toHaveLength(2);
|
|
115
|
+
expect(replies?.[0]?.content).toBe("reply 1");
|
|
116
|
+
expect(replies?.[1]?.content).toBe("reply 2");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("limits preview replies to previewCount", async () => {
|
|
120
|
+
const root = await postService.create({
|
|
121
|
+
type: "note",
|
|
122
|
+
content: "root",
|
|
123
|
+
});
|
|
124
|
+
for (let i = 0; i < 5; i++) {
|
|
125
|
+
await postService.create({
|
|
126
|
+
type: "note",
|
|
127
|
+
content: `reply ${i}`,
|
|
128
|
+
replyToId: root.id,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const previews = await postService.getThreadPreviews([root.id], 2);
|
|
133
|
+
const replies = previews.get(root.id);
|
|
134
|
+
expect(replies).toHaveLength(2);
|
|
135
|
+
expect(replies?.[0]?.content).toBe("reply 0");
|
|
136
|
+
expect(replies?.[1]?.content).toBe("reply 1");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("defaults to 3 preview replies", async () => {
|
|
140
|
+
const root = await postService.create({
|
|
141
|
+
type: "note",
|
|
142
|
+
content: "root",
|
|
143
|
+
});
|
|
144
|
+
for (let i = 0; i < 5; i++) {
|
|
145
|
+
await postService.create({
|
|
146
|
+
type: "note",
|
|
147
|
+
content: `reply ${i}`,
|
|
148
|
+
replyToId: root.id,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const previews = await postService.getThreadPreviews([root.id]);
|
|
153
|
+
const replies = previews.get(root.id);
|
|
154
|
+
expect(replies).toHaveLength(3);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("handles multiple thread roots", async () => {
|
|
158
|
+
const root1 = await postService.create({
|
|
159
|
+
type: "note",
|
|
160
|
+
content: "root 1",
|
|
161
|
+
});
|
|
162
|
+
const root2 = await postService.create({
|
|
163
|
+
type: "note",
|
|
164
|
+
content: "root 2",
|
|
165
|
+
});
|
|
166
|
+
await postService.create({
|
|
167
|
+
type: "note",
|
|
168
|
+
content: "reply to root 1",
|
|
169
|
+
replyToId: root1.id,
|
|
170
|
+
});
|
|
171
|
+
await postService.create({
|
|
172
|
+
type: "note",
|
|
173
|
+
content: "reply to root 2",
|
|
174
|
+
replyToId: root2.id,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const previews = await postService.getThreadPreviews([
|
|
178
|
+
root1.id,
|
|
179
|
+
root2.id,
|
|
180
|
+
]);
|
|
181
|
+
expect(previews.size).toBe(2);
|
|
182
|
+
expect(previews.get(root1.id)).toHaveLength(1);
|
|
183
|
+
expect(previews.get(root2.id)).toHaveLength(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("excludes deleted replies", async () => {
|
|
187
|
+
const root = await postService.create({
|
|
188
|
+
type: "note",
|
|
189
|
+
content: "root",
|
|
190
|
+
});
|
|
191
|
+
const reply1 = await postService.create({
|
|
192
|
+
type: "note",
|
|
193
|
+
content: "reply 1",
|
|
194
|
+
replyToId: root.id,
|
|
195
|
+
});
|
|
196
|
+
await postService.create({
|
|
197
|
+
type: "note",
|
|
198
|
+
content: "reply 2",
|
|
199
|
+
replyToId: root.id,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await postService.delete(reply1.id);
|
|
203
|
+
|
|
204
|
+
const previews = await postService.getThreadPreviews([root.id]);
|
|
205
|
+
const replies = previews.get(root.id);
|
|
206
|
+
expect(replies).toHaveLength(1);
|
|
207
|
+
expect(replies?.[0]?.content).toBe("reply 2");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns empty for roots with no replies", async () => {
|
|
211
|
+
const root = await postService.create({
|
|
212
|
+
type: "note",
|
|
213
|
+
content: "root with no replies",
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const previews = await postService.getThreadPreviews([root.id]);
|
|
217
|
+
expect(previews.get(root.id)).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
package/src/services/index.ts
CHANGED
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
type CollectionService,
|
|
15
15
|
} from "./collection.js";
|
|
16
16
|
import { createSearchService, type SearchService } from "./search.js";
|
|
17
|
+
import {
|
|
18
|
+
createNavigationLinkService,
|
|
19
|
+
type NavigationLinkService,
|
|
20
|
+
} from "./navigation.js";
|
|
17
21
|
|
|
18
22
|
export interface Services {
|
|
19
23
|
settings: SettingsService;
|
|
@@ -22,6 +26,7 @@ export interface Services {
|
|
|
22
26
|
media: MediaService;
|
|
23
27
|
collections: CollectionService;
|
|
24
28
|
search: SearchService;
|
|
29
|
+
navigationLinks: NavigationLinkService;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function createServices(db: Database, d1: D1Database): Services {
|
|
@@ -32,6 +37,7 @@ export function createServices(db: Database, d1: D1Database): Services {
|
|
|
32
37
|
media: createMediaService(db),
|
|
33
38
|
collections: createCollectionService(db),
|
|
34
39
|
search: createSearchService(d1),
|
|
40
|
+
navigationLinks: createNavigationLinkService(db),
|
|
35
41
|
};
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -41,3 +47,4 @@ export type { RedirectService } from "./redirect.js";
|
|
|
41
47
|
export type { MediaService } from "./media.js";
|
|
42
48
|
export type { CollectionService } from "./collection.js";
|
|
43
49
|
export type { SearchService, SearchResult, SearchOptions } from "./search.js";
|
|
50
|
+
export type { NavigationLinkService } from "./navigation.js";
|
package/src/services/media.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface MediaService {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export interface CreateMediaData {
|
|
28
|
+
id?: string;
|
|
28
29
|
postId?: number;
|
|
29
30
|
filename: string;
|
|
30
31
|
originalName: string;
|
|
@@ -125,7 +126,7 @@ export function createMediaService(db: Database): MediaService {
|
|
|
125
126
|
},
|
|
126
127
|
|
|
127
128
|
async create(data) {
|
|
128
|
-
const id = uuidv7();
|
|
129
|
+
const id = data.id ?? uuidv7();
|
|
129
130
|
const timestamp = now();
|
|
130
131
|
|
|
131
132
|
const result = await db
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Link Service
|
|
3
|
+
*
|
|
4
|
+
* Manages navigation links displayed on public pages
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { eq, asc, sql } from "drizzle-orm";
|
|
8
|
+
import type { Database } from "../db/index.js";
|
|
9
|
+
import { navigationLinks } from "../db/schema.js";
|
|
10
|
+
import { now } from "../lib/time.js";
|
|
11
|
+
import type {
|
|
12
|
+
NavigationLink,
|
|
13
|
+
CreateNavigationLink,
|
|
14
|
+
UpdateNavigationLink,
|
|
15
|
+
} from "../types.js";
|
|
16
|
+
|
|
17
|
+
export interface NavigationLinkService {
|
|
18
|
+
list(): Promise<NavigationLink[]>;
|
|
19
|
+
getById(id: number): Promise<NavigationLink | null>;
|
|
20
|
+
create(data: CreateNavigationLink): Promise<NavigationLink>;
|
|
21
|
+
update(
|
|
22
|
+
id: number,
|
|
23
|
+
data: UpdateNavigationLink,
|
|
24
|
+
): Promise<NavigationLink | null>;
|
|
25
|
+
delete(id: number): Promise<boolean>;
|
|
26
|
+
reorder(ids: number[]): Promise<void>;
|
|
27
|
+
ensureDefaults(): Promise<NavigationLink[]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createNavigationLinkService(
|
|
31
|
+
db: Database,
|
|
32
|
+
): NavigationLinkService {
|
|
33
|
+
function toNavigationLink(
|
|
34
|
+
row: typeof navigationLinks.$inferSelect,
|
|
35
|
+
): NavigationLink {
|
|
36
|
+
return {
|
|
37
|
+
id: row.id,
|
|
38
|
+
label: row.label,
|
|
39
|
+
url: row.url,
|
|
40
|
+
position: row.position,
|
|
41
|
+
createdAt: row.createdAt,
|
|
42
|
+
updatedAt: row.updatedAt,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
async list() {
|
|
48
|
+
const rows = await db
|
|
49
|
+
.select()
|
|
50
|
+
.from(navigationLinks)
|
|
51
|
+
.orderBy(asc(navigationLinks.position));
|
|
52
|
+
return rows.map(toNavigationLink);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async getById(id) {
|
|
56
|
+
const result = await db
|
|
57
|
+
.select()
|
|
58
|
+
.from(navigationLinks)
|
|
59
|
+
.where(eq(navigationLinks.id, id))
|
|
60
|
+
.limit(1);
|
|
61
|
+
return result[0] ? toNavigationLink(result[0]) : null;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async create(data) {
|
|
65
|
+
const timestamp = now();
|
|
66
|
+
|
|
67
|
+
let position = data.position;
|
|
68
|
+
if (position === undefined) {
|
|
69
|
+
const maxResult = await db
|
|
70
|
+
.select({ maxPos: sql<number>`COALESCE(MAX(position), -1)` })
|
|
71
|
+
.from(navigationLinks);
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
|
|
73
|
+
position = maxResult[0]!.maxPos + 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await db
|
|
77
|
+
.insert(navigationLinks)
|
|
78
|
+
.values({
|
|
79
|
+
label: data.label,
|
|
80
|
+
url: data.url,
|
|
81
|
+
position,
|
|
82
|
+
createdAt: timestamp,
|
|
83
|
+
updatedAt: timestamp,
|
|
84
|
+
})
|
|
85
|
+
.returning();
|
|
86
|
+
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
|
|
88
|
+
return toNavigationLink(result[0]!);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async update(id, data) {
|
|
92
|
+
const existing = await db
|
|
93
|
+
.select()
|
|
94
|
+
.from(navigationLinks)
|
|
95
|
+
.where(eq(navigationLinks.id, id))
|
|
96
|
+
.limit(1);
|
|
97
|
+
if (!existing[0]) return null;
|
|
98
|
+
|
|
99
|
+
const timestamp = now();
|
|
100
|
+
const result = await db
|
|
101
|
+
.update(navigationLinks)
|
|
102
|
+
.set({
|
|
103
|
+
...(data.label !== undefined && { label: data.label }),
|
|
104
|
+
...(data.url !== undefined && { url: data.url }),
|
|
105
|
+
...(data.position !== undefined && { position: data.position }),
|
|
106
|
+
updatedAt: timestamp,
|
|
107
|
+
})
|
|
108
|
+
.where(eq(navigationLinks.id, id))
|
|
109
|
+
.returning();
|
|
110
|
+
|
|
111
|
+
return result[0] ? toNavigationLink(result[0]) : null;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async delete(id) {
|
|
115
|
+
const result = await db
|
|
116
|
+
.delete(navigationLinks)
|
|
117
|
+
.where(eq(navigationLinks.id, id))
|
|
118
|
+
.returning();
|
|
119
|
+
return result.length > 0;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async reorder(ids) {
|
|
123
|
+
const timestamp = now();
|
|
124
|
+
for (let i = 0; i < ids.length; i++) {
|
|
125
|
+
await db
|
|
126
|
+
.update(navigationLinks)
|
|
127
|
+
.set({ position: i, updatedAt: timestamp })
|
|
128
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loop index guarantees element exists
|
|
129
|
+
.where(eq(navigationLinks.id, ids[i]!));
|
|
130
|
+
}
|
|
131
|
+
},
|
|
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
|
+
};
|
|
165
|
+
}
|