feat: initial release v0.3.0
This commit is contained in:
84
src/components/media/MediaImage.tsx
Normal file
84
src/components/media/MediaImage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import type { CSSProperties, ImgHTMLAttributes, MouseEventHandler } from 'react'
|
||||
|
||||
export type MediaImageProps = {
|
||||
src: string | null | undefined
|
||||
alt: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
onClick?: MouseEventHandler<HTMLImageElement>
|
||||
fill?: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
sizes?: string
|
||||
priority?: boolean
|
||||
} & Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'width' | 'height'>
|
||||
|
||||
function isStableMediaRoute(src: string) {
|
||||
return src.startsWith('/m/')
|
||||
}
|
||||
|
||||
export function MediaImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
style,
|
||||
onClick,
|
||||
fill = false,
|
||||
width = 1200,
|
||||
height = 1200,
|
||||
sizes,
|
||||
priority = false,
|
||||
...imgProps
|
||||
}: MediaImageProps) {
|
||||
if (!src) return null
|
||||
|
||||
if (isStableMediaRoute(src)) {
|
||||
if (fill) {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes={sizes || '100vw'}
|
||||
priority={priority}
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
{...imgProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
sizes={sizes}
|
||||
priority={priority}
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
{...imgProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// 外部 URL 兜底,避免 next/image 远程域名限制影响兼容链路
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
{...imgProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
91
src/components/media/MediaImageWithLoading.tsx
Normal file
91
src/components/media/MediaImageWithLoading.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MediaImage, type MediaImageProps } from './MediaImage'
|
||||
|
||||
type MediaImageWithLoadingProps = MediaImageProps & {
|
||||
containerClassName?: string
|
||||
skeletonClassName?: string
|
||||
keepSkeletonOnError?: boolean
|
||||
showLoadingIndicator?: boolean
|
||||
loadingIndicatorClassName?: string
|
||||
}
|
||||
|
||||
function mergeClassNames(...classNames: Array<string | undefined | false>): string {
|
||||
return classNames.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export function MediaImageWithLoading({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
containerClassName,
|
||||
skeletonClassName,
|
||||
keepSkeletonOnError = false,
|
||||
showLoadingIndicator = true,
|
||||
loadingIndicatorClassName,
|
||||
onLoad,
|
||||
onError,
|
||||
...restProps
|
||||
}: MediaImageWithLoadingProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isError, setIsError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false)
|
||||
setIsError(false)
|
||||
}, [src])
|
||||
|
||||
if (!src) return null
|
||||
|
||||
const shouldShowSkeleton = !isLoaded && (!isError || keepSkeletonOnError)
|
||||
|
||||
const imageClassName = mergeClassNames(
|
||||
className,
|
||||
'transition-opacity duration-200',
|
||||
shouldShowSkeleton ? 'opacity-0' : 'opacity-100',
|
||||
)
|
||||
|
||||
const handleLoad: NonNullable<MediaImageProps['onLoad']> = (event) => {
|
||||
setIsLoaded(true)
|
||||
onLoad?.(event)
|
||||
}
|
||||
|
||||
const handleError: NonNullable<MediaImageProps['onError']> = (event) => {
|
||||
setIsError(true)
|
||||
setIsLoaded(true)
|
||||
onError?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={mergeClassNames('relative overflow-hidden bg-[var(--glass-bg-muted)]', containerClassName)}>
|
||||
{shouldShowSkeleton && (
|
||||
<div
|
||||
className={mergeClassNames(
|
||||
'pointer-events-none absolute inset-0 z-0 animate-pulse bg-[var(--glass-bg-muted)]',
|
||||
skeletonClassName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowSkeleton && showLoadingIndicator && (
|
||||
<div
|
||||
className={mergeClassNames(
|
||||
'pointer-events-none absolute inset-0 z-[1] flex items-center justify-center',
|
||||
loadingIndicatorClassName,
|
||||
)}
|
||||
>
|
||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-[var(--glass-stroke-strong)] border-t-[var(--glass-tone-info-fg)]" />
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)}
|
||||
<MediaImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={imageClassName}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user