@jant/core 0.3.6 → 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 +11 -21
- package/dist/client.js +1 -0
- package/dist/db/schema.js +15 -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/index.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/schemas.js +32 -2
- package/dist/lib/sse.js +7 -8
- package/dist/lib/theme-components.js +49 -0
- package/dist/routes/api/posts.js +101 -5
- package/dist/routes/api/timeline.js +115 -0
- package/dist/routes/api/upload.js +9 -5
- package/dist/routes/dash/media.js +38 -0
- package/dist/routes/dash/navigation.js +274 -0
- package/dist/routes/dash/posts.js +45 -6
- package/dist/routes/feed/rss.js +10 -1
- package/dist/routes/pages/archive.js +14 -27
- package/dist/routes/pages/collection.js +10 -19
- package/dist/routes/pages/home.js +88 -98
- package/dist/routes/pages/page.js +19 -38
- package/dist/routes/pages/post.js +61 -48
- package/dist/routes/pages/search.js +13 -26
- package/dist/services/collection.js +13 -0
- package/dist/services/index.js +3 -1
- package/dist/services/media.js +55 -2
- package/dist/services/navigation.js +115 -0
- package/dist/services/post.js +26 -1
- package/dist/theme/components/MediaGallery.js +107 -0
- package/dist/theme/components/PostForm.js +158 -2
- package/dist/theme/components/PostList.js +5 -0
- package/dist/theme/components/index.js +3 -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/dist/types.js +27 -0
- package/package.json +3 -2
- package/src/__tests__/helpers/app.ts +6 -1
- package/src/__tests__/helpers/db.ts +20 -0
- package/src/app.tsx +11 -25
- package/src/client.ts +1 -0
- package/src/db/migrations/0002_add_media_attachments.sql +3 -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 +15 -0
- package/src/i18n/locales/en.po +170 -58
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +162 -71
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +162 -71
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +13 -1
- package/src/lib/__tests__/schemas.test.ts +89 -1
- package/src/lib/__tests__/sse.test.ts +13 -1
- 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/schemas.ts +47 -1
- package/src/lib/sse.ts +10 -11
- package/src/lib/theme-components.ts +76 -0
- package/src/routes/api/__tests__/posts.test.ts +239 -0
- package/src/routes/api/__tests__/timeline.test.ts +242 -0
- package/src/routes/api/posts.ts +134 -5
- package/src/routes/api/timeline.tsx +145 -0
- package/src/routes/api/upload.ts +9 -5
- package/src/routes/dash/media.tsx +50 -0
- package/src/routes/dash/navigation.tsx +306 -0
- package/src/routes/dash/posts.tsx +79 -7
- package/src/routes/feed/rss.ts +14 -1
- package/src/routes/pages/archive.tsx +15 -23
- package/src/routes/pages/collection.tsx +8 -15
- package/src/routes/pages/home.tsx +121 -88
- package/src/routes/pages/page.tsx +17 -30
- package/src/routes/pages/post.tsx +64 -40
- package/src/routes/pages/search.tsx +18 -22
- package/src/services/__tests__/collection.test.ts +102 -0
- package/src/services/__tests__/media.test.ts +282 -7
- package/src/services/__tests__/navigation.test.ts +213 -0
- package/src/services/__tests__/post-timeline.test.ts +220 -0
- package/src/services/collection.ts +19 -0
- package/src/services/index.ts +7 -0
- package/src/services/media.ts +78 -2
- 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/MediaGallery.tsx +128 -0
- package/src/theme/components/PostForm.tsx +170 -2
- package/src/theme/components/PostList.tsx +7 -0
- package/src/theme/components/index.ts +13 -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 +97 -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 -1507
- 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 -113
- 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 -31
- 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 -27
- 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/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 -11
- 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 -13
- 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 -213
- 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
|
+
});
|
|
@@ -21,6 +21,7 @@ export interface CollectionService {
|
|
|
21
21
|
removePost(collectionId: number, postId: number): Promise<void>;
|
|
22
22
|
getPosts(collectionId: number): Promise<Post[]>;
|
|
23
23
|
getCollectionsForPost(postId: number): Promise<Collection[]>;
|
|
24
|
+
syncPostCollections(postId: number, collectionIds: number[]): Promise<void>;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export interface CreateCollectionData {
|
|
@@ -197,5 +198,23 @@ export function createCollectionService(db: Database): CollectionService {
|
|
|
197
198
|
|
|
198
199
|
return rows.map((r) => toCollection(r.collection));
|
|
199
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);
|
|
206
|
+
|
|
207
|
+
const toAdd = collectionIds.filter((id) => !currentIds.has(id));
|
|
208
|
+
const toRemove = current
|
|
209
|
+
.map((c) => c.id)
|
|
210
|
+
.filter((id) => !desiredIds.has(id));
|
|
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);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
200
219
|
};
|
|
201
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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Handles media upload and management with R2 storage
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { eq, desc } from "drizzle-orm";
|
|
7
|
+
import { eq, desc, inArray, asc } from "drizzle-orm";
|
|
8
8
|
import { uuidv7 } from "uuidv7";
|
|
9
9
|
import type { Database } from "../db/index.js";
|
|
10
10
|
import { media } from "../db/schema.js";
|
|
@@ -13,13 +13,19 @@ import type { Media } from "../types.js";
|
|
|
13
13
|
|
|
14
14
|
export interface MediaService {
|
|
15
15
|
getById(id: string): Promise<Media | null>;
|
|
16
|
+
getByIds(ids: string[]): Promise<Media[]>;
|
|
17
|
+
getByPostId(postId: number): Promise<Media[]>;
|
|
18
|
+
getByPostIds(postIds: number[]): Promise<Map<number, Media[]>>;
|
|
16
19
|
list(limit?: number): Promise<Media[]>;
|
|
17
20
|
create(data: CreateMediaData): Promise<Media>;
|
|
18
21
|
delete(id: string): Promise<boolean>;
|
|
19
22
|
getByR2Key(r2Key: string): Promise<Media | null>;
|
|
23
|
+
attachToPost(postId: number, mediaIds: string[]): Promise<void>;
|
|
24
|
+
detachFromPost(postId: number): Promise<void>;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export interface CreateMediaData {
|
|
28
|
+
id?: string;
|
|
23
29
|
postId?: number;
|
|
24
30
|
filename: string;
|
|
25
31
|
originalName: string;
|
|
@@ -29,6 +35,8 @@ export interface CreateMediaData {
|
|
|
29
35
|
width?: number;
|
|
30
36
|
height?: number;
|
|
31
37
|
alt?: string;
|
|
38
|
+
position?: number;
|
|
39
|
+
blurhash?: string;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
export function createMediaService(db: Database): MediaService {
|
|
@@ -44,6 +52,8 @@ export function createMediaService(db: Database): MediaService {
|
|
|
44
52
|
width: row.width,
|
|
45
53
|
height: row.height,
|
|
46
54
|
alt: row.alt,
|
|
55
|
+
position: row.position,
|
|
56
|
+
blurhash: row.blurhash,
|
|
47
57
|
createdAt: row.createdAt,
|
|
48
58
|
};
|
|
49
59
|
}
|
|
@@ -58,6 +68,45 @@ export function createMediaService(db: Database): MediaService {
|
|
|
58
68
|
return result[0] ? toMedia(result[0]) : null;
|
|
59
69
|
},
|
|
60
70
|
|
|
71
|
+
async getByIds(ids) {
|
|
72
|
+
if (ids.length === 0) return [];
|
|
73
|
+
const rows = await db.select().from(media).where(inArray(media.id, ids));
|
|
74
|
+
return rows.map(toMedia);
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async getByPostId(postId) {
|
|
78
|
+
const rows = await db
|
|
79
|
+
.select()
|
|
80
|
+
.from(media)
|
|
81
|
+
.where(eq(media.postId, postId))
|
|
82
|
+
.orderBy(asc(media.position));
|
|
83
|
+
return rows.map(toMedia);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async getByPostIds(postIds) {
|
|
87
|
+
const result = new Map<number, Media[]>();
|
|
88
|
+
if (postIds.length === 0) return result;
|
|
89
|
+
|
|
90
|
+
const rows = await db
|
|
91
|
+
.select()
|
|
92
|
+
.from(media)
|
|
93
|
+
.where(inArray(media.postId, postIds))
|
|
94
|
+
.orderBy(asc(media.position));
|
|
95
|
+
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
const m = toMedia(row);
|
|
98
|
+
if (m.postId === null) continue;
|
|
99
|
+
const list = result.get(m.postId);
|
|
100
|
+
if (list) {
|
|
101
|
+
list.push(m);
|
|
102
|
+
} else {
|
|
103
|
+
result.set(m.postId, [m]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return result;
|
|
108
|
+
},
|
|
109
|
+
|
|
61
110
|
async getByR2Key(r2Key) {
|
|
62
111
|
const result = await db
|
|
63
112
|
.select()
|
|
@@ -77,7 +126,7 @@ export function createMediaService(db: Database): MediaService {
|
|
|
77
126
|
},
|
|
78
127
|
|
|
79
128
|
async create(data) {
|
|
80
|
-
const id = uuidv7();
|
|
129
|
+
const id = data.id ?? uuidv7();
|
|
81
130
|
const timestamp = now();
|
|
82
131
|
|
|
83
132
|
const result = await db
|
|
@@ -93,6 +142,8 @@ export function createMediaService(db: Database): MediaService {
|
|
|
93
142
|
width: data.width ?? null,
|
|
94
143
|
height: data.height ?? null,
|
|
95
144
|
alt: data.alt ?? null,
|
|
145
|
+
position: data.position ?? 0,
|
|
146
|
+
blurhash: data.blurhash ?? null,
|
|
96
147
|
createdAt: timestamp,
|
|
97
148
|
})
|
|
98
149
|
.returning();
|
|
@@ -101,6 +152,31 @@ export function createMediaService(db: Database): MediaService {
|
|
|
101
152
|
return toMedia(result[0]!);
|
|
102
153
|
},
|
|
103
154
|
|
|
155
|
+
async attachToPost(postId, mediaIds) {
|
|
156
|
+
// Clear existing attachments
|
|
157
|
+
await db
|
|
158
|
+
.update(media)
|
|
159
|
+
.set({ postId: null, position: 0 })
|
|
160
|
+
.where(eq(media.postId, postId));
|
|
161
|
+
|
|
162
|
+
// Set new attachments with position = array index
|
|
163
|
+
for (let i = 0; i < mediaIds.length; i++) {
|
|
164
|
+
const mediaId = mediaIds[i];
|
|
165
|
+
if (!mediaId) continue;
|
|
166
|
+
await db
|
|
167
|
+
.update(media)
|
|
168
|
+
.set({ postId, position: i })
|
|
169
|
+
.where(eq(media.id, mediaId));
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async detachFromPost(postId) {
|
|
174
|
+
await db
|
|
175
|
+
.update(media)
|
|
176
|
+
.set({ postId: null, position: 0 })
|
|
177
|
+
.where(eq(media.postId, postId));
|
|
178
|
+
},
|
|
179
|
+
|
|
104
180
|
async delete(id) {
|
|
105
181
|
const result = await db.delete(media).where(eq(media.id, id)).returning();
|
|
106
182
|
return result.length > 0;
|