import React, { useState, useEffect } from 'react'; import { ChevronLeft, ChevronRight, X, MoreHorizontal, Heart, MessageCircle, Play, Bookmark } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { Post } from '../types'; import { cn, formatDateSafe } from '../lib/utils'; import { FADE, NAVIGATE, PRESENT, prefersReducedMotion, withVelocity } from '../lib/motion'; import { MediaRenderer } from './MediaRenderer'; interface PostModalProps { post: Post; nextPost?: Post; prevPost?: Post; onClose: () => void; onNextPost?: () => void; onPrevPost?: () => void; hasNextPost?: boolean; hasPrevPost?: boolean; profilePic: string | null; } export const PostModal: React.FC = ({ post, nextPost, prevPost, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic }) => { const [currentIndex, setCurrentIndex] = useState(0); /** * How the next slide/post should enter: along which axis, in which direction, * and carrying how much velocity from the gesture that triggered it. */ const [slideMotion, setSlideMotion] = useState<{ axis: 'x' | 'y'; dir: number; velocity: number }>( { axis: 'x', dir: 0, velocity: 0 }, ); const reduceMotion = prefersReducedMotion(); // Preloading Logic useEffect(() => { const controller = new AbortController(); const preloadMedia = async (url: string, type: 'image' | 'video') => { if (!url) return; try { if (type === 'image') { const img = new Image(); img.src = url; } else { const video = document.createElement('video'); video.src = url; video.preload = 'auto'; } } catch (e) {} }; // 1. Current post: Immediate preload of first two slides if (post.media[0]) preloadMedia(post.media[0].url, post.media[0].type); if (post.media[1]) preloadMedia(post.media[1].url, post.media[1].type); // 2. Next/Prev posts: Preload their first slides if (nextPost?.media[0]) preloadMedia(nextPost.media[0].url, nextPost.media[0].type); if (prevPost?.media[0]) preloadMedia(prevPost.media[0].url, prevPost.media[0].type); // 3. Current post: Delayed preload of the rest const timeout = setTimeout(() => { for (let i = 2; i < post.media.length; i++) { if (controller.signal.aborted) break; preloadMedia(post.media[i].url, post.media[i].type); } }, 1000); return () => { controller.abort(); clearTimeout(timeout); }; }, [post.id, post.media, nextPost?.id, prevPost?.id]); useEffect(() => setCurrentIndex(0), [post.id]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowRight') goToPost(1, 'x'); else if (e.key === 'ArrowLeft') goToPost(-1, 'x'); else if (e.key === '.') paginate(1); else if (e.key === ',') paginate(-1); else if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onNextPost, onPrevPost, currentIndex, post.media.length, onClose]); const paginate = (newDirection: number, velocity = 0) => { const nextIndex = currentIndex + newDirection; if (nextIndex >= 0 && nextIndex < post.media.length) { setSlideMotion({ axis: 'x', dir: newDirection, velocity }); setCurrentIndex(nextIndex); } }; /** * Move between posts, animating along the axis the input implies: vertical * for a touch swipe, horizontal for the desktop arrows and arrow keys. */ const goToPost = (dir: 1 | -1, axis: 'x' | 'y', velocity = 0) => { if (dir > 0 ? !hasNextPost : !hasPrevPost) return; setSlideMotion({ axis, dir, velocity }); if (dir > 0) onNextPost?.(); else onPrevPost?.(); }; type SlideMotion = { axis: 'x' | 'y'; dir: number }; const offscreen = (dir: number) => (dir > 0 ? '100%' : '-100%'); const variants = { enter: ({ axis, dir }: SlideMotion) => axis === 'y' ? { y: offscreen(dir), x: 0, opacity: 1, zIndex: 0 } : { x: offscreen(dir), y: 0, opacity: 1, zIndex: 0 }, center: { zIndex: 1, x: 0, y: 0, opacity: 1 }, exit: ({ axis, dir }: SlideMotion) => axis === 'y' ? { zIndex: 0, y: offscreen(-dir), x: 0, opacity: 1 } : { zIndex: 0, x: offscreen(-dir), y: 0, opacity: 1 }, }; const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity; /** Gesture navigation is touch-shaped below md; above it the arrows do the job. */ const [isMobile, setIsMobile] = useState( () => typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches, ); useEffect(() => { const query = window.matchMedia('(max-width: 767px)'); const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches); query.addEventListener('change', onChange); return () => query.removeEventListener('change', onChange); }, []); /** * Vertical swipe moves between posts on touch, matching Instagram: horizontal * belongs to the carousel and only the carousel, so reaching the last slide * no longer flings you into the next post. * * Swiping down on the first post falls back to dismissing, which keeps the * familiar drag-to-close gesture available where it can't mean "previous". */ const SWIPE_DISTANCE = 90; const SWIPE_POWER = 8000; const handleVerticalDragEnd = (offset: { y: number }, velocity: { y: number }) => { const power = swipePower(offset.y, velocity.y); if (!isMobile) { if (offset.y > 200 || velocity.y > 800) onClose(); return; } const swipedUp = offset.y < -SWIPE_DISTANCE || power < -SWIPE_POWER; const swipedDown = offset.y > SWIPE_DISTANCE || power > SWIPE_POWER; if (swipedUp && hasNextPost) goToPost(1, 'y', velocity.y); else if (swipedDown) { if (hasPrevPost) goToPost(-1, 'y', velocity.y); else onClose(); } }; /* * Horizontal padding on the overlay reserves a gutter for the prev/next * arrows so they always sit *outside* the modal. Without it the modal grows * until it sits under them and a white chevron lands on the white caption * panel, leaving the control invisible until hovered. * * overscroll-contain stops wheel events chaining through to the very long * post grid behind the overlay. */ return (
{/* Solid pill so the arrows read against whatever sits behind them. */} {hasPrevPost && onPrevPost && } {hasNextPost && onNextPost && } handleVerticalDragEnd(offset, velocity)} initial={reduceMotion ? false : { opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }} transition={PRESENT} className="bg-black flex flex-col md:flex-row w-full max-w-6xl h-auto md:rounded-sm overflow-hidden shadow-2xl relative text-black" onClick={e => e.stopPropagation()}>
{ // Carousel only. Crossing into the next post from the last // slide made a horizontal flick mean two different things. const s = swipePower(offset.x, velocity.x); if (s < -15000) paginate(1, velocity.x); else if (s > 15000) paginate(-1, velocity.x); }} className="col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing relative text-black" >
{post.media.length > 1 && ( <> {currentIndex > 0 && } {currentIndex < post.media.length - 1 && }
{post.media.map((_, i) =>
)}
)}
{profilePic ? : {post.username[0]}}
{post.username}
{profilePic ? : {post.username[0]}}
{post.username}{post.caption}
{formatDateSafe(post.date, 'MMMM d, yyyy')}
Archived Post{post.id}
); };