1051 lines
34 KiB
Markdown
1051 lines
34 KiB
Markdown
# 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"
|
||
```
|