@jant/core 0.3.35 → 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 (307) 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/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -7,8 +7,8 @@
7
7
  import type { Database } from "../db/index.js";
8
8
  import { createSettingsService, type SettingsService } from "./settings.js";
9
9
  import { createPostService, type PostService } from "./post.js";
10
- import { createPageService, type PageService } from "./page.js";
11
- import { createRedirectService, type RedirectService } from "./redirect.js";
10
+ import { createCustomUrlService, type CustomUrlService } from "./custom-url.js";
11
+ import { createPathService, type PathService } from "./path.js";
12
12
  import { createMediaService, type MediaService } from "./media.js";
13
13
  import {
14
14
  createCollectionService,
@@ -17,48 +17,55 @@ import {
17
17
  import { createSearchService, type SearchService } from "./search.js";
18
18
  import { createNavItemService, type NavItemService } from "./navigation.js";
19
19
  import { createAuthService, type AuthService } from "./auth.js";
20
- import {
21
- createPathRegistryService,
22
- type PathRegistryService,
23
- } from "./path-registry.js";
20
+ import { createApiTokenService, type ApiTokenService } from "./api-token.js";
24
21
 
25
22
  export interface Services {
26
23
  settings: SettingsService;
24
+ paths: PathService;
27
25
  posts: PostService;
28
- pages: PageService;
29
- redirects: RedirectService;
26
+ customUrls: CustomUrlService;
30
27
  media: MediaService;
31
28
  collections: CollectionService;
32
29
  search: SearchService;
33
30
  navItems: NavItemService;
34
31
  auth: AuthService;
35
- pathRegistry: PathRegistryService;
32
+ apiTokens: ApiTokenService;
36
33
  }
37
34
 
38
- export function createServices(db: Database, d1: D1Database): Services {
35
+ export function createServices(
36
+ db: Database,
37
+ d1: D1Database,
38
+ config?: { slugIdLength?: number },
39
+ ): Services {
39
40
  const settings = createSettingsService(db);
40
- const pathRegistry = createPathRegistryService(db);
41
+ const paths = createPathService(db);
41
42
  return {
42
43
  settings,
43
- pathRegistry,
44
- posts: createPostService(db, pathRegistry),
45
- pages: createPageService(db, pathRegistry),
46
- redirects: createRedirectService(db, pathRegistry),
44
+ paths,
45
+ posts: createPostService(
46
+ db,
47
+ {
48
+ slugIdLength: config?.slugIdLength ?? 5,
49
+ },
50
+ paths,
51
+ ),
52
+ customUrls: createCustomUrlService(db, paths),
47
53
  media: createMediaService(db),
48
- collections: createCollectionService(db),
54
+ collections: createCollectionService(db, paths),
49
55
  search: createSearchService(d1),
50
56
  navItems: createNavItemService(db),
51
57
  auth: createAuthService(db, settings),
58
+ apiTokens: createApiTokenService(db),
52
59
  };
53
60
  }
54
61
 
55
62
  export type { SettingsService } from "./settings.js";
63
+ export type { PathService } from "./path.js";
56
64
  export type { PostService, PostFilters, PostDeleteDeps } from "./post.js";
57
- export type { PageService, PageFilters } from "./page.js";
58
- export type { RedirectService } from "./redirect.js";
65
+ export type { CustomUrlService } from "./custom-url.js";
59
66
  export type { MediaService, MediaFilters } from "./media.js";
60
67
  export type { CollectionService } from "./collection.js";
61
68
  export type { SearchService, SearchResult, SearchOptions } from "./search.js";
62
69
  export type { NavItemService } from "./navigation.js";
63
70
  export type { AuthService } from "./auth.js";
64
- export type { PathRegistryService, OwnerType } from "./path-registry.js";
71
+ export type { ApiTokenService } from "./api-token.js";
@@ -5,15 +5,19 @@
5
5
  */
6
6
 
7
7
  import { eq, desc, inArray, asc, sql, and } from "drizzle-orm";
8
+ import { generateKeyBetween } from "fractional-indexing";
8
9
  import { uuidv7 } from "uuidv7";
9
10
  import type { Database } from "../db/index.js";
10
11
  import { media } from "../db/schema.js";
11
12
  import { now } from "../lib/time.js";
12
13
  import type { StorageDriver } from "../lib/storage.js";
13
- import type { Media } from "../types.js";
14
+ import { toMediaKind } from "../lib/upload.js";
15
+ import type { Media, MediaKind } from "../types.js";
14
16
  import { MAX_MEDIA_ATTACHMENTS } from "../types.js";
15
17
  import { ValidationError } from "../lib/errors.js";
16
18
 
19
+ const DEFAULT_MEDIA_POSITION = "a0";
20
+
17
21
  export interface MediaFilters {
18
22
  limit?: number;
19
23
  /** Filter by MIME type prefix, e.g. "image/" */
@@ -23,8 +27,8 @@ export interface MediaFilters {
23
27
  export interface MediaService {
24
28
  getById(id: string): Promise<Media | null>;
25
29
  getByIds(ids: string[]): Promise<Media[]>;
26
- getByPostId(postId: number): Promise<Media[]>;
27
- getByPostIds(postIds: number[]): Promise<Map<number, Media[]>>;
30
+ getByPostId(postId: string): Promise<Media[]>;
31
+ getByPostIds(postIds: string[]): Promise<Map<string, Media[]>>;
28
32
  list(filters?: MediaFilters): Promise<Media[]>;
29
33
  create(data: CreateMediaData): Promise<Media>;
30
34
  /**
@@ -50,15 +54,15 @@ export interface MediaService {
50
54
  * @param storage - Optional storage driver; when provided the files are deleted from storage
51
55
  */
52
56
  deleteByIds(ids: string[], storage?: StorageDriver | null): Promise<void>;
53
- getByStorageKey(storageKey: string): Promise<Media | null>;
54
- attachToPost(postId: number, mediaIds: string[]): Promise<void>;
55
- detachFromPost(postId: number): Promise<void>;
57
+ getByStorageKey(storageKey: string, provider: string): Promise<Media | null>;
58
+ attachToPost(postId: string, mediaIds: string[]): Promise<void>;
59
+ detachFromPost(postId: string): Promise<void>;
56
60
  updateAlt(id: string, alt: string): Promise<void>;
57
61
  }
58
62
 
59
63
  export interface CreateMediaData {
60
64
  id?: string;
61
- postId?: number;
65
+ postId?: string;
62
66
  filename: string;
63
67
  originalName: string;
64
68
  mimeType: string;
@@ -68,11 +72,38 @@ export interface CreateMediaData {
68
72
  width?: number;
69
73
  height?: number;
70
74
  alt?: string;
71
- position?: number;
75
+ position?: string;
72
76
  blurhash?: string;
77
+ waveform?: string;
78
+ posterKey?: string;
79
+ summary?: string;
80
+ chars?: number;
81
+ mediaKind?: MediaKind;
73
82
  }
74
83
 
75
84
  export function createMediaService(db: Database): MediaService {
85
+ async function getLastPosition(postId: string): Promise<string | null> {
86
+ const rows = await db
87
+ .select({ position: media.position })
88
+ .from(media)
89
+ .where(eq(media.postId, postId))
90
+ .orderBy(sql`${media.position} DESC`)
91
+ .limit(1);
92
+ return rows[0]?.position ?? null;
93
+ }
94
+
95
+ function buildSequentialPositions(count: number): string[] {
96
+ const positions: string[] = [];
97
+ let previous: string | null = null;
98
+
99
+ for (let i = 0; i < count; i += 1) {
100
+ previous = generateKeyBetween(previous, null);
101
+ positions.push(previous);
102
+ }
103
+
104
+ return positions;
105
+ }
106
+
76
107
  function toMedia(row: typeof media.$inferSelect): Media {
77
108
  return {
78
109
  id: row.id,
@@ -88,7 +119,13 @@ export function createMediaService(db: Database): MediaService {
88
119
  alt: row.alt,
89
120
  position: row.position,
90
121
  blurhash: row.blurhash,
122
+ waveform: row.waveform,
123
+ posterKey: row.posterKey,
124
+ summary: row.summary,
125
+ chars: row.chars,
126
+ mediaKind: row.mediaKind as MediaKind,
91
127
  createdAt: row.createdAt,
128
+ updatedAt: row.updatedAt,
92
129
  };
93
130
  }
94
131
 
@@ -118,7 +155,7 @@ export function createMediaService(db: Database): MediaService {
118
155
  },
119
156
 
120
157
  async getByPostIds(postIds) {
121
- const result = new Map<number, Media[]>();
158
+ const result = new Map<string, Media[]>();
122
159
  if (postIds.length === 0) return result;
123
160
 
124
161
  const rows = await db
@@ -141,11 +178,13 @@ export function createMediaService(db: Database): MediaService {
141
178
  return result;
142
179
  },
143
180
 
144
- async getByStorageKey(storageKey) {
181
+ async getByStorageKey(storageKey, provider) {
145
182
  const result = await db
146
183
  .select()
147
184
  .from(media)
148
- .where(eq(media.storageKey, storageKey))
185
+ .where(
186
+ and(eq(media.storageKey, storageKey), eq(media.provider, provider)),
187
+ )
149
188
  .limit(1);
150
189
  return result[0] ? toMedia(result[0]) : null;
151
190
  },
@@ -185,6 +224,11 @@ export function createMediaService(db: Database): MediaService {
185
224
  async create(data) {
186
225
  const id = data.id ?? uuidv7();
187
226
  const timestamp = now();
227
+ const mediaKind = data.mediaKind ?? toMediaKind(data.mimeType);
228
+ const lastPosition =
229
+ data.position === undefined && data.postId
230
+ ? await getLastPosition(data.postId)
231
+ : null;
188
232
 
189
233
  const result = await db
190
234
  .insert(media)
@@ -200,9 +244,19 @@ export function createMediaService(db: Database): MediaService {
200
244
  width: data.width ?? null,
201
245
  height: data.height ?? null,
202
246
  alt: data.alt ?? null,
203
- position: data.position ?? 0,
247
+ position:
248
+ data.position ??
249
+ (data.postId
250
+ ? generateKeyBetween(lastPosition, null)
251
+ : DEFAULT_MEDIA_POSITION),
204
252
  blurhash: data.blurhash ?? null,
253
+ waveform: data.waveform ?? null,
254
+ posterKey: data.posterKey ?? null,
255
+ summary: data.summary ?? null,
256
+ chars: data.chars ?? null,
257
+ mediaKind,
205
258
  createdAt: timestamp,
259
+ updatedAt: timestamp,
206
260
  })
207
261
  .returning();
208
262
 
@@ -211,9 +265,14 @@ export function createMediaService(db: Database): MediaService {
211
265
  },
212
266
 
213
267
  async attachToPost(postId, mediaIds) {
268
+ const timestamp = now();
214
269
  const clearQuery = db
215
270
  .update(media)
216
- .set({ postId: null, position: 0 })
271
+ .set({
272
+ postId: null,
273
+ position: DEFAULT_MEDIA_POSITION,
274
+ updatedAt: timestamp,
275
+ })
217
276
  .where(eq(media.postId, postId));
218
277
 
219
278
  const validIds = mediaIds.filter((id): id is string => Boolean(id));
@@ -223,13 +282,20 @@ export function createMediaService(db: Database): MediaService {
223
282
  return;
224
283
  }
225
284
 
285
+ const positions = buildSequentialPositions(validIds.length);
286
+
226
287
  // Clear existing + re-attach atomically
227
- const attachQueries = validIds.map((mediaId, i) =>
228
- db
288
+ const attachQueries = validIds.map((mediaId, i) => {
289
+ const position = positions[i];
290
+ if (!position) {
291
+ throw new Error("Failed to assign a media position");
292
+ }
293
+
294
+ return db
229
295
  .update(media)
230
- .set({ postId, position: i })
231
- .where(eq(media.id, mediaId)),
232
- );
296
+ .set({ postId, position, updatedAt: timestamp })
297
+ .where(eq(media.id, mediaId));
298
+ });
233
299
  await db.batch([clearQuery, ...attachQueries] as [
234
300
  typeof clearQuery,
235
301
  ...(typeof attachQueries)[number][],
@@ -239,12 +305,15 @@ export function createMediaService(db: Database): MediaService {
239
305
  async detachFromPost(postId) {
240
306
  await db
241
307
  .update(media)
242
- .set({ postId: null, position: 0 })
308
+ .set({ postId: null, position: DEFAULT_MEDIA_POSITION })
243
309
  .where(eq(media.postId, postId));
244
310
  },
245
311
 
246
312
  async updateAlt(id, alt) {
247
- await db.update(media).set({ alt }).where(eq(media.id, id));
313
+ await db
314
+ .update(media)
315
+ .set({ alt, updatedAt: now() })
316
+ .where(eq(media.id, id));
248
317
  },
249
318
 
250
319
  async delete(id, storage) {
@@ -256,6 +325,12 @@ export function createMediaService(db: Database): MediaService {
256
325
  // eslint-disable-next-line no-console -- Error logging is intentional
257
326
  console.error("Storage delete error:", err);
258
327
  });
328
+ if (record.posterKey) {
329
+ await storage.delete(record.posterKey).catch((err) => {
330
+ // eslint-disable-next-line no-console -- Error logging is intentional
331
+ console.error("Storage delete poster error:", err);
332
+ });
333
+ }
259
334
  }
260
335
 
261
336
  await db.delete(media).where(eq(media.id, id));
@@ -267,9 +342,12 @@ export function createMediaService(db: Database): MediaService {
267
342
 
268
343
  if (storage) {
269
344
  const records = await this.getByIds(ids);
345
+ const keys = records.flatMap((r) =>
346
+ r.posterKey ? [r.storageKey, r.posterKey] : [r.storageKey],
347
+ );
270
348
  await Promise.all(
271
- records.map((r) =>
272
- storage.delete(r.storageKey).catch((err) => {
349
+ keys.map((key) =>
350
+ storage.delete(key).catch((err) => {
273
351
  // eslint-disable-next-line no-console -- Error logging is intentional
274
352
  console.error("Storage delete error:", err);
275
353
  }),
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Nav Item Service (v2)
3
3
  *
4
- * Manages navigation items (page links and external links)
4
+ * Manages navigation items (external links and system links)
5
+ * with fractional indexing for efficient reordering.
5
6
  */
6
7
 
7
8
  import { eq, asc, sql } from "drizzle-orm";
9
+ import { generateKeyBetween } from "fractional-indexing";
10
+ import { uuidv7 } from "uuidv7";
8
11
  import type { Database } from "../db/index.js";
9
12
  import { navItems } from "../db/schema.js";
10
13
  import { now } from "../lib/time.js";
@@ -15,14 +18,37 @@ import type {
15
18
  UpdateNavItem,
16
19
  } from "../types.js";
17
20
 
21
+ const POSITION_RETRY_ATTEMPTS = 5;
22
+
23
+ function isUniqueConstraintError(err: unknown): boolean {
24
+ let current: unknown = err;
25
+ while (current) {
26
+ const msg = String(current);
27
+ if (
28
+ msg.includes("UNIQUE constraint") ||
29
+ msg.includes("SQLITE_CONSTRAINT")
30
+ ) {
31
+ return true;
32
+ }
33
+ current =
34
+ current instanceof Error && current.cause !== current
35
+ ? current.cause
36
+ : undefined;
37
+ }
38
+ return false;
39
+ }
40
+
18
41
  export interface NavItemService {
19
42
  list(): Promise<NavItem[]>;
20
- getById(id: number): Promise<NavItem | null>;
43
+ getById(id: string): Promise<NavItem | null>;
21
44
  create(data: CreateNavItem): Promise<NavItem>;
22
- update(id: number, data: UpdateNavItem): Promise<NavItem | null>;
23
- delete(id: number): Promise<boolean>;
24
- deleteByPageId(pageId: number): Promise<boolean>;
25
- reorder(ids: number[]): Promise<void>;
45
+ update(id: string, data: UpdateNavItem): Promise<NavItem | null>;
46
+ delete(id: string): Promise<boolean>;
47
+ move(
48
+ id: string,
49
+ afterId: string | null,
50
+ beforeId: string | null,
51
+ ): Promise<NavItem | null>;
26
52
  }
27
53
 
28
54
  export function createNavItemService(db: Database): NavItemService {
@@ -32,13 +58,63 @@ export function createNavItemService(db: Database): NavItemService {
32
58
  type: row.type as NavItemType,
33
59
  label: row.label,
34
60
  url: row.url,
35
- pageId: row.pageId,
36
61
  position: row.position,
37
62
  createdAt: row.createdAt,
38
63
  updatedAt: row.updatedAt,
39
64
  };
40
65
  }
41
66
 
67
+ async function getLastPosition(): Promise<string | null> {
68
+ const rows = await db
69
+ .select({ position: navItems.position })
70
+ .from(navItems)
71
+ .orderBy(sql`${navItems.position} DESC`)
72
+ .limit(1);
73
+ return rows[0]?.position ?? null;
74
+ }
75
+
76
+ async function listOrderedPositions(excludeId?: string) {
77
+ const rows = await db
78
+ .select({ id: navItems.id, position: navItems.position })
79
+ .from(navItems)
80
+ .orderBy(asc(navItems.position));
81
+ return excludeId ? rows.filter((row) => row.id !== excludeId) : rows;
82
+ }
83
+
84
+ async function getAppendPosition(): Promise<string> {
85
+ const lastPos = await getLastPosition();
86
+ return generateKeyBetween(lastPos, null);
87
+ }
88
+
89
+ async function getMovePosition(
90
+ id: string,
91
+ afterId: string | null,
92
+ beforeId: string | null,
93
+ ): Promise<string> {
94
+ const rows = await listOrderedPositions(id);
95
+ const afterIndex = afterId
96
+ ? rows.findIndex((row) => row.id === afterId)
97
+ : -1;
98
+ if (afterIndex >= 0) {
99
+ return generateKeyBetween(
100
+ rows[afterIndex]?.position ?? null,
101
+ rows[afterIndex + 1]?.position ?? null,
102
+ );
103
+ }
104
+
105
+ const beforeIndex = beforeId
106
+ ? rows.findIndex((row) => row.id === beforeId)
107
+ : -1;
108
+ if (beforeIndex >= 0) {
109
+ return generateKeyBetween(
110
+ rows[beforeIndex - 1]?.position ?? null,
111
+ rows[beforeIndex]?.position ?? null,
112
+ );
113
+ }
114
+
115
+ return generateKeyBetween(rows.at(-1)?.position ?? null, null);
116
+ }
117
+
42
118
  return {
43
119
  async list() {
44
120
  const rows = await db
@@ -58,32 +134,55 @@ export function createNavItemService(db: Database): NavItemService {
58
134
  },
59
135
 
60
136
  async create(data) {
137
+ const id = uuidv7();
61
138
  const timestamp = now();
62
139
 
63
- let position = data.position;
64
- if (position === undefined) {
65
- const maxResult = await db
66
- .select({ maxPos: sql<number>`COALESCE(MAX(position), -1)` })
67
- .from(navItems);
68
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
69
- position = maxResult[0]!.maxPos + 1;
140
+ if (data.position !== undefined) {
141
+ const result = await db
142
+ .insert(navItems)
143
+ .values({
144
+ id,
145
+ type: data.type,
146
+ label: data.label,
147
+ url: data.url,
148
+ position: data.position,
149
+ createdAt: timestamp,
150
+ updatedAt: timestamp,
151
+ })
152
+ .returning();
153
+
154
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
155
+ return toNavItem(result[0]!);
70
156
  }
71
157
 
72
- const result = await db
73
- .insert(navItems)
74
- .values({
75
- type: data.type,
76
- label: data.label,
77
- url: data.url,
78
- pageId: data.pageId ?? null,
79
- position,
80
- createdAt: timestamp,
81
- updatedAt: timestamp,
82
- })
83
- .returning();
158
+ for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
159
+ try {
160
+ const result = await db
161
+ .insert(navItems)
162
+ .values({
163
+ id,
164
+ type: data.type,
165
+ label: data.label,
166
+ url: data.url,
167
+ position: await getAppendPosition(),
168
+ createdAt: timestamp,
169
+ updatedAt: timestamp,
170
+ })
171
+ .returning();
84
172
 
85
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
86
- return toNavItem(result[0]!);
173
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
174
+ return toNavItem(result[0]!);
175
+ } catch (err) {
176
+ if (
177
+ !isUniqueConstraintError(err) ||
178
+ attempt === POSITION_RETRY_ATTEMPTS - 1
179
+ ) {
180
+ throw err;
181
+ }
182
+ }
183
+ }
184
+
185
+ throw new Error("Failed to assign a unique nav item position");
87
186
  },
88
187
 
89
188
  async update(id, data) {
@@ -101,7 +200,6 @@ export function createNavItemService(db: Database): NavItemService {
101
200
  ...(data.type !== undefined && { type: data.type }),
102
201
  ...(data.label !== undefined && { label: data.label }),
103
202
  ...(data.url !== undefined && { url: data.url }),
104
- ...(data.pageId !== undefined && { pageId: data.pageId }),
105
203
  ...(data.position !== undefined && { position: data.position }),
106
204
  updatedAt: timestamp,
107
205
  })
@@ -119,26 +217,39 @@ export function createNavItemService(db: Database): NavItemService {
119
217
  return result.length > 0;
120
218
  },
121
219
 
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
- },
220
+ async move(id, afterId, beforeId) {
221
+ // Look up the item
222
+ const items = await db
223
+ .select()
224
+ .from(navItems)
225
+ .where(eq(navItems.id, id))
226
+ .limit(1);
227
+ if (!items[0]) return null;
129
228
 
130
- async reorder(ids) {
131
- if (ids.length === 0) return;
132
229
  const timestamp = now();
133
- const queries = ids.map((id, i) =>
134
- db
135
- .update(navItems)
136
- .set({ position: i, updatedAt: timestamp })
137
- .where(eq(navItems.id, id)),
138
- );
139
- await db.batch(
140
- queries as [(typeof queries)[number], ...(typeof queries)[number][]],
141
- );
230
+ for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
231
+ try {
232
+ const result = await db
233
+ .update(navItems)
234
+ .set({
235
+ position: await getMovePosition(id, afterId, beforeId),
236
+ updatedAt: timestamp,
237
+ })
238
+ .where(eq(navItems.id, id))
239
+ .returning();
240
+
241
+ return result[0] ? toNavItem(result[0]) : null;
242
+ } catch (err) {
243
+ if (
244
+ !isUniqueConstraintError(err) ||
245
+ attempt === POSITION_RETRY_ATTEMPTS - 1
246
+ ) {
247
+ throw err;
248
+ }
249
+ }
250
+ }
251
+
252
+ throw new Error("Failed to assign a unique nav item position");
142
253
  },
143
254
  };
144
255
  }