From 5856a9eaf65fbc2b4f285f6e2157c8c042b87842 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 15 May 2026 13:27:22 +0500 Subject: [PATCH] feat: add OptimizedImage component with AVIF/WebP srcset --- client/src/shared/ui/OptimizedImage.tsx | 87 +++++++++++++++++++ .../ui/__tests__/OptimizedImage.test.tsx | 48 ++++++++++ 2 files changed, 135 insertions(+) create mode 100644 client/src/shared/ui/OptimizedImage.tsx create mode 100644 client/src/shared/ui/__tests__/OptimizedImage.test.tsx diff --git a/client/src/shared/ui/OptimizedImage.tsx b/client/src/shared/ui/OptimizedImage.tsx new file mode 100644 index 0000000..29c4c08 --- /dev/null +++ b/client/src/shared/ui/OptimizedImage.tsx @@ -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 + 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 ( + + ) + } + + const sizesAttr = sizes ?? '(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px' + + return ( + + + + + + ) +} diff --git a/client/src/shared/ui/__tests__/OptimizedImage.test.tsx b/client/src/shared/ui/__tests__/OptimizedImage.test.tsx new file mode 100644 index 0000000..be4501b --- /dev/null +++ b/client/src/shared/ui/__tests__/OptimizedImage.test.tsx @@ -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() + 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() + 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() + 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() + const img = screen.getByAltText('hero') as HTMLImageElement + expect(img.getAttribute('loading')).toBe('eager') + }) + + it('respects custom widths', () => { + render() + 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') + }) +})