This commit is contained in:
Kirill
2026-05-26 12:10:38 +05:00
parent 4b8b86e1b8
commit e092299a11
37 changed files with 39573 additions and 214 deletions
@@ -1,11 +1,18 @@
import { useRef, useState } from 'react'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
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 { fetchAdminCatalogSlider } from '@/entities/catalog-slider'
import {
deleteGalleryImage,
fetchAdminGallery,
@@ -13,10 +20,11 @@ import {
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 { GallerySliderSection } from './GallerySliderSection'
import type { AxiosError } from 'axios'
import { Grid3x3, List } from 'lucide-react'
function getApiErrorMessage(error: unknown): string | null {
const e = error as AxiosError<{ error?: string }>
@@ -24,15 +32,102 @@ function getApiErrorMessage(error: unknown): string | null {
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 sliderQuery = useQuery({
queryKey: ['admin', 'catalog-slider'],
queryFn: fetchAdminCatalogSlider,
})
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
const galleryQuery = useQuery({
queryKey: ['admin', 'gallery'],
@@ -82,29 +177,6 @@ export function AdminGalleryPage() {
изображения доступны для добавления в карточку товара и слайдер.
</Typography>
{sliderQuery.isError && (
<Typography color="error" sx={{ mb: 2 }}>
Не удалось загрузить настройки слайдера.
</Typography>
)}
{sliderQuery.isLoading && (
<Typography color="text.secondary" sx={{ mb: 2 }}>
Загрузка настроек слайдера
</Typography>
)}
{sliderQuery.isSuccess && (
<GallerySliderSection
key={sliderQuery.dataUpdatedAt}
initialSlides={sliderQuery.data.slides.map((s) => ({
galleryImageId: s.galleryImageId,
caption: s.caption,
}))}
galleryItems={items}
/>
)}
<Divider sx={{ mb: 3 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Форматы: PNG, JPEG, WebP. На один файл до {formatAdminImageMaxSizeHint()}.
</Typography>
@@ -125,6 +197,19 @@ export function AdminGalleryPage() {
}}
/>
</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">
@@ -145,13 +230,23 @@ export function AdminGalleryPage() {
</Typography>
)}
<GalleryGrid
items={items}
deleting={deleteMut.isPending}
resizing={resizingId}
onDelete={(id) => deleteMut.mutate(id)}
onResize={(id) => resizeMut.mutate(id)}
/>
{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>