feat: add OptimizedImage component with AVIF/WebP srcset
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles'
|
||||||
|
|
||||||
|
const DEFAULT_WIDTHS = [320, 640, 1024, 1600]
|
||||||
|
|
||||||
|
type OptimizedImageProps = {
|
||||||
|
src: string
|
||||||
|
alt: string
|
||||||
|
widths?: number[]
|
||||||
|
sizes?: string
|
||||||
|
sx?: SxProps<Theme>
|
||||||
|
priority?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract UUID and subdir from a /uploads/... URL */
|
||||||
|
function parseUploadUrl(src: string): { uuid: string; ext: string; subdir: string } | null {
|
||||||
|
const match = src.match(/^\/uploads(?:\/(reviews))?\/([^.\/]+)\.(png|jpe?g|webp)/i)
|
||||||
|
if (!match) return null
|
||||||
|
return { subdir: match[1] || '', uuid: match[2], ext: match[3].toLowerCase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSrcSet(src: string, widths: number[]): string | null {
|
||||||
|
const parsed = parseUploadUrl(src)
|
||||||
|
if (!parsed) return null
|
||||||
|
|
||||||
|
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
|
||||||
|
return widths
|
||||||
|
.map((w) => `/uploads-resized/${pathPrefix}${parsed.uuid}.avif?w=${w} ${w}w`)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackSrc(src: string, width: number): string {
|
||||||
|
const parsed = parseUploadUrl(src)
|
||||||
|
if (!parsed) return src
|
||||||
|
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
|
||||||
|
return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptimizedImage({ src, alt, widths = DEFAULT_WIDTHS, sizes, sx, priority = false }: OptimizedImageProps) {
|
||||||
|
const srcSet = useMemo(() => buildSrcSet(src, widths), [src, widths])
|
||||||
|
const fallbackSrc = useMemo(() => buildFallbackSrc(src, widths[0]), [src, widths])
|
||||||
|
|
||||||
|
// If src is not an upload URL, render a plain img
|
||||||
|
if (!srcSet) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
loading={priority ? 'eager' : 'lazy'}
|
||||||
|
decoding="async"
|
||||||
|
sx={sx}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizesAttr = sizes ?? '(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="picture"
|
||||||
|
sx={{ display: 'block', lineHeight: 0, ...sx }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="source"
|
||||||
|
type="image/avif"
|
||||||
|
srcSet={srcSet}
|
||||||
|
sizes={sizesAttr}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
component="source"
|
||||||
|
type="image/webp"
|
||||||
|
srcSet={fallbackSrc}
|
||||||
|
sizes={sizesAttr}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={fallbackSrc}
|
||||||
|
alt={alt}
|
||||||
|
loading={priority ? 'eager' : 'lazy'}
|
||||||
|
decoding="async"
|
||||||
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||||
|
|
||||||
|
describe('OptimizedImage', () => {
|
||||||
|
it('renders a plain img for non-upload URLs', () => {
|
||||||
|
render(<OptimizedImage src="https://example.com/photo.jpg" alt="test" />)
|
||||||
|
const img = screen.getByAltText('test') as HTMLImageElement
|
||||||
|
expect(img.src).toBe('https://example.com/photo.jpg')
|
||||||
|
expect(img.getAttribute('loading')).toBe('lazy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders picture element with srcset for upload URLs', () => {
|
||||||
|
render(<OptimizedImage src="/uploads/abc123.png" alt="product" />)
|
||||||
|
const picture = screen.getByAltText('product').closest('picture')
|
||||||
|
expect(picture).not.toBeNull()
|
||||||
|
|
||||||
|
const avifSource = picture?.querySelector('source[type="image/avif"]') as HTMLSourceElement
|
||||||
|
expect(avifSource).not.toBeNull()
|
||||||
|
const srcSet = avifSource.getAttribute('srcset') ?? ''
|
||||||
|
expect(srcSet).toContain('/uploads-resized/abc123.avif?w=320')
|
||||||
|
expect(srcSet).toContain('/uploads-resized/abc123.avif?w=640')
|
||||||
|
expect(srcSet).toContain('/uploads-resized/abc123.avif?w=1024')
|
||||||
|
expect(srcSet).toContain('/uploads-resized/abc123.avif?w=1600')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles review subdir correctly', () => {
|
||||||
|
render(<OptimizedImage src="/uploads/reviews/def456.jpg" alt="review" />)
|
||||||
|
const avifSource = screen.getByAltText('review').closest('picture')?.querySelector('source[type="image/avif"]') as HTMLSourceElement
|
||||||
|
const srcSet = avifSource?.getAttribute('srcset') ?? ''
|
||||||
|
expect(srcSet).toContain('/uploads-resized/reviews/def456.avif?w=320')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses eager loading when priority is true', () => {
|
||||||
|
render(<OptimizedImage src="/uploads/abc123.png" alt="hero" priority />)
|
||||||
|
const img = screen.getByAltText('hero') as HTMLImageElement
|
||||||
|
expect(img.getAttribute('loading')).toBe('eager')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects custom widths', () => {
|
||||||
|
render(<OptimizedImage src="/uploads/abc123.png" alt="test" widths={[200, 400]} />)
|
||||||
|
const avifSource = screen.getByAltText('test').closest('picture')?.querySelector('source[type="image/avif"]') as HTMLSourceElement
|
||||||
|
const srcSet = avifSource?.getAttribute('srcset') ?? ''
|
||||||
|
expect(srcSet).toContain('?w=200')
|
||||||
|
expect(srcSet).toContain('?w=400')
|
||||||
|
expect(srcSet).not.toContain('?w=640')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user