34 KiB
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(viaprisma 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"
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/uploadroute
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.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
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/resizeroute
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/uploadsroute
Delete lines 62-87 from server/src/routes/api/admin-products.js (the entire fastify.post('/api/admin/uploads', ...) block).
Remove unused imports:
formatFileTooLargeMessagegetProductImageMaxFileBytesisMultipartFileTooLargeErrorpersistMultipartImages
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"
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:
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 errorbut changeeager: truetoeager: falsein 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
isResizedto 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
uploadAdminProductImageswithuploadGalleryImages, 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"
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:
- Remove
uploadAdminProductImagesimport (line 34) - Remove
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'(line 37 — no longer needed in this file) - Remove
productImagesInputRef(line 206) - Remove
uploadImagesMutmutation (lines 208-217) - Change
mutationErrorto excludeuploadImagesMut.error(line 219 →const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error) - Remove the "Выбрать файлы" button and its description text
- 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 —
upsertGalleryImagesByUrlsis no longer imported anywhere. Remove the fileserver/src/lib/gallery.jsif 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
- Start server:
cd server && npm run dev - Start client:
cd client && npm run dev - Open
/admin/gallery→ upload a PNG → verify it shows with "Не обработано" badge - Click resize button → verify it becomes "Готово"
- Open
/admin/products→ edit/create product → verify no "Выбрать файлы" button, only "Из галереи" - Click "Из галереи" → verify only resized images show up
- Add image to product → save → verify product card shows the image
- 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"