@jant/core 0.3.27 → 0.3.28

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 (313) hide show
  1. package/dist/client/client.css +1 -0
  2. package/dist/client/client.js +31561 -0
  3. package/dist/index.js +15209 -15
  4. package/package.json +21 -15
  5. package/src/__tests__/helpers/app.ts +19 -3
  6. package/src/__tests__/helpers/db.ts +44 -0
  7. package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
  8. package/src/app.tsx +111 -174
  9. package/src/client.ts +13 -0
  10. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  11. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  12. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  13. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  14. package/src/db/schema.ts +24 -4
  15. package/src/i18n/locales/en.po +810 -385
  16. package/src/i18n/locales/en.ts +1 -1
  17. package/src/i18n/locales/zh-Hans.po +733 -522
  18. package/src/i18n/locales/zh-Hans.ts +1 -1
  19. package/src/i18n/locales/zh-Hant.po +733 -522
  20. package/src/i18n/locales/zh-Hant.ts +1 -1
  21. package/src/i18n/middleware.ts +7 -11
  22. package/src/index.ts +1 -1
  23. package/src/lib/__tests__/icons.test.ts +178 -0
  24. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  25. package/src/lib/__tests__/schemas.test.ts +12 -6
  26. package/src/lib/__tests__/theme.test.ts +62 -0
  27. package/src/lib/__tests__/timezones.test.ts +1 -1
  28. package/src/lib/__tests__/url.test.ts +12 -0
  29. package/src/lib/__tests__/view.test.ts +1 -5
  30. package/src/lib/avatar-upload.ts +18 -10
  31. package/src/lib/collection-form-bridge.ts +52 -0
  32. package/src/lib/collections-reorder.ts +28 -0
  33. package/src/lib/compose-bridge.ts +251 -0
  34. package/src/lib/errors.ts +116 -0
  35. package/src/lib/excerpt.ts +1 -1
  36. package/src/lib/favicon.ts +3 -5
  37. package/src/lib/html.ts +22 -0
  38. package/src/lib/icon-catalog.ts +181 -0
  39. package/src/lib/icons.ts +202 -0
  40. package/src/lib/navigation.ts +18 -33
  41. package/src/lib/pagination.ts +3 -2
  42. package/src/lib/post-form-bridge.ts +136 -0
  43. package/src/lib/render.tsx +11 -4
  44. package/src/lib/resolve-config.ts +157 -0
  45. package/src/lib/schemas.ts +76 -12
  46. package/src/lib/settings-bridge.ts +139 -0
  47. package/src/lib/storage.ts +37 -16
  48. package/src/lib/theme.ts +5 -7
  49. package/src/lib/timeline.ts +4 -8
  50. package/src/lib/toast.ts +134 -0
  51. package/src/lib/upload.ts +71 -0
  52. package/src/lib/url.ts +9 -1
  53. package/src/lib/version.ts +16 -0
  54. package/src/lib/view.ts +9 -10
  55. package/src/middleware/__tests__/auth.test.ts +6 -28
  56. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  57. package/src/middleware/auth.ts +6 -12
  58. package/src/middleware/config.ts +51 -0
  59. package/src/middleware/error-handler.ts +56 -0
  60. package/src/middleware/onboarding.ts +1 -1
  61. package/src/preset.css +6 -0
  62. package/src/routes/__tests__/compose.test.ts +104 -17
  63. package/src/routes/api/__tests__/collections.test.ts +93 -2
  64. package/src/routes/api/__tests__/posts.test.ts +2 -1
  65. package/src/routes/api/__tests__/settings.test.ts +1 -1
  66. package/src/routes/api/collections.ts +64 -68
  67. package/src/routes/api/nav-items.ts +21 -59
  68. package/src/routes/api/pages.ts +18 -46
  69. package/src/routes/api/posts.ts +64 -86
  70. package/src/routes/api/search.ts +6 -4
  71. package/src/routes/api/settings.ts +8 -24
  72. package/src/routes/api/upload.ts +55 -53
  73. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  74. package/src/routes/auth/reset.tsx +17 -66
  75. package/src/routes/auth/setup.tsx +67 -11
  76. package/src/routes/auth/signin.tsx +44 -8
  77. package/src/routes/compose.tsx +194 -0
  78. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  79. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  80. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  81. package/src/routes/dash/appearance.tsx +173 -0
  82. package/src/routes/dash/collections.tsx +80 -14
  83. package/src/routes/dash/index.tsx +12 -14
  84. package/src/routes/dash/media.tsx +46 -49
  85. package/src/routes/dash/pages.tsx +85 -37
  86. package/src/routes/dash/posts.tsx +60 -23
  87. package/src/routes/dash/redirects.tsx +43 -33
  88. package/src/routes/dash/settings.tsx +234 -214
  89. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  90. package/src/routes/feed/rss.ts +11 -16
  91. package/src/routes/feed/sitemap.ts +15 -9
  92. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  93. package/src/routes/pages/archive.tsx +2 -2
  94. package/src/routes/pages/collection.tsx +76 -9
  95. package/src/routes/pages/collections.tsx +3 -1
  96. package/src/routes/pages/featured.tsx +2 -2
  97. package/src/routes/pages/home.tsx +3 -3
  98. package/src/routes/pages/latest.tsx +2 -2
  99. package/src/routes/pages/page.tsx +2 -2
  100. package/src/routes/pages/post.tsx +2 -2
  101. package/src/routes/pages/search.tsx +2 -2
  102. package/src/services/__tests__/collection.test.ts +324 -34
  103. package/src/services/__tests__/media.test.ts +1 -1
  104. package/src/services/__tests__/page.test.ts +116 -1
  105. package/src/services/auth.ts +88 -0
  106. package/src/services/collection.ts +169 -30
  107. package/src/services/index.ts +8 -3
  108. package/src/services/media.ts +39 -12
  109. package/src/services/navigation.ts +17 -5
  110. package/src/services/page.ts +24 -4
  111. package/src/services/post.ts +87 -19
  112. package/src/services/search.ts +0 -1
  113. package/src/services/settings.ts +21 -13
  114. package/src/style.css +3 -0
  115. package/src/styles/components.css +42 -1
  116. package/src/styles/tokens.css +4 -0
  117. package/src/styles/ui.css +902 -73
  118. package/src/types/app-context.ts +25 -0
  119. package/src/types/bindings.ts +1 -0
  120. package/src/types/config.ts +60 -23
  121. package/src/types/entities.ts +12 -2
  122. package/src/types/lingui-react-macro.d.ts +3 -3
  123. package/src/types/operations.ts +2 -4
  124. package/src/types/views.ts +1 -3
  125. package/src/ui/__tests__/font-themes.test.ts +27 -8
  126. package/src/ui/color-themes.ts +1 -1
  127. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  128. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  129. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  130. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  131. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  132. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  133. package/src/ui/components/collection-types.ts +45 -0
  134. package/src/ui/components/compose-types.ts +75 -0
  135. package/src/ui/components/jant-collection-form.ts +512 -0
  136. package/src/ui/components/jant-compose-dialog.ts +494 -0
  137. package/src/ui/components/jant-compose-editor.ts +799 -0
  138. package/src/ui/components/jant-post-form.ts +290 -0
  139. package/src/ui/components/jant-settings-avatar.ts +231 -0
  140. package/src/ui/components/jant-settings-general.ts +436 -0
  141. package/src/ui/components/post-form-template.ts +260 -0
  142. package/src/ui/components/post-form-types.ts +87 -0
  143. package/src/ui/components/settings-types.ts +62 -0
  144. package/src/ui/compose/ComposeDialog.tsx +141 -385
  145. package/src/ui/compose/ComposePrompt.tsx +3 -3
  146. package/src/ui/dash/PostList.tsx +55 -61
  147. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  148. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  149. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  150. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  151. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  152. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  153. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  154. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  155. package/src/ui/dash/index.ts +1 -1
  156. package/src/ui/dash/posts/PostForm.tsx +248 -0
  157. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  158. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  159. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  160. package/src/ui/font-themes.ts +115 -32
  161. package/src/ui/layouts/BaseLayout.tsx +49 -19
  162. package/src/ui/layouts/DashLayout.tsx +14 -9
  163. package/src/ui/layouts/SiteLayout.tsx +38 -23
  164. package/src/ui/pages/CollectionPage.tsx +12 -2
  165. package/src/ui/pages/CollectionsPage.tsx +27 -27
  166. package/src/ui/pages/HomePage.tsx +15 -6
  167. package/src/ui/pages/SearchPage.tsx +1 -2
  168. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  169. package/src/ui/shared/Pagination.tsx +2 -2
  170. package/dist/app.js +0 -267
  171. package/dist/auth.js +0 -39
  172. package/dist/client.js +0 -13
  173. package/dist/db/index.js +0 -10
  174. package/dist/db/schema.js +0 -224
  175. package/dist/i18n/Trans.js +0 -24
  176. package/dist/i18n/context.js +0 -58
  177. package/dist/i18n/detect.js +0 -26
  178. package/dist/i18n/i18n.js +0 -49
  179. package/dist/i18n/index.js +0 -44
  180. package/dist/i18n/locales/en.js +0 -1
  181. package/dist/i18n/locales/zh-Hans.js +0 -1
  182. package/dist/i18n/locales/zh-Hant.js +0 -1
  183. package/dist/i18n/locales.js +0 -13
  184. package/dist/i18n/middleware.js +0 -30
  185. package/dist/lib/avatar-upload.js +0 -134
  186. package/dist/lib/config.js +0 -143
  187. package/dist/lib/constants.js +0 -50
  188. package/dist/lib/excerpt.js +0 -76
  189. package/dist/lib/favicon.js +0 -102
  190. package/dist/lib/feed.js +0 -123
  191. package/dist/lib/image-processor.js +0 -187
  192. package/dist/lib/image.js +0 -97
  193. package/dist/lib/index.js +0 -7
  194. package/dist/lib/markdown.js +0 -83
  195. package/dist/lib/media-helpers.js +0 -49
  196. package/dist/lib/media-upload.js +0 -104
  197. package/dist/lib/nav-reorder.js +0 -27
  198. package/dist/lib/navigation.js +0 -79
  199. package/dist/lib/pagination.js +0 -44
  200. package/dist/lib/render.js +0 -53
  201. package/dist/lib/schemas.js +0 -174
  202. package/dist/lib/sqid.js +0 -72
  203. package/dist/lib/sse.js +0 -218
  204. package/dist/lib/storage.js +0 -164
  205. package/dist/lib/theme.js +0 -65
  206. package/dist/lib/time.js +0 -159
  207. package/dist/lib/timeline.js +0 -95
  208. package/dist/lib/timezones.js +0 -388
  209. package/dist/lib/url.js +0 -89
  210. package/dist/lib/view.js +0 -217
  211. package/dist/middleware/auth.js +0 -52
  212. package/dist/middleware/onboarding.js +0 -41
  213. package/dist/routes/api/collections.js +0 -124
  214. package/dist/routes/api/nav-items.js +0 -104
  215. package/dist/routes/api/pages.js +0 -91
  216. package/dist/routes/api/posts.js +0 -218
  217. package/dist/routes/api/search.js +0 -48
  218. package/dist/routes/api/settings.js +0 -68
  219. package/dist/routes/api/upload.js +0 -246
  220. package/dist/routes/auth/reset.js +0 -221
  221. package/dist/routes/auth/setup.js +0 -194
  222. package/dist/routes/auth/signin.js +0 -176
  223. package/dist/routes/compose.js +0 -48
  224. package/dist/routes/dash/collections.js +0 -115
  225. package/dist/routes/dash/index.js +0 -118
  226. package/dist/routes/dash/media.js +0 -106
  227. package/dist/routes/dash/pages.js +0 -294
  228. package/dist/routes/dash/posts.js +0 -244
  229. package/dist/routes/dash/redirects.js +0 -257
  230. package/dist/routes/dash/settings.js +0 -379
  231. package/dist/routes/feed/rss.js +0 -62
  232. package/dist/routes/feed/sitemap.js +0 -49
  233. package/dist/routes/pages/archive.js +0 -62
  234. package/dist/routes/pages/collection.js +0 -34
  235. package/dist/routes/pages/collections.js +0 -28
  236. package/dist/routes/pages/featured.js +0 -36
  237. package/dist/routes/pages/home.js +0 -64
  238. package/dist/routes/pages/latest.js +0 -45
  239. package/dist/routes/pages/page.js +0 -68
  240. package/dist/routes/pages/post.js +0 -44
  241. package/dist/routes/pages/search.js +0 -54
  242. package/dist/services/collection.js +0 -109
  243. package/dist/services/index.js +0 -24
  244. package/dist/services/media.js +0 -117
  245. package/dist/services/navigation.js +0 -91
  246. package/dist/services/page.js +0 -84
  247. package/dist/services/post.js +0 -229
  248. package/dist/services/redirect.js +0 -48
  249. package/dist/services/search.js +0 -67
  250. package/dist/services/settings.js +0 -68
  251. package/dist/types/bindings.js +0 -3
  252. package/dist/types/config.js +0 -147
  253. package/dist/types/constants.js +0 -27
  254. package/dist/types/entities.js +0 -3
  255. package/dist/types/lingui-react-macro.d.js +0 -9
  256. package/dist/types/operations.js +0 -3
  257. package/dist/types/props.js +0 -3
  258. package/dist/types/sortablejs.d.js +0 -5
  259. package/dist/types/views.js +0 -5
  260. package/dist/types.js +0 -11
  261. package/dist/ui/color-themes.js +0 -268
  262. package/dist/ui/compose/ComposeDialog.js +0 -467
  263. package/dist/ui/compose/ComposePrompt.js +0 -55
  264. package/dist/ui/dash/ActionButtons.js +0 -46
  265. package/dist/ui/dash/CrudPageHeader.js +0 -22
  266. package/dist/ui/dash/DangerZone.js +0 -36
  267. package/dist/ui/dash/FormatBadge.js +0 -27
  268. package/dist/ui/dash/ListItemRow.js +0 -21
  269. package/dist/ui/dash/PageForm.js +0 -195
  270. package/dist/ui/dash/PostForm.js +0 -395
  271. package/dist/ui/dash/PostList.js +0 -83
  272. package/dist/ui/dash/StatusBadge.js +0 -46
  273. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  274. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  275. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  276. package/dist/ui/dash/index.js +0 -10
  277. package/dist/ui/dash/media/MediaListContent.js +0 -166
  278. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  279. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  280. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  281. package/dist/ui/dash/settings/AccountContent.js +0 -209
  282. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  283. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  284. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  285. package/dist/ui/feed/LinkCard.js +0 -72
  286. package/dist/ui/feed/NoteCard.js +0 -58
  287. package/dist/ui/feed/QuoteCard.js +0 -63
  288. package/dist/ui/feed/ThreadPreview.js +0 -48
  289. package/dist/ui/feed/TimelineFeed.js +0 -41
  290. package/dist/ui/feed/TimelineItem.js +0 -27
  291. package/dist/ui/font-themes.js +0 -36
  292. package/dist/ui/layouts/BaseLayout.js +0 -153
  293. package/dist/ui/layouts/DashLayout.js +0 -141
  294. package/dist/ui/layouts/SiteLayout.js +0 -169
  295. package/dist/ui/pages/ArchivePage.js +0 -143
  296. package/dist/ui/pages/CollectionPage.js +0 -70
  297. package/dist/ui/pages/CollectionsPage.js +0 -76
  298. package/dist/ui/pages/FeaturedPage.js +0 -24
  299. package/dist/ui/pages/HomePage.js +0 -24
  300. package/dist/ui/pages/PostPage.js +0 -55
  301. package/dist/ui/pages/SearchPage.js +0 -122
  302. package/dist/ui/pages/SinglePage.js +0 -23
  303. package/dist/ui/shared/EmptyState.js +0 -27
  304. package/dist/ui/shared/MediaGallery.js +0 -35
  305. package/dist/ui/shared/Pagination.js +0 -195
  306. package/dist/ui/shared/ThreadView.js +0 -108
  307. package/dist/ui/shared/index.js +0 -5
  308. package/dist/vendor/datastar.js +0 -1606
  309. package/src/lib/__tests__/config.test.ts +0 -192
  310. package/src/lib/config.ts +0 -167
  311. package/src/routes/compose.ts +0 -63
  312. package/src/ui/dash/PostForm.tsx +0 -360
  313. package/src/ui/dash/settings/AppearanceContent.tsx +0 -254
@@ -1,15 +1,20 @@
1
1
  /**
2
2
  * Collection Service (v2)
3
3
  *
4
- * Manages collections. Posts belong to collections via posts.collection_id (1:M).
4
+ * Manages collections. Posts belong to collections via post_collections junction table (M:N).
5
5
  */
6
6
 
7
- import { eq, asc, sql, desc } from "drizzle-orm";
7
+ import { eq, asc, sql, desc, and } from "drizzle-orm";
8
8
  import type { Database } from "../db/index.js";
9
- import { collections, posts } from "../db/schema.js";
9
+ import {
10
+ collections,
11
+ collectionDividers,
12
+ postCollections,
13
+ } from "../db/schema.js";
10
14
  import { now } from "../lib/time.js";
11
15
  import type {
12
16
  Collection,
17
+ CollectionDivider,
13
18
  CreateCollection,
14
19
  UpdateCollection,
15
20
  SortOrder,
@@ -23,8 +28,26 @@ export interface CollectionService {
23
28
  update(id: number, data: UpdateCollection): Promise<Collection | null>;
24
29
  delete(id: number): Promise<boolean>;
25
30
  reorder(ids: number[]): Promise<void>;
31
+ /** Reorder mixed collections and dividers using prefixed IDs (e.g. "c-1", "d-2") */
32
+ reorderAll(items: string[]): Promise<void>;
33
+ /** Create a standalone divider with auto-assigned position */
34
+ createDivider(): Promise<CollectionDivider>;
35
+ /** Delete a divider by ID */
36
+ deleteDivider(id: number): Promise<boolean>;
37
+ /** List all dividers ordered by position */
38
+ listDividers(): Promise<CollectionDivider[]>;
26
39
  /** Get post count per collection */
27
40
  getPostCounts(): Promise<Map<number, number>>;
41
+ /** Add a post to a collection */
42
+ addPost(collectionId: number, postId: number): Promise<void>;
43
+ /** Remove a post from a collection */
44
+ removePost(collectionId: number, postId: number): Promise<void>;
45
+ /** Get all collections a post belongs to */
46
+ getCollectionsByPostId(postId: number): Promise<Collection[]>;
47
+ /** Get all post IDs in a collection */
48
+ getPostIds(collectionId: number): Promise<number[]>;
49
+ /** Sync a post's collection memberships (replace all with given IDs) */
50
+ syncPostCollections(postId: number, collectionIds: number[]): Promise<void>;
28
51
  }
29
52
 
30
53
  export function createCollectionService(db: Database): CollectionService {
@@ -37,7 +60,17 @@ export function createCollectionService(db: Database): CollectionService {
37
60
  icon: row.icon,
38
61
  sortOrder: row.sortOrder as SortOrder,
39
62
  position: row.position,
40
- showDivider: row.showDivider,
63
+ createdAt: row.createdAt,
64
+ updatedAt: row.updatedAt,
65
+ };
66
+ }
67
+
68
+ function toDivider(
69
+ row: typeof collectionDividers.$inferSelect,
70
+ ): CollectionDivider {
71
+ return {
72
+ id: row.id,
73
+ position: row.position,
41
74
  createdAt: row.createdAt,
42
75
  updatedAt: row.updatedAt,
43
76
  };
@@ -75,11 +108,11 @@ export function createCollectionService(db: Database): CollectionService {
75
108
 
76
109
  let position = data.position;
77
110
  if (position === undefined) {
78
- const maxResult = await db
79
- .select({ maxPos: sql<number>`COALESCE(MAX(position), -1)` })
80
- .from(collections);
111
+ const result = await db.all<{ maxPos: number }>(
112
+ sql`SELECT COALESCE(MAX(pos), -1) AS maxPos FROM (SELECT position AS pos FROM ${collections} UNION ALL SELECT position AS pos FROM ${collectionDividers})`,
113
+ );
81
114
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
82
- position = maxResult[0]!.maxPos + 1;
115
+ position = result[0]!.maxPos + 1;
83
116
  }
84
117
 
85
118
  const result = await db
@@ -91,7 +124,6 @@ export function createCollectionService(db: Database): CollectionService {
91
124
  icon: data.icon ?? null,
92
125
  sortOrder: data.sortOrder ?? "newest",
93
126
  position,
94
- showDivider: data.showDivider ? 1 : 0,
95
127
  createdAt: timestamp,
96
128
  updatedAt: timestamp,
97
129
  })
@@ -117,8 +149,6 @@ export function createCollectionService(db: Database): CollectionService {
117
149
  if (data.icon !== undefined) updates.icon = data.icon;
118
150
  if (data.sortOrder !== undefined) updates.sortOrder = data.sortOrder;
119
151
  if (data.position !== undefined) updates.position = data.position;
120
- if (data.showDivider !== undefined)
121
- updates.showDivider = data.showDivider ? 1 : 0;
122
152
 
123
153
  const result = await db
124
154
  .update(collections)
@@ -130,12 +160,7 @@ export function createCollectionService(db: Database): CollectionService {
130
160
  },
131
161
 
132
162
  async delete(id) {
133
- // Clear collection_id on posts that belong to this collection
134
- await db
135
- .update(posts)
136
- .set({ collectionId: null })
137
- .where(eq(posts.collectionId, id));
138
-
163
+ // Junction table entries are cleaned up by ON DELETE CASCADE
139
164
  const result = await db
140
165
  .delete(collections)
141
166
  .where(eq(collections.id, id))
@@ -144,35 +169,149 @@ export function createCollectionService(db: Database): CollectionService {
144
169
  },
145
170
 
146
171
  async reorder(ids) {
172
+ // Delegate to reorderAll with "c-" prefix for backward compat
173
+ await this.reorderAll(ids.map((id) => `c-${id}`));
174
+ },
175
+
176
+ async reorderAll(items) {
177
+ if (items.length === 0) return;
147
178
  const timestamp = now();
148
- for (let i = 0; i < ids.length; i++) {
149
- await db
179
+ const queries = items.map((item, i) => {
180
+ const [prefix, idStr] = item.split("-");
181
+ const id = Number(idStr);
182
+ if (prefix === "d") {
183
+ return db
184
+ .update(collectionDividers)
185
+ .set({ position: i, updatedAt: timestamp })
186
+ .where(eq(collectionDividers.id, id));
187
+ }
188
+ return db
150
189
  .update(collections)
151
190
  .set({ position: i, updatedAt: timestamp })
152
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loop index guarantees element exists
153
- .where(eq(collections.id, ids[i]!));
154
- }
191
+ .where(eq(collections.id, id));
192
+ });
193
+ await db.batch(
194
+ queries as [(typeof queries)[number], ...(typeof queries)[number][]],
195
+ );
196
+ },
197
+
198
+ async createDivider() {
199
+ const timestamp = now();
200
+
201
+ const maxResult = await db.all<{ maxPos: number }>(
202
+ sql`SELECT COALESCE(MAX(pos), -1) AS maxPos FROM (SELECT position AS pos FROM ${collections} UNION ALL SELECT position AS pos FROM ${collectionDividers})`,
203
+ );
204
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
205
+ const position = maxResult[0]!.maxPos + 1;
206
+
207
+ const result = await db
208
+ .insert(collectionDividers)
209
+ .values({
210
+ position,
211
+ createdAt: timestamp,
212
+ updatedAt: timestamp,
213
+ })
214
+ .returning();
215
+
216
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
217
+ return toDivider(result[0]!);
218
+ },
219
+
220
+ async deleteDivider(id) {
221
+ const result = await db
222
+ .delete(collectionDividers)
223
+ .where(eq(collectionDividers.id, id))
224
+ .returning();
225
+ return result.length > 0;
226
+ },
227
+
228
+ async listDividers() {
229
+ const rows = await db
230
+ .select()
231
+ .from(collectionDividers)
232
+ .orderBy(asc(collectionDividers.position));
233
+ return rows.map(toDivider);
155
234
  },
156
235
 
157
236
  async getPostCounts() {
158
237
  const rows = await db
159
238
  .select({
160
- collectionId: posts.collectionId,
239
+ collectionId: postCollections.collectionId,
161
240
  count: sql<number>`count(*)`.as("count"),
162
241
  })
163
- .from(posts)
164
- .where(
165
- sql`${posts.collectionId} IS NOT NULL AND ${posts.deletedAt} IS NULL`,
242
+ .from(postCollections)
243
+ .innerJoin(
244
+ sql`posts`,
245
+ sql`posts.id = ${postCollections.postId} AND posts.deleted_at IS NULL`,
166
246
  )
167
- .groupBy(posts.collectionId);
247
+ .groupBy(postCollections.collectionId);
168
248
 
169
249
  const counts = new Map<number, number>();
170
250
  for (const row of rows) {
171
- if (row.collectionId !== null) {
172
- counts.set(row.collectionId, row.count);
173
- }
251
+ counts.set(row.collectionId, row.count);
174
252
  }
175
253
  return counts;
176
254
  },
255
+
256
+ async addPost(collectionId, postId) {
257
+ await db
258
+ .insert(postCollections)
259
+ .values({ postId, collectionId })
260
+ .onConflictDoNothing();
261
+ },
262
+
263
+ async removePost(collectionId, postId) {
264
+ await db
265
+ .delete(postCollections)
266
+ .where(
267
+ and(
268
+ eq(postCollections.postId, postId),
269
+ eq(postCollections.collectionId, collectionId),
270
+ ),
271
+ );
272
+ },
273
+
274
+ async getCollectionsByPostId(postId) {
275
+ const rows = await db
276
+ .select({ collection: collections })
277
+ .from(postCollections)
278
+ .innerJoin(
279
+ collections,
280
+ eq(postCollections.collectionId, collections.id),
281
+ )
282
+ .where(eq(postCollections.postId, postId))
283
+ .orderBy(asc(collections.position));
284
+
285
+ return rows.map((r) => toCollection(r.collection));
286
+ },
287
+
288
+ async getPostIds(collectionId) {
289
+ const rows = await db
290
+ .select({ postId: postCollections.postId })
291
+ .from(postCollections)
292
+ .where(eq(postCollections.collectionId, collectionId));
293
+
294
+ return rows.map((r) => r.postId);
295
+ },
296
+
297
+ async syncPostCollections(postId, collectionIds) {
298
+ if (collectionIds.length === 0) {
299
+ // Only delete — single statement, no batch needed
300
+ await db
301
+ .delete(postCollections)
302
+ .where(eq(postCollections.postId, postId));
303
+ return;
304
+ }
305
+ // Delete existing + insert new atomically
306
+ const deleteQuery = db
307
+ .delete(postCollections)
308
+ .where(eq(postCollections.postId, postId));
309
+ const insertQuery = db
310
+ .insert(postCollections)
311
+ .values(
312
+ collectionIds.map((collectionId) => ({ postId, collectionId })),
313
+ );
314
+ await db.batch([deleteQuery, insertQuery]);
315
+ },
177
316
  };
178
317
  }
@@ -16,6 +16,7 @@ import {
16
16
  } from "./collection.js";
17
17
  import { createSearchService, type SearchService } from "./search.js";
18
18
  import { createNavItemService, type NavItemService } from "./navigation.js";
19
+ import { createAuthService, type AuthService } from "./auth.js";
19
20
 
20
21
  export interface Services {
21
22
  settings: SettingsService;
@@ -26,11 +27,13 @@ export interface Services {
26
27
  collections: CollectionService;
27
28
  search: SearchService;
28
29
  navItems: NavItemService;
30
+ auth: AuthService;
29
31
  }
30
32
 
31
33
  export function createServices(db: Database, d1: D1Database): Services {
34
+ const settings = createSettingsService(db);
32
35
  return {
33
- settings: createSettingsService(db),
36
+ settings,
34
37
  posts: createPostService(db),
35
38
  pages: createPageService(db),
36
39
  redirects: createRedirectService(db),
@@ -38,14 +41,16 @@ export function createServices(db: Database, d1: D1Database): Services {
38
41
  collections: createCollectionService(db),
39
42
  search: createSearchService(d1),
40
43
  navItems: createNavItemService(db),
44
+ auth: createAuthService(db, settings),
41
45
  };
42
46
  }
43
47
 
44
48
  export type { SettingsService } from "./settings.js";
45
49
  export type { PostService, PostFilters } from "./post.js";
46
- export type { PageService } from "./page.js";
50
+ export type { PageService, PageFilters } from "./page.js";
47
51
  export type { RedirectService } from "./redirect.js";
48
- export type { MediaService } from "./media.js";
52
+ export type { MediaService, MediaFilters } from "./media.js";
49
53
  export type { CollectionService } from "./collection.js";
50
54
  export type { SearchService, SearchResult, SearchOptions } from "./search.js";
51
55
  export type { NavItemService } from "./navigation.js";
56
+ export type { AuthService } from "./auth.js";
@@ -4,24 +4,31 @@
4
4
  * Handles media upload and management with pluggable storage backends.
5
5
  */
6
6
 
7
- import { eq, desc, inArray, asc } from "drizzle-orm";
7
+ import { eq, desc, inArray, asc, sql, and } 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";
11
11
  import { now } from "../lib/time.js";
12
12
  import type { Media } from "../types.js";
13
13
 
14
+ export interface MediaFilters {
15
+ limit?: number;
16
+ /** Filter by MIME type prefix, e.g. "image/" */
17
+ mimePrefix?: string;
18
+ }
19
+
14
20
  export interface MediaService {
15
21
  getById(id: string): Promise<Media | null>;
16
22
  getByIds(ids: string[]): Promise<Media[]>;
17
23
  getByPostId(postId: number): Promise<Media[]>;
18
24
  getByPostIds(postIds: number[]): Promise<Map<number, Media[]>>;
19
- list(limit?: number): Promise<Media[]>;
25
+ list(filters?: MediaFilters): Promise<Media[]>;
20
26
  create(data: CreateMediaData): Promise<Media>;
21
27
  delete(id: string): Promise<boolean>;
22
28
  getByStorageKey(storageKey: string): Promise<Media | null>;
23
29
  attachToPost(postId: number, mediaIds: string[]): Promise<void>;
24
30
  detachFromPost(postId: number): Promise<void>;
31
+ updateAlt(id: string, alt: string): Promise<void>;
25
32
  }
26
33
 
27
34
  export interface CreateMediaData {
@@ -118,10 +125,18 @@ export function createMediaService(db: Database): MediaService {
118
125
  return result[0] ? toMedia(result[0]) : null;
119
126
  },
120
127
 
121
- async list(limit = 100) {
128
+ async list(filters?: MediaFilters) {
129
+ const limit = filters?.limit ?? 100;
130
+ const conditions = [];
131
+ if (filters?.mimePrefix) {
132
+ conditions.push(
133
+ sql`${media.mimeType} LIKE ${filters.mimePrefix + "%"}`,
134
+ );
135
+ }
122
136
  const rows = await db
123
137
  .select()
124
138
  .from(media)
139
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
125
140
  .orderBy(desc(media.createdAt))
126
141
  .limit(limit);
127
142
  return rows.map(toMedia);
@@ -156,21 +171,29 @@ export function createMediaService(db: Database): MediaService {
156
171
  },
157
172
 
158
173
  async attachToPost(postId, mediaIds) {
159
- // Clear existing attachments
160
- await db
174
+ const clearQuery = db
161
175
  .update(media)
162
176
  .set({ postId: null, position: 0 })
163
177
  .where(eq(media.postId, postId));
164
178
 
165
- // Set new attachments with position = array index
166
- for (let i = 0; i < mediaIds.length; i++) {
167
- const mediaId = mediaIds[i];
168
- if (!mediaId) continue;
169
- await db
179
+ const validIds = mediaIds.filter((id): id is string => Boolean(id));
180
+ if (validIds.length === 0) {
181
+ // Only clear — single statement, no batch needed
182
+ await clearQuery;
183
+ return;
184
+ }
185
+
186
+ // Clear existing + re-attach atomically
187
+ const attachQueries = validIds.map((mediaId, i) =>
188
+ db
170
189
  .update(media)
171
190
  .set({ postId, position: i })
172
- .where(eq(media.id, mediaId));
173
- }
191
+ .where(eq(media.id, mediaId)),
192
+ );
193
+ await db.batch([clearQuery, ...attachQueries] as [
194
+ typeof clearQuery,
195
+ ...(typeof attachQueries)[number][],
196
+ ]);
174
197
  },
175
198
 
176
199
  async detachFromPost(postId) {
@@ -180,6 +203,10 @@ export function createMediaService(db: Database): MediaService {
180
203
  .where(eq(media.postId, postId));
181
204
  },
182
205
 
206
+ async updateAlt(id, alt) {
207
+ await db.update(media).set({ alt }).where(eq(media.id, id));
208
+ },
209
+
183
210
  async delete(id) {
184
211
  const result = await db.delete(media).where(eq(media.id, id)).returning();
185
212
  return result.length > 0;
@@ -21,6 +21,7 @@ export interface NavItemService {
21
21
  create(data: CreateNavItem): Promise<NavItem>;
22
22
  update(id: number, data: UpdateNavItem): Promise<NavItem | null>;
23
23
  delete(id: number): Promise<boolean>;
24
+ deleteByPageId(pageId: number): Promise<boolean>;
24
25
  reorder(ids: number[]): Promise<void>;
25
26
  }
26
27
 
@@ -118,15 +119,26 @@ export function createNavItemService(db: Database): NavItemService {
118
119
  return result.length > 0;
119
120
  },
120
121
 
122
+ async deleteByPageId(pageId) {
123
+ const result = await db
124
+ .delete(navItems)
125
+ .where(eq(navItems.pageId, pageId))
126
+ .returning();
127
+ return result.length > 0;
128
+ },
129
+
121
130
  async reorder(ids) {
131
+ if (ids.length === 0) return;
122
132
  const timestamp = now();
123
- for (let i = 0; i < ids.length; i++) {
124
- await db
133
+ const queries = ids.map((id, i) =>
134
+ db
125
135
  .update(navItems)
126
136
  .set({ position: i, updatedAt: timestamp })
127
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loop index guarantees element exists
128
- .where(eq(navItems.id, ids[i]!));
129
- }
137
+ .where(eq(navItems.id, id)),
138
+ );
139
+ await db.batch(
140
+ queries as [(typeof queries)[number], ...(typeof queries)[number][]],
141
+ );
130
142
  },
131
143
  };
132
144
  }
@@ -4,17 +4,21 @@
4
4
  * CRUD operations for standalone pages (about, now, etc.)
5
5
  */
6
6
 
7
- import { eq, desc, sql } from "drizzle-orm";
7
+ import { eq, desc, sql, and } from "drizzle-orm";
8
8
  import type { Database } from "../db/index.js";
9
9
  import { pages, navItems } from "../db/schema.js";
10
10
  import { now } from "../lib/time.js";
11
11
  import { render as renderMarkdown } from "../lib/markdown.js";
12
12
  import type { Page, Status, CreatePage, UpdatePage } from "../types.js";
13
13
 
14
+ export interface PageFilters {
15
+ status?: Status;
16
+ }
17
+
14
18
  export interface PageService {
15
19
  getById(id: number): Promise<Page | null>;
16
20
  getBySlug(slug: string): Promise<Page | null>;
17
- list(): Promise<Page[]>;
21
+ list(filters?: PageFilters): Promise<Page[]>;
18
22
  listNotInNav(): Promise<Page[]>;
19
23
  create(data: CreatePage): Promise<Page>;
20
24
  update(id: number, data: UpdatePage): Promise<Page | null>;
@@ -54,8 +58,16 @@ export function createPageService(db: Database): PageService {
54
58
  return result[0] ? toPage(result[0]) : null;
55
59
  },
56
60
 
57
- async list() {
58
- const rows = await db.select().from(pages).orderBy(desc(pages.createdAt));
61
+ async list(filters?: PageFilters) {
62
+ const conditions = [];
63
+ if (filters?.status) {
64
+ conditions.push(eq(pages.status, filters.status));
65
+ }
66
+ const rows = await db
67
+ .select()
68
+ .from(pages)
69
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
70
+ .orderBy(desc(pages.createdAt));
59
71
  return rows.map(toPage);
60
72
  },
61
73
 
@@ -118,6 +130,14 @@ export function createPageService(db: Database): PageService {
118
130
  .where(eq(navItems.pageId, id));
119
131
  }
120
132
 
133
+ // If title changed, update related nav_items label
134
+ if (data.title !== undefined && data.title !== existing.title) {
135
+ await db
136
+ .update(navItems)
137
+ .set({ label: data.title ?? existing.slug, updatedAt: timestamp })
138
+ .where(eq(navItems.pageId, id));
139
+ }
140
+
121
141
  const result = await db
122
142
  .update(pages)
123
143
  .set(updates)
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import { eq, and, isNull, desc, or, inArray, sql } from "drizzle-orm";
10
+ import type { BatchItem } from "drizzle-orm/batch";
10
11
  import type { Database } from "../db/index.js";
11
- import { posts } from "../db/schema.js";
12
+ import { posts, postCollections } from "../db/schema.js";
12
13
  import { now } from "../lib/time.js";
13
14
  import { render as renderMarkdown } from "../lib/markdown.js";
14
15
  import type { Format, Status, Post, CreatePost, UpdatePost } from "../types.js";
@@ -70,7 +71,10 @@ export function createPostService(db: Database): PostService {
70
71
  conditions.push(eq(posts.format, filters.format));
71
72
  }
72
73
  if (filters.collectionId !== undefined) {
73
- conditions.push(eq(posts.collectionId, filters.collectionId));
74
+ // Filter by collection via junction table
75
+ conditions.push(
76
+ sql`${posts.id} IN (SELECT post_id FROM post_collections WHERE collection_id = ${filters.collectionId})`,
77
+ );
74
78
  }
75
79
  if (filters.threadId) {
76
80
  conditions.push(eq(posts.threadId, filters.threadId));
@@ -99,7 +103,6 @@ export function createPostService(db: Database): PostService {
99
103
  bodyHtml: row.bodyHtml,
100
104
  quoteText: row.quoteText,
101
105
  rating: row.rating,
102
- collectionId: row.collectionId,
103
106
  replyToId: row.replyToId,
104
107
  threadId: row.threadId,
105
108
  deletedAt: row.deletedAt,
@@ -200,7 +203,6 @@ export function createPostService(db: Database): PostService {
200
203
  bodyHtml,
201
204
  quoteText: data.quoteText ?? null,
202
205
  rating: data.rating ?? null,
203
- collectionId: data.collectionId ?? null,
204
206
  replyToId: data.replyToId ?? null,
205
207
  threadId,
206
208
  publishedAt: data.publishedAt ?? timestamp,
@@ -210,7 +212,19 @@ export function createPostService(db: Database): PostService {
210
212
  .returning();
211
213
 
212
214
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
213
- return toPost(result[0]!);
215
+ const post = toPost(result[0]!);
216
+
217
+ // Sync collection memberships if provided
218
+ if (data.collectionIds && data.collectionIds.length > 0) {
219
+ await db.insert(postCollections).values(
220
+ data.collectionIds.map((collectionId) => ({
221
+ postId: post.id,
222
+ collectionId,
223
+ })),
224
+ );
225
+ }
226
+
227
+ return post;
214
228
  },
215
229
 
216
230
  async update(id, data) {
@@ -228,8 +242,6 @@ export function createPostService(db: Database): PostService {
228
242
  if (data.url !== undefined) updates.url = data.url;
229
243
  if (data.quoteText !== undefined) updates.quoteText = data.quoteText;
230
244
  if (data.rating !== undefined) updates.rating = data.rating;
231
- if (data.collectionId !== undefined)
232
- updates.collectionId = data.collectionId;
233
245
  if (data.publishedAt !== undefined)
234
246
  updates.publishedAt = data.publishedAt;
235
247
  if (data.pinned !== undefined) updates.pinned = data.pinned ? 1 : 0;
@@ -249,22 +261,78 @@ export function createPostService(db: Database): PostService {
249
261
  if (statusChanged) updates.status = data.status;
250
262
  if (featuredChanged) updates.featured = data.featured ? 1 : 0;
251
263
 
252
- // If this is a root post and status/featured changed, cascade to thread
253
- if ((statusChanged || featuredChanged) && !existing.threadId) {
254
- await this.updateThreadStatusAndFeatured(
255
- id,
256
- data.status ?? (existing.status as Status),
257
- data.featured !== undefined ? data.featured : existing.featured === 1,
264
+ // Build all write queries for atomic execution via D1 batch
265
+ const needsCascade =
266
+ (statusChanged || featuredChanged) && !existing.threadId;
267
+ const needsCollectionSync = data.collectionIds !== undefined;
268
+ const hasExtraWrites = needsCascade || needsCollectionSync;
269
+
270
+ if (!hasExtraWrites) {
271
+ // Simple case: only the post update
272
+ const result = await db
273
+ .update(posts)
274
+ .set(updates)
275
+ .where(eq(posts.id, id))
276
+ .returning();
277
+ return result[0] ? toPost(result[0]) : null;
278
+ }
279
+
280
+ // Complex case: batch cascade + update + collection sync atomically
281
+ const writeQueries: BatchItem<"sqlite">[] = [];
282
+
283
+ if (needsCascade) {
284
+ writeQueries.push(
285
+ db
286
+ .update(posts)
287
+ .set({
288
+ status: data.status ?? (existing.status as Status),
289
+ featured: (
290
+ data.featured !== undefined
291
+ ? data.featured
292
+ : existing.featured === 1
293
+ )
294
+ ? 1
295
+ : 0,
296
+ updatedAt: timestamp,
297
+ })
298
+ .where(eq(posts.threadId, id)),
258
299
  );
259
300
  }
260
301
 
261
- const result = await db
262
- .update(posts)
263
- .set(updates)
264
- .where(eq(posts.id, id))
265
- .returning();
302
+ // Post update is always present; track its index for result extraction
303
+ const updateIdx = writeQueries.length;
304
+ writeQueries.push(
305
+ db.update(posts).set(updates).where(eq(posts.id, id)).returning(),
306
+ );
266
307
 
267
- return result[0] ? toPost(result[0]) : null;
308
+ if (needsCollectionSync) {
309
+ writeQueries.push(
310
+ db.delete(postCollections).where(eq(postCollections.postId, id)),
311
+ );
312
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by needsCollectionSync
313
+ if (data.collectionIds!.length > 0) {
314
+ writeQueries.push(
315
+ db.insert(postCollections).values(
316
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by needsCollectionSync
317
+ data.collectionIds!.map((collectionId) => ({
318
+ postId: id,
319
+ collectionId,
320
+ })),
321
+ ),
322
+ );
323
+ }
324
+ }
325
+
326
+ const results = await db.batch(
327
+ writeQueries as [
328
+ (typeof writeQueries)[number],
329
+ ...(typeof writeQueries)[number][],
330
+ ],
331
+ );
332
+ const updateResult = results[updateIdx] as
333
+ | (typeof posts.$inferSelect)[]
334
+ | undefined;
335
+ return updateResult?.[0] ? toPost(updateResult[0]) : null;
268
336
  },
269
337
 
270
338
  async delete(id) {
@@ -106,7 +106,6 @@ export function createSearchService(d1: D1Database): SearchService {
106
106
  bodyHtml: row.body_html,
107
107
  quoteText: row.quote_text,
108
108
  rating: row.rating,
109
- collectionId: row.collection_id,
110
109
  replyToId: row.reply_to_id,
111
110
  threadId: row.thread_id,
112
111
  deletedAt: row.deleted_at,