@jant/core 0.3.36 → 0.3.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * Media Gallery Component
3
3
  *
4
- * Renders media attachments: images in a horizontal scrollable row
5
- * (with lightbox support), videos inline with play overlay, audio
6
- * as compact player cards, and PDFs as file cards linking to the file.
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.
7
7
  */
8
8
 
9
9
  import type { FC } from "hono/jsx";
10
10
  import type { MediaView } from "../../types.js";
11
11
  import { getMediaCategory } from "../../lib/upload.js";
12
+ import { blurhashToDataUrl } from "../../lib/blurhash-placeholder.js";
12
13
 
13
14
  export interface MediaGalleryProps {
14
15
  attachments: MediaView[];
@@ -20,49 +21,225 @@ function formatSize(bytes: number): string {
20
21
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
21
22
  }
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
+
23
153
  export const MediaGallery: FC<MediaGalleryProps> = ({ attachments }) => {
24
154
  if (attachments.length === 0) return null;
25
155
 
26
- const images = attachments.filter(
27
- (a) => getMediaCategory(a.mimeType) === "image",
28
- );
29
- const videos = attachments.filter(
30
- (a) => getMediaCategory(a.mimeType) === "video",
31
- );
32
- const audios = attachments.filter(
33
- (a) => getMediaCategory(a.mimeType) === "audio",
34
- );
35
- const documents = attachments.filter(
36
- (a) => getMediaCategory(a.mimeType) === "document",
37
- );
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
+ });
161
+
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
+ });
38
197
 
39
- // Build lightbox group from images + videos
40
- const lightboxItems = [
41
- ...images.map((img) => ({
42
- url: img.url,
43
- alt: img.altText || "",
44
- width: img.width,
45
- height: img.height,
46
- })),
47
- ...videos.map((v) => ({
48
- url: v.url,
49
- alt: v.altText || "",
50
- width: v.width,
51
- height: v.height,
52
- mimeType: v.mimeType,
53
- })),
54
- ];
55
-
56
- const hasVisualMedia = images.length > 0 || videos.length > 0;
57
- const singleVisual = images.length + videos.length === 1;
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);
58
233
 
59
234
  return (
60
235
  <>
61
- {/* Images + Videos gallery */}
62
- {hasVisualMedia && (
236
+ {/* Unified gallery row */}
237
+ {hasGalleryItems && (
63
238
  <div
64
239
  data-post-media
65
- data-lightbox-group={JSON.stringify(lightboxItems)}
240
+ data-lightbox-group={
241
+ lightboxItems.length > 0 ? JSON.stringify(lightboxItems) : undefined
242
+ }
66
243
  class={`mt-3 flex gap-2 ${singleVisual ? "" : "overflow-x-auto scroll-smooth snap-x snap-mandatory"}`}
67
244
  style={
68
245
  singleVisual
@@ -70,128 +247,247 @@ export const MediaGallery: FC<MediaGalleryProps> = ({ attachments }) => {
70
247
  : "scrollbar-width: none; -ms-overflow-style: none;"
71
248
  }
72
249
  >
73
- {images.map((img, index) => {
74
- const aspectRatio =
75
- img.width && img.height ? img.width / img.height : 4 / 3;
76
- const itemWidth = singleVisual
77
- ? undefined
78
- : `${Math.round(320 * Math.min(Math.max(aspectRatio, 0.6), 1.6))}px`;
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`;
79
260
 
80
- return (
81
- <a
82
- key={img.id}
83
- href={img.url}
84
- data-lightbox-index={index}
85
- class={`${singleVisual ? "" : "shrink-0 snap-start"} block rounded-lg overflow-hidden`}
86
- style={
87
- singleVisual
88
- ? undefined
89
- : { width: itemWidth, maxWidth: "85%" }
90
- }
91
- >
92
- <img
93
- src={img.thumbnailUrl}
94
- alt={img.altText || ""}
95
- class={
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={
96
316
  singleVisual
97
- ? "rounded-lg max-w-full max-h-96 h-auto object-contain"
98
- : "h-80 w-full object-cover"
317
+ ? undefined
318
+ : { width: itemWidth, maxWidth: "85%" }
99
319
  }
100
- loading="lazy"
101
- />
102
- </a>
103
- );
104
- })}
105
- {videos.map((v, vIdx) => {
106
- const lightboxIndex = images.length + vIdx;
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
107
462
  return (
108
- <a
109
- key={v.id}
110
- href={v.url}
111
- data-lightbox-index={lightboxIndex}
112
- class={`${singleVisual ? "" : "shrink-0 snap-start"} media-video-wrap`}
113
- style={
114
- singleVisual ? undefined : { width: "320px", maxWidth: "85%" }
115
- }
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
+ }}
116
472
  >
117
- <video
118
- src={v.url}
119
- preload="metadata"
120
- muted
121
- playsinline
122
- class={singleVisual ? "max-h-96" : "h-80 w-full object-cover"}
123
- />
124
- <div class="media-video-play-overlay">
125
- <svg viewBox="0 0 24 24" fill="white">
126
- <path d="M8 5v14l11-7z" />
127
- </svg>
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
+ )}
128
485
  </div>
129
- </a>
486
+ </button>
130
487
  );
131
488
  })}
132
489
  </div>
133
490
  )}
134
-
135
- {/* Audio cards */}
136
- {audios.map((a) => (
137
- <div key={a.id} class="media-audio-card">
138
- <div class="media-audio-icon">
139
- <svg
140
- width="20"
141
- height="20"
142
- viewBox="0 0 24 24"
143
- fill="none"
144
- stroke="currentColor"
145
- stroke-width="1.5"
146
- stroke-linecap="round"
147
- stroke-linejoin="round"
148
- >
149
- <path d="M9 18V5l12-2v13" />
150
- <circle cx="6" cy="18" r="3" />
151
- <circle cx="18" cy="16" r="3" />
152
- </svg>
153
- </div>
154
- {a.altText && <span class="media-audio-name">{a.altText}</span>}
155
- <div class="media-audio-player">
156
- <audio controls preload="metadata">
157
- <source src={a.url} type={a.mimeType} />
158
- </audio>
159
- </div>
160
- </div>
161
- ))}
162
-
163
- {/* PDF cards */}
164
- {documents.map((d) => (
165
- <a
166
- key={d.id}
167
- href={d.url}
168
- target="_blank"
169
- rel="noopener noreferrer"
170
- class="media-pdf-card"
171
- >
172
- <div class="media-pdf-icon">
173
- <svg
174
- width="20"
175
- height="20"
176
- viewBox="0 0 24 24"
177
- fill="none"
178
- stroke="currentColor"
179
- stroke-width="1.5"
180
- stroke-linecap="round"
181
- stroke-linejoin="round"
182
- >
183
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
184
- <polyline points="14 2 14 8 20 8" />
185
- <line x1="16" y1="13" x2="8" y2="13" />
186
- <line x1="16" y1="17" x2="8" y2="17" />
187
- </svg>
188
- </div>
189
- <span class="media-pdf-name">{d.altText || "PDF"}</span>
190
- {d.size != null && (
191
- <span class="media-pdf-size">{formatSize(d.size)}</span>
192
- )}
193
- </a>
194
- ))}
195
491
  </>
196
492
  );
197
493
  };