@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
@@ -1,59 +1,493 @@
1
1
  /**
2
2
  * Media Gallery Component
3
3
  *
4
- * Renders media attachments in a horizontal scrollable row,
5
- * similar to an image carousel.
4
+ * Renders media attachments in a unified horizontal row: images with
5
+ * lightbox support, videos with play overlay, audio/documents as 3:4
6
+ * styled card tiles, and attached texts as summary cards.
6
7
  */
7
8
 
8
9
  import type { FC } from "hono/jsx";
9
10
  import type { MediaView } from "../../types.js";
11
+ import { getMediaCategory } from "../../lib/upload.js";
12
+ import { blurhashToDataUrl } from "../../lib/blurhash-placeholder.js";
10
13
 
11
14
  export interface MediaGalleryProps {
12
15
  attachments: MediaView[];
13
16
  }
14
17
 
18
+ function formatSize(bytes: number): string {
19
+ if (bytes < 1024) return `${bytes} B`;
20
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
21
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
22
+ }
23
+
24
+ export function formatChars(count: number): string {
25
+ if (count < 1000) return `${count} chars`;
26
+ if (count < 1_000_000) {
27
+ return `${parseFloat((count / 1000).toFixed(1))}k chars`;
28
+ }
29
+ return `${parseFloat((count / 1_000_000).toFixed(1))}M chars`;
30
+ }
31
+
32
+ /**
33
+ * Format-specific file icon. Each MIME type gets a visually distinct icon
34
+ * built on the same document silhouette base.
35
+ */
36
+ const FileIcon = ({
37
+ mimeType,
38
+ size = 24,
39
+ }: {
40
+ mimeType: string;
41
+ size?: number;
42
+ }) => {
43
+ const base = {
44
+ width: `${size}`,
45
+ height: `${size}`,
46
+ viewBox: "0 0 24 24",
47
+ fill: "none",
48
+ stroke: "currentColor",
49
+ "stroke-width": "1.5",
50
+ "stroke-linecap": "round",
51
+ "stroke-linejoin": "round",
52
+ } as const;
53
+
54
+ // PDF — bold "PDF" label
55
+ if (mimeType === "application/pdf") {
56
+ return (
57
+ <svg {...base}>
58
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
59
+ <polyline points="14 2 14 8 20 8" />
60
+ <text
61
+ x="12"
62
+ y="16.5"
63
+ text-anchor="middle"
64
+ fill="currentColor"
65
+ stroke="none"
66
+ font-size="6"
67
+ font-weight="700"
68
+ font-family="system-ui, sans-serif"
69
+ >
70
+ PDF
71
+ </text>
72
+ </svg>
73
+ );
74
+ }
75
+
76
+ // Markdown — "#" heading symbol
77
+ if (mimeType === "text/markdown") {
78
+ return (
79
+ <svg {...base}>
80
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
81
+ <polyline points="14 2 14 8 20 8" />
82
+ <text
83
+ x="12"
84
+ y="16.5"
85
+ text-anchor="middle"
86
+ fill="currentColor"
87
+ stroke="none"
88
+ font-size="10"
89
+ font-weight="700"
90
+ font-family="system-ui, sans-serif"
91
+ >
92
+ #
93
+ </text>
94
+ </svg>
95
+ );
96
+ }
97
+
98
+ // CSV — 3x2 grid/table
99
+ if (mimeType === "text/csv") {
100
+ return (
101
+ <svg {...base}>
102
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
103
+ <polyline points="14 2 14 8 20 8" />
104
+ {/* Horizontal lines */}
105
+ <line x1="8" y1="12" x2="16" y2="12" />
106
+ <line x1="8" y1="15" x2="16" y2="15" />
107
+ <line x1="8" y1="18" x2="16" y2="18" />
108
+ {/* Vertical dividers */}
109
+ <line x1="10.7" y1="12" x2="10.7" y2="18" />
110
+ <line x1="13.3" y1="12" x2="13.3" y2="18" />
111
+ </svg>
112
+ );
113
+ }
114
+
115
+ // Archive — vertical zipper dashes
116
+ if (getMediaCategory(mimeType) === "archive") {
117
+ return (
118
+ <svg {...base}>
119
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
120
+ <polyline points="14 2 14 8 20 8" />
121
+ <line x1="12" y1="10" x2="12" y2="11.5" />
122
+ <line x1="12" y1="13" x2="12" y2="14.5" />
123
+ <line x1="12" y1="16" x2="12" y2="17.5" />
124
+ </svg>
125
+ );
126
+ }
127
+
128
+ // Tiptap JSON — notepad with paragraph lines
129
+ if (mimeType === "text/x-tiptap+json") {
130
+ return (
131
+ <svg {...base}>
132
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
133
+ <polyline points="14 2 14 8 20 8" />
134
+ <line x1="16" y1="11" x2="8" y2="11" />
135
+ <line x1="16" y1="14" x2="8" y2="14" />
136
+ <line x1="12" y1="17" x2="8" y2="17" />
137
+ </svg>
138
+ );
139
+ }
140
+
141
+ // Plain text (default) — 3 horizontal text lines
142
+ return (
143
+ <svg {...base}>
144
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
145
+ <polyline points="14 2 14 8 20 8" />
146
+ <line x1="16" y1="13" x2="8" y2="13" />
147
+ <line x1="16" y1="17" x2="8" y2="17" />
148
+ <line x1="10" y1="9" x2="8" y2="9" />
149
+ </svg>
150
+ );
151
+ };
152
+
15
153
  export const MediaGallery: FC<MediaGalleryProps> = ({ attachments }) => {
16
- const images = attachments.filter((a) => a.mimeType.startsWith("image/"));
17
- if (images.length === 0) return null;
154
+ if (attachments.length === 0) return null;
155
+
156
+ // Category checks for layout decisions
157
+ const hasNonVisualAttachment = attachments.some((a) => {
158
+ const cat = getMediaCategory(a.mimeType);
159
+ return cat !== "image" && cat !== "video";
160
+ });
18
161
 
19
- const single = images.length === 1;
162
+ // Build lightbox group from images + videos in position order
163
+ // (documents/texts don't use lightbox)
164
+ const lightboxItems = attachments
165
+ .filter((a) => {
166
+ const cat = getMediaCategory(a.mimeType);
167
+ return cat === "image" || cat === "video";
168
+ })
169
+ .map((a) => ({
170
+ url: a.url,
171
+ alt: a.altText || "",
172
+ width: a.width,
173
+ height: a.height,
174
+ ...(getMediaCategory(a.mimeType) === "video"
175
+ ? { mimeType: a.mimeType, posterUrl: a.posterUrl || undefined }
176
+ : {}),
177
+ }));
178
+
179
+ // Build gallery items preserving position order from the database
180
+ type GalleryItem =
181
+ | (MediaView & { _kind: "image" | "video"; _lbIdx: number })
182
+ | (MediaView & { _kind: "document" })
183
+ | (MediaView & { _kind: "text" })
184
+ | (MediaView & { _kind: "audio" });
185
+
186
+ let lbIdx = 0;
187
+ const galleryItems: GalleryItem[] = attachments.map((a) => {
188
+ const cat = getMediaCategory(a.mimeType);
189
+ if (cat === "image" || cat === "video") {
190
+ return { ...a, _kind: cat, _lbIdx: lbIdx++ } as GalleryItem;
191
+ }
192
+ if (cat === "audio")
193
+ return { ...a, _kind: "audio" as const } as GalleryItem;
194
+ if (cat === "text") return { ...a, _kind: "text" as const } as GalleryItem;
195
+ return { ...a, _kind: "document" as const } as GalleryItem;
196
+ });
197
+
198
+ const hasGalleryItems = galleryItems.length > 0;
199
+ const singleItem = galleryItems.length === 1;
200
+ // Documents/texts have no intrinsic size — treat as single only if the one item is visual
201
+ const firstItem = galleryItems[0];
202
+ const singleVisual =
203
+ singleItem &&
204
+ firstItem !== undefined &&
205
+ (firstItem._kind === "image" || firstItem._kind === "video");
206
+
207
+ // When non-visual attachments are mixed with visuals, use a compact row
208
+ const hasNonVisual = hasNonVisualAttachment;
209
+ const COMPACT_HEIGHT = 160;
210
+
211
+ // Row height adapts to the first visual item's aspect ratio
212
+ const ROW_MIN = hasNonVisual ? 160 : 240;
213
+ const ROW_MAX = hasNonVisual ? 240 : 400;
214
+ let rowHeight = hasNonVisual ? COMPACT_HEIGHT : 320;
215
+ if (!singleVisual && galleryItems.length > 1) {
216
+ const firstVisual = galleryItems.find(
217
+ (item) => item._kind === "image" || item._kind === "video",
218
+ );
219
+ if (firstVisual && "width" in firstVisual && "height" in firstVisual) {
220
+ const firstRatio =
221
+ firstVisual.width && firstVisual.height
222
+ ? firstVisual.width / firstVisual.height
223
+ : 4 / 3;
224
+ rowHeight = Math.round(
225
+ Math.min(ROW_MAX, Math.max(ROW_MIN, 320 / Math.max(firstRatio, 0.5))),
226
+ );
227
+ }
228
+ }
229
+
230
+ // Document card: 3:4 portrait, same height as row
231
+ const DOC_RATIO = 3 / 4;
232
+ const docCardWidth = Math.round(rowHeight * DOC_RATIO);
20
233
 
21
234
  return (
22
- <div
23
- class={`mt-3 flex gap-2 ${single ? "" : "overflow-x-auto scroll-smooth snap-x snap-mandatory"}`}
24
- style={
25
- single ? undefined : "scrollbar-width: none; -ms-overflow-style: none;"
26
- }
27
- >
28
- {images.map((img) => {
29
- const aspectRatio =
30
- img.width && img.height ? img.width / img.height : 4 / 3;
31
- const itemWidth = single
32
- ? undefined
33
- : `${Math.round(320 * Math.min(Math.max(aspectRatio, 0.6), 1.6))}px`;
34
-
35
- return (
36
- <a
37
- key={img.id}
38
- href={img.url}
39
- target="_blank"
40
- rel="noopener noreferrer"
41
- class={`${single ? "" : "shrink-0 snap-start"} block rounded-lg overflow-hidden`}
42
- style={single ? undefined : { width: itemWidth, maxWidth: "85%" }}
43
- >
44
- <img
45
- src={img.thumbnailUrl}
46
- alt={img.altText || ""}
47
- class={
48
- single
49
- ? "rounded-lg max-w-full max-h-96 h-auto object-contain"
50
- : "h-80 w-full object-cover"
51
- }
52
- loading="lazy"
53
- />
54
- </a>
55
- );
56
- })}
57
- </div>
235
+ <>
236
+ {/* Unified gallery row */}
237
+ {hasGalleryItems && (
238
+ <div
239
+ data-post-media
240
+ data-lightbox-group={
241
+ lightboxItems.length > 0 ? JSON.stringify(lightboxItems) : undefined
242
+ }
243
+ class={`mt-3 flex gap-2 ${singleVisual ? "" : "overflow-x-auto scroll-smooth snap-x snap-mandatory"}`}
244
+ style={
245
+ singleVisual
246
+ ? undefined
247
+ : "scrollbar-width: none; -ms-overflow-style: none;"
248
+ }
249
+ >
250
+ {galleryItems.map((item) => {
251
+ if (item._kind === "image") {
252
+ const ratio =
253
+ item.width && item.height ? item.width / item.height : 4 / 3;
254
+ const placeholder = item.blurhash
255
+ ? blurhashToDataUrl(item.blurhash)
256
+ : undefined;
257
+ const itemWidth = singleVisual
258
+ ? undefined
259
+ : `${Math.round(Math.max(160, rowHeight * ratio))}px`;
260
+
261
+ return (
262
+ <a
263
+ key={item.id}
264
+ href={item.url}
265
+ data-lightbox-index={item._lbIdx}
266
+ class={`${singleVisual ? "" : "shrink-0 snap-start"} block rounded-lg overflow-hidden`}
267
+ style={{
268
+ ...(singleVisual
269
+ ? {}
270
+ : { width: itemWidth, maxWidth: "85%" }),
271
+ ...(placeholder
272
+ ? {
273
+ backgroundImage: `url(${placeholder})`,
274
+ backgroundSize: "cover",
275
+ }
276
+ : {}),
277
+ }}
278
+ >
279
+ <img
280
+ src={item.thumbnailUrl}
281
+ alt={item.altText || ""}
282
+ style={
283
+ singleVisual && item.width && item.height
284
+ ? { aspectRatio: `${item.width}/${item.height}` }
285
+ : { height: `${rowHeight}px` }
286
+ }
287
+ class={
288
+ singleVisual
289
+ ? "rounded-lg max-w-full max-h-96 h-auto object-contain bg-transparent"
290
+ : "w-full object-cover bg-transparent"
291
+ }
292
+ loading="lazy"
293
+ />
294
+ </a>
295
+ );
296
+ }
297
+
298
+ if (item._kind === "video") {
299
+ const ratio =
300
+ item.width && item.height ? item.width / item.height : 4 / 3;
301
+ const placeholder = item.blurhash
302
+ ? blurhashToDataUrl(item.blurhash)
303
+ : undefined;
304
+ const itemWidth = singleVisual
305
+ ? undefined
306
+ : `${Math.round(Math.max(160, rowHeight * ratio))}px`;
307
+ const posterSrc = item.posterUrl || placeholder;
308
+
309
+ return (
310
+ <a
311
+ key={item.id}
312
+ href={item.url}
313
+ data-lightbox-index={item._lbIdx}
314
+ class={`${singleVisual ? "" : "shrink-0 snap-start"} media-video-wrap`}
315
+ style={
316
+ singleVisual
317
+ ? undefined
318
+ : { width: itemWidth, maxWidth: "85%" }
319
+ }
320
+ >
321
+ <video
322
+ preload="none"
323
+ muted
324
+ playsinline
325
+ poster={posterSrc}
326
+ style={
327
+ singleVisual && item.width && item.height
328
+ ? { aspectRatio: `${item.width}/${item.height}` }
329
+ : { height: `${rowHeight}px` }
330
+ }
331
+ class={singleVisual ? "max-h-96" : "w-full object-cover"}
332
+ />
333
+ <div class="media-video-play-overlay">
334
+ <svg viewBox="0 0 24 24" fill="white">
335
+ <path d="M8 5v14l11-7z" />
336
+ </svg>
337
+ </div>
338
+ </a>
339
+ );
340
+ }
341
+
342
+ if (item._kind === "audio") {
343
+ const audioName = item.originalName || item.altText || "Audio";
344
+ return (
345
+ <div
346
+ key={item.id}
347
+ class={`media-gallery-card media-audio-card shrink-0 snap-start${item.waveform ? " has-waveform" : ""}`}
348
+ style={{
349
+ width: `${docCardWidth}px`,
350
+ height: `${rowHeight}px`,
351
+ }}
352
+ >
353
+ {/* Hidden audio element — JS controls it */}
354
+ <audio preload="none" class="media-audio-el">
355
+ <source src={item.url} type={item.mimeType} />
356
+ </audio>
357
+
358
+ {/* Artwork area */}
359
+ <div class="media-audio-artwork">
360
+ <svg
361
+ viewBox="0 0 24 24"
362
+ fill="none"
363
+ stroke="currentColor"
364
+ stroke-width="1.2"
365
+ stroke-linecap="round"
366
+ stroke-linejoin="round"
367
+ >
368
+ <path d="M9 18V5l12-2v13" />
369
+ <circle cx="6" cy="18" r="3" />
370
+ <circle cx="18" cy="16" r="3" />
371
+ </svg>
372
+ </div>
373
+
374
+ {/* Bottom control strip */}
375
+ <div class="media-audio-controls">
376
+ {/* Range fallback — hidden when waveform loads */}
377
+ <input
378
+ type="range"
379
+ min="0"
380
+ max="1000"
381
+ value="0"
382
+ class="media-audio-range"
383
+ data-audio-range
384
+ aria-label="Seek"
385
+ />
386
+ {/* Waveform canvas — replaces range after first play */}
387
+ <canvas
388
+ class="media-audio-waveform"
389
+ data-audio-waveform
390
+ data-audio-peaks={item.waveform || undefined}
391
+ />
392
+
393
+ {/* Title + play button row */}
394
+ <div class="media-audio-row">
395
+ <div class="media-audio-info">
396
+ <div class="media-audio-title" title={audioName}>
397
+ {audioName}
398
+ </div>
399
+ <div class="media-audio-time" data-audio-time>
400
+ 0:00
401
+ </div>
402
+ </div>
403
+
404
+ <button
405
+ type="button"
406
+ class="media-audio-play-btn"
407
+ data-audio-play
408
+ aria-label="Play"
409
+ >
410
+ <svg
411
+ class="media-audio-icon-play"
412
+ viewBox="0 0 24 24"
413
+ fill="currentColor"
414
+ >
415
+ <path d="M8 5v14l11-7z" />
416
+ </svg>
417
+ <svg
418
+ class="media-audio-icon-pause"
419
+ viewBox="0 0 24 24"
420
+ fill="currentColor"
421
+ >
422
+ <path d="M6 4h4v16H6zM14 4h4v16h-4z" />
423
+ </svg>
424
+ </button>
425
+ </div>
426
+ </div>
427
+ </div>
428
+ );
429
+ }
430
+
431
+ if (item._kind === "document") {
432
+ return (
433
+ <a
434
+ key={item.id}
435
+ href={item.url}
436
+ target="_blank"
437
+ rel="noopener noreferrer"
438
+ class="media-gallery-card shrink-0 snap-start"
439
+ style={{
440
+ width: `${docCardWidth}px`,
441
+ height: `${rowHeight}px`,
442
+ }}
443
+ >
444
+ <div class="media-gallery-card-inner">
445
+ <div class="media-gallery-card-icon">
446
+ <FileIcon mimeType={item.mimeType} />
447
+ </div>
448
+ <span class="media-gallery-card-summary">
449
+ {item.originalName || item.altText || "Document"}
450
+ </span>
451
+ {item.size != null && (
452
+ <span class="media-gallery-card-meta">
453
+ {formatSize(item.size)}
454
+ </span>
455
+ )}
456
+ </div>
457
+ </a>
458
+ );
459
+ }
460
+
461
+ // Text card — 3:4 portrait, matching document cards
462
+ return (
463
+ <button
464
+ key={item.id}
465
+ type="button"
466
+ data-text-preview-id={item.id}
467
+ class="media-gallery-card shrink-0 snap-start"
468
+ style={{
469
+ width: `${docCardWidth}px`,
470
+ height: `${rowHeight}px`,
471
+ }}
472
+ >
473
+ <div class="media-gallery-card-inner">
474
+ <div class="media-gallery-card-icon">
475
+ <FileIcon mimeType={item.mimeType} />
476
+ </div>
477
+ <span class="media-gallery-card-summary">
478
+ {item.summary || item.originalName || "Attached text"}
479
+ </span>
480
+ {typeof item.chars === "number" && item.chars > 0 && (
481
+ <span class="media-gallery-card-meta">
482
+ {formatChars(item.chars)}
483
+ </span>
484
+ )}
485
+ </div>
486
+ </button>
487
+ );
488
+ })}
489
+ </div>
490
+ )}
491
+ </>
58
492
  );
59
493
  };