@blogmcp/mdx-render-react 0.1.0

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 (68) hide show
  1. package/README.md +32 -0
  2. package/dist/MdxContent.d.ts +29 -0
  3. package/dist/MdxContent.js +51 -0
  4. package/dist/index.d.ts +19 -0
  5. package/dist/index.js +43 -0
  6. package/dist/marked.d.ts +34 -0
  7. package/dist/marked.js +15 -0
  8. package/dist/registry.d.ts +33 -0
  9. package/dist/registry.js +88 -0
  10. package/dist/render/alert/Alert.d.ts +13 -0
  11. package/dist/render/alert/Alert.js +24 -0
  12. package/dist/render/audioplayer/AudioPlayerDefault.d.ts +6 -0
  13. package/dist/render/audioplayer/AudioPlayerDefault.js +75 -0
  14. package/dist/render/badge/Badge.d.ts +11 -0
  15. package/dist/render/badge/Badge.js +12 -0
  16. package/dist/render/button/Button.d.ts +14 -0
  17. package/dist/render/button/Button.js +20 -0
  18. package/dist/render/callout/CalloutDefault.d.ts +11 -0
  19. package/dist/render/callout/CalloutDefault.js +19 -0
  20. package/dist/render/callout/CalloutMinimal.d.ts +10 -0
  21. package/dist/render/callout/CalloutMinimal.js +16 -0
  22. package/dist/render/card/Card.d.ts +9 -0
  23. package/dist/render/card/Card.js +17 -0
  24. package/dist/render/codeblock/CodeBlockDefault.d.ts +14 -0
  25. package/dist/render/codeblock/CodeBlockDefault.js +56 -0
  26. package/dist/render/codeblock/language-labels.d.ts +8 -0
  27. package/dist/render/codeblock/language-labels.js +42 -0
  28. package/dist/render/gallery/GalleryGrid.d.ts +11 -0
  29. package/dist/render/gallery/GalleryGrid.js +106 -0
  30. package/dist/render/gallery/GalleryLightbox.d.ts +18 -0
  31. package/dist/render/gallery/GalleryLightbox.js +154 -0
  32. package/dist/render/gallery/gallery.d.ts +29 -0
  33. package/dist/render/gallery/gallery.js +56 -0
  34. package/dist/render/icon/Icon.d.ts +11 -0
  35. package/dist/render/icon/Icon.js +18 -0
  36. package/dist/render/index.d.ts +25 -0
  37. package/dist/render/index.js +26 -0
  38. package/dist/render/tooltip/Tooltip.d.ts +13 -0
  39. package/dist/render/tooltip/Tooltip.js +15 -0
  40. package/dist/render/youtube/YouTubeDefault.d.ts +11 -0
  41. package/dist/render/youtube/YouTubeDefault.js +53 -0
  42. package/dist/render/youtube/youtube.d.ts +8 -0
  43. package/dist/render/youtube/youtube.js +13 -0
  44. package/dist/server-render-map.d.ts +54 -0
  45. package/dist/server-render-map.js +56 -0
  46. package/package.json +77 -0
  47. package/src/MdxContent.tsx +105 -0
  48. package/src/index.ts +37 -0
  49. package/src/marked.ts +49 -0
  50. package/src/registry.ts +131 -0
  51. package/src/render/alert/Alert.tsx +32 -0
  52. package/src/render/audioplayer/AudioPlayerDefault.tsx +106 -0
  53. package/src/render/badge/Badge.tsx +24 -0
  54. package/src/render/button/Button.tsx +49 -0
  55. package/src/render/callout/CalloutDefault.tsx +34 -0
  56. package/src/render/callout/CalloutMinimal.tsx +25 -0
  57. package/src/render/card/Card.tsx +21 -0
  58. package/src/render/codeblock/CodeBlockDefault.tsx +82 -0
  59. package/src/render/codeblock/language-labels.ts +46 -0
  60. package/src/render/gallery/GalleryGrid.tsx +143 -0
  61. package/src/render/gallery/GalleryLightbox.tsx +201 -0
  62. package/src/render/gallery/gallery.ts +90 -0
  63. package/src/render/icon/Icon.tsx +25 -0
  64. package/src/render/index.ts +26 -0
  65. package/src/render/tooltip/Tooltip.tsx +38 -0
  66. package/src/render/youtube/YouTubeDefault.tsx +58 -0
  67. package/src/render/youtube/youtube.ts +17 -0
  68. package/src/server-render-map.ts +120 -0
@@ -0,0 +1,106 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useState } from 'react'
4
+ import type { AudioPlayerProps } from '@blogmcp/mdx-schema'
5
+
6
+ /**
7
+ * Audio player with a collapsible, lazy-loaded transcript. Client island —
8
+ * mirrors Vue AudioPlayerDefault.vue + useAudioPlayer.ts behaviour and classes.
9
+ */
10
+ export default function AudioPlayerDefault({ slug, title, showTranscription = false }: AudioPlayerProps) {
11
+ const hasTitle = !!title
12
+ const displayTitle = title || slug || 'Audio'
13
+ const audioUrl = slug ? `/api/media/file/${slug}` : ''
14
+ const transcriptionUrl = slug ? `/api/media/transcription/${slug}` : ''
15
+
16
+ const [transcriptionText, setTranscriptionText] = useState<string | null>(null)
17
+ const [transcriptionSummary, setTranscriptionSummary] = useState<string | null>(null)
18
+ const [transcriptionLoading, setTranscriptionLoading] = useState(false)
19
+ const [transcriptionExpanded, setTranscriptionExpanded] = useState(false)
20
+
21
+ const loadTranscription = useCallback(async () => {
22
+ if (!slug || transcriptionText !== null) return
23
+ setTranscriptionLoading(true)
24
+ try {
25
+ const response = await fetch(transcriptionUrl)
26
+ if (response.ok) {
27
+ const data = await response.json()
28
+ setTranscriptionText(data.fullText || data.full_text || '')
29
+ setTranscriptionSummary(data.summary || null)
30
+ } else {
31
+ setTranscriptionText('')
32
+ }
33
+ } catch {
34
+ setTranscriptionText('')
35
+ } finally {
36
+ setTranscriptionLoading(false)
37
+ }
38
+ }, [slug, transcriptionUrl, transcriptionText])
39
+
40
+ const toggleTranscription = useCallback(() => {
41
+ setTranscriptionExpanded((expanded) => {
42
+ const next = !expanded
43
+ if (next && transcriptionText === null) {
44
+ void loadTranscription()
45
+ }
46
+ return next
47
+ })
48
+ }, [loadTranscription, transcriptionText])
49
+
50
+ return (
51
+ <div className="audio-player my-6 rounded-lg bg-gray-50 p-5 border border-gray-200" data-mdx-component="AudioPlayer">
52
+ <div className="flex items-center gap-3 mb-3">
53
+ <svg className="w-5 h-5 text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
54
+ <path
55
+ strokeLinecap="round"
56
+ strokeLinejoin="round"
57
+ strokeWidth={2}
58
+ d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
59
+ />
60
+ </svg>
61
+ {hasTitle ? <h3 className="text-base font-semibold text-gray-800 m-0">{displayTitle}</h3> : null}
62
+ </div>
63
+
64
+ <audio src={audioUrl} controls preload="metadata" className="w-full" />
65
+
66
+ {showTranscription ? (
67
+ <div className="mt-4">
68
+ <button
69
+ type="button"
70
+ className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors cursor-pointer bg-transparent border-0 p-0"
71
+ onClick={toggleTranscription}
72
+ >
73
+ <svg
74
+ className={['w-4 h-4 transition-transform', transcriptionExpanded ? 'rotate-90' : ''].join(' ').trim()}
75
+ fill="none"
76
+ stroke="currentColor"
77
+ viewBox="0 0 24 24"
78
+ >
79
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
80
+ </svg>
81
+ Transcription
82
+ </button>
83
+ {transcriptionExpanded ? (
84
+ <div className="mt-2 p-3 bg-white rounded border border-gray-200 text-sm text-gray-700 leading-relaxed max-h-64 overflow-y-auto">
85
+ {transcriptionLoading ? (
86
+ <p className="text-gray-400 italic">Loading transcription...</p>
87
+ ) : !transcriptionText ? (
88
+ <p className="text-gray-400 italic">No transcription available.</p>
89
+ ) : (
90
+ <>
91
+ {transcriptionSummary ? (
92
+ <div className="mb-3 pb-3 border-b border-gray-200">
93
+ <div className="text-xs uppercase tracking-wide text-gray-400 mb-1">Summary</div>
94
+ <div className="text-gray-800">{transcriptionSummary}</div>
95
+ </div>
96
+ ) : null}
97
+ <div className="whitespace-pre-wrap">{transcriptionText}</div>
98
+ </>
99
+ )}
100
+ </div>
101
+ ) : null}
102
+ </div>
103
+ ) : null}
104
+ </div>
105
+ )
106
+ }
@@ -0,0 +1,24 @@
1
+ import { badgeStyles, type BadgeVariant, type BadgeColor } from '@blogmcp/mdx-schema/styles'
2
+
3
+ export interface BadgeProps {
4
+ label: string
5
+ color?: BadgeColor
6
+ variant?: BadgeVariant
7
+ }
8
+
9
+ /**
10
+ * Small label / tag. Mirrors Vue Badge.vue — a single <span> with the
11
+ * shared base + variant×color class table.
12
+ */
13
+ export default function Badge({ label, color = 'neutral', variant = 'soft' }: BadgeProps) {
14
+ const className = [
15
+ badgeStyles.base,
16
+ badgeStyles.variantColor[variant][color],
17
+ ].join(' ')
18
+
19
+ return (
20
+ <span className={className} data-mdx-component="Badge">
21
+ {label}
22
+ </span>
23
+ )
24
+ }
@@ -0,0 +1,49 @@
1
+ import { buttonStyles, type ButtonVariant, type ButtonColor, type ButtonSize } from '@blogmcp/mdx-schema/styles'
2
+ // URL sanitizer — returns 'about:blank' for javascript:/data:text/html/etc.
3
+ // Component-level prop sanitization: DOMPurify only sees HTML output, never
4
+ // JSX props consumed by render code. See:
5
+ // competitor-monitor/docs/internal/sanitization-trust-contract.md
6
+ import { sanitizeUrl } from '@blogmcp/sanitize'
7
+
8
+ export interface ButtonProps {
9
+ label: string
10
+ to?: string
11
+ variant?: ButtonVariant
12
+ color?: ButtonColor
13
+ size?: ButtonSize
14
+ }
15
+
16
+ /**
17
+ * Action button / CTA link. Mirrors Vue Button.vue:
18
+ * renders <a> when `to` is set, else <button type="button">.
19
+ * Class tables come from the shared @blogmcp/mdx-schema/styles module.
20
+ */
21
+ export default function Button({
22
+ label,
23
+ to,
24
+ variant = 'solid',
25
+ color = 'primary',
26
+ size = 'md',
27
+ }: ButtonProps) {
28
+ const className = [
29
+ buttonStyles.base,
30
+ buttonStyles.size[size],
31
+ buttonStyles.variantColor[variant][color],
32
+ ].join(' ')
33
+
34
+ const safeHref = to ? sanitizeUrl(to) : undefined
35
+
36
+ if (safeHref) {
37
+ return (
38
+ <a href={safeHref} className={className} data-mdx-component="Button">
39
+ {label}
40
+ </a>
41
+ )
42
+ }
43
+
44
+ return (
45
+ <button type="button" className={className} data-mdx-component="Button">
46
+ {label}
47
+ </button>
48
+ )
49
+ }
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { CalloutType } from '@blogmcp/mdx-schema'
3
+ import { calloutStyles } from '@blogmcp/mdx-schema/styles'
4
+
5
+ export interface CalloutProps {
6
+ type?: CalloutType
7
+ children?: ReactNode
8
+ }
9
+
10
+ /**
11
+ * Standard callout (icon + accent left border). Mirrors Vue CalloutDefault.vue.
12
+ * Color table + icon SVG path data come from the shared styles module.
13
+ */
14
+ export default function CalloutDefault({ type, children }: CalloutProps) {
15
+ const effectiveType: CalloutType = type || 'info'
16
+ const colors = calloutStyles.default.type[effectiveType]
17
+ const iconPath = calloutStyles.iconPaths[effectiveType]
18
+
19
+ return (
20
+ <div
21
+ className={[calloutStyles.default.base, colors.bg, colors.border, colors.text].join(' ')}
22
+ data-mdx-component="Callout"
23
+ >
24
+ <div className="flex items-start gap-3">
25
+ <div className="flex-shrink-0 mt-0.5">
26
+ <svg className={['w-5 h-5', colors.iconColor].join(' ')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
27
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={iconPath} />
28
+ </svg>
29
+ </div>
30
+ <div className="flex-1 text-sm leading-relaxed">{children}</div>
31
+ </div>
32
+ </div>
33
+ )
34
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { CalloutType } from '@blogmcp/mdx-schema'
3
+ import { calloutStyles } from '@blogmcp/mdx-schema/styles'
4
+
5
+ export interface CalloutProps {
6
+ type?: CalloutType
7
+ children?: ReactNode
8
+ }
9
+
10
+ /**
11
+ * Icon-less, flatter callout. Mirrors Vue CalloutMinimal.vue.
12
+ */
13
+ export default function CalloutMinimal({ type, children }: CalloutProps) {
14
+ const effectiveType: CalloutType = type || 'info'
15
+ const colors = calloutStyles.minimal.type[effectiveType]
16
+
17
+ return (
18
+ <div
19
+ className={[calloutStyles.minimal.base, colors.bg, colors.border, colors.text].join(' ')}
20
+ data-mdx-component="Callout"
21
+ >
22
+ <div className="text-sm leading-relaxed">{children}</div>
23
+ </div>
24
+ )
25
+ }
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export interface CardProps {
4
+ title?: string
5
+ children?: ReactNode
6
+ }
7
+
8
+ /**
9
+ * Bordered content block with optional title. Mirrors Vue Card.vue.
10
+ */
11
+ export default function Card({ title, children }: CardProps) {
12
+ return (
13
+ <section
14
+ className="mdx-card rounded-lg border border-gray-200 bg-white p-5 shadow-sm"
15
+ data-mdx-component="Card"
16
+ >
17
+ {title ? <h3 className="mb-3 text-lg font-semibold text-gray-900">{title}</h3> : null}
18
+ <div className="text-gray-700">{children}</div>
19
+ </section>
20
+ )
21
+ }
@@ -0,0 +1,82 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, useState, type ReactNode } from 'react'
4
+ import type { CodeBlockProps as SchemaCodeBlockProps } from '@blogmcp/mdx-schema'
5
+ import { getLanguageLabel } from './language-labels'
6
+
7
+ export interface CodeBlockProps extends SchemaCodeBlockProps {
8
+ children?: ReactNode
9
+ }
10
+
11
+ /**
12
+ * Syntax-highlighted code block. Client island — mirrors Vue CodeBlockDefault.vue
13
+ * + useCodeBlock.ts. The header (language label + optional title) and the
14
+ * `<pre><code>` block render as the initial DOM exactly as before; the copy
15
+ * button now copies the code via `navigator.clipboard.writeText` and shows a
16
+ * transient "Copied!" state (2s), toggling the same Tailwind classes as Vue.
17
+ * `code-block` + `data-mdx-component="CodeBlock"` are unchanged.
18
+ */
19
+ export default function CodeBlockDefault({ language, title, children }: CodeBlockProps) {
20
+ const languageLabel = getLanguageLabel(language)
21
+ const languageClass = `language-${language || 'text'}`
22
+
23
+ const [copied, setCopied] = useState(false)
24
+ const codeRef = useRef<HTMLElement>(null)
25
+
26
+ const handleCopy = useCallback(async () => {
27
+ const codeContent = codeRef.current?.textContent || ''
28
+ try {
29
+ await navigator.clipboard.writeText(codeContent)
30
+ setCopied(true)
31
+ setTimeout(() => setCopied(false), 2000)
32
+ } catch (error) {
33
+ console.error('Failed to copy code:', error)
34
+ }
35
+ }, [])
36
+
37
+ return (
38
+ <div
39
+ className="code-block my-6 rounded-lg overflow-hidden border border-gray-200 bg-gray-50"
40
+ data-mdx-component="CodeBlock"
41
+ >
42
+ <div className="flex items-center justify-between px-4 py-2 bg-gray-100 border-b border-gray-200">
43
+ <div className="flex items-center gap-2">
44
+ <span className="text-xs text-gray-600 font-medium">{languageLabel}</span>
45
+ {title ? <span className="text-xs text-gray-500">{title}</span> : null}
46
+ </div>
47
+ <button
48
+ type="button"
49
+ className={[
50
+ 'flex items-center gap-1 px-2 py-1 text-xs rounded transition-colors',
51
+ copied ? 'bg-green-500 text-white' : 'hover:bg-gray-200 text-gray-500 hover:text-gray-700',
52
+ ].join(' ')}
53
+ onClick={handleCopy}
54
+ >
55
+ {!copied ? (
56
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
57
+ <path
58
+ strokeLinecap="round"
59
+ strokeLinejoin="round"
60
+ strokeWidth={2}
61
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
62
+ />
63
+ </svg>
64
+ ) : (
65
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
66
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
67
+ </svg>
68
+ )}
69
+ {copied ? 'Copied!' : 'Copy'}
70
+ </button>
71
+ </div>
72
+
73
+ <div className="code-block-content p-4 overflow-x-auto">
74
+ <pre className="m-0">
75
+ <code ref={codeRef} className={languageClass}>
76
+ {children}
77
+ </code>
78
+ </pre>
79
+ </div>
80
+ </div>
81
+ )
82
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Language label mappings — mirrored verbatim from
3
+ * `@blogmcp/mdx-render-vue/src/composables/useCodeBlock.ts` so the React
4
+ * CodeBlock header reads identically to the Vue one.
5
+ */
6
+ export const languageLabels: Record<string, string> = {
7
+ js: 'JavaScript',
8
+ javascript: 'JavaScript',
9
+ ts: 'TypeScript',
10
+ typescript: 'TypeScript',
11
+ vue: 'Vue',
12
+ html: 'HTML',
13
+ css: 'CSS',
14
+ json: 'JSON',
15
+ python: 'Python',
16
+ py: 'Python',
17
+ bash: 'Bash',
18
+ sh: 'Shell',
19
+ sql: 'SQL',
20
+ text: 'Plain Text',
21
+ rust: 'Rust',
22
+ go: 'Go',
23
+ java: 'Java',
24
+ cpp: 'C++',
25
+ c: 'C',
26
+ csharp: 'C#',
27
+ php: 'PHP',
28
+ ruby: 'Ruby',
29
+ swift: 'Swift',
30
+ kotlin: 'Kotlin',
31
+ yaml: 'YAML',
32
+ yml: 'YAML',
33
+ xml: 'XML',
34
+ markdown: 'Markdown',
35
+ md: 'Markdown',
36
+ graphql: 'GraphQL',
37
+ dockerfile: 'Dockerfile',
38
+ }
39
+
40
+ /** Get display label for a language (capitalizes unknown languages). */
41
+ export function getLanguageLabel(language?: string): string {
42
+ if (!language) return 'Plain Text'
43
+ const lang = language.toLowerCase()
44
+ if (languageLabels[lang]) return languageLabels[lang]
45
+ return lang.charAt(0).toUpperCase() + lang.slice(1)
46
+ }
@@ -0,0 +1,143 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import type { GalleryProps } from '@blogmcp/mdx-schema'
5
+ import {
6
+ gridColsClass,
7
+ gapClass,
8
+ galleryTileLayout,
9
+ getImageSrc,
10
+ getImageAlt,
11
+ getImageDescription,
12
+ isVideo,
13
+ getVideoPoster,
14
+ } from './gallery'
15
+ import GalleryLightbox from './GalleryLightbox'
16
+
17
+ /**
18
+ * Image/video grid. Client island — mirrors Vue GalleryGrid.vue + useGallery.ts.
19
+ *
20
+ * The grid (initial DOM) is unchanged: `data-mdx-component="Gallery"`,
21
+ * `grid-cols-N`, video posters, captions, and expand-icon overlay all match the
22
+ * static/Vue versions. Clicking a (non-video) image opens a fullscreen lightbox
23
+ * (GalleryLightbox) with prev/next, Escape, and backdrop-to-close — the lightbox
24
+ * indexes only the image items, exactly like the Vue `imageOnlyIndices` mapping.
25
+ */
26
+ export default function GalleryGrid({ images, columns, gap, aspectRatio, fit, position }: GalleryProps) {
27
+ const [lightboxOpen, setLightboxOpen] = useState(false)
28
+ const [lightboxIndex, setLightboxIndex] = useState(0)
29
+
30
+ // Tile geometry from aspectRatio/fit/position — mirrors Vue GalleryGrid.vue.
31
+ const tileLayout = galleryTileLayout({ aspectRatio, fit, position })
32
+
33
+ // Map between full images-array indices and image-only (lightbox) indices.
34
+ const imageOnlyIndices = useMemo(() => {
35
+ const indices: number[] = []
36
+ if (!images) return indices
37
+ for (let i = 0; i < images.length; i++) {
38
+ if (!isVideo(images[i])) indices.push(i)
39
+ }
40
+ return indices
41
+ }, [images])
42
+
43
+ if (!images || images.length === 0) return null
44
+
45
+ const imageOnlyCount = imageOnlyIndices.length
46
+
47
+ function openLightbox(fullIndex: number) {
48
+ if (isVideo(images[fullIndex])) return
49
+ const lightboxIdx = imageOnlyIndices.indexOf(fullIndex)
50
+ if (lightboxIdx === -1) return
51
+ setLightboxIndex(lightboxIdx)
52
+ setLightboxOpen(true)
53
+ }
54
+
55
+ // Lightbox getters operate on lightbox (image-only) indices.
56
+ const lightboxGetSrc = (idx: number) => getImageSrc(images[imageOnlyIndices[idx]])
57
+ const lightboxGetAlt = (idx: number) => getImageAlt(images[imageOnlyIndices[idx]], imageOnlyIndices[idx])
58
+ const lightboxGetDescription = (idx: number) => getImageDescription(images[imageOnlyIndices[idx]])
59
+
60
+ return (
61
+ <>
62
+ <div
63
+ className={['gallery my-6', gridColsClass(columns), gapClass(gap)].join(' ')}
64
+ style={{ display: 'grid' }}
65
+ data-mdx-component="Gallery"
66
+ >
67
+ {images.map((image, index) => {
68
+ const description = getImageDescription(image)
69
+ const video = isVideo(image)
70
+ return (
71
+ <div key={index} className="gallery-item">
72
+ {/* Fixed-aspect tile (default square object-cover) — mirrors Vue
73
+ GalleryGrid.vue so both flavors render identically. Geometry
74
+ comes from tileLayout (aspectRatio/fit/position props). */}
75
+ <div
76
+ // Tile bg is media-type-specific: VIDEO keeps a dark cell
77
+ // (bg-gray-900) so a posterless video isn't blank white; IMAGE
78
+ // uses a light neutral (bg-gray-100) so `fit="contain"` letterbox
79
+ // bars + pre-load blend with the light theme (not a dark backdrop).
80
+ className={['gallery-media group relative overflow-hidden rounded', video ? 'bg-gray-900' : 'bg-gray-100 cursor-pointer'].join(' ').trim()}
81
+ style={tileLayout.tileStyle}
82
+ onClick={() => openLightbox(index)}
83
+ >
84
+ {video ? (
85
+ <video
86
+ src={getImageSrc(image)}
87
+ poster={getVideoPoster(image)}
88
+ controls
89
+ preload="metadata"
90
+ className={tileLayout.mediaSizeClass}
91
+ style={tileLayout.mediaStyle}
92
+ />
93
+ ) : (
94
+ <>
95
+ <img
96
+ src={getImageSrc(image)}
97
+ alt={getImageAlt(image, index)}
98
+ className={[tileLayout.mediaSizeClass, 'transition-transform duration-200 group-hover:scale-[1.03]'].join(' ')}
99
+ style={tileLayout.mediaStyle}
100
+ loading="lazy"
101
+ />
102
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200 pointer-events-none" />
103
+ <svg
104
+ className="gallery-expand-icon absolute top-2 right-2 text-white opacity-50 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-80 transition-opacity duration-200 drop-shadow-md pointer-events-none"
105
+ xmlns="http://www.w3.org/2000/svg"
106
+ width="20"
107
+ height="20"
108
+ viewBox="0 0 24 24"
109
+ fill="none"
110
+ stroke="currentColor"
111
+ strokeWidth={2}
112
+ strokeLinecap="round"
113
+ strokeLinejoin="round"
114
+ >
115
+ <polyline points="15 3 21 3 21 9" />
116
+ <polyline points="9 21 3 21 3 15" />
117
+ <line x1="21" y1="3" x2="14" y2="10" />
118
+ <line x1="3" y1="21" x2="10" y2="14" />
119
+ </svg>
120
+ </>
121
+ )}
122
+ </div>
123
+ {description ? (
124
+ <p className="gallery-description text-center text-sm text-gray-500 mt-2">{description}</p>
125
+ ) : null}
126
+ </div>
127
+ )
128
+ })}
129
+ </div>
130
+
131
+ <GalleryLightbox
132
+ isOpen={lightboxOpen}
133
+ currentIndex={lightboxIndex}
134
+ imageCount={imageOnlyCount}
135
+ getImageSrc={lightboxGetSrc}
136
+ getImageAlt={lightboxGetAlt}
137
+ getImageDescription={lightboxGetDescription}
138
+ onClose={() => setLightboxOpen(false)}
139
+ onNavigate={setLightboxIndex}
140
+ />
141
+ </>
142
+ )
143
+ }