@jant/core 0.3.36 → 0.3.37

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 (271) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -2,80 +2,211 @@
2
2
  * Collection Service (v2)
3
3
  *
4
4
  * Manages collections. Posts belong to collections via post_collections junction table (M:N).
5
+ * Sidebar ordering is managed through the sidebar_items table with fractional indexing.
5
6
  */
6
7
 
7
- import { eq, asc, sql, desc, and } from "drizzle-orm";
8
- import type { Database } from "../db/index.js";
8
+ import { eq, asc, sql, and, inArray, desc } from "drizzle-orm";
9
+ import type { BatchItem } from "drizzle-orm/batch";
10
+ import { generateKeyBetween } from "fractional-indexing";
11
+ import { uuidv7 } from "uuidv7";
12
+ import { type Database, batchQueryRows } from "../db/index.js";
9
13
  import {
10
14
  collections,
11
- collectionDividers,
15
+ pathRegistry,
16
+ sidebarItems,
12
17
  postCollections,
13
18
  } from "../db/schema.js";
14
19
  import { now } from "../lib/time.js";
15
20
  import type {
16
21
  Collection,
17
- CollectionDivider,
22
+ SidebarItem,
23
+ SidebarItemType,
18
24
  CreateCollection,
19
25
  UpdateCollection,
20
26
  SortOrder,
21
27
  } from "../types.js";
28
+ import { ConflictError } from "../lib/errors.js";
29
+ import {
30
+ createPathService,
31
+ toCollectionPath,
32
+ type PathService,
33
+ } from "./path.js";
34
+
35
+ const POSITION_RETRY_ATTEMPTS = 5;
36
+
37
+ function isUniqueConstraintError(err: unknown): boolean {
38
+ let current: unknown = err;
39
+ while (current) {
40
+ const msg = String(current);
41
+ if (
42
+ msg.includes("UNIQUE constraint") ||
43
+ msg.includes("SQLITE_CONSTRAINT")
44
+ ) {
45
+ return true;
46
+ }
47
+ current =
48
+ current instanceof Error && current.cause !== current
49
+ ? current.cause
50
+ : undefined;
51
+ }
52
+ return false;
53
+ }
22
54
 
23
55
  export interface CollectionService {
24
- getById(id: number): Promise<Collection | null>;
56
+ getById(id: string): Promise<Collection | null>;
25
57
  getBySlug(slug: string): Promise<Collection | null>;
26
58
  list(): Promise<Collection[]>;
59
+ /** List collections sorted by most recent post addition (for compose dialog) */
60
+ listByRecentActivity(): Promise<Collection[]>;
27
61
  create(data: CreateCollection): Promise<Collection>;
28
- update(id: number, data: UpdateCollection): Promise<Collection | null>;
29
- delete(id: number): Promise<boolean>;
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[]>;
62
+ update(id: string, data: UpdateCollection): Promise<Collection | null>;
63
+ delete(id: string): Promise<boolean>;
64
+ /** List all sidebar items ordered by position */
65
+ listSidebarItems(): Promise<SidebarItem[]>;
66
+ /** Create a sidebar item (collection or divider) */
67
+ createSidebarItem(
68
+ type: SidebarItemType,
69
+ collectionId?: string,
70
+ ): Promise<SidebarItem>;
71
+ /** Delete a sidebar item by ID */
72
+ deleteSidebarItem(id: string): Promise<boolean>;
73
+ /** Move a sidebar item between two neighbors */
74
+ moveSidebarItem(
75
+ id: string,
76
+ after: string | null,
77
+ before: string | null,
78
+ ): Promise<SidebarItem | null>;
39
79
  /** Get post count per collection */
40
- getPostCounts(): Promise<Map<number, number>>;
80
+ getPostCounts(): Promise<Map<string, number>>;
41
81
  /** Add a post to a collection */
42
- addPost(collectionId: number, postId: number): Promise<void>;
82
+ addPost(collectionId: string, postId: string): Promise<void>;
43
83
  /** Remove a post from a collection */
44
- removePost(collectionId: number, postId: number): Promise<void>;
84
+ removePost(collectionId: string, postId: string): Promise<void>;
45
85
  /** Get all collections a post belongs to */
46
- getCollectionsByPostId(postId: number): Promise<Collection[]>;
86
+ getCollectionsByPostId(postId: string): Promise<Collection[]>;
87
+ /** Batch get collections for multiple posts */
88
+ getCollectionsByPostIds(
89
+ postIds: string[],
90
+ ): Promise<Map<string, Collection[]>>;
47
91
  /** Get all post IDs in a collection */
48
- getPostIds(collectionId: number): Promise<number[]>;
92
+ getPostIds(collectionId: string): Promise<string[]>;
49
93
  /** Sync a post's collection memberships (replace all with given IDs) */
50
- syncPostCollections(postId: number, collectionIds: number[]): Promise<void>;
94
+ syncPostCollections(postId: string, collectionIds: string[]): Promise<void>;
51
95
  }
52
96
 
53
- export function createCollectionService(db: Database): CollectionService {
54
- function toCollection(row: typeof collections.$inferSelect): Collection {
97
+ export function createCollectionService(
98
+ db: Database,
99
+ paths: PathService = createPathService(db),
100
+ ): CollectionService {
101
+ function toCollection(
102
+ row: typeof collections.$inferSelect,
103
+ slug: string,
104
+ ): Collection {
55
105
  return {
56
106
  id: row.id,
57
- slug: row.slug,
107
+ slug,
58
108
  title: row.title,
59
109
  description: row.description,
60
110
  icon: row.icon,
61
111
  sortOrder: row.sortOrder as SortOrder,
62
- position: row.position,
63
112
  createdAt: row.createdAt,
64
113
  updatedAt: row.updatedAt,
65
114
  };
66
115
  }
67
116
 
68
- function toDivider(
69
- row: typeof collectionDividers.$inferSelect,
70
- ): CollectionDivider {
117
+ function toSidebarItem(row: typeof sidebarItems.$inferSelect): SidebarItem {
71
118
  return {
72
119
  id: row.id,
120
+ type: row.type as SidebarItemType,
121
+ collectionId: row.collectionId,
73
122
  position: row.position,
74
123
  createdAt: row.createdAt,
75
124
  updatedAt: row.updatedAt,
76
125
  };
77
126
  }
78
127
 
128
+ async function getLastSidebarPosition(): Promise<string | null> {
129
+ const rows = await db
130
+ .select({ position: sidebarItems.position })
131
+ .from(sidebarItems)
132
+ .orderBy(sql`${sidebarItems.position} DESC`)
133
+ .limit(1);
134
+ return rows[0]?.position ?? null;
135
+ }
136
+
137
+ async function listOrderedSidebarPositions(excludeId?: string) {
138
+ const rows = await db
139
+ .select({ id: sidebarItems.id, position: sidebarItems.position })
140
+ .from(sidebarItems)
141
+ .orderBy(asc(sidebarItems.position));
142
+ return excludeId ? rows.filter((row) => row.id !== excludeId) : rows;
143
+ }
144
+
145
+ async function getAppendSidebarPosition(): Promise<string> {
146
+ const lastPos = await getLastSidebarPosition();
147
+ return generateKeyBetween(lastPos, null);
148
+ }
149
+
150
+ async function pathExists(path: string): Promise<boolean> {
151
+ const rows = await db
152
+ .select({ id: pathRegistry.id })
153
+ .from(pathRegistry)
154
+ .where(eq(pathRegistry.path, path))
155
+ .limit(1);
156
+ return rows.length > 0;
157
+ }
158
+
159
+ async function getSidebarMovePosition(
160
+ id: string,
161
+ afterId: string | null,
162
+ beforeId: string | null,
163
+ ): Promise<string> {
164
+ const rows = await listOrderedSidebarPositions(id);
165
+ const afterIndex = afterId
166
+ ? rows.findIndex((row) => row.id === afterId)
167
+ : -1;
168
+ if (afterIndex >= 0) {
169
+ return generateKeyBetween(
170
+ rows[afterIndex]?.position ?? null,
171
+ rows[afterIndex + 1]?.position ?? null,
172
+ );
173
+ }
174
+
175
+ const beforeIndex = beforeId
176
+ ? rows.findIndex((row) => row.id === beforeId)
177
+ : -1;
178
+ if (beforeIndex >= 0) {
179
+ return generateKeyBetween(
180
+ rows[beforeIndex - 1]?.position ?? null,
181
+ rows[beforeIndex]?.position ?? null,
182
+ );
183
+ }
184
+
185
+ return generateKeyBetween(rows.at(-1)?.position ?? null, null);
186
+ }
187
+
188
+ async function hydrateCollection(
189
+ row: typeof collections.$inferSelect | undefined,
190
+ ): Promise<Collection | null> {
191
+ if (!row) return null;
192
+ const slug = await paths.getCollectionSlug(row.id);
193
+ if (!slug) return null;
194
+ return toCollection(row, slug);
195
+ }
196
+
197
+ async function hydrateCollections(
198
+ rows: (typeof collections.$inferSelect)[],
199
+ ): Promise<Collection[]> {
200
+ if (rows.length === 0) return [];
201
+ const slugMap = await paths.getCollectionSlugMap(rows.map((row) => row.id));
202
+ return rows
203
+ .map((row) => {
204
+ const slug = slugMap.get(row.id);
205
+ return slug ? toCollection(row, slug) : null;
206
+ })
207
+ .filter((row): row is Collection => row !== null);
208
+ }
209
+
79
210
  return {
80
211
  async getById(id) {
81
212
  const result = await db
@@ -83,72 +214,136 @@ export function createCollectionService(db: Database): CollectionService {
83
214
  .from(collections)
84
215
  .where(eq(collections.id, id))
85
216
  .limit(1);
86
- return result[0] ? toCollection(result[0]) : null;
217
+ return hydrateCollection(result[0]);
87
218
  },
88
219
 
89
220
  async getBySlug(slug) {
90
- const result = await db
91
- .select()
92
- .from(collections)
93
- .where(eq(collections.slug, slug))
94
- .limit(1);
95
- return result[0] ? toCollection(result[0]) : null;
221
+ const resolved = await paths.resolve(toCollectionPath(slug));
222
+ if (!resolved || resolved.kind !== "slug" || !resolved.collectionId) {
223
+ return null;
224
+ }
225
+ return this.getById(resolved.collectionId);
96
226
  },
97
227
 
98
228
  async list() {
99
229
  const rows = await db
100
230
  .select()
101
231
  .from(collections)
102
- .orderBy(asc(collections.position), desc(collections.createdAt));
103
- return rows.map(toCollection);
232
+ .orderBy(asc(collections.createdAt));
233
+ return hydrateCollections(rows);
234
+ },
235
+
236
+ async listByRecentActivity() {
237
+ const lastAddedAt = sql<
238
+ number | null
239
+ >`MAX(${postCollections.createdAt})`.as("last_added_at");
240
+ const rows = await db
241
+ .select({ collection: collections, lastAddedAt })
242
+ .from(collections)
243
+ .leftJoin(
244
+ postCollections,
245
+ eq(collections.id, postCollections.collectionId),
246
+ )
247
+ .groupBy(collections.id)
248
+ .orderBy(desc(sql`last_added_at`), asc(collections.createdAt));
249
+ return hydrateCollections(rows.map((row) => row.collection));
104
250
  },
105
251
 
106
252
  async create(data) {
253
+ const id = uuidv7();
107
254
  const timestamp = now();
108
-
109
- let position = data.position;
110
- if (position === undefined) {
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
- );
114
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
115
- position = result[0]!.maxPos + 1;
255
+ const slugPath = toCollectionPath(data.slug);
256
+
257
+ for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
258
+ try {
259
+ const position = await getAppendSidebarPosition();
260
+ const writeQueries: BatchItem<"sqlite">[] = [
261
+ db.insert(collections).values({
262
+ id,
263
+ title: data.title,
264
+ description: data.description ?? null,
265
+ icon: data.icon ?? null,
266
+ sortOrder: data.sortOrder ?? "newest",
267
+ createdAt: timestamp,
268
+ updatedAt: timestamp,
269
+ }),
270
+ db.insert(pathRegistry).values({
271
+ id: uuidv7(),
272
+ path: slugPath,
273
+ kind: "slug",
274
+ postId: null,
275
+ collectionId: id,
276
+ redirectToPath: null,
277
+ redirectType: null,
278
+ createdAt: timestamp,
279
+ updatedAt: timestamp,
280
+ }),
281
+ db.insert(sidebarItems).values({
282
+ id: uuidv7(),
283
+ type: "collection",
284
+ collectionId: id,
285
+ position,
286
+ createdAt: timestamp,
287
+ updatedAt: timestamp,
288
+ }),
289
+ ];
290
+
291
+ await db.batch(
292
+ writeQueries as [
293
+ (typeof writeQueries)[number],
294
+ ...(typeof writeQueries)[number][],
295
+ ],
296
+ );
297
+
298
+ const collection = await this.getById(id);
299
+ if (!collection) {
300
+ throw new ConflictError(
301
+ `Slug "${data.slug}" could not be resolved`,
302
+ );
303
+ }
304
+ return collection;
305
+ } catch (err) {
306
+ if (err instanceof ConflictError) {
307
+ throw err;
308
+ }
309
+ if (isUniqueConstraintError(err) && (await pathExists(slugPath))) {
310
+ throw new ConflictError(`Slug "${data.slug}" is already in use`);
311
+ }
312
+ if (attempt === POSITION_RETRY_ATTEMPTS - 1) {
313
+ throw err;
314
+ }
315
+ }
116
316
  }
117
317
 
118
- const result = await db
119
- .insert(collections)
120
- .values({
121
- slug: data.slug,
122
- title: data.title,
123
- description: data.description ?? null,
124
- icon: data.icon ?? null,
125
- sortOrder: data.sortOrder ?? "newest",
126
- position,
127
- createdAt: timestamp,
128
- updatedAt: timestamp,
129
- })
130
- .returning();
131
-
132
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
133
- return toCollection(result[0]!);
318
+ throw new Error("Failed to assign a unique sidebar item position");
134
319
  },
135
320
 
136
321
  async update(id, data) {
137
322
  const existing = await this.getById(id);
138
323
  if (!existing) return null;
139
324
 
325
+ if (data.slug !== undefined && data.slug !== existing.slug) {
326
+ try {
327
+ await paths.updateCollectionSlug(id, data.slug);
328
+ } catch (err) {
329
+ if (err instanceof ConflictError) {
330
+ throw new ConflictError(`Slug "${data.slug}" is already in use`);
331
+ }
332
+ throw err;
333
+ }
334
+ }
335
+
140
336
  const timestamp = now();
141
337
  const updates: Partial<typeof collections.$inferInsert> = {
142
338
  updatedAt: timestamp,
143
339
  };
144
340
 
145
341
  if (data.title !== undefined) updates.title = data.title;
146
- if (data.slug !== undefined) updates.slug = data.slug;
147
- if (data.description !== undefined)
342
+ if (data.description !== undefined) {
148
343
  updates.description = data.description;
344
+ }
149
345
  if (data.icon !== undefined) updates.icon = data.icon;
150
346
  if (data.sortOrder !== undefined) updates.sortOrder = data.sortOrder;
151
- if (data.position !== undefined) updates.position = data.position;
152
347
 
153
348
  const result = await db
154
349
  .update(collections)
@@ -156,11 +351,14 @@ export function createCollectionService(db: Database): CollectionService {
156
351
  .where(eq(collections.id, id))
157
352
  .returning();
158
353
 
159
- return result[0] ? toCollection(result[0]) : null;
354
+ return hydrateCollection(result[0]);
160
355
  },
161
356
 
162
357
  async delete(id) {
163
- // Junction table entries are cleaned up by ON DELETE CASCADE
358
+ await db
359
+ .delete(postCollections)
360
+ .where(eq(postCollections.collectionId, id));
361
+ await db.delete(sidebarItems).where(eq(sidebarItems.collectionId, id));
164
362
  const result = await db
165
363
  .delete(collections)
166
364
  .where(eq(collections.id, id))
@@ -168,69 +366,101 @@ export function createCollectionService(db: Database): CollectionService {
168
366
  return result.length > 0;
169
367
  },
170
368
 
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;
178
- const timestamp = now();
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
189
- .update(collections)
190
- .set({ position: i, updatedAt: timestamp })
191
- .where(eq(collections.id, id));
192
- });
193
- await db.batch(
194
- queries as [(typeof queries)[number], ...(typeof queries)[number][]],
195
- );
369
+ async listSidebarItems() {
370
+ const rows = await db
371
+ .select()
372
+ .from(sidebarItems)
373
+ .orderBy(asc(sidebarItems.position));
374
+ return rows.map(toSidebarItem);
196
375
  },
197
376
 
198
- async createDivider() {
377
+ async createSidebarItem(type, collectionId) {
378
+ const id = uuidv7();
199
379
  const timestamp = now();
200
380
 
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();
381
+ for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
382
+ try {
383
+ const result = await db
384
+ .insert(sidebarItems)
385
+ .values({
386
+ id,
387
+ type,
388
+ collectionId: collectionId ?? null,
389
+ position: await getAppendSidebarPosition(),
390
+ createdAt: timestamp,
391
+ updatedAt: timestamp,
392
+ })
393
+ .returning();
394
+
395
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
396
+ return toSidebarItem(result[0]!);
397
+ } catch (err) {
398
+ if (
399
+ type === "collection" &&
400
+ collectionId &&
401
+ isUniqueConstraintError(err)
402
+ ) {
403
+ const existing = await db
404
+ .select({ id: sidebarItems.id })
405
+ .from(sidebarItems)
406
+ .where(eq(sidebarItems.collectionId, collectionId))
407
+ .limit(1);
408
+ if (existing.length > 0) {
409
+ throw new ConflictError("Collection is already in the sidebar.");
410
+ }
411
+ }
412
+ if (
413
+ !isUniqueConstraintError(err) ||
414
+ attempt === POSITION_RETRY_ATTEMPTS - 1
415
+ ) {
416
+ throw err;
417
+ }
418
+ }
419
+ }
215
420
 
216
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
217
- return toDivider(result[0]!);
421
+ throw new Error("Failed to assign a unique sidebar item position");
218
422
  },
219
423
 
220
- async deleteDivider(id) {
424
+ async deleteSidebarItem(id) {
221
425
  const result = await db
222
- .delete(collectionDividers)
223
- .where(eq(collectionDividers.id, id))
426
+ .delete(sidebarItems)
427
+ .where(eq(sidebarItems.id, id))
224
428
  .returning();
225
429
  return result.length > 0;
226
430
  },
227
431
 
228
- async listDividers() {
229
- const rows = await db
432
+ async moveSidebarItem(id, afterId, beforeId) {
433
+ const items = await db
230
434
  .select()
231
- .from(collectionDividers)
232
- .orderBy(asc(collectionDividers.position));
233
- return rows.map(toDivider);
435
+ .from(sidebarItems)
436
+ .where(eq(sidebarItems.id, id))
437
+ .limit(1);
438
+ if (!items[0]) return null;
439
+
440
+ const timestamp = now();
441
+ for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
442
+ try {
443
+ const result = await db
444
+ .update(sidebarItems)
445
+ .set({
446
+ position: await getSidebarMovePosition(id, afterId, beforeId),
447
+ updatedAt: timestamp,
448
+ })
449
+ .where(eq(sidebarItems.id, id))
450
+ .returning();
451
+
452
+ return result[0] ? toSidebarItem(result[0]) : null;
453
+ } catch (err) {
454
+ if (
455
+ !isUniqueConstraintError(err) ||
456
+ attempt === POSITION_RETRY_ATTEMPTS - 1
457
+ ) {
458
+ throw err;
459
+ }
460
+ }
461
+ }
462
+
463
+ throw new Error("Failed to assign a unique sidebar item position");
234
464
  },
235
465
 
236
466
  async getPostCounts() {
@@ -241,12 +471,12 @@ export function createCollectionService(db: Database): CollectionService {
241
471
  })
242
472
  .from(postCollections)
243
473
  .innerJoin(
244
- sql`posts`,
245
- sql`posts.id = ${postCollections.postId} AND posts.deleted_at IS NULL`,
474
+ sql`post`,
475
+ sql`post.id = ${postCollections.postId} AND post.deleted_at IS NULL`,
246
476
  )
247
477
  .groupBy(postCollections.collectionId);
248
478
 
249
- const counts = new Map<number, number>();
479
+ const counts = new Map<string, number>();
250
480
  for (const row of rows) {
251
481
  counts.set(row.collectionId, row.count);
252
482
  }
@@ -256,7 +486,7 @@ export function createCollectionService(db: Database): CollectionService {
256
486
  async addPost(collectionId, postId) {
257
487
  await db
258
488
  .insert(postCollections)
259
- .values({ postId, collectionId })
489
+ .values({ postId, collectionId, createdAt: now() })
260
490
  .onConflictDoNothing();
261
491
  },
262
492
 
@@ -280,9 +510,44 @@ export function createCollectionService(db: Database): CollectionService {
280
510
  eq(postCollections.collectionId, collections.id),
281
511
  )
282
512
  .where(eq(postCollections.postId, postId))
283
- .orderBy(asc(collections.position));
513
+ .orderBy(asc(collections.createdAt));
284
514
 
285
- return rows.map((r) => toCollection(r.collection));
515
+ return hydrateCollections(rows.map((row) => row.collection));
516
+ },
517
+
518
+ async getCollectionsByPostIds(postIds) {
519
+ const result = new Map<string, Collection[]>();
520
+ if (postIds.length === 0) return result;
521
+
522
+ const rows = await batchQueryRows(postIds, (chunk) =>
523
+ db
524
+ .select({
525
+ postId: postCollections.postId,
526
+ collection: collections,
527
+ })
528
+ .from(postCollections)
529
+ .innerJoin(
530
+ collections,
531
+ eq(postCollections.collectionId, collections.id),
532
+ )
533
+ .where(inArray(postCollections.postId, chunk))
534
+ .orderBy(asc(collections.createdAt)),
535
+ );
536
+
537
+ const collectionRows = rows.map((row) => row.collection);
538
+ const slugMap = await paths.getCollectionSlugMap(
539
+ collectionRows.map((row) => row.id),
540
+ );
541
+
542
+ for (const row of rows) {
543
+ const slug = slugMap.get(row.collection.id);
544
+ if (!slug) continue;
545
+ const existing = result.get(row.postId) ?? [];
546
+ existing.push(toCollection(row.collection, slug));
547
+ result.set(row.postId, existing);
548
+ }
549
+
550
+ return result;
286
551
  },
287
552
 
288
553
  async getPostIds(collectionId) {
@@ -291,26 +556,27 @@ export function createCollectionService(db: Database): CollectionService {
291
556
  .from(postCollections)
292
557
  .where(eq(postCollections.collectionId, collectionId));
293
558
 
294
- return rows.map((r) => r.postId);
559
+ return rows.map((row) => row.postId);
295
560
  },
296
561
 
297
562
  async syncPostCollections(postId, collectionIds) {
298
563
  if (collectionIds.length === 0) {
299
- // Only delete — single statement, no batch needed
300
564
  await db
301
565
  .delete(postCollections)
302
566
  .where(eq(postCollections.postId, postId));
303
567
  return;
304
568
  }
305
- // Delete existing + insert new atomically
569
+
306
570
  const deleteQuery = db
307
571
  .delete(postCollections)
308
572
  .where(eq(postCollections.postId, postId));
309
- const insertQuery = db
310
- .insert(postCollections)
311
- .values(
312
- collectionIds.map((collectionId) => ({ postId, collectionId })),
313
- );
573
+ const insertQuery = db.insert(postCollections).values(
574
+ collectionIds.map((collectionId) => ({
575
+ postId,
576
+ collectionId,
577
+ createdAt: now(),
578
+ })),
579
+ );
314
580
  await db.batch([deleteQuery, insertQuery]);
315
581
  },
316
582
  };