mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
354 lines
11 KiB
TypeScript
354 lines
11 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { Box, ButtonBase, IconButton, Modal, Typography, alpha, useTheme } from '@mui/material';
|
|
import { useTranslation } from 'react-i18next';
|
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
|
import CloseIcon from '@mui/icons-material/Close';
|
|
import useEmblaCarousel from 'embla-carousel-react';
|
|
import { GlassCard } from './Glass';
|
|
|
|
export interface HeroGalleryProps {
|
|
images: string[];
|
|
alt: string;
|
|
badge?: React.ReactNode;
|
|
enableLightbox?: boolean;
|
|
}
|
|
|
|
interface CarouselProps {
|
|
images: string[];
|
|
alt: string;
|
|
onMainClick?: () => void;
|
|
variant: 'inline' | 'lightbox';
|
|
}
|
|
|
|
const Carousel: React.FC<CarouselProps> = ({ images, alt, onMainClick, variant }) => {
|
|
const theme = useTheme();
|
|
const { t } = useTranslation();
|
|
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false, align: 'center' });
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [canPrev, setCanPrev] = useState(false);
|
|
const [canNext, setCanNext] = useState(false);
|
|
|
|
const updateState = useCallback(() => {
|
|
if (!emblaApi) return;
|
|
setSelectedIndex(emblaApi.selectedScrollSnap());
|
|
setCanPrev(emblaApi.canScrollPrev());
|
|
setCanNext(emblaApi.canScrollNext());
|
|
}, [emblaApi]);
|
|
|
|
useEffect(() => {
|
|
if (!emblaApi) return;
|
|
updateState();
|
|
emblaApi.on('select', updateState);
|
|
emblaApi.on('reInit', updateState);
|
|
}, [emblaApi, updateState]);
|
|
|
|
const scrollPrev = () => emblaApi?.scrollPrev();
|
|
const scrollNext = () => emblaApi?.scrollNext();
|
|
const scrollTo = (idx: number) => emblaApi?.scrollTo(idx);
|
|
|
|
const isLightbox = variant === 'lightbox';
|
|
|
|
return (
|
|
<Box sx={{ position: 'relative', width: '100%' }}>
|
|
<Box
|
|
ref={emblaRef}
|
|
role="region"
|
|
aria-roledescription="carousel"
|
|
aria-label={t('flavorDetail.galleryLabel', { name: alt })}
|
|
sx={{
|
|
overflow: 'hidden',
|
|
aspectRatio: isLightbox ? 'auto' : '1 / 1',
|
|
height: isLightbox ? '80vh' : 'auto',
|
|
bgcolor: isLightbox ? 'transparent' : 'action.hover',
|
|
borderRadius: isLightbox ? 0 : 0,
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', height: '100%' }}>
|
|
{images.map((src, i) => (
|
|
<Box
|
|
key={`${src}-${i}`}
|
|
role="group"
|
|
aria-roledescription="slide"
|
|
aria-label={t('flavorDetail.imageOf', { current: i + 1, total: images.length })}
|
|
sx={{
|
|
flex: '0 0 100%',
|
|
minWidth: 0,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
p: isLightbox ? 4 : 4,
|
|
cursor: onMainClick ? 'zoom-in' : 'default',
|
|
}}
|
|
onClick={onMainClick}
|
|
>
|
|
<Box
|
|
component="img"
|
|
src={src}
|
|
alt={`${alt} — ${i + 1}`}
|
|
loading={i === 0 ? 'eager' : 'lazy'}
|
|
sx={{
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
width: 'auto',
|
|
height: 'auto',
|
|
objectFit: 'contain',
|
|
filter: isLightbox
|
|
? 'drop-shadow(0px 24px 48px rgba(0,0,0,0.5))'
|
|
: 'drop-shadow(0px 10px 20px rgba(0,0,0,0.15))',
|
|
userSelect: 'none',
|
|
}}
|
|
draggable={false}
|
|
/>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Chevrons (desktop only) */}
|
|
{images.length > 1 && (
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
pointerEvents: 'none',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 1,
|
|
'@media (hover: none)': { display: 'none' },
|
|
}}
|
|
>
|
|
<IconButton
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
scrollPrev();
|
|
}}
|
|
disabled={!canPrev}
|
|
aria-label={t('flavorDetail.previousImage')}
|
|
sx={{
|
|
pointerEvents: 'auto',
|
|
bgcolor: alpha(theme.palette.background.paper, 0.6),
|
|
backdropFilter: theme.tokens.glass.blur,
|
|
opacity: canPrev ? 1 : 0,
|
|
transition: 'opacity 200ms ease',
|
|
'&:hover': { bgcolor: alpha(theme.palette.background.paper, 0.85) },
|
|
}}
|
|
>
|
|
<ChevronLeftIcon />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
scrollNext();
|
|
}}
|
|
disabled={!canNext}
|
|
aria-label={t('flavorDetail.nextImage')}
|
|
sx={{
|
|
pointerEvents: 'auto',
|
|
bgcolor: alpha(theme.palette.background.paper, 0.6),
|
|
backdropFilter: theme.tokens.glass.blur,
|
|
opacity: canNext ? 1 : 0,
|
|
transition: 'opacity 200ms ease',
|
|
'&:hover': { bgcolor: alpha(theme.palette.background.paper, 0.85) },
|
|
}}
|
|
>
|
|
<ChevronRightIcon />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Thumbnails */}
|
|
{images.length > 1 && !isLightbox && (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
gap: 0.75,
|
|
mt: 1.5,
|
|
mx: 2,
|
|
mb: 1.5,
|
|
overflowX: 'auto',
|
|
scrollbarWidth: 'thin',
|
|
'&::-webkit-scrollbar': { height: 6 },
|
|
'&::-webkit-scrollbar-thumb': {
|
|
bgcolor: alpha(theme.palette.text.primary, 0.2),
|
|
borderRadius: 4,
|
|
},
|
|
}}
|
|
>
|
|
{images.map((src, i) => {
|
|
const active = selectedIndex === i;
|
|
return (
|
|
<ButtonBase
|
|
key={`thumb-${src}-${i}`}
|
|
onClick={() => scrollTo(i)}
|
|
aria-label={t('flavorDetail.showImage', { n: i + 1 })}
|
|
aria-current={active ? 'true' : undefined}
|
|
sx={{
|
|
flex: '0 0 auto',
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: theme.tokens.radius.sm + 'px',
|
|
border: '2px solid',
|
|
borderColor: active ? 'primary.main' : alpha(theme.palette.text.primary, 0.12),
|
|
overflow: 'hidden',
|
|
bgcolor: 'action.hover',
|
|
transition: 'border-color 160ms ease, transform 160ms ease',
|
|
'&:hover': { borderColor: 'primary.main', transform: 'translateY(-1px)' },
|
|
'&:focus-visible': {
|
|
outline: `2px solid ${alpha(theme.palette.primary.main, 0.6)}`,
|
|
outlineOffset: 2,
|
|
},
|
|
}}
|
|
>
|
|
<Box
|
|
component="img"
|
|
src={src}
|
|
alt=""
|
|
loading="lazy"
|
|
sx={{ width: '100%', height: '100%', objectFit: 'contain', p: 0.5 }}
|
|
/>
|
|
</ButtonBase>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const HeroGallery: React.FC<HeroGalleryProps> = ({ images, alt, badge, enableLightbox = true }) => {
|
|
const { t } = useTranslation();
|
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
|
|
const hasImages = images.length > 0;
|
|
const single = images.length === 1;
|
|
|
|
return (
|
|
<>
|
|
<GlassCard intensity="strong" sx={{ overflow: 'hidden', position: 'relative' }}>
|
|
{badge && <Box sx={{ position: 'absolute', top: 16, right: 16, zIndex: 2 }}>{badge}</Box>}
|
|
|
|
{!hasImages ? (
|
|
<Box
|
|
sx={{
|
|
p: 4,
|
|
aspectRatio: '1 / 1',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: 'action.hover',
|
|
}}
|
|
>
|
|
<Typography color="text.secondary">{t('flavorDetail.noImage')}</Typography>
|
|
</Box>
|
|
) : single ? (
|
|
<Box
|
|
onClick={enableLightbox ? () => setLightboxOpen(true) : undefined}
|
|
sx={{
|
|
p: 4,
|
|
aspectRatio: '1 / 1',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: 'action.hover',
|
|
cursor: enableLightbox ? 'zoom-in' : 'default',
|
|
}}
|
|
>
|
|
<Box
|
|
component="img"
|
|
src={images[0]}
|
|
alt={alt}
|
|
loading="eager"
|
|
sx={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'contain',
|
|
filter: 'drop-shadow(0px 10px 20px rgba(0,0,0,0.15))',
|
|
}}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<Carousel
|
|
images={images}
|
|
alt={alt}
|
|
variant="inline"
|
|
onMainClick={enableLightbox ? () => setLightboxOpen(true) : undefined}
|
|
/>
|
|
)}
|
|
</GlassCard>
|
|
|
|
{enableLightbox && hasImages && (
|
|
<Modal
|
|
open={lightboxOpen}
|
|
onClose={() => setLightboxOpen(false)}
|
|
aria-label={t('flavorDetail.galleryLabel', { name: alt })}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backdropFilter: 'blur(8px)',
|
|
}}
|
|
slotProps={{
|
|
backdrop: {
|
|
sx: { bgcolor: alpha('#000', 0.85) },
|
|
},
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
position: 'relative',
|
|
width: { xs: '100vw', sm: '90vw' },
|
|
maxWidth: 1200,
|
|
outline: 'none',
|
|
}}
|
|
>
|
|
<IconButton
|
|
onClick={() => setLightboxOpen(false)}
|
|
aria-label="Close"
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 8,
|
|
right: 8,
|
|
zIndex: 3,
|
|
color: 'common.white',
|
|
bgcolor: alpha('#000', 0.4),
|
|
'&:hover': { bgcolor: alpha('#000', 0.6) },
|
|
}}
|
|
>
|
|
<CloseIcon />
|
|
</IconButton>
|
|
{single ? (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: '80vh',
|
|
p: 4,
|
|
}}
|
|
>
|
|
<Box
|
|
component="img"
|
|
src={images[0]}
|
|
alt={alt}
|
|
sx={{
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
objectFit: 'contain',
|
|
filter: 'drop-shadow(0px 24px 48px rgba(0,0,0,0.5))',
|
|
}}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<Carousel images={images} alt={alt} variant="lightbox" />
|
|
)}
|
|
</Box>
|
|
</Modal>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default HeroGallery;
|