'use client'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Card, MediaItem } from '@/types'; import { withBasePath } from '@/lib/url'; 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 FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) { const [spread, setSpread] = useState(0); const [flipping, setFlipping] = useState<'forward' | 'backward' | null>(null); const dragStartX = useRef(null); 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; setFlipping('forward'); window.setTimeout(() => { setSpread(s => s + 1); setFlipping(null); }, 700); }, [flipping, spread, totalSpreads]); const goPrev = useCallback(() => { if (flipping !== null || spread <= 0) return; setFlipping('backward'); window.setTimeout(() => { setSpread(s => s - 1); setFlipping(null); }, 700); }, [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; }; 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 pageImgClass = 'w-full h-full object-contain'; return (