Docker Build and Publish / build-and-push (push) Failing after 10s
Instaloader metadata was silently lost Every .json.xz failed with "Failed to fetch" during a scan, though the same URL fetched fine on its own. RemoteArchiveFile.stream() started a fetch, piped the body into a TransformStream and returned the readable immediately — nothing caught a fetch rejection, and the decompressor stops reading at the end of the xz member, so the response body was never drained or cancelled. Across ~190 sidecars that exhausted the connection pool. Everything Instaloader archives carry lives in those files, so the failure was invisible but total. rivvsofficial reported 188 posts, no stories, 0 followers and a placeholder bio; it now reports 68 posts, 120 stories, 10,337 followers and the real name, bio and link — 68 + 120 = 188, matching the sidecars exactly (106 GraphStoryVideo + 14 GraphStoryImage = 120). These sidecars are a few KB, so they are now read into memory before decompressing. stream() was left unused by that change and is removed from the interface and both implementations rather than kept as a trap. Arrow keys page the carousel They moved between posts, which contradicted the arrows drawn on the carousel itself. Arrows now page slides; , and . move between posts, alongside the side buttons. Backdrop cross-fade AnimatePresence had no exit variant, so the outgoing scan backdrop was removed instantly while its replacement faded in over 1.5s, exposing the pale page behind it as a white flash. Layers now stack: the outgoing image holds full opacity until covered, and the 0.4 moved onto the group so overlapping layers don't darken as they cross. Measured over a real scan: 152 cross-fades with a layer always opaque, except the opening fade-in where nothing is underneath. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
220 lines
13 KiB
TypeScript
220 lines
13 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(() => {
|
|
// Arrows page within the carousel — the thing the arrows visually point at.
|
|
// Moving between posts stays on the side buttons, with , and . as keyboard
|
|
// equivalents.
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'ArrowRight') paginate(1);
|
|
else if (e.key === 'ArrowLeft') paginate(-1);
|
|
else if (e.key === '.') goToPost(1, 'x');
|
|
else if (e.key === ',') goToPost(-1, 'x');
|
|
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;
|
|
|
|
/**
|
|
* Desktop-only surface: mobile opens PostFeed instead, so the only vertical
|
|
* gesture left here is drag-to-dismiss.
|
|
*/
|
|
const handleVerticalDragEnd = (offset: { y: number }, velocity: { y: number }) => {
|
|
if (offset.y > 200 || velocity.y > 800) 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>
|
|
);
|
|
};
|