@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.
Files changed (264) hide show
  1. package/dist/app.js +11 -21
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +15 -0
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/image.js +3 -3
  9. package/dist/lib/media-helpers.js +43 -0
  10. package/dist/lib/nav-reorder.js +27 -0
  11. package/dist/lib/navigation.js +35 -0
  12. package/dist/lib/schemas.js +32 -2
  13. package/dist/lib/sse.js +7 -8
  14. package/dist/lib/theme-components.js +49 -0
  15. package/dist/routes/api/posts.js +101 -5
  16. package/dist/routes/api/timeline.js +115 -0
  17. package/dist/routes/api/upload.js +9 -5
  18. package/dist/routes/dash/media.js +38 -0
  19. package/dist/routes/dash/navigation.js +274 -0
  20. package/dist/routes/dash/posts.js +45 -6
  21. package/dist/routes/feed/rss.js +10 -1
  22. package/dist/routes/pages/archive.js +14 -27
  23. package/dist/routes/pages/collection.js +10 -19
  24. package/dist/routes/pages/home.js +88 -98
  25. package/dist/routes/pages/page.js +19 -38
  26. package/dist/routes/pages/post.js +61 -48
  27. package/dist/routes/pages/search.js +13 -26
  28. package/dist/services/collection.js +13 -0
  29. package/dist/services/index.js +3 -1
  30. package/dist/services/media.js +55 -2
  31. package/dist/services/navigation.js +115 -0
  32. package/dist/services/post.js +26 -1
  33. package/dist/theme/components/MediaGallery.js +107 -0
  34. package/dist/theme/components/PostForm.js +158 -2
  35. package/dist/theme/components/PostList.js +5 -0
  36. package/dist/theme/components/index.js +3 -0
  37. package/dist/theme/components/timeline/ArticleCard.js +50 -0
  38. package/dist/theme/components/timeline/ImageCard.js +86 -0
  39. package/dist/theme/components/timeline/LinkCard.js +62 -0
  40. package/dist/theme/components/timeline/NoteCard.js +37 -0
  41. package/dist/theme/components/timeline/QuoteCard.js +51 -0
  42. package/dist/theme/components/timeline/ThreadPreview.js +52 -0
  43. package/dist/theme/components/timeline/TimelineFeed.js +43 -0
  44. package/dist/theme/components/timeline/TimelineItem.js +25 -0
  45. package/dist/theme/components/timeline/index.js +8 -0
  46. package/dist/theme/layouts/DashLayout.js +8 -0
  47. package/dist/theme/layouts/SiteLayout.js +160 -0
  48. package/dist/theme/layouts/index.js +1 -0
  49. package/dist/types/sortablejs.d.js +5 -0
  50. package/dist/types.js +27 -0
  51. package/package.json +3 -2
  52. package/src/__tests__/helpers/app.ts +6 -1
  53. package/src/__tests__/helpers/db.ts +20 -0
  54. package/src/app.tsx +11 -25
  55. package/src/client.ts +1 -0
  56. package/src/db/migrations/0002_add_media_attachments.sql +3 -0
  57. package/src/db/migrations/0003_add_navigation_links.sql +8 -0
  58. package/src/db/migrations/meta/0003_snapshot.json +821 -0
  59. package/src/db/migrations/meta/_journal.json +14 -0
  60. package/src/db/schema.ts +15 -0
  61. package/src/i18n/locales/en.po +170 -58
  62. package/src/i18n/locales/en.ts +1 -1
  63. package/src/i18n/locales/zh-Hans.po +162 -71
  64. package/src/i18n/locales/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/zh-Hant.po +162 -71
  66. package/src/i18n/locales/zh-Hant.ts +1 -1
  67. package/src/index.ts +13 -1
  68. package/src/lib/__tests__/schemas.test.ts +89 -1
  69. package/src/lib/__tests__/sse.test.ts +13 -1
  70. package/src/lib/__tests__/theme-components.test.ts +107 -0
  71. package/src/lib/image.ts +3 -3
  72. package/src/lib/media-helpers.ts +54 -0
  73. package/src/lib/nav-reorder.ts +26 -0
  74. package/src/lib/navigation.ts +46 -0
  75. package/src/lib/schemas.ts +47 -1
  76. package/src/lib/sse.ts +10 -11
  77. package/src/lib/theme-components.ts +76 -0
  78. package/src/routes/api/__tests__/posts.test.ts +239 -0
  79. package/src/routes/api/__tests__/timeline.test.ts +242 -0
  80. package/src/routes/api/posts.ts +134 -5
  81. package/src/routes/api/timeline.tsx +145 -0
  82. package/src/routes/api/upload.ts +9 -5
  83. package/src/routes/dash/media.tsx +50 -0
  84. package/src/routes/dash/navigation.tsx +306 -0
  85. package/src/routes/dash/posts.tsx +79 -7
  86. package/src/routes/feed/rss.ts +14 -1
  87. package/src/routes/pages/archive.tsx +15 -23
  88. package/src/routes/pages/collection.tsx +8 -15
  89. package/src/routes/pages/home.tsx +121 -88
  90. package/src/routes/pages/page.tsx +17 -30
  91. package/src/routes/pages/post.tsx +64 -40
  92. package/src/routes/pages/search.tsx +18 -22
  93. package/src/services/__tests__/collection.test.ts +102 -0
  94. package/src/services/__tests__/media.test.ts +282 -7
  95. package/src/services/__tests__/navigation.test.ts +213 -0
  96. package/src/services/__tests__/post-timeline.test.ts +220 -0
  97. package/src/services/collection.ts +19 -0
  98. package/src/services/index.ts +7 -0
  99. package/src/services/media.ts +78 -2
  100. package/src/services/navigation.ts +165 -0
  101. package/src/services/post.ts +48 -1
  102. package/src/styles/components.css +59 -0
  103. package/src/theme/components/MediaGallery.tsx +128 -0
  104. package/src/theme/components/PostForm.tsx +170 -2
  105. package/src/theme/components/PostList.tsx +7 -0
  106. package/src/theme/components/index.ts +13 -0
  107. package/src/theme/components/timeline/ArticleCard.tsx +57 -0
  108. package/src/theme/components/timeline/ImageCard.tsx +80 -0
  109. package/src/theme/components/timeline/LinkCard.tsx +66 -0
  110. package/src/theme/components/timeline/NoteCard.tsx +41 -0
  111. package/src/theme/components/timeline/QuoteCard.tsx +55 -0
  112. package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
  113. package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
  114. package/src/theme/components/timeline/TimelineItem.tsx +39 -0
  115. package/src/theme/components/timeline/index.ts +8 -0
  116. package/src/theme/layouts/DashLayout.tsx +10 -0
  117. package/src/theme/layouts/SiteLayout.tsx +184 -0
  118. package/src/theme/layouts/index.ts +1 -0
  119. package/src/types/sortablejs.d.ts +23 -0
  120. package/src/types.ts +97 -0
  121. package/dist/app.d.ts +0 -38
  122. package/dist/app.d.ts.map +0 -1
  123. package/dist/auth.d.ts +0 -25
  124. package/dist/auth.d.ts.map +0 -1
  125. package/dist/db/index.d.ts +0 -10
  126. package/dist/db/index.d.ts.map +0 -1
  127. package/dist/db/schema.d.ts +0 -1507
  128. package/dist/db/schema.d.ts.map +0 -1
  129. package/dist/i18n/Trans.d.ts +0 -25
  130. package/dist/i18n/Trans.d.ts.map +0 -1
  131. package/dist/i18n/context.d.ts +0 -69
  132. package/dist/i18n/context.d.ts.map +0 -1
  133. package/dist/i18n/detect.d.ts +0 -20
  134. package/dist/i18n/detect.d.ts.map +0 -1
  135. package/dist/i18n/i18n.d.ts +0 -32
  136. package/dist/i18n/i18n.d.ts.map +0 -1
  137. package/dist/i18n/index.d.ts +0 -41
  138. package/dist/i18n/index.d.ts.map +0 -1
  139. package/dist/i18n/locales/en.d.ts +0 -3
  140. package/dist/i18n/locales/en.d.ts.map +0 -1
  141. package/dist/i18n/locales/zh-Hans.d.ts +0 -3
  142. package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
  143. package/dist/i18n/locales/zh-Hant.d.ts +0 -3
  144. package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
  145. package/dist/i18n/locales.d.ts +0 -11
  146. package/dist/i18n/locales.d.ts.map +0 -1
  147. package/dist/i18n/middleware.d.ts +0 -21
  148. package/dist/i18n/middleware.d.ts.map +0 -1
  149. package/dist/index.d.ts +0 -16
  150. package/dist/index.d.ts.map +0 -1
  151. package/dist/lib/config.d.ts +0 -83
  152. package/dist/lib/config.d.ts.map +0 -1
  153. package/dist/lib/constants.d.ts +0 -37
  154. package/dist/lib/constants.d.ts.map +0 -1
  155. package/dist/lib/image.d.ts +0 -73
  156. package/dist/lib/image.d.ts.map +0 -1
  157. package/dist/lib/index.d.ts +0 -9
  158. package/dist/lib/index.d.ts.map +0 -1
  159. package/dist/lib/markdown.d.ts +0 -60
  160. package/dist/lib/markdown.d.ts.map +0 -1
  161. package/dist/lib/schemas.d.ts +0 -113
  162. package/dist/lib/schemas.d.ts.map +0 -1
  163. package/dist/lib/sqid.d.ts +0 -60
  164. package/dist/lib/sqid.d.ts.map +0 -1
  165. package/dist/lib/sse.d.ts +0 -192
  166. package/dist/lib/sse.d.ts.map +0 -1
  167. package/dist/lib/theme.d.ts +0 -44
  168. package/dist/lib/theme.d.ts.map +0 -1
  169. package/dist/lib/time.d.ts +0 -90
  170. package/dist/lib/time.d.ts.map +0 -1
  171. package/dist/lib/url.d.ts +0 -82
  172. package/dist/lib/url.d.ts.map +0 -1
  173. package/dist/middleware/auth.d.ts +0 -24
  174. package/dist/middleware/auth.d.ts.map +0 -1
  175. package/dist/middleware/onboarding.d.ts +0 -26
  176. package/dist/middleware/onboarding.d.ts.map +0 -1
  177. package/dist/routes/api/posts.d.ts +0 -13
  178. package/dist/routes/api/posts.d.ts.map +0 -1
  179. package/dist/routes/api/search.d.ts +0 -13
  180. package/dist/routes/api/search.d.ts.map +0 -1
  181. package/dist/routes/api/upload.d.ts +0 -16
  182. package/dist/routes/api/upload.d.ts.map +0 -1
  183. package/dist/routes/dash/collections.d.ts +0 -13
  184. package/dist/routes/dash/collections.d.ts.map +0 -1
  185. package/dist/routes/dash/index.d.ts +0 -15
  186. package/dist/routes/dash/index.d.ts.map +0 -1
  187. package/dist/routes/dash/media.d.ts +0 -16
  188. package/dist/routes/dash/media.d.ts.map +0 -1
  189. package/dist/routes/dash/pages.d.ts +0 -15
  190. package/dist/routes/dash/pages.d.ts.map +0 -1
  191. package/dist/routes/dash/posts.d.ts +0 -13
  192. package/dist/routes/dash/posts.d.ts.map +0 -1
  193. package/dist/routes/dash/redirects.d.ts +0 -13
  194. package/dist/routes/dash/redirects.d.ts.map +0 -1
  195. package/dist/routes/dash/settings.d.ts +0 -15
  196. package/dist/routes/dash/settings.d.ts.map +0 -1
  197. package/dist/routes/feed/rss.d.ts +0 -13
  198. package/dist/routes/feed/rss.d.ts.map +0 -1
  199. package/dist/routes/feed/sitemap.d.ts +0 -13
  200. package/dist/routes/feed/sitemap.d.ts.map +0 -1
  201. package/dist/routes/pages/archive.d.ts +0 -15
  202. package/dist/routes/pages/archive.d.ts.map +0 -1
  203. package/dist/routes/pages/collection.d.ts +0 -13
  204. package/dist/routes/pages/collection.d.ts.map +0 -1
  205. package/dist/routes/pages/home.d.ts +0 -13
  206. package/dist/routes/pages/home.d.ts.map +0 -1
  207. package/dist/routes/pages/page.d.ts +0 -15
  208. package/dist/routes/pages/page.d.ts.map +0 -1
  209. package/dist/routes/pages/post.d.ts +0 -13
  210. package/dist/routes/pages/post.d.ts.map +0 -1
  211. package/dist/routes/pages/search.d.ts +0 -13
  212. package/dist/routes/pages/search.d.ts.map +0 -1
  213. package/dist/services/collection.d.ts +0 -31
  214. package/dist/services/collection.d.ts.map +0 -1
  215. package/dist/services/index.d.ts +0 -28
  216. package/dist/services/index.d.ts.map +0 -1
  217. package/dist/services/media.d.ts +0 -27
  218. package/dist/services/media.d.ts.map +0 -1
  219. package/dist/services/post.d.ts +0 -31
  220. package/dist/services/post.d.ts.map +0 -1
  221. package/dist/services/redirect.d.ts +0 -15
  222. package/dist/services/redirect.d.ts.map +0 -1
  223. package/dist/services/search.d.ts +0 -26
  224. package/dist/services/search.d.ts.map +0 -1
  225. package/dist/services/settings.d.ts +0 -18
  226. package/dist/services/settings.d.ts.map +0 -1
  227. package/dist/theme/color-themes.d.ts +0 -30
  228. package/dist/theme/color-themes.d.ts.map +0 -1
  229. package/dist/theme/components/ActionButtons.d.ts +0 -43
  230. package/dist/theme/components/ActionButtons.d.ts.map +0 -1
  231. package/dist/theme/components/CrudPageHeader.d.ts +0 -23
  232. package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
  233. package/dist/theme/components/DangerZone.d.ts +0 -36
  234. package/dist/theme/components/DangerZone.d.ts.map +0 -1
  235. package/dist/theme/components/EmptyState.d.ts +0 -27
  236. package/dist/theme/components/EmptyState.d.ts.map +0 -1
  237. package/dist/theme/components/ListItemRow.d.ts +0 -15
  238. package/dist/theme/components/ListItemRow.d.ts.map +0 -1
  239. package/dist/theme/components/PageForm.d.ts +0 -14
  240. package/dist/theme/components/PageForm.d.ts.map +0 -1
  241. package/dist/theme/components/Pagination.d.ts +0 -46
  242. package/dist/theme/components/Pagination.d.ts.map +0 -1
  243. package/dist/theme/components/PostForm.d.ts +0 -11
  244. package/dist/theme/components/PostForm.d.ts.map +0 -1
  245. package/dist/theme/components/PostList.d.ts +0 -10
  246. package/dist/theme/components/PostList.d.ts.map +0 -1
  247. package/dist/theme/components/ThreadView.d.ts +0 -15
  248. package/dist/theme/components/ThreadView.d.ts.map +0 -1
  249. package/dist/theme/components/TypeBadge.d.ts +0 -12
  250. package/dist/theme/components/TypeBadge.d.ts.map +0 -1
  251. package/dist/theme/components/VisibilityBadge.d.ts +0 -12
  252. package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
  253. package/dist/theme/components/index.d.ts +0 -13
  254. package/dist/theme/components/index.d.ts.map +0 -1
  255. package/dist/theme/index.d.ts +0 -21
  256. package/dist/theme/index.d.ts.map +0 -1
  257. package/dist/theme/layouts/BaseLayout.d.ts +0 -23
  258. package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
  259. package/dist/theme/layouts/DashLayout.d.ts +0 -17
  260. package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
  261. package/dist/theme/layouts/index.d.ts +0 -3
  262. package/dist/theme/layouts/index.d.ts.map +0 -1
  263. package/dist/types.d.ts +0 -213
  264. 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
  }
@@ -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";
@@ -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;