# 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:

```prisma
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:
```bash
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`:

```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:
```bash
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**

```bash
git add server/prisma/
git commit -m "feat(db): add isResized to GalleryImage"
```

---

### Task 2: New gallery upload endpoint (server)

**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:

```js
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.js` — `registerAdminGalleryRoutes` should already be imported and registered.

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

- [ ] **Step 3: Commit**

```bash
git add server/src/routes/api/admin-gallery.js
git commit -m "feat(server): add POST /api/admin/gallery/upload endpoint"
```

---

### Task 3: New gallery resize endpoint (server)

**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:

```js
  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**

```bash
cd server && npm test
```
Expected: all existing tests pass.

- [ ] **Step 3: Commit**

```bash
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:
```js
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:

```js
// 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`):
```js
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):
```js
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**

```bash
cd server && npm test
```
Expected: all existing tests pass.

- [ ] **Step 4: Commit**

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

---

### Task 5: Update existing server tests + add new gallery tests

**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`:

```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**

```bash
cd server && npm test
```
Expected: all tests pass.

- [ ] **Step 4: Commit**

```bash
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`:

```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`:

```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`:

```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**

```bash
cd client && npx tsc -b --noEmit
```
Expected: no type errors.

- [ ] **Step 5: Commit**

```bash
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`:

```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**

```bash
cd client && npx tsc -b --noEmit
```
Expected: no type errors.

- [ ] **Step 3: Commit**

```bash
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`:

```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**

```bash
cd client && npm run lint
```
Expected: no errors.

- [ ] **Step 3: Run client typecheck**

```bash
cd client && npx tsc -b --noEmit
```
Expected: no errors.

- [ ] **Step 4: Commit**

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

---

### Task 9: Update AdminProductsPage — remove direct upload, filter gallery to resized only

**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:

```tsx
            <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:
```tsx
{(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:
```tsx
{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**

```bash
cd client && npm run lint && npx tsc -b --noEmit
```
Expected: no errors.

- [ ] **Step 4: Commit**

```bash
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:

```tsx
const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id) && i.isResized)
```

- [ ] **Step 2: Commit**

```bash
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**

```bash
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**

```bash
cd server && npm test
```
Expected: all tests pass.

- [ ] **Step 2: Run client lint + format check + typecheck**

```bash
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**

```bash
git add -A
git commit -m "feat: complete admin image redesign — upload, resize, attach flow"
```
