@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.
- package/README.md +32 -0
- package/dist/MdxContent.d.ts +29 -0
- package/dist/MdxContent.js +51 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +43 -0
- package/dist/marked.d.ts +34 -0
- package/dist/marked.js +15 -0
- package/dist/registry.d.ts +33 -0
- package/dist/registry.js +88 -0
- package/dist/render/alert/Alert.d.ts +13 -0
- package/dist/render/alert/Alert.js +24 -0
- package/dist/render/audioplayer/AudioPlayerDefault.d.ts +6 -0
- package/dist/render/audioplayer/AudioPlayerDefault.js +75 -0
- package/dist/render/badge/Badge.d.ts +11 -0
- package/dist/render/badge/Badge.js +12 -0
- package/dist/render/button/Button.d.ts +14 -0
- package/dist/render/button/Button.js +20 -0
- package/dist/render/callout/CalloutDefault.d.ts +11 -0
- package/dist/render/callout/CalloutDefault.js +19 -0
- package/dist/render/callout/CalloutMinimal.d.ts +10 -0
- package/dist/render/callout/CalloutMinimal.js +16 -0
- package/dist/render/card/Card.d.ts +9 -0
- package/dist/render/card/Card.js +17 -0
- package/dist/render/codeblock/CodeBlockDefault.d.ts +14 -0
- package/dist/render/codeblock/CodeBlockDefault.js +56 -0
- package/dist/render/codeblock/language-labels.d.ts +8 -0
- package/dist/render/codeblock/language-labels.js +42 -0
- package/dist/render/gallery/GalleryGrid.d.ts +11 -0
- package/dist/render/gallery/GalleryGrid.js +106 -0
- package/dist/render/gallery/GalleryLightbox.d.ts +18 -0
- package/dist/render/gallery/GalleryLightbox.js +154 -0
- package/dist/render/gallery/gallery.d.ts +29 -0
- package/dist/render/gallery/gallery.js +56 -0
- package/dist/render/icon/Icon.d.ts +11 -0
- package/dist/render/icon/Icon.js +18 -0
- package/dist/render/index.d.ts +25 -0
- package/dist/render/index.js +26 -0
- package/dist/render/tooltip/Tooltip.d.ts +13 -0
- package/dist/render/tooltip/Tooltip.js +15 -0
- package/dist/render/youtube/YouTubeDefault.d.ts +11 -0
- package/dist/render/youtube/YouTubeDefault.js +53 -0
- package/dist/render/youtube/youtube.d.ts +8 -0
- package/dist/render/youtube/youtube.js +13 -0
- package/dist/server-render-map.d.ts +54 -0
- package/dist/server-render-map.js +56 -0
- package/package.json +77 -0
- package/src/MdxContent.tsx +105 -0
- package/src/index.ts +37 -0
- package/src/marked.ts +49 -0
- package/src/registry.ts +131 -0
- package/src/render/alert/Alert.tsx +32 -0
- package/src/render/audioplayer/AudioPlayerDefault.tsx +106 -0
- package/src/render/badge/Badge.tsx +24 -0
- package/src/render/button/Button.tsx +49 -0
- package/src/render/callout/CalloutDefault.tsx +34 -0
- package/src/render/callout/CalloutMinimal.tsx +25 -0
- package/src/render/card/Card.tsx +21 -0
- package/src/render/codeblock/CodeBlockDefault.tsx +82 -0
- package/src/render/codeblock/language-labels.ts +46 -0
- package/src/render/gallery/GalleryGrid.tsx +143 -0
- package/src/render/gallery/GalleryLightbox.tsx +201 -0
- package/src/render/gallery/gallery.ts +90 -0
- package/src/render/icon/Icon.tsx +25 -0
- package/src/render/index.ts +26 -0
- package/src/render/tooltip/Tooltip.tsx +38 -0
- package/src/render/youtube/YouTubeDefault.tsx +58 -0
- package/src/render/youtube/youtube.ts +17 -0
- 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
|
+
}
|