252 lines
9.4 KiB
TypeScript
252 lines
9.4 KiB
TypeScript
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>
|
||
)
|
||
}
|