feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View 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}
/>
)
}

View 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>
)
}