Files
shop-server/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx
T
2026-05-26 12:10:38 +05:00

257 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Stack from '@mui/material/Stack'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import ToggleButton from '@mui/material/ToggleButton'
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
deleteGalleryImage,
fetchAdminGallery,
GalleryGrid,
resizeGalleryImage,
uploadGalleryImages,
} from '@/entities/gallery'
import type { GalleryImageItem } from '@/entities/gallery'
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import type { AxiosError } from 'axios'
import { Grid3x3, List } from 'lucide-react'
function getApiErrorMessage(error: unknown): string | null {
const e = error as AxiosError<{ error?: string }>
const msg = e?.response?.data?.error
return msg ? String(msg) : null
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch {
return ''
}
}
function fileNameFromUrl(url: string): string {
const parts = url.split('/')
return parts[parts.length - 1] || url
}
function GalleryTable({
items,
deleting,
resizing,
onDelete,
onResize,
}: {
items: GalleryImageItem[]
deleting: boolean
resizing: string | null
onDelete: (id: string) => void
onResize: (id: string) => void
}) {
return (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Миниатюра</TableCell>
<TableCell>Имя файла</TableCell>
<TableCell>Статус</TableCell>
<TableCell>Дата</TableCell>
<TableCell>Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<Box
component="img"
src={item.url}
alt=""
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1, display: 'block' }}
/>
</TableCell>
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fileNameFromUrl(item.url)}
</TableCell>
<TableCell>
<Chip
label={item.isResized ? 'Готово' : 'Не обработано'}
size="small"
color={item.isResized ? 'success' : 'warning'}
/>
</TableCell>
<TableCell>{formatDate(item.createdAt)}</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5}>
{!item.isResized && (
<Button
size="small"
variant="outlined"
disabled={resizing === item.id}
onClick={() => onResize(item.id)}
>
Resize
</Button>
)}
<Button
size="small"
variant="outlined"
color="error"
disabled={deleting}
onClick={() => onDelete(item.id)}
>
Удалить
</Button>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}
export function AdminGalleryPage() {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [resizingId, setResizingId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
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 deleteMut = useMutation({
mutationFn: (id: string) => deleteGalleryImage(id),
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']])
},
})
const resizeMut = useMutation({
mutationFn: async (id: string) => {
setResizingId(id)
try {
return await resizeGalleryImage(id)
} finally {
setResizingId(null)
}
},
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']])
},
})
const items = galleryQuery.data?.items ?? []
return (
<Box>
<Typography variant="h4" gutterBottom>
Галерея
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Изображения загружаются без обработки. После загрузки нажмите «Resize» для подготовки к публикации. Обработанные
изображения доступны для добавления в карточку товара и слайдер.
</Typography>
<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>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, v) => v && setViewMode(v)}
size="small"
>
<ToggleButton value="grid" aria-label="Сетка">
<Grid3x3 size={16} />
</ToggleButton>
<ToggleButton value="table" aria-label="Таблица">
<List size={16} />
</ToggleButton>
</ToggleButtonGroup>
{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>
)}
{viewMode === 'grid' ? (
<GalleryGrid
items={items}
deleting={deleteMut.isPending}
resizing={resizingId}
onDelete={(id) => deleteMut.mutate(id)}
onResize={(id) => resizeMut.mutate(id)}
/>
) : (
<GalleryTable
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>
)
}