feat: mobile opens posts as a scrolling feed instead of a modal
Docker Build and Publish / build-and-push (push) Failing after 10s
Docker Build and Publish / build-and-push (push) Failing after 10s
Tapping a post on a phone now opens a real feed page — header, media, actions, caption, next post peeking in below — scrolled with the browser's own vertical scrolling rather than swipe gestures. Desktop keeps the modal, where a centred sheet with side arrows suits a pointer. Only a window of posts is mounted: a profile here holds up to 1129 posts and mounting them all would mean as many full-size images. The window grows in both directions as you scroll. Growing upwards shifts everything below it, so the scroll offset is corrected in the same frame, before paint — measured against the real archive, an anchored post moves exactly one screen per scroll with no jump. Only the post crossing the viewport centre plays its video; the rest stay paused, so a feed of reels doesn't play ten at once. The URL tracks that same post, so scrolling updates /<archive>/p/<shortcode>/ the way Instagram does, and the back button returns to the grid with its scroll position intact. Feed video sizes to the container width rather than its intrinsic size: a <video> reports 300x150 until metadata loads, which made it render narrow and then jump to full width. It also gets a taller height ceiling than the modal so ordinary portrait media fills the width instead of sitting in side bars. The carousel is extracted into a shared MediaCarousel used by both surfaces, so horizontal paging behaves identically; touch-action keeps vertical scrolling passing through to the feed. PostModal loses its now-dead mobile swipe branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
This commit is contained in:
co-authored by
Claude Opus 5
parent
877d21ff1f
commit
c54f8d5b09
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+18
@@ -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<HTMLInputElement>(null);
|
||||
const profilePicInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -549,6 +552,20 @@ export default function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/*
|
||||
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 ? (
|
||||
<PostFeed
|
||||
posts={filteredPosts}
|
||||
initialPostId={selectedPost.id}
|
||||
profilePic={profilePic}
|
||||
title={activeTab === 'reels' ? 'Reels' : 'Posts'}
|
||||
onClose={() => setSelectedPost(null)}
|
||||
onActivePostChange={setSelectedPost}
|
||||
/>
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
{selectedPost && (
|
||||
<PostModal
|
||||
@@ -564,6 +581,7 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
<AnimatePresence>{showStoryViewer && allStories.length > 0 && <StoryViewer stories={allStories} onClose={() => setShowStoryViewer(false)} profilePic={profilePic} />}</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{activeHighlight && (
|
||||
|
||||
@@ -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<FeedPostProps> = ({ post, profilePic, paused }) => (
|
||||
<article className="bg-white border-b border-gray-200">
|
||||
<header className="flex items-center justify-between px-3 py-2.5">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-yellow-400 to-purple-600 p-0.5 shrink-0">
|
||||
<div className="w-full h-full rounded-full bg-white p-0.5">
|
||||
<div className="w-full h-full rounded-full bg-gray-200 overflow-hidden flex items-center justify-center text-[10px] font-bold uppercase">
|
||||
{profilePic
|
||||
? <img src={profilePic} alt="" className="w-full h-full object-cover" referrerPolicy="no-referrer" />
|
||||
: <span>{post.username[0]}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-semibold text-sm truncate">{post.username}</span>
|
||||
</div>
|
||||
<MoreHorizontal size={20} className="text-gray-500 shrink-0" />
|
||||
</header>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<MediaCarousel post={post} paused={paused} heightCap="max-h-[85vh]" fillWidth className="bg-black" />
|
||||
|
||||
<div className="px-3 pt-3 pb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Heart size={24} className="cursor-pointer" />
|
||||
<MessageCircle size={24} className="cursor-pointer" />
|
||||
<Send size={24} className="cursor-pointer" />
|
||||
</div>
|
||||
<Bookmark size={24} className="cursor-pointer" />
|
||||
</div>
|
||||
|
||||
{post.caption && (
|
||||
<div className="px-3 pb-1 text-sm">
|
||||
<span className="font-semibold mr-2">{post.username}</span>
|
||||
<span className="whitespace-pre-wrap">{post.caption}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3 pb-3 pt-1 text-[10px] uppercase tracking-wide text-gray-400">
|
||||
{formatDateSafe(post.date, 'MMMM d, yyyy')}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -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<MediaCarouselProps> = ({ 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 (
|
||||
<div className={cn('relative bg-black flex items-center justify-center group overflow-hidden w-full', className)}>
|
||||
<div className="w-full grid grid-cols-1 grid-rows-1">
|
||||
<AnimatePresence initial={false} custom={slide.dir}>
|
||||
<motion.div
|
||||
key={`${post.id}-${index}`}
|
||||
custom={slide.dir}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={reduceMotion ? { duration: 0 } : withVelocity(slide.velocity, NAVIGATE)}
|
||||
drag={post.media.length > 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 && <MediaRenderer file={current} isFullView paused={paused} heightCap={heightCap} fillWidth={fillWidth} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{post.media.length > 1 && (
|
||||
<>
|
||||
{index > 0 && (
|
||||
<button
|
||||
aria-label="Previous photo"
|
||||
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>
|
||||
)}
|
||||
{index < post.media.length - 1 && (
|
||||
<button
|
||||
aria-label="Next photo"
|
||||
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">
|
||||
{post.media.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'w-1.5 h-1.5 rounded-full transition-all',
|
||||
i === index ? 'bg-blue-500 scale-125' : 'bg-white/40 shadow-sm',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 <video> reports 300x150 until metadata loads, so `w-auto` makes it render
|
||||
* narrow and then jump to full width. The feed needs a stable width more than
|
||||
* it needs a snug fit.
|
||||
*/
|
||||
fillWidth?: boolean;
|
||||
}
|
||||
|
||||
export const MediaRenderer = ({ file, className, isFullView, paused, heightCap, fillWidth }: MediaRendererProps) => {
|
||||
// Try to play with sound: opening the modal is a user gesture, so browsers
|
||||
// generally allow it. If this particular browser still refuses, the effect
|
||||
// below falls back to muted playback rather than leaving a stalled video.
|
||||
@@ -14,6 +32,11 @@ export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile
|
||||
const video = videoRef.current;
|
||||
if (!video || file.type !== 'video') return;
|
||||
|
||||
if (paused) {
|
||||
video.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
video.muted = false;
|
||||
video.play().catch(() => {
|
||||
@@ -24,7 +47,7 @@ export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [file.url, file.type]);
|
||||
}, [file.url, file.type, paused]);
|
||||
/**
|
||||
* In full view the media must never outgrow the viewport.
|
||||
*
|
||||
@@ -36,8 +59,11 @@ export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile
|
||||
* The desktop cap subtracts the modal's own padding (md:p-10 = 2.5rem each
|
||||
* side); mobile leaves room for the caption panel stacked underneath.
|
||||
*/
|
||||
const fullViewCap = "max-h-[70vh] md:max-h-[calc(100vh-5rem)] object-contain";
|
||||
const videoSizing = isFullView ? `block w-auto max-w-full ${fullViewCap}` : "w-full h-full object-cover";
|
||||
const fullViewCap = `${heightCap ?? 'max-h-[70vh] md:max-h-[calc(100vh-5rem)]'} object-contain`;
|
||||
const videoFullView = fillWidth
|
||||
? `block w-full h-auto ${fullViewCap}`
|
||||
: `block w-auto max-w-full ${fullViewCap}`;
|
||||
const videoSizing = isFullView ? videoFullView : "w-full h-full object-cover";
|
||||
const imageSizing = isFullView ? `block w-full h-auto ${fullViewCap}` : "w-full h-full object-cover";
|
||||
const sizingClass = file.type === 'video' ? videoSizing : imageSizing;
|
||||
const mediaStyle = { transform: 'translateZ(0)' };
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { Post } from '../types';
|
||||
import { FeedPost } from './FeedPost';
|
||||
|
||||
interface PostFeedProps {
|
||||
posts: Post[];
|
||||
/** Post the feed should open at. */
|
||||
initialPostId: string;
|
||||
profilePic: string | null;
|
||||
onClose: () => void;
|
||||
/** Fires as the post crossing the viewport centre changes. */
|
||||
onActivePostChange: (post: Post) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** Posts added each time the feed grows in either direction. */
|
||||
const BATCH = 6;
|
||||
/** Render this many ahead of the entry point so the first scroll is smooth. */
|
||||
const LOOKAHEAD = 3;
|
||||
|
||||
/**
|
||||
* The mobile post view: a real scrolling feed, not a modal.
|
||||
*
|
||||
* Only a window of posts around the entry point is mounted — a profile can hold
|
||||
* thousands, and mounting them all would mean thousands of full-size images.
|
||||
* The window grows in both directions as you scroll; growing *upwards* shifts
|
||||
* everything below it, so the scroll position is corrected in the same frame to
|
||||
* keep the content under your thumb still.
|
||||
*/
|
||||
export const PostFeed: React.FC<PostFeedProps> = ({
|
||||
posts, initialPostId, profilePic, onClose, onActivePostChange, title,
|
||||
}) => {
|
||||
const initialIndex = useMemo(() => {
|
||||
const found = posts.findIndex(p => p.id === initialPostId);
|
||||
return found === -1 ? 0 : found;
|
||||
}, [posts, initialPostId]);
|
||||
|
||||
const [range, setRange] = useState(() => ({
|
||||
start: initialIndex,
|
||||
end: Math.min(posts.length, initialIndex + LOOKAHEAD + 1),
|
||||
}));
|
||||
const [activeId, setActiveId] = useState(initialPostId);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
||||
/** Distance from the bottom of the content, captured before a prepend. */
|
||||
const anchorRef = useRef<number | null>(null);
|
||||
|
||||
const visible = posts.slice(range.start, range.end);
|
||||
|
||||
const extendDown = useCallback(() => {
|
||||
setRange(r => (r.end >= posts.length ? r : { ...r, end: Math.min(posts.length, r.end + BATCH) }));
|
||||
}, [posts.length]);
|
||||
|
||||
const extendUp = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setRange(r => {
|
||||
if (r.start === 0) return r;
|
||||
// Measure from the bottom: prepending changes scrollHeight, but the
|
||||
// distance between our position and the end of the content does not.
|
||||
anchorRef.current = el.scrollHeight - el.scrollTop;
|
||||
return { ...r, start: Math.max(0, r.start - BATCH) };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Restore the scroll position in the same frame the prepended posts appear,
|
||||
// before the browser paints, so nothing visibly jumps.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && anchorRef.current !== null) {
|
||||
el.scrollTop = el.scrollHeight - anchorRef.current;
|
||||
anchorRef.current = null;
|
||||
}
|
||||
}, [range.start]);
|
||||
|
||||
// Grow the window when either end comes into view.
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
if (entry.target === bottomSentinelRef.current) extendDown();
|
||||
if (entry.target === topSentinelRef.current) extendUp();
|
||||
}
|
||||
}, { root, rootMargin: '600px 0px' });
|
||||
|
||||
if (topSentinelRef.current) observer.observe(topSentinelRef.current);
|
||||
if (bottomSentinelRef.current) observer.observe(bottomSentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [extendDown, extendUp]);
|
||||
|
||||
// Track the post crossing the viewport centre. The negative margins collapse
|
||||
// the root to a thin band, so exactly one post qualifies at a time.
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const id = (entry.target as HTMLElement).dataset.postId;
|
||||
if (id) setActiveId(id);
|
||||
}
|
||||
}, { root, rootMargin: '-45% 0px -45% 0px', threshold: 0 });
|
||||
|
||||
root.querySelectorAll('[data-post-id]').forEach(el => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [visible.length, range.start]);
|
||||
|
||||
useEffect(() => {
|
||||
const post = posts.find(p => p.id === activeId);
|
||||
if (post) onActivePostChange(post);
|
||||
}, [activeId, posts, onActivePostChange]);
|
||||
|
||||
// Escape closes, matching the modal it replaces.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white flex flex-col">
|
||||
<header className="flex items-center gap-3 px-2 h-12 border-b border-gray-200 bg-white/95 backdrop-blur-md shrink-0">
|
||||
<button onClick={onClose} aria-label="Back" className="p-2 -ml-1 active:opacity-60">
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
<span className="font-semibold text-base">{title ?? 'Posts'}</span>
|
||||
</header>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto overscroll-contain">
|
||||
<div ref={topSentinelRef} aria-hidden />
|
||||
{visible.map(post => (
|
||||
<div key={post.id} data-post-id={post.id}>
|
||||
<FeedPost post={post} profilePic={profilePic} paused={post.id !== activeId} />
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomSentinelRef} aria-hidden />
|
||||
{range.end >= posts.length && (
|
||||
<div className="py-10 text-center text-xs uppercase tracking-widest text-gray-400">
|
||||
End of {title?.toLowerCase() ?? 'posts'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -126,44 +126,12 @@ export const PostModal: React.FC<PostModalProps> = ({
|
||||
};
|
||||
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".
|
||||
* Desktop-only surface: mobile opens PostFeed instead, so the only vertical
|
||||
* gesture left here is drag-to-dismiss.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Matches Tailwind's `md` breakpoint, the point where the layout splits. */
|
||||
const MOBILE_QUERY = '(max-width: 767px)';
|
||||
|
||||
/**
|
||||
* True on phone-sized viewports.
|
||||
*
|
||||
* Drives more than styling: mobile opens posts as a scrollable feed page while
|
||||
* desktop uses the modal, so this needs to be real state rather than a CSS
|
||||
* media query.
|
||||
*/
|
||||
export const useIsMobile = () => {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(MOBILE_QUERY).matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(MOBILE_QUERY);
|
||||
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
query.addEventListener('change', onChange);
|
||||
setIsMobile(query.matches);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
};
|
||||
Reference in New Issue
Block a user