|
|
|
@@ -421,6 +421,200 @@ function FullscreenViewer({ |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
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 touchStartX = 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); }, 650); |
|
|
|
}, [flipping, spread, totalSpreads]); |
|
|
|
|
|
|
|
const goPrev = useCallback(() => { |
|
|
|
if (flipping !== null || spread <= 0) return; |
|
|
|
playFlipSound(getCtx()); |
|
|
|
setFlipping('backward'); |
|
|
|
window.setTimeout(() => { setSpread(s => s - 1); setFlipping(null); }, 650); |
|
|
|
}, [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 onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; }; |
|
|
|
const onTouchEnd = (e: React.TouchEvent) => { |
|
|
|
if (touchStartX.current === null) return; |
|
|
|
const dx = e.changedTouches[0].clientX - touchStartX.current; |
|
|
|
if (Math.abs(dx) > 50) (dx < 0 ? goNext() : goPrev()); |
|
|
|
touchStartX.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" |
|
|
|
onTouchStart={onTouchStart} |
|
|
|
onTouchEnd={onTouchEnd} |
|
|
|
style={{ perspective: '2500px' }} |
|
|
|
> |
|
|
|
<style dangerouslySetInnerHTML={{ __html: ` |
|
|
|
@keyframes flipBookForward { from { transform: rotateY(0deg); } to { transform: rotateY(-180deg); } } |
|
|
|
@keyframes flipBookBackward { from { transform: rotateY(0deg); } to { transform: rotateY(180deg); } } |
|
|
|
`}} /> |
|
|
|
|
|
|
|
<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 */} |
|
|
|
<div className="absolute left-0 top-0 w-1/2 h-full bg-white overflow-hidden"> |
|
|
|
{visibleLeft && <img src={visibleLeft} className={pageImgClass} alt="" draggable={false} />} |
|
|
|
</div> |
|
|
|
{/* Static right page */} |
|
|
|
<div className="absolute right-0 top-0 w-1/2 h-full bg-white overflow-hidden"> |
|
|
|
{visibleRight && <img src={visibleRight} className={pageImgClass} alt="" draggable={false} />} |
|
|
|
</div> |
|
|
|
{/* 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 650ms ease-in-out forwards', |
|
|
|
zIndex: 20, |
|
|
|
}} |
|
|
|
> |
|
|
|
<div className={pageBoxClass} style={{ backfaceVisibility: 'hidden' }}> |
|
|
|
{getPage(rightIdx) && <img src={getPage(rightIdx)} className={pageImgClass} alt="" draggable={false} />} |
|
|
|
</div> |
|
|
|
<div className={pageBoxClass} style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}> |
|
|
|
{getPage(nextLeftIdx) && <img src={getPage(nextLeftIdx)} className={pageImgClass} alt="" draggable={false} />} |
|
|
|
</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 650ms ease-in-out forwards', |
|
|
|
zIndex: 20, |
|
|
|
}} |
|
|
|
> |
|
|
|
<div className={pageBoxClass} style={{ backfaceVisibility: 'hidden' }}> |
|
|
|
{getPage(leftIdx) && <img src={getPage(leftIdx)} className={pageImgClass} alt="" draggable={false} />} |
|
|
|
</div> |
|
|
|
<div className={pageBoxClass} style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}> |
|
|
|
{getPage(prevRightIdx) && <img src={getPage(prevRightIdx)} className={pageImgClass} alt="" draggable={false} />} |
|
|
|
</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); |
|
|
|
@@ -471,7 +665,7 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC |
|
|
|
} |
|
|
|
} |
|
|
|
setActiveCard(card); |
|
|
|
if (card.autoFullscreen) setFullscreenIndex(0); |
|
|
|
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" |
|
|
|
> |
|
|
|
@@ -515,7 +709,14 @@ export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxC |
|
|
|
})} |
|
|
|
</div> |
|
|
|
|
|
|
|
{activeCard && fullscreenIndex === null && ( |
|
|
|
{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)} |
|
|
|
|