Преглед на файлове

Book card interaction smooth

main
Lorenzo Pollutri преди 1 месец
родител
ревизия
4c968e245b
променени са 1 файла, в които са добавени 790 реда и са изтрити 55 реда
  1. +790
    -55
      components/PublicGrid.tsx

+ 790
- 55
components/PublicGrid.tsx Целия файл

@@ -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);
}}
/>
)}
</>
);
}
}

Зареждане…
Отказ
Запис