mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
import { Box, AspectRatio, Text, useColorModeValue, SimpleGrid, Heading, HStack, Badge, Button, Link, Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, useDisclosure, Icon, VStack } from '@chakra-ui/react';
|
|
import { Link as RouterLink } from 'react-router-dom';
|
|
import { FaYoutube, FaPlay } from 'react-icons/fa';
|
|
import HorizontalScroller from '../ui/HorizontalScroller';
|
|
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
|
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
import CommentsSection from '../comments/CommentsSection';
|
|
|
|
type Props = {
|
|
videos?: string[];
|
|
variant?: 'grid' | 'carousel';
|
|
};
|
|
|
|
type RenderItem = {
|
|
key: string;
|
|
title: string;
|
|
embedUrl: string;
|
|
thumbnail?: string;
|
|
date?: string; // YYYY-MM-DD
|
|
videoId?: string;
|
|
};
|
|
|
|
const toEmbed = (idOrUrl: string): string => {
|
|
// If a full URL is passed, try to extract the id; otherwise assume it's already an id
|
|
// supports https://www.youtube.com/watch?v=ID or youtu.be/ID
|
|
try {
|
|
if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) {
|
|
const u = new URL(idOrUrl);
|
|
if (u.hostname.includes('youtu.be')) {
|
|
const id = u.pathname.replace('/', '');
|
|
return `https://www.youtube.com/embed/${id}`;
|
|
}
|
|
const id = u.searchParams.get('v');
|
|
if (id) return `https://www.youtube.com/embed/${id}`;
|
|
}
|
|
} catch {}
|
|
// otherwise treat as id
|
|
return `https://www.youtube.com/embed/${idOrUrl}`;
|
|
};
|
|
|
|
const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
|
const cardBg = useColorModeValue('white', 'gray.800');
|
|
const theme = useClubTheme();
|
|
const { data: settings } = usePublicSettings();
|
|
const [yt, setYt] = useState<YouTubeVideo[]>([]);
|
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
|
const [selectedVideo, setSelectedVideo] = useState<RenderItem | null>(null);
|
|
const titleOverrides: Record<string, string> = (settings as any)?.videos_title_overrides || {};
|
|
// If admin explicitly disabled, respect it. Otherwise default to ON when there are manual videos configured
|
|
// or when a YouTube URL is present for auto mode.
|
|
const hasManualConfigured = Boolean((settings as any)?.videos_items?.length || (settings as any)?.videos?.length);
|
|
const hasAutoConfigured = Boolean((settings as any)?.youtube_url || (settings as any)?.social_youtube);
|
|
// Default enablement: if not explicitly set, enable when manual items exist or when a YouTube URL is configured (auto mode).
|
|
// This avoids flicker caused by toggling visibility while data is loading.
|
|
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
|
|
? Boolean((settings as any)?.videos_module_enabled)
|
|
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured));
|
|
const style = (() => {
|
|
if (variant === 'carousel') return 'slider';
|
|
if (variant === 'grid') return 'grid';
|
|
return settings?.videos_style || 'slider';
|
|
})();
|
|
const source = settings?.videos_source || 'auto';
|
|
// Default to 6 items on homepage unless overridden by settings (max 12)
|
|
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
|
|
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
|
|
|
|
useEffect(() => {
|
|
try {
|
|
if (isOpen) onClose();
|
|
setSelectedVideo(null);
|
|
} catch {}
|
|
}, [style]);
|
|
|
|
useEffect(() => {
|
|
let canceled = false;
|
|
const run = async () => {
|
|
if (source !== 'auto') return;
|
|
const payload = await getCachedYouTube();
|
|
if (!payload) return;
|
|
// Sort by published_date descending (safety; service should already do this)
|
|
const vids = (payload.videos || []).slice().sort((a, b) => (Date.parse(b.published_date || '') || 0) - (Date.parse(a.published_date || '') || 0));
|
|
if (!canceled) setYt(vids);
|
|
};
|
|
run();
|
|
return () => { canceled = true; };
|
|
}, [source]);
|
|
|
|
const extractVideoId = (embedUrl: string): string | undefined => {
|
|
if (embedUrl?.includes('/embed/')) {
|
|
return embedUrl.split('/embed/')[1]?.split('?')[0];
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const items: RenderItem[] = useMemo(() => {
|
|
if (source === 'auto') {
|
|
return (yt || []).slice(0, limit).map(v => ({
|
|
key: v.video_id,
|
|
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
|
|
embedUrl: toEmbed(v.video_id),
|
|
thumbnail: v.thumbnail_url,
|
|
date: v.published_date,
|
|
videoId: v.video_id,
|
|
}));
|
|
}
|
|
// manual fallback from settings or prop
|
|
const manual = (settings?.videos_items || []).map((it, i) => {
|
|
const embedUrl = toEmbed(it.url);
|
|
return {
|
|
key: `${i}-${it.url}`,
|
|
title: it.title || `Video ${i+1}`,
|
|
embedUrl,
|
|
thumbnail: it.thumbnail_url,
|
|
date: it.uploaded_at,
|
|
videoId: extractVideoId(embedUrl),
|
|
};
|
|
});
|
|
const legacy = (videos || settings?.videos || []).map((url, i) => {
|
|
const embedUrl = toEmbed(url as any);
|
|
return {
|
|
key: `${i}-${url}`,
|
|
title: `Video ${i+1}`,
|
|
embedUrl,
|
|
videoId: extractVideoId(embedUrl),
|
|
};
|
|
});
|
|
return (manual.length ? manual : legacy).slice(0, limit);
|
|
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit, titleOverrides]);
|
|
|
|
if (!enabled || items.length === 0) return null;
|
|
|
|
const handlePlayClick = (it: RenderItem) => {
|
|
setSelectedVideo(it);
|
|
onOpen();
|
|
};
|
|
|
|
const Card: React.FC<{ it: RenderItem; idx: number }> = ({ it, idx }) => {
|
|
const thumb = it.thumbnail || (it.videoId ? `https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg` : undefined);
|
|
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
|
const placeholderBg = useColorModeValue('gray.100', 'gray.700');
|
|
const placeholderIcon = useColorModeValue('gray.400', 'gray.500');
|
|
const videoPrimaryColor = theme.primary;
|
|
|
|
return (
|
|
<Box
|
|
className="video-card card"
|
|
bg={cardBg}
|
|
borderRadius="xl"
|
|
overflow="hidden"
|
|
boxShadow="sm"
|
|
borderWidth="2px"
|
|
borderColor={borderColor}
|
|
transition="all 0.3s"
|
|
position="relative"
|
|
sx={{
|
|
'&:hover': {
|
|
transform: 'translateY(-8px)',
|
|
boxShadow: '0 20px 40px rgba(0,0,0,0.15)',
|
|
borderColor: 'brand.primary',
|
|
},
|
|
'&:hover .play-overlay': {
|
|
opacity: 1,
|
|
},
|
|
'&:hover .play-overlay > div': {
|
|
transform: 'scale(1.05)',
|
|
},
|
|
}}
|
|
>
|
|
<AspectRatio ratio={16 / 9}>
|
|
<Box
|
|
position="relative"
|
|
cursor="pointer"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Přehrát video: ${it.title}`}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePlayClick(it); } }}
|
|
onClick={() => handlePlayClick(it)}
|
|
>
|
|
{/* Thumbnail */}
|
|
{thumb ? (
|
|
<Box
|
|
as="img"
|
|
src={thumb}
|
|
alt={it.title}
|
|
width="100%"
|
|
height="100%"
|
|
loading="lazy"
|
|
decoding="async"
|
|
referrerPolicy="origin-when-cross-origin"
|
|
style={{ objectFit: 'cover' }}
|
|
/>
|
|
) : (
|
|
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
|
|
<Icon as={FaPlay} boxSize={12} color={placeholderIcon} />
|
|
</Box>
|
|
)}
|
|
{/* Play overlay */}
|
|
<Box
|
|
className="play-overlay"
|
|
position="absolute"
|
|
inset={0}
|
|
display="flex"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
opacity={0}
|
|
transition="opacity 0.3s ease"
|
|
pointerEvents="none"
|
|
>
|
|
<Box
|
|
bg="white"
|
|
color="brand.primary"
|
|
borderRadius="full"
|
|
px={8}
|
|
py={4}
|
|
fontWeight="bold"
|
|
display="flex"
|
|
alignItems="center"
|
|
gap={2}
|
|
transform="scale(0.9)"
|
|
transition="transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
|
boxShadow="0 12px 32px rgba(0,0,0,0.4)"
|
|
>
|
|
<Icon as={FaPlay} boxSize={5} />
|
|
<Text fontSize="lg">Přehrát</Text>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</AspectRatio>
|
|
<Box p={4} borderTopWidth="2px" borderTopColor={videoPrimaryColor}>
|
|
<VStack align="start" spacing={2}>
|
|
<Text fontWeight="bold" fontSize="md" color={videoPrimaryColor} noOfLines={2}>
|
|
{it.title}
|
|
</Text>
|
|
<HStack justify="space-between" width="100%">
|
|
{it.date && (
|
|
<Badge colorScheme="gray" fontSize="0.7rem">
|
|
{new Date(it.date).toLocaleDateString('cs-CZ')}
|
|
</Badge>
|
|
)}
|
|
{it.videoId && (
|
|
<Link href={`https://www.youtube.com/watch?v=${it.videoId}`} isExternal onClick={(e) => e.stopPropagation()}>
|
|
<Button
|
|
size="xs"
|
|
variant="ghost"
|
|
colorScheme="red"
|
|
leftIcon={<Icon as={FaYoutube} />}
|
|
>
|
|
YouTube
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
</HStack>
|
|
</VStack>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
if (style === 'slider') {
|
|
return (
|
|
<Box>
|
|
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
|
|
<HStack spacing={3}>
|
|
<Heading as="h3" size="lg" fontWeight="700" id="home-videos-heading">Videa</Heading>
|
|
</HStack>
|
|
<Link as={RouterLink} to="/videa">
|
|
<Button
|
|
size="md"
|
|
variant="solid"
|
|
bg={theme.primary}
|
|
color="white"
|
|
rightIcon={<Box as="span">→</Box>}
|
|
_hover={{ opacity: 0.9, transform: 'translateX(4px)' }}
|
|
transition="all 0.2s"
|
|
>
|
|
Více videí
|
|
</Button>
|
|
</Link>
|
|
</Box>
|
|
<HorizontalScroller key={`videos-hs-${style}-${items.length}`} draggable>
|
|
{items.map((it, idx) => (
|
|
<Box
|
|
key={it.key}
|
|
minW={{ base: '85%', md: '60%', lg: '33%' }}
|
|
display="flex"
|
|
flexDirection="column"
|
|
>
|
|
<Card it={it} idx={idx} />
|
|
</Box>
|
|
))}
|
|
</HorizontalScroller>
|
|
|
|
{/* Video Modal */}
|
|
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
|
<ModalOverlay bg="blackAlpha.800" />
|
|
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
|
|
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
|
|
<ModalBody p={0}>
|
|
{selectedVideo && (
|
|
<Box>
|
|
<AspectRatio ratio={16 / 9} maxH="90vh">
|
|
<iframe
|
|
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
|
|
title={selectedVideo.title}
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
allowFullScreen
|
|
loading="lazy"
|
|
referrerPolicy="strict-origin-when-cross-origin"
|
|
style={{ borderRadius: '8px' }}
|
|
/>
|
|
</AspectRatio>
|
|
<Box bg={useColorModeValue('white', 'gray.800')} p={4} borderRadius="md" mt={2}>
|
|
<HStack justify="space-between" align="start">
|
|
<VStack align="start" flex={1}>
|
|
<Text fontWeight="bold" fontSize="lg">{selectedVideo.title}</Text>
|
|
{selectedVideo.date && (
|
|
<Text color={useColorModeValue('gray.600', 'gray.300')} fontSize="sm">
|
|
{new Date(selectedVideo.date).toLocaleDateString('cs-CZ', { year: 'numeric', month: 'long', day: 'numeric' })}
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
{selectedVideo.videoId && (
|
|
<Link href={`https://www.youtube.com/watch?v=${selectedVideo.videoId}`} isExternal>
|
|
<Button size="sm" colorScheme="red" leftIcon={<Icon as={FaYoutube} />}>Otevřít na YouTube</Button>
|
|
</Link>
|
|
)}
|
|
</HStack>
|
|
{selectedVideo.videoId && (
|
|
<Box mt={4}>
|
|
<CommentsSection targetType="youtube_video" targetId={selectedVideo.videoId} />
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</ModalBody>
|
|
</ModalContent>
|
|
</Modal>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const cols = style === 'grid3' ? { base: 1, md: 3 } : { base: 1, md: 2, lg: 3 };
|
|
return (
|
|
<Box>
|
|
<Box className="section-head">
|
|
<Heading as="h3" size="md" id="home-videos-heading">Videa</Heading>
|
|
<Link as={RouterLink} to="/videa">
|
|
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
|
|
</Link>
|
|
</Box>
|
|
<SimpleGrid key={`videos-grid-${style}-${items.length}`} columns={cols} spacing={4}>
|
|
{items.map((it, idx) => (
|
|
<Card key={it.key} it={it} idx={idx} />
|
|
))}
|
|
</SimpleGrid>
|
|
|
|
{/* Video Modal */}
|
|
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
|
<ModalOverlay bg="blackAlpha.800" />
|
|
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
|
|
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
|
|
<ModalBody p={0}>
|
|
{selectedVideo && (
|
|
<Box>
|
|
<AspectRatio ratio={16 / 9} maxH="90vh">
|
|
<iframe
|
|
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
|
|
title={selectedVideo.title}
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
allowFullScreen
|
|
loading="lazy"
|
|
referrerPolicy="strict-origin-when-cross-origin"
|
|
style={{ borderRadius: '8px' }}
|
|
/>
|
|
</AspectRatio>
|
|
<Box bg={useColorModeValue('white', 'gray.800')} p={4} borderRadius="md" mt={2}>
|
|
<HStack justify="space-between" align="start">
|
|
<VStack align="start" flex={1}>
|
|
<Text fontWeight="bold" fontSize="lg">{selectedVideo.title}</Text>
|
|
{selectedVideo.date && (
|
|
<Text color={useColorModeValue('gray.600', 'gray.300')} fontSize="sm">
|
|
{new Date(selectedVideo.date).toLocaleDateString('cs-CZ', { year: 'numeric', month: 'long', day: 'numeric' })}
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
{selectedVideo.videoId && (
|
|
<Link href={`https://www.youtube.com/watch?v=${selectedVideo.videoId}`} isExternal>
|
|
<Button size="sm" colorScheme="red" leftIcon={<Icon as={FaYoutube} />}>Otevřít na YouTube</Button>
|
|
</Link>
|
|
)}
|
|
</HStack>
|
|
{selectedVideo.videoId && (
|
|
<Box mt={4}>
|
|
<CommentsSection targetType="youtube_video" targetId={selectedVideo.videoId} />
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</ModalBody>
|
|
</ModalContent>
|
|
</Modal>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default React.memo(VideosSection);
|