Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57be521a2 | ||
|
|
792b834cbe | ||
|
|
106d3f6691 | ||
|
|
92a4ada3c2 | ||
|
|
c54f8d5b09 |
@@ -15,6 +15,9 @@ 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` and
|
||||||
|
`docs/jdownloader.md`)
|
||||||
|
|
||||||
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 +74,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.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"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.6.0",
|
||||||
"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",
|
||||||
|
|||||||
+76
-23
@@ -17,7 +17,7 @@ import {
|
|||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
import { PRESS } from './lib/motion';
|
import { PRESS, prefersReducedMotion } from './lib/motion';
|
||||||
import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing';
|
import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing';
|
||||||
import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files';
|
import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files';
|
||||||
import {
|
import {
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -108,6 +111,30 @@ export default function App() {
|
|||||||
|
|
||||||
const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState<string | null>(null);
|
const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blurred backdrops behind the scanning UI, newest last.
|
||||||
|
*
|
||||||
|
* Each new image is stacked *over* the previous one and fades in; the one
|
||||||
|
* underneath stays fully opaque until it's covered. Cross-fading by swapping
|
||||||
|
* a single element left the pale backdrop showing through mid-transition,
|
||||||
|
* which read as a white flash between every image.
|
||||||
|
*/
|
||||||
|
const [scanBackdrops, setScanBackdrops] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastLoadedScanningImage) return;
|
||||||
|
setScanBackdrops(prev =>
|
||||||
|
prev[prev.length - 1] === lastLoadedScanningImage
|
||||||
|
? prev
|
||||||
|
: [...prev, lastLoadedScanningImage].slice(-3),
|
||||||
|
);
|
||||||
|
}, [lastLoadedScanningImage]);
|
||||||
|
|
||||||
|
// Don't carry one archive's backdrops into the next scan.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isScanning) { setScanBackdrops([]); setLastLoadedScanningImage(null); }
|
||||||
|
}, [isScanning]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
username,
|
username,
|
||||||
fullName,
|
fullName,
|
||||||
@@ -457,17 +484,28 @@ export default function App() {
|
|||||||
onLoad={() => setLastLoadedScanningImage(currentScanningImage)}
|
onLoad={() => setLastLoadedScanningImage(currentScanningImage)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 z-0">
|
{/*
|
||||||
<AnimatePresence initial={false}>
|
The 0.4 lives on the group, not the images: two layers overlap
|
||||||
|
during a cross-fade, and fading them individually would darken the
|
||||||
|
backdrop as they cross. Inside the group each layer goes to full
|
||||||
|
opacity, so the stack is always completely covered.
|
||||||
|
*/}
|
||||||
|
<div className="absolute inset-0 z-0 opacity-40">
|
||||||
|
{scanBackdrops.map(src => (
|
||||||
<motion.img
|
<motion.img
|
||||||
key={lastLoadedScanningImage}
|
key={src}
|
||||||
src={lastLoadedScanningImage || undefined}
|
src={src}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 0.4 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 1.5 }}
|
transition={prefersReducedMotion() ? { duration: 0 } : { duration: 0.9, ease: 'easeInOut' }}
|
||||||
|
onAnimationComplete={() => setScanBackdrops(prev => {
|
||||||
|
// Once this layer is opaque it hides everything below it.
|
||||||
|
const i = prev.indexOf(src);
|
||||||
|
return i > 0 ? prev.slice(i) : prev;
|
||||||
|
})}
|
||||||
className="absolute inset-0 w-full h-full object-cover blur-[60px] scale-110"
|
className="absolute inset-0 w-full h-full object-cover blur-[60px] scale-110"
|
||||||
/>
|
/>
|
||||||
</AnimatePresence>
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 bg-white/40 z-1" />
|
<div className="absolute inset-0 bg-white/40 z-1" />
|
||||||
<div className="relative z-10 w-full max-w-4xl px-4 flex flex-col items-center gap-8 text-black">
|
<div className="relative z-10 w-full max-w-4xl px-4 flex flex-col items-center gap-8 text-black">
|
||||||
@@ -549,21 +587,36 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AnimatePresence>
|
{/*
|
||||||
{selectedPost && (
|
Mobile opens a real scrolling feed page, the way Instagram does; desktop
|
||||||
<PostModal
|
keeps the modal, where a centred sheet with side arrows fits the pointer.
|
||||||
post={selectedPost}
|
*/}
|
||||||
nextPost={postIndex < filteredPosts.length - 1 ? filteredPosts[postIndex + 1] : undefined}
|
{selectedPost && isMobile ? (
|
||||||
prevPost={postIndex > 0 ? filteredPosts[postIndex - 1] : undefined}
|
<PostFeed
|
||||||
onClose={() => setSelectedPost(null)}
|
posts={filteredPosts}
|
||||||
onNextPost={onNextPost}
|
initialPostId={selectedPost.id}
|
||||||
onPrevPost={onPrevPost}
|
profilePic={profilePic}
|
||||||
hasNextPost={postIndex < filteredPosts.length - 1}
|
title={activeTab === 'reels' ? 'Reels' : 'Posts'}
|
||||||
hasPrevPost={postIndex > 0}
|
onClose={() => setSelectedPost(null)}
|
||||||
profilePic={profilePic}
|
onActivePostChange={setSelectedPost}
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
</AnimatePresence>
|
<AnimatePresence>
|
||||||
|
{selectedPost && (
|
||||||
|
<PostModal
|
||||||
|
post={selectedPost}
|
||||||
|
nextPost={postIndex < filteredPosts.length - 1 ? filteredPosts[postIndex + 1] : undefined}
|
||||||
|
prevPost={postIndex > 0 ? filteredPosts[postIndex - 1] : undefined}
|
||||||
|
onClose={() => setSelectedPost(null)}
|
||||||
|
onNextPost={onNextPost}
|
||||||
|
onPrevPost={onPrevPost}
|
||||||
|
hasNextPost={postIndex < filteredPosts.length - 1}
|
||||||
|
hasPrevPost={postIndex > 0}
|
||||||
|
profilePic={profilePic}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -82,11 +82,14 @@ export const PostModal: React.FC<PostModalProps> = ({
|
|||||||
|
|
||||||
useEffect(() => setCurrentIndex(0), [post.id]);
|
useEffect(() => setCurrentIndex(0), [post.id]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Arrows page within the carousel — the thing the arrows visually point at.
|
||||||
|
// Moving between posts stays on the side buttons, with , and . as keyboard
|
||||||
|
// equivalents.
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'ArrowRight') goToPost(1, 'x');
|
if (e.key === 'ArrowRight') paginate(1);
|
||||||
else if (e.key === 'ArrowLeft') goToPost(-1, 'x');
|
else if (e.key === 'ArrowLeft') paginate(-1);
|
||||||
else if (e.key === '.') paginate(1);
|
else if (e.key === '.') goToPost(1, 'x');
|
||||||
else if (e.key === ',') paginate(-1);
|
else if (e.key === ',') goToPost(-1, 'x');
|
||||||
else if (e.key === 'Escape') onClose();
|
else if (e.key === 'Escape') onClose();
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
@@ -126,44 +129,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 (offset.y > 200 || velocity.y > 800) onClose();
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -107,11 +107,23 @@ export const useArchiveScanner = (
|
|||||||
/** Stable identity for a media file, used to rehydrate URLs after a reload. */
|
/** Stable identity for a media file, used to rehydrate URLs after a reload. */
|
||||||
const mediaPath = (file: ArchiveFile) => file.webkitRelativePath || file.name;
|
const mediaPath = (file: ArchiveFile) => file.webkitRelativePath || file.name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompress an `.xz` metadata sidecar.
|
||||||
|
*
|
||||||
|
* Read fully into memory first rather than handing the live HTTP body to
|
||||||
|
* the decompressor. These sidecars are a few KB, so buffering costs
|
||||||
|
* nothing, and streaming was actively harmful: the decompressor stops
|
||||||
|
* reading at the end of the xz member, leaving the response body neither
|
||||||
|
* drained nor cancelled. Across a couple of hundred sidecars that exhausts
|
||||||
|
* the connection pool and every later fetch fails with "Failed to fetch" —
|
||||||
|
* which silently cost Instaloader archives their captions, story flags and
|
||||||
|
* profile metadata, since all of it lives in these files.
|
||||||
|
*/
|
||||||
const parseXZFile = async (file: ArchiveFile) => {
|
const parseXZFile = async (file: ArchiveFile) => {
|
||||||
try {
|
try {
|
||||||
const stream = new XzReadableStream(file.stream());
|
const compressed = await file.arrayBuffer();
|
||||||
const response = new Response(stream);
|
const stream = new XzReadableStream(new Blob([compressed]).stream());
|
||||||
return await response.json();
|
return await new Response(stream).json();
|
||||||
} catch (e) { console.error(`[Scanner] XZ Parse Error:`, file.name, e); return null; }
|
} catch (e) { console.error(`[Scanner] XZ Parse Error:`, file.name, e); return null; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -14,7 +14,6 @@ export class LocalArchiveFile implements ArchiveFile {
|
|||||||
get size() { return this.file.size; }
|
get size() { return this.file.size; }
|
||||||
text() { return this.file.text(); }
|
text() { return this.file.text(); }
|
||||||
arrayBuffer() { return this.file.arrayBuffer(); }
|
arrayBuffer() { return this.file.arrayBuffer(); }
|
||||||
stream() { return this.file.stream(); }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A blob: URL backed directly by the on-disk File.
|
* A blob: URL backed directly by the on-disk File.
|
||||||
@@ -55,15 +54,6 @@ export class RemoteArchiveFile implements ArchiveFile {
|
|||||||
const res = await fetch(this.url);
|
const res = await fetch(this.url);
|
||||||
return res.arrayBuffer();
|
return res.arrayBuffer();
|
||||||
}
|
}
|
||||||
stream() {
|
|
||||||
const transform = new TransformStream();
|
|
||||||
fetch(this.url).then(res => {
|
|
||||||
if (res.body) res.body.pipeTo(transform.writable);
|
|
||||||
else transform.writable.getWriter().close();
|
|
||||||
});
|
|
||||||
return transform.readable;
|
|
||||||
}
|
|
||||||
|
|
||||||
createObjectUrl() {
|
createObjectUrl() {
|
||||||
return this.url;
|
return this.url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export interface ArchiveFile {
|
|||||||
size: number;
|
size: number;
|
||||||
text(): Promise<string>;
|
text(): Promise<string>;
|
||||||
arrayBuffer(): Promise<ArrayBuffer>;
|
arrayBuffer(): Promise<ArrayBuffer>;
|
||||||
stream(): ReadableStream<Uint8Array>;
|
|
||||||
url?: string;
|
url?: string;
|
||||||
/**
|
/**
|
||||||
* A URL pointing at this file's contents. Local files mint a disk-backed
|
* A URL pointing at this file's contents. Local files mint a disk-backed
|
||||||
|
|||||||
Reference in New Issue
Block a user