| @@ -1,20 +1,676 @@ | |||
| 'use client'; | |||
| import { useState, useEffect } from 'react'; | |||
| import { Card } from '@/types'; | |||
| 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<Set<number>>(new Set()); | |||
| const touchStartX = useRef<number | null>(null); | |||
| const videoRefs = useRef<Record<number, HTMLVideoElement | 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 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 <div className="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>; | |||
| } | |||
| return ( | |||
| <div | |||
| className="relative h-72 w-full bg-black overflow-hidden rounded-t-2xl select-none" | |||
| onTouchStart={onTouchStart} | |||
| onTouchEnd={onTouchEnd} | |||
| > | |||
| {items.map((item, i) => { | |||
| const isActive = i === current; | |||
| const video = isVideoUrl(item.url); | |||
| return ( | |||
| <div | |||
| key={item.url + i} | |||
| className={`absolute inset-0 transition-opacity duration-300 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} | |||
| > | |||
| {video ? ( | |||
| <div | |||
| className="relative w-full h-full cursor-pointer" | |||
| onClick={(e) => { e.stopPropagation(); togglePlay(i); }} | |||
| > | |||
| <video | |||
| ref={el => { videoRefs.current[i] = el; }} | |||
| src={item.url} | |||
| className="w-full h-full object-contain bg-black pointer-events-none" | |||
| playsInline | |||
| preload="metadata" | |||
| onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)} | |||
| onPlay={() => markPlaying(i)} | |||
| onPause={() => markPaused(i)} | |||
| onEnded={() => markPaused(i)} | |||
| /> | |||
| {/* Custom play overlay (shown when paused) */} | |||
| {!playing.has(i) && ( | |||
| <div className="absolute inset-0 flex items-center justify-center pointer-events-none"> | |||
| <span className="bg-black/60 text-white w-20 h-20 rounded-full flex items-center justify-center text-4xl shadow-2xl pl-1">▶</span> | |||
| </div> | |||
| )} | |||
| {/* Custom expand button */} | |||
| <button | |||
| onClick={(e) => { e.stopPropagation(); onMediaClick?.(i); }} | |||
| className="absolute bottom-3 right-3 bg-black/60 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10" | |||
| title="Expand fullscreen" | |||
| aria-label="Expand fullscreen" | |||
| > | |||
| <svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> | |||
| <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" /> | |||
| </svg> | |||
| </button> | |||
| </div> | |||
| ) : ( | |||
| <img | |||
| src={item.url} | |||
| alt="" | |||
| className="w-full h-full object-cover cursor-zoom-in" | |||
| onClick={() => onMediaClick?.(i)} | |||
| title="Click to view fullscreen" | |||
| /> | |||
| )} | |||
| </div> | |||
| ); | |||
| })} | |||
| {items.length > 1 && ( | |||
| <> | |||
| <button | |||
| onClick={(e) => { e.stopPropagation(); prev(); }} | |||
| className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10" | |||
| aria-label="Previous" | |||
| >‹</button> | |||
| <button | |||
| onClick={(e) => { e.stopPropagation(); next(); }} | |||
| className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10" | |||
| aria-label="Next" | |||
| >›</button> | |||
| <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10"> | |||
| {items.map((_, i) => ( | |||
| <button | |||
| key={i} | |||
| onClick={(e) => { e.stopPropagation(); setCurrent(i); }} | |||
| className={`rounded-full transition-all duration-200 ${i === current ? 'w-5 h-2 bg-white' : 'w-2 h-2 bg-white/50 hover:bg-white/80'}`} | |||
| aria-label={`Go to slide ${i + 1}`} | |||
| /> | |||
| ))} | |||
| </div> | |||
| <div className="absolute top-3 left-3 bg-black/60 text-white text-xs font-semibold px-2 py-0.5 rounded-full z-10 flex items-center gap-1"> | |||
| <span>⊞</span> | |||
| <span>{current + 1} / {items.length}</span> | |||
| </div> | |||
| </> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| function FullscreenViewer({ | |||
| items, | |||
| startIndex, | |||
| onClose, | |||
| }: { | |||
| items: MediaItem[]; | |||
| startIndex: number; | |||
| onClose: () => void; | |||
| }) { | |||
| const [current, setCurrent] = useState(startIndex); | |||
| const [playing, setPlaying] = useState<Set<number>>(new Set()); | |||
| const [zoom, setZoom] = useState(1); | |||
| const [pan, setPan] = useState({ x: 0, y: 0 }); | |||
| const touchStartX = useRef<number | null>(null); | |||
| const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({}); | |||
| const containerRef = useRef<HTMLDivElement | null>(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<HTMLImageElement>) => { | |||
| 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<HTMLImageElement>) => { | |||
| 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<HTMLImageElement>) => { | |||
| 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 ( | |||
| <div | |||
| ref={containerRef} | |||
| 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 ? ( | |||
| <div | |||
| className="relative w-full h-full flex items-center justify-center cursor-pointer" | |||
| onClick={(e) => { e.stopPropagation(); togglePlay(i); }} | |||
| > | |||
| <video | |||
| ref={el => { videoRefs.current[i] = el; }} | |||
| src={item.url} | |||
| className="max-w-full max-h-full object-contain pointer-events-none" | |||
| playsInline | |||
| preload="metadata" | |||
| onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)} | |||
| onPlay={() => markPlaying(i)} | |||
| onPause={() => markPaused(i)} | |||
| onEnded={() => markPaused(i)} | |||
| /> | |||
| {!playing.has(i) && ( | |||
| <div className="absolute inset-0 flex items-center justify-center pointer-events-none"> | |||
| <span className="bg-black/60 text-white w-24 h-24 rounded-full flex items-center justify-center text-5xl shadow-2xl pl-1">▶</span> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ) : ( | |||
| <img | |||
| src={item.url} | |||
| alt="" | |||
| className="max-w-full max-h-full object-contain" | |||
| draggable={false} | |||
| style={isActive ? { | |||
| transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, | |||
| cursor: zoom > 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} | |||
| /> | |||
| )} | |||
| </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> | |||
| ); | |||
| } | |||
| 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 [spread, setSpread] = useState(0); | |||
| const [flipping, setFlipping] = useState<'forward' | 'backward' | null>(null); | |||
| const audioRef = useRef<AudioContext | null>(null); | |||
| const dragStartX = useRef<number | null>(null); | |||
| 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; | |||
| }; | |||
| const totalSpreads = Math.max(1, Math.ceil(pages.length / 2)); | |||
| const getPage = (i: number) => (i >= 0 && i < pages.length ? pages[i] : ''); | |||
| const leftIdx = spread * 2; | |||
| const rightIdx = spread * 2 + 1; | |||
| const nextLeftIdx = (spread + 1) * 2; | |||
| const nextRightIdx = (spread + 1) * 2 + 1; | |||
| const prevLeftIdx = (spread - 1) * 2; | |||
| const prevRightIdx = (spread - 1) * 2 + 1; | |||
| const goNext = useCallback(() => { | |||
| if (flipping !== null || spread >= totalSpreads - 1) return; | |||
| playFlipSound(getCtx()); | |||
| setFlipping('forward'); | |||
| window.setTimeout(() => { setSpread(s => s + 1); setFlipping(null); }, 750); | |||
| }, [flipping, spread, totalSpreads]); | |||
| const goPrev = useCallback(() => { | |||
| if (flipping !== null || spread <= 0) return; | |||
| playFlipSound(getCtx()); | |||
| setFlipping('backward'); | |||
| window.setTimeout(() => { setSpread(s => s - 1); setFlipping(null); }, 750); | |||
| }, [flipping, spread]); | |||
| 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]); | |||
| const onPointerDown = (e: React.PointerEvent) => { dragStartX.current = e.clientX; }; | |||
| const onPointerUp = (e: React.PointerEvent) => { | |||
| if (dragStartX.current === null) return; | |||
| const dx = e.clientX - dragStartX.current; | |||
| if (Math.abs(dx) > 50) (dx < 0 ? goNext() : goPrev()); | |||
| dragStartX.current = null; | |||
| }; | |||
| const onPointerCancel = () => { dragStartX.current = null; }; | |||
| // Pages visible underneath the flipping overlay | |||
| const visibleLeft = flipping === 'backward' ? getPage(prevLeftIdx) : getPage(leftIdx); | |||
| const visibleRight = flipping === 'forward' ? getPage(nextRightIdx) : getPage(rightIdx); | |||
| const currentPageNum = Math.min(leftIdx + 1, pages.length); | |||
| const lastPageNum = Math.min(rightIdx + 1, pages.length); | |||
| const indicatorLabel = currentPageNum === lastPageNum | |||
| ? `${currentPageNum} / ${pages.length}` | |||
| : `${currentPageNum}-${lastPageNum} / ${pages.length}`; | |||
| const pageBoxClass = 'absolute inset-0 bg-white overflow-hidden'; | |||
| const pageImgClass = 'w-full h-full object-contain'; | |||
| return ( | |||
| <div | |||
| className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 select-none" | |||
| onPointerDown={onPointerDown} | |||
| onPointerUp={onPointerUp} | |||
| onPointerCancel={onPointerCancel} | |||
| style={{ perspective: '2500px', touchAction: 'pan-y' }} | |||
| > | |||
| <style dangerouslySetInnerHTML={{ __html: ` | |||
| @keyframes flipBookForward { from { transform: rotateY(0deg); } to { transform: rotateY(-180deg); } } | |||
| @keyframes flipBookBackward { from { transform: rotateY(0deg); } to { transform: rotateY(180deg); } } | |||
| @keyframes flipBookShadowIn { 0% { opacity: 0; } 100% { opacity: 1; } } | |||
| @keyframes flipBookShadowOut { 0% { opacity: 1; } 100% { opacity: 0; } } | |||
| `}} /> | |||
| <button | |||
| onClick={onClose} | |||
| className="absolute top-4 right-4 bg-white/10 hover:bg-white/25 text-white w-11 h-11 flex items-center justify-center rounded-full text-xl z-30 transition-colors" | |||
| title="Chiudi" | |||
| aria-label="Chiudi" | |||
| >✕</button> | |||
| <div | |||
| className="relative shadow-2xl" | |||
| style={{ | |||
| width: 'min(95vw, 1400px)', | |||
| height: 'min(85vh, 900px)', | |||
| transformStyle: 'preserve-3d', | |||
| }} | |||
| > | |||
| {/* Static left page */} | |||
| {visibleLeft ? ( | |||
| <div className="absolute left-0 top-0 w-1/2 h-full bg-white overflow-hidden"> | |||
| <img src={visibleLeft} className={pageImgClass} alt="" draggable={false} /> | |||
| </div> | |||
| ) : ( | |||
| <div | |||
| className="absolute left-0 top-0 w-1/2 h-full overflow-hidden" | |||
| style={{ | |||
| background: 'linear-gradient(135deg, #4a3826 0%, #2e2114 55%, #1a1108 100%)', | |||
| boxShadow: 'inset 0 0 80px rgba(0,0,0,0.55)', | |||
| }} | |||
| aria-hidden | |||
| /> | |||
| )} | |||
| {/* Static right page */} | |||
| {visibleRight ? ( | |||
| <div className="absolute right-0 top-0 w-1/2 h-full bg-white overflow-hidden"> | |||
| <img src={visibleRight} className={pageImgClass} alt="" draggable={false} /> | |||
| </div> | |||
| ) : ( | |||
| <div | |||
| className="absolute right-0 top-0 w-1/2 h-full overflow-hidden" | |||
| style={{ | |||
| background: 'linear-gradient(225deg, #4a3826 0%, #2e2114 55%, #1a1108 100%)', | |||
| boxShadow: 'inset 0 0 80px rgba(0,0,0,0.55)', | |||
| }} | |||
| aria-hidden | |||
| /> | |||
| )} | |||
| {/* Spine */} | |||
| <div className="absolute left-1/2 top-0 -translate-x-1/2 w-2 h-full bg-gradient-to-r from-black/40 via-black/20 to-black/40 z-10 pointer-events-none" /> | |||
| {/* Flipping overlay — forward (right page rotates left) */} | |||
| {flipping === 'forward' && ( | |||
| <div | |||
| className="absolute right-0 top-0 w-1/2 h-full" | |||
| style={{ | |||
| transformOrigin: 'left center', | |||
| transformStyle: 'preserve-3d', | |||
| animation: 'flipBookForward 750ms cubic-bezier(0.4, 0.0, 0.35, 1) forwards', | |||
| filter: 'drop-shadow(0 10px 18px rgba(0,0,0,0.55))', | |||
| zIndex: 20, | |||
| }} | |||
| > | |||
| <div className={pageBoxClass} style={{ backfaceVisibility: 'hidden' }}> | |||
| {getPage(rightIdx) && <img src={getPage(rightIdx)} className={pageImgClass} alt="" draggable={false} />} | |||
| <div className="absolute inset-0 pointer-events-none" style={{ | |||
| background: 'linear-gradient(90deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 65%)', | |||
| animation: 'flipBookShadowIn 750ms ease-out forwards', | |||
| }} /> | |||
| </div> | |||
| <div className={pageBoxClass} style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}> | |||
| {getPage(nextLeftIdx) && <img src={getPage(nextLeftIdx)} className={pageImgClass} alt="" draggable={false} />} | |||
| <div className="absolute inset-0 pointer-events-none" style={{ | |||
| background: 'linear-gradient(270deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 65%)', | |||
| animation: 'flipBookShadowOut 750ms ease-in forwards', | |||
| }} /> | |||
| </div> | |||
| </div> | |||
| )} | |||
| {/* Flipping overlay — backward (left page rotates right) */} | |||
| {flipping === 'backward' && ( | |||
| <div | |||
| className="absolute left-0 top-0 w-1/2 h-full" | |||
| style={{ | |||
| transformOrigin: 'right center', | |||
| transformStyle: 'preserve-3d', | |||
| animation: 'flipBookBackward 750ms cubic-bezier(0.4, 0.0, 0.35, 1) forwards', | |||
| filter: 'drop-shadow(0 10px 18px rgba(0,0,0,0.55))', | |||
| zIndex: 20, | |||
| }} | |||
| > | |||
| <div className={pageBoxClass} style={{ backfaceVisibility: 'hidden' }}> | |||
| {getPage(leftIdx) && <img src={getPage(leftIdx)} className={pageImgClass} alt="" draggable={false} />} | |||
| <div className="absolute inset-0 pointer-events-none" style={{ | |||
| background: 'linear-gradient(270deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 65%)', | |||
| animation: 'flipBookShadowIn 750ms ease-out forwards', | |||
| }} /> | |||
| </div> | |||
| <div className={pageBoxClass} style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}> | |||
| {getPage(prevRightIdx) && <img src={getPage(prevRightIdx)} className={pageImgClass} alt="" draggable={false} />} | |||
| <div className="absolute inset-0 pointer-events-none" style={{ | |||
| background: 'linear-gradient(90deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 65%)', | |||
| animation: 'flipBookShadowOut 750ms ease-in forwards', | |||
| }} /> | |||
| </div> | |||
| </div> | |||
| )} | |||
| </div> | |||
| <button | |||
| onClick={goPrev} | |||
| disabled={spread === 0 || flipping !== null} | |||
| className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 disabled:opacity-25 disabled:cursor-not-allowed text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors" | |||
| title="Pagina precedente" | |||
| aria-label="Pagina precedente" | |||
| >‹</button> | |||
| <button | |||
| onClick={goNext} | |||
| disabled={spread >= totalSpreads - 1 || flipping !== null} | |||
| className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 disabled:opacity-25 disabled:cursor-not-allowed text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors" | |||
| title="Pagina successiva" | |||
| aria-label="Pagina successiva" | |||
| >›</button> | |||
| <div className="absolute bottom-5 left-1/2 -translate-x-1/2 bg-white/15 text-white px-4 py-1.5 rounded-full text-sm font-medium z-30"> | |||
| {pages.length === 0 ? 'Nessuna pagina' : indicatorLabel} | |||
| </div> | |||
| </div> | |||
| ); | |||
| } | |||
| export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) { | |||
| const [activeCard, setActiveCard] = useState<Card | null>(null); | |||
| const [fullscreenIndex, setFullscreenIndex] = useState<number | null>(null); | |||
| // Prevent background scrolling when modal is open | |||
| useEffect(() => { | |||
| if (activeCard) document.body.style.overflow = 'hidden'; | |||
| if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden'; | |||
| else document.body.style.overflow = 'unset'; | |||
| return () => { document.body.style.overflow = 'unset'; } | |||
| }, [activeCard]); | |||
| return () => { document.body.style.overflow = 'unset'; }; | |||
| }, [activeCard, fullscreenIndex]); | |||
| // Tailwind classes mapping based on the admin's chosen max columns | |||
| const gridClasses: Record<number, string> = { | |||
| 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1', // ADDED THIS LINE | |||
| 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', | |||
| @@ -24,65 +680,144 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC | |||
| const activeGridClass = gridClasses[maxCols] || gridClasses[5]; | |||
| const carouselItems: MediaItem[] = activeCard | |||
| ? [ | |||
| ...(activeCard.imageUrl && !activeCard.skipPreview ? [{ url: activeCard.imageUrl }] : []), | |||
| ...(activeCard.extraMedia || []), | |||
| ] | |||
| : []; | |||
| return ( | |||
| <> | |||
| <div className={`grid gap-4 ${activeGridClass}`}> | |||
| {cards.map((card) => ( | |||
| <div | |||
| key={card.id} | |||
| onClick={() => setActiveCard(card)} | |||
| 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" | |||
| > | |||
| {card.imageUrl ? ( | |||
| <img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" /> | |||
| ) : ( | |||
| <div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-100 to-gray-200 text-gray-400">No Image</div> | |||
| )} | |||
| <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent flex flex-col justify-end p-5 text-white"> | |||
| <h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3> | |||
| <p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow"> | |||
| {card.shortDescription} | |||
| </p> | |||
| {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 ( | |||
| <div | |||
| key={card.id} | |||
| onClick={() => { | |||
| // 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 ? ( | |||
| <video | |||
| src={previewUrl} | |||
| className="absolute inset-0 w-full h-full object-cover pointer-events-none" | |||
| muted | |||
| playsInline | |||
| preload="metadata" | |||
| /> | |||
| ) : ( | |||
| <img src={previewUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" /> | |||
| ) | |||
| ) : ( | |||
| <div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-100 to-gray-200 text-gray-400">No Image</div> | |||
| )} | |||
| {isExternalLink ? ( | |||
| <div | |||
| className="absolute top-2 left-2 bg-black/60 text-white text-xs font-bold px-2 py-0.5 rounded-full flex items-center justify-center z-10 leading-none" | |||
| title="External Link" | |||
| aria-label="External Link" | |||
| > | |||
| L | |||
| </div> | |||
| ) : galleryCount > 0 ? ( | |||
| <div className="absolute top-2 left-2 bg-black/60 text-white text-xs font-semibold px-1.5 py-0.5 rounded-full flex items-center gap-1 z-10"> | |||
| <span>⊞</span> | |||
| <span>{galleryCount}</span> | |||
| </div> | |||
| ) : null} | |||
| <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent flex flex-col justify-end p-5 text-white"> | |||
| <h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3> | |||
| <p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow"> | |||
| {card.shortDescription} | |||
| </p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| ))} | |||
| ); | |||
| })} | |||
| </div> | |||
| {/* Improved Modal Pop-up */} | |||
| {activeCard && ( | |||
| <div | |||
| className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4 transition-opacity" | |||
| onClick={() => setActiveCard(null)} // Click outside to close | |||
| {activeCard && activeCard.cardType === 'BOOK' && ( | |||
| <FlipBook | |||
| pages={(activeCard.extraMedia || []).map(m => m.url)} | |||
| onClose={() => setActiveCard(null)} | |||
| /> | |||
| )} | |||
| {activeCard && activeCard.cardType !== 'BOOK' && fullscreenIndex === null && ( | |||
| <div | |||
| className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4" | |||
| onClick={() => setActiveCard(null)} | |||
| > | |||
| <div | |||
| <div | |||
| className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-in fade-in zoom-in-95 duration-200" | |||
| onClick={(e) => e.stopPropagation()} // Prevent clicks inside modal from closing it | |||
| onClick={(e) => e.stopPropagation()} | |||
| > | |||
| <div className="relative h-72 w-full bg-gray-100"> | |||
| {activeCard.imageUrl && ( | |||
| <img src={activeCard.imageUrl} className="w-full h-full object-cover rounded-t-2xl" alt="" /> | |||
| )} | |||
| {/* Improved Close Button */} | |||
| <button | |||
| onClick={() => setActiveCard(null)} | |||
| className="absolute top-4 right-4 bg-black/60 text-white w-10 h-10 flex items-center justify-center rounded-full hover:bg-black hover:scale-110 transition-all shadow-lg" | |||
| title="Close" | |||
| > | |||
| ✕ | |||
| </button> | |||
| </div> | |||
| <div className="p-8"> | |||
| <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div> | |||
| <h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2> | |||
| {activeCard.fullContent ? ( | |||
| <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} /> | |||
| ) : ( | |||
| <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p> | |||
| )} | |||
| <div className="relative"> | |||
| <MediaCarousel | |||
| items={carouselItems} | |||
| onMediaClick={(i) => setFullscreenIndex(i)} | |||
| /> | |||
| <button | |||
| onClick={() => setActiveCard(null)} | |||
| className="absolute top-4 right-4 bg-black/60 text-white w-10 h-10 flex items-center justify-center rounded-full hover:bg-black hover:scale-110 transition-all shadow-lg z-20" | |||
| title="Close" | |||
| >✕</button> | |||
| </div> | |||
| {(activeCard.title || activeCard.shortDescription || activeCard.fullContent || activeCard.actionUrl) && ( | |||
| <div className="p-8"> | |||
| {activeCard.title && ( | |||
| <h2 className={`text-3xl font-bold text-gray-900 ${(activeCard.shortDescription || activeCard.fullContent || activeCard.actionUrl) ? 'mb-4' : ''}`}> | |||
| {activeCard.title} | |||
| </h2> | |||
| )} | |||
| {activeCard.cardType === 'EXTERNAL_LINK' && (activeCard.actionUrl || activeCard.shortDescription) ? ( | |||
| <a | |||
| href={activeCard.actionUrl || activeCard.shortDescription} | |||
| target="_blank" | |||
| rel="noopener noreferrer" | |||
| className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 underline break-all text-lg font-medium" | |||
| > | |||
| <span>{activeCard.shortDescription || activeCard.actionUrl}</span> | |||
| <span aria-hidden>↗</span> | |||
| </a> | |||
| ) : activeCard.fullContent ? ( | |||
| <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} /> | |||
| ) : activeCard.shortDescription ? ( | |||
| <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p> | |||
| ) : null} | |||
| </div> | |||
| )} | |||
| </div> | |||
| </div> | |||
| )} | |||
| {fullscreenIndex !== null && activeCard && ( | |||
| <FullscreenViewer | |||
| items={carouselItems} | |||
| startIndex={fullscreenIndex} | |||
| onClose={() => { | |||
| setFullscreenIndex(null); | |||
| setActiveCard(null); | |||
| }} | |||
| /> | |||
| )} | |||
| </> | |||
| ); | |||
| } | |||
| } | |||