From 20096c1eecbe776d91ce2494f673c0f487b1c4f9 Mon Sep 17 00:00:00 2001 From: "@kirill.komarov" Date: Sun, 10 May 2026 17:38:04 +0500 Subject: [PATCH] deploy --- .../src/entities/gallery/api/gallery-api.ts | 11 ++ client/src/entities/gallery/model/types.ts | 5 + client/src/pages/admin-gallery/index.ts | 1 + .../admin-gallery/ui/AdminGalleryPage.tsx | 138 ++++++++++++++++++ .../pages/admin-layout/ui/AdminLayoutPage.tsx | 4 + client/src/pages/admin/ui/AdminPage.tsx | 124 +++++++++++++++- .../migration.sql | 9 ++ server/prisma/schema.prisma | 7 + server/src/lib/gallery.js | 12 ++ server/src/routes/api.js | 2 + server/src/routes/api/admin-gallery.js | 47 ++++++ server/src/routes/api/admin-products.js | 2 + 12 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 client/src/entities/gallery/api/gallery-api.ts create mode 100644 client/src/entities/gallery/model/types.ts create mode 100644 client/src/pages/admin-gallery/index.ts create mode 100644 client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx create mode 100644 server/prisma/migrations/20260510123418_add_gallery_image/migration.sql create mode 100644 server/src/lib/gallery.js create mode 100644 server/src/routes/api/admin-gallery.js diff --git a/client/src/entities/gallery/api/gallery-api.ts b/client/src/entities/gallery/api/gallery-api.ts new file mode 100644 index 0000000..8838fe8 --- /dev/null +++ b/client/src/entities/gallery/api/gallery-api.ts @@ -0,0 +1,11 @@ +import type { GalleryImageItem } from '@/entities/gallery/model/types' +import { apiClient } from '@/shared/api/client' + +export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> { + const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery') + return data +} + +export async function deleteGalleryImage(id: string): Promise { + await apiClient.delete(`admin/gallery/${id}`) +} diff --git a/client/src/entities/gallery/model/types.ts b/client/src/entities/gallery/model/types.ts new file mode 100644 index 0000000..7fe0189 --- /dev/null +++ b/client/src/entities/gallery/model/types.ts @@ -0,0 +1,5 @@ +export type GalleryImageItem = { + id: string + url: string + createdAt: string +} diff --git a/client/src/pages/admin-gallery/index.ts b/client/src/pages/admin-gallery/index.ts new file mode 100644 index 0000000..c50af2d --- /dev/null +++ b/client/src/pages/admin-gallery/index.ts @@ -0,0 +1 @@ +export { AdminGalleryPage } from './ui/AdminGalleryPage' diff --git a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx new file mode 100644 index 0000000..08b87fb --- /dev/null +++ b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx @@ -0,0 +1,138 @@ +import { useRef } from 'react' +import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import IconButton from '@mui/material/IconButton' +import Stack from '@mui/material/Stack' +import Tooltip from '@mui/material/Tooltip' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { deleteGalleryImage, fetchAdminGallery } from '@/entities/gallery/api/gallery-api' +import { uploadAdminProductImages } from '@/entities/product/api/product-api' +import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' + +export function AdminGalleryPage() { + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + + const galleryQuery = useQuery({ + queryKey: ['admin', 'gallery'], + queryFn: fetchAdminGallery, + }) + + const uploadMut = useMutation({ + mutationFn: (files: File[]) => uploadAdminProductImages(files), + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'gallery']]) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + }, + }) + + const deleteMut = useMutation({ + mutationFn: (id: string) => deleteGalleryImage(id), + onSuccess: () => { + void invalidateQueryKeys(queryClient, [['admin', 'gallery']]) + }, + }) + + const items = galleryQuery.data?.items ?? [] + + return ( + + + Галерея + + + Изображения без привязки к товару можно загружать здесь; их же можно добавить в карточку товара через «Из + галереи». Удаление из списка стирает файл с диска, если оно не используется в товаре. + + + + + {uploadMut.isPending && Загрузка…} + {uploadMut.isError && ( + + {uploadMut.error instanceof Error ? uploadMut.error.message : 'Ошибка загрузки'} + + )} + {deleteMut.isError && ( + + {deleteMut.error instanceof Error ? deleteMut.error.message : 'Ошибка удаления'} + + )} + + + {galleryQuery.isError && ( + + Не удалось загрузить список. + + )} + + + {items.map((item) => ( + + + + deleteMut.mutate(item.id)} + > + + + + + ))} + + + {!galleryQuery.isLoading && items.length === 0 && ( + Пока нет загруженных изображений. + )} + + ) +} diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index ed1777c..757a098 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -4,6 +4,7 @@ import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettin import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined' import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined' import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined' +import PhotoLibraryOutlinedIcon from '@mui/icons-material/PhotoLibraryOutlined' import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined' import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined' import Badge from '@mui/material/Badge' @@ -24,6 +25,7 @@ import { useUnit } from 'effector-react' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api' import { AdminPage } from '@/pages/admin' +import { AdminGalleryPage } from '@/pages/admin-gallery' import { AdminInfoPage } from '@/pages/admin-info' import { AdminOrdersPage } from '@/pages/admin-orders' import { AdminReviewsPage } from '@/pages/admin-reviews' @@ -58,6 +60,7 @@ export function AdminLayoutPage() { const navItems: NavItem[] = useMemo( () => [ { to: '/admin', label: 'Товары', icon: }, + { to: '/admin/gallery', label: 'Галерея', icon: }, { to: '/admin/orders', label: 'Заказы', icon: }, { to: '/admin/reviews', label: 'Отзывы', icon: }, { to: '/admin/users', label: 'Пользователи', icon: }, @@ -183,6 +186,7 @@ export function AdminLayoutPage() { } /> + } /> } /> } /> } /> diff --git a/client/src/pages/admin/ui/AdminPage.tsx b/client/src/pages/admin/ui/AdminPage.tsx index 835fd27..374c992 100644 --- a/client/src/pages/admin/ui/AdminPage.tsx +++ b/client/src/pages/admin/ui/AdminPage.tsx @@ -2,12 +2,14 @@ import { useRef, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' +import Checkbox from '@mui/material/Checkbox' import Dialog from '@mui/material/Dialog' import DialogActions from '@mui/material/DialogActions' import DialogContent from '@mui/material/DialogContent' import DialogTitle from '@mui/material/DialogTitle' import FormControl from '@mui/material/FormControl' import FormControlLabel from '@mui/material/FormControlLabel' +import FormHelperText from '@mui/material/FormHelperText' import InputLabel from '@mui/material/InputLabel' import MenuItem from '@mui/material/MenuItem' import Select from '@mui/material/Select' @@ -22,6 +24,7 @@ import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Controller, useForm } from 'react-hook-form' +import { fetchAdminGallery } from '@/entities/gallery/api/gallery-api' import { createCategory, createProduct, @@ -72,6 +75,8 @@ export function AdminPage() { const queryClient = useQueryClient() const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState() const [catOpen, setCatOpen] = useState(false) + const [galleryPickOpen, setGalleryPickOpen] = useState(false) + const [gallerySelectedUrls, setGallerySelectedUrls] = useState>(() => new Set()) const productForm = useForm({ defaultValues: emptyForm(), @@ -97,6 +102,12 @@ export function AdminPage() { queryFn: fetchAdminProducts, }) + const galleryForPickQuery = useQuery({ + queryKey: ['admin', 'gallery'], + queryFn: fetchAdminGallery, + enabled: galleryPickOpen, + }) + const openCreate = () => { productForm.reset(emptyForm()) openCreateDialog() @@ -253,6 +264,31 @@ export function AdminPage() { ) } + const toggleGalleryPickUrl = (url: string) => { + setGallerySelectedUrls((prev) => { + const next = new Set(prev) + if (next.has(url)) { + next.delete(url) + } else { + next.add(url) + } + return next + }) + } + + const appendGalleryUrlsToForm = () => { + const current = productForm.getValues('imageUrls') + const merged = [...current] + for (const url of gallerySelectedUrls) { + if (!merged.includes(url)) { + merged.push(url) + } + } + productForm.setValue('imageUrls', merged, { shouldDirty: true }) + setGalleryPickOpen(false) + setGallerySelectedUrls(new Set()) + } + return ( @@ -371,15 +407,19 @@ export function AdminPage() { render={({ field }) => } /> - + Фото (загрузка) + + Крестик на превью убирает фото только из карточки; файл остаётся на сервере и в галерее. + + {uploadImagesMut.isPending && Загрузка…} {uploadImagesMut.isError && Не удалось загрузить фото} @@ -428,6 +477,8 @@ export function AdminPage() { color="error" variant="contained" onClick={() => removeImage(url)} + aria-label="Убрать из карточки" + title="Убрать из карточки" sx={{ position: 'absolute', top: 4, @@ -502,6 +553,77 @@ export function AdminPage() { + { + setGalleryPickOpen(false) + setGallerySelectedUrls(new Set()) + }} + fullWidth + maxWidth="sm" + > + Изображения из галереи + + {galleryForPickQuery.isLoading && Загрузка списка…} + {galleryForPickQuery.isError && ( + Не удалось загрузить галерею. Попробуйте ещё раз. + )} + {galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && ( + В галерее пока нет файлов. Загрузите их в разделе «Галерея». + )} + + {(galleryForPickQuery.data?.items ?? []).map((item) => { + const alreadyInCard = productForm.watch('imageUrls').includes(item.url) + return ( + toggleGalleryPickUrl(item.url)} + /> + } + label={ + + } + /> + ) + })} + + + + + + + + setCatOpen(false)} fullWidth maxWidth="xs"> Новая категория diff --git a/server/prisma/migrations/20260510123418_add_gallery_image/migration.sql b/server/prisma/migrations/20260510123418_add_gallery_image/migration.sql new file mode 100644 index 0000000..e90f479 --- /dev/null +++ b/server/prisma/migrations/20260510123418_add_gallery_image/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "GalleryImage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "url" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "GalleryImage_url_key" ON "GalleryImage"("url"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 4adb042..2be8c41 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -55,6 +55,13 @@ model ProductImage { @@index([productId, sort]) } +/// Медиатека админки: зарегистрированные файлы /uploads/... (без обязательной привязки к товару). +model GalleryImage { + id String @id @default(cuid()) + url String @unique + createdAt DateTime @default(now()) +} + model User { id String @id @default(cuid()) email String @unique diff --git a/server/src/lib/gallery.js b/server/src/lib/gallery.js new file mode 100644 index 0000000..732eda4 --- /dev/null +++ b/server/src/lib/gallery.js @@ -0,0 +1,12 @@ +import { prisma } from './prisma.js' + +/** Регистрация загруженных путей в медиатеке (идемпотентно). */ +export async function upsertGalleryImagesByUrls(urls) { + for (const url of urls) { + await prisma.galleryImage.upsert({ + where: { url }, + create: { url }, + update: {}, + }) + } +} diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 50aa7bc..121f5a4 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -3,6 +3,7 @@ import { parseMaterialsInput, slugify, } from './api/_product-helpers.js' +import { registerAdminGalleryRoutes } from './api/admin-gallery.js' import { registerAdminCategoryRoutes } from './api/admin-categories.js' import { registerAdminOrderRoutes } from './api/admin-orders.js' import { registerAdminProductRoutes } from './api/admin-products.js' @@ -22,6 +23,7 @@ export async function registerApiRoutes(fastify) { parseMaterialsInput, mapProductForApi, }) + await registerAdminGalleryRoutes(fastify) await registerAdminCategoryRoutes(fastify, { slugify }) await registerAdminOrderRoutes(fastify) await registerAdminReviewRoutes(fastify) diff --git a/server/src/routes/api/admin-gallery.js b/server/src/routes/api/admin-gallery.js new file mode 100644 index 0000000..d60a410 --- /dev/null +++ b/server/src/routes/api/admin-gallery.js @@ -0,0 +1,47 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { prisma } from '../../lib/prisma.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.delete( + '/api/admin/gallery/:id', + { 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: 'Не найдено' }) + } + + const usedInImages = await prisma.productImage.count({ where: { url: row.url } }) + const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } }) + if (usedInImages > 0 || usedAsLegacy > 0) { + return reply.code(409).send({ error: 'Изображение используется в карточке товара' }) + } + + const relative = row.url.replace(/^\//, '') + const filePath = path.join(process.cwd(), relative) + try { + await fs.unlink(filePath) + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') { + throw err + } + } + + await prisma.galleryImage.delete({ where: { id } }) + return reply.code(204).send() + }, + ) +} diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js index dc97395..7d7a824 100644 --- a/server/src/routes/api/admin-products.js +++ b/server/src/routes/api/admin-products.js @@ -1,3 +1,4 @@ +import { upsertGalleryImagesByUrls } from '../../lib/gallery.js' import { prisma } from '../../lib/prisma.js' import { formatFileTooLargeMessage, @@ -31,6 +32,7 @@ export async function registerAdminProductRoutes( maxFiles: 10, maxFileBytes: getProductImageMaxFileBytes(), }) + await upsertGalleryImagesByUrls(urls) return { urls } } catch (error) { let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'