ыввы
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { useEffect, 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 }
|
||||
|
||||
export function AdminSliderPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const sliderQuery = useQuery({
|
||||
queryKey: ['admin', 'catalog-slider'],
|
||||
queryFn: fetchAdminCatalogSlider,
|
||||
})
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: ['admin', 'gallery'],
|
||||
queryFn: fetchAdminGallery,
|
||||
})
|
||||
|
||||
const [sliderDraft, setSliderDraft] = useState<SlideDraft[]>([])
|
||||
const [pickOpen, setPickOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (sliderQuery.isSuccess && sliderDraft.length === 0) {
|
||||
setSliderDraft(
|
||||
sliderQuery.data.slides.map((s) => ({
|
||||
galleryImageId: s.galleryImageId,
|
||||
caption: s.caption,
|
||||
textColor: s.textColor || '#ffffff',
|
||||
})),
|
||||
)
|
||||
}
|
||||
}, [sliderQuery.isSuccess, sliderQuery.dataUpdatedAt])
|
||||
|
||||
const galleryItems: GalleryImageItem[] = galleryQuery.data?.items ?? []
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user