diff --git a/package-lock.json b/package-lock.json index 35cfe45..a53aa18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instaarchive-viewer", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instaarchive-viewer", - "version": "1.4.0", + "version": "1.5.0", "dependencies": { "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", diff --git a/package.json b/package.json index 4adac9d..cbc67de 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "instaarchive-viewer", "private": true, - "version": "1.4.0", + "version": "1.5.0", "type": "module", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", diff --git a/src/App.tsx b/src/App.tsx index 689709a..6896e2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,8 @@ import { CacheData, Post, ServerArchive, ServerArchiveFile } from './types'; import { ArchiveDashboard } from './components/ArchiveDashboard'; import { StoryViewer } from './components/StoryViewer'; import { PostModal } from './components/PostModal'; +import { PostFeed } from './components/PostFeed'; +import { useIsMobile } from './hooks/useIsMobile'; import { PostThumbnail } from './components/PostThumbnail'; import { useArchiveScanner } from './hooks/useArchiveScanner'; import { useThumbnailQueue } from './hooks/useThumbnailQueue'; @@ -70,6 +72,7 @@ export default function App() { */ const initialRouteRef = useRef(parseRoute(window.location.pathname, window.location.search)); + const isMobile = useIsMobile(); const fileInputRef = useRef(null); const profilePicInputRef = useRef(null); @@ -549,21 +552,36 @@ export default function App() { )} - - {selectedPost && ( - 0 ? filteredPosts[postIndex - 1] : undefined} - onClose={() => setSelectedPost(null)} - onNextPost={onNextPost} - onPrevPost={onPrevPost} - hasNextPost={postIndex < filteredPosts.length - 1} - hasPrevPost={postIndex > 0} - profilePic={profilePic} - /> - )} - + {/* + Mobile opens a real scrolling feed page, the way Instagram does; desktop + keeps the modal, where a centred sheet with side arrows fits the pointer. + */} + {selectedPost && isMobile ? ( + setSelectedPost(null)} + onActivePostChange={setSelectedPost} + /> + ) : ( + + {selectedPost && ( + 0 ? filteredPosts[postIndex - 1] : undefined} + onClose={() => setSelectedPost(null)} + onNextPost={onNextPost} + onPrevPost={onPrevPost} + hasNextPost={postIndex < filteredPosts.length - 1} + hasPrevPost={postIndex > 0} + profilePic={profilePic} + /> + )} + + )} {showStoryViewer && allStories.length > 0 && setShowStoryViewer(false)} profilePic={profilePic} />} {activeHighlight && ( diff --git a/src/components/FeedPost.tsx b/src/components/FeedPost.tsx new file mode 100644 index 0000000..35eef18 --- /dev/null +++ b/src/components/FeedPost.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Bookmark, Heart, MessageCircle, MoreHorizontal, Send } from 'lucide-react'; +import { Post } from '../types'; +import { formatDateSafe } from '../lib/utils'; +import { MediaCarousel } from './MediaCarousel'; + +interface FeedPostProps { + post: Post; + profilePic: string | null; + /** Off-screen posts keep their video paused. */ + paused: boolean; +} + +/** + * One post in the mobile feed, laid out like Instagram's: header, media, + * action row, then caption. + * + * Media is capped below full viewport height so the next post always peeks in + * at the bottom — that overlap is what tells you the page scrolls rather than + * pages. + */ +export const FeedPost: React.FC = ({ post, profilePic, paused }) => ( +
+
+
+
+
+
+ {profilePic + ? + : {post.username[0]}} +
+
+
+ {post.username} +
+ +
+ + {/* + Taller ceiling than the modal so ordinary portrait media (9:16 reels, + 4:5 photos) fills the feed width instead of sitting in side bars, while + still stopping anything extreme from swallowing the screen. + */} + + +
+
+ + + +
+ +
+ + {post.caption && ( +
+ {post.username} + {post.caption} +
+ )} + +
+ {formatDateSafe(post.date, 'MMMM d, yyyy')} +
+
+); diff --git a/src/components/MediaCarousel.tsx b/src/components/MediaCarousel.tsx new file mode 100644 index 0000000..b947a94 --- /dev/null +++ b/src/components/MediaCarousel.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; +import { Post } from '../types'; +import { cn } from '../lib/utils'; +import { NAVIGATE, prefersReducedMotion, withVelocity } from '../lib/motion'; +import { MediaRenderer } from './MediaRenderer'; + +interface MediaCarouselProps { + post: Post; + /** Pause video even when this slide is on screen (feed: only one plays). */ + paused?: boolean; + /** Override the media height ceiling (the feed allows taller media). */ + heightCap?: string; + /** Size video to the container width (see MediaRenderer). */ + fillWidth?: boolean; + className?: string; +} + +/** + * The horizontal slide strip for one post. + * + * Shared by the desktop modal and the mobile feed so a carousel behaves the + * same in both. Horizontal drag belongs to the carousel and never navigates + * between posts — vertical movement is the page's to handle. + */ +export const MediaCarousel: React.FC = ({ post, paused, heightCap, fillWidth, className }) => { + const [index, setIndex] = useState(0); + const [slide, setSlide] = useState<{ dir: number; velocity: number }>({ dir: 0, velocity: 0 }); + const reduceMotion = prefersReducedMotion(); + + useEffect(() => setIndex(0), [post.id]); + + const paginate = (dir: number, velocity = 0) => { + const next = index + dir; + if (next < 0 || next >= post.media.length) return; + setSlide({ dir, velocity }); + setIndex(next); + }; + + const variants = { + enter: (d: number) => ({ x: d > 0 ? '100%' : '-100%', opacity: 1, zIndex: 0 }), + center: { x: 0, opacity: 1, zIndex: 1 }, + exit: (d: number) => ({ x: d < 0 ? '100%' : '-100%', opacity: 1, zIndex: 0 }), + }; + const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity; + const current = post.media[index]; + + return ( +
+
+ + 1 ? 'x' : false} + dragDirectionLock + dragConstraints={{ left: 0, right: 0 }} + dragElastic={0.5} + onDragEnd={(e, { offset, velocity }) => { + const power = swipePower(offset.x, velocity.x); + if (power < -15000) paginate(1, velocity.x); + else if (power > 15000) paginate(-1, velocity.x); + }} + className="col-start-1 row-start-1 w-full flex items-center justify-center relative touch-pan-y" + > + {current && } + + +
+ + {post.media.length > 1 && ( + <> + {index > 0 && ( + + )} + {index < post.media.length - 1 && ( + + )} +
+ {post.media.map((_, i) => ( +
+ ))} +
+ + )} +
+ ); +}; diff --git a/src/components/MediaRenderer.tsx b/src/components/MediaRenderer.tsx index 6777bf0..fd160ed 100644 --- a/src/components/MediaRenderer.tsx +++ b/src/components/MediaRenderer.tsx @@ -3,7 +3,25 @@ import { Play, Volume2, VolumeX } from 'lucide-react'; import { MediaFile } from '../types'; import { cn } from '../lib/utils'; -export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => { +interface MediaRendererProps { + file: MediaFile; + className?: string; + isFullView?: boolean; + /** Hold playback: the feed keeps every off-screen video paused. */ + paused?: boolean; + /** Cap media height to this instead of the default full-view ceiling. */ + heightCap?: string; + /** + * Size video to the container width rather than its own intrinsic size. + * + * A