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

1051 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```