From 4c968e245bb951ce54f75c6b3a407453a47ba3ff Mon Sep 17 00:00:00 2001 From: pollutri Date: Mon, 18 May 2026 17:20:45 +0200 Subject: [PATCH] Book card interaction smooth --- components/PublicGrid.tsx | 845 +++++++++++++++++++++++++++++++++++--- 1 file changed, 790 insertions(+), 55 deletions(-) diff --git a/components/PublicGrid.tsx b/components/PublicGrid.tsx index ab76447..ea70289 100644 --- a/components/PublicGrid.tsx +++ b/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>(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 [spread, setSpread] = useState(0); + const [flipping, setFlipping] = useState<'forward' | 'backward' | null>(null); + const audioRef = useRef(null); + const dragStartX = useRef(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 ( +
+