Files
shop-server/client/src/pages/admin-slider/ui/AdminSliderPage.tsx
T
2026-05-26 12:30:09 +05:00

252 lines
9.4 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 { useMemo, useState } from 'react'
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
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 IconButton from '@mui/material/IconButton'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchAdminCatalogSlider, putAdminCatalogSlider } from '@/entities/catalog-slider'
import { fetchAdminGallery } from '@/entities/gallery'
import type { GalleryImageItem } from '@/entities/gallery'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
type SlideDraft = { galleryImageId: string; caption: string; textColor: string }
function SliderEditor({
initialSlides,
galleryItems,
}: {
initialSlides: SlideDraft[]
galleryItems: GalleryImageItem[]
}) {
const queryClient = useQueryClient()
const [sliderDraft, setSliderDraft] = useState<SlideDraft[]>(initialSlides)
const [pickOpen, setPickOpen] = useState(false)
const usedIds = new Set(sliderDraft.map((s) => s.galleryImageId))
const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id) && i.isResized)
const saveSliderMut = useMutation({
mutationFn: () =>
putAdminCatalogSlider({
slides: sliderDraft.map((s) => ({
galleryImageId: s.galleryImageId,
caption: s.caption,
textColor: s.textColor,
})),
}),
onSuccess: async () => {
await invalidateQueryKeys(queryClient, [['admin', 'catalog-slider'], ['catalog-slider']])
},
})
const moveSlide = (idx: number, dir: -1 | 1) => {
const next = idx + dir
if (next < 0 || next >= sliderDraft.length) return
setSliderDraft((prev) => {
const copy = [...prev]
const t = copy[idx]!
copy[idx] = copy[next]!
copy[next] = t
return copy
})
}
const updateDraft = (idx: number, patch: Partial<SlideDraft>) => {
setSliderDraft((prev) => {
const copy = [...prev]
copy[idx] = { ...copy[idx]!, ...patch }
return copy
})
}
return (
<>
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Stack spacing={1.5} sx={{ mb: 2 }}>
{sliderDraft.length === 0 && (
<Typography color="text.secondary">Нет слайдов. Добавьте изображения из галереи.</Typography>
)}
{sliderDraft.map((row, idx) => {
const img = galleryItems.find((g) => g.id === row.galleryImageId)
return (
<Stack
key={`${row.galleryImageId}-${idx}`}
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
sx={{ alignItems: { sm: 'flex-start' } }}
>
<Box
sx={{
width: 100,
height: 100,
flexShrink: 0,
borderRadius: 1,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
}}
>
<Box
component="img"
src={img?.url ?? ''}
alt=""
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
<Stack spacing={1.5} sx={{ flex: 1, minWidth: 0 }}>
<TextField
label="Подпись на слайде"
fullWidth
multiline
minRows={2}
value={row.caption}
onChange={(e) => updateDraft(idx, { caption: e.target.value })}
/>
<TextField
label="Цвет текста"
type="color"
value={row.textColor}
onChange={(e) => updateDraft(idx, { textColor: e.target.value })}
sx={{ width: 80 }}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Stack>
<Stack direction="row" spacing={0.5} sx={{ alignSelf: 'flex-start' }}>
<IconButton size="small" aria-label="Выше" onClick={() => moveSlide(idx, -1)} disabled={idx === 0}>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="Ниже"
onClick={() => moveSlide(idx, 1)}
disabled={idx >= sliderDraft.length - 1}
>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
aria-label="Убрать из слайдера"
onClick={() => setSliderDraft((prev) => prev.filter((_, i) => i !== idx))}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
</Stack>
)
})}
</Stack>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
<Button variant="outlined" disabled={pickCandidates.length === 0} onClick={() => setPickOpen(true)}>
Добавить слайд из галереи
</Button>
<Button variant="contained" disabled={saveSliderMut.isPending} onClick={() => saveSliderMut.mutate()}>
Сохранить слайдер
</Button>
{saveSliderMut.isError && (
<Typography color="error">
{saveSliderMut.error instanceof Error ? saveSliderMut.error.message : 'Ошибка сохранения'}
</Typography>
)}
</Stack>
</Paper>
<Dialog open={pickOpen} onClose={() => setPickOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Выберите изображение</DialogTitle>
<DialogContent dividers>
{pickCandidates.length === 0 ? (
<Typography color="text.secondary">Нет доступных файлов (все уже в слайдере или галерея пуста).</Typography>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 1.5,
pt: 1,
}}
>
{pickCandidates.map((item) => (
<Button
key={item.id}
sx={{ p: 0, minWidth: 0, display: 'block', borderRadius: 1, overflow: 'hidden' }}
onClick={() => {
setSliderDraft((prev) => [...prev, { galleryImageId: item.id, caption: '', textColor: '#ffffff' }])
setPickOpen(false)
}}
>
<Box
component="img"
src={item.url}
alt=""
sx={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }}
/>
</Button>
))}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setPickOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</>
)
}
export function AdminSliderPage() {
const sliderQuery = useQuery({
queryKey: ['admin', 'catalog-slider'],
queryFn: fetchAdminCatalogSlider,
})
const galleryQuery = useQuery({
queryKey: ['admin', 'gallery'],
queryFn: fetchAdminGallery,
})
const galleryItems: GalleryImageItem[] = galleryQuery.data?.items ?? []
const initialSlides = useMemo<SlideDraft[]>(() => {
if (!sliderQuery.isSuccess) return []
return sliderQuery.data.slides.map((s) => ({
galleryImageId: s.galleryImageId,
caption: s.caption,
textColor: s.textColor || '#ffffff',
}))
}, [sliderQuery.isSuccess, sliderQuery.data?.slides])
if (sliderQuery.isLoading || galleryQuery.isLoading) {
return <Typography color="text.secondary">Загрузка</Typography>
}
if (sliderQuery.isError) {
return <Typography color="error">Не удалось загрузить слайдер.</Typography>
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Слайдер
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Изображения для карусели на главной странице. Сначала загрузите фото в Галерею и обработайте их (Resize), затем
добавьте в слайдер. Порядок строк = порядок показа.
</Typography>
<SliderEditor key={sliderQuery.dataUpdatedAt} initialSlides={initialSlides} galleryItems={galleryItems} />
</Box>
)
}