@jant/core 0.3.26 → 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 (314) 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 +112 -173
  9. package/src/auth.ts +4 -1
  10. package/src/client.ts +13 -0
  11. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  12. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  13. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  14. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  15. package/src/db/schema.ts +24 -4
  16. package/src/i18n/locales/en.po +810 -385
  17. package/src/i18n/locales/en.ts +1 -1
  18. package/src/i18n/locales/zh-Hans.po +733 -522
  19. package/src/i18n/locales/zh-Hans.ts +1 -1
  20. package/src/i18n/locales/zh-Hant.po +733 -522
  21. package/src/i18n/locales/zh-Hant.ts +1 -1
  22. package/src/i18n/middleware.ts +7 -11
  23. package/src/index.ts +1 -1
  24. package/src/lib/__tests__/icons.test.ts +178 -0
  25. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  26. package/src/lib/__tests__/schemas.test.ts +12 -6
  27. package/src/lib/__tests__/theme.test.ts +62 -0
  28. package/src/lib/__tests__/timezones.test.ts +1 -1
  29. package/src/lib/__tests__/url.test.ts +12 -0
  30. package/src/lib/__tests__/view.test.ts +1 -5
  31. package/src/lib/avatar-upload.ts +18 -10
  32. package/src/lib/collection-form-bridge.ts +52 -0
  33. package/src/lib/collections-reorder.ts +28 -0
  34. package/src/lib/compose-bridge.ts +251 -0
  35. package/src/lib/errors.ts +116 -0
  36. package/src/lib/excerpt.ts +1 -1
  37. package/src/lib/favicon.ts +3 -5
  38. package/src/lib/html.ts +22 -0
  39. package/src/lib/icon-catalog.ts +181 -0
  40. package/src/lib/icons.ts +202 -0
  41. package/src/lib/navigation.ts +18 -33
  42. package/src/lib/pagination.ts +3 -2
  43. package/src/lib/post-form-bridge.ts +136 -0
  44. package/src/lib/render.tsx +11 -4
  45. package/src/lib/resolve-config.ts +157 -0
  46. package/src/lib/schemas.ts +76 -12
  47. package/src/lib/settings-bridge.ts +139 -0
  48. package/src/lib/storage.ts +37 -16
  49. package/src/lib/theme.ts +5 -7
  50. package/src/lib/timeline.ts +4 -8
  51. package/src/lib/toast.ts +134 -0
  52. package/src/lib/upload.ts +71 -0
  53. package/src/lib/url.ts +9 -1
  54. package/src/lib/version.ts +16 -0
  55. package/src/lib/view.ts +9 -10
  56. package/src/middleware/__tests__/auth.test.ts +6 -28
  57. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  58. package/src/middleware/auth.ts +6 -12
  59. package/src/middleware/config.ts +51 -0
  60. package/src/middleware/error-handler.ts +56 -0
  61. package/src/middleware/onboarding.ts +1 -1
  62. package/src/preset.css +6 -0
  63. package/src/routes/__tests__/compose.test.ts +104 -17
  64. package/src/routes/api/__tests__/collections.test.ts +93 -2
  65. package/src/routes/api/__tests__/posts.test.ts +2 -1
  66. package/src/routes/api/__tests__/settings.test.ts +1 -1
  67. package/src/routes/api/collections.ts +64 -68
  68. package/src/routes/api/nav-items.ts +21 -59
  69. package/src/routes/api/pages.ts +18 -46
  70. package/src/routes/api/posts.ts +64 -86
  71. package/src/routes/api/search.ts +6 -4
  72. package/src/routes/api/settings.ts +8 -24
  73. package/src/routes/api/upload.ts +55 -53
  74. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  75. package/src/routes/auth/reset.tsx +17 -66
  76. package/src/routes/auth/setup.tsx +67 -11
  77. package/src/routes/auth/signin.tsx +44 -8
  78. package/src/routes/compose.tsx +194 -0
  79. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  80. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  81. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  82. package/src/routes/dash/appearance.tsx +173 -0
  83. package/src/routes/dash/collections.tsx +80 -14
  84. package/src/routes/dash/index.tsx +12 -14
  85. package/src/routes/dash/media.tsx +46 -49
  86. package/src/routes/dash/pages.tsx +85 -37
  87. package/src/routes/dash/posts.tsx +60 -23
  88. package/src/routes/dash/redirects.tsx +43 -33
  89. package/src/routes/dash/settings.tsx +234 -214
  90. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  91. package/src/routes/feed/rss.ts +11 -16
  92. package/src/routes/feed/sitemap.ts +15 -9
  93. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  94. package/src/routes/pages/archive.tsx +2 -2
  95. package/src/routes/pages/collection.tsx +76 -9
  96. package/src/routes/pages/collections.tsx +3 -1
  97. package/src/routes/pages/featured.tsx +2 -2
  98. package/src/routes/pages/home.tsx +3 -3
  99. package/src/routes/pages/latest.tsx +2 -2
  100. package/src/routes/pages/page.tsx +2 -2
  101. package/src/routes/pages/post.tsx +2 -2
  102. package/src/routes/pages/search.tsx +2 -2
  103. package/src/services/__tests__/collection.test.ts +324 -34
  104. package/src/services/__tests__/media.test.ts +1 -1
  105. package/src/services/__tests__/page.test.ts +116 -1
  106. package/src/services/auth.ts +88 -0
  107. package/src/services/collection.ts +169 -30
  108. package/src/services/index.ts +8 -3
  109. package/src/services/media.ts +39 -12
  110. package/src/services/navigation.ts +17 -5
  111. package/src/services/page.ts +24 -4
  112. package/src/services/post.ts +87 -19
  113. package/src/services/search.ts +0 -1
  114. package/src/services/settings.ts +21 -13
  115. package/src/style.css +3 -0
  116. package/src/styles/components.css +42 -1
  117. package/src/styles/tokens.css +4 -0
  118. package/src/styles/ui.css +902 -73
  119. package/src/types/app-context.ts +25 -0
  120. package/src/types/bindings.ts +1 -0
  121. package/src/types/config.ts +60 -23
  122. package/src/types/entities.ts +12 -2
  123. package/src/types/lingui-react-macro.d.ts +3 -3
  124. package/src/types/operations.ts +2 -4
  125. package/src/types/views.ts +1 -3
  126. package/src/ui/__tests__/font-themes.test.ts +27 -8
  127. package/src/ui/color-themes.ts +1 -1
  128. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  129. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  130. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  131. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  132. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  133. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  134. package/src/ui/components/collection-types.ts +45 -0
  135. package/src/ui/components/compose-types.ts +75 -0
  136. package/src/ui/components/jant-collection-form.ts +512 -0
  137. package/src/ui/components/jant-compose-dialog.ts +494 -0
  138. package/src/ui/components/jant-compose-editor.ts +799 -0
  139. package/src/ui/components/jant-post-form.ts +290 -0
  140. package/src/ui/components/jant-settings-avatar.ts +231 -0
  141. package/src/ui/components/jant-settings-general.ts +436 -0
  142. package/src/ui/components/post-form-template.ts +260 -0
  143. package/src/ui/components/post-form-types.ts +87 -0
  144. package/src/ui/components/settings-types.ts +62 -0
  145. package/src/ui/compose/ComposeDialog.tsx +141 -385
  146. package/src/ui/compose/ComposePrompt.tsx +3 -3
  147. package/src/ui/dash/PostList.tsx +55 -61
  148. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  149. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  150. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  151. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  152. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  153. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  154. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  155. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  156. package/src/ui/dash/index.ts +1 -1
  157. package/src/ui/dash/posts/PostForm.tsx +248 -0
  158. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  159. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  160. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  161. package/src/ui/font-themes.ts +115 -32
  162. package/src/ui/layouts/BaseLayout.tsx +49 -19
  163. package/src/ui/layouts/DashLayout.tsx +14 -9
  164. package/src/ui/layouts/SiteLayout.tsx +38 -23
  165. package/src/ui/pages/CollectionPage.tsx +12 -2
  166. package/src/ui/pages/CollectionsPage.tsx +27 -27
  167. package/src/ui/pages/HomePage.tsx +15 -6
  168. package/src/ui/pages/SearchPage.tsx +1 -2
  169. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  170. package/src/ui/shared/Pagination.tsx +2 -2
  171. package/dist/app.js +0 -265
  172. package/dist/auth.js +0 -36
  173. package/dist/client.js +0 -13
  174. package/dist/db/index.js +0 -10
  175. package/dist/db/schema.js +0 -224
  176. package/dist/i18n/Trans.js +0 -24
  177. package/dist/i18n/context.js +0 -58
  178. package/dist/i18n/detect.js +0 -26
  179. package/dist/i18n/i18n.js +0 -49
  180. package/dist/i18n/index.js +0 -44
  181. package/dist/i18n/locales/en.js +0 -1
  182. package/dist/i18n/locales/zh-Hans.js +0 -1
  183. package/dist/i18n/locales/zh-Hant.js +0 -1
  184. package/dist/i18n/locales.js +0 -13
  185. package/dist/i18n/middleware.js +0 -30
  186. package/dist/lib/avatar-upload.js +0 -134
  187. package/dist/lib/config.js +0 -143
  188. package/dist/lib/constants.js +0 -50
  189. package/dist/lib/excerpt.js +0 -76
  190. package/dist/lib/favicon.js +0 -102
  191. package/dist/lib/feed.js +0 -123
  192. package/dist/lib/image-processor.js +0 -187
  193. package/dist/lib/image.js +0 -97
  194. package/dist/lib/index.js +0 -7
  195. package/dist/lib/markdown.js +0 -83
  196. package/dist/lib/media-helpers.js +0 -49
  197. package/dist/lib/media-upload.js +0 -104
  198. package/dist/lib/nav-reorder.js +0 -27
  199. package/dist/lib/navigation.js +0 -79
  200. package/dist/lib/pagination.js +0 -44
  201. package/dist/lib/render.js +0 -53
  202. package/dist/lib/schemas.js +0 -174
  203. package/dist/lib/sqid.js +0 -72
  204. package/dist/lib/sse.js +0 -218
  205. package/dist/lib/storage.js +0 -164
  206. package/dist/lib/theme.js +0 -65
  207. package/dist/lib/time.js +0 -159
  208. package/dist/lib/timeline.js +0 -95
  209. package/dist/lib/timezones.js +0 -388
  210. package/dist/lib/url.js +0 -89
  211. package/dist/lib/view.js +0 -217
  212. package/dist/middleware/auth.js +0 -52
  213. package/dist/middleware/onboarding.js +0 -41
  214. package/dist/routes/api/collections.js +0 -124
  215. package/dist/routes/api/nav-items.js +0 -104
  216. package/dist/routes/api/pages.js +0 -91
  217. package/dist/routes/api/posts.js +0 -218
  218. package/dist/routes/api/search.js +0 -48
  219. package/dist/routes/api/settings.js +0 -68
  220. package/dist/routes/api/upload.js +0 -246
  221. package/dist/routes/auth/reset.js +0 -221
  222. package/dist/routes/auth/setup.js +0 -194
  223. package/dist/routes/auth/signin.js +0 -176
  224. package/dist/routes/compose.js +0 -48
  225. package/dist/routes/dash/collections.js +0 -115
  226. package/dist/routes/dash/index.js +0 -118
  227. package/dist/routes/dash/media.js +0 -106
  228. package/dist/routes/dash/pages.js +0 -294
  229. package/dist/routes/dash/posts.js +0 -244
  230. package/dist/routes/dash/redirects.js +0 -257
  231. package/dist/routes/dash/settings.js +0 -379
  232. package/dist/routes/feed/rss.js +0 -62
  233. package/dist/routes/feed/sitemap.js +0 -49
  234. package/dist/routes/pages/archive.js +0 -62
  235. package/dist/routes/pages/collection.js +0 -34
  236. package/dist/routes/pages/collections.js +0 -28
  237. package/dist/routes/pages/featured.js +0 -36
  238. package/dist/routes/pages/home.js +0 -64
  239. package/dist/routes/pages/latest.js +0 -45
  240. package/dist/routes/pages/page.js +0 -68
  241. package/dist/routes/pages/post.js +0 -44
  242. package/dist/routes/pages/search.js +0 -54
  243. package/dist/services/collection.js +0 -109
  244. package/dist/services/index.js +0 -24
  245. package/dist/services/media.js +0 -117
  246. package/dist/services/navigation.js +0 -91
  247. package/dist/services/page.js +0 -84
  248. package/dist/services/post.js +0 -229
  249. package/dist/services/redirect.js +0 -48
  250. package/dist/services/search.js +0 -67
  251. package/dist/services/settings.js +0 -68
  252. package/dist/types/bindings.js +0 -3
  253. package/dist/types/config.js +0 -147
  254. package/dist/types/constants.js +0 -27
  255. package/dist/types/entities.js +0 -3
  256. package/dist/types/lingui-react-macro.d.js +0 -9
  257. package/dist/types/operations.js +0 -3
  258. package/dist/types/props.js +0 -3
  259. package/dist/types/sortablejs.d.js +0 -5
  260. package/dist/types/views.js +0 -5
  261. package/dist/types.js +0 -11
  262. package/dist/ui/color-themes.js +0 -268
  263. package/dist/ui/compose/ComposeDialog.js +0 -467
  264. package/dist/ui/compose/ComposePrompt.js +0 -55
  265. package/dist/ui/dash/ActionButtons.js +0 -46
  266. package/dist/ui/dash/CrudPageHeader.js +0 -22
  267. package/dist/ui/dash/DangerZone.js +0 -36
  268. package/dist/ui/dash/FormatBadge.js +0 -27
  269. package/dist/ui/dash/ListItemRow.js +0 -21
  270. package/dist/ui/dash/PageForm.js +0 -195
  271. package/dist/ui/dash/PostForm.js +0 -395
  272. package/dist/ui/dash/PostList.js +0 -83
  273. package/dist/ui/dash/StatusBadge.js +0 -46
  274. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  275. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  276. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  277. package/dist/ui/dash/index.js +0 -10
  278. package/dist/ui/dash/media/MediaListContent.js +0 -166
  279. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  280. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  281. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  282. package/dist/ui/dash/settings/AccountContent.js +0 -209
  283. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  284. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  285. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  286. package/dist/ui/feed/LinkCard.js +0 -72
  287. package/dist/ui/feed/NoteCard.js +0 -58
  288. package/dist/ui/feed/QuoteCard.js +0 -63
  289. package/dist/ui/feed/ThreadPreview.js +0 -48
  290. package/dist/ui/feed/TimelineFeed.js +0 -41
  291. package/dist/ui/feed/TimelineItem.js +0 -27
  292. package/dist/ui/font-themes.js +0 -36
  293. package/dist/ui/layouts/BaseLayout.js +0 -153
  294. package/dist/ui/layouts/DashLayout.js +0 -141
  295. package/dist/ui/layouts/SiteLayout.js +0 -169
  296. package/dist/ui/pages/ArchivePage.js +0 -143
  297. package/dist/ui/pages/CollectionPage.js +0 -70
  298. package/dist/ui/pages/CollectionsPage.js +0 -76
  299. package/dist/ui/pages/FeaturedPage.js +0 -24
  300. package/dist/ui/pages/HomePage.js +0 -24
  301. package/dist/ui/pages/PostPage.js +0 -55
  302. package/dist/ui/pages/SearchPage.js +0 -122
  303. package/dist/ui/pages/SinglePage.js +0 -23
  304. package/dist/ui/shared/EmptyState.js +0 -27
  305. package/dist/ui/shared/MediaGallery.js +0 -35
  306. package/dist/ui/shared/Pagination.js +0 -195
  307. package/dist/ui/shared/ThreadView.js +0 -108
  308. package/dist/ui/shared/index.js +0 -5
  309. package/dist/vendor/datastar.js +0 -1606
  310. package/src/lib/__tests__/config.test.ts +0 -192
  311. package/src/lib/config.ts +0 -167
  312. package/src/routes/compose.ts +0 -63
  313. package/src/ui/dash/PostForm.tsx +0 -360
  314. 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,