Docker Build and Publish / build-and-push (push) Failing after 9s
Navigation gestures Horizontal swipe used to advance the carousel and then, on the last slide, fling you into the next post — one gesture meaning two things. Horizontal is now carousel-only. On touch, vertical swipe moves between posts (down on the first post still dismisses, keeping drag-to-close where it can't mean "previous"). Desktop keeps the arrows outside the modal. URLs Permalinks now mirror Instagram: /<archive>/ profile /<archive>/reels/ tab /<archive>/p/<shortcode>/ post A post URL carries no tab, as on Instagram; the tab is re-derived from the post's source, so opening a reel link lands on the Reels tab with next/prev paging through reels. Sidecar posts keep directory-scoped ids internally but expose only the shortcode. The old ?a=&t=&p= form is still parsed so existing links keep working, and reserved prefixes (api, archives, assets…) can never be mistaken for a profile name. Animations Adds a shared motion vocabulary tuned to feel native: critically damped springs rather than fixed-duration easing, and gestures hand their exit velocity to the animation so a flick continues instead of restarting. Post transitions animate along the axis the input implies — vertical for a swipe, horizontal for the arrows. Modal and story viewer present/dismiss with a scale, tiles and highlight circles get touch-down feedback, and prefers-reduced-motion is honoured throughout. Adds 22 routing tests (58 total). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
249 lines
14 KiB
TypeScript
249 lines
14 KiB
TypeScript
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<PostModalProps> = ({
|
|
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 (
|
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={FADE} className="fixed inset-0 z-50 flex items-start justify-center bg-[#0c1014]/95 md:bg-[#0c1014]/70 p-0 md:py-10 md:px-16 lg:px-24 overflow-y-auto overscroll-contain text-black" onClick={onClose}>
|
|
<div className="min-h-full w-full flex items-center justify-center md:py-0">
|
|
<button onClick={onClose} className="fixed top-4 right-4 text-white hover:text-gray-300 z-50 p-2 md:p-3 bg-black/20 rounded-full backdrop-blur-sm"><X size={24} className="md:w-8 md:h-8" /></button>
|
|
{/* Solid pill so the arrows read against whatever sits behind them. */}
|
|
{hasPrevPost && onPrevPost && <button aria-label="Previous post" onClick={(e) => { e.stopPropagation(); goToPost(-1, 'x'); }} className="hidden md:flex items-center justify-center fixed md:left-3 lg:left-6 top-1/2 -translate-y-1/2 z-50 p-2 rounded-full bg-white/90 hover:bg-white text-gray-800 shadow-lg transition-transform hover:scale-110 active:scale-90"><ChevronLeft size={28} strokeWidth={2} /></button>}
|
|
{hasNextPost && onNextPost && <button aria-label="Next post" onClick={(e) => { e.stopPropagation(); goToPost(1, 'x'); }} className="hidden md:flex items-center justify-center fixed md:right-3 lg:right-6 top-1/2 -translate-y-1/2 z-50 p-2 rounded-full bg-white/90 hover:bg-white text-gray-800 shadow-lg transition-transform hover:scale-110 active:scale-90"><ChevronRight size={28} strokeWidth={2} /></button>}
|
|
<motion.div drag="y" dragDirectionLock dragConstraints={{ top: 0, bottom: 0 }} dragElastic={0.15} onDragEnd={(e, { offset, velocity }) => 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()}>
|
|
<div className="relative bg-black flex items-center justify-center group overflow-hidden w-full h-auto text-black">
|
|
<div className="w-full grid grid-cols-1 grid-rows-1 text-black">
|
|
<AnimatePresence initial={false} custom={slideMotion}>
|
|
<motion.div
|
|
key={`${post.id}-${currentIndex}`}
|
|
custom={slideMotion}
|
|
variants={variants}
|
|
initial="enter"
|
|
animate="center"
|
|
exit="exit"
|
|
transition={reduceMotion
|
|
? { duration: 0 }
|
|
: { x: withVelocity(slideMotion.velocity, NAVIGATE), y: withVelocity(slideMotion.velocity, NAVIGATE) }}
|
|
|
|
drag="x"
|
|
dragDirectionLock
|
|
dragConstraints={{ left: 0, right: 0 }}
|
|
dragElastic={0.5}
|
|
onDragEnd={(e, { offset, velocity }) => {
|
|
// 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"
|
|
>
|
|
<MediaRenderer file={post.media[currentIndex]} isFullView={true} />
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
{post.media.length > 1 && (
|
|
<>
|
|
{currentIndex > 0 && <button onClick={(e) => { e.stopPropagation(); paginate(-1); }} className="hidden md:block absolute left-4 top-1/2 -translate-y-1/2 bg-white/20 hover:bg-white/40 text-white p-2 rounded-full backdrop-blur-md transition-all opacity-0 group-hover:opacity-100 z-30"><ChevronLeft size={24} /></button>}
|
|
{currentIndex < post.media.length - 1 && <button onClick={(e) => { e.stopPropagation(); paginate(1); }} className="hidden md:block absolute right-4 top-1/2 -translate-y-1/2 bg-white/20 hover:bg-white/40 text-white p-2 rounded-full backdrop-blur-md transition-all opacity-0 group-hover:opacity-100 z-30"><ChevronRight size={24} /></button>}
|
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-1.5 z-30 text-black">{post.media.map((_, i) => <div key={i} className={cn("w-1.5 h-1.5 rounded-full transition-all", i === currentIndex ? "bg-blue-500 scale-125" : "bg-white/40 shadow-sm")} />)}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="w-full md:w-80 lg:w-96 bg-white flex flex-col border-l border-gray-200 overflow-hidden shrink-0 text-black">
|
|
<div className="p-3 md:p-4 border-b border-gray-100 flex items-center justify-between shrink-0 text-black">
|
|
<div className="flex items-center gap-3 text-black">
|
|
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-yellow-400 to-purple-600 p-0.5 text-black"><div className="w-full h-full rounded-full bg-white p-0.5 text-black"><div className="w-full h-full rounded-full bg-gray-200 flex items-center justify-center overflow-hidden text-[10px] font-bold uppercase text-black">{profilePic ? <img src={profilePic} alt="" className="w-full h-full object-cover text-black" referrerPolicy="no-referrer" /> : <span className="text-black">{post.username[0]}</span>}</div></div></div>
|
|
<span className="font-semibold text-sm text-black">{post.username}</span>
|
|
</div>
|
|
<MoreHorizontal size={20} className="text-gray-500 text-black" />
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-3 md:p-4 space-y-4 min-h-0 md:max-h-[60vh] text-black">
|
|
<div className="flex gap-3 text-black">
|
|
<div className="w-8 h-8 rounded-full bg-gray-200 flex-shrink-0 flex items-center justify-center text-[10px] font-bold uppercase overflow-hidden text-black">{profilePic ? <img src={profilePic} alt="" className="w-full h-full object-cover" referrerPolicy="no-referrer" /> : <span className="text-black">{post.username[0]}</span>}</div>
|
|
<div className="text-sm text-black"><span className="font-semibold mr-2 text-black">{post.username}</span><span className="whitespace-pre-wrap text-black">{post.caption}</span><div className="mt-2 text-xs text-gray-500 uppercase tracking-tight text-black">{formatDateSafe(post.date, 'MMMM d, yyyy')}</div></div>
|
|
</div>
|
|
</div>
|
|
<div className="p-3 md:p-4 border-t border-gray-100 space-y-3 shrink-0 bg-white text-black">
|
|
<div className="flex items-center justify-between text-black"><div className="flex items-center gap-4 text-black"><Heart size={24} className="hover:text-gray-500 cursor-pointer text-black" /><MessageCircle size={24} className="hover:text-gray-500 cursor-pointer text-black" /><Play size={24} className="hover:text-gray-500 cursor-pointer text-black" /></div><Bookmark size={24} className="hover:text-gray-500 cursor-pointer text-black" /></div>
|
|
<div className="text-sm flex items-center gap-2 text-black"><span className="font-semibold text-black">Archived Post</span><span className="text-gray-400 font-normal text-xs text-black">{post.id}</span></div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
};
|