deploy
This commit is contained in:
@@ -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<void> {
|
||||
await apiClient.delete(`admin/gallery/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type GalleryImageItem = {
|
||||
id: string
|
||||
url: string
|
||||
createdAt: string
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AdminGalleryPage } from './ui/AdminGalleryPage'
|
||||
@@ -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<HTMLInputElement>(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 (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Галерея
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Изображения без привязки к товару можно загружать здесь; их же можно добавить в карточку товара через «Из
|
||||
галереи». Удаление из списка стирает файл с диска, если оно не используется в товаре.
|
||||
</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">
|
||||
{deleteMut.error instanceof Error ? deleteMut.error.message : 'Ошибка удаления'}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{galleryQuery.isError && (
|
||||
<Typography color="error" sx={{ mb: 2 }}>
|
||||
Не удалось загрузить список.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={item.url}
|
||||
alt=""
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<Tooltip title="Удалить из галереи">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
|
||||
}}
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => deleteMut.mutate(item.id)}
|
||||
>
|
||||
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{!galleryQuery.isLoading && items.length === 0 && (
|
||||
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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: <StorefrontOutlinedIcon /> },
|
||||
{ to: '/admin/gallery', label: 'Галерея', icon: <PhotoLibraryOutlinedIcon /> },
|
||||
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
|
||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
|
||||
{ to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> },
|
||||
@@ -183,6 +186,7 @@ export function AdminLayoutPage() {
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
|
||||
<Routes>
|
||||
<Route index element={<AdminPage />} />
|
||||
<Route path="gallery" element={<AdminGalleryPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
<Route path="reviews" element={<AdminReviewsPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
|
||||
@@ -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<Product>()
|
||||
const [catOpen, setCatOpen] = useState(false)
|
||||
const [galleryPickOpen, setGalleryPickOpen] = useState(false)
|
||||
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const productForm = useForm<FormState>({
|
||||
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 (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
@@ -371,15 +407,19 @@ export function AdminPage() {
|
||||
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
<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 component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
|
||||
@@ -397,6 +437,15 @@ export function AdminPage() {
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setGallerySelectedUrls(new Set())
|
||||
setGalleryPickOpen(true)
|
||||
}}
|
||||
>
|
||||
Из галереи
|
||||
</Button>
|
||||
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
|
||||
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
|
||||
</Box>
|
||||
@@ -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() {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={galleryPickOpen}
|
||||
onClose={() => {
|
||||
setGalleryPickOpen(false)
|
||||
setGallerySelectedUrls(new Set())
|
||||
}}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
>
|
||||
<DialogTitle>Изображения из галереи</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{galleryForPickQuery.isLoading && <Typography color="text.secondary">Загрузка списка…</Typography>}
|
||||
{galleryForPickQuery.isError && (
|
||||
<Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>
|
||||
)}
|
||||
{galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && (
|
||||
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 1.5,
|
||||
pt: 1,
|
||||
}}
|
||||
>
|
||||
{(galleryForPickQuery.data?.items ?? []).map((item) => {
|
||||
const alreadyInCard = productForm.watch('imageUrls').includes(item.url)
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
sx={{ m: 0, alignItems: 'flex-start' }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={alreadyInCard || gallerySelectedUrls.has(item.url)}
|
||||
disabled={alreadyInCard}
|
||||
onChange={() => toggleGalleryPickUrl(item.url)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box
|
||||
component="img"
|
||||
src={item.url}
|
||||
alt=""
|
||||
sx={{ width: '100%', maxHeight: 100, objectFit: 'cover', borderRadius: 1, display: 'block' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setGalleryPickOpen(false)
|
||||
setGallerySelectedUrls(new Set())
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={appendGalleryUrlsToForm}
|
||||
disabled={![...gallerySelectedUrls].some((u) => !productForm.watch('imageUrls').includes(u))}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={catOpen} onClose={() => setCatOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>Новая категория</DialogTitle>
|
||||
<DialogContent>
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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 : 'Не удалось загрузить файлы'
|
||||
|
||||
Reference in New Issue
Block a user