@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
@@ -6,6 +6,18 @@
6
6
 
7
7
  import type { Bindings } from "../types.js";
8
8
 
9
+ /** Tracks an in-progress multipart upload */
10
+ export interface MultipartUploadSession {
11
+ uploadId: string;
12
+ key: string;
13
+ }
14
+
15
+ /** Represents a successfully uploaded part */
16
+ export interface UploadedPart {
17
+ partNumber: number;
18
+ etag: string;
19
+ }
20
+
9
21
  /**
10
22
  * Common interface for storage operations.
11
23
  *
@@ -20,13 +32,68 @@ export interface StorageDriver {
20
32
  opts?: { contentType?: string },
21
33
  ): Promise<void>;
22
34
 
23
- /** Retrieve a file from storage. Returns null if not found. */
35
+ /** Retrieve a file (or byte range) from storage. Returns null if not found. */
24
36
  get(
25
37
  key: string,
26
- ): Promise<{ body: ReadableStream; contentType?: string } | null>;
38
+ opts?: { range?: { offset: number; length: number } },
39
+ ): Promise<{
40
+ body: ReadableStream;
41
+ contentType?: string;
42
+ size?: number;
43
+ } | null>;
27
44
 
28
45
  /** Delete a file from storage */
29
46
  delete(key: string): Promise<void>;
47
+
48
+ /** Start a multipart upload (optional — R2 only) */
49
+ createMultipartUpload?(
50
+ key: string,
51
+ opts?: { contentType?: string },
52
+ ): Promise<MultipartUploadSession>;
53
+
54
+ /** Upload a single part of a multipart upload */
55
+ uploadPart?(
56
+ key: string,
57
+ uploadId: string,
58
+ partNumber: number,
59
+ body: ReadableStream | ArrayBuffer | Uint8Array,
60
+ ): Promise<UploadedPart>;
61
+
62
+ /** Finalize a multipart upload by combining all parts */
63
+ completeMultipartUpload?(
64
+ key: string,
65
+ uploadId: string,
66
+ parts: UploadedPart[],
67
+ ): Promise<void>;
68
+
69
+ /** Cancel a multipart upload and discard uploaded parts */
70
+ abortMultipartUpload?(key: string, uploadId: string): Promise<void>;
71
+ }
72
+
73
+ /**
74
+ * Type guard that checks whether a storage driver supports multipart uploads.
75
+ *
76
+ * @param driver - The storage driver to check
77
+ * @returns true if all multipart methods are present
78
+ */
79
+ export function supportsMultipart(
80
+ driver: StorageDriver,
81
+ ): driver is StorageDriver &
82
+ Required<
83
+ Pick<
84
+ StorageDriver,
85
+ | "createMultipartUpload"
86
+ | "uploadPart"
87
+ | "completeMultipartUpload"
88
+ | "abortMultipartUpload"
89
+ >
90
+ > {
91
+ return (
92
+ typeof driver.createMultipartUpload === "function" &&
93
+ typeof driver.uploadPart === "function" &&
94
+ typeof driver.completeMultipartUpload === "function" &&
95
+ typeof driver.abortMultipartUpload === "function"
96
+ );
30
97
  }
31
98
 
32
99
  /**
@@ -45,18 +112,47 @@ export function createR2Driver(r2: R2Bucket): StorageDriver {
45
112
  });
46
113
  },
47
114
 
48
- async get(key) {
49
- const object = await r2.get(key);
115
+ async get(key, opts) {
116
+ const object = await r2.get(
117
+ key,
118
+ opts?.range ? { range: opts.range } : undefined,
119
+ );
50
120
  if (!object) return null;
51
121
  return {
52
122
  body: object.body,
53
123
  contentType: object.httpMetadata?.contentType ?? undefined,
124
+ size: object.size,
54
125
  };
55
126
  },
56
127
 
57
128
  async delete(key) {
58
129
  await r2.delete(key);
59
130
  },
131
+
132
+ async createMultipartUpload(key, opts) {
133
+ const upload = await r2.createMultipartUpload(key, {
134
+ httpMetadata: opts?.contentType
135
+ ? { contentType: opts.contentType }
136
+ : undefined,
137
+ });
138
+ return { uploadId: upload.uploadId, key: upload.key };
139
+ },
140
+
141
+ async uploadPart(key, uploadId, partNumber, body) {
142
+ const upload = r2.resumeMultipartUpload(key, uploadId);
143
+ const part = await upload.uploadPart(partNumber, body);
144
+ return { partNumber: part.partNumber, etag: part.etag };
145
+ },
146
+
147
+ async completeMultipartUpload(key, uploadId, parts) {
148
+ const upload = r2.resumeMultipartUpload(key, uploadId);
149
+ await upload.complete(parts);
150
+ },
151
+
152
+ async abortMultipartUpload(key, uploadId) {
153
+ const upload = r2.resumeMultipartUpload(key, uploadId);
154
+ await upload.abort();
155
+ },
60
156
  };
61
157
  }
62
158
 
@@ -84,7 +180,14 @@ interface PutObjectInput {
84
180
  ContentType?: string;
85
181
  }
86
182
 
87
- /** Input for GetObject / DeleteObject */
183
+ /** Input for GetObject */
184
+ interface GetObjectInput {
185
+ Bucket: string;
186
+ Key: string;
187
+ Range?: string;
188
+ }
189
+
190
+ /** Input for DeleteObject */
88
191
  interface ObjectKeyInput {
89
192
  Bucket: string;
90
193
  Key: string;
@@ -94,13 +197,14 @@ interface ObjectKeyInput {
94
197
  interface S3GetObjectOutput {
95
198
  Body?: { transformToWebStream(): ReadableStream };
96
199
  ContentType?: string;
200
+ ContentLength?: number;
97
201
  }
98
202
 
99
203
  /** Lazy-loaded S3 client bundle */
100
204
  interface S3ClientBundle {
101
205
  send: (command: unknown) => Promise<unknown>;
102
206
  PutObjectCommand: S3CommandCtor<PutObjectInput>;
103
- GetObjectCommand: S3CommandCtor<ObjectKeyInput>;
207
+ GetObjectCommand: S3CommandCtor<GetObjectInput>;
104
208
  DeleteObjectCommand: S3CommandCtor<ObjectKeyInput>;
105
209
  bucket: string;
106
210
  }
@@ -178,18 +282,22 @@ export function createS3Driver(config: S3DriverConfig): StorageDriver {
178
282
  await s3.send(command);
179
283
  },
180
284
 
181
- async get(key) {
285
+ async get(key, opts) {
182
286
  const s3 = await getClient();
183
287
  try {
184
288
  const command = new s3.GetObjectCommand({
185
289
  Bucket: s3.bucket,
186
290
  Key: key,
291
+ Range: opts?.range
292
+ ? `bytes=${opts.range.offset}-${opts.range.offset + opts.range.length - 1}`
293
+ : undefined,
187
294
  });
188
295
  const response = (await s3.send(command)) as S3GetObjectOutput;
189
296
  if (!response.Body) return null;
190
297
  return {
191
298
  body: response.Body.transformToWebStream(),
192
299
  contentType: response.ContentType ?? undefined,
300
+ size: response.ContentLength ?? undefined,
193
301
  };
194
302
  } catch (err: unknown) {
195
303
  // NoSuchKey → return null instead of throwing
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Summary Extraction from Tiptap JSON
3
+ *
4
+ * Extracts a plain-text summary from a Tiptap JSON document for use
5
+ * in feeds, meta descriptions, and article previews.
6
+ */
7
+
8
+ interface TiptapNode {
9
+ type: string;
10
+ content?: TiptapNode[];
11
+ text?: string;
12
+ marks?: unknown[];
13
+ attrs?: Record<string, unknown>;
14
+ }
15
+
16
+ /**
17
+ * Recursively extracts plain text from a Tiptap node, ignoring marks.
18
+ */
19
+ function extractPlainText(node: TiptapNode): string {
20
+ if (node.type === "text") return node.text ?? "";
21
+ if (node.type === "hardBreak") return "\n";
22
+ if (!node.content) return "";
23
+ return node.content.map(extractPlainText).join("");
24
+ }
25
+
26
+ /**
27
+ * Extracts a plain-text summary from a Tiptap JSON body string.
28
+ *
29
+ * Algorithm:
30
+ * 1. If a `moreBreak` node is found, collect all paragraph text before it
31
+ * 2. Otherwise, accumulate paragraph nodes until limits are reached
32
+ * 3. Skip headings, images, code blocks, blockquotes, lists, horizontal rules
33
+ *
34
+ * @param bodyJson - Tiptap JSON string
35
+ * @param maxParagraphs - Maximum number of paragraphs to include
36
+ * @param maxChars - Maximum total character count
37
+ * @returns Plain text summary, or null if no paragraphs found
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * const summary = extractSummary(body, 5, 500);
42
+ * ```
43
+ */
44
+ /**
45
+ * Content-bearing TipTap node types whose text should be indexed for search.
46
+ * Block-level containers (bulletList, orderedList, table, etc.) are included
47
+ * because they recurse into child nodes that carry text.
48
+ */
49
+ const SEARCHABLE_TYPES = new Set([
50
+ "doc",
51
+ "paragraph",
52
+ "heading",
53
+ "codeBlock",
54
+ "bulletList",
55
+ "orderedList",
56
+ "listItem",
57
+ "blockquote",
58
+ "table",
59
+ "tableRow",
60
+ "tableCell",
61
+ "tableHeader",
62
+ "text",
63
+ "hardBreak",
64
+ ]);
65
+
66
+ /**
67
+ * Recursively extracts all searchable plain text from a TipTap JSON body string.
68
+ *
69
+ * Used for FTS indexing — includes text from paragraphs, headings, code blocks,
70
+ * lists, blockquotes, and tables. Skips non-textual nodes (image, moreBreak,
71
+ * horizontalRule). Block-level nodes are joined with spaces for better trigram
72
+ * matching.
73
+ *
74
+ * @param bodyJson - TipTap JSON string (the `body` column)
75
+ * @returns Plain text for FTS indexing, or null if parsing fails or doc is empty
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * const text = extractBodyText(body);
80
+ * // "Hello world Some code here"
81
+ * ```
82
+ */
83
+ export function extractBodyText(bodyJson: string): string | null {
84
+ let doc: TiptapNode;
85
+ try {
86
+ doc = JSON.parse(bodyJson) as TiptapNode;
87
+ } catch {
88
+ return null;
89
+ }
90
+
91
+ if (doc.type !== "doc" || !doc.content) return null;
92
+
93
+ function collectText(node: TiptapNode): string {
94
+ if (!SEARCHABLE_TYPES.has(node.type)) return "";
95
+ if (node.type === "text") return node.text ?? "";
96
+ if (node.type === "hardBreak") return " ";
97
+ if (!node.content) return "";
98
+ return node.content.map(collectText).join(" ");
99
+ }
100
+
101
+ const parts: string[] = [];
102
+ for (const child of doc.content) {
103
+ const text = collectText(child).trim();
104
+ if (text) parts.push(text);
105
+ }
106
+
107
+ return parts.length > 0 ? parts.join(" ") : null;
108
+ }
109
+
110
+ export function extractSummary(
111
+ bodyJson: string,
112
+ maxParagraphs: number,
113
+ maxChars: number,
114
+ ): string | null {
115
+ let doc: TiptapNode;
116
+ try {
117
+ doc = JSON.parse(bodyJson) as TiptapNode;
118
+ } catch {
119
+ return null;
120
+ }
121
+
122
+ if (doc.type !== "doc" || !doc.content) return null;
123
+
124
+ const nodes = doc.content;
125
+
126
+ // Check for moreBreak — collect paragraph text before it
127
+ const moreBreakIdx = nodes.findIndex((n) => n.type === "moreBreak");
128
+ if (moreBreakIdx !== -1) {
129
+ const paragraphs: string[] = [];
130
+ for (let i = 0; i < moreBreakIdx; i++) {
131
+ const node = nodes[i];
132
+ if (!node) continue;
133
+ if (node.type === "paragraph") {
134
+ const text = extractPlainText(node).trim();
135
+ if (text) paragraphs.push(text);
136
+ }
137
+ }
138
+ return paragraphs.length > 0 ? paragraphs.join("\n\n") : null;
139
+ }
140
+
141
+ // No moreBreak — accumulate paragraphs up to limits
142
+ const paragraphs: string[] = [];
143
+ let totalChars = 0;
144
+
145
+ for (const node of nodes) {
146
+ if (node.type !== "paragraph") continue;
147
+
148
+ const text = extractPlainText(node).trim();
149
+ if (!text) continue;
150
+
151
+ if (paragraphs.length >= maxParagraphs || totalChars >= maxChars) break;
152
+
153
+ paragraphs.push(text);
154
+ totalChars += text.length;
155
+ }
156
+
157
+ return paragraphs.length > 0 ? paragraphs.join("\n\n") : null;
158
+ }
package/src/lib/theme.ts CHANGED
@@ -33,15 +33,16 @@ export function getAvailableThemes(): ColorTheme[] {
33
33
  * @param cssVariables - Extra CSS variable overrides
34
34
  * @returns CSS string to inject in `<head>`, or empty string if nothing to inject
35
35
  *
36
- * Uses `:root:root` and `:root.dark` selectors for higher specificity than
37
- * BaseCoat defaults (`:root` and `.dark`). This ensures theme overrides win
38
- * regardless of source order important because Vite dev mode injects CSS
39
- * as `<style>` tags after the theme `<style>`.
36
+ * Uses `:root:root` for light mode and `@media (prefers-color-scheme: dark)`
37
+ * with `:root:root` for dark mode, giving higher specificity than BaseCoat
38
+ * defaults (`:root`). This ensures theme overrides win regardless of source
39
+ * order important because Vite dev mode injects CSS as `<style>` tags
40
+ * after the theme `<style>`.
40
41
  *
41
42
  * @example
42
43
  * ```typescript
43
44
  * const css = buildThemeStyle(blueTheme, { "--radius": "0.5rem" });
44
- * // => ":root:root { --primary: oklch(...); ... }\n:root.dark { ... }"
45
+ * // => ":root:root { ... }\n@media (prefers-color-scheme: dark) { :root:root { ... } }"
45
46
  * ```
46
47
  */
47
48
  export function buildThemeStyle(
@@ -74,10 +75,12 @@ export function buildThemeStyle(
74
75
 
75
76
  if (hasDark) {
76
77
  const declarations = Object.entries(darkVars)
77
- .map(([k, v]) => ` ${k}: ${v};`)
78
+ .map(([k, v]) => ` ${k}: ${v};`)
78
79
  .join("\n");
79
- // :root.dark has specificity (0,1,1) > BaseCoat's .dark (0,1,0)
80
- parts.push(`:root.dark {\n${declarations}\n}`);
80
+ // :root:root inside @media has specificity (0,0,2) > preset fallback :root (0,0,1)
81
+ parts.push(
82
+ `@media (prefers-color-scheme: dark) {\n :root:root {\n${declarations}\n }\n}`,
83
+ );
81
84
  }
82
85
 
83
86
  return parts.join("\n");
@@ -9,7 +9,7 @@ import type { Context } from "hono";
9
9
  import type { Bindings, TimelineItemView } from "../types.js";
10
10
  import type { AppVariables } from "../types/app-context.js";
11
11
  import { buildMediaMap } from "./media-helpers.js";
12
- import { createMediaContext, toPostView, toPostViews } from "./view.js";
12
+ import { createMediaContext, toPostView } from "./view.js";
13
13
 
14
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
15
 
@@ -40,17 +40,21 @@ export interface TimelineResult {
40
40
  */
41
41
  export async function assembleTimeline(
42
42
  c: Context<Env>,
43
- options?: { page?: number },
43
+ options?: { page?: number; isAuthenticated?: boolean },
44
44
  ): Promise<TimelineResult> {
45
45
  const pageSize = c.var.appConfig.pageSize;
46
46
 
47
47
  const page = Math.max(1, options?.page ?? 1);
48
48
  const offset = (page - 1) * pageSize;
49
49
 
50
+ const excludePrivate = !(options?.isAuthenticated ?? false);
51
+
50
52
  // Get total count for pagination
51
53
  const totalCount = await c.var.services.posts.count({
52
54
  status: "published",
53
55
  excludeReplies: true,
56
+ excludeUnlisted: true,
57
+ excludePrivate,
54
58
  });
55
59
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
56
60
 
@@ -58,6 +62,8 @@ export async function assembleTimeline(
58
62
  const posts = await c.var.services.posts.list({
59
63
  status: "published",
60
64
  excludeReplies: true,
65
+ excludeUnlisted: true,
66
+ excludePrivate,
61
67
  limit: pageSize,
62
68
  offset,
63
69
  });
@@ -66,7 +72,7 @@ export async function assembleTimeline(
66
72
  return { items: [], currentPage: page, totalPages };
67
73
  }
68
74
 
69
- // Batch load media attachments
75
+ // Batch load media
70
76
  const postIds = posts.map((p) => p.id);
71
77
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
72
78
  const mediaCtx = createMediaContext(c.var.appConfig);
@@ -77,55 +83,91 @@ export async function assembleTimeline(
77
83
  mediaCtx.s3PublicUrl,
78
84
  );
79
85
 
86
+ // Batch load collections for main posts
87
+ const collectionsMap =
88
+ await c.var.services.collections.getCollectionsByPostIds(postIds);
89
+
80
90
  // Get reply counts to identify thread roots
81
91
  const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
82
92
  const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
83
93
 
84
- // Batch load thread previews
85
- const threadPreviews = await c.var.services.posts.getThreadPreviews(
86
- threadRootIds,
87
- 3,
88
- );
94
+ // Batch load thread timeline context (latest reply + parent)
95
+ const threadContexts =
96
+ await c.var.services.posts.getThreadTimelineContext(threadRootIds);
89
97
 
90
- // Batch load media for preview replies
91
- const previewReplyIds: number[] = [];
92
- for (const replies of threadPreviews.values()) {
93
- for (const reply of replies) {
94
- previewReplyIds.push(reply.id);
98
+ // Batch load media for context posts (latestReply + parentReply)
99
+ const contextPostIds: string[] = [];
100
+ for (const ctx of threadContexts.values()) {
101
+ contextPostIds.push(ctx.latestReply.id);
102
+ if (ctx.parentReply) {
103
+ contextPostIds.push(ctx.parentReply.id);
95
104
  }
96
105
  }
97
- const previewMediaMap =
98
- previewReplyIds.length > 0
99
- ? buildMediaMap(
100
- await c.var.services.media.getByPostIds(previewReplyIds),
101
- mediaCtx.r2PublicUrl,
102
- mediaCtx.imageTransformUrl,
103
- mediaCtx.s3PublicUrl,
104
- )
105
- : new Map();
106
+ const [contextMediaMap, contextCollectionsMap] =
107
+ contextPostIds.length > 0
108
+ ? await Promise.all([
109
+ c.var.services.media
110
+ .getByPostIds(contextPostIds)
111
+ .then((raw) =>
112
+ buildMediaMap(
113
+ raw,
114
+ mediaCtx.r2PublicUrl,
115
+ mediaCtx.imageTransformUrl,
116
+ mediaCtx.s3PublicUrl,
117
+ ),
118
+ ),
119
+ c.var.services.collections.getCollectionsByPostIds(contextPostIds),
120
+ ])
121
+ : [new Map(), new Map()];
106
122
 
107
123
  // Assemble timeline items with View Models
108
124
  const items: TimelineItemView[] = posts.map((post) => {
109
125
  const postView = toPostView(
110
- { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
126
+ {
127
+ ...post,
128
+ mediaAttachments: mediaMap.get(post.id) ?? [],
129
+ },
111
130
  mediaCtx,
131
+ collectionsMap.get(post.id),
112
132
  );
113
133
 
114
- const replyCount = replyCounts.get(post.id) ?? 0;
115
- const previewReplies = threadPreviews.get(post.id);
134
+ const threadCtx = threadContexts.get(post.id);
135
+
136
+ if (threadCtx) {
137
+ // Thread root is not the last post — hide reply button on it
138
+ postView.isLastInThread = false;
139
+
140
+ const latestReplyView = toPostView(
141
+ {
142
+ ...threadCtx.latestReply,
143
+ mediaAttachments: contextMediaMap.get(threadCtx.latestReply.id) ?? [],
144
+ },
145
+ mediaCtx,
146
+ contextCollectionsMap.get(threadCtx.latestReply.id),
147
+ undefined,
148
+ true, // latestReply is the last post in the thread
149
+ );
150
+
151
+ const parentReplyView = threadCtx.parentReply
152
+ ? toPostView(
153
+ {
154
+ ...threadCtx.parentReply,
155
+ mediaAttachments:
156
+ contextMediaMap.get(threadCtx.parentReply.id) ?? [],
157
+ },
158
+ mediaCtx,
159
+ contextCollectionsMap.get(threadCtx.parentReply.id),
160
+ undefined,
161
+ false, // parentReply is not the last post
162
+ )
163
+ : undefined;
116
164
 
117
- if (replyCount > 0 && previewReplies) {
118
165
  return {
119
166
  post: postView,
120
167
  threadPreview: {
121
- replies: toPostViews(
122
- previewReplies.map((r) => ({
123
- ...r,
124
- mediaAttachments: previewMediaMap.get(r.id) ?? [],
125
- })),
126
- mediaCtx,
127
- ),
128
- totalReplyCount: replyCount,
168
+ latestReply: latestReplyView,
169
+ parentReply: parentReplyView,
170
+ totalReplyCount: threadCtx.totalReplyCount,
129
171
  },
130
172
  };
131
173
  }