🖼️ added images support
- Should investigate how to resize the image from .md specs
This commit is contained in:
79
components/blog/ImageGallery.tsx
Normal file
79
components/blog/ImageGallery.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { OptimizedImage } from './OptimizedImage'
|
||||
|
||||
interface ImageItem {
|
||||
src: string
|
||||
alt: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
interface ImageGalleryProps {
|
||||
images: ImageItem[]
|
||||
columns?: 2 | 3 | 4
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ImageGallery({ images, columns = 3, className = '' }: ImageGalleryProps) {
|
||||
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null)
|
||||
|
||||
const gridCols = {
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`grid ${gridCols[columns]} gap-4 ${className}`}>
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(image)}
|
||||
className="group relative overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/50 transition-all hover:border-emerald-500 hover:shadow-lg hover:shadow-emerald-500/20"
|
||||
>
|
||||
<div className="aspect-video relative">
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
{image.caption && <div className="p-2 text-sm text-zinc-400">{image.caption}</div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<button
|
||||
className="absolute top-4 right-4 text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="max-w-5xl w-full" onClick={e => e.stopPropagation()}>
|
||||
<OptimizedImage
|
||||
src={selectedImage.src}
|
||||
alt={selectedImage.alt}
|
||||
caption={selectedImage.caption}
|
||||
width={1200}
|
||||
height={800}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
65
components/blog/OptimizedImage.tsx
Normal file
65
components/blog/OptimizedImage.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface OptimizedImageProps {
|
||||
src: string
|
||||
alt: string
|
||||
caption?: string
|
||||
width?: number
|
||||
height?: number
|
||||
priority?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function OptimizedImage({
|
||||
src,
|
||||
alt,
|
||||
caption,
|
||||
width = 800,
|
||||
height = 600,
|
||||
priority = false,
|
||||
className = '',
|
||||
}: OptimizedImageProps) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="my-8 rounded-lg border border-zinc-800 bg-zinc-900/50 p-8 text-center">
|
||||
<p className="text-zinc-400">Failed to load image</p>
|
||||
{caption && <p className="mt-2 text-sm text-zinc-500">{caption}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<figure className={`my-8 ${className}`}>
|
||||
<div className="relative overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/50">
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
priority={priority}
|
||||
className={`w-full h-auto transition-opacity duration-300 ${
|
||||
isLoading ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onError={() => setHasError(true)}
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Crect width='400' height='300' fill='%2318181b'/%3E%3C/svg%3E"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{caption && (
|
||||
<figcaption className="mt-3 text-center text-sm text-zinc-400">{caption}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -2,87 +2,200 @@
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import { OptimizedImage } from './OptimizedImage'
|
||||
import { CodeBlock } from './code-block'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => {
|
||||
const text = String(children)
|
||||
const id = text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
return <h1 id={id}>{children}</h1>
|
||||
},
|
||||
h2: ({ children }) => {
|
||||
const text = String(children)
|
||||
const id = text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
return <h2 id={id}>{children}</h2>
|
||||
},
|
||||
h3: ({ children }) => {
|
||||
const text = String(children)
|
||||
const id = text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
return <h3 id={id}>{children}</h3>
|
||||
},
|
||||
code: ({ inline, className, children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
if (!inline && match) {
|
||||
return <CodeBlock code={String(children).replace(/\n$/, '')} language={match[1]} />
|
||||
}
|
||||
return <code {...props}>{children}</code>
|
||||
},
|
||||
img: ({ src, alt }) => {
|
||||
if (!src || typeof src !== 'string') return null
|
||||
const isExternal = src.startsWith('http://') || src.startsWith('https://')
|
||||
<div className={`prose prose-invert prose-zinc max-w-none ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{
|
||||
img: ({ node, src, alt, title, ...props }) => {
|
||||
if (!src || typeof src !== 'string') return null
|
||||
|
||||
if (isExternal) {
|
||||
return <img src={src} alt={alt || ''} className="w-full h-auto" />
|
||||
}
|
||||
const isExternal = src.startsWith('http://') || src.startsWith('https://')
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-auto">
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
width={800}
|
||||
height={600}
|
||||
style={{ width: '100%', height: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
a: ({ href, children }) => {
|
||||
if (!href) return <>{children}</>
|
||||
const isExternal = href.startsWith('http://') || href.startsWith('https://')
|
||||
if (isExternal) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
title={title}
|
||||
className="rounded-lg border border-zinc-800"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Ensure absolute path for Next Image
|
||||
const absoluteSrc = src.startsWith('/') ? src : `/${src}`
|
||||
|
||||
const titleStr = typeof title === 'string' ? title : ''
|
||||
const [altText, caption] = titleStr?.includes('|')
|
||||
? titleStr.split('|').map(s => s.trim())
|
||||
: [alt, undefined]
|
||||
|
||||
const url = new URL(absoluteSrc, 'http://localhost')
|
||||
const width = url.searchParams.get('w') ? parseInt(url.searchParams.get('w')!) : 800
|
||||
const height = url.searchParams.get('h') ? parseInt(url.searchParams.get('h')!) : 600
|
||||
const cleanSrc = absoluteSrc.split('?')[0]
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
<OptimizedImage
|
||||
src={cleanSrc}
|
||||
alt={altText || alt || ''}
|
||||
caption={caption}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
)
|
||||
}
|
||||
},
|
||||
code: ({ node, className, children, ...props }) => {
|
||||
const inline = !className && typeof children === 'string' && !children.includes('\n')
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const language = match ? match[1] : ''
|
||||
|
||||
return <Link href={href}>{children}</Link>
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
if (inline) {
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-zinc-900 px-1.5 py-0.5 text-sm text-emerald-400"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return <CodeBlock code={String(children).replace(/\n$/, '')} language={language} />
|
||||
},
|
||||
a: ({ node, href, children, ...props }) => {
|
||||
if (!href) return <a {...props}>{children}</a>
|
||||
|
||||
const isExternal = href.startsWith('http://') || href.startsWith('https://')
|
||||
const isAnchor = href.startsWith('#')
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-emerald-400 hover:text-emerald-300 inline-flex items-center gap-1"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAnchor) {
|
||||
return (
|
||||
<a href={href} className="text-emerald-400 hover:text-emerald-300" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className="text-emerald-400 hover:text-emerald-300" {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
h1: ({ node, children, ...props }) => {
|
||||
const text = String(children)
|
||||
const id = text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
return (
|
||||
<h1 id={id} className="text-3xl font-bold text-zinc-100 mt-8 mb-4" {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
h2: ({ node, children, ...props }) => {
|
||||
const text = String(children)
|
||||
const id = text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
return (
|
||||
<h2 id={id} className="text-2xl font-bold text-zinc-100 mt-6 mb-3" {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
},
|
||||
h3: ({ node, children, ...props }) => {
|
||||
const text = String(children)
|
||||
const id = text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
return (
|
||||
<h3 id={id} className="text-xl font-bold text-zinc-100 mt-4 mb-2" {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
ul: ({ node, children, ...props }) => (
|
||||
<ul className="list-disc list-inside space-y-2 text-zinc-300" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ node, children, ...props }) => (
|
||||
<ol className="list-decimal list-inside space-y-2 text-zinc-300" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
blockquote: ({ node, children, ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-emerald-500 pl-4 italic text-zinc-400"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ node, children, ...props }) => (
|
||||
<div className="overflow-x-auto my-6">
|
||||
<table className="min-w-full border border-zinc-800" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ node, children, ...props }) => (
|
||||
<th
|
||||
className="bg-zinc-900 px-4 py-2 text-left font-bold text-zinc-100 border border-zinc-800"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ node, children, ...props }) => (
|
||||
<td className="px-4 py-2 text-zinc-300 border border-zinc-800" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import Link from 'next/link';
|
||||
import { getPopularTags } from '@/lib/tags';
|
||||
import { TagBadge } from './tag-badge';
|
||||
import Link from 'next/link'
|
||||
import { getPopularTags } from '@/lib/tags'
|
||||
import { TagBadge } from './tag-badge'
|
||||
|
||||
export async function PopularTags({ limit = 5 }: { limit?: number }) {
|
||||
const tags = await getPopularTags(limit);
|
||||
const tags = await getPopularTags(limit)
|
||||
|
||||
if (tags.length === 0) return null;
|
||||
if (tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="border-2 border-slate-700 bg-slate-900 p-6">
|
||||
<div className="border-b-2 border-slate-700 pb-3 mb-4">
|
||||
<h3 className="font-mono text-sm font-bold uppercase text-cyan-400">
|
||||
POPULAR TAGS
|
||||
</h3>
|
||||
<h3 className="font-mono text-sm font-bold uppercase text-cyan-400">POPULAR TAGS</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{tags.map((tag, index) => (
|
||||
@@ -22,9 +20,7 @@ export async function PopularTags({ limit = 5 }: { limit?: number }) {
|
||||
className="flex items-center justify-between group p-2 border border-slate-700 hover:border-cyan-400 hover:bg-slate-800 transition"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="font-mono text-xs text-zinc-500">
|
||||
[{index + 1}]
|
||||
</span>
|
||||
<span className="font-mono text-xs text-zinc-500">[{index + 1}]</span>
|
||||
<span className="font-mono text-xs uppercase group-hover:text-cyan-400 transition">
|
||||
#{tag.name}
|
||||
</span>
|
||||
@@ -40,5 +36,5 @@ export async function PopularTags({ limit = 5 }: { limit?: number }) {
|
||||
> VIEW ALL TAGS
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface TagBadgeProps {
|
||||
count: number;
|
||||
className?: string;
|
||||
count: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TagBadge({ count, className = '' }: TagBadgeProps) {
|
||||
@@ -16,5 +16,5 @@ export function TagBadge({ count, className = '' }: TagBadgeProps) {
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import { TagInfo } from '@/lib/tags';
|
||||
import Link from 'next/link'
|
||||
import { TagInfo } from '@/lib/tags'
|
||||
|
||||
interface TagCloudProps {
|
||||
tags: Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>;
|
||||
tags: Array<TagInfo & { size: 'sm' | 'md' | 'lg' | 'xl' }>
|
||||
}
|
||||
|
||||
export function TagCloud({ tags }: TagCloudProps) {
|
||||
@@ -11,7 +11,7 @@ export function TagCloud({ tags }: TagCloudProps) {
|
||||
md: 'text-sm',
|
||||
lg: 'text-base font-bold',
|
||||
xl: 'text-lg font-bold',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-4 items-baseline">
|
||||
@@ -32,5 +32,5 @@ export function TagCloud({ tags }: TagCloudProps) {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import Link from 'next/link';
|
||||
import { slugifyTag } from '@/lib/tags';
|
||||
import Link from 'next/link'
|
||||
import { slugifyTag } from '@/lib/tags'
|
||||
|
||||
interface TagListProps {
|
||||
tags: (string | undefined)[];
|
||||
variant?: 'default' | 'minimal' | 'colored';
|
||||
className?: string;
|
||||
tags: (string | undefined)[]
|
||||
variant?: 'default' | 'minimal' | 'colored'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TagList({ tags, variant = 'default', className = '' }: TagListProps) {
|
||||
const validTags = tags.filter(Boolean) as string[];
|
||||
const validTags = tags.filter(Boolean) as string[]
|
||||
|
||||
if (validTags.length === 0) return null;
|
||||
if (validTags.length === 0) return null
|
||||
|
||||
const baseClasses = 'inline-flex items-center font-mono text-xs uppercase border transition-colors';
|
||||
const baseClasses =
|
||||
'inline-flex items-center font-mono text-xs uppercase border transition-colors'
|
||||
|
||||
const variants = {
|
||||
default: 'px-3 py-1 bg-zinc-900 border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400',
|
||||
default:
|
||||
'px-3 py-1 bg-zinc-900 border-slate-700 text-zinc-400 hover:border-cyan-400 hover:text-cyan-400',
|
||||
minimal: 'px-2 py-0.5 border-transparent text-zinc-500 hover:text-cyan-400',
|
||||
colored: 'px-3 py-1 bg-cyan-900 border-cyan-700 text-cyan-300 hover:bg-cyan-800 hover:border-cyan-600',
|
||||
};
|
||||
colored:
|
||||
'px-3 py-1 bg-cyan-900 border-cyan-700 text-cyan-300 hover:bg-cyan-800 hover:border-cyan-600',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-2 ${className}`}>
|
||||
@@ -33,5 +36,5 @@ export function TagList({ tags, variant = 'default', className = '' }: TagListPr
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user