# 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/.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 { await apiClient.delete(`admin/gallery/${id}`) } export async function uploadGalleryImages(files: File[]): Promise { 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 ( {items.map((item) => ( {item.isResized ? ( } sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }} /> ) : ( )} {!item.isResized && ( onResize(item.id)} > )} onDelete(item.id)} > ))} ) } ``` - [ ] **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(null) const [resizingId, setResizingId] = useState(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 ( Галерея Изображения загружаются без обработки. После загрузки нажмите «Resize» для подготовки к публикации. Обработанные изображения доступны для добавления в карточку товара и слайдер. {sliderQuery.isError && ( Не удалось загрузить настройки слайдера. )} {sliderQuery.isLoading && ( Загрузка настроек слайдера… )} {sliderQuery.isSuccess && ( ({ galleryImageId: s.galleryImageId, caption: s.caption, }))} galleryItems={items} /> )} Форматы: PNG, JPEG, WebP. На один файл — до {formatAdminImageMaxSizeHint()}. {uploadMut.isPending && Загрузка…} {uploadMut.isError && ( {uploadMut.error instanceof Error ? uploadMut.error.message : 'Ошибка загрузки'} )} {deleteMut.isError && ( {getApiErrorMessage(deleteMut.error) ?? 'Ошибка удаления'} )} {resizeMut.isError && ( {getApiErrorMessage(resizeMut.error) ?? 'Ошибка обработки'} )} {galleryQuery.isError && ( Не удалось загрузить список. )} deleteMut.mutate(id)} onResize={(id) => resizeMut.mutate(id)} /> {!galleryQuery.isLoading && items.length === 0 && ( Пока нет загруженных изображений. )} ) } ``` - [ ] **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 Фото (из галереи) Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл остаётся на сервере и в галерее. // ... keep the rest (imageUrls preview) unchanged ``` - [ ] **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 && ( В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея». )} ``` - [ ] **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" ```