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 AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
|
||||||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'
|
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'
|
||||||
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
|
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
|
||||||
|
import PhotoLibraryOutlinedIcon from '@mui/icons-material/PhotoLibraryOutlined'
|
||||||
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
|
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
|
||||||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
|
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
|
||||||
import Badge from '@mui/material/Badge'
|
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 { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
||||||
import { AdminPage } from '@/pages/admin'
|
import { AdminPage } from '@/pages/admin'
|
||||||
|
import { AdminGalleryPage } from '@/pages/admin-gallery'
|
||||||
import { AdminInfoPage } from '@/pages/admin-info'
|
import { AdminInfoPage } from '@/pages/admin-info'
|
||||||
import { AdminOrdersPage } from '@/pages/admin-orders'
|
import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||||
@@ -58,6 +60,7 @@ export function AdminLayoutPage() {
|
|||||||
const navItems: NavItem[] = useMemo(
|
const navItems: NavItem[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ to: '/admin', label: 'Товары', icon: <StorefrontOutlinedIcon /> },
|
{ to: '/admin', label: 'Товары', icon: <StorefrontOutlinedIcon /> },
|
||||||
|
{ to: '/admin/gallery', label: 'Галерея', icon: <PhotoLibraryOutlinedIcon /> },
|
||||||
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
|
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
|
||||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
|
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
|
||||||
{ to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> },
|
{ to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> },
|
||||||
@@ -183,6 +186,7 @@ export function AdminLayoutPage() {
|
|||||||
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
|
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<AdminPage />} />
|
<Route index element={<AdminPage />} />
|
||||||
|
<Route path="gallery" element={<AdminGalleryPage />} />
|
||||||
<Route path="orders" element={<AdminOrdersPage />} />
|
<Route path="orders" element={<AdminOrdersPage />} />
|
||||||
<Route path="reviews" element={<AdminReviewsPage />} />
|
<Route path="reviews" element={<AdminReviewsPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { useRef, useState } from 'react'
|
|||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
|
import Checkbox from '@mui/material/Checkbox'
|
||||||
import Dialog from '@mui/material/Dialog'
|
import Dialog from '@mui/material/Dialog'
|
||||||
import DialogActions from '@mui/material/DialogActions'
|
import DialogActions from '@mui/material/DialogActions'
|
||||||
import DialogContent from '@mui/material/DialogContent'
|
import DialogContent from '@mui/material/DialogContent'
|
||||||
import DialogTitle from '@mui/material/DialogTitle'
|
import DialogTitle from '@mui/material/DialogTitle'
|
||||||
import FormControl from '@mui/material/FormControl'
|
import FormControl from '@mui/material/FormControl'
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||||
|
import FormHelperText from '@mui/material/FormHelperText'
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
import InputLabel from '@mui/material/InputLabel'
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
import MenuItem from '@mui/material/MenuItem'
|
||||||
import Select from '@mui/material/Select'
|
import Select from '@mui/material/Select'
|
||||||
@@ -22,6 +24,7 @@ import TextField from '@mui/material/TextField'
|
|||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import { fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
|
||||||
import {
|
import {
|
||||||
createCategory,
|
createCategory,
|
||||||
createProduct,
|
createProduct,
|
||||||
@@ -72,6 +75,8 @@ export function AdminPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
|
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
|
||||||
const [catOpen, setCatOpen] = useState(false)
|
const [catOpen, setCatOpen] = useState(false)
|
||||||
|
const [galleryPickOpen, setGalleryPickOpen] = useState(false)
|
||||||
|
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
const productForm = useForm<FormState>({
|
const productForm = useForm<FormState>({
|
||||||
defaultValues: emptyForm(),
|
defaultValues: emptyForm(),
|
||||||
@@ -97,6 +102,12 @@ export function AdminPage() {
|
|||||||
queryFn: fetchAdminProducts,
|
queryFn: fetchAdminProducts,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const galleryForPickQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'gallery'],
|
||||||
|
queryFn: fetchAdminGallery,
|
||||||
|
enabled: galleryPickOpen,
|
||||||
|
})
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
productForm.reset(emptyForm())
|
productForm.reset(emptyForm())
|
||||||
openCreateDialog()
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Typography variant="h4" gutterBottom>
|
||||||
@@ -371,15 +407,19 @@ export function AdminPage() {
|
|||||||
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
||||||
/>
|
/>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
Фото (загрузка)
|
Фото (загрузка)
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
||||||
|
Крестик на превью убирает фото только из карточки; файл остаётся на сервере и в галерее.
|
||||||
|
</FormHelperText>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 2,
|
gap: 2,
|
||||||
alignItems: { sm: 'center' },
|
alignItems: { sm: 'center' },
|
||||||
flexDirection: { xs: 'column', sm: 'row' },
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
flexWrap: 'wrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
|
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
|
||||||
@@ -397,6 +437,15 @@ export function AdminPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
setGallerySelectedUrls(new Set())
|
||||||
|
setGalleryPickOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Из галереи
|
||||||
|
</Button>
|
||||||
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
|
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
|
||||||
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
|
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -428,6 +477,8 @@ export function AdminPage() {
|
|||||||
color="error"
|
color="error"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => removeImage(url)}
|
onClick={() => removeImage(url)}
|
||||||
|
aria-label="Убрать из карточки"
|
||||||
|
title="Убрать из карточки"
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 4,
|
top: 4,
|
||||||
@@ -502,6 +553,77 @@ export function AdminPage() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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">
|
<Dialog open={catOpen} onClose={() => setCatOpen(false)} fullWidth maxWidth="xs">
|
||||||
<DialogTitle>Новая категория</DialogTitle>
|
<DialogTitle>Новая категория</DialogTitle>
|
||||||
<DialogContent>
|
<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])
|
@@index([productId, sort])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Медиатека админки: зарегистрированные файлы /uploads/... (без обязательной привязки к товару).
|
||||||
|
model GalleryImage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
url String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
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,
|
parseMaterialsInput,
|
||||||
slugify,
|
slugify,
|
||||||
} from './api/_product-helpers.js'
|
} from './api/_product-helpers.js'
|
||||||
|
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
||||||
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
||||||
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
||||||
import { registerAdminProductRoutes } from './api/admin-products.js'
|
import { registerAdminProductRoutes } from './api/admin-products.js'
|
||||||
@@ -22,6 +23,7 @@ export async function registerApiRoutes(fastify) {
|
|||||||
parseMaterialsInput,
|
parseMaterialsInput,
|
||||||
mapProductForApi,
|
mapProductForApi,
|
||||||
})
|
})
|
||||||
|
await registerAdminGalleryRoutes(fastify)
|
||||||
await registerAdminCategoryRoutes(fastify, { slugify })
|
await registerAdminCategoryRoutes(fastify, { slugify })
|
||||||
await registerAdminOrderRoutes(fastify)
|
await registerAdminOrderRoutes(fastify)
|
||||||
await registerAdminReviewRoutes(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 { prisma } from '../../lib/prisma.js'
|
||||||
import {
|
import {
|
||||||
formatFileTooLargeMessage,
|
formatFileTooLargeMessage,
|
||||||
@@ -31,6 +32,7 @@ export async function registerAdminProductRoutes(
|
|||||||
maxFiles: 10,
|
maxFiles: 10,
|
||||||
maxFileBytes: getProductImageMaxFileBytes(),
|
maxFileBytes: getProductImageMaxFileBytes(),
|
||||||
})
|
})
|
||||||
|
await upsertGalleryImagesByUrls(urls)
|
||||||
return { urls }
|
return { urls }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
|
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
|
||||||
|
|||||||
Reference in New Issue
Block a user