1293 lines
40 KiB
Markdown
1293 lines
40 KiB
Markdown
# Lighthouse Optimization Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Optimize site loading to achieve ~100 Lighthouse score through image resizing, lazy loading, cache headers, code splitting, and meta tags.
|
|
|
|
**Architecture:** Server-side image resizing via sharp (on-demand AVIF/WebP generation with disk cache), client `<OptimizedImage>` component with srcset/sizes, route-level lazy loading for admin/me pages, Vite manual chunks for vendor splitting.
|
|
|
|
**Tech Stack:** sharp (server), React + MUI (client), Vite, Fastify
|
|
|
|
---
|
|
|
|
### Task 1: Install sharp dependency
|
|
|
|
**Files:**
|
|
- Modify: `server/package.json`
|
|
|
|
- [ ] **Step 1: Add sharp to server dependencies**
|
|
|
|
Run: `npm install sharp` in `server/` directory
|
|
|
|
```bash
|
|
cd /mnt/d/my_projects/shop/server && npm install sharp
|
|
```
|
|
|
|
Expected: sharp added to package.json dependencies, node_modules updated.
|
|
|
|
---
|
|
|
|
### Task 2: Server image resize library
|
|
|
|
**Files:**
|
|
- Create: `server/src/lib/image-resize.js`
|
|
|
|
- [ ] **Step 1: Create the image resize library**
|
|
|
|
```js
|
|
// server/src/lib/image-resize.js
|
|
import crypto from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
|
|
const CACHE_DIR = path.join(UPLOADS_DIR, '.cache')
|
|
const VALID_WIDTHS = [320, 640, 1024, 1600]
|
|
const SUPPORTED_FORMATS = new Set(['avif', 'webp'])
|
|
|
|
/**
|
|
* Find the original file by UUID (without extension) in the uploads directory tree.
|
|
* Searches both /uploads/ and /uploads/reviews/.
|
|
* Returns full path or null.
|
|
*/
|
|
export async function findOriginalFile(uuid, subdir = '') {
|
|
const searchDirs = subdir ? [subdir] : ['', 'reviews']
|
|
for (const dir of searchDirs) {
|
|
for (const ext of ['.png', '.jpg', '.jpeg', '.webp']) {
|
|
const fullPath = path.join(UPLOADS_DIR, dir, `${uuid}${ext}`)
|
|
try {
|
|
await fs.promises.access(fullPath)
|
|
return fullPath
|
|
} catch {
|
|
// file not found with this extension, try next
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Get or generate a resized image. Returns { path: string, isNew: boolean }.
|
|
*/
|
|
export async function getOrCreateResized(uuid, width, format, subdir = '') {
|
|
const cacheSubdir = subdir ? subdir : ''
|
|
const cacheFileName = `${uuid}_w${width}.${format}`
|
|
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
|
|
|
|
try {
|
|
await fs.promises.access(cachePath)
|
|
return { path: cachePath, isNew: false }
|
|
} catch {
|
|
// cache miss, need to generate
|
|
}
|
|
|
|
const originalPath = await findOriginalFile(uuid, subdir)
|
|
if (!originalPath) {
|
|
return null
|
|
}
|
|
|
|
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true })
|
|
|
|
const sharp = (await import('sharp')).default
|
|
let pipeline = sharp(originalPath)
|
|
|
|
if (width) {
|
|
pipeline = pipeline.resize(width, null, { withoutEnlargement: true })
|
|
}
|
|
|
|
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
|
|
await pipeline[format](options).toFile(cachePath)
|
|
|
|
return { path: cachePath, isNew: true }
|
|
}
|
|
|
|
export { VALID_WIDTHS, SUPPORTED_FORMATS }
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Server route for resized images
|
|
|
|
**Files:**
|
|
- Create: `server/src/routes/uploads-resized.js`
|
|
- Modify: `server/src/index.js`
|
|
|
|
- [ ] **Step 1: Create the uploads-resized route**
|
|
|
|
```js
|
|
// server/src/routes/uploads-resized.js
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js'
|
|
|
|
const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable'
|
|
const CACHE_CONTROL_SHORT = 'public, max-age=86400'
|
|
|
|
/**
|
|
* Register GET /uploads-resized/* route for on-demand image resizing.
|
|
* Must be registered BEFORE fastify-static for /uploads/.
|
|
*/
|
|
export function registerUploadsResized(fastify) {
|
|
fastify.get('/uploads-resized/*', async (request, reply) => {
|
|
const rawPath = request.params['*']
|
|
const url = new URL(request.url, 'http://localhost')
|
|
const widthParam = url.searchParams.get('w')
|
|
|
|
// Parse: [subdir/]filename.format
|
|
const parts = rawPath.split('/')
|
|
let filename, subdir = ''
|
|
|
|
if (parts.length > 1) {
|
|
subdir = parts.slice(0, -1).join('/') + '/'
|
|
filename = parts[parts.length - 1]
|
|
} else {
|
|
filename = parts[0]
|
|
}
|
|
|
|
const dotIdx = filename.lastIndexOf('.')
|
|
if (dotIdx === -1) {
|
|
return reply.code(400).send({ error: 'Invalid request: no format specified' })
|
|
}
|
|
|
|
const uuid = filename.slice(0, dotIdx)
|
|
const format = filename.slice(dotIdx + 1).toLowerCase()
|
|
|
|
if (!SUPPORTED_FORMATS.has(format)) {
|
|
return reply.code(400).send({ error: `Unsupported format: ${format}. Use avif or webp.` })
|
|
}
|
|
|
|
// Validate width
|
|
let width = null
|
|
if (widthParam) {
|
|
const w = parseInt(widthParam, 10)
|
|
if (!VALID_WIDTHS.includes(w)) {
|
|
return reply.code(400).send({ error: `Invalid width: ${widthParam}. Use: ${VALID_WIDTHS.join(', ')}` })
|
|
}
|
|
width = w
|
|
}
|
|
|
|
// If no width requested, serve original with short cache
|
|
if (!width) {
|
|
const originalPath = await findOriginalFile(uuid, subdir || undefined)
|
|
if (!originalPath) {
|
|
return reply.code(404).send({ error: 'Image not found' })
|
|
}
|
|
reply.header('Cache-Control', CACHE_CONTROL_SHORT)
|
|
reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp')
|
|
return reply.send(fs.createReadStream(originalPath))
|
|
}
|
|
|
|
const result = await getOrCreateResized(uuid, width, format, subdir || undefined)
|
|
if (!result) {
|
|
return reply.code(404).send({ error: 'Image not found' })
|
|
}
|
|
|
|
reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE)
|
|
reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp')
|
|
return reply.send(fs.createReadStream(result.path))
|
|
})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Register the route in server/src/index.js**
|
|
|
|
Read `server/src/index.js` and add the import + registration. Insert the route registration BEFORE `fastifyStatic` registration.
|
|
|
|
```js
|
|
// Add import at top
|
|
import { registerUploadsResized } from './routes/uploads-resized.js'
|
|
|
|
// Add BEFORE the fastifyStatic registration (line ~49):
|
|
registerUploadsResized(fastify)
|
|
```
|
|
|
|
The import should go after the existing imports, and the `registerUploadsResized(fastify)` call should be placed right before `const uploadsDir = path.join(process.cwd(), 'uploads')`.
|
|
|
|
- [ ] **Step 3: Add Cache-Control headers to fastify-static for /uploads/**
|
|
|
|
Modify the fastify-static registration in `server/src/index.js` to add cache headers:
|
|
|
|
```js
|
|
await fastify.register(fastifyStatic, {
|
|
root: uploadsDir,
|
|
prefix: '/uploads/',
|
|
setHeaders(res, filePath) {
|
|
// .cache files get immutable headers
|
|
if (filePath.includes('/.cache/')) {
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
|
} else {
|
|
res.setHeader('Cache-Control', 'public, max-age=86400')
|
|
}
|
|
},
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Separate review images into /uploads/reviews/
|
|
|
|
**Files:**
|
|
- Modify: `server/src/lib/upload-images.js`
|
|
- Modify: `server/src/routes/api/public-reviews.js`
|
|
|
|
- [ ] **Step 1: Update saveImageBufferToUploads to accept subdir**
|
|
|
|
Modify `server/src/lib/upload-images.js`:
|
|
|
|
```js
|
|
/** Сохранить один буфер изображения в uploads/, вернуть путь `/uploads/...`. */
|
|
export async function saveImageBufferToUploads(originalFilename, buffer, subdir = '') {
|
|
const ext = safeImageExt(originalFilename)
|
|
if (!ext) {
|
|
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
|
|
}
|
|
|
|
const uploadsDir = path.join(process.cwd(), 'uploads')
|
|
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
|
|
await fs.promises.mkdir(targetDir, { recursive: true })
|
|
|
|
const fileName = `${crypto.randomUUID()}${ext}`
|
|
const fullPath = path.join(targetDir, fileName)
|
|
await fs.promises.writeFile(fullPath, buffer)
|
|
return subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update review upload to use /reviews/ subdir**
|
|
|
|
Modify `server/src/routes/api/public-reviews.js`, the `/api/reviews/upload-image` handler:
|
|
|
|
```js
|
|
// Change the persistMultipartImages call to use a custom save function
|
|
// that puts files in /uploads/reviews/
|
|
|
|
// Replace the existing handler with:
|
|
fastify.post(
|
|
'/api/reviews/upload-image',
|
|
{ preHandler: [fastify.authenticate] },
|
|
async (request, reply) => {
|
|
try {
|
|
const urls = await persistMultipartImages(request, {
|
|
maxFiles: 1,
|
|
maxFileBytes: getOtherUploadMaxFileBytes(),
|
|
subdir: 'reviews',
|
|
})
|
|
if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
|
|
return { url: urls[0] }
|
|
} catch (error) {
|
|
let message = error instanceof Error ? error.message : 'Не удалось загрузить изображение'
|
|
let statusCode =
|
|
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
|
? Number(error.statusCode)
|
|
: 400
|
|
if (isMultipartFileTooLargeError(error)) {
|
|
message = formatFileTooLargeMessage(getOtherUploadMaxFileBytes())
|
|
statusCode = 413
|
|
}
|
|
return reply.code(statusCode).send({ error: message })
|
|
}
|
|
},
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3: Update persistMultipartImages to accept subdir**
|
|
|
|
Modify `server/src/lib/upload-images.js`, the `persistMultipartImages` function:
|
|
|
|
```js
|
|
export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '' }) {
|
|
if (!request.isMultipart()) {
|
|
throw uploadError('Ожидается multipart/form-data')
|
|
}
|
|
|
|
const uploadsDir = path.join(process.cwd(), 'uploads')
|
|
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
|
|
await fs.promises.mkdir(targetDir, { recursive: true })
|
|
|
|
const urls = []
|
|
const parts = request.parts({
|
|
limits: {
|
|
fileSize: maxFileBytes,
|
|
files: maxFiles,
|
|
},
|
|
})
|
|
for await (const part of parts) {
|
|
if (!part.file) continue
|
|
if (urls.length >= maxFiles) {
|
|
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
|
|
}
|
|
const ext = safeImageExt(part.filename)
|
|
if (!ext) {
|
|
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
|
|
}
|
|
|
|
const fileName = `${crypto.randomUUID()}${ext}`
|
|
const fullPath = path.join(targetDir, fileName)
|
|
await fs.promises.writeFile(fullPath, await part.toBuffer())
|
|
urls.push(subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`)
|
|
}
|
|
|
|
if (urls.length === 0) {
|
|
throw uploadError(
|
|
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
|
|
)
|
|
}
|
|
|
|
return urls
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Client OptimizedImage component
|
|
|
|
**Files:**
|
|
- Create: `client/src/shared/ui/OptimizedImage.tsx`
|
|
- Create: `client/src/shared/ui/__tests__/OptimizedImage.test.tsx`
|
|
|
|
- [ ] **Step 1: Create the OptimizedImage component**
|
|
|
|
```tsx
|
|
// client/src/shared/ui/OptimizedImage.tsx
|
|
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>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create tests for OptimizedImage**
|
|
|
|
```tsx
|
|
// client/src/shared/ui/__tests__/OptimizedImage.test.tsx
|
|
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.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()
|
|
expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=320')
|
|
expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=640')
|
|
expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=1024')
|
|
expect(avifSource.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
|
|
expect(avifSource.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.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
|
|
expect(avifSource.srcSet).toContain('?w=200')
|
|
expect(avifSource.srcSet).toContain('?w=400')
|
|
expect(avifSource.srcSet).not.toContain('?w=640')
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests to verify they pass**
|
|
|
|
```bash
|
|
cd /mnt/d/my_projects/shop/client && npm test -- --run src/shared/ui/__tests__/OptimizedImage.test.tsx
|
|
```
|
|
|
|
Expected: All 5 tests pass.
|
|
|
|
---
|
|
|
|
### Task 6: Replace images in ProductCard
|
|
|
|
**Files:**
|
|
- Modify: `client/src/entities/product/ui/ProductCard.tsx`
|
|
|
|
- [ ] **Step 1: Import OptimizedImage and replace img elements**
|
|
|
|
```tsx
|
|
// Add import
|
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
|
|
|
// Replace the <Box component="img" ... /> inside SwiperSlide (around line 88-103):
|
|
// OLD:
|
|
// {imageUrls.map((url) => (
|
|
// <SwiperSlide key={url}>
|
|
// <Box
|
|
// component="img"
|
|
// src={url}
|
|
// alt={product.title}
|
|
// className="product-card__media"
|
|
// sx={{
|
|
// width: '100%',
|
|
// height: mediaHeight,
|
|
// objectFit: 'cover',
|
|
// display: 'block',
|
|
// transition: 'transform 320ms ease',
|
|
// '@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
|
// userSelect: 'none',
|
|
// bgcolor: 'grey.50',
|
|
// }}
|
|
// />
|
|
// </SwiperSlide>
|
|
// ))}
|
|
|
|
// NEW:
|
|
{imageUrls.map((url) => (
|
|
<SwiperSlide key={url}>
|
|
<Box
|
|
className="product-card__media"
|
|
sx={{
|
|
width: '100%',
|
|
height: mediaHeight,
|
|
transition: 'transform 320ms ease',
|
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
|
userSelect: 'none',
|
|
bgcolor: 'grey.50',
|
|
}}
|
|
>
|
|
<OptimizedImage
|
|
src={url}
|
|
alt={product.title}
|
|
sizes={`(max-width: 600px) ${mediaHeight}px, (max-width: 1024px) ${Math.round(mediaHeight * 1.5)}px, ${mediaHeight}px`}
|
|
sx={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
/>
|
|
</Box>
|
|
</SwiperSlide>
|
|
))}
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Replace images in ProductPage
|
|
|
|
**Files:**
|
|
- Modify: `client/src/pages/product/ui/ProductPage.tsx`
|
|
|
|
- [ ] **Step 1: Import OptimizedImage and replace all img elements**
|
|
|
|
```tsx
|
|
// Add import
|
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
|
|
|
// Replace the main product image in SwiperSlide (around line 91-110):
|
|
// OLD:
|
|
// {imageUrls.map((url, idx) => (
|
|
// <SwiperSlide key={url}>
|
|
// <Box
|
|
// component="img"
|
|
// src={url}
|
|
// alt={p.title}
|
|
// onClick={() => {
|
|
// setViewerIndex(idx)
|
|
// setViewerOpen(true)
|
|
// }}
|
|
// sx={{
|
|
// width: '100%',
|
|
// height: 420,
|
|
// objectFit: 'cover',
|
|
// display: 'block',
|
|
// cursor: 'zoom-in',
|
|
// userSelect: 'none',
|
|
// }}
|
|
// />
|
|
// </SwiperSlide>
|
|
// ))}
|
|
|
|
// NEW:
|
|
{imageUrls.map((url, idx) => (
|
|
<SwiperSlide key={url}>
|
|
<Box
|
|
onClick={() => {
|
|
setViewerIndex(idx)
|
|
setViewerOpen(true)
|
|
}}
|
|
sx={{
|
|
width: '100%',
|
|
height: 420,
|
|
cursor: 'zoom-in',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
<OptimizedImage
|
|
src={url}
|
|
alt={p.title}
|
|
sizes="(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px"
|
|
sx={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
/>
|
|
</Box>
|
|
</SwiperSlide>
|
|
))}
|
|
|
|
// Replace the review image (around line 220-234):
|
|
// OLD:
|
|
// {rv.imageUrl && (
|
|
// <Box
|
|
// component="img"
|
|
// src={rv.imageUrl}
|
|
// alt="Фото к отзыву"
|
|
// sx={{
|
|
// width: 140,
|
|
// height: 140,
|
|
// objectFit: 'cover',
|
|
// borderRadius: 1.5,
|
|
// border: 1,
|
|
// borderColor: 'divider',
|
|
// }}
|
|
// />
|
|
// )}
|
|
|
|
// NEW:
|
|
{rv.imageUrl && (
|
|
<Box
|
|
sx={{
|
|
width: 140,
|
|
height: 140,
|
|
borderRadius: 1.5,
|
|
border: 1,
|
|
borderColor: 'divider',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<OptimizedImage
|
|
src={rv.imageUrl}
|
|
alt="Фото к отзыву"
|
|
widths={[140, 320]}
|
|
sizes="140px"
|
|
sx={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
// Replace the fullscreen viewer images (around line 265-274):
|
|
// OLD:
|
|
// {imageUrls.map((url) => (
|
|
// <SwiperSlide key={`fs:${url}`}>
|
|
// <Box
|
|
// component="img"
|
|
// src={url}
|
|
// alt={p.title}
|
|
// sx={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
|
|
// />
|
|
// </SwiperSlide>
|
|
// ))}
|
|
|
|
// NEW (viewer uses original — no resize needed for fullscreen):
|
|
{imageUrls.map((url) => (
|
|
<SwiperSlide key={`fs:${url}`}>
|
|
<Box
|
|
component="img"
|
|
src={url}
|
|
alt={p.title}
|
|
sx={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
|
|
/>
|
|
</SwiperSlide>
|
|
))}
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Replace images in CatalogSlider
|
|
|
|
**Files:**
|
|
- Modify: `client/src/widgets/catalog-slider/ui/CatalogSlider.tsx`
|
|
|
|
- [ ] **Step 1: Import OptimizedImage and replace img**
|
|
|
|
```tsx
|
|
// Add import
|
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
|
|
|
// Replace the <Box component="img" ... /> inside the slider (around line 64-78):
|
|
// OLD:
|
|
// <Box
|
|
// component="img"
|
|
// src={slide.url}
|
|
// alt={captionText || 'Слайд каталога'}
|
|
// loading={i === 0 ? 'eager' : 'lazy'}
|
|
// sx={{
|
|
// position: 'absolute',
|
|
// inset: 0,
|
|
// width: '100%',
|
|
// height: '100%',
|
|
// objectFit: 'cover',
|
|
// display: 'block',
|
|
// zIndex: 0,
|
|
// }}
|
|
// />
|
|
|
|
// NEW:
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
zIndex: 0,
|
|
}}
|
|
>
|
|
<OptimizedImage
|
|
src={slide.url}
|
|
alt={captionText || 'Слайд каталога'}
|
|
priority={i === 0}
|
|
sizes="(max-width: 600px) 320px, (max-width: 1024px) 1024px, 1600px"
|
|
sx={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
/>
|
|
</Box>
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Replace images in GalleryGrid
|
|
|
|
**Files:**
|
|
- Modify: `client/src/entities/gallery/ui/GalleryGrid.tsx`
|
|
|
|
- [ ] **Step 1: Import OptimizedImage and replace img**
|
|
|
|
```tsx
|
|
// Add import
|
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
|
|
|
// Replace the <Box component="img" ... /> (around line 34-39):
|
|
// OLD:
|
|
// <Box
|
|
// component="img"
|
|
// src={item.url}
|
|
// alt=""
|
|
// sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
// />
|
|
|
|
// NEW:
|
|
<OptimizedImage
|
|
src={item.url}
|
|
alt=""
|
|
sizes="140px"
|
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Replace images in ReviewsBlock
|
|
|
|
**Files:**
|
|
- Modify: `client/src/widgets/reviews-block/ui/ReviewsBlock.tsx`
|
|
|
|
- [ ] **Step 1: Import OptimizedImage and replace img**
|
|
|
|
```tsx
|
|
// Add import
|
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
|
|
|
// Replace the review image (around line 113-128):
|
|
// OLD:
|
|
// {r.imageUrl && (
|
|
// <Box
|
|
// component="img"
|
|
// src={r.imageUrl}
|
|
// alt="Фото к отзыву"
|
|
// sx={{
|
|
// mt: 1.5,
|
|
// width: 120,
|
|
// height: 120,
|
|
// objectFit: 'cover',
|
|
// borderRadius: 1.5,
|
|
// border: 1,
|
|
// borderColor: 'divider',
|
|
// }}
|
|
// />
|
|
// )}
|
|
|
|
// NEW:
|
|
{r.imageUrl && (
|
|
<Box
|
|
sx={{
|
|
mt: 1.5,
|
|
width: 120,
|
|
height: 120,
|
|
borderRadius: 1.5,
|
|
border: 1,
|
|
borderColor: 'divider',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<OptimizedImage
|
|
src={r.imageUrl}
|
|
alt="Фото к отзыву"
|
|
widths={[120, 320]}
|
|
sizes="120px"
|
|
sx={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
/>
|
|
</Box>
|
|
)}
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Replace images in admin pages
|
|
|
|
**Files:**
|
|
- Modify: `client/src/pages/admin-products/ui/AdminProductsPage.tsx`
|
|
- Modify: `client/src/pages/admin/ui/AdminPage.tsx`
|
|
|
|
- [ ] **Step 1: Replace images in AdminProductsPage**
|
|
|
|
```tsx
|
|
// Add import
|
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
|
|
|
// Find all <Box component="img" src={url} ... /> patterns and replace with OptimizedImage.
|
|
// There are two locations:
|
|
// 1. Product form preview thumbnails (around line 403-429)
|
|
// 2. Gallery picker thumbnails (around line 536-541)
|
|
|
|
// For the form preview (productForm.watch('imageUrls').map):
|
|
// OLD pattern:
|
|
// <Box
|
|
// component="img"
|
|
// src={url}
|
|
// alt=""
|
|
// sx={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 1, display: 'block' }}
|
|
// />
|
|
|
|
// NEW:
|
|
<Box sx={{ width: '100%', height: 80, borderRadius: 1, overflow: 'hidden' }}>
|
|
<OptimizedImage
|
|
src={url}
|
|
alt=""
|
|
widths={[80, 160]}
|
|
sizes="80px"
|
|
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
</Box>
|
|
|
|
// For the gallery picker (around line 536-541):
|
|
// OLD:
|
|
// <Box
|
|
// component="img"
|
|
// src={item.url}
|
|
// alt=""
|
|
// sx={{ width: '100%', maxHeight: 100, objectFit: 'cover', borderRadius: 1, display: 'block' }}
|
|
// />
|
|
|
|
// NEW:
|
|
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
|
|
<OptimizedImage
|
|
src={item.url}
|
|
alt=""
|
|
widths={[120, 240]}
|
|
sizes="120px"
|
|
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
</Box>
|
|
```
|
|
|
|
- [ ] **Step 2: Replace images in AdminPage**
|
|
|
|
```tsx
|
|
// Add import (if not already added)
|
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
|
|
|
// For the form preview thumbnails (productForm.watch('imageUrls').map, ~line 556-582):
|
|
// OLD:
|
|
// <Box
|
|
// component="img"
|
|
// src={url}
|
|
// alt=""
|
|
// sx={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 1, display: 'block' }}
|
|
// />
|
|
|
|
// NEW:
|
|
<Box sx={{ width: '100%', height: 80, borderRadius: 1, overflow: 'hidden' }}>
|
|
<OptimizedImage
|
|
src={url}
|
|
alt=""
|
|
widths={[80, 160]}
|
|
sizes="80px"
|
|
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
</Box>
|
|
|
|
// For the gallery picker thumbnails (~line 675-683):
|
|
// OLD:
|
|
// <Box
|
|
// component="img"
|
|
// src={item.url}
|
|
// alt=""
|
|
// sx={{ width: '100%', maxHeight: 100, objectFit: 'cover', borderRadius: 1, display: 'block' }}
|
|
// />
|
|
|
|
// NEW:
|
|
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
|
|
<OptimizedImage
|
|
src={item.url}
|
|
alt=""
|
|
widths={[120, 240]}
|
|
sizes="120px"
|
|
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
</Box>
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Vite code splitting
|
|
|
|
**Files:**
|
|
- Modify: `client/vite.config.ts`
|
|
|
|
- [ ] **Step 1: Add manualChunks to vite config**
|
|
|
|
```ts
|
|
// Modify the defineConfig to include build.rollupOptions:
|
|
export default defineConfig({
|
|
plugins: [react()],
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(rootDir, 'src'),
|
|
'@shared': path.resolve(projectRoot, 'shared'),
|
|
},
|
|
},
|
|
server: {
|
|
fs: {
|
|
allow: [projectRoot],
|
|
},
|
|
port: 5173,
|
|
proxy: {
|
|
'/api': {
|
|
target: 'http://127.0.0.1:3333',
|
|
changeOrigin: true,
|
|
},
|
|
'/uploads': {
|
|
target: 'http://127.0.0.1:3333',
|
|
changeOrigin: true,
|
|
},
|
|
},
|
|
},
|
|
build: {
|
|
rollupOptions: {
|
|
output: {
|
|
manualChunks: {
|
|
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
|
'vendor-mui': ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
|
|
'vendor-swiper': ['swiper/react', 'swiper/modules'],
|
|
'vendor-query': ['@tanstack/react-query'],
|
|
'vendor-effector': ['effector', 'effector-react'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
Note: maplibre-gl and @tiptap are only imported on specific pages, so they'll be auto-split by Vite's dynamic import. No need to manually chunk them.
|
|
|
|
---
|
|
|
|
### Task 13: Route-level lazy loading
|
|
|
|
**Files:**
|
|
- Modify: `client/src/app/routes/index.tsx`
|
|
|
|
- [ ] **Step 1: Add lazy imports for admin and me routes**
|
|
|
|
```tsx
|
|
// OLD:
|
|
// import { AdminLayoutPage } from '@/pages/admin-layout'
|
|
// import { MeLayoutPage } from '@/pages/me'
|
|
|
|
// NEW:
|
|
import { lazy, Suspense } from 'react'
|
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
|
import { MainLayout } from '@/app/layout/MainLayout'
|
|
import { AboutPage } from '@/pages/about'
|
|
// import { AdminLayoutPage } from '@/pages/admin-layout'
|
|
// import { MeLayoutPage } from '@/pages/me'
|
|
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
|
|
import { CartPage } from '@/pages/cart'
|
|
import { CheckoutPage } from '@/pages/checkout'
|
|
import { HomePage } from '@/pages/home'
|
|
import { InfoPage } from '@/pages/info'
|
|
import { PrivacyPolicyPage } from '@/pages/privacy-policy'
|
|
import { ProductPage } from '@/pages/product'
|
|
import { SkeletonPage } from '@/shared/ui/SkeletonPage'
|
|
|
|
const AdminLayoutPage = lazy(() => import('@/pages/admin-layout'))
|
|
const MeLayoutPage = lazy(() => import('@/pages/me'))
|
|
|
|
// Wrap lazy routes with Suspense:
|
|
// OLD:
|
|
// <Route path="/admin/*" element={<AdminLayoutPage />} />
|
|
// <Route path="/me/*" element={<MeLayoutPage />} />
|
|
|
|
// NEW:
|
|
<Route path="/admin/*" element={<Suspense fallback={<SkeletonPage />}><AdminLayoutPage /></Suspense>} />
|
|
<Route path="/me/*" element={<Suspense fallback={<SkeletonPage />}><MeLayoutPage /></Suspense>} />
|
|
```
|
|
|
|
- [ ] **Step 2: Create SkeletonPage component**
|
|
|
|
```tsx
|
|
// client/src/shared/ui/SkeletonPage.tsx
|
|
import Box from '@mui/material/Box'
|
|
import Skeleton from '@mui/material/Skeleton'
|
|
|
|
export function SkeletonPage() {
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, py: 3 }}>
|
|
<Skeleton variant="text" width="40%" height={48} />
|
|
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2 }} />
|
|
<Skeleton variant="text" width="60%" />
|
|
<Skeleton variant="text" width="80%" />
|
|
<Skeleton variant="text" width="50%" />
|
|
</Box>
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: HTML meta tags
|
|
|
|
**Files:**
|
|
- Modify: `client/index.html`
|
|
|
|
- [ ] **Step 1: Add meta tags to index.html**
|
|
|
|
```html
|
|
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<meta name="description" content="Любимый Креатив — изделия ручной работы: игрушки, сувениры и другие уникальные товары с душой и вниманием к деталям." />
|
|
<meta name="theme-color" content="#1976d2" />
|
|
<title>Любимый Креатив — Изделия ручной работы</title>
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:title" content="Любимый Креатив — Изделия ручной работы" />
|
|
<meta property="og:description" content="Игрушки, сувениры и другие уникальные изделия ручной работы." />
|
|
<meta property="og:image" content="/favicon.svg" />
|
|
<meta property="og:locale" content="ru_RU" />
|
|
<link rel="canonical" href="https://craftshop.example.com/" /> <!-- TODO: Replace with actual domain -->
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="module" src="/src/main.tsx"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Add .cache to .gitignore
|
|
|
|
**Files:**
|
|
- Modify: `server/.gitignore` (or root `.gitignore`)
|
|
|
|
- [ ] **Step 1: Add uploads cache directory to gitignore**
|
|
|
|
Check which .gitignore exists and add:
|
|
|
|
```
|
|
# Image resize cache
|
|
uploads/.cache/
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Server tests for image resizing
|
|
|
|
**Files:**
|
|
- Create: `server/src/lib/__tests__/image-resize.test.js`
|
|
|
|
- [ ] **Step 1: Create test for image resize library**
|
|
|
|
```js
|
|
// server/src/lib/__tests__/image-resize.test.js
|
|
import crypto from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { findOriginalFile, getOrCreateResized } from '../image-resize.js'
|
|
|
|
const TEST_DIR = path.join(process.cwd(), 'uploads', '.test-tmp')
|
|
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
|
|
|
|
beforeAll(async () => {
|
|
await fs.promises.mkdir(TEST_DIR, { recursive: true })
|
|
// Create a small test PNG file
|
|
const sharp = (await import('sharp')).default
|
|
const testPng = path.join(TEST_DIR, 'test-original.png')
|
|
await sharp({
|
|
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
|
})
|
|
.png()
|
|
.toFile(testPng)
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await fs.promises.rm(TEST_DIR, { recursive: true, force: true })
|
|
// Clean up any cache files created during tests
|
|
const cacheDir = path.join(UPLOADS_DIR, '.cache')
|
|
try {
|
|
await fs.promises.rm(cacheDir, { recursive: true, force: true })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
})
|
|
|
|
describe('image-resize', () => {
|
|
it('findOriginalFile locates file by UUID', async () => {
|
|
const files = await fs.promises.readdir(TEST_DIR)
|
|
const pngFile = files.find((f) => f.endsWith('.png'))
|
|
const uuid = pngFile.replace('.png', '')
|
|
|
|
// Temporarily override UPLOADS_DIR by copying file to actual uploads
|
|
const destPath = path.join(UPLOADS_DIR, pngFile)
|
|
await fs.promises.copyFile(path.join(TEST_DIR, pngFile), destPath)
|
|
|
|
const found = await findOriginalFile(uuid)
|
|
expect(found).not.toBeNull()
|
|
expect(found).toBe(destPath)
|
|
|
|
// Cleanup
|
|
await fs.promises.unlink(destPath)
|
|
})
|
|
|
|
it('getOrCreateResized generates AVIF file', async () => {
|
|
// Create a test file in uploads/
|
|
const sharp = (await import('sharp')).default
|
|
const uuid = crypto.randomUUID()
|
|
const testPath = path.join(UPLOADS_DIR, `${uuid}.png`)
|
|
await sharp({
|
|
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } },
|
|
})
|
|
.png()
|
|
.toFile(testPath)
|
|
|
|
const result = await getOrCreateResized(uuid, 100, 'avif')
|
|
expect(result).not.toBeNull()
|
|
expect(result.isNew).toBe(true)
|
|
expect(result.path).toContain('.cache')
|
|
expect(result.path).toContain('_w100.avif')
|
|
|
|
const exists = await fs.promises.access(result.path).then(() => true).catch(() => false)
|
|
expect(exists).toBe(true)
|
|
|
|
// Verify it's actually AVIF
|
|
const info = await sharp(result.path).metadata()
|
|
expect(info.format).toBe('avif')
|
|
|
|
// Cleanup
|
|
await fs.promises.unlink(testPath)
|
|
await fs.promises.unlink(result.path)
|
|
})
|
|
|
|
it('getOrCreateResized returns cached file on second call', async () => {
|
|
const sharp = (await import('sharp')).default
|
|
const uuid = crypto.randomUUID()
|
|
const testPath = path.join(UPLOADS_DIR, `${uuid}.png`)
|
|
await sharp({
|
|
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 0, b: 255 } },
|
|
})
|
|
.png()
|
|
.toFile(testPath)
|
|
|
|
const first = await getOrCreateResized(uuid, 100, 'webp')
|
|
expect(first.isNew).toBe(true)
|
|
|
|
const second = await getOrCreateResized(uuid, 100, 'webp')
|
|
expect(second.isNew).toBe(false)
|
|
expect(second.path).toBe(first.path)
|
|
|
|
// Cleanup
|
|
await fs.promises.unlink(testPath)
|
|
await fs.promises.unlink(first.path)
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Run server tests**
|
|
|
|
```bash
|
|
cd /mnt/d/my_projects/shop/server && npm test
|
|
```
|
|
|
|
Expected: All tests pass including new image-resize tests.
|
|
|
|
---
|
|
|
|
### Task 17: Build and verify
|
|
|
|
**Files:** N/A
|
|
|
|
- [ ] **Step 1: Build the client**
|
|
|
|
```bash
|
|
cd /mnt/d/my_projects/shop/client && npm run build
|
|
```
|
|
|
|
Expected: Build succeeds with no TypeScript errors. Check that output has multiple chunk files (vendor-react, vendor-mui, etc.).
|
|
|
|
- [ ] **Step 2: Run client lint**
|
|
|
|
```bash
|
|
cd /mnt/d/my_projects/shop/client && npm run lint
|
|
```
|
|
|
|
Expected: No errors.
|
|
|
|
- [ ] **Step 3: Run client tests**
|
|
|
|
```bash
|
|
cd /mnt/d/my_projects/shop/client && npm test
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 4: Run server tests**
|
|
|
|
```bash
|
|
cd /mnt/d/my_projects/shop/server && npm test
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 5: Commit all changes**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "perf: optimize lighthouse score with image resizing, lazy loading, and code splitting
|
|
|
|
- Add sharp-based on-demand AVIF/WebP image resizing with disk cache
|
|
- Add <OptimizedImage> component with srcset/sizes/lazy loading
|
|
- Separate review images into /uploads/reviews/
|
|
- Add Cache-Control headers for uploads
|
|
- Code split vendor bundles (react, mui, swiper, etc.)
|
|
- Lazy load admin and me routes
|
|
- Add meta tags for SEO and OG"
|
|
```
|