@jant/core 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/dist/app.js +4 -0
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +13 -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/lib/image.js +3 -3
  8. package/dist/lib/media-helpers.js +43 -0
  9. package/dist/lib/nav-reorder.js +27 -0
  10. package/dist/lib/navigation.js +35 -0
  11. package/dist/lib/theme-components.js +49 -0
  12. package/dist/routes/api/timeline.js +115 -0
  13. package/dist/routes/api/upload.js +9 -5
  14. package/dist/routes/dash/navigation.js +274 -0
  15. package/dist/routes/pages/archive.js +14 -27
  16. package/dist/routes/pages/collection.js +10 -19
  17. package/dist/routes/pages/home.js +83 -126
  18. package/dist/routes/pages/page.js +19 -38
  19. package/dist/routes/pages/post.js +38 -51
  20. package/dist/routes/pages/search.js +13 -26
  21. package/dist/services/index.js +3 -1
  22. package/dist/services/media.js +1 -1
  23. package/dist/services/navigation.js +115 -0
  24. package/dist/services/post.js +26 -1
  25. package/dist/theme/components/PostList.js +5 -0
  26. package/dist/theme/components/index.js +2 -0
  27. package/dist/theme/components/timeline/ArticleCard.js +50 -0
  28. package/dist/theme/components/timeline/ImageCard.js +86 -0
  29. package/dist/theme/components/timeline/LinkCard.js +62 -0
  30. package/dist/theme/components/timeline/NoteCard.js +37 -0
  31. package/dist/theme/components/timeline/QuoteCard.js +51 -0
  32. package/dist/theme/components/timeline/ThreadPreview.js +52 -0
  33. package/dist/theme/components/timeline/TimelineFeed.js +43 -0
  34. package/dist/theme/components/timeline/TimelineItem.js +25 -0
  35. package/dist/theme/components/timeline/index.js +8 -0
  36. package/dist/theme/layouts/DashLayout.js +8 -0
  37. package/dist/theme/layouts/SiteLayout.js +160 -0
  38. package/dist/theme/layouts/index.js +1 -0
  39. package/dist/types/sortablejs.d.js +5 -0
  40. package/package.json +3 -2
  41. package/src/__tests__/helpers/db.ts +10 -0
  42. package/src/app.tsx +4 -0
  43. package/src/client.ts +1 -0
  44. package/src/db/migrations/0003_add_navigation_links.sql +8 -0
  45. package/src/db/migrations/meta/0003_snapshot.json +821 -0
  46. package/src/db/migrations/meta/_journal.json +14 -0
  47. package/src/db/schema.ts +13 -0
  48. package/src/i18n/locales/en.po +100 -32
  49. package/src/i18n/locales/en.ts +1 -1
  50. package/src/i18n/locales/zh-Hans.po +102 -55
  51. package/src/i18n/locales/zh-Hans.ts +1 -1
  52. package/src/i18n/locales/zh-Hant.po +102 -55
  53. package/src/i18n/locales/zh-Hant.ts +1 -1
  54. package/src/index.ts +5 -0
  55. package/src/lib/__tests__/theme-components.test.ts +107 -0
  56. package/src/lib/image.ts +3 -3
  57. package/src/lib/media-helpers.ts +54 -0
  58. package/src/lib/nav-reorder.ts +26 -0
  59. package/src/lib/navigation.ts +46 -0
  60. package/src/lib/theme-components.ts +76 -0
  61. package/src/routes/api/__tests__/posts.test.ts +8 -8
  62. package/src/routes/api/__tests__/timeline.test.ts +242 -0
  63. package/src/routes/api/timeline.tsx +145 -0
  64. package/src/routes/api/upload.ts +9 -5
  65. package/src/routes/dash/navigation.tsx +306 -0
  66. package/src/routes/pages/archive.tsx +15 -23
  67. package/src/routes/pages/collection.tsx +8 -15
  68. package/src/routes/pages/home.tsx +111 -122
  69. package/src/routes/pages/page.tsx +17 -30
  70. package/src/routes/pages/post.tsx +33 -42
  71. package/src/routes/pages/search.tsx +18 -22
  72. package/src/services/__tests__/media.test.ts +34 -7
  73. package/src/services/__tests__/navigation.test.ts +213 -0
  74. package/src/services/__tests__/post-timeline.test.ts +220 -0
  75. package/src/services/index.ts +7 -0
  76. package/src/services/media.ts +2 -1
  77. package/src/services/navigation.ts +165 -0
  78. package/src/services/post.ts +48 -1
  79. package/src/styles/components.css +59 -0
  80. package/src/theme/components/PostList.tsx +7 -0
  81. package/src/theme/components/index.ts +12 -0
  82. package/src/theme/components/timeline/ArticleCard.tsx +57 -0
  83. package/src/theme/components/timeline/ImageCard.tsx +80 -0
  84. package/src/theme/components/timeline/LinkCard.tsx +66 -0
  85. package/src/theme/components/timeline/NoteCard.tsx +41 -0
  86. package/src/theme/components/timeline/QuoteCard.tsx +55 -0
  87. package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
  88. package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
  89. package/src/theme/components/timeline/TimelineItem.tsx +39 -0
  90. package/src/theme/components/timeline/index.ts +8 -0
  91. package/src/theme/layouts/DashLayout.tsx +10 -0
  92. package/src/theme/layouts/SiteLayout.tsx +184 -0
  93. package/src/theme/layouts/index.ts +1 -0
  94. package/src/types/sortablejs.d.ts +23 -0
  95. package/src/types.ts +61 -0
  96. package/dist/app.d.ts +0 -38
  97. package/dist/app.d.ts.map +0 -1
  98. package/dist/auth.d.ts +0 -25
  99. package/dist/auth.d.ts.map +0 -1
  100. package/dist/db/index.d.ts +0 -10
  101. package/dist/db/index.d.ts.map +0 -1
  102. package/dist/db/schema.d.ts +0 -1543
  103. package/dist/db/schema.d.ts.map +0 -1
  104. package/dist/i18n/Trans.d.ts +0 -25
  105. package/dist/i18n/Trans.d.ts.map +0 -1
  106. package/dist/i18n/context.d.ts +0 -69
  107. package/dist/i18n/context.d.ts.map +0 -1
  108. package/dist/i18n/detect.d.ts +0 -20
  109. package/dist/i18n/detect.d.ts.map +0 -1
  110. package/dist/i18n/i18n.d.ts +0 -32
  111. package/dist/i18n/i18n.d.ts.map +0 -1
  112. package/dist/i18n/index.d.ts +0 -41
  113. package/dist/i18n/index.d.ts.map +0 -1
  114. package/dist/i18n/locales/en.d.ts +0 -3
  115. package/dist/i18n/locales/en.d.ts.map +0 -1
  116. package/dist/i18n/locales/zh-Hans.d.ts +0 -3
  117. package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
  118. package/dist/i18n/locales/zh-Hant.d.ts +0 -3
  119. package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
  120. package/dist/i18n/locales.d.ts +0 -11
  121. package/dist/i18n/locales.d.ts.map +0 -1
  122. package/dist/i18n/middleware.d.ts +0 -21
  123. package/dist/i18n/middleware.d.ts.map +0 -1
  124. package/dist/index.d.ts +0 -16
  125. package/dist/index.d.ts.map +0 -1
  126. package/dist/lib/config.d.ts +0 -83
  127. package/dist/lib/config.d.ts.map +0 -1
  128. package/dist/lib/constants.d.ts +0 -37
  129. package/dist/lib/constants.d.ts.map +0 -1
  130. package/dist/lib/image.d.ts +0 -73
  131. package/dist/lib/image.d.ts.map +0 -1
  132. package/dist/lib/index.d.ts +0 -9
  133. package/dist/lib/index.d.ts.map +0 -1
  134. package/dist/lib/markdown.d.ts +0 -60
  135. package/dist/lib/markdown.d.ts.map +0 -1
  136. package/dist/lib/schemas.d.ts +0 -130
  137. package/dist/lib/schemas.d.ts.map +0 -1
  138. package/dist/lib/sqid.d.ts +0 -60
  139. package/dist/lib/sqid.d.ts.map +0 -1
  140. package/dist/lib/sse.d.ts +0 -192
  141. package/dist/lib/sse.d.ts.map +0 -1
  142. package/dist/lib/theme.d.ts +0 -44
  143. package/dist/lib/theme.d.ts.map +0 -1
  144. package/dist/lib/time.d.ts +0 -90
  145. package/dist/lib/time.d.ts.map +0 -1
  146. package/dist/lib/url.d.ts +0 -82
  147. package/dist/lib/url.d.ts.map +0 -1
  148. package/dist/middleware/auth.d.ts +0 -24
  149. package/dist/middleware/auth.d.ts.map +0 -1
  150. package/dist/middleware/onboarding.d.ts +0 -26
  151. package/dist/middleware/onboarding.d.ts.map +0 -1
  152. package/dist/routes/api/posts.d.ts +0 -13
  153. package/dist/routes/api/posts.d.ts.map +0 -1
  154. package/dist/routes/api/search.d.ts +0 -13
  155. package/dist/routes/api/search.d.ts.map +0 -1
  156. package/dist/routes/api/upload.d.ts +0 -16
  157. package/dist/routes/api/upload.d.ts.map +0 -1
  158. package/dist/routes/dash/collections.d.ts +0 -13
  159. package/dist/routes/dash/collections.d.ts.map +0 -1
  160. package/dist/routes/dash/index.d.ts +0 -15
  161. package/dist/routes/dash/index.d.ts.map +0 -1
  162. package/dist/routes/dash/media.d.ts +0 -16
  163. package/dist/routes/dash/media.d.ts.map +0 -1
  164. package/dist/routes/dash/pages.d.ts +0 -15
  165. package/dist/routes/dash/pages.d.ts.map +0 -1
  166. package/dist/routes/dash/posts.d.ts +0 -13
  167. package/dist/routes/dash/posts.d.ts.map +0 -1
  168. package/dist/routes/dash/redirects.d.ts +0 -13
  169. package/dist/routes/dash/redirects.d.ts.map +0 -1
  170. package/dist/routes/dash/settings.d.ts +0 -15
  171. package/dist/routes/dash/settings.d.ts.map +0 -1
  172. package/dist/routes/feed/rss.d.ts +0 -13
  173. package/dist/routes/feed/rss.d.ts.map +0 -1
  174. package/dist/routes/feed/sitemap.d.ts +0 -13
  175. package/dist/routes/feed/sitemap.d.ts.map +0 -1
  176. package/dist/routes/pages/archive.d.ts +0 -15
  177. package/dist/routes/pages/archive.d.ts.map +0 -1
  178. package/dist/routes/pages/collection.d.ts +0 -13
  179. package/dist/routes/pages/collection.d.ts.map +0 -1
  180. package/dist/routes/pages/home.d.ts +0 -13
  181. package/dist/routes/pages/home.d.ts.map +0 -1
  182. package/dist/routes/pages/page.d.ts +0 -15
  183. package/dist/routes/pages/page.d.ts.map +0 -1
  184. package/dist/routes/pages/post.d.ts +0 -13
  185. package/dist/routes/pages/post.d.ts.map +0 -1
  186. package/dist/routes/pages/search.d.ts +0 -13
  187. package/dist/routes/pages/search.d.ts.map +0 -1
  188. package/dist/services/collection.d.ts +0 -32
  189. package/dist/services/collection.d.ts.map +0 -1
  190. package/dist/services/index.d.ts +0 -28
  191. package/dist/services/index.d.ts.map +0 -1
  192. package/dist/services/media.d.ts +0 -34
  193. package/dist/services/media.d.ts.map +0 -1
  194. package/dist/services/post.d.ts +0 -31
  195. package/dist/services/post.d.ts.map +0 -1
  196. package/dist/services/redirect.d.ts +0 -15
  197. package/dist/services/redirect.d.ts.map +0 -1
  198. package/dist/services/search.d.ts +0 -26
  199. package/dist/services/search.d.ts.map +0 -1
  200. package/dist/services/settings.d.ts +0 -18
  201. package/dist/services/settings.d.ts.map +0 -1
  202. package/dist/theme/color-themes.d.ts +0 -30
  203. package/dist/theme/color-themes.d.ts.map +0 -1
  204. package/dist/theme/components/ActionButtons.d.ts +0 -43
  205. package/dist/theme/components/ActionButtons.d.ts.map +0 -1
  206. package/dist/theme/components/CrudPageHeader.d.ts +0 -23
  207. package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
  208. package/dist/theme/components/DangerZone.d.ts +0 -36
  209. package/dist/theme/components/DangerZone.d.ts.map +0 -1
  210. package/dist/theme/components/EmptyState.d.ts +0 -27
  211. package/dist/theme/components/EmptyState.d.ts.map +0 -1
  212. package/dist/theme/components/ListItemRow.d.ts +0 -15
  213. package/dist/theme/components/ListItemRow.d.ts.map +0 -1
  214. package/dist/theme/components/MediaGallery.d.ts +0 -13
  215. package/dist/theme/components/MediaGallery.d.ts.map +0 -1
  216. package/dist/theme/components/PageForm.d.ts +0 -14
  217. package/dist/theme/components/PageForm.d.ts.map +0 -1
  218. package/dist/theme/components/Pagination.d.ts +0 -46
  219. package/dist/theme/components/Pagination.d.ts.map +0 -1
  220. package/dist/theme/components/PostForm.d.ts +0 -16
  221. package/dist/theme/components/PostForm.d.ts.map +0 -1
  222. package/dist/theme/components/PostList.d.ts +0 -10
  223. package/dist/theme/components/PostList.d.ts.map +0 -1
  224. package/dist/theme/components/ThreadView.d.ts +0 -15
  225. package/dist/theme/components/ThreadView.d.ts.map +0 -1
  226. package/dist/theme/components/TypeBadge.d.ts +0 -12
  227. package/dist/theme/components/TypeBadge.d.ts.map +0 -1
  228. package/dist/theme/components/VisibilityBadge.d.ts +0 -12
  229. package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
  230. package/dist/theme/components/index.d.ts +0 -14
  231. package/dist/theme/components/index.d.ts.map +0 -1
  232. package/dist/theme/index.d.ts +0 -21
  233. package/dist/theme/index.d.ts.map +0 -1
  234. package/dist/theme/layouts/BaseLayout.d.ts +0 -23
  235. package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
  236. package/dist/theme/layouts/DashLayout.d.ts +0 -17
  237. package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
  238. package/dist/theme/layouts/index.d.ts +0 -3
  239. package/dist/theme/layouts/index.d.ts.map +0 -1
  240. package/dist/types.d.ts +0 -237
  241. 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
+ });
@@ -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";
@@ -25,6 +25,7 @@ export interface MediaService {
25
25
  }
26
26
 
27
27
  export interface CreateMediaData {
28
+ id?: string;
28
29
  postId?: number;
29
30
  filename: string;
30
31
  originalName: string;
@@ -125,7 +126,7 @@ export function createMediaService(db: Database): MediaService {
125
126
  },
126
127
 
127
128
  async create(data) {
128
- const id = uuidv7();
129
+ const id = data.id ?? uuidv7();
129
130
  const timestamp = now();
130
131
 
131
132
  const result = await db
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Navigation Link Service
3
+ *
4
+ * Manages navigation links displayed on public pages
5
+ */
6
+
7
+ import { eq, asc, sql } from "drizzle-orm";
8
+ import type { Database } from "../db/index.js";
9
+ import { navigationLinks } from "../db/schema.js";
10
+ import { now } from "../lib/time.js";
11
+ import type {
12
+ NavigationLink,
13
+ CreateNavigationLink,
14
+ UpdateNavigationLink,
15
+ } from "../types.js";
16
+
17
+ export interface NavigationLinkService {
18
+ list(): Promise<NavigationLink[]>;
19
+ getById(id: number): Promise<NavigationLink | null>;
20
+ create(data: CreateNavigationLink): Promise<NavigationLink>;
21
+ update(
22
+ id: number,
23
+ data: UpdateNavigationLink,
24
+ ): Promise<NavigationLink | null>;
25
+ delete(id: number): Promise<boolean>;
26
+ reorder(ids: number[]): Promise<void>;
27
+ ensureDefaults(): Promise<NavigationLink[]>;
28
+ }
29
+
30
+ export function createNavigationLinkService(
31
+ db: Database,
32
+ ): NavigationLinkService {
33
+ function toNavigationLink(
34
+ row: typeof navigationLinks.$inferSelect,
35
+ ): NavigationLink {
36
+ return {
37
+ id: row.id,
38
+ label: row.label,
39
+ url: row.url,
40
+ position: row.position,
41
+ createdAt: row.createdAt,
42
+ updatedAt: row.updatedAt,
43
+ };
44
+ }
45
+
46
+ return {
47
+ async list() {
48
+ const rows = await db
49
+ .select()
50
+ .from(navigationLinks)
51
+ .orderBy(asc(navigationLinks.position));
52
+ return rows.map(toNavigationLink);
53
+ },
54
+
55
+ async getById(id) {
56
+ const result = await db
57
+ .select()
58
+ .from(navigationLinks)
59
+ .where(eq(navigationLinks.id, id))
60
+ .limit(1);
61
+ return result[0] ? toNavigationLink(result[0]) : null;
62
+ },
63
+
64
+ async create(data) {
65
+ const timestamp = now();
66
+
67
+ let position = data.position;
68
+ if (position === undefined) {
69
+ const maxResult = await db
70
+ .select({ maxPos: sql<number>`COALESCE(MAX(position), -1)` })
71
+ .from(navigationLinks);
72
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
73
+ position = maxResult[0]!.maxPos + 1;
74
+ }
75
+
76
+ const result = await db
77
+ .insert(navigationLinks)
78
+ .values({
79
+ label: data.label,
80
+ url: data.url,
81
+ position,
82
+ createdAt: timestamp,
83
+ updatedAt: timestamp,
84
+ })
85
+ .returning();
86
+
87
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
88
+ return toNavigationLink(result[0]!);
89
+ },
90
+
91
+ async update(id, data) {
92
+ const existing = await db
93
+ .select()
94
+ .from(navigationLinks)
95
+ .where(eq(navigationLinks.id, id))
96
+ .limit(1);
97
+ if (!existing[0]) return null;
98
+
99
+ const timestamp = now();
100
+ const result = await db
101
+ .update(navigationLinks)
102
+ .set({
103
+ ...(data.label !== undefined && { label: data.label }),
104
+ ...(data.url !== undefined && { url: data.url }),
105
+ ...(data.position !== undefined && { position: data.position }),
106
+ updatedAt: timestamp,
107
+ })
108
+ .where(eq(navigationLinks.id, id))
109
+ .returning();
110
+
111
+ return result[0] ? toNavigationLink(result[0]) : null;
112
+ },
113
+
114
+ async delete(id) {
115
+ const result = await db
116
+ .delete(navigationLinks)
117
+ .where(eq(navigationLinks.id, id))
118
+ .returning();
119
+ return result.length > 0;
120
+ },
121
+
122
+ async reorder(ids) {
123
+ const timestamp = now();
124
+ for (let i = 0; i < ids.length; i++) {
125
+ await db
126
+ .update(navigationLinks)
127
+ .set({ position: i, updatedAt: timestamp })
128
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loop index guarantees element exists
129
+ .where(eq(navigationLinks.id, ids[i]!));
130
+ }
131
+ },
132
+
133
+ async ensureDefaults() {
134
+ const existing = await db.select().from(navigationLinks).limit(1);
135
+ if (existing.length > 0) {
136
+ const rows = await db
137
+ .select()
138
+ .from(navigationLinks)
139
+ .orderBy(asc(navigationLinks.position));
140
+ return rows.map(toNavigationLink);
141
+ }
142
+
143
+ const timestamp = now();
144
+ const defaults = [
145
+ { label: "Home", url: "/", position: 0 },
146
+ { label: "Archive", url: "/archive", position: 1 },
147
+ { label: "RSS", url: "/feed", position: 2 },
148
+ ];
149
+
150
+ for (const link of defaults) {
151
+ await db.insert(navigationLinks).values({
152
+ ...link,
153
+ createdAt: timestamp,
154
+ updatedAt: timestamp,
155
+ });
156
+ }
157
+
158
+ const rows = await db
159
+ .select()
160
+ .from(navigationLinks)
161
+ .orderBy(asc(navigationLinks.position));
162
+ return rows.map(toNavigationLink);
163
+ },
164
+ };
165
+ }