diff --git a/package-lock.json b/package-lock.json index 1845b76..35cfe45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instaarchive-viewer", - "version": "1.3.3", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instaarchive-viewer", - "version": "1.3.3", + "version": "1.4.0", "dependencies": { "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", diff --git a/package.json b/package.json index a97bd35..4adac9d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "instaarchive-viewer", "private": true, - "version": "1.3.3", + "version": "1.4.0", "type": "module", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", diff --git a/src/App.tsx b/src/App.tsx index ce88049..689709a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,8 @@ import { import { motion, AnimatePresence } from 'motion/react'; import { cn } from './lib/utils'; +import { PRESS } from './lib/motion'; +import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing'; import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files'; import { deleteCachedArchive, @@ -60,13 +62,13 @@ export default function App() { const [hasInitialLoaded, setHasInitialLoaded] = useState(false); /** - * The query string as it was when the app booted. + * The route as it was when the app booted. * * Captured during the first render because the URL is rewritten from app * state as soon as anything loads; reading `window.location` later would see * the rewritten value rather than the link the user actually followed. */ - const initialParamsRef = useRef(new URLSearchParams(window.location.search)); + const initialRouteRef = useRef(parseRoute(window.location.pathname, window.location.search)); const fileInputRef = useRef(null); const profilePicInputRef = useRef(null); @@ -339,41 +341,28 @@ export default function App() { // loader below is waiting to read. if (!hasInitialLoaded) return; - const params = new URLSearchParams(window.location.search); - if (currentArchive) params.set('a', currentArchive.name); - else if (allPosts.length > 0 && username) params.set('a', username); - else params.delete('a'); + const archive = currentArchive?.name ?? (allPosts.length > 0 ? username : null) ?? null; + const nextPath = buildPath({ + archive, + tab: activeTab, + post: selectedPost ? postSlug(selectedPost) : null, + }); - if (activeTab !== 'posts') params.set('t', activeTab); - else params.delete('t'); - - if (selectedPost) params.set('p', selectedPost.id); - else params.delete('p'); - - const newSearch = params.toString(); - const currentSearch = new URLSearchParams(window.location.search).toString(); - if (newSearch !== currentSearch) { - console.log(`[Permalink] Updating URL to: ?${newSearch}`); - const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : ''); - window.history.replaceState(null, '', newUrl); + if (nextPath !== window.location.pathname + window.location.search) { + console.log(`[Permalink] Updating URL to: ${nextPath}`); + window.history.replaceState(null, '', nextPath); } }, [hasInitialLoaded, currentArchive?.name, username, allPosts.length, activeTab, selectedPost?.id]); useEffect(() => { if (hasInitialLoaded) return; - const params = initialParamsRef.current; - const archiveName = params.get('a'); - const tab = params.get('t'); - console.log('[Permalink] Initial read from URL:', { - archiveName, tab, postId: params.get('p'), - }); + const route = initialRouteRef.current; + console.log('[Permalink] Initial route:', route); - if (tab && ['posts', 'reels', 'saved'].includes(tab)) { - setActiveTab(tab as 'posts' | 'reels' | 'saved'); - } + if (route.tab !== 'posts') setActiveTab(route.tab); - if (!archiveName) { + if (!route.archive) { setHasInitialLoaded(true); return; } @@ -381,12 +370,12 @@ export default function App() { // Wait for the archive list before deciding the link is unresolvable. if (!archivesFetched) return; - const archive = serverArchives.find(a => a.name === archiveName); + const archive = serverArchives.find(a => a.name === route.archive); if (archive) { - console.log(`[Permalink] Auto-loading archive: ?a=${archiveName}`); + console.log(`[Permalink] Auto-loading archive: ${route.archive}`); loadServerArchive(archive); } else { - console.warn(`[Permalink] No archive named "${archiveName}".`); + console.warn(`[Permalink] No archive named "${route.archive}".`); } setHasInitialLoaded(true); }, [serverArchives, archivesFetched, hasInitialLoaded, loadServerArchive]); @@ -406,10 +395,14 @@ export default function App() { if (appliedPostParamRef.current === archiveKey) return; appliedPostParamRef.current = archiveKey; - const postId = initialParamsRef.current.get('p'); - if (!postId) return; - const post = allPosts.find(p => p.id === postId); - if (post) setSelectedPost(post); + const slug = initialRouteRef.current.post; + if (!slug) return; + const post = findPostBySlug(allPosts, slug); + if (!post) return; + // A /p// link carries no tab, so derive the one that contains it — + // otherwise next/prev would page through the wrong list. + setActiveTab(tabForSource(post.source)); + setSelectedPost(post); }, [allPosts, currentArchive?.name, username]); return ( @@ -504,9 +497,11 @@ export default function App() { {highlightGroups.length > 0 && (
{highlightGroups.map(group => ( -
{group.title} - + ))} )} @@ -538,7 +533,7 @@ export default function App() {
{activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => (
Blank
))} {visiblePosts.map((post) => ( - setSelectedPost(post)} className={cn("relative group cursor-pointer overflow-hidden bg-gray-200 transition-all duration-300 text-black", activeTab === 'reels' ? "aspect-[9/16]" : (gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]"))}> + setSelectedPost(post)} whileTap={{ scale: 0.97 }} transition={PRESS} className={cn("relative group cursor-pointer overflow-hidden bg-gray-200 transition-all duration-300 text-black", activeTab === 'reels' ? "aspect-[9/16]" : (gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]"))}> = ({ post, nextPost, prevPost, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic }) => { const [currentIndex, setCurrentIndex] = useState(0); - const [direction, setDirection] = 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(() => { @@ -75,8 +83,8 @@ export const PostModal: React.FC = ({ useEffect(() => setCurrentIndex(0), [post.id]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'ArrowRight') onNextPost?.(); - else if (e.key === 'ArrowLeft') onPrevPost?.(); + 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(); @@ -85,18 +93,79 @@ export const PostModal: React.FC = ({ return () => window.removeEventListener('keydown', handleKeyDown); }, [onNextPost, onPrevPost, currentIndex, post.media.length, onClose]); - const paginate = (newDirection: number) => { + const paginate = (newDirection: number, velocity = 0) => { const nextIndex = currentIndex + newDirection; - if (nextIndex >= 0 && nextIndex < post.media.length) { setDirection(newDirection); setCurrentIndex(nextIndex); } + if (nextIndex >= 0 && nextIndex < post.media.length) { + setSlideMotion({ axis: 'x', dir: newDirection, velocity }); + setCurrentIndex(nextIndex); + } }; - const variants = { - enter: (d: number) => ({ x: d > 0 ? '100%' : '-100%', opacity: 1, zIndex: 0 }), - center: { zIndex: 1, x: 0, opacity: 1 }, - exit: (d: number) => ({ zIndex: 0, x: d < 0 ? '100%' : '-100%', opacity: 1 }) + /** + * 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 @@ -107,32 +176,37 @@ export const PostModal: React.FC = ({ * post grid behind the overlay. */ return ( - +
{/* Solid pill so the arrows read against whatever sits behind them. */} - {hasPrevPost && onPrevPost && } - {hasNextPost && onNextPost && } - { if (offset.y > 200 || velocity.y > 800) onClose(); }} 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()}> + {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) { if (currentIndex < post.media.length - 1) paginate(1); else if (hasNextPost && onNextPost && s < -40000) onNextPost(); } - else if (s > 15000) { if (currentIndex > 0) paginate(-1); else if (hasPrevPost && onPrevPost && s > 40000) onPrevPost(); } + 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" > diff --git a/src/components/StoryViewer.tsx b/src/components/StoryViewer.tsx index cf2cd94..5db9939 100644 --- a/src/components/StoryViewer.tsx +++ b/src/components/StoryViewer.tsx @@ -9,6 +9,7 @@ import { import { motion } from 'motion/react'; import { Post } from '../types'; import { cn, formatDateSafe } from '../lib/utils'; +import { FADE, PRESENT, prefersReducedMotion } from '../lib/motion'; interface StoryViewerProps { stories: Post[]; @@ -31,6 +32,7 @@ export const StoryViewer: React.FC = ({ // the progress bar on the first video. const [isMuted, setIsMuted] = useState(false); const videoRef = useRef(null); + const reduceMotion = prefersReducedMotion(); const story = stories[currentStoryIndex]; const primary = story?.media?.[0]; @@ -106,10 +108,11 @@ export const StoryViewer: React.FC = ({ if (!story || !primary) return null; return ( - @@ -138,7 +141,11 @@ export const StoryViewer: React.FC = ({ -
e.stopPropagation()} > @@ -223,7 +230,7 @@ export const StoryViewer: React.FC = ({ {story.caption}
)} -
+ ); }; diff --git a/src/lib/motion.ts b/src/lib/motion.ts new file mode 100644 index 0000000..08175d4 --- /dev/null +++ b/src/lib/motion.ts @@ -0,0 +1,44 @@ +import type { Transition } from 'motion/react'; + +/** + * Shared motion vocabulary, tuned to feel like a native iOS app. + * + * Two rules do most of the work: + * - UIKit animates with springs, not fixed-duration easing, so gestures hand + * their exit velocity to the animation and motion continues rather than + * restarting. + * - iOS springs are critically damped. They settle firmly with no visible + * bounce; overshoot reads as "web animation", not "native". + */ + +/** The curve UIKit uses for sheet presentation. */ +export const IOS_EASE = [0.32, 0.72, 0, 1] as const; + +/** Moving between peers: carousel slides, next/previous post. */ +export const NAVIGATE: Transition = { type: 'spring', stiffness: 420, damping: 40, mass: 1 }; + +/** Presenting or dismissing a surface. Slightly softer than navigation. */ +export const PRESENT: Transition = { type: 'spring', stiffness: 320, damping: 34, mass: 1 }; + +/** Backdrops and cross-fades, where a spring would feel fussy. */ +export const FADE: Transition = { duration: 0.28, ease: IOS_EASE }; + +/** Touch-down feedback. Fast enough to feel like a direct response. */ +export const PRESS: Transition = { type: 'spring', stiffness: 600, damping: 30 }; + +/** + * Continue a drag into its animation. + * + * Handing the gesture's exit velocity to the spring is what separates "the + * sheet kept moving because I flicked it" from "the sheet started a new + * animation once I let go". + */ +export const withVelocity = (velocity: number, base: Transition = NAVIGATE): Transition => ({ + ...base, + velocity, +}); + +/** True when the viewer has asked the OS to reduce motion. */ +export const prefersReducedMotion = () => + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/src/lib/routing.test.ts b/src/lib/routing.test.ts new file mode 100644 index 0000000..573bf9b --- /dev/null +++ b/src/lib/routing.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './routing'; +import { Post } from '../types'; + +const post = (id: string, source?: Post['source']): Post => ({ + id, date: '2024-01-01', username: 'u', caption: '', media: [], thumbnail: '', source, +}); + +describe('parseRoute', () => { + it('reads the explorer root', () => { + expect(parseRoute('/')).toEqual({ archive: null, tab: 'posts', post: null }); + }); + + it('reads a profile', () => { + expect(parseRoute('/0ct0ber19/')).toEqual({ archive: '0ct0ber19', tab: 'posts', post: null }); + }); + + it('reads a profile without a trailing slash', () => { + expect(parseRoute('/0ct0ber19')).toEqual({ archive: '0ct0ber19', tab: 'posts', post: null }); + }); + + it('reads a tab', () => { + expect(parseRoute('/0ct0ber19/reels/').tab).toBe('reels'); + expect(parseRoute('/0ct0ber19/saved/').tab).toBe('saved'); + }); + + it('reads a post in Instagram form', () => { + expect(parseRoute('/0ct0ber19/p/Db5tIoRCcvm/')).toEqual({ + archive: '0ct0ber19', tab: 'posts', post: 'Db5tIoRCcvm', + }); + }); + + it('decodes archive names containing spaces', () => { + expect(parseRoute('/Heejin_Bubble%20heejinmedia/').archive).toBe('Heejin_Bubble heejinmedia'); + }); + + it('does not treat reserved prefixes as archives', () => { + for (const path of ['/api/archives', '/archives/x/y.jpg', '/assets/index.js']) { + expect(parseRoute(path).archive).toBeNull(); + } + }); + + it('still understands the legacy query form', () => { + expect(parseRoute('/', '?a=0ct0ber19&t=reels&p=ABC')).toEqual({ + archive: '0ct0ber19', tab: 'reels', post: 'ABC', + }); + }); + + it('ignores an unknown tab', () => { + expect(parseRoute('/', '?a=u&t=bogus').tab).toBe('posts'); + }); +}); + +describe('buildPath', () => { + it.each([ + [{ archive: null, tab: 'posts', post: null }, '/'], + [{ archive: '0ct0ber19', tab: 'posts', post: null }, '/0ct0ber19/'], + [{ archive: '0ct0ber19', tab: 'reels', post: null }, '/0ct0ber19/reels/'], + [{ archive: '0ct0ber19', tab: 'posts', post: 'Db5tIoRCcvm' }, '/0ct0ber19/p/Db5tIoRCcvm/'], + ] as const)('builds %j', (route, expected) => { + expect(buildPath(route as any)).toBe(expected); + }); + + it('omits the tab from a post URL, matching Instagram', () => { + expect(buildPath({ archive: 'u', tab: 'reels', post: 'ABC' })).toBe('/u/p/ABC/'); + }); + + it('encodes archive names with spaces', () => { + expect(buildPath({ archive: 'a b', tab: 'posts', post: null })).toBe('/a%20b/'); + }); + + it('round-trips through parseRoute', () => { + for (const route of [ + { archive: '0ct0ber19', tab: 'posts' as const, post: null }, + { archive: '0ct0ber19', tab: 'reels' as const, post: null }, + { archive: 'Heejin_Bubble heejinmedia', tab: 'posts' as const, post: null }, + ]) { + expect(parseRoute(buildPath(route))).toEqual(route); + } + }); +}); + +describe('postSlug / findPostBySlug', () => { + it('uses the bare shortcode for base posts', () => { + expect(postSlug(post('Db5tIoRCcvm'))).toBe('Db5tIoRCcvm'); + }); + + it('strips the sidecar directory from the slug', () => { + expect(postSlug(post('story highlights - u - Heestory/C5dQPEYpd9W'))).toBe('C5dQPEYpd9W'); + }); + + it('resolves a slug back to its post', () => { + const posts = [post('AAA'), post('0ct0ber19 - reels/BBB', 'reels')]; + expect(findPostBySlug(posts, 'BBB')?.id).toBe('0ct0ber19 - reels/BBB'); + expect(findPostBySlug(posts, 'AAA')?.id).toBe('AAA'); + }); + + it('prefers an exact id match over a shortcode match', () => { + const posts = [post('x/ABC'), post('ABC')]; + expect(findPostBySlug(posts, 'ABC')?.id).toBe('ABC'); + }); + + it('returns undefined for an unknown slug', () => { + expect(findPostBySlug([post('AAA')], 'ZZZ')).toBeUndefined(); + }); +}); + +describe('tabForSource', () => { + it('sends reels to the reels tab and everything else to posts', () => { + expect(tabForSource('reels')).toBe('reels'); + expect(tabForSource('posts')).toBe('posts'); + expect(tabForSource(undefined)).toBe('posts'); + }); +}); diff --git a/src/lib/routing.ts b/src/lib/routing.ts new file mode 100644 index 0000000..a83d31c --- /dev/null +++ b/src/lib/routing.ts @@ -0,0 +1,85 @@ +import { Post, SourceKind } from '../types'; + +/** + * Instagram-shaped paths. + * + * / the archive explorer + * // a profile, posts tab + * //reels/ a profile, reels tab + * //saved/ + * //p// a single post + * + * The older `?a=&t=&p=` query form is still parsed so existing links keep + * working; it is never written back. + */ + +export type Tab = 'posts' | 'reels' | 'saved'; + +const TABS: Tab[] = ['posts', 'reels', 'saved']; + +/** + * Path prefixes the app must never treat as an archive name, or a profile + * called "api" would shadow the backend. + */ +const RESERVED = new Set(['api', 'archives', 'assets', 'p', 'fonts', 'sw.js', 'manifest.webmanifest']); + +export interface Route { + archive: string | null; + tab: Tab; + /** Post shortcode, i.e. the trailing segment of a post id. */ + post: string | null; +} + +/** + * A post's URL slug. + * + * Sidecar posts carry a directory-scoped id (`story highlights - u - H/ABC`) + * so ids stay unique across sources, but only the shortcode belongs in a URL. + */ +export const postSlug = (post: Pick): string => { + const tail = post.id.split('/').pop() ?? post.id; + return encodeURIComponent(tail); +}; + +/** Find the post a slug refers to, preferring an exact id match. */ +export const findPostBySlug = (posts: Post[], slug: string): Post | undefined => { + const decoded = decodeURIComponent(slug); + return posts.find(p => p.id === decoded) + ?? posts.find(p => (p.id.split('/').pop() ?? p.id) === decoded); +}; + +/** Which tab shows a given post, so a deep link lands on the right one. */ +export const tabForSource = (source?: SourceKind): Tab => (source === 'reels' ? 'reels' : 'posts'); + +export const parseRoute = (pathname: string, search = ''): Route => { + const segments = pathname.split('/').filter(Boolean).map(decodeURIComponent); + + if (segments.length && !RESERVED.has(segments[0])) { + const [archive, second, third] = segments; + + if (second === 'p' && third) return { archive, tab: 'posts', post: third }; + if (second && TABS.includes(second as Tab)) return { archive, tab: second as Tab, post: null }; + return { archive, tab: 'posts', post: null }; + } + + // Legacy query form: ?a=&t=&p= + const params = new URLSearchParams(search); + const archive = params.get('a'); + const tab = params.get('t'); + return { + archive: archive || null, + tab: tab && TABS.includes(tab as Tab) ? (tab as Tab) : 'posts', + post: params.get('p'), + }; +}; + +export const buildPath = ({ archive, tab, post }: Route): string => { + if (!archive) return '/'; + + const base = `/${encodeURIComponent(archive)}`; + // A post URL omits the tab, matching Instagram; the tab is re-derived from + // the post itself when the link is opened. + if (post) return `${base}/p/${post}/`; + if (tab !== 'posts') return `${base}/${tab}/`; + return `${base}/`; +};