Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b053b4b2e | ||
|
|
d1fa1a8d2f | ||
|
|
b9ece021d4 | ||
|
|
f300b8d9f5 | ||
|
|
e57be521a2 | ||
|
|
792b834cbe | ||
|
|
106d3f6691 | ||
|
|
92a4ada3c2 | ||
|
|
c54f8d5b09 | ||
|
|
877d21ff1f | ||
|
|
0cff91edae |
@@ -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,25 +74,74 @@ 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`)
|
### Profile tabs (`src/lib/post-tabs.ts`)
|
||||||
|
|
||||||
App state syncs to `?a=` / `?t=` / `?p=`. Two rules, both learned from real bugs:
|
The grid holds **everything**, reels included, and the Reels tab is a *filtered view* of that same set — as on Instagram. Only the Reels tab filters. The tabs were mutually exclusive until v1.7.0, which hid a lot: 1100 of `groupfandom`'s 3813 posts and 533 of `for.member`'s 1225 never appeared in the grid at all.
|
||||||
|
|
||||||
- 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.
|
Deciding *what is a reel* has no good answer for most archives. Instagram's own marker is `product_type` on the post's GraphQL node (`clips` = reel, `feed` = ordinary feed video, `igtv`, `story`) — `__typename` is `GraphVideo` for all three, and aspect ratio does not separate them either. But:
|
||||||
|
|
||||||
|
- Only Instaloader archives carry that metadata, and only newer captures. A survey of `hazelofficial` found `product_type` on 1101 of 5919 sidecars, and just **2** posts marked `clips`.
|
||||||
|
- JDownloader archives carry none at all — media plus a `.txt` holding the bare caption.
|
||||||
|
|
||||||
|
So the viewer believes a `- reels` sidecar directory when one exists, and otherwise falls back to treating a lone video as a reel. **The fallback is a guess**: it cannot tell a reel from a feed video or an old IGTV upload, and it misses videos inside carousels.
|
||||||
|
|
||||||
|
`dedupePostCopies` exists because the JDownloader flow crawls the profile URL and the `/reels/` URL separately (the profile page misses some reels), so the two overlap and a reel can land on disk twice. Those become two posts with distinct directory-scoped ids, which the grid would otherwise render side by side. It dedupes by shortcode, preferring the reels-source copy. It is only safe over `allPosts` — stories and highlights are excluded there, and a shortcode may legitimately appear in both a profile and a highlight.
|
||||||
|
|
||||||
|
### URL state (`src/App.tsx`, `src/lib/routing.ts`)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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`)
|
||||||
|
|
||||||
Serves `/api/archives`, `/api/archives/:name/files`, static `/archives`, and the built SPA. Notes:
|
Serves `/api/archives`, `/api/archives/:name/files`, static `/archives`, and the built SPA. Notes:
|
||||||
|
|
||||||
- Express decodes route params **after** segment matching, so `..%2f` reaches the handler as `../`. All user-supplied archive names go through `resolveArchivePath`.
|
- Express decodes route params **after** segment matching, so `..%2f` reaches the handler as `../`. All user-supplied archive names go through `resolveArchivePath`.
|
||||||
- Sets CSP and related security headers. The CSP allows `blob:`/`data:` for media and `unsafe-inline` styles (the animation library sets inline styles); scripts stay same-origin only.
|
|
||||||
- `os.userInfo()` throws for a UID with no `/etc/passwd` entry, which is what `--user 1234:1234` produces — use `describeUser()`.
|
- `os.userInfo()` throws for a UID with no `/etc/passwd` entry, which is what `--user 1234:1234` produces — use `describeUser()`.
|
||||||
|
|
||||||
|
### CSP: do not tighten `script-src` or `connect-src` without testing xz
|
||||||
|
|
||||||
|
The xz decompressor for Instaloader `.json.xz` sidecars is **WebAssembly**, embedded as a `data:` URL the library fetches at startup. The policy must keep:
|
||||||
|
|
||||||
|
```
|
||||||
|
script-src 'self' 'wasm-unsafe-eval' // compile wasm, without allowing eval() of JS
|
||||||
|
connect-src 'self' data: // fetch the embedded module
|
||||||
|
```
|
||||||
|
|
||||||
|
Removing either breaks decoding with a bare `TypeError: Failed to fetch` **and no stack** — it surfaces through `new Response(stream).json()`, so it reads like a network fault rather than a policy block. The visible symptom is not an error page: archives silently lose captions, story flags and all profile metadata (follower counts, bio, name). This shipped broken for several releases.
|
||||||
|
|
||||||
|
To check quickly, run in the page console:
|
||||||
|
|
||||||
|
```js
|
||||||
|
await fetch('data:application/wasm;base64,AGFzbQEAAAA=') // connect-src
|
||||||
|
await WebAssembly.instantiate(Uint8Array.of(0,97,115,109,1,0,0,0)) // script-src
|
||||||
|
```
|
||||||
|
|
||||||
|
**The Vite dev server does not send these headers**, so anything CSP-related is invisible in `npm run dev`. Verify security-header and PWA behaviour by building and serving `dist/` through `server.js`, not against the dev server.
|
||||||
|
|
||||||
|
### PWA: server-only changes do not reach installed clients
|
||||||
|
|
||||||
|
The service worker precaches `index.html` **together with its response headers**. A change that touches only the server (a CSP fix, a new header) leaves the client build byte-identical, so the precache manifest and `sw.js` are unchanged, the worker never updates, and installed clients keep replaying the old shell with the old headers — indefinitely.
|
||||||
|
|
||||||
|
`vite.config.ts` therefore compiles the package version into the client via `define: { __APP_VERSION__ }`, and `App.tsx` renders it in the footer. That is **load-bearing**: it makes every release change the bundle hash → `index.html` → its precache revision → `sw.js`, which is what browsers byte-compare to decide whether to update. Don't remove it as dead weight.
|
||||||
|
|
||||||
|
To recover a client stuck on an old shell: unregister the service worker, delete its caches, reload.
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
|
Live archives live in `<share>/Instagram-archive/archives/` — one directory per profile plus sidecars. Directories that are not Instagram profiles (tool output, exports from other services) sit *outside* that folder so they never reach the viewer.
|
||||||
|
|
||||||
Container runs as non-root. The image defaults to `node`, but the archive share must be *listable* by that UID — a mode-711 share owned by another account needs `user: "<uid>:<gid>"` in compose. Mount a volume at `/cache` so the index survives restarts.
|
Container runs as non-root. The image defaults to `node`, but the archive share must be *listable* by that UID — a mode-711 share owned by another account needs `user: "<uid>:<gid>"` in compose. Mount a volume at `/cache` so the index survives restarts.
|
||||||
|
|
||||||
### PWA / build quirks
|
### PWA / build quirks
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.3.2",
|
"version": "1.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.3.2",
|
"version": "1.7.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.3.2",
|
"version": "1.7.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",
|
||||||
|
|||||||
@@ -80,10 +80,16 @@ app.use((req, res, next) => {
|
|||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"img-src 'self' blob: data:",
|
"img-src 'self' blob: data:",
|
||||||
"media-src 'self' blob: data:",
|
"media-src 'self' blob: data:",
|
||||||
"script-src 'self'",
|
// 'wasm-unsafe-eval' permits WebAssembly compilation without allowing
|
||||||
|
// eval() of JavaScript. The xz decompressor used for Instaloader's
|
||||||
|
// .json.xz sidecars is WebAssembly, embedded as a data: URL it fetches at
|
||||||
|
// startup — so connect-src must allow data: too. Without both, decoding
|
||||||
|
// fails with a bare "TypeError: Failed to fetch" and every archive silently
|
||||||
|
// loses its captions, story flags and profile metadata.
|
||||||
|
"script-src 'self' 'wasm-unsafe-eval'",
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
"connect-src 'self'",
|
"connect-src 'self' data:",
|
||||||
"worker-src 'self' blob:",
|
"worker-src 'self' blob:",
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
|
|||||||
+99
-59
@@ -17,6 +17,9 @@ 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, prefersReducedMotion } from './lib/motion';
|
||||||
|
import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing';
|
||||||
|
import { postsForTab } from './lib/post-tabs';
|
||||||
import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files';
|
import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files';
|
||||||
import {
|
import {
|
||||||
deleteCachedArchive,
|
deleteCachedArchive,
|
||||||
@@ -36,6 +39,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';
|
||||||
@@ -60,14 +65,15 @@ export default function App() {
|
|||||||
const [hasInitialLoaded, setHasInitialLoaded] = useState(false);
|
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
|
* Captured during the first render because the URL is rewritten from app
|
||||||
* state as soon as anything loads; reading `window.location` later would see
|
* state as soon as anything loads; reading `window.location` later would see
|
||||||
* the rewritten value rather than the link the user actually followed.
|
* 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 isMobile = useIsMobile();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const profilePicInputRef = useRef<HTMLInputElement>(null);
|
const profilePicInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -106,6 +112,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,
|
||||||
@@ -143,20 +173,11 @@ export default function App() {
|
|||||||
const clearCache = async (name: string) => { await deleteCachedArchive(name); await refreshCachedArchives(); };
|
const clearCache = async (name: string) => { await deleteCachedArchive(name); await refreshCachedArchives(); };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archives with a `- reels` sidecar directory say outright which posts are
|
* The grid shows everything, reels included, and the Reels tab is a filtered
|
||||||
* reels; only fall back to the "lone video" heuristic for archives that have
|
* view of the same set — see src/lib/post-tabs.ts for the reel test and for
|
||||||
* no such directory.
|
* why a reel can arrive on disk twice.
|
||||||
*/
|
*/
|
||||||
const hasReelSource = useMemo(() => allPosts.some(p => p.source === 'reels'), [allPosts]);
|
const filteredPosts = useMemo(() => postsForTab(allPosts, activeTab), [allPosts, activeTab]);
|
||||||
const isReel = useCallback((p: Post) => (
|
|
||||||
hasReelSource ? p.source === 'reels' : p.media.length === 1 && p.media[0].type === 'video'
|
|
||||||
), [hasReelSource]);
|
|
||||||
|
|
||||||
const filteredPosts = useMemo(() => {
|
|
||||||
if (activeTab === 'reels') return allPosts.filter(isReel);
|
|
||||||
if (activeTab === 'posts') return allPosts.filter(p => !isReel(p));
|
|
||||||
return [];
|
|
||||||
}, [allPosts, activeTab, isReel]);
|
|
||||||
|
|
||||||
/** Story highlights, grouped into the circles shown under the bio. */
|
/** Story highlights, grouped into the circles shown under the bio. */
|
||||||
const highlightGroups = useMemo(() => {
|
const highlightGroups = useMemo(() => {
|
||||||
@@ -339,41 +360,28 @@ export default function App() {
|
|||||||
// loader below is waiting to read.
|
// loader below is waiting to read.
|
||||||
if (!hasInitialLoaded) return;
|
if (!hasInitialLoaded) return;
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const archive = currentArchive?.name ?? (allPosts.length > 0 ? username : null) ?? null;
|
||||||
if (currentArchive) params.set('a', currentArchive.name);
|
const nextPath = buildPath({
|
||||||
else if (allPosts.length > 0 && username) params.set('a', username);
|
archive,
|
||||||
else params.delete('a');
|
tab: activeTab,
|
||||||
|
post: selectedPost ? postSlug(selectedPost) : null,
|
||||||
|
});
|
||||||
|
|
||||||
if (activeTab !== 'posts') params.set('t', activeTab);
|
if (nextPath !== window.location.pathname + window.location.search) {
|
||||||
else params.delete('t');
|
console.log(`[Permalink] Updating URL to: ${nextPath}`);
|
||||||
|
window.history.replaceState(null, '', nextPath);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}, [hasInitialLoaded, currentArchive?.name, username, allPosts.length, activeTab, selectedPost?.id]);
|
}, [hasInitialLoaded, currentArchive?.name, username, allPosts.length, activeTab, selectedPost?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasInitialLoaded) return;
|
if (hasInitialLoaded) return;
|
||||||
|
|
||||||
const params = initialParamsRef.current;
|
const route = initialRouteRef.current;
|
||||||
const archiveName = params.get('a');
|
console.log('[Permalink] Initial route:', route);
|
||||||
const tab = params.get('t');
|
|
||||||
console.log('[Permalink] Initial read from URL:', {
|
|
||||||
archiveName, tab, postId: params.get('p'),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tab && ['posts', 'reels', 'saved'].includes(tab)) {
|
if (route.tab !== 'posts') setActiveTab(route.tab);
|
||||||
setActiveTab(tab as 'posts' | 'reels' | 'saved');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!archiveName) {
|
if (!route.archive) {
|
||||||
setHasInitialLoaded(true);
|
setHasInitialLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -381,12 +389,12 @@ export default function App() {
|
|||||||
// Wait for the archive list before deciding the link is unresolvable.
|
// Wait for the archive list before deciding the link is unresolvable.
|
||||||
if (!archivesFetched) return;
|
if (!archivesFetched) return;
|
||||||
|
|
||||||
const archive = serverArchives.find(a => a.name === archiveName);
|
const archive = serverArchives.find(a => a.name === route.archive);
|
||||||
if (archive) {
|
if (archive) {
|
||||||
console.log(`[Permalink] Auto-loading archive: ?a=${archiveName}`);
|
console.log(`[Permalink] Auto-loading archive: ${route.archive}`);
|
||||||
loadServerArchive(archive);
|
loadServerArchive(archive);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[Permalink] No archive named "${archiveName}".`);
|
console.warn(`[Permalink] No archive named "${route.archive}".`);
|
||||||
}
|
}
|
||||||
setHasInitialLoaded(true);
|
setHasInitialLoaded(true);
|
||||||
}, [serverArchives, archivesFetched, hasInitialLoaded, loadServerArchive]);
|
}, [serverArchives, archivesFetched, hasInitialLoaded, loadServerArchive]);
|
||||||
@@ -406,10 +414,14 @@ export default function App() {
|
|||||||
if (appliedPostParamRef.current === archiveKey) return;
|
if (appliedPostParamRef.current === archiveKey) return;
|
||||||
appliedPostParamRef.current = archiveKey;
|
appliedPostParamRef.current = archiveKey;
|
||||||
|
|
||||||
const postId = initialParamsRef.current.get('p');
|
const slug = initialRouteRef.current.post;
|
||||||
if (!postId) return;
|
if (!slug) return;
|
||||||
const post = allPosts.find(p => p.id === postId);
|
const post = findPostBySlug(allPosts, slug);
|
||||||
if (post) setSelectedPost(post);
|
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]);
|
}, [allPosts, currentArchive?.name, username]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -464,17 +476,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">
|
||||||
@@ -504,9 +527,11 @@ export default function App() {
|
|||||||
{highlightGroups.length > 0 && (
|
{highlightGroups.length > 0 && (
|
||||||
<div className="flex gap-6 md:gap-8 overflow-x-auto scrollbar-hide px-4 pb-2">
|
<div className="flex gap-6 md:gap-8 overflow-x-auto scrollbar-hide px-4 pb-2">
|
||||||
{highlightGroups.map(group => (
|
{highlightGroups.map(group => (
|
||||||
<button
|
<motion.button
|
||||||
key={group.title}
|
key={group.title}
|
||||||
onClick={() => setActiveHighlight(group.title)}
|
onClick={() => setActiveHighlight(group.title)}
|
||||||
|
whileTap={{ scale: 0.94 }}
|
||||||
|
transition={PRESS}
|
||||||
className="flex flex-col items-center gap-2 shrink-0 group/hl"
|
className="flex flex-col items-center gap-2 shrink-0 group/hl"
|
||||||
title={`${group.title} — ${group.items.length} item${group.items.length === 1 ? '' : 's'}`}
|
title={`${group.title} — ${group.items.length} item${group.items.length === 1 ? '' : 's'}`}
|
||||||
>
|
>
|
||||||
@@ -522,7 +547,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] max-w-[80px] truncate text-gray-700">{group.title}</span>
|
<span className="text-[11px] max-w-[80px] truncate text-gray-700">{group.title}</span>
|
||||||
</button>
|
</motion.button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -538,7 +563,7 @@ export default function App() {
|
|||||||
<div className="grid grid-cols-3 gap-[2px] md:gap-[2px] text-black">
|
<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>))}
|
{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) => (
|
{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
|
<PostThumbnail
|
||||||
post={post}
|
post={post}
|
||||||
thumbnailUrl={cacheHits.get(post.id)}
|
thumbnailUrl={cacheHits.get(post.id)}
|
||||||
@@ -554,6 +579,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
|
||||||
@@ -569,6 +608,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 && (
|
||||||
@@ -584,7 +624,7 @@ export default function App() {
|
|||||||
{!isScanning && (
|
{!isScanning && (
|
||||||
<footer className="max-w-5xl mx-auto px-4 py-12 text-center text-xs text-gray-400 space-y-4 text-black">
|
<footer className="max-w-5xl mx-auto px-4 py-12 text-center text-xs text-gray-400 space-y-4 text-black">
|
||||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2 uppercase tracking-tight text-black"><span>Meta</span><span>About</span><span>Blog</span><span>Jobs</span><span>Help</span><span>API</span><span>Privacy</span><span>Terms</span><span>Locations</span><span>Instagram Lite</span><span>Threads</span><span>Contact Uploading & Non-Users</span><span>Meta Verified</span></div>
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2 uppercase tracking-tight text-black"><span>Meta</span><span>About</span><span>Blog</span><span>Jobs</span><span>Help</span><span>API</span><span>Privacy</span><span>Terms</span><span>Locations</span><span>Instagram Lite</span><span>Threads</span><span>Contact Uploading & Non-Users</span><span>Meta Verified</span></div>
|
||||||
<div className="text-black/40 text-black">© 2026 InstaArchive Viewer</div>
|
<div className="text-black/40 text-black">© 2026 InstaArchive Viewer · v{__APP_VERSION__}</div>
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { Post } from '../types';
|
import { Post } from '../types';
|
||||||
import { cn, formatDateSafe } from '../lib/utils';
|
import { cn, formatDateSafe } from '../lib/utils';
|
||||||
|
import { FADE, NAVIGATE, PRESENT, prefersReducedMotion, withVelocity } from '../lib/motion';
|
||||||
import { MediaRenderer } from './MediaRenderer';
|
import { MediaRenderer } from './MediaRenderer';
|
||||||
|
|
||||||
interface PostModalProps {
|
interface PostModalProps {
|
||||||
@@ -30,7 +31,14 @@ export const PostModal: React.FC<PostModalProps> = ({
|
|||||||
post, nextPost, prevPost, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic
|
post, nextPost, prevPost, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
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
|
// Preloading Logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,55 +82,102 @@ 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') onNextPost?.();
|
if (e.key === 'ArrowRight') paginate(1);
|
||||||
else if (e.key === 'ArrowLeft') onPrevPost?.();
|
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);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onNextPost, onPrevPost, currentIndex, post.media.length, onClose]);
|
}, [onNextPost, onPrevPost, currentIndex, post.media.length, onClose]);
|
||||||
|
|
||||||
const paginate = (newDirection: number) => {
|
const paginate = (newDirection: number, velocity = 0) => {
|
||||||
const nextIndex = currentIndex + newDirection;
|
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 = {
|
const variants = {
|
||||||
enter: (d: number) => ({ x: d > 0 ? '100%' : '-100%', opacity: 1, zIndex: 0 }),
|
enter: ({ axis, dir }: SlideMotion) =>
|
||||||
center: { zIndex: 1, x: 0, opacity: 1 },
|
axis === 'y'
|
||||||
exit: (d: number) => ({ zIndex: 0, x: d < 0 ? '100%' : '-100%', opacity: 1 })
|
? { 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;
|
const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop-only surface: mobile opens PostFeed instead, so the only vertical
|
||||||
|
* gesture left here is drag-to-dismiss.
|
||||||
|
*/
|
||||||
|
const handleVerticalDragEnd = (offset: { y: number }, velocity: { y: number }) => {
|
||||||
|
if (offset.y > 200 || velocity.y > 800) 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
|
||||||
|
* until it sits under them and a white chevron lands on the white caption
|
||||||
|
* panel, leaving the control invisible until hovered.
|
||||||
|
*
|
||||||
|
* overscroll-contain stops wheel events chaining through to the very long
|
||||||
|
* post grid behind the overlay.
|
||||||
|
*/
|
||||||
return (
|
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:p-10 overflow-y-auto 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">
|
<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>
|
<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>
|
||||||
{hasPrevPost && onPrevPost && <button onClick={(e) => { e.stopPropagation(); onPrevPost(); }} className="hidden md:block fixed left-4 md:left-10 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-50 transition-transform hover:scale-110 active:scale-90"><ChevronLeft size={48} strokeWidth={1.5} /></button>}
|
{/* Solid pill so the arrows read against whatever sits behind them. */}
|
||||||
{hasNextPost && onNextPost && <button onClick={(e) => { e.stopPropagation(); onNextPost(); }} className="hidden md:block fixed right-4 md:right-10 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-50 transition-transform hover:scale-110 active:scale-90"><ChevronRight size={48} strokeWidth={1.5} /></button>}
|
{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>}
|
||||||
<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()}>
|
{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="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">
|
<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
|
<motion.div
|
||||||
key={`${post.id}-${currentIndex}`}
|
key={`${post.id}-${currentIndex}`}
|
||||||
custom={direction}
|
custom={slideMotion}
|
||||||
variants={variants}
|
variants={variants}
|
||||||
initial="enter"
|
initial="enter"
|
||||||
animate="center"
|
animate="center"
|
||||||
exit="exit"
|
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"
|
drag="x"
|
||||||
dragDirectionLock
|
dragDirectionLock
|
||||||
dragConstraints={{ left: 0, right: 0 }}
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
dragElastic={0.5}
|
dragElastic={0.5}
|
||||||
onDragEnd={(e, { offset, velocity }) => {
|
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);
|
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(); }
|
if (s < -15000) paginate(1, velocity.x);
|
||||||
else if (s > 15000) { if (currentIndex > 0) paginate(-1); else if (hasPrevPost && onPrevPost && s > 40000) onPrevPost(); }
|
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"
|
className="col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing relative text-black"
|
||||||
>
|
>
|
||||||
@@ -138,7 +193,7 @@ export const PostModal: React.FC<PostModalProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full md:w-96 bg-white flex flex-col border-l border-gray-200 overflow-hidden shrink-0 text-black">
|
<div className="w-full md:w-80 lg:w-96 bg-white flex flex-col border-l border-gray-200 overflow-hidden shrink-0 text-black">
|
||||||
<div className="p-3 md:p-4 border-b border-gray-100 flex items-center justify-between shrink-0 text-black">
|
<div className="p-3 md:p-4 border-b border-gray-100 flex items-center justify-between shrink-0 text-black">
|
||||||
<div className="flex items-center gap-3 text-black">
|
<div className="flex items-center gap-3 text-black">
|
||||||
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-yellow-400 to-purple-600 p-0.5 text-black"><div className="w-full h-full rounded-full bg-white p-0.5 text-black"><div className="w-full h-full rounded-full bg-gray-200 flex items-center justify-center overflow-hidden text-[10px] font-bold uppercase text-black">{profilePic ? <img src={profilePic} alt="" className="w-full h-full object-cover text-black" referrerPolicy="no-referrer" /> : <span className="text-black">{post.username[0]}</span>}</div></div></div>
|
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-yellow-400 to-purple-600 p-0.5 text-black"><div className="w-full h-full rounded-full bg-white p-0.5 text-black"><div className="w-full h-full rounded-full bg-gray-200 flex items-center justify-center overflow-hidden text-[10px] font-bold uppercase text-black">{profilePic ? <img src={profilePic} alt="" className="w-full h-full object-cover text-black" referrerPolicy="no-referrer" /> : <span className="text-black">{post.username[0]}</span>}</div></div></div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Post } from '../types';
|
import { Post } from '../types';
|
||||||
import { cn, formatDateSafe } from '../lib/utils';
|
import { cn, formatDateSafe } from '../lib/utils';
|
||||||
|
import { FADE, PRESENT, prefersReducedMotion } from '../lib/motion';
|
||||||
|
|
||||||
interface StoryViewerProps {
|
interface StoryViewerProps {
|
||||||
stories: Post[];
|
stories: Post[];
|
||||||
@@ -31,6 +32,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
// the progress bar on the first video.
|
// the progress bar on the first video.
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const reduceMotion = prefersReducedMotion();
|
||||||
const story = stories[currentStoryIndex];
|
const story = stories[currentStoryIndex];
|
||||||
const primary = story?.media?.[0];
|
const primary = story?.media?.[0];
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
transition={FADE}
|
||||||
className="fixed inset-0 z-[100] bg-[#1a1a1a] flex items-center justify-center overflow-hidden text-white"
|
className="fixed inset-0 z-[100] bg-[#1a1a1a] flex items-center justify-center overflow-hidden text-white"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
@@ -138,7 +141,11 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
<ChevronRight size={32} strokeWidth={1.5} />
|
<ChevronRight size={32} strokeWidth={1.5} />
|
||||||
</button>
|
</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"
|
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()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -223,7 +230,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
{story.caption}
|
{story.caption}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,100 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { dedupePostCopies, hasReelSource, makeIsReel, postsForTab } from './post-tabs';
|
||||||
|
import { MediaFile, Post } from '../types';
|
||||||
|
|
||||||
|
const media = (type: MediaFile['type'], index = 1): MediaFile => ({
|
||||||
|
name: `f${index}.${type === 'video' ? 'mp4' : 'jpg'}`,
|
||||||
|
path: `d/f${index}`, url: '', type, index,
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (id: string, opts: Partial<Post> = {}): Post => ({
|
||||||
|
id, date: '2024-01-01', username: 'u', caption: '', media: [media('image')], thumbnail: '', ...opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const video = (id: string, opts: Partial<Post> = {}) => post(id, { media: [media('video')], ...opts });
|
||||||
|
const carousel = (id: string, opts: Partial<Post> = {}) =>
|
||||||
|
post(id, { media: [media('image', 1), media('video', 2)], ...opts });
|
||||||
|
|
||||||
|
describe('hasReelSource', () => {
|
||||||
|
it('is false for an archive with no reels directory', () => {
|
||||||
|
expect(hasReelSource([post('A'), video('B')])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true once any post came from a reels directory', () => {
|
||||||
|
expect(hasReelSource([post('A'), video('u - reels/B', { source: 'reels' })])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeIsReel', () => {
|
||||||
|
it('believes the reels directory when there is one', () => {
|
||||||
|
const posts = [video('A'), video('u - reels/B', { source: 'reels' })];
|
||||||
|
const isReel = makeIsReel(posts);
|
||||||
|
// A is a lone video too, but the archive states which posts are reels.
|
||||||
|
expect(isReel(posts[0])).toBe(false);
|
||||||
|
expect(isReel(posts[1])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the lone-video heuristic without one', () => {
|
||||||
|
const posts = [post('A'), video('B'), carousel('C')];
|
||||||
|
const isReel = makeIsReel(posts);
|
||||||
|
expect(posts.map(isReel)).toEqual([false, true, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dedupePostCopies', () => {
|
||||||
|
it('leaves distinct posts alone', () => {
|
||||||
|
const posts = [post('A'), video('B')];
|
||||||
|
expect(dedupePostCopies(posts).map(p => p.id)).toEqual(['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses a reel fetched into both the profile and the reels directory', () => {
|
||||||
|
const posts = [video('B'), video('u - reels/B', { source: 'reels' })];
|
||||||
|
const deduped = dedupePostCopies(posts);
|
||||||
|
expect(deduped).toHaveLength(1);
|
||||||
|
// The reels copy wins, so the survivor is still recognised as a reel.
|
||||||
|
expect(deduped[0].source).toBe('reels');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the reels copy regardless of scan order', () => {
|
||||||
|
const profileCopy = video('B');
|
||||||
|
const reelCopy = video('u - reels/B', { source: 'reels' });
|
||||||
|
expect(dedupePostCopies([profileCopy, reelCopy])[0].source).toBe('reels');
|
||||||
|
expect(dedupePostCopies([reelCopy, profileCopy])[0].source).toBe('reels');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the position of the first copy seen', () => {
|
||||||
|
const posts = [post('A'), video('B'), post('C'), video('u - reels/B', { source: 'reels' })];
|
||||||
|
expect(dedupePostCopies(posts).map(p => p.id.split('/').pop())).toEqual(['A', 'B', 'C']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postsForTab', () => {
|
||||||
|
it('shows reels in the profile grid, as Instagram does', () => {
|
||||||
|
const posts = [post('A'), video('u - reels/B', { source: 'reels' })];
|
||||||
|
expect(postsForTab(posts, 'posts').map(p => p.id)).toEqual(['A', 'u - reels/B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the same reel in both tabs', () => {
|
||||||
|
const posts = [post('A'), video('u - reels/B', { source: 'reels' })];
|
||||||
|
const inGrid = postsForTab(posts, 'posts').map(p => p.id);
|
||||||
|
const inReels = postsForTab(posts, 'reels').map(p => p.id);
|
||||||
|
expect(inReels).toEqual(['u - reels/B']);
|
||||||
|
expect(inGrid).toContain('u - reels/B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a duplicated reel once in the grid, not twice', () => {
|
||||||
|
const posts = [post('A'), video('B'), video('u - reels/B', { source: 'reels' })];
|
||||||
|
expect(postsForTab(posts, 'posts')).toHaveLength(2);
|
||||||
|
expect(postsForTab(posts, 'reels')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats lone videos as reels for archives with no reels directory', () => {
|
||||||
|
const posts = [post('A'), video('B'), carousel('C')];
|
||||||
|
expect(postsForTab(posts, 'posts').map(p => p.id)).toEqual(['A', 'B', 'C']);
|
||||||
|
expect(postsForTab(posts, 'reels').map(p => p.id)).toEqual(['B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has nothing saved', () => {
|
||||||
|
expect(postsForTab([post('A')], 'saved')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Post, SourceKind } from '../types';
|
||||||
|
import { Tab } from './routing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which posts each profile tab shows.
|
||||||
|
*
|
||||||
|
* Instagram's profile grid holds everything the account posted — photos,
|
||||||
|
* carousels and reels alike — and the Reels tab is a *filtered view* of that
|
||||||
|
* same set rather than a separate one. So a reel belongs in both tabs, and
|
||||||
|
* only the Reels tab does any filtering.
|
||||||
|
*
|
||||||
|
* Kept pure and separate from App.tsx so the reel heuristic and the
|
||||||
|
* duplicate-copy rules can be tested directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shortcode shared by every copy of a post, regardless of which source
|
||||||
|
* directory it came from. Sidecar ids are directory-scoped
|
||||||
|
* (`4utumn07 - reels/Cq8LrxSJAJE`); the trailing segment is the shortcode.
|
||||||
|
*/
|
||||||
|
const shortcode = (post: Post): string => post.id.split('/').pop() ?? post.id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the archive has a `- reels` sidecar directory, i.e. it states
|
||||||
|
* outright which posts are reels.
|
||||||
|
*/
|
||||||
|
export const hasReelSource = (posts: Post[]): boolean => posts.some(p => p.source === 'reels');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the reel test for an archive.
|
||||||
|
*
|
||||||
|
* Instagram's own marker is `product_type: "clips"` on the post's GraphQL
|
||||||
|
* node, but only Instaloader archives carry that metadata, and only on newer
|
||||||
|
* captures — JDownloader grabs are media plus a caption `.txt` and nothing
|
||||||
|
* else (see docs/jdownloader.md). So:
|
||||||
|
*
|
||||||
|
* - archives with a `- reels` directory are believed outright;
|
||||||
|
* - everything else falls back to treating a lone video as a reel.
|
||||||
|
*
|
||||||
|
* The fallback is a guess: it cannot tell a reel from an ordinary feed video
|
||||||
|
* or an old IGTV upload, all three of which are plain `GraphVideo` nodes
|
||||||
|
* distinguished only by `product_type`.
|
||||||
|
*/
|
||||||
|
export const makeIsReel = (posts: Post[]): ((post: Post) => boolean) => (
|
||||||
|
hasReelSource(posts)
|
||||||
|
? (post: Post) => post.source === 'reels'
|
||||||
|
: (post: Post) => post.media.length === 1 && post.media[0]?.type === 'video'
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Preference order when the same post was fetched into more than one directory. */
|
||||||
|
const SOURCE_RANK: Record<SourceKind, number> = { reels: 0, posts: 1, stories: 2, highlight: 3 };
|
||||||
|
|
||||||
|
const rankOf = (post: Post): number => SOURCE_RANK[post.source ?? 'posts'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse copies of one post that were fetched into more than one directory.
|
||||||
|
*
|
||||||
|
* The JDownloader flow crawls a profile URL and its `/reels/` URL separately
|
||||||
|
* because the profile page misses some reels — so the two overlap, and a reel
|
||||||
|
* present in both lands on disk twice. Those become two posts with distinct
|
||||||
|
* directory-scoped ids, which the grid would happily render side by side.
|
||||||
|
*
|
||||||
|
* The reels-source copy wins, so the surviving post still reports
|
||||||
|
* `source: 'reels'` and both the Reels tab and `tabForSource` recognise it.
|
||||||
|
*
|
||||||
|
* Only safe because callers pass the grid's posts, which exclude stories and
|
||||||
|
* highlights — a shortcode may legitimately appear in both the profile and a
|
||||||
|
* highlight, and those must stay distinct.
|
||||||
|
*/
|
||||||
|
export const dedupePostCopies = (posts: Post[]): Post[] => {
|
||||||
|
const winners = new Map<string, Post>();
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
const code = shortcode(post);
|
||||||
|
const existing = winners.get(code);
|
||||||
|
if (!existing || rankOf(post) < rankOf(existing)) winners.set(code, post);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve input order, keyed on the winner so ordering does not depend on
|
||||||
|
// which copy happened to be scanned first.
|
||||||
|
const emitted = new Set<string>();
|
||||||
|
const result: Post[] = [];
|
||||||
|
for (const post of posts) {
|
||||||
|
const code = shortcode(post);
|
||||||
|
if (emitted.has(code)) continue;
|
||||||
|
emitted.add(code);
|
||||||
|
result.push(winners.get(code)!);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The posts a tab displays.
|
||||||
|
*
|
||||||
|
* `posts` must already exclude stories and highlights (App passes `allPosts`).
|
||||||
|
*/
|
||||||
|
export const postsForTab = (posts: Post[], tab: Tab): Post[] => {
|
||||||
|
if (tab === 'saved') return [];
|
||||||
|
|
||||||
|
const unique = dedupePostCopies(posts);
|
||||||
|
if (tab === 'posts') return unique;
|
||||||
|
return unique.filter(makeIsReel(posts));
|
||||||
|
};
|
||||||
@@ -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}/`;
|
||||||
|
};
|
||||||
+1
-1
@@ -14,7 +14,7 @@ const updateSW = registerSW({
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
r.update();
|
r.update();
|
||||||
}, 60 * 60 * 1000);
|
}, 60 * 60 * 1000);
|
||||||
console.log('[PWA] Service Worker registered and update interval set.');
|
console.log(`[PWA] v${__APP_VERSION__} registered; hourly update checks enabled.`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNeedRefresh() {
|
onNeedRefresh() {
|
||||||
|
|||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Build-time constants.
|
||||||
|
*
|
||||||
|
* This file deliberately has no imports or exports: that keeps it an ambient
|
||||||
|
* script rather than a module, so the declarations below are global.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Release version, injected by `define` in vite.config.ts. */
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -3,9 +3,24 @@ import react from '@vitejs/plugin-react';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {defineConfig} from 'vite';
|
import {defineConfig} from 'vite';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const { version } = createRequire(import.meta.url)('./package.json');
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(() => {
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
* The release version, compiled into the client.
|
||||||
|
*
|
||||||
|
* This is load-bearing, not cosmetic. The service worker precaches
|
||||||
|
* index.html *including its response headers*, so a server-side header
|
||||||
|
* change (a CSP fix, say) never reaches an installed PWA: nothing in the
|
||||||
|
* client build changed, the precache manifest is byte-identical, and the
|
||||||
|
* worker has no reason to update. Baking the version in means every release
|
||||||
|
* changes the bundle hash, which changes index.html, which invalidates the
|
||||||
|
* precache and re-fetches the shell with current headers.
|
||||||
|
*/
|
||||||
|
define: { __APP_VERSION__: JSON.stringify(version) },
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|||||||
Reference in New Issue
Block a user