@jant/core 0.3.23 → 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.
Files changed (169) hide show
  1. package/dist/app.js +4 -5
  2. package/dist/db/schema.js +72 -47
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +3 -3
  7. package/dist/lib/constants.js +1 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/navigation.js +4 -5
  11. package/dist/lib/render.js +1 -1
  12. package/dist/lib/schemas.js +80 -38
  13. package/dist/lib/theme-components.js +8 -11
  14. package/dist/lib/time.js +56 -1
  15. package/dist/lib/timeline.js +119 -0
  16. package/dist/lib/view.js +61 -72
  17. package/dist/routes/api/posts.js +29 -35
  18. package/dist/routes/api/search.js +5 -6
  19. package/dist/routes/api/upload.js +13 -13
  20. package/dist/routes/dash/collections.js +22 -40
  21. package/dist/routes/dash/index.js +2 -2
  22. package/dist/routes/dash/navigation.js +25 -24
  23. package/dist/routes/dash/pages.js +42 -57
  24. package/dist/routes/dash/posts.js +27 -35
  25. package/dist/routes/feed/rss.js +2 -4
  26. package/dist/routes/feed/sitemap.js +10 -7
  27. package/dist/routes/pages/archive.js +12 -11
  28. package/dist/routes/pages/collection.js +11 -5
  29. package/dist/routes/pages/home.js +53 -61
  30. package/dist/routes/pages/page.js +60 -29
  31. package/dist/routes/pages/post.js +5 -12
  32. package/dist/routes/pages/search.js +3 -4
  33. package/dist/services/collection.js +52 -64
  34. package/dist/services/index.js +5 -3
  35. package/dist/services/navigation.js +29 -53
  36. package/dist/services/page.js +80 -0
  37. package/dist/services/post.js +68 -69
  38. package/dist/services/search.js +24 -18
  39. package/dist/theme/components/MediaGallery.js +19 -91
  40. package/dist/theme/components/PageForm.js +15 -15
  41. package/dist/theme/components/PostForm.js +136 -129
  42. package/dist/theme/components/PostList.js +13 -8
  43. package/dist/theme/components/ThreadView.js +3 -3
  44. package/dist/theme/components/TypeBadge.js +3 -14
  45. package/dist/theme/components/VisibilityBadge.js +33 -23
  46. package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
  47. package/dist/themes/threads/index.js +81 -0
  48. package/dist/themes/{minimal → threads}/pages/ArchivePage.js +32 -47
  49. package/dist/themes/threads/pages/CollectionPage.js +65 -0
  50. package/dist/themes/{minimal → threads}/pages/HomePage.js +3 -3
  51. package/dist/themes/{minimal → threads}/pages/PostPage.js +12 -9
  52. package/dist/themes/{minimal → threads}/pages/SearchPage.js +13 -14
  53. package/dist/themes/{minimal → threads}/pages/SinglePage.js +4 -4
  54. package/dist/themes/threads/timeline/LinkCard.js +68 -0
  55. package/dist/themes/threads/timeline/NoteCard.js +53 -0
  56. package/dist/themes/threads/timeline/QuoteCard.js +59 -0
  57. package/dist/themes/{minimal → threads}/timeline/ThreadPreview.js +17 -13
  58. package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
  59. package/dist/themes/{minimal → threads}/timeline/TimelineItem.js +8 -16
  60. package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
  61. package/dist/themes/threads/timeline/groupByDate.js +22 -0
  62. package/dist/themes/threads/timeline/timelineMore.js +107 -0
  63. package/dist/types.js +24 -40
  64. package/package.json +2 -1
  65. package/src/__tests__/helpers/app.ts +4 -0
  66. package/src/__tests__/helpers/db.ts +51 -74
  67. package/src/app.tsx +4 -6
  68. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  69. package/src/db/migrations/meta/_journal.json +7 -0
  70. package/src/db/schema.ts +63 -46
  71. package/src/i18n/locales/en.po +216 -164
  72. package/src/i18n/locales/en.ts +1 -1
  73. package/src/i18n/locales/zh-Hans.po +216 -164
  74. package/src/i18n/locales/zh-Hans.ts +1 -1
  75. package/src/i18n/locales/zh-Hant.po +216 -164
  76. package/src/i18n/locales/zh-Hant.ts +1 -1
  77. package/src/index.ts +28 -12
  78. package/src/lib/__tests__/excerpt.test.ts +125 -0
  79. package/src/lib/__tests__/schemas.test.ts +166 -105
  80. package/src/lib/__tests__/theme-components.test.ts +4 -25
  81. package/src/lib/__tests__/time.test.ts +62 -0
  82. package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
  83. package/src/lib/__tests__/view.test.ts +199 -51
  84. package/src/lib/constants.ts +1 -4
  85. package/src/lib/excerpt.ts +87 -0
  86. package/src/lib/feed.ts +22 -7
  87. package/src/lib/navigation.ts +6 -7
  88. package/src/lib/render.tsx +1 -1
  89. package/src/lib/schemas.ts +118 -52
  90. package/src/lib/theme-components.ts +10 -13
  91. package/src/lib/time.ts +64 -0
  92. package/src/lib/timeline.ts +170 -0
  93. package/src/lib/view.ts +80 -82
  94. package/src/preset.css +45 -0
  95. package/src/routes/api/__tests__/posts.test.ts +50 -108
  96. package/src/routes/api/__tests__/search.test.ts +2 -3
  97. package/src/routes/api/posts.ts +30 -30
  98. package/src/routes/api/search.ts +4 -4
  99. package/src/routes/api/upload.ts +16 -6
  100. package/src/routes/dash/collections.tsx +18 -40
  101. package/src/routes/dash/index.tsx +2 -2
  102. package/src/routes/dash/navigation.tsx +27 -26
  103. package/src/routes/dash/pages.tsx +45 -60
  104. package/src/routes/dash/posts.tsx +44 -52
  105. package/src/routes/feed/rss.ts +2 -1
  106. package/src/routes/feed/sitemap.ts +14 -4
  107. package/src/routes/pages/archive.tsx +14 -10
  108. package/src/routes/pages/collection.tsx +17 -6
  109. package/src/routes/pages/home.tsx +56 -81
  110. package/src/routes/pages/page.tsx +64 -27
  111. package/src/routes/pages/post.tsx +5 -14
  112. package/src/routes/pages/search.tsx +2 -2
  113. package/src/services/__tests__/collection.test.ts +257 -158
  114. package/src/services/__tests__/media.test.ts +18 -18
  115. package/src/services/__tests__/navigation.test.ts +161 -87
  116. package/src/services/__tests__/post-timeline.test.ts +92 -88
  117. package/src/services/__tests__/post.test.ts +342 -206
  118. package/src/services/__tests__/search.test.ts +19 -25
  119. package/src/services/collection.ts +71 -113
  120. package/src/services/index.ts +9 -8
  121. package/src/services/navigation.ts +38 -71
  122. package/src/services/page.ts +124 -0
  123. package/src/services/post.ts +93 -103
  124. package/src/services/search.ts +38 -27
  125. package/src/theme/components/MediaGallery.tsx +27 -96
  126. package/src/theme/components/PageForm.tsx +21 -21
  127. package/src/theme/components/PostForm.tsx +122 -118
  128. package/src/theme/components/PostList.tsx +58 -49
  129. package/src/theme/components/ThreadView.tsx +6 -3
  130. package/src/theme/components/TypeBadge.tsx +9 -17
  131. package/src/theme/components/VisibilityBadge.tsx +40 -23
  132. package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
  133. package/src/themes/{minimal → threads}/index.ts +30 -13
  134. package/src/themes/{minimal → threads}/pages/ArchivePage.tsx +53 -53
  135. package/src/themes/threads/pages/CollectionPage.tsx +61 -0
  136. package/src/themes/{minimal → threads}/pages/HomePage.tsx +3 -3
  137. package/src/themes/{minimal → threads}/pages/PostPage.tsx +12 -8
  138. package/src/themes/{minimal → threads}/pages/SearchPage.tsx +15 -13
  139. package/src/themes/{minimal → threads}/pages/SinglePage.tsx +4 -4
  140. package/src/themes/threads/style.css +336 -0
  141. package/src/themes/threads/timeline/LinkCard.tsx +67 -0
  142. package/src/themes/threads/timeline/NoteCard.tsx +58 -0
  143. package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
  144. package/src/themes/{minimal → threads}/timeline/ThreadPreview.tsx +15 -13
  145. package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
  146. package/src/themes/{minimal → threads}/timeline/TimelineItem.tsx +9 -17
  147. package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
  148. package/src/themes/threads/timeline/groupByDate.ts +30 -0
  149. package/src/themes/threads/timeline/timelineMore.tsx +130 -0
  150. package/src/types.ts +242 -98
  151. package/dist/routes/api/timeline.js +0 -120
  152. package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
  153. package/dist/themes/minimal/index.js +0 -65
  154. package/dist/themes/minimal/pages/CollectionPage.js +0 -65
  155. package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
  156. package/dist/themes/minimal/timeline/ImageCard.js +0 -67
  157. package/dist/themes/minimal/timeline/LinkCard.js +0 -47
  158. package/dist/themes/minimal/timeline/NoteCard.js +0 -34
  159. package/dist/themes/minimal/timeline/QuoteCard.js +0 -48
  160. package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
  161. package/src/routes/api/timeline.tsx +0 -159
  162. package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
  163. package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
  164. package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
  165. package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
  166. package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
  167. package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
  168. package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
  169. package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
@@ -5,7 +5,7 @@
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
- import { PostPage as DefaultPostPage } from "../../themes/minimal/pages/PostPage.js";
8
+ import { PostPage as DefaultPostPage } from "../../themes/threads/pages/PostPage.js";
9
9
  import * as sqid from "../../lib/sqid.js";
10
10
  import { getNavigationData } from "../../lib/navigation.js";
11
11
  import { renderPublicPage } from "../../lib/render.js";
@@ -19,24 +19,15 @@ export const postRoutes = new Hono<Env>();
19
19
  postRoutes.get("/:id", async (c) => {
20
20
  const paramId = c.req.param("id");
21
21
 
22
- // Try to decode as sqid first
23
- let id = sqid.decode(paramId);
24
-
25
- // If not a valid sqid, try to find by path
26
- if (!id) {
27
- const post = await c.var.services.posts.getByPath(paramId);
28
- if (post) {
29
- id = post.id;
30
- }
31
- }
32
-
22
+ // Decode sqid to numeric ID
23
+ const id = sqid.decode(paramId);
33
24
  if (!id) return c.notFound();
34
25
 
35
26
  const post = await c.var.services.posts.getById(id);
36
27
  if (!post) return c.notFound();
37
28
 
38
29
  // Don't show drafts on public site
39
- if (post.visibility === "draft") {
30
+ if (post.status === "draft") {
40
31
  return c.notFound();
41
32
  }
42
33
 
@@ -64,7 +55,7 @@ postRoutes.get("/:id", async (c) => {
64
55
 
65
56
  return renderPublicPage(c, {
66
57
  title,
67
- description: post.content?.slice(0, 160),
58
+ description: post.body?.slice(0, 160),
68
59
  navData,
69
60
  content: <Page post={postView} theme={components} />,
70
61
  });
@@ -5,7 +5,7 @@
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings, SearchResult } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
- import { SearchPage as DefaultSearchPage } from "../../themes/minimal/pages/SearchPage.js";
8
+ import { SearchPage as DefaultSearchPage } from "../../themes/threads/pages/SearchPage.js";
9
9
  import { getNavigationData } from "../../lib/navigation.js";
10
10
  import { renderPublicPage } from "../../lib/render.js";
11
11
  import { createMediaContext, toSearchResultViews } from "../../lib/view.js";
@@ -34,7 +34,7 @@ searchRoutes.get("/", async (c) => {
34
34
  results = await c.var.services.search.search(query, {
35
35
  limit: PAGE_SIZE + 1,
36
36
  offset: (page - 1) * PAGE_SIZE,
37
- visibility: ["featured", "quiet"],
37
+ status: ["published"],
38
38
  });
39
39
 
40
40
  hasMore = results.length > PAGE_SIZE;
@@ -19,44 +19,80 @@ describe("CollectionService", () => {
19
19
  describe("create", () => {
20
20
  it("creates a collection with required fields", async () => {
21
21
  const collection = await collectionService.create({
22
+ slug: "my-collection",
22
23
  title: "My Collection",
23
24
  });
24
25
 
25
26
  expect(collection.id).toBe(1);
27
+ expect(collection.slug).toBe("my-collection");
26
28
  expect(collection.title).toBe("My Collection");
27
- expect(collection.path).toBeNull();
28
29
  expect(collection.description).toBeNull();
30
+ expect(collection.icon).toBeNull();
31
+ expect(collection.sortOrder).toBe("newest");
32
+ expect(collection.showDivider).toBe(0);
29
33
  });
30
34
 
31
35
  it("creates a collection with all fields", async () => {
32
36
  const collection = await collectionService.create({
37
+ slug: "tech",
33
38
  title: "Tech Posts",
34
- path: "tech",
35
39
  description: "Posts about technology",
40
+ icon: "laptop",
41
+ sortOrder: "oldest",
42
+ position: 5,
43
+ showDivider: true,
36
44
  });
37
45
 
46
+ expect(collection.slug).toBe("tech");
38
47
  expect(collection.title).toBe("Tech Posts");
39
- expect(collection.path).toBe("tech");
40
48
  expect(collection.description).toBe("Posts about technology");
49
+ expect(collection.icon).toBe("laptop");
50
+ expect(collection.sortOrder).toBe("oldest");
51
+ expect(collection.position).toBe(5);
52
+ expect(collection.showDivider).toBe(1);
41
53
  });
42
54
 
43
55
  it("sets timestamps", async () => {
44
56
  const collection = await collectionService.create({
57
+ slug: "test",
45
58
  title: "Test",
46
59
  });
47
60
 
48
61
  expect(collection.createdAt).toBeGreaterThan(0);
49
62
  expect(collection.updatedAt).toBeGreaterThan(0);
50
63
  });
64
+
65
+ it("auto-assigns position when not provided", async () => {
66
+ const first = await collectionService.create({
67
+ slug: "first",
68
+ title: "First",
69
+ });
70
+ const second = await collectionService.create({
71
+ slug: "second",
72
+ title: "Second",
73
+ });
74
+ const third = await collectionService.create({
75
+ slug: "third",
76
+ title: "Third",
77
+ });
78
+
79
+ expect(first.position).toBe(0);
80
+ expect(second.position).toBe(1);
81
+ expect(third.position).toBe(2);
82
+ });
51
83
  });
52
84
 
53
85
  describe("getById", () => {
54
86
  it("returns a collection by ID", async () => {
55
- const created = await collectionService.create({ title: "Test" });
87
+ const created = await collectionService.create({
88
+ slug: "test",
89
+ title: "Test",
90
+ });
56
91
 
57
92
  const found = await collectionService.getById(created.id);
58
93
  expect(found).not.toBeNull();
59
94
  expect(found?.title).toBe("Test");
95
+ expect(found?.slug).toBe("test");
60
96
  });
61
97
 
62
98
  it("returns null for non-existent ID", async () => {
@@ -65,17 +101,18 @@ describe("CollectionService", () => {
65
101
  });
66
102
  });
67
103
 
68
- describe("getByPath", () => {
69
- it("returns a collection by path", async () => {
70
- await collectionService.create({ title: "Tech", path: "tech" });
104
+ describe("getBySlug", () => {
105
+ it("returns a collection by slug", async () => {
106
+ await collectionService.create({ slug: "tech", title: "Tech" });
71
107
 
72
- const found = await collectionService.getByPath("tech");
108
+ const found = await collectionService.getBySlug("tech");
73
109
  expect(found).not.toBeNull();
74
110
  expect(found?.title).toBe("Tech");
111
+ expect(found?.slug).toBe("tech");
75
112
  });
76
113
 
77
- it("returns null for non-existent path", async () => {
78
- const found = await collectionService.getByPath("nonexistent");
114
+ it("returns null for non-existent slug", async () => {
115
+ const found = await collectionService.getBySlug("nonexistent");
79
116
  expect(found).toBeNull();
80
117
  });
81
118
  });
@@ -87,18 +124,44 @@ describe("CollectionService", () => {
87
124
  });
88
125
 
89
126
  it("returns all collections", async () => {
90
- await collectionService.create({ title: "First" });
91
- await collectionService.create({ title: "Second" });
92
- await collectionService.create({ title: "Third" });
127
+ await collectionService.create({ slug: "first", title: "First" });
128
+ await collectionService.create({ slug: "second", title: "Second" });
129
+ await collectionService.create({ slug: "third", title: "Third" });
93
130
 
94
131
  const list = await collectionService.list();
95
132
  expect(list).toHaveLength(3);
96
133
  });
134
+
135
+ it("orders by position ASC, then createdAt DESC", async () => {
136
+ const a = await collectionService.create({
137
+ slug: "a",
138
+ title: "A",
139
+ position: 2,
140
+ });
141
+ const b = await collectionService.create({
142
+ slug: "b",
143
+ title: "B",
144
+ position: 0,
145
+ });
146
+ const c = await collectionService.create({
147
+ slug: "c",
148
+ title: "C",
149
+ position: 1,
150
+ });
151
+
152
+ const list = await collectionService.list();
153
+ expect(list[0]?.id).toBe(b.id);
154
+ expect(list[1]?.id).toBe(c.id);
155
+ expect(list[2]?.id).toBe(a.id);
156
+ });
97
157
  });
98
158
 
99
159
  describe("update", () => {
100
160
  it("updates collection title", async () => {
101
- const collection = await collectionService.create({ title: "Old" });
161
+ const collection = await collectionService.create({
162
+ slug: "test",
163
+ title: "Old",
164
+ });
102
165
 
103
166
  const updated = await collectionService.update(collection.id, {
104
167
  title: "New",
@@ -107,17 +170,80 @@ describe("CollectionService", () => {
107
170
  expect(updated?.title).toBe("New");
108
171
  });
109
172
 
110
- it("updates collection path", async () => {
173
+ it("updates collection slug", async () => {
174
+ const collection = await collectionService.create({
175
+ slug: "old-slug",
176
+ title: "Test",
177
+ });
178
+
179
+ const updated = await collectionService.update(collection.id, {
180
+ slug: "new-slug",
181
+ });
182
+
183
+ expect(updated?.slug).toBe("new-slug");
184
+ });
185
+
186
+ it("updates description", async () => {
187
+ const collection = await collectionService.create({
188
+ slug: "test",
189
+ title: "Test",
190
+ description: "Old description",
191
+ });
192
+
193
+ const updated = await collectionService.update(collection.id, {
194
+ description: "New description",
195
+ });
196
+
197
+ expect(updated?.description).toBe("New description");
198
+ });
199
+
200
+ it("clears nullable fields with null", async () => {
201
+ const collection = await collectionService.create({
202
+ slug: "test",
203
+ title: "Test",
204
+ description: "Some desc",
205
+ icon: "star",
206
+ });
207
+
208
+ const updated = await collectionService.update(collection.id, {
209
+ description: null,
210
+ icon: null,
211
+ });
212
+
213
+ expect(updated?.description).toBeNull();
214
+ expect(updated?.icon).toBeNull();
215
+ });
216
+
217
+ it("updates icon, sortOrder, position, and showDivider", async () => {
218
+ const collection = await collectionService.create({
219
+ slug: "test",
220
+ title: "Test",
221
+ });
222
+
223
+ const updated = await collectionService.update(collection.id, {
224
+ icon: "rocket",
225
+ sortOrder: "rating_desc",
226
+ position: 10,
227
+ showDivider: true,
228
+ });
229
+
230
+ expect(updated?.icon).toBe("rocket");
231
+ expect(updated?.sortOrder).toBe("rating_desc");
232
+ expect(updated?.position).toBe(10);
233
+ expect(updated?.showDivider).toBe(1);
234
+ });
235
+
236
+ it("updates updatedAt timestamp", async () => {
111
237
  const collection = await collectionService.create({
238
+ slug: "test",
112
239
  title: "Test",
113
- path: "old-path",
114
240
  });
115
241
 
116
242
  const updated = await collectionService.update(collection.id, {
117
- path: "new-path",
243
+ title: "Updated",
118
244
  });
119
245
 
120
- expect(updated?.path).toBe("new-path");
246
+ expect(updated?.updatedAt).toBeGreaterThanOrEqual(collection.updatedAt);
121
247
  });
122
248
 
123
249
  it("returns null for non-existent collection", async () => {
@@ -128,7 +254,10 @@ describe("CollectionService", () => {
128
254
 
129
255
  describe("delete", () => {
130
256
  it("deletes a collection", async () => {
131
- const collection = await collectionService.create({ title: "Test" });
257
+ const collection = await collectionService.create({
258
+ slug: "test",
259
+ title: "Test",
260
+ });
132
261
 
133
262
  const result = await collectionService.delete(collection.id);
134
263
  expect(result).toBe(true);
@@ -137,18 +266,23 @@ describe("CollectionService", () => {
137
266
  expect(found).toBeNull();
138
267
  });
139
268
 
140
- it("deletes associated post-collection relationships", async () => {
141
- const collection = await collectionService.create({ title: "Test" });
269
+ it("clears collectionId on related posts", async () => {
270
+ const collection = await collectionService.create({
271
+ slug: "test",
272
+ title: "Test",
273
+ });
142
274
  const post = await postService.create({
143
- type: "note",
144
- content: "test",
275
+ format: "note",
276
+ body: "test post",
277
+ collectionId: collection.id,
145
278
  });
146
279
 
147
- await collectionService.addPost(collection.id, post.id);
148
280
  await collectionService.delete(collection.id);
149
281
 
150
- // Post itself should still exist
151
- expect(await postService.getById(post.id)).not.toBeNull();
282
+ // Post itself should still exist but with null collectionId
283
+ const found = await postService.getById(post.id);
284
+ expect(found).not.toBeNull();
285
+ expect(found?.collectionId).toBeNull();
152
286
  });
153
287
 
154
288
  it("returns false for non-existent collection", async () => {
@@ -157,172 +291,137 @@ describe("CollectionService", () => {
157
291
  });
158
292
  });
159
293
 
160
- describe("post relationships", () => {
161
- it("adds a post to a collection", async () => {
162
- const collection = await collectionService.create({ title: "Test" });
163
- const post = await postService.create({
164
- type: "note",
165
- content: "test",
166
- });
167
-
168
- await collectionService.addPost(collection.id, post.id);
294
+ describe("reorder", () => {
295
+ it("updates positions based on array order", async () => {
296
+ const a = await collectionService.create({ slug: "a", title: "A" });
297
+ const b = await collectionService.create({ slug: "b", title: "B" });
298
+ const c = await collectionService.create({ slug: "c", title: "C" });
169
299
 
170
- const posts = await collectionService.getPosts(collection.id);
171
- expect(posts).toHaveLength(1);
172
- expect(posts[0]?.id).toBe(post.id);
173
- });
174
-
175
- it("adding same post twice is idempotent", async () => {
176
- const collection = await collectionService.create({ title: "Test" });
177
- const post = await postService.create({
178
- type: "note",
179
- content: "test",
180
- });
300
+ // Reverse the order: C, B, A
301
+ await collectionService.reorder([c.id, b.id, a.id]);
181
302
 
182
- await collectionService.addPost(collection.id, post.id);
183
- await collectionService.addPost(collection.id, post.id);
303
+ const reorderedC = await collectionService.getById(c.id);
304
+ const reorderedB = await collectionService.getById(b.id);
305
+ const reorderedA = await collectionService.getById(a.id);
184
306
 
185
- const posts = await collectionService.getPosts(collection.id);
186
- expect(posts).toHaveLength(1);
307
+ expect(reorderedC?.position).toBe(0);
308
+ expect(reorderedB?.position).toBe(1);
309
+ expect(reorderedA?.position).toBe(2);
187
310
  });
188
311
 
189
- it("removes a post from a collection", async () => {
190
- const collection = await collectionService.create({ title: "Test" });
191
- const post = await postService.create({
192
- type: "note",
193
- content: "test",
194
- });
312
+ it("updates updatedAt when reordering", async () => {
313
+ const a = await collectionService.create({ slug: "a", title: "A" });
314
+ const b = await collectionService.create({ slug: "b", title: "B" });
195
315
 
196
- await collectionService.addPost(collection.id, post.id);
197
- await collectionService.removePost(collection.id, post.id);
316
+ await collectionService.reorder([b.id, a.id]);
198
317
 
199
- const posts = await collectionService.getPosts(collection.id);
200
- expect(posts).toHaveLength(0);
318
+ const reorderedA = await collectionService.getById(a.id);
319
+ expect(reorderedA?.updatedAt).toBeGreaterThanOrEqual(a.updatedAt);
201
320
  });
202
321
 
203
- it("returns collections for a post", async () => {
204
- const col1 = await collectionService.create({ title: "Col 1" });
205
- const col2 = await collectionService.create({ title: "Col 2" });
206
- const post = await postService.create({
207
- type: "note",
208
- content: "test",
209
- });
322
+ it("handles empty array", async () => {
323
+ await collectionService.reorder([]);
324
+ // Should not throw
325
+ const list = await collectionService.list();
326
+ expect(list).toEqual([]);
327
+ });
210
328
 
211
- await collectionService.addPost(col1.id, post.id);
212
- await collectionService.addPost(col2.id, post.id);
329
+ it("reflects new order in list()", async () => {
330
+ const a = await collectionService.create({ slug: "a", title: "A" });
331
+ const b = await collectionService.create({ slug: "b", title: "B" });
332
+ const c = await collectionService.create({ slug: "c", title: "C" });
213
333
 
214
- const collections = await collectionService.getCollectionsForPost(
215
- post.id,
216
- );
217
- expect(collections).toHaveLength(2);
218
- });
334
+ await collectionService.reorder([c.id, a.id, b.id]);
219
335
 
220
- it("getPosts returns empty array for empty collection", async () => {
221
- const collection = await collectionService.create({ title: "Empty" });
222
- const posts = await collectionService.getPosts(collection.id);
223
- expect(posts).toEqual([]);
336
+ const list = await collectionService.list();
337
+ expect(list[0]?.id).toBe(c.id);
338
+ expect(list[1]?.id).toBe(a.id);
339
+ expect(list[2]?.id).toBe(b.id);
224
340
  });
225
341
  });
226
342
 
227
- describe("syncPostCollections", () => {
228
- it("adds collections to a post with no existing collections", async () => {
229
- const col1 = await collectionService.create({ title: "Col 1" });
230
- const col2 = await collectionService.create({ title: "Col 2" });
231
- const post = await postService.create({
232
- type: "note",
233
- content: "test",
234
- });
235
-
236
- await collectionService.syncPostCollections(post.id, [col1.id, col2.id]);
343
+ describe("getPostCounts", () => {
344
+ it("returns empty map when no posts exist", async () => {
345
+ await collectionService.create({ slug: "empty", title: "Empty" });
237
346
 
238
- const collections = await collectionService.getCollectionsForPost(
239
- post.id,
240
- );
241
- expect(collections).toHaveLength(2);
242
- expect(collections.map((c) => c.id).sort()).toEqual(
243
- [col1.id, col2.id].sort(),
244
- );
347
+ const counts = await collectionService.getPostCounts();
348
+ expect(counts.size).toBe(0);
245
349
  });
246
350
 
247
- it("removes collections no longer in the list", async () => {
248
- const col1 = await collectionService.create({ title: "Col 1" });
249
- const col2 = await collectionService.create({ title: "Col 2" });
250
- const post = await postService.create({
251
- type: "note",
252
- content: "test",
351
+ it("returns correct counts for collections with posts", async () => {
352
+ const col1 = await collectionService.create({
353
+ slug: "col1",
354
+ title: "Col 1",
355
+ });
356
+ const col2 = await collectionService.create({
357
+ slug: "col2",
358
+ title: "Col 2",
253
359
  });
254
360
 
255
- await collectionService.addPost(col1.id, post.id);
256
- await collectionService.addPost(col2.id, post.id);
257
-
258
- // Sync with only col1 — col2 should be removed
259
- await collectionService.syncPostCollections(post.id, [col1.id]);
361
+ await postService.create({
362
+ format: "note",
363
+ body: "post 1",
364
+ collectionId: col1.id,
365
+ });
366
+ await postService.create({
367
+ format: "note",
368
+ body: "post 2",
369
+ collectionId: col1.id,
370
+ });
371
+ await postService.create({
372
+ format: "note",
373
+ body: "post 3",
374
+ collectionId: col2.id,
375
+ });
260
376
 
261
- const collections = await collectionService.getCollectionsForPost(
262
- post.id,
263
- );
264
- expect(collections).toHaveLength(1);
265
- expect(collections[0]?.id).toBe(col1.id);
377
+ const counts = await collectionService.getPostCounts();
378
+ expect(counts.get(col1.id)).toBe(2);
379
+ expect(counts.get(col2.id)).toBe(1);
266
380
  });
267
381
 
268
- it("handles mixed adds and removes", async () => {
269
- const col1 = await collectionService.create({ title: "Col 1" });
270
- const col2 = await collectionService.create({ title: "Col 2" });
271
- const col3 = await collectionService.create({ title: "Col 3" });
272
- const post = await postService.create({
273
- type: "note",
274
- content: "test",
382
+ it("does not count posts without a collection", async () => {
383
+ const col = await collectionService.create({
384
+ slug: "col",
385
+ title: "Col",
275
386
  });
276
387
 
277
- // Start with col1 and col2
278
- await collectionService.addPost(col1.id, post.id);
279
- await collectionService.addPost(col2.id, post.id);
280
-
281
- // Sync to col2 and col3 (remove col1, keep col2, add col3)
282
- await collectionService.syncPostCollections(post.id, [col2.id, col3.id]);
388
+ await postService.create({
389
+ format: "note",
390
+ body: "with collection",
391
+ collectionId: col.id,
392
+ });
393
+ await postService.create({
394
+ format: "note",
395
+ body: "no collection",
396
+ });
283
397
 
284
- const collections = await collectionService.getCollectionsForPost(
285
- post.id,
286
- );
287
- expect(collections).toHaveLength(2);
288
- expect(collections.map((c) => c.id).sort()).toEqual(
289
- [col2.id, col3.id].sort(),
290
- );
398
+ const counts = await collectionService.getPostCounts();
399
+ expect(counts.get(col.id)).toBe(1);
400
+ expect(counts.size).toBe(1);
291
401
  });
292
402
 
293
- it("removes all collections when synced with empty array", async () => {
294
- const col1 = await collectionService.create({ title: "Col 1" });
295
- const post = await postService.create({
296
- type: "note",
297
- content: "test",
403
+ it("does not count soft-deleted posts", async () => {
404
+ const col = await collectionService.create({
405
+ slug: "col",
406
+ title: "Col",
298
407
  });
299
408
 
300
- await collectionService.addPost(col1.id, post.id);
301
-
302
- await collectionService.syncPostCollections(post.id, []);
303
-
304
- const collections = await collectionService.getCollectionsForPost(
305
- post.id,
306
- );
307
- expect(collections).toHaveLength(0);
308
- });
309
-
310
- it("is a no-op when already in sync", async () => {
311
- const col1 = await collectionService.create({ title: "Col 1" });
312
409
  const post = await postService.create({
313
- type: "note",
314
- content: "test",
410
+ format: "note",
411
+ body: "will be deleted",
412
+ collectionId: col.id,
413
+ });
414
+ await postService.create({
415
+ format: "note",
416
+ body: "still alive",
417
+ collectionId: col.id,
315
418
  });
316
419
 
317
- await collectionService.addPost(col1.id, post.id);
318
-
319
- await collectionService.syncPostCollections(post.id, [col1.id]);
420
+ // Soft-delete one post
421
+ await postService.delete(post.id);
320
422
 
321
- const collections = await collectionService.getCollectionsForPost(
322
- post.id,
323
- );
324
- expect(collections).toHaveLength(1);
325
- expect(collections[0]?.id).toBe(col1.id);
423
+ const counts = await collectionService.getPostCounts();
424
+ expect(counts.get(col.id)).toBe(1);
326
425
  });
327
426
  });
328
427
  });