| @@ -4,7 +4,13 @@ import { Card, MediaItem } from '@/types'; | |||||
| const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); | const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url); | ||||
| function MediaCarousel({ items }: { items: MediaItem[] }) { | |||||
| function MediaCarousel({ | |||||
| items, | |||||
| onImageClick, | |||||
| }: { | |||||
| items: MediaItem[]; | |||||
| onImageClick?: (index: number) => void; | |||||
| }) { | |||||
| const [current, setCurrent] = useState(0); | const [current, setCurrent] = useState(0); | ||||
| const touchStartX = useRef<number | null>(null); | const touchStartX = useRef<number | null>(null); | ||||
| const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({}); | const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({}); | ||||
| @@ -23,26 +29,21 @@ function MediaCarousel({ items }: { items: MediaItem[] }) { | |||||
| useEffect(() => { setCurrent(0); }, [items]); | useEffect(() => { setCurrent(0); }, [items]); | ||||
| // Pause all videos that aren't current; autoplay current if flagged | |||||
| // Pause non-current videos; autoplay current if flagged | |||||
| useEffect(() => { | useEffect(() => { | ||||
| Object.entries(videoRefs.current).forEach(([key, vid]) => { | Object.entries(videoRefs.current).forEach(([key, vid]) => { | ||||
| if (!vid) return; | if (!vid) return; | ||||
| const idx = parseInt(key, 10); | const idx = parseInt(key, 10); | ||||
| if (idx !== current) { | |||||
| vid.pause(); | |||||
| return; | |||||
| } | |||||
| if (idx !== current) { vid.pause(); return; } | |||||
| const item = items[idx]; | const item = items[idx]; | ||||
| if (item && isVideoUrl(item.url) && item.autoplay) { | if (item && isVideoUrl(item.url) && item.autoplay) { | ||||
| vid.muted = true; | vid.muted = true; | ||||
| vid.play().catch(() => {/* autoplay blocked, ignore */}); | |||||
| vid.play().catch(() => {}); | |||||
| } | } | ||||
| }); | }); | ||||
| }, [current, items]); | }, [current, items]); | ||||
| const onTouchStart = (e: React.TouchEvent) => { | |||||
| touchStartX.current = e.touches[0].clientX; | |||||
| }; | |||||
| const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; }; | |||||
| const onTouchEnd = (e: React.TouchEvent) => { | const onTouchEnd = (e: React.TouchEvent) => { | ||||
| if (touchStartX.current === null) return; | if (touchStartX.current === null) return; | ||||
| const delta = e.changedTouches[0].clientX - touchStartX.current; | const delta = e.changedTouches[0].clientX - touchStartX.current; | ||||
| @@ -79,7 +80,13 @@ function MediaCarousel({ items }: { items: MediaItem[] }) { | |||||
| preload="metadata" | preload="metadata" | ||||
| /> | /> | ||||
| ) : ( | ) : ( | ||||
| <img src={item.url} alt="" className="w-full h-full object-cover" /> | |||||
| <img | |||||
| src={item.url} | |||||
| alt="" | |||||
| className="w-full h-full object-cover cursor-zoom-in" | |||||
| onClick={() => onImageClick?.(i)} | |||||
| title="Click to view fullscreen" | |||||
| /> | |||||
| )} | )} | ||||
| </div> | </div> | ||||
| ); | ); | ||||
| @@ -118,14 +125,133 @@ function MediaCarousel({ items }: { items: MediaItem[] }) { | |||||
| ); | ); | ||||
| } | } | ||||
| function FullscreenViewer({ | |||||
| items, | |||||
| startIndex, | |||||
| onClose, | |||||
| }: { | |||||
| items: MediaItem[]; | |||||
| startIndex: number; | |||||
| onClose: () => void; | |||||
| }) { | |||||
| const [current, setCurrent] = useState(startIndex); | |||||
| const touchStartX = useRef<number | null>(null); | |||||
| const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]); | |||||
| const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]); | |||||
| useEffect(() => { | |||||
| const onKey = (e: KeyboardEvent) => { | |||||
| if (e.key === 'Escape') onClose(); | |||||
| else if (e.key === 'ArrowLeft') prev(); | |||||
| else if (e.key === 'ArrowRight') next(); | |||||
| }; | |||||
| window.addEventListener('keydown', onKey); | |||||
| return () => window.removeEventListener('keydown', onKey); | |||||
| }, [prev, next, onClose]); | |||||
| const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; }; | |||||
| const onTouchEnd = (e: React.TouchEvent) => { | |||||
| if (touchStartX.current === null) return; | |||||
| const delta = e.changedTouches[0].clientX - touchStartX.current; | |||||
| if (Math.abs(delta) > 50) delta < 0 ? next() : prev(); | |||||
| touchStartX.current = null; | |||||
| }; | |||||
| return ( | |||||
| <div | |||||
| className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center select-none animate-in fade-in duration-200" | |||||
| onTouchStart={onTouchStart} | |||||
| onTouchEnd={onTouchEnd} | |||||
| > | |||||
| {/* Media — full resolution, contained */} | |||||
| {items.map((item, i) => { | |||||
| const isActive = i === current; | |||||
| const video = isVideoUrl(item.url); | |||||
| return ( | |||||
| <div | |||||
| key={item.url + i} | |||||
| className={`absolute inset-0 flex items-center justify-center p-4 pb-28 transition-opacity duration-200 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} | |||||
| > | |||||
| {video ? ( | |||||
| <video | |||||
| src={item.url} | |||||
| className="max-w-full max-h-full" | |||||
| controls | |||||
| playsInline | |||||
| autoPlay={!!item.autoplay} | |||||
| muted={!!item.autoplay} | |||||
| /> | |||||
| ) : ( | |||||
| <img | |||||
| src={item.url} | |||||
| alt="" | |||||
| className="max-w-full max-h-full object-contain" | |||||
| /> | |||||
| )} | |||||
| </div> | |||||
| ); | |||||
| })} | |||||
| {/* Counter — top center */} | |||||
| {items.length > 1 && ( | |||||
| <div className="absolute top-4 left-1/2 -translate-x-1/2 bg-black/60 text-white text-sm font-semibold px-3 py-1 rounded-full z-20"> | |||||
| {current + 1} / {items.length} | |||||
| </div> | |||||
| )} | |||||
| {/* Side arrows */} | |||||
| {items.length > 1 && ( | |||||
| <> | |||||
| <button | |||||
| onClick={(e) => { e.stopPropagation(); prev(); }} | |||||
| className="absolute left-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-12 h-12 rounded-full flex items-center justify-center text-2xl shadow-lg z-20" | |||||
| aria-label="Previous" | |||||
| >‹</button> | |||||
| <button | |||||
| onClick={(e) => { e.stopPropagation(); next(); }} | |||||
| className="absolute right-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-12 h-12 rounded-full flex items-center justify-center text-2xl shadow-lg z-20" | |||||
| aria-label="Next" | |||||
| >›</button> | |||||
| </> | |||||
| )} | |||||
| {/* Close button — bottom center, ABOVE the dots */} | |||||
| <button | |||||
| onClick={onClose} | |||||
| className="absolute bottom-12 left-1/2 -translate-x-1/2 bg-white/90 hover:bg-white text-black px-6 py-2.5 rounded-full font-semibold shadow-2xl flex items-center gap-2 z-20 transition-transform hover:scale-105" | |||||
| aria-label="Close fullscreen" | |||||
| > | |||||
| <span className="text-lg leading-none">✕</span> | |||||
| <span>Close</span> | |||||
| </button> | |||||
| {/* Dots — at the very bottom */} | |||||
| {items.length > 1 && ( | |||||
| <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-20"> | |||||
| {items.map((_, i) => ( | |||||
| <button | |||||
| key={i} | |||||
| onClick={(e) => { e.stopPropagation(); setCurrent(i); }} | |||||
| className={`rounded-full transition-all duration-200 ${i === current ? 'w-6 h-2 bg-white' : 'w-2 h-2 bg-white/50 hover:bg-white/80'}`} | |||||
| aria-label={`Go to slide ${i + 1}`} | |||||
| /> | |||||
| ))} | |||||
| </div> | |||||
| )} | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) { | export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) { | ||||
| const [activeCard, setActiveCard] = useState<Card | null>(null); | const [activeCard, setActiveCard] = useState<Card | null>(null); | ||||
| const [fullscreenIndex, setFullscreenIndex] = useState<number | null>(null); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (activeCard) document.body.style.overflow = 'hidden'; | |||||
| if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden'; | |||||
| else document.body.style.overflow = 'unset'; | else document.body.style.overflow = 'unset'; | ||||
| return () => { document.body.style.overflow = 'unset'; }; | return () => { document.body.style.overflow = 'unset'; }; | ||||
| }, [activeCard]); | |||||
| }, [activeCard, fullscreenIndex]); | |||||
| const gridClasses: Record<number, string> = { | const gridClasses: Record<number, string> = { | ||||
| 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', | 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', | ||||
| @@ -138,6 +264,13 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC | |||||
| const activeGridClass = gridClasses[maxCols] || gridClasses[5]; | const activeGridClass = gridClasses[maxCols] || gridClasses[5]; | ||||
| const carouselItems: MediaItem[] = activeCard | |||||
| ? [ | |||||
| ...(activeCard.imageUrl ? [{ url: activeCard.imageUrl }] : []), | |||||
| ...(activeCard.extraMedia || []), | |||||
| ] | |||||
| : []; | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <div className={`grid gap-4 ${activeGridClass}`}> | <div className={`grid gap-4 ${activeGridClass}`}> | ||||
| @@ -182,10 +315,8 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC | |||||
| > | > | ||||
| <div className="relative"> | <div className="relative"> | ||||
| <MediaCarousel | <MediaCarousel | ||||
| items={[ | |||||
| ...(activeCard.imageUrl ? [{ url: activeCard.imageUrl }] : []), | |||||
| ...(activeCard.extraMedia || []), | |||||
| ]} | |||||
| items={carouselItems} | |||||
| onImageClick={(i) => setFullscreenIndex(i)} | |||||
| /> | /> | ||||
| <button | <button | ||||
| onClick={() => setActiveCard(null)} | onClick={() => setActiveCard(null)} | ||||
| @@ -205,6 +336,14 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| )} | )} | ||||
| {fullscreenIndex !== null && activeCard && ( | |||||
| <FullscreenViewer | |||||
| items={carouselItems} | |||||
| startIndex={fullscreenIndex} | |||||
| onClose={() => setFullscreenIndex(null)} | |||||
| /> | |||||
| )} | |||||
| </> | </> | ||||
| ); | ); | ||||
| } | } | ||||