Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
877d21ff1f |
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+33
-38
@@ -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<HTMLInputElement>(null);
|
||||
const profilePicInputRef = useRef<HTMLInputElement>(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/<code>/ 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 && (
|
||||
<div className="flex gap-6 md:gap-8 overflow-x-auto scrollbar-hide px-4 pb-2">
|
||||
{highlightGroups.map(group => (
|
||||
<button
|
||||
<motion.button
|
||||
key={group.title}
|
||||
onClick={() => setActiveHighlight(group.title)}
|
||||
whileTap={{ scale: 0.94 }}
|
||||
transition={PRESS}
|
||||
className="flex flex-col items-center gap-2 shrink-0 group/hl"
|
||||
title={`${group.title} — ${group.items.length} item${group.items.length === 1 ? '' : 's'}`}
|
||||
>
|
||||
@@ -522,7 +517,7 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] max-w-[80px] truncate text-gray-700">{group.title}</span>
|
||||
</button>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -538,7 +533,7 @@ export default function App() {
|
||||
<div className="grid grid-cols-3 gap-[2px] md:gap-[2px] text-black">
|
||||
{activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => (<div key={`blank-${i}`} className={cn("bg-gray-100/50 border border-dashed border-gray-200 flex items-center justify-center text-[10px] font-bold text-gray-300 uppercase tracking-tighter text-black", gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]")}>Blank</div>))}
|
||||
{visiblePosts.map((post) => (
|
||||
<motion.div key={post.id} layoutId={post.id} onClick={() => 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]"))}>
|
||||
<motion.div key={post.id} layoutId={post.id} onClick={() => 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]"))}>
|
||||
<PostThumbnail
|
||||
post={post}
|
||||
thumbnailUrl={cacheHits.get(post.id)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
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 {
|
||||
@@ -30,7 +31,14 @@ export const PostModal: React.FC<PostModalProps> = ({
|
||||
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<PostModalProps> = ({
|
||||
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<PostModalProps> = ({
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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: (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 })
|
||||
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<PostModalProps> = ({
|
||||
* post grid behind the overlay.
|
||||
*/
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} 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}>
|
||||
<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(); onPrevPost(); }} 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(); onNextPost(); }} 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 }) => { 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 && <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={direction}>
|
||||
<AnimatePresence initial={false} custom={slideMotion}>
|
||||
<motion.div
|
||||
key={`${post.id}-${currentIndex}`}
|
||||
custom={direction}
|
||||
custom={slideMotion}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ x: { type: "spring", stiffness: 200, damping: 26, bounce: 0 } }}
|
||||
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) { 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"
|
||||
>
|
||||
|
||||
@@ -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<StoryViewerProps> = ({
|
||||
// the progress bar on the first video.
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
const story = stories[currentStoryIndex];
|
||||
const primary = story?.media?.[0];
|
||||
|
||||
@@ -110,6 +112,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={FADE}
|
||||
className="fixed inset-0 z-[100] bg-[#1a1a1a] flex items-center justify-center overflow-hidden text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
@@ -138,7 +141,11 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
||||
<ChevronRight size={32} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
<motion.div
|
||||
initial={reduceMotion ? false : { scale: 0.94, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { scale: 0.94, opacity: 0 }}
|
||||
transition={PRESENT}
|
||||
className="relative w-full h-full md:h-[90vh] md:max-w-[45vh] bg-black overflow-hidden md:rounded-lg shadow-2xl z-10 text-white"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
@@ -223,7 +230,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
||||
{story.caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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('/4utumn07/')).toEqual({ archive: '4utumn07', tab: 'posts', post: null });
|
||||
});
|
||||
|
||||
it('reads a profile without a trailing slash', () => {
|
||||
expect(parseRoute('/4utumn07')).toEqual({ archive: '4utumn07', tab: 'posts', post: null });
|
||||
});
|
||||
|
||||
it('reads a tab', () => {
|
||||
expect(parseRoute('/4utumn07/reels/').tab).toBe('reels');
|
||||
expect(parseRoute('/4utumn07/saved/').tab).toBe('saved');
|
||||
});
|
||||
|
||||
it('reads a post in Instagram form', () => {
|
||||
expect(parseRoute('/4utumn07/p/Db5tIoRCcvm/')).toEqual({
|
||||
archive: '4utumn07', 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=4utumn07&t=reels&p=ABC')).toEqual({
|
||||
archive: '4utumn07', 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: '4utumn07', tab: 'posts', post: null }, '/4utumn07/'],
|
||||
[{ archive: '4utumn07', tab: 'reels', post: null }, '/4utumn07/reels/'],
|
||||
[{ archive: '4utumn07', tab: 'posts', post: 'Db5tIoRCcvm' }, '/4utumn07/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: '4utumn07', tab: 'posts' as const, post: null },
|
||||
{ archive: '4utumn07', 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 - Sunstory/C5dQPEYpd9W'))).toBe('C5dQPEYpd9W');
|
||||
});
|
||||
|
||||
it('resolves a slug back to its post', () => {
|
||||
const posts = [post('AAA'), post('4utumn07 - reels/BBB', 'reels')];
|
||||
expect(findPostBySlug(posts, 'BBB')?.id).toBe('4utumn07 - 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Post, SourceKind } from '../types';
|
||||
|
||||
/**
|
||||
* Instagram-shaped paths.
|
||||
*
|
||||
* / the archive explorer
|
||||
* /<archive>/ a profile, posts tab
|
||||
* /<archive>/reels/ a profile, reels tab
|
||||
* /<archive>/saved/
|
||||
* /<archive>/p/<shortcode>/ 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<Post, 'id'>): 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=<archive>&t=<tab>&p=<post id>
|
||||
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}/`;
|
||||
};
|
||||
Reference in New Issue
Block a user