'use client'; 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 FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) { const containerRef = useRef(null); const flipRef = useRef(null); const [currentPage, setCurrentPage] = useState(0); const [pageCount, setPageCount] = useState(pages.length); useEffect(() => { if (!containerRef.current || pages.length === 0) return; let cancelled = false; // page-flip's destroy() removes its own block from the DOM. Give it a child // div we own so React's managed container stays intact across StrictMode cycles. const block = document.createElement('div'); block.style.width = '100%'; block.style.height = '100%'; containerRef.current.appendChild(block); // HTML mode: each page is a div with an using object-fit:contain so // mixed aspect ratios get letterboxed with white margins (like a real book // page) instead of being stretched to fill the slot. const pageElements = pages.map((url) => { const page = document.createElement('div'); page.style.cssText = 'background:#ffffff;width:100%;height:100%;overflow:hidden;display:flex;align-items:center;justify-content:center;'; const img = document.createElement('img'); img.src = url; img.draggable = false; img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;display:block;pointer-events:none;user-select:none;'; page.appendChild(img); return page; }); import('@/lib/page-flip').then(({ PageFlip }) => { if (cancelled) return; const flip = new PageFlip(block, { width: 550, height: 733, size: 'stretch', minWidth: 315, maxWidth: 1400, minHeight: 420, maxHeight: 900, drawShadow: true, flippingTime: 800, usePortrait: true, maxShadowOpacity: 0.5, showCover: false, mobileScrollSupport: false, useMouseEvents: true, swipeDistance: 30, showPageCorners: true, disableFlipByClick: false, }); flip.on('flip', (e) => { const newPage = typeof e.data === 'number' ? e.data : 0; setCurrentPage(newPage); }); flip.on('init', () => { setPageCount(flip.getPageCount()); }); flip.loadFromHTML(pageElements); flipRef.current = flip; }).catch(() => {}); return () => { cancelled = true; if (flipRef.current) { try { flipRef.current.destroy(); } catch {} flipRef.current = null; } try { block.remove(); } catch {} }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const goNext = useCallback(() => flipRef.current?.flipNext(), []); const goPrev = useCallback(() => flipRef.current?.flipPrev(), []); 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]); return (