257 lines
8.5 KiB
TypeScript
257 lines
8.5 KiB
TypeScript
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>
|
||
)
|
||
}
|