@jant/core 0.3.34 → 0.3.36

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 (156) hide show
  1. package/dist/client/assets/module-RjUF93sV.js +716 -0
  2. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  3. package/dist/client/assets/url-8Dj-5CLW.js +1 -0
  4. package/dist/client/client.css +1 -1
  5. package/dist/client/client.js +3109 -2294
  6. package/dist/index.js +3327 -3031
  7. package/package.json +13 -4
  8. package/src/__tests__/helpers/app.ts +1 -1
  9. package/src/__tests__/helpers/db.ts +6 -0
  10. package/src/app.tsx +1 -5
  11. package/src/{lib → client}/avatar-upload.ts +1 -1
  12. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  13. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  14. package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
  15. package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
  16. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
  17. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  18. package/src/client/components/collection-sidebar-types.ts +45 -0
  19. package/src/{ui → client}/components/collection-types.ts +3 -4
  20. package/src/{ui → client}/components/compose-types.ts +3 -1
  21. package/src/{ui → client}/components/jant-collection-form.ts +301 -182
  22. package/src/client/components/jant-collection-sidebar.ts +801 -0
  23. package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
  24. package/src/client/components/jant-compose-editor.ts +1249 -0
  25. package/src/client/components/jant-compose-fullscreen.ts +338 -0
  26. package/src/client/components/jant-media-lightbox.ts +257 -0
  27. package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
  28. package/src/{ui → client}/components/jant-post-form.ts +57 -8
  29. package/src/{ui → client}/components/jant-settings-general.ts +2 -2
  30. package/src/{ui → client}/components/nav-manager-types.ts +3 -0
  31. package/src/{ui → client}/components/post-form-template.ts +35 -31
  32. package/src/{ui → client}/components/post-form-types.ts +7 -3
  33. package/src/{lib → client}/compose-bridge.ts +9 -7
  34. package/src/client/lazy-slugify.ts +51 -0
  35. package/src/{lib → client}/media-upload.ts +16 -3
  36. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  37. package/src/client/page-slug-bridge.ts +42 -0
  38. package/src/{lib → client}/post-form-bridge.ts +2 -2
  39. package/src/{lib → client}/settings-bridge.ts +3 -3
  40. package/src/client/tiptap/bubble-menu.ts +205 -0
  41. package/src/client/tiptap/create-editor.ts +40 -0
  42. package/src/client/tiptap/exitable-marks.ts +73 -0
  43. package/src/client/tiptap/extensions.ts +60 -0
  44. package/src/client/tiptap/image-node.ts +488 -0
  45. package/src/client/tiptap/link-toolbar.ts +371 -0
  46. package/src/client/tiptap/more-break.ts +50 -0
  47. package/src/client/tiptap/paste-image.ts +140 -0
  48. package/src/client/tiptap/slash-commands.ts +328 -0
  49. package/src/{types → client/types}/sortablejs.d.ts +1 -1
  50. package/src/client.ts +24 -17
  51. package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
  52. package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
  53. package/src/db/schema.ts +6 -1
  54. package/src/i18n/locales/en.po +641 -215
  55. package/src/i18n/locales/en.ts +1 -1
  56. package/src/i18n/locales/zh-Hans.po +642 -204
  57. package/src/i18n/locales/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/zh-Hant.po +642 -204
  59. package/src/i18n/locales/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  61. package/src/lib/__tests__/schemas.test.ts +9 -6
  62. package/src/lib/__tests__/url.test.ts +2 -2
  63. package/src/lib/__tests__/view.test.ts +9 -9
  64. package/src/lib/emoji-catalog.ts +146 -0
  65. package/src/lib/feed.ts +1 -1
  66. package/src/lib/media-helpers.ts +10 -9
  67. package/src/lib/render.tsx +4 -3
  68. package/src/lib/resolve-config.ts +8 -1
  69. package/src/lib/schemas.ts +2 -3
  70. package/src/lib/summary.ts +92 -0
  71. package/src/lib/timeline.ts +2 -0
  72. package/src/lib/tiptap-render.ts +196 -0
  73. package/src/lib/upload.ts +97 -9
  74. package/src/lib/url.ts +7 -23
  75. package/src/lib/view.ts +33 -19
  76. package/src/middleware/error-handler.ts +3 -3
  77. package/src/preset.css +38 -0
  78. package/src/routes/api/collections.ts +20 -3
  79. package/src/routes/api/posts.ts +48 -33
  80. package/src/routes/api/upload.ts +7 -5
  81. package/src/routes/auth/reset.tsx +5 -4
  82. package/src/routes/auth/setup.tsx +26 -11
  83. package/src/routes/auth/signin.tsx +10 -7
  84. package/src/routes/compose.tsx +20 -11
  85. package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
  86. package/src/routes/dash/index.tsx +7 -1
  87. package/src/routes/dash/media.tsx +3 -0
  88. package/src/routes/dash/pages.tsx +8 -2
  89. package/src/routes/dash/posts.tsx +6 -2
  90. package/src/routes/dash/redirects.tsx +15 -9
  91. package/src/routes/dash/settings.tsx +336 -32
  92. package/src/routes/feed/__tests__/rss.test.ts +245 -6
  93. package/src/routes/feed/rss.ts +70 -6
  94. package/src/routes/pages/__tests__/featured.test.ts +6 -7
  95. package/src/routes/pages/archive.tsx +11 -7
  96. package/src/routes/pages/collection.tsx +32 -15
  97. package/src/routes/pages/collections.tsx +11 -2
  98. package/src/routes/pages/featured.tsx +1 -1
  99. package/src/routes/pages/home.tsx +1 -1
  100. package/src/services/__tests__/post.test.ts +124 -33
  101. package/src/services/__tests__/settings.test.ts +3 -3
  102. package/src/services/page.ts +16 -3
  103. package/src/services/post.ts +96 -37
  104. package/src/services/search.ts +4 -2
  105. package/src/services/settings.ts +6 -2
  106. package/src/styles/components.css +240 -60
  107. package/src/styles/tokens.css +10 -0
  108. package/src/styles/ui.css +1157 -81
  109. package/src/types/bindings.ts +5 -0
  110. package/src/types/config.ts +23 -1
  111. package/src/types/constants.ts +3 -0
  112. package/src/types/entities.ts +9 -2
  113. package/src/types/operations.ts +9 -3
  114. package/src/types/props.ts +3 -3
  115. package/src/types/views.ts +3 -2
  116. package/src/ui/compose/ComposeDialog.tsx +24 -7
  117. package/src/ui/dash/PageForm.tsx +2 -0
  118. package/src/ui/dash/PostList.tsx +5 -5
  119. package/src/ui/dash/StatusBadge.tsx +13 -5
  120. package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
  121. package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
  122. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  123. package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
  124. package/src/ui/dash/media/MediaListContent.tsx +9 -4
  125. package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
  126. package/src/ui/dash/pages/PagesContent.tsx +2 -1
  127. package/src/ui/dash/posts/PostForm.tsx +19 -7
  128. package/src/ui/dash/settings/AccountContent.tsx +133 -138
  129. package/src/ui/dash/settings/AvatarContent.tsx +70 -0
  130. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  131. package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
  132. package/src/ui/layouts/DashLayout.tsx +157 -75
  133. package/src/ui/layouts/SiteLayout.tsx +13 -13
  134. package/src/ui/pages/ArchivePage.tsx +10 -7
  135. package/src/ui/pages/CollectionPage.tsx +6 -35
  136. package/src/ui/pages/CollectionsPage.tsx +2 -1
  137. package/src/ui/pages/FeaturedPage.tsx +2 -1
  138. package/src/ui/pages/HomePage.tsx +1 -1
  139. package/src/ui/pages/SearchPage.tsx +1 -1
  140. package/src/ui/shared/CollectionsSidebar.tsx +228 -3
  141. package/src/ui/shared/MediaGallery.tsx +179 -41
  142. package/src/lib/collections-reorder.ts +0 -28
  143. package/src/routes/dash/appearance.tsx +0 -240
  144. package/src/routes/dash/collections.tsx +0 -211
  145. package/src/ui/components/jant-compose-editor.ts +0 -814
  146. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  147. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  148. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  149. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  150. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  151. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  152. /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
  153. /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
  154. /package/src/{ui → client}/components/settings-types.ts +0 -0
  155. /package/src/{lib → client}/image-processor.ts +0 -0
  156. /package/src/{lib → client}/toast.ts +0 -0
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * Collection Page
3
3
  *
4
- * Collection header with icon and divider-separated post list.
4
+ * Collection header with icon and timeline feed of posts.
5
5
  */
6
6
 
7
7
  import type { FC } from "hono/jsx";
8
8
  import { useLingui } from "@lingui/react/macro";
9
9
  import type { CollectionPageProps } from "../../types.js";
10
10
  import { renderCollectionIcon } from "../../lib/icons.js";
11
+ import { TimelineFeed } from "../feed/TimelineFeed.js";
11
12
 
12
13
  export const CollectionPage: FC<CollectionPageProps> = ({
13
14
  collection,
14
- posts,
15
+ items,
15
16
  }) => {
16
17
  const { t } = useLingui();
17
18
  const iconHtml = renderCollectionIcon(collection.icon, { size: 28 });
@@ -34,45 +35,15 @@ export const CollectionPage: FC<CollectionPageProps> = ({
34
35
  </header>
35
36
 
36
37
  <main>
37
- {posts.length === 0 ? (
38
+ {items.length === 0 ? (
38
39
  <p class="text-muted-foreground">
39
40
  {t({
40
- message: "No posts in this collection.",
41
+ message: "This collection is empty. Add posts from the editor.",
41
42
  comment: "@context: Empty state message",
42
43
  })}
43
44
  </p>
44
45
  ) : (
45
- <div class="divide-y divide-border">
46
- {posts.map((post) => (
47
- <article
48
- key={post.id}
49
- class="h-entry py-4"
50
- data-post
51
- data-format={post.format}
52
- >
53
- {post.title && (
54
- <h2 class="p-name text-lg font-medium mb-2">
55
- <a href={post.permalink} class="u-url hover:underline">
56
- {post.title}
57
- </a>
58
- </h2>
59
- )}
60
- <div
61
- class="e-content prose prose-sm"
62
- data-post-body
63
- dangerouslySetInnerHTML={{ __html: post.bodyHtml || "" }}
64
- />
65
- <footer
66
- class="mt-2 text-sm text-muted-foreground"
67
- data-post-meta
68
- >
69
- <time class="dt-published" datetime={post.publishedAt}>
70
- {post.publishedAtFormatted}
71
- </time>
72
- </footer>
73
- </article>
74
- ))}
75
- </div>
46
+ <TimelineFeed items={items} />
76
47
  )}
77
48
  </main>
78
49
  </div>
@@ -27,7 +27,8 @@ export const CollectionsPage: FC<CollectionsPageProps> = ({ collections }) => {
27
27
  {collections.length === 0 ? (
28
28
  <p class="text-muted-foreground">
29
29
  {t({
30
- message: "No collections yet.",
30
+ message:
31
+ "No collections yet. Start one to organize posts by topic.",
31
32
  comment: "@context: Empty state message on collections page",
32
33
  })}
33
34
  </p>
@@ -18,7 +18,8 @@ export const FeaturedPage: FC<FeaturedPageProps> = ({ items }) => {
18
18
  {items.length === 0 ? (
19
19
  <p class="text-muted-foreground">
20
20
  {t({
21
- message: "No featured posts yet.",
21
+ message:
22
+ "No featured posts. Mark a post as featured to highlight it here.",
22
23
  comment: "@context: Empty state message on featured page",
23
24
  })}
24
25
  </p>
@@ -27,7 +27,7 @@ export const HomePage: FC<HomePageProps> = ({
27
27
  class="py-12 text-center text-muted-foreground"
28
28
  >
29
29
  {t({
30
- message: "No posts yet.",
30
+ message: "Nothing here yet.",
31
31
  comment: "@context: Empty state message on home page",
32
32
  })}
33
33
  </p>
@@ -62,7 +62,7 @@ export const SearchPage: FC<SearchPageProps> = ({
62
62
  <p class="text-sm text-muted-foreground mb-4">
63
63
  {results.length === 0
64
64
  ? t({
65
- message: "No results found.",
65
+ message: "No results. Try different keywords.",
66
66
  comment: "@context: Search empty results",
67
67
  })
68
68
  : results.length === 1
@@ -2,25 +2,74 @@
2
2
  * Collections Sidebar
3
3
  *
4
4
  * Shared sidebar navigation for public collection pages.
5
- * Shows all collections with icons and active state.
5
+ * - Anonymous users: static nav with collections and dividers
6
+ * - Authenticated users: interactive Lit component with CRUD, reorder, divider management
6
7
  */
7
8
 
8
9
  import type { FC } from "hono/jsx";
9
10
  import { useLingui } from "@lingui/react/macro";
10
- import type { Collection } from "../../types.js";
11
+ import type { Collection, CollectionDivider } from "../../types.js";
11
12
  import { renderCollectionIcon } from "../../lib/icons.js";
12
13
 
14
+ const escapeJson = (data: unknown) =>
15
+ JSON.stringify(data).replace(/</g, "\\u003c");
16
+
13
17
  export interface CollectionsSidebarProps {
14
18
  collections: Collection[];
19
+ dividers: CollectionDivider[];
15
20
  activeSlug?: string;
21
+ isAuthenticated?: boolean;
22
+ postCounts?: Map<number, number>;
16
23
  }
17
24
 
18
25
  export const CollectionsSidebar: FC<CollectionsSidebarProps> = ({
19
26
  collections,
27
+ dividers,
20
28
  activeSlug,
29
+ isAuthenticated,
30
+ postCounts,
21
31
  }) => {
32
+ if (isAuthenticated) {
33
+ return (
34
+ <AuthenticatedSidebar
35
+ collections={collections}
36
+ dividers={dividers}
37
+ activeSlug={activeSlug}
38
+ postCounts={postCounts}
39
+ />
40
+ );
41
+ }
42
+
43
+ return (
44
+ <AnonymousSidebar
45
+ collections={collections}
46
+ dividers={dividers}
47
+ activeSlug={activeSlug}
48
+ />
49
+ );
50
+ };
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Anonymous: static HTML nav
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const AnonymousSidebar: FC<{
57
+ collections: Collection[];
58
+ dividers: CollectionDivider[];
59
+ activeSlug?: string;
60
+ }> = ({ collections, dividers, activeSlug }) => {
22
61
  const { t } = useLingui();
23
62
 
63
+ // Interleave collections and dividers by position
64
+ type Item =
65
+ | { kind: "collection"; data: Collection }
66
+ | { kind: "divider"; data: CollectionDivider };
67
+
68
+ const items: Item[] = [
69
+ ...collections.map((c) => ({ kind: "collection" as const, data: c })),
70
+ ...dividers.map((d) => ({ kind: "divider" as const, data: d })),
71
+ ].sort((a, b) => a.data.position - b.data.position);
72
+
24
73
  return (
25
74
  <nav class="flex flex-col gap-1 pt-6">
26
75
  <h2 class="px-3 pb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
@@ -29,7 +78,15 @@ export const CollectionsSidebar: FC<CollectionsSidebarProps> = ({
29
78
  comment: "@context: Sidebar heading for collections nav",
30
79
  })}
31
80
  </h2>
32
- {collections.map((col) => {
81
+ {items.map((item) => {
82
+ if (item.kind === "divider") {
83
+ return (
84
+ <div key={`d-${item.data.id}`} class="px-3 py-1">
85
+ <hr class="border-border" />
86
+ </div>
87
+ );
88
+ }
89
+ const col = item.data;
33
90
  const isActive = col.slug === activeSlug;
34
91
  return (
35
92
  <a
@@ -57,3 +114,171 @@ export const CollectionsSidebar: FC<CollectionsSidebarProps> = ({
57
114
  </nav>
58
115
  );
59
116
  };
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Authenticated: Lit component shell
120
+ // ---------------------------------------------------------------------------
121
+
122
+ const AuthenticatedSidebar: FC<{
123
+ collections: Collection[];
124
+ dividers: CollectionDivider[];
125
+ activeSlug?: string;
126
+ postCounts?: Map<number, number>;
127
+ }> = ({ collections, dividers, activeSlug, postCounts }) => {
128
+ const { t } = useLingui();
129
+
130
+ const sidebarCollections = collections.map((col) => ({
131
+ id: col.id,
132
+ slug: col.slug,
133
+ title: col.title,
134
+ description: col.description,
135
+ icon: col.icon,
136
+ sortOrder: col.sortOrder,
137
+ position: col.position,
138
+ postCount: postCounts?.get(col.id) ?? 0,
139
+ }));
140
+
141
+ const labels = {
142
+ collections: t({
143
+ message: "Collections",
144
+ comment: "@context: Sidebar heading for collections nav",
145
+ }),
146
+ reorder: t({
147
+ message: "Reorder",
148
+ comment: "@context: Menu action to reorder collections",
149
+ }),
150
+ done: t({
151
+ message: "Done",
152
+ comment: "@context: Button to exit reorder mode",
153
+ }),
154
+ addDivider: t({
155
+ message: "Add Divider",
156
+ comment: "@context: Menu action to add a divider",
157
+ }),
158
+ newCollection: t({
159
+ message: "New Collection",
160
+ comment: "@context: Tooltip/aria for add collection button",
161
+ }),
162
+ edit: t({
163
+ message: "Edit",
164
+ comment: "@context: Per-collection edit action",
165
+ }),
166
+ deleteDivider: t({
167
+ message: "Remove Divider",
168
+ comment: "@context: Tooltip for divider delete button",
169
+ }),
170
+ moreActions: t({
171
+ message: "More actions",
172
+ comment: "@context: Aria-label for more button",
173
+ }),
174
+ deleteCollection: t({
175
+ message: "Delete",
176
+ comment: "@context: Delete collection action",
177
+ }),
178
+ confirmDelete: t({
179
+ message:
180
+ "Delete this collection permanently? Posts inside won't be removed.",
181
+ comment: "@context: Confirm dialog for deleting a collection",
182
+ }),
183
+ orderSaved: t({
184
+ message: "Order saved",
185
+ comment: "@context: Toast after reordering collections",
186
+ }),
187
+ saved: t({
188
+ message: "Saved",
189
+ comment: "@context: Toast after saving a collection",
190
+ }),
191
+ saveFailed: t({
192
+ message: "Couldn't save. Try again in a moment.",
193
+ comment: "@context: Toast when save fails",
194
+ }),
195
+ deleted: t({
196
+ message: "Deleted",
197
+ comment: "@context: Toast after deleting a collection",
198
+ }),
199
+ formLabels: {
200
+ titleLabel: t({
201
+ message: "Title",
202
+ comment: "@context: Collection form field",
203
+ }),
204
+ titlePlaceholder: t({
205
+ message: "My Collection",
206
+ comment: "@context: Collection title placeholder",
207
+ }),
208
+ slugLabel: t({
209
+ message: "Slug",
210
+ comment: "@context: Collection form field",
211
+ }),
212
+ slugHelp: t({
213
+ message:
214
+ "URL-safe identifier (lowercase, numbers, hyphens). For CJK titles, slug will be auto-generated on the server.",
215
+ comment: "@context: Collection path help text",
216
+ }),
217
+ descriptionLabel: t({
218
+ message: "Description (optional)",
219
+ comment: "@context: Collection form field",
220
+ }),
221
+ descriptionPlaceholder: t({
222
+ message: "What's this collection about?",
223
+ comment: "@context: Collection description placeholder",
224
+ }),
225
+ removeIcon: t({
226
+ message: "Remove",
227
+ comment: "@context: Button to remove icon",
228
+ }),
229
+ iconsTab: t({
230
+ message: "Icons",
231
+ comment: "@context: Icon picker tab label",
232
+ }),
233
+ emojisTab: t({
234
+ message: "Emojis",
235
+ comment: "@context: Emoji picker tab label",
236
+ }),
237
+ searchIconsPlaceholder: t({
238
+ message: "Search icons...",
239
+ comment: "@context: Icon picker search placeholder",
240
+ }),
241
+ searchEmojisPlaceholder: t({
242
+ message: "Search emojis...",
243
+ comment: "@context: Emoji picker search placeholder",
244
+ }),
245
+ sortOrderLabel: t({
246
+ message: "Sort Order",
247
+ comment: "@context: Collection form field",
248
+ }),
249
+ sortNewest: t({
250
+ message: "Newest first",
251
+ comment: "@context: Collection sort order option",
252
+ }),
253
+ sortOldest: t({
254
+ message: "Oldest first",
255
+ comment: "@context: Collection sort order option",
256
+ }),
257
+ sortRatingDesc: t({
258
+ message: "Highest rated",
259
+ comment: "@context: Collection sort order option",
260
+ }),
261
+ sortRatingAsc: t({
262
+ message: "Lowest rated",
263
+ comment: "@context: Collection sort order option",
264
+ }),
265
+ submitLabel: t({
266
+ message: "Save",
267
+ comment: "@context: Button to save collection",
268
+ }),
269
+ cancelLabel: t({
270
+ message: "Cancel",
271
+ comment: "@context: Button to cancel form",
272
+ }),
273
+ },
274
+ };
275
+
276
+ return (
277
+ <jant-collection-sidebar
278
+ collections={escapeJson(sidebarCollections)}
279
+ dividers={escapeJson(dividers)}
280
+ labels={escapeJson(labels)}
281
+ active-slug={activeSlug ?? ""}
282
+ />
283
+ );
284
+ };
@@ -1,59 +1,197 @@
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: 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.
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";
10
12
 
11
13
  export interface MediaGalleryProps {
12
14
  attachments: MediaView[];
13
15
  }
14
16
 
17
+ function formatSize(bytes: number): string {
18
+ if (bytes < 1024) return `${bytes} B`;
19
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
20
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
21
+ }
22
+
15
23
  export const MediaGallery: FC<MediaGalleryProps> = ({ attachments }) => {
16
- const images = attachments.filter((a) => a.mimeType.startsWith("image/"));
17
- if (images.length === 0) return null;
24
+ if (attachments.length === 0) return null;
18
25
 
19
- const single = images.length === 1;
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
+ );
38
+
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;
20
58
 
21
59
  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>
60
+ <>
61
+ {/* Images + Videos gallery */}
62
+ {hasVisualMedia && (
63
+ <div
64
+ data-post-media
65
+ data-lightbox-group={JSON.stringify(lightboxItems)}
66
+ class={`mt-3 flex gap-2 ${singleVisual ? "" : "overflow-x-auto scroll-smooth snap-x snap-mandatory"}`}
67
+ style={
68
+ singleVisual
69
+ ? undefined
70
+ : "scrollbar-width: none; -ms-overflow-style: none;"
71
+ }
72
+ >
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`;
79
+
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={
96
+ singleVisual
97
+ ? "rounded-lg max-w-full max-h-96 h-auto object-contain"
98
+ : "h-80 w-full object-cover"
99
+ }
100
+ loading="lazy"
101
+ />
102
+ </a>
103
+ );
104
+ })}
105
+ {videos.map((v, vIdx) => {
106
+ const lightboxIndex = images.length + vIdx;
107
+ 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
+ }
116
+ >
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>
128
+ </div>
129
+ </a>
130
+ );
131
+ })}
132
+ </div>
133
+ )}
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
+ </>
58
196
  );
59
197
  };
@@ -1,28 +0,0 @@
1
- /**
2
- * Collection Reorder
3
- *
4
- * Initializes SortableJS on the collections list in the dashboard.
5
- * Auto-detects the list element and only activates when present.
6
- * Sends prefixed string IDs (e.g. "c-1", "d-2") to support mixed
7
- * collections and dividers in a unified sort order.
8
- */
9
-
10
- import Sortable from "sortablejs";
11
-
12
- const list = document.getElementById("collections-list");
13
- if (list) {
14
- Sortable.create(list, {
15
- animation: 150,
16
- handle: "[data-id]",
17
- onEnd() {
18
- const items = [...list.querySelectorAll<HTMLElement>("[data-id]")]
19
- .map((el) => el.dataset.id)
20
- .filter((id): id is string => id !== undefined);
21
- fetch("/dash/collections/reorder", {
22
- method: "POST",
23
- headers: { "Content-Type": "application/json" },
24
- body: JSON.stringify({ items }),
25
- });
26
- },
27
- });
28
- }