'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { Card, MediaItem } from '@/types'; const VIDEO_EXTENSIONS = 'mp4|m4v|webm|mov|qt|mkv|avi|divx|wmv|asf|flv|f4v|3gp|3gpp|3g2|mts|m2ts|ts|mpg|mpeg|vob|mxf|ogv|ogg'; const isVideoUrl = (url: string) => new RegExp(`\\.(${VIDEO_EXTENSIONS})(\\?|$)`, 'i').test(url); function MediaCarousel({ items, onMediaClick, }: { items: MediaItem[]; onMediaClick?: (index: number) => void; }) { const [current, setCurrent] = useState(0); const [playing, setPlaying] = useState>(new Set()); const touchStartX = useRef(null); const videoRefs = useRef>({}); const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; }); const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; }); const applyVolume = (v: HTMLVideoElement, item: MediaItem) => { v.muted = !!item.muted; }; const togglePlay = (i: number) => { const v = videoRefs.current[i]; if (!v) return; if (v.paused) { const item = items[i]; if (item) applyVolume(v, item); v.play().catch(() => {}); } else { v.pause(); } }; 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 === 'ArrowLeft') prev(); if (e.key === 'ArrowRight') next(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [prev, next]); useEffect(() => { setCurrent(0); }, [items]); // Pause non-current videos; autoplay current if flagged useEffect(() => { Object.entries(videoRefs.current).forEach(([key, vid]) => { if (!vid) return; const idx = parseInt(key, 10); if (idx !== current) { vid.pause(); return; } const item = items[idx]; if (item && isVideoUrl(item.url) && item.autoplay) { applyVolume(vid, item); vid.play().catch(() => { // Browser blocked unmuted autoplay — fall back to muted. vid.muted = true; vid.play().catch(() => {}); }); } }); }, [current, items]); 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; }; if (items.length === 0) { return
No Image
; } return (
{items.map((item, i) => { const isActive = i === current; const video = isVideoUrl(item.url); return (
{video ? (
{ e.stopPropagation(); togglePlay(i); }} >
) : ( onMediaClick?.(i)} title="Click to view fullscreen" /> )}
); })} {items.length > 1 && ( <>
{items.map((_, i) => (
{current + 1} / {items.length}
)}
); } function FullscreenViewer({ items, startIndex, onClose, }: { items: MediaItem[]; startIndex: number; onClose: () => void; }) { const [current, setCurrent] = useState(startIndex); const [playing, setPlaying] = useState>(new Set()); const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); const touchStartX = useRef(null); const videoRefs = useRef>({}); const containerRef = useRef(null); const dragRef = useRef<{ sx: number; sy: number; px: number; py: number; moved: boolean } | null>(null); const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; }); const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; }); const applyVolume = (v: HTMLVideoElement, item: MediaItem) => { v.muted = !!item.muted; }; const togglePlay = (i: number) => { const v = videoRefs.current[i]; if (!v) return; if (v.paused) { const item = items[i]; if (item) applyVolume(v, item); v.play().catch(() => {}); } else { v.pause(); } }; const onImgClick = (e: React.MouseEvent) => { if (dragRef.current?.moved) { dragRef.current = null; return; } dragRef.current = null; if (zoom > 1) { setZoom(1); setPan({ x: 0, y: 0 }); } else { const r = e.currentTarget.getBoundingClientRect(); const ox = e.clientX - r.left - r.width / 2; const oy = e.clientY - r.top - r.height / 2; setZoom(2); setPan({ x: -2 * ox, y: -2 * oy }); } }; const onImgPointerDown = (e: React.PointerEvent) => { if (zoom <= 1) return; dragRef.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y, moved: false }; e.currentTarget.setPointerCapture?.(e.pointerId); }; const onImgPointerMove = (e: React.PointerEvent) => { if (!dragRef.current) return; const dx = e.clientX - dragRef.current.sx; const dy = e.clientY - dragRef.current.sy; if (Math.hypot(dx, dy) > 4) dragRef.current.moved = true; setPan({ x: dragRef.current.px + dx, y: dragRef.current.py + dy }); }; 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]); // Pause non-current videos in fullscreen useEffect(() => { Object.entries(videoRefs.current).forEach(([key, vid]) => { if (!vid) return; const idx = parseInt(key, 10); if (idx !== current) { vid.pause(); return; } const item = items[idx]; if (item && isVideoUrl(item.url) && item.autoplay) { applyVolume(vid, item); vid.play().catch(() => { vid.muted = true; vid.play().catch(() => {}); }); } }); }, [current, items]); // Reset zoom whenever the active slide changes useEffect(() => { setZoom(1); setPan({ x: 0, y: 0 }); }, [current]); // Wheel zoom (only on images). preventDefault requires passive: false → manual listener. useEffect(() => { const el = containerRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { const item = items[current]; if (!item || isVideoUrl(item.url)) return; e.preventDefault(); const factor = 1 - e.deltaY * 0.001; setZoom(prev => { const next = Math.max(1, Math.min(4, prev * factor)); if (next === 1) setPan({ x: 0, y: 0 }); return next; }); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [current, items]); const onTouchStart = (e: React.TouchEvent) => { if (zoom > 1) return; // pan via pointer events instead touchStartX.current = e.touches[0].clientX; }; const onTouchEnd = (e: React.TouchEvent) => { if (zoom > 1) return; 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 (
{/* Media — full resolution, contained */} {items.map((item, i) => { const isActive = i === current; const video = isVideoUrl(item.url); return (
{video ? (
{ e.stopPropagation(); togglePlay(i); }} >
) : ( 1 ? 'grab' : 'zoom-in', transition: dragRef.current ? 'none' : 'transform 0.2s', touchAction: zoom > 1 ? 'none' : 'auto', willChange: 'transform', } : undefined} onClick={isActive ? onImgClick : undefined} onPointerDown={isActive ? onImgPointerDown : undefined} onPointerMove={isActive ? onImgPointerMove : undefined} /> )}
); })} {/* Counter — top center */} {items.length > 1 && (
{current + 1} / {items.length}
)} {/* Side arrows */} {items.length > 1 && ( <> )} {/* Close button — bottom center, ABOVE the dots */} {/* Dots — at the very bottom */} {items.length > 1 && (
{items.map((_, i) => (
)}
); } function playFlipSound(ctx: AudioContext | null) { if (!ctx) return; const duration = 0.28; const sr = ctx.sampleRate; const buffer = ctx.createBuffer(1, Math.floor(sr * duration), sr); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) { const t = i / sr; const envelope = Math.exp(-t * 14) * (1 - Math.exp(-t * 80)); data[i] = (Math.random() * 2 - 1) * envelope * 0.45; } const src = ctx.createBufferSource(); src.buffer = buffer; const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2200; bp.Q.value = 0.9; const gain = ctx.createGain(); gain.gain.value = 0.6; src.connect(bp); bp.connect(gain); gain.connect(ctx.destination); src.start(); } function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) { const containerRef = useRef(null); const flipRef = useRef(null); const audioRef = useRef(null); const [currentPage, setCurrentPage] = useState(0); const [pageCount, setPageCount] = useState(pages.length); const getCtx = () => { if (!audioRef.current) { const Ctx = (window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext }).AudioContext ?? (window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (Ctx) audioRef.current = new Ctx(); } return audioRef.current; }; useEffect(() => { if (!containerRef.current || pages.length === 0) return; let cancelled = false; // page-flip's destroy() removes its own block from the DOM. Give it a child // div we own so React's managed container stays intact across StrictMode cycles. const block = document.createElement('div'); block.style.width = '100%'; block.style.height = '100%'; containerRef.current.appendChild(block); import('page-flip/dist/js/page-flip.module.js').then(({ PageFlip }) => { if (cancelled) return; const flip = new PageFlip(block, { width: 550, height: 733, size: 'stretch', minWidth: 315, maxWidth: 1400, minHeight: 420, maxHeight: 900, drawShadow: true, flippingTime: 800, usePortrait: true, maxShadowOpacity: 0.5, showCover: true, mobileScrollSupport: false, useMouseEvents: true, swipeDistance: 30, showPageCorners: true, disableFlipByClick: false, }); flip.on('flip', (e) => { const newPage = typeof e.data === 'number' ? e.data : 0; setCurrentPage(newPage); playFlipSound(getCtx()); }); flip.on('init', () => { setPageCount(flip.getPageCount()); }); flip.loadFromImages(pages); flipRef.current = flip; }).catch(() => {}); return () => { cancelled = true; if (flipRef.current) { try { flipRef.current.destroy(); } catch {} flipRef.current = null; } try { block.remove(); } catch {} }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const goNext = useCallback(() => flipRef.current?.flipNext(), []); const goPrev = useCallback(() => flipRef.current?.flipPrev(), []); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); else if (e.key === 'ArrowRight') goNext(); else if (e.key === 'ArrowLeft') goPrev(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, goNext, goPrev]); return (
{pageCount === 0 ? 'Nessuna pagina' : `${currentPage + 1} / ${pageCount}`}
); } export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) { const [activeCard, setActiveCard] = useState(null); const [fullscreenIndex, setFullscreenIndex] = useState(null); useEffect(() => { if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden'; else document.body.style.overflow = 'unset'; return () => { document.body.style.overflow = 'unset'; }; }, [activeCard, fullscreenIndex]); const gridClasses: Record = { 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', 4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', 5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1', 6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2', 7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2', 8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2', }; const activeGridClass = gridClasses[maxCols] || gridClasses[5]; const carouselItems: MediaItem[] = activeCard ? [ ...(activeCard.imageUrl && !activeCard.skipPreview ? [{ url: activeCard.imageUrl }] : []), ...(activeCard.extraMedia || []), ] : []; return ( <>
{cards.map((card) => { const galleryCount = card.extraMedia?.length || 0; const isExternalLink = card.cardType === 'EXTERNAL_LINK'; // Fall back to the first gallery item when no explicit cover is set. const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || ''; const previewIsVideo = !!previewUrl && isVideoUrl(previewUrl); return (
{ // EXTERNAL_LINK + redirectOnClick: salta il modale e apri direttamente l'URL if (card.cardType === 'EXTERNAL_LINK' && card.redirectOnClick) { const url = card.actionUrl || card.shortDescription; if (url) { window.open(url, '_blank', 'noopener,noreferrer'); return; } } setActiveCard(card); if (card.cardType !== 'BOOK' && card.autoFullscreen) setFullscreenIndex(0); }} className="group relative cursor-pointer overflow-hidden rounded-xl shadow-md aspect-square bg-gray-200 transition-all duration-300 hover:shadow-xl hover:-translate-y-1" > {previewUrl ? ( previewIsVideo ? (
); })}
{activeCard && activeCard.cardType === 'BOOK' && ( m.url)} onClose={() => setActiveCard(null)} /> )} {activeCard && activeCard.cardType !== 'BOOK' && fullscreenIndex === null && (
setActiveCard(null)} >
e.stopPropagation()} >
setFullscreenIndex(i)} />
{(activeCard.title || activeCard.shortDescription || activeCard.fullContent || activeCard.actionUrl) && (
{activeCard.title && (

{activeCard.title}

)} {activeCard.cardType === 'EXTERNAL_LINK' && (activeCard.actionUrl || activeCard.shortDescription) ? ( {activeCard.shortDescription || activeCard.actionUrl} ) : activeCard.fullContent ? (
) : activeCard.shortDescription ? (

{activeCard.shortDescription}

) : null}
)}
)} {fullscreenIndex !== null && activeCard && ( { setFullscreenIndex(null); setActiveCard(null); }} /> )} ); }