Files
shop-server/docs/superpowers/plans/2026-05-17-admin-image-redesign.md
T
2026-05-18 11:45:51 +05:00

34 KiB
Raw Blame History

Admin Image Redesign 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: Separate admin image upload, resize, and attach-to-product into three explicit steps.

Architecture: Upload → gallery only (raw file). Resize is a manual trigger per image. Attach to product/slider allowed only after resize. Uses isResized boolean on GalleryImage model.

Tech Stack: Fastify, Prisma (SQLite), React, MUI, @tanstack/react-query, sharp


Task 1: Add isResized to GalleryImage (Prisma schema + migration)

Files:

  • Modify: server/prisma/schema.prisma

  • Create: server/prisma/migrations/xxx_add_is_resized/migration.sql (via prisma migrate dev)

  • Step 1: Add field to schema

Edit server/prisma/schema.prisma, add isResized to GalleryImage:

model GalleryImage {
  id        String                @id @default(cuid())
  url       String                @unique
  isResized Boolean               @default(false)
  createdAt DateTime              @default(now())
  catalogSliderSlides CatalogSliderSlide[]
}
  • Step 2: Generate migration

Run:

cd server && npx prisma migrate dev --name add_is_resized

Expected: new migration file created, DB updated.

  • Step 3: Write seed/migration script to mark existing images as resized

Create server/prisma/seed-is-resized.js:

import { prisma } from '../src/lib/prisma.js'

async function main() {
  const { count } = await prisma.galleryImage.updateMany({
    where: { isResized: false },
    data: { isResized: true },
  })
  console.log(`Marked ${count} existing images as resized`)
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect())

Run once manually after deploy:

cd server && node prisma/seed-is-resized.js
  • Step 4: Verify

Run: cd server && npx prisma studio Check that GalleryImage table has isResized column.

  • Step 5: Commit
git add server/prisma/
git commit -m "feat(db): add isResized to GalleryImage"

Files:

  • Modify: server/src/routes/api/admin-gallery.js

  • Modify: server/src/routes/api.js (if needed to register routes)

  • Step 1: Add POST /api/admin/gallery/upload route

Edit server/src/routes/api/admin-gallery.js. Add a new POST route before the DELETE route:

import fs from 'node:fs/promises'
import path from 'node:path'
import { prisma } from '../../lib/prisma.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
import {
  formatFileTooLargeMessage,
  getProductImageMaxFileBytes,
  isMultipartFileTooLargeError,
} from '../../lib/upload-limits.js'

export async function registerAdminGalleryRoutes(fastify) {
  fastify.get(
    '/api/admin/gallery',
    { preHandler: [fastify.verifyAdmin] },
    async () => {
      const items = await prisma.galleryImage.findMany({
        orderBy: { createdAt: 'desc' },
      })
      return { items }
    },
  )

  fastify.post(
    '/api/admin/gallery/upload',
    { preHandler: [fastify.verifyAdmin] },
    async (request, reply) => {
      try {
        const urls = await persistMultipartImages(request, {
          maxFiles: 10,
          maxFileBytes: getProductImageMaxFileBytes(),
          subdir: '',
          eager: false,
        })
        for (const url of urls) {
          await prisma.galleryImage.create({
            data: { url, isResized: false },
          })
        }
        return { urls }
      } 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(getProductImageMaxFileBytes())
          statusCode = 413
        }
        return reply.code(statusCode).send({ error: message })
      }
    },
  )

  fastify.delete(
    // ... existing code unchanged
  )
}
  • Step 2: Verify no new route registration needed

Check server/src/routes/api.jsregisterAdminGalleryRoutes should already be imported and registered.

Run: rg "registerAdminGalleryRoutes" server/src/routes/api.js Expected: import and fastify.register(registerAdminGalleryRoutes).

  • Step 3: Commit
git add server/src/routes/api/admin-gallery.js
git commit -m "feat(server): add POST /api/admin/gallery/upload endpoint"

Files:

  • Modify: server/src/routes/api/admin-gallery.js

  • Step 1: Add POST /api/admin/gallery/:id/resize route

Edit server/src/routes/api/admin-gallery.js. Add after the upload route:

  fastify.post(
    '/api/admin/gallery/:id/resize',
    { preHandler: [fastify.verifyAdmin] },
    async (request, reply) => {
      const { id } = request.params
      const row = await prisma.galleryImage.findUnique({ where: { id } })
      if (!row) {
        return reply.code(404).send({ error: 'Изображение не найдено' })
      }
      if (row.isResized) {
        return reply.code(409).send({ error: 'Изображение уже обработано' })
      }

      // Extract UUID from url like "/uploads/<uuid>.png"
      const urlParts = row.url.replace(/^\//, '').split('/')
      const fileName = urlParts[urlParts.length - 1]
      const uuid = fileName.replace(/\.\w+$/, '')

      try {
        const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')

        const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
        await generateAllSizes(uuid, '', fullPath)
        const newUrl = await convertOriginalToWebp(uuid, '')

        await prisma.galleryImage.update({
          where: { id },
          data: { url: newUrl, isResized: true },
        })

        return { url: newUrl }
      } catch (error) {
        return reply.code(500).send({ error: 'Ошибка обработки изображения' })
      }
    },
  )
  • Step 2: Run server tests
cd server && npm test

Expected: all existing tests pass.

  • Step 3: Commit
git add server/src/routes/api/admin-gallery.js
git commit -m "feat(server): add POST /api/admin/gallery/:id/resize endpoint"

Task 4: Remove old combined upload endpoint + validate isResized on product endpoints

Files:

  • Modify: server/src/routes/api/admin-products.js

  • Step 1: Remove POST /api/admin/uploads route

Delete lines 62-87 from server/src/routes/api/admin-products.js (the entire fastify.post('/api/admin/uploads', ...) block).

Remove unused imports:

  • formatFileTooLargeMessage
  • getProductImageMaxFileBytes
  • isMultipartFileTooLargeError
  • persistMultipartImages

Remove unused import upsertGalleryImagesByUrls (it was only used in the upload route).

The imports section should become:

import { prisma } from '../../lib/prisma.js'
  • Step 2: Add isResized validation to product create and patch

In POST /api/admin/products and PATCH /api/admin/products/:id, before creating/updating images, add:

// Validate all imageUrls belong to resized gallery images
if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) {
  const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
  if (urls.length > 0) {
    const galleryImages = await prisma.galleryImage.findMany({
      where: { url: { in: urls } },
      select: { url: true, isResized: true },
    })
    const galleryMap = new Map(galleryImages.map((g) => [g.url, g]))
    const notFound = urls.filter((u) => !galleryMap.has(u))
    const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u)!.isResized)
    if (notFound.length > 0) {
      return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
    }
    if (notResized.length > 0) {
      return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
    }
  }
}

Place this validation in both the create handler (after the exists slug check, around line 123) and the patch handler (after if (body.categoryId !== undefined) check, around line 229).

For the patch handler, the validation should be inside the if (body.imageUrls !== undefined) block or right before const imagesUpdate.

Let me be more precise about where in the code:

Create handler — add after line 123 (return after slug conflict), before line 132 (const product = await prisma.product.create):

if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) {
  const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
  if (urls.length > 0) {
    const galleryImages = await prisma.galleryImage.findMany({
      where: { url: { in: urls } },
      select: { url: true, isResized: true },
    })
    const galleryMap = new Map(galleryImages.map((g) => [g.url, g]))
    const notFound = urls.filter((u) => !galleryMap.has(u))
    const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized)
    if (notFound.length > 0) {
      return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
    }
    if (notResized.length > 0) {
      return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
    }
  }
}

Patch handler — add before const imagesUpdate block (before line 231):

if (body.imageUrls !== undefined && Array.isArray(body.imageUrls)) {
  const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
  if (urls.length > 0) {
    const galleryImages = await prisma.galleryImage.findMany({
      where: { url: { in: urls } },
      select: { url: true, isResized: true },
    })
    const galleryMap = new Map(galleryImages.map((g) => [g.url, g]))
    const notFound = urls.filter((u) => !galleryMap.has(u))
    const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized)
    if (notFound.length > 0) {
      return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
    }
    if (notResized.length > 0) {
      return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
    }
  }
}
  • Step 3: Run server tests
cd server && npm test

Expected: all existing tests pass.

  • Step 4: Commit
git add server/src/routes/api/admin-products.js
git commit -m "feat(server): remove old /admin/uploads, validate isResized on product endpoints"

Files:

  • Create: server/src/routes/api/__tests__/admin-gallery.test.js

  • Modify: server/src/lib/__tests__/upload-images.test.js (adapt eager tests)

  • Step 1: Create admin gallery test

Create server/src/routes/api/__tests__/admin-gallery.test.js:

import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'

const UPLOADS_DIR = path.join(process.cwd(), 'uploads')

// We'll test the resize endpoint logic directly via the lib functions
// since the router requires a full Fastify instance

import { generateAllSizes, convertOriginalToWebp } from '../../lib/image-resize.js'

describe('Gallery resize endpoint logic', () => {
  const testUuid = 'gallery-test-resize-uuid'
  const testOriginalPath = path.join(UPLOADS_DIR, `${testUuid}.png`)

  beforeAll(async () => {
    const sharp = (await import('sharp')).default
    await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } })
      .png()
      .toFile(testOriginalPath)
  })

  afterAll(async () => {
    await fs.promises.unlink(testOriginalPath).catch(() => {})
    const webpPath = path.join(UPLOADS_DIR, `${testUuid}.webp`)
    await fs.promises.unlink(webpPath).catch(() => {})
    const cacheDir = path.join(UPLOADS_DIR, '.cache')
    for (const width of [320, 640, 1024, 1600]) {
      for (const format of ['avif', 'webp']) {
        await fs.promises.unlink(path.join(cacheDir, `${testUuid}_w${width}.${format}`)).catch(() => {})
      }
    }
  })

  it('generateAllSizes + convertOriginalToWebp works on raw upload', async () => {
    await generateAllSizes(testUuid, '', testOriginalPath)
    const newUrl = await convertOriginalToWebp(testUuid, '')

    expect(newUrl).toBe(`/uploads/${testUuid}.webp`)

    // Verify original PNG is deleted
    const pngExists = await fs.promises.access(testOriginalPath).then(() => true).catch(() => false)
    expect(pngExists).toBe(false)

    // Verify cached files exist
    const cacheDir = path.join(UPLOADS_DIR, '.cache')
    for (const width of [320, 640, 1024, 1600]) {
      for (const format of ['avif', 'webp']) {
        const cachePath = path.join(cacheDir, `${testUuid}_w${width}.${format}`)
        const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
        expect(exists).toBe(true)
      }
    }

    // Verify webp original exists
    const webpExists = await fs.promises.access(path.join(UPLOADS_DIR, `${testUuid}.webp`)).then(() => true).catch(() => false)
    expect(webpExists).toBe(true)
  })
})
  • Step 2: Adapt upload-images tests

The upload-images.test.js has tests for eager: true mode. Since we no longer use eager mode for admin uploads, migrate the eager test to work under the gallery resize flow instead. The simplest approach: remove the eager: true test and keep the eager: false test (it tests raw file saving, which is what gallery upload does).

Edit server/src/lib/__tests__/upload-images.test.js:

  • Remove the first test returns WebP URLs when eager=true (lines 19-61)
  • Keep the second test returns original format URLs when eager=false
  • Keep the third test cleans up original file on eager processing error but change eager: true to eager: false in its options — this tests error handling of the function itself, which is still valid.

Actually, re-reading the third test: it tests that when eager: true and processing fails, the original file is cleaned up. With eager: false, no processing happens so there's nothing to fail on that front. Let me just remove the third test too. The important behavior (raw file saving in eager: false mode) is covered by the remaining test.

So we keep only the second test returns original format URLs when eager=false.

  • Step 3: Run server tests
cd server && npm test

Expected: all tests pass.

  • Step 4: Commit
git add server/src/routes/api/__tests__/admin-gallery.test.js server/src/lib/__tests__/upload-images.test.js
git commit -m "test(server): add gallery resize test, adapt upload tests"

Task 6: Client types and API layer

Files:

  • Modify: client/src/entities/gallery/model/types.ts

  • Modify: client/src/entities/gallery/api/gallery-api.ts

  • Modify: client/src/entities/gallery/index.ts

  • Step 1: Add isResized to GalleryImageItem type

Edit client/src/entities/gallery/model/types.ts:

export type GalleryImageItem = {
  id: string
  url: string
  isResized: boolean
  createdAt: string
}
  • Step 2: Add new gallery API functions

Edit client/src/entities/gallery/api/gallery-api.ts:

import type { GalleryImageItem } from '@/entities/gallery/model/types'
import { apiClient } from '@/shared/api/client'
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
import { apiBaseURL } from '@/shared/config'

export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> {
  const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery')
  return data
}

export async function deleteGalleryImage(id: string): Promise<void> {
  await apiClient.delete(`admin/gallery/${id}`)
}

export async function uploadGalleryImages(files: File[]): Promise<string[]> {
  for (const f of files) {
    if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
      throw new Error(
        `Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
      )
    }
  }
  const fd = new FormData()
  for (const f of files) {
    fd.append('files', f, f.name)
  }
  const token = localStorage.getItem('craftshop_auth_token')
  const base = apiBaseURL.replace(/\/$/, '')
  const res = await fetch(`${base}/admin/gallery/upload`, {
    method: 'POST',
    headers: token ? { Authorization: `Bearer ${token}` } : {},
    body: fd,
  })
  const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
  if (!res.ok) {
    if (res.status === 413) {
      throw new Error(
        'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
      )
    }
    throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
  }
  if (!Array.isArray(payload.urls)) {
    throw new Error('Некорректный ответ сервера')
  }
  return payload.urls
}

export async function resizeGalleryImage(id: string): Promise<{ url: string }> {
  const { data } = await apiClient.post<{ url: string }>(`admin/gallery/${id}/resize`)
  return data
}
  • Step 3: Update gallery index

Edit client/src/entities/gallery/index.ts:

export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api'
export type { GalleryImageItem } from './model/types'
export { GalleryGrid } from './ui/GalleryGrid'
  • Step 4: Run client typecheck
cd client && npx tsc -b --noEmit

Expected: no type errors.

  • Step 5: Commit
git add client/src/entities/gallery/
git commit -m "feat(client): add isResized type, uploadGalleryImages, resizeGalleryImage API"

Task 7: Update GalleryGrid — add status badge and resize button

Files:

  • Modify: client/src/entities/gallery/ui/GalleryGrid.tsx

  • Step 1: Add resize button + status badge to GalleryGrid

Edit client/src/entities/gallery/ui/GalleryGrid.tsx:

import AutoFixHighOutlinedIcon from '@mui/icons-material/AutoFixHighOutlined'
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import type { GalleryImageItem } from '../model/types'

type Props = {
  items: GalleryImageItem[]
  deleting?: boolean
  resizing?: string | null
  onDelete: (id: string) => void
  onResize: (id: string) => void
}

export function GalleryGrid({ items, deleting, resizing, onDelete, onResize }: Props) {
  return (
    <Box
      sx={{
        display: 'grid',
        gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
        gap: 2,
      }}
    >
      {items.map((item) => (
        <Box
          key={item.id}
          sx={{
            position: 'relative',
            borderRadius: 1,
            overflow: 'hidden',
            border: 1,
            borderColor: 'divider',
            aspectRatio: '1',
          }}
        >
          <OptimizedImage
            src={item.url}
            alt=""
            sizes="140px"
            sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
          />
          <Box sx={{ position: 'absolute', top: 4, left: 4 }}>
            {item.isResized ? (
              <Chip
                label="Готово"
                size="small"
                color="success"
                icon={<CheckCircleOutlineOutlinedIcon fontSize="small" />}
                sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }}
              />
            ) : (
              <Chip
                label="Не обработано"
                size="small"
                color="warning"
                sx={{ height: 24, '& .MuiChip-label': { px: 0.75 } }}
              />
            )}
          </Box>
          <Box sx={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 0.5 }}>
            {!item.isResized && (
              <Tooltip title="Обработать (resize)">
                <IconButton
                  size="small"
                  color="primary"
                  sx={{
                    bgcolor: 'background.paper',
                    '&:hover': { bgcolor: 'primary.light', color: 'primary.contrastText' },
                  }}
                  disabled={resizing === item.id}
                  onClick={() => onResize(item.id)}
                >
                  <AutoFixHighOutlinedIcon fontSize="small" />
                </IconButton>
              </Tooltip>
            )}
            <Tooltip title="Удалить из галереи">
              <IconButton
                size="small"
                color="error"
                sx={{
                  bgcolor: 'background.paper',
                  '&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
                }}
                disabled={deleting}
                onClick={() => onDelete(item.id)}
              >
                <DeleteOutlineOutlinedIcon fontSize="small" />
              </IconButton>
            </Tooltip>
          </Box>
        </Box>
      ))}
    </Box>
  )
}
  • Step 2: Run client typecheck
cd client && npx tsc -b --noEmit

Expected: no type errors.

  • Step 3: Commit
git add client/src/entities/gallery/ui/GalleryGrid.tsx
git commit -m "feat(client): add resize button and status badge to GalleryGrid"

Task 8: Update AdminGalleryPage — use new upload + add resize mutation

Files:

  • Modify: client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx

  • Step 1: Replace uploadAdminProductImages with uploadGalleryImages, add resize mutation

Edit client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx:

import { useRef, useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider'
import {
  deleteGalleryImage,
  fetchAdminGallery,
  GalleryGrid,
  resizeGalleryImage,
  uploadGalleryImages,
} from '@/entities/gallery'
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { GallerySliderSection } from './GallerySliderSection'
import type { AxiosError } from 'axios'

function getApiErrorMessage(error: unknown): string | null {
  const e = error as AxiosError<{ error?: string }>
  const msg = e?.response?.data?.error
  return msg ? String(msg) : null
}

export function AdminGalleryPage() {
  const queryClient = useQueryClient()
  const fileInputRef = useRef<HTMLInputElement>(null)
  const [resizingId, setResizingId] = useState<string | null>(null)

  const sliderQuery = useQuery({
    queryKey: ['admin', 'catalog-slider'],
    queryFn: fetchAdminCatalogSlider,
  })

  const galleryQuery = useQuery({
    queryKey: ['admin', 'gallery'],
    queryFn: fetchAdminGallery,
  })

  const uploadMut = useMutation({
    mutationFn: (files: File[]) => uploadGalleryImages(files),
    onSuccess: () => {
      void invalidateQueryKeys(queryClient, [['admin', 'gallery']])
      if (fileInputRef.current) {
        fileInputRef.current.value = ''
      }
    },
  })

  const resizeMut = useMutation({
    mutationFn: async (id: string) => {
      setResizingId(id)
      try {
        await resizeGalleryImage(id)
      } finally {
        setResizingId(null)
      }
    },
    onSuccess: () => {
      void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']])
    },
  })

  const deleteMut = useMutation({
    mutationFn: (id: string) => deleteGalleryImage(id),
    onSuccess: () => {
      void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']])
    },
  })

  const items = galleryQuery.data?.items ?? []
  const deleteError = deleteMut.error
  const uploadError = uploadMut.error
  const resizeError = resizeMut.error

  return (
    <Box>
      <Typography variant="h4" gutterBottom>
        Галерея
      </Typography>
      <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
        Изображения загружаются без обработки. После загрузки нажмите «Resize» для подготовки к публикации.
        Обработанные изображения доступны для добавления в карточку товара и слайдер.
      </Typography>

      {sliderQuery.isError && (
        <Typography color="error" sx={{ mb: 2 }}>
          Не удалось загрузить настройки слайдера.
        </Typography>
      )}
      {sliderQuery.isLoading && (
        <Typography color="text.secondary" sx={{ mb: 2 }}>
          Загрузка настроек слайдера…
        </Typography>
      )}
      {sliderQuery.isSuccess && (
        <GallerySliderSection
          key={sliderQuery.dataUpdatedAt}
          initialSlides={sliderQuery.data.slides.map((s) => ({
            galleryImageId: s.galleryImageId,
            caption: s.caption,
          }))}
          galleryItems={items}
        />
      )}

      <Divider sx={{ mb: 3 }} />

      <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
        Форматы: PNG, JPEG, WebP. На один файл  до {formatAdminImageMaxSizeHint()}.
      </Typography>

      <Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
        <Button variant="contained" component="label" disabled={uploadMut.isPending}>
          Загрузить файлы
          <input
            ref={fileInputRef}
            hidden
            type="file"
            accept="image/png,image/jpeg,image/webp"
            multiple
            onChange={(e) => {
              const files = e.target.files
              if (!files?.length) return
              uploadMut.mutate(Array.from(files))
            }}
          />
        </Button>
        {uploadMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
        {uploadMut.isError && (
          <Typography color="error">
            {uploadMut.error instanceof Error ? uploadMut.error.message : 'Ошибка загрузки'}
          </Typography>
        )}
        {deleteMut.isError && (
          <Typography color="error">{getApiErrorMessage(deleteMut.error) ?? 'Ошибка удаления'}</Typography>
        )}
        {resizeMut.isError && (
          <Typography color="error">{getApiErrorMessage(resizeMut.error) ?? 'Ошибка обработки'}</Typography>
        )}
      </Stack>

      {galleryQuery.isError && (
        <Typography color="error" sx={{ mb: 2 }}>
          Не удалось загрузить список.
        </Typography>
      )}

      <GalleryGrid
        items={items}
        deleting={deleteMut.isPending}
        resizing={resizingId}
        onDelete={(id) => deleteMut.mutate(id)}
        onResize={(id) => resizeMut.mutate(id)}
      />

      {!galleryQuery.isLoading && items.length === 0 && (
        <Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
      )}
    </Box>
  )
}
  • Step 2: Run client lint
cd client && npm run lint

Expected: no errors.

  • Step 3: Run client typecheck
cd client && npx tsc -b --noEmit

Expected: no errors.

  • Step 4: Commit
git add client/src/pages/admin-gallery/
git commit -m "feat(client): update AdminGalleryPage with new upload and resize UI"

Files:

  • Modify: client/src/pages/admin-products/ui/AdminProductsPage.tsx

  • Step 1: Remove direct file upload from product form

Edit client/src/pages/admin-products/ui/AdminProductsPage.tsx:

Changes:

  1. Remove uploadAdminProductImages import (line 34)
  2. Remove import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' (line 37 — no longer needed in this file)
  3. Remove productImagesInputRef (line 206)
  4. Remove uploadImagesMut mutation (lines 208-217)
  5. Change mutationError to exclude uploadImagesMut.error (line 219 → const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error)
  6. Remove the "Выбрать файлы" button and its description text
  7. Update the "Фото" section description to remove mention of file upload limits

The relevant section (lines 402-445) should become:

            <Box>
              <Typography variant="subtitle2" sx={{ mb: 0.5 }}>
                Фото (из галереи)
              </Typography>
              <FormHelperText sx={{ mt: 0, mb: 1 }}>
                Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл
                остаётся на сервере и в галерее.
              </FormHelperText>
              <Box
                sx={{
                  display: 'flex',
                  gap: 2,
                  alignItems: { sm: 'center' },
                  flexDirection: { xs: 'column', sm: 'row' },
                  flexWrap: 'wrap',
                }}
              >
                <Button
                  variant="outlined"
                  onClick={() => {
                    setGallerySelectedUrls(new Set())
                    setGalleryPickOpen(true)
                  }}
                >
                  Из галереи
                </Button>
              </Box>

              // ... keep the rest (imageUrls preview) unchanged
            </Box>
  • Step 2: Filter gallery picker to resized images only

In the gallery picker dialog, filter the items:

Change line 569:

{(galleryForPickQuery.data?.items ?? [])
  .filter((item) => item.isResized)
  .map((item) => {

Also add an empty state message when there are gallery items but none are resized:

After the existing empty state check (line 558-560), add:

{galleryForPickQuery.data &&
  galleryForPickQuery.data.items.length > 0 &&
  galleryForPickQuery.data.items.filter((i) => i.isResized).length === 0 &&
  !galleryForPickQuery.isLoading && (
    <Typography color="text.secondary">
      В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
    </Typography>
  )}
  • Step 3: Run client lint and typecheck
cd client && npm run lint && npx tsc -b --noEmit

Expected: no errors.

  • Step 4: Commit
git add client/src/pages/admin-products/
git commit -m "feat(client): remove direct upload from product form, filter gallery to resized"

Task 10: Update GallerySliderSection — filter to resized images only

Files:

  • Modify: client/src/pages/admin-gallery/ui/GallerySliderSection.tsx

  • Step 1: Filter pickCandidates to resized only

Edit client/src/pages/admin-gallery/ui/GallerySliderSection.tsx, change line 34:

const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id) && i.isResized)
  • Step 2: Commit
git add client/src/pages/admin-gallery/ui/GallerySliderSection.tsx
git commit -m "feat(client): slider picker shows only resized images"

Task 11: Clean up unused server import (gallery.js)

Files:

  • Modify: server/src/lib/gallery.js — no change needed, keep as is (still used by future migration scripts or could be useful)

  • Actually nothing to clean up here — upsertGalleryImagesByUrls is no longer imported anywhere. Remove the file server/src/lib/gallery.js if nothing else uses it.

  • Step 1: Check if gallery.js is used anywhere else

Run: rg "gallery.js" server/src/ Expected: only in the import in the now-removed admin-products.js line 1.

  • Step 2: Remove gallery.js file

Delete server/src/lib/gallery.js since it's no longer used.

  • Step 3: Commit
git rm server/src/lib/gallery.js
git commit -m "chore(server): remove unused gallery.js"

Task 12: End-to-end verification

  • Step 1: Run all server tests
cd server && npm test

Expected: all tests pass.

  • Step 2: Run client lint + format check + typecheck
cd client && npm run lint && npm run format:check && npx tsc -b --noEmit

Expected: no errors.

  • Step 3: Manual smoke test
  1. Start server: cd server && npm run dev
  2. Start client: cd client && npm run dev
  3. Open /admin/gallery → upload a PNG → verify it shows with "Не обработано" badge
  4. Click resize button → verify it becomes "Готово"
  5. Open /admin/products → edit/create product → verify no "Выбрать файлы" button, only "Из галереи"
  6. Click "Из галереи" → verify only resized images show up
  7. Add image to product → save → verify product card shows the image
  8. Open /admin/gallery → verify resized image can't be resized again (no resize button)
  • Step 4: Commit any remaining changes
git add -A
git commit -m "feat: complete admin image redesign — upload, resize, attach flow"