Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
106d3f6691 | ||
|
|
92a4ada3c2 | ||
|
|
c54f8d5b09 |
@@ -15,6 +15,8 @@ InstaArchive Viewer is a React 19 + Vite 6 PWA for browsing archived Instagram d
|
|||||||
- `npm run lint` — type-check only (`tsc --noEmit`)
|
- `npm run lint` — type-check only (`tsc --noEmit`)
|
||||||
- `npm test` / `npm run test:watch` — vitest
|
- `npm test` / `npm run test:watch` — vitest
|
||||||
- `npx vitest run src/lib/archive-patterns.test.ts` — a single test file
|
- `npx vitest run src/lib/archive-patterns.test.ts` — a single test file
|
||||||
|
- `npm run jd2 -- --archives <dir> --dry-run` — generate JDownloader `.crawljob`
|
||||||
|
files for every profile on disk (see `scripts/jd2-sync.ts`)
|
||||||
|
|
||||||
Local development usually needs both `npm run dev` and `npm run server`. Local-folder mode works without the backend; server-mode archives do not.
|
Local development usually needs both `npm run dev` and `npm run server`. Local-folder mode works without the backend; server-mode archives do not.
|
||||||
|
|
||||||
@@ -71,14 +73,21 @@ Restoring an archive **rehydrates URLs from `path`**: server archives rebuild HT
|
|||||||
|
|
||||||
Images over 1MiB are downscaled in a Web Worker via `OffscreenCanvas`. The queue is **serial on purpose** — decoding several 50MP+ images at once OOMs the tab. `requestThumbnail` must keep a stable identity (it reads cache state through a ref), or every completed thumbnail re-runs the effect in all mounted thumbnails.
|
Images over 1MiB are downscaled in a Web Worker via `OffscreenCanvas`. The queue is **serial on purpose** — decoding several 50MP+ images at once OOMs the tab. `requestThumbnail` must keep a stable identity (it reads cache state through a ref), or every completed thumbnail re-runs the effect in all mounted thumbnails.
|
||||||
|
|
||||||
### URL state (`src/App.tsx`)
|
### URL state (`src/App.tsx`, `src/lib/routing.ts`)
|
||||||
|
|
||||||
App state syncs to `?a=` / `?t=` / `?p=`. Two rules, both learned from real bugs:
|
Paths mirror Instagram: `/<archive>/`, `/<archive>/reels/`, `/<archive>/p/<shortcode>/`. The old `?a=&t=&p=` form is still parsed for existing links but never written. Reserved prefixes (`api`, `archives`, `assets`…) can't be mistaken for a profile name.
|
||||||
|
|
||||||
- The initial query string is captured into a ref on first render; the URL is rewritten from state as soon as anything loads, so reading `window.location` later sees the rewrite, not the user's link.
|
A post URL carries no tab, as on Instagram — the tab is re-derived from the post's `source`, so a reel link lands on the Reels tab and pages through reels. Sidecar posts keep directory-scoped ids internally but expose only the shortcode.
|
||||||
|
|
||||||
|
Three rules, all learned from real bugs:
|
||||||
|
|
||||||
|
- The initial route is captured into a ref on first render; the URL is rewritten from state as soon as anything loads, so reading `window.location` later sees the rewrite, not the user's link.
|
||||||
- URL writing is gated on `hasInitialLoaded`, otherwise it erases the deep link before the loader consumes it.
|
- URL writing is gated on `hasInitialLoaded`, otherwise it erases the deep link before the loader consumes it.
|
||||||
|
- Deep-link resolution waits on the archive fetch having *settled* (`archivesFetched`), not on `isServerMode`, which is still false while the request is in flight.
|
||||||
|
|
||||||
Deep-link resolution waits on the archive fetch having *settled*, not on `isServerMode` (which is still false while in flight).
|
### Mobile feed (`src/components/PostFeed.tsx`)
|
||||||
|
|
||||||
|
Below `md`, opening a post renders a scrolling feed page rather than the modal (`useIsMobile` decides). Only a window of posts is mounted; it grows both ways, and prepending corrects `scrollTop` in a `useLayoutEffect` so content doesn't jump. Only the post crossing the viewport centre plays its video and drives the URL. Desktop keeps `PostModal`; both share `MediaCarousel`.
|
||||||
|
|
||||||
### Backend (`server.ts`)
|
### Backend (`server.ts`)
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.4.0",
|
"version": "1.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.4.0",
|
"version": "1.5.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.0",
|
"version": "1.5.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"jd2": "tsx scripts/jd2-sync.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
|||||||
+18
@@ -38,6 +38,8 @@ import { CacheData, Post, ServerArchive, ServerArchiveFile } from './types';
|
|||||||
import { ArchiveDashboard } from './components/ArchiveDashboard';
|
import { ArchiveDashboard } from './components/ArchiveDashboard';
|
||||||
import { StoryViewer } from './components/StoryViewer';
|
import { StoryViewer } from './components/StoryViewer';
|
||||||
import { PostModal } from './components/PostModal';
|
import { PostModal } from './components/PostModal';
|
||||||
|
import { PostFeed } from './components/PostFeed';
|
||||||
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
import { PostThumbnail } from './components/PostThumbnail';
|
import { PostThumbnail } from './components/PostThumbnail';
|
||||||
import { useArchiveScanner } from './hooks/useArchiveScanner';
|
import { useArchiveScanner } from './hooks/useArchiveScanner';
|
||||||
import { useThumbnailQueue } from './hooks/useThumbnailQueue';
|
import { useThumbnailQueue } from './hooks/useThumbnailQueue';
|
||||||
@@ -70,6 +72,7 @@ export default function App() {
|
|||||||
*/
|
*/
|
||||||
const initialRouteRef = useRef(parseRoute(window.location.pathname, window.location.search));
|
const initialRouteRef = useRef(parseRoute(window.location.pathname, window.location.search));
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const profilePicInputRef = useRef<HTMLInputElement>(null);
|
const profilePicInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -549,6 +552,20 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</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>
|
<AnimatePresence>
|
||||||
{selectedPost && (
|
{selectedPost && (
|
||||||
<PostModal
|
<PostModal
|
||||||
@@ -564,6 +581,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
<AnimatePresence>{showStoryViewer && allStories.length > 0 && <StoryViewer stories={allStories} onClose={() => setShowStoryViewer(false)} profilePic={profilePic} />}</AnimatePresence>
|
<AnimatePresence>{showStoryViewer && allStories.length > 0 && <StoryViewer stories={allStories} onClose={() => setShowStoryViewer(false)} profilePic={profilePic} />}</AnimatePresence>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{activeHighlight && (
|
{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 { MediaFile } from '../types';
|
||||||
import { cn } from '../lib/utils';
|
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
|
// 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
|
// generally allow it. If this particular browser still refuses, the effect
|
||||||
// below falls back to muted playback rather than leaving a stalled video.
|
// 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;
|
const video = videoRef.current;
|
||||||
if (!video || file.type !== 'video') return;
|
if (!video || file.type !== 'video') return;
|
||||||
|
|
||||||
|
if (paused) {
|
||||||
|
video.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
video.muted = false;
|
video.muted = false;
|
||||||
video.play().catch(() => {
|
video.play().catch(() => {
|
||||||
@@ -24,7 +47,7 @@ export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [file.url, file.type]);
|
}, [file.url, file.type, paused]);
|
||||||
/**
|
/**
|
||||||
* In full view the media must never outgrow the viewport.
|
* 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
|
* 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.
|
* side); mobile leaves room for the caption panel stacked underneath.
|
||||||
*/
|
*/
|
||||||
const fullViewCap = "max-h-[70vh] md:max-h-[calc(100vh-5rem)] object-contain";
|
const fullViewCap = `${heightCap ?? '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 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 imageSizing = isFullView ? `block w-full h-auto ${fullViewCap}` : "w-full h-full object-cover";
|
||||||
const sizingClass = file.type === 'video' ? videoSizing : imageSizing;
|
const sizingClass = file.type === 'video' ? videoSizing : imageSizing;
|
||||||
const mediaStyle = { transform: 'translateZ(0)' };
|
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;
|
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
|
* Desktop-only surface: mobile opens PostFeed instead, so the only vertical
|
||||||
* belongs to the carousel and only the carousel, so reaching the last slide
|
* gesture left here is drag-to-dismiss.
|
||||||
* 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 handleVerticalDragEnd = (offset: { y: number }, velocity: { y: number }) => {
|
||||||
const power = swipePower(offset.y, velocity.y);
|
|
||||||
|
|
||||||
if (!isMobile) {
|
|
||||||
if (offset.y > 200 || velocity.y > 800) onClose();
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isSystemDirectory } from './archive-index';
|
||||||
|
|
||||||
|
describe('isSystemDirectory', () => {
|
||||||
|
it.each([
|
||||||
|
['@eaDir', 'Synology thumbnail/index metadata, written inside every folder'],
|
||||||
|
['@tmp', 'Synology scratch'],
|
||||||
|
['.sync', 'Resilio state'],
|
||||||
|
['.DS_Store', 'macOS'],
|
||||||
|
['#recycle', 'Synology deletions'],
|
||||||
|
['#snapshot', 'Synology snapshots'],
|
||||||
|
])('skips %s (%s)', name => {
|
||||||
|
expect(isSystemDirectory(name)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'4utumn07',
|
||||||
|
'4utumn07 - reels',
|
||||||
|
'story - dawn_petal',
|
||||||
|
'story highlights - official_band - A.B.C',
|
||||||
|
'story highlights - theoldlyricmuseinsta - 💙1999-2005 era',
|
||||||
|
'Heejin_Bubble heejinmedia',
|
||||||
|
'gallery-dl',
|
||||||
|
'posts',
|
||||||
|
])('keeps %s', name => {
|
||||||
|
expect(isSystemDirectory(name)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a leading underscore as a system directory', () => {
|
||||||
|
// `_gemini-plans` is filtered separately at the archive root only; nothing
|
||||||
|
// below the root should be excluded just for starting with an underscore.
|
||||||
|
expect(isSystemDirectory('_gemini-plans')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,21 @@ interface DirIndex {
|
|||||||
const MEDIA_RE = /\.(jpg|jpeg|png|webp|gif|bmp|tiff|mp4|webm|ogv|mov)$/i;
|
const MEDIA_RE = /\.(jpg|jpeg|png|webp|gif|bmp|tiff|mp4|webm|ogv|mov)$/i;
|
||||||
const STAT_CONCURRENCY = 16;
|
const STAT_CONCURRENCY = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directories the walk must never descend into.
|
||||||
|
*
|
||||||
|
* NAS filesystems scatter sidecar metadata *inside* every folder, not just at
|
||||||
|
* the share root: Synology writes `@eaDir` (thumbnails and indexing data),
|
||||||
|
* `#recycle` holds deletions, and `.sync` is Resilio's state. Indexing those
|
||||||
|
* would count NAS thumbnails as archive media and spend a stat on each one —
|
||||||
|
* measured on a real share, `@eaDir` accounted for 12,516 of 123,023 files.
|
||||||
|
*
|
||||||
|
* The archive root is already filtered by prefix; this is the same rule applied
|
||||||
|
* at every level below it.
|
||||||
|
*/
|
||||||
|
export const isSystemDirectory = (name: string): boolean =>
|
||||||
|
name.startsWith('@') || name.startsWith('.') || name === '#recycle' || name === '#snapshot';
|
||||||
|
|
||||||
export class ArchiveIndex {
|
export class ArchiveIndex {
|
||||||
private dirs = new Map<string, DirIndex>();
|
private dirs = new Map<string, DirIndex>();
|
||||||
private inFlight = new Map<string, Promise<DirIndex>>();
|
private inFlight = new Map<string, Promise<DirIndex>>();
|
||||||
@@ -43,7 +58,7 @@ export class ArchiveIndex {
|
|||||||
/** Visible (non-system) directories at the archive root. */
|
/** Visible (non-system) directories at the archive root. */
|
||||||
private listRootDirs(): string[] {
|
private listRootDirs(): string[] {
|
||||||
return fs.readdirSync(this.archivesDir, { withFileTypes: true })
|
return fs.readdirSync(this.archivesDir, { withFileTypes: true })
|
||||||
.filter(e => e.isDirectory() && !/^[.@_]/.test(e.name))
|
.filter(e => e.isDirectory() && !isSystemDirectory(e.name) && !e.name.startsWith('_'))
|
||||||
.map(e => e.name);
|
.map(e => e.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +84,7 @@ export class ArchiveIndex {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
if (isSystemDirectory(entry.name)) continue;
|
||||||
const rel = base ? `${base}/${entry.name}` : entry.name;
|
const rel = base ? `${base}/${entry.name}` : entry.name;
|
||||||
if (entry.isDirectory()) out = out.concat(this.walk(path.join(absDir, entry.name), rel));
|
if (entry.isDirectory()) out = out.concat(this.walk(path.join(absDir, entry.name), rel));
|
||||||
else if (entry.isFile()) out.push(rel);
|
else if (entry.isFile()) out.push(rel);
|
||||||
|
|||||||
Reference in New Issue
Block a user