Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f5d19ce4 | ||
|
|
1b4aba54d6 | ||
|
|
b30285fe70 | ||
|
|
20209bcad5 | ||
|
|
74902234b3 | ||
|
|
d62bddc3aa | ||
|
|
42c13ea106 | ||
|
|
a4e9ce16a7 | ||
|
|
4f89a69ee3 | ||
|
|
147dcdf2f1 | ||
|
|
d4e20d9b98 | ||
|
|
ec8c771733 | ||
|
|
3784e8729b | ||
|
|
69d62eaa5c | ||
|
|
767f9c508b | ||
|
|
103ce6f207 | ||
|
|
5267dab236 | ||
|
|
ebf2bf660a | ||
|
|
c0f3523a9c | ||
|
|
6f5021638c | ||
|
|
67f7750157 | ||
|
|
9e306eb85e | ||
|
|
b2da08d52d | ||
|
|
d7c13ecc19 | ||
|
|
d396b356be | ||
|
|
e23dfe4474 | ||
|
|
41e7c5e206 | ||
|
|
f685eaebd7 | ||
|
|
47e44ec5e9 | ||
|
|
cd7dc5f981 | ||
|
|
a724e5bc87 |
@@ -4,164 +4,63 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
InstaArchive Viewer is a React 19 + Vite 6 PWA for browsing archived Instagram data (official Instagram exports and Instaloader archives). All archive *parsing* happens client-side in the browser; the Express backend only indexes and serves files from disk, and never parses archive contents.
|
InstaArchive Viewer is a React 19 + Vite 6 PWA for browsing archived Instagram data (both official Instagram exports and Instaloader archives). All archive parsing and media processing happens client-side in the browser — the Express backend only lists/serves files from disk, it never parses archive contents.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `npm install` — install dependencies
|
- `npm install` — install dependencies
|
||||||
- `npm run dev` — Vite dev server on port 3000 (proxies `/api` and `/archives` to `http://localhost:3001`)
|
- `npm run dev` — start Vite dev server on port 3000 (proxies `/api` and `/archives` to `http://localhost:3001`)
|
||||||
- `npm run server` — Express backend (`tsx server.ts`) on port 3001, serving `ARCHIVES_DIR` (defaults to `./_sample-archives`)
|
- `npm run server` — start the Express backend (`tsx server.ts`) on port 3001, serving archives from `ARCHIVES_DIR` (defaults to `./_sample-archives`)
|
||||||
- `npm run build` — frontend to `dist/`, backend to `dist-server/`
|
- `npm run build` — build frontend to `dist/` (`vite build`) and backend to `dist-server/` (`tsc server.ts ...`)
|
||||||
- `npm run lint` — type-check only (`tsc --noEmit`)
|
- `npm run lint` — type-check only (`tsc --noEmit`); there is no separate test suite or linter config
|
||||||
- `npm test` / `npm run test:watch` — vitest
|
- `npm run clean` — remove `dist/`
|
||||||
- `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.
|
For local development you typically need both `npm run dev` and `npm run server` running concurrently — the frontend alone has nothing to talk to for server-mode archives (local-folder mode works without the backend).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Two archive sources, one data model
|
### Two archive sources, one data model
|
||||||
|
|
||||||
Loading is unified behind the `ArchiveFile` interface (`src/types/index.ts`, implementations in `src/lib/archive-files.ts`):
|
The app supports loading archives two ways, unified behind the `ArchiveFile` interface (`src/types/index.ts`, implementations in `src/lib/archive-files.ts`):
|
||||||
|
|
||||||
- **`LocalArchiveFile`** — wraps a browser `File`. `createObjectUrl()` mints a **disk-backed** blob URL directly from the File; never route media through `arrayBuffer()`, which pulls whole files into memory.
|
- **`LocalArchiveFile`** — wraps a browser `File` from a local folder picker (`webkitdirectory`). Fully offline, media is never uploaded anywhere.
|
||||||
- **`RemoteArchiveFile`** — wraps a file served from `/archives/...`, fetched on demand.
|
- **`RemoteArchiveFile`** — wraps a file served from the Express backend's `/archives/:name/...` static route, fetched on demand.
|
||||||
|
|
||||||
`revocable` tells callers whether the returned URL must be revoked. The scanner tracks every minted URL and releases them on archive teardown.
|
All downstream parsing code (`useArchiveScanner`) operates only on `ArchiveFile[]` and doesn't care which backing implementation it got.
|
||||||
|
|
||||||
### Sidecar directories
|
|
||||||
|
|
||||||
An archive root holds one directory per profile plus *sidecars* that belong to it:
|
|
||||||
|
|
||||||
```
|
|
||||||
4utumn07 -> posts (base)
|
|
||||||
4utumn07 - reels -> reels
|
|
||||||
story - 4utumn07 -> stories
|
|
||||||
story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
|
|
||||||
```
|
|
||||||
|
|
||||||
`src/lib/archive-grouping.ts` (shared by server and tests) folds these into a single profile with a `sources` list. Sidecars never appear as standalone archives. Each file the server returns carries its `kind`, so the client routes posts / reels / story ring / highlight circles without re-deriving naming rules.
|
|
||||||
|
|
||||||
### Server-side archive index (`src/lib/archive-index.ts`)
|
|
||||||
|
|
||||||
**Do not reintroduce per-request filesystem walks.** Archives typically live on network storage where per-file `stat` costs ~1.4ms and does not parallelise; a naive walk of a 110k-file root took ~52s per listing. Instead:
|
|
||||||
|
|
||||||
- Each source directory is indexed once and cached, keyed by its **directory mtime** (a directory `stat` is effectively free).
|
|
||||||
- The index is warmed in the background at startup and persisted to `CACHE_DIR` (mount a volume at `/cache`).
|
|
||||||
- `GET /api/archives` does no file walking at all — it returns directory-mtime `signature`s, which the client uses for cache invalidation instead of a file count.
|
|
||||||
- Only media files are stat'd (for `size`, which gates thumbnailing) and only highlights need `mtime` (their filenames carry no date).
|
|
||||||
|
|
||||||
### Scanning pipeline (`src/hooks/useArchiveScanner.ts`)
|
### Scanning pipeline (`src/hooks/useArchiveScanner.ts`)
|
||||||
|
|
||||||
`handleFiles` indexes files, detects format, then parses via one of three largely independent paths that all write into a shared `postsMap`:
|
This is the core of the app — a single large `handleFiles` function that:
|
||||||
|
1. **Indexes** all files, detecting archive format by filename regex: Instagram "export" format (`YYYY-MM-DD_user - post_id[- idx][- story].ext`), Instaloader format (`YYYY-MM-DD_HH-MM-SS_UTC[_idx][_story].ext`), or a generic JSON-manifest format (`posts_1.json`, `reels_1.json`, `stories_1.json`, possibly `.json.xz`-compressed via `xz-decompress`).
|
||||||
|
2. **Parses** according to detected format, building a `Map<postId, Partial<Post>>`. For JSON-manifest format, media files are matched to JSON entries by URI substring match, then by ID substring match, then by filename-derived heuristic — in that fallback order.
|
||||||
|
3. **Falls back** to generic filename-prefix grouping when no posts were found via regex/JSON matching (treats files sharing a common basename as one carousel post, chunked into groups of 20).
|
||||||
|
4. Detects a **profile picture** from `*_profile_pic.jpg` / `<username>.jpg` files, or falls back to the oldest image in the archive by filename sort ("Smart Fallback").
|
||||||
|
5. **Caches** the final `{ posts, stories, profileMetadata, ... }` result to IndexedDB via `idb-keyval`, keyed by archive name (or `local_archive` for unnamed local folders) — this is what makes repeat visits load instantly. Both server and local archives are cached; the cache shape is documented inline in `useArchiveScanner`'s state (mirrors the `CacheData` interface in `GEMINI.md`).
|
||||||
|
|
||||||
1. **JSON manifest** (`posts_1.json`, possibly `.json.xz` via `xz-decompress`) — media matched to entries by URI, then ID, then filename heuristic, in that fallback order.
|
When modifying format-detection or media-matching logic, be aware the three code paths (JSON-manifest, filename-regex export/instaloader, generic fallback) are largely independent and a change to one rarely needs to touch the others — but all three write into the same `postsMap`.
|
||||||
2. **Filename patterns** — see `src/lib/archive-patterns.ts` for the export / Instaloader / highlight regexes, extracted as pure functions and covered by tests. Prefer changing them there.
|
|
||||||
3. **Generic grouping fallback** — when nothing else matched, files sharing a basename become one carousel.
|
|
||||||
|
|
||||||
Results are cached to IndexedDB. Media records store a stable `path`; **`url` is not persistable** for local archives because blob URLs die with the document.
|
### Thumbnail generation (`src/hooks/useThumbnailQueue.ts` + `src/lib/thumbnail-worker.ts`)
|
||||||
|
|
||||||
Three different JSON shapes turn up as `.json`, so they are told apart structurally, not by filename (`src/lib/gallery-dl-sidecar.ts`):
|
High-res images (>1MiB) are downscaled off the main thread:
|
||||||
|
- `useThumbnailQueue` maintains a **serial** (one-at-a-time) queue — this is deliberate, not a bug: decoding multiple 50MP+ images concurrently causes OOM crashes in the browser.
|
||||||
|
- Actual resizing happens in `thumbnail-worker.ts` using `OffscreenCanvas`/`createImageBitmap` inside a Web Worker.
|
||||||
|
- Results are cached in IndexedDB under a `thumb_<id>` key, checked before falling back to the worker, so thumbnails persist across sessions.
|
||||||
|
|
||||||
| shape | marker |
|
### URL state sync (`src/App.tsx`)
|
||||||
|---|---|
|
|
||||||
| Instagram export manifest | top-level `media` array |
|
|
||||||
| Instaloader `.json.xz` | GraphQL node under `node` / `__typename` |
|
|
||||||
| gallery-dl sidecar | flat, `post_shortcode` + `type`, none of the above |
|
|
||||||
|
|
||||||
The gallery-dl sidecar is the only source that states what a post *is*: its `type` (`post` / `reel` / `story` / `highlight`) is Instagram's own classification, so `post.isReel` set from it beats every fallback in `post-tabs.ts`. This matters — of the 781 items in `official_band - reels`, the sidecars say only **360 are reels**; the other 421 are ordinary feed videos the clips endpoint returns via `include_feed_video`. Directory-based classification counted all 781.
|
App state (selected archive, active tab, selected post) is synchronized with URL query params (`?a=`, `?t=`, `?p=`) via `URLSearchParams` + `window.history.replaceState` in a cluster of `useEffect` hooks — this is what enables permalinks/deep-linking. When adding new shareable state, follow this pattern rather than introducing a router.
|
||||||
|
|
||||||
**Dates are ranked, not last-write-wins** (`src/lib/post-dates.ts`): sidecar (what Instagram reported) beats filename (what the fetcher wrote) beats mtime (when the file hit disk, and unrelated to when it was posted). Ties keep the incumbent. Several files describe one post and they are scanned in directory order, not in order of trustworthiness, so without the ranking the date was decided by whichever file came first. Only JDownloader highlights fall to mtime at all — `parseArchiveFilename` flags those via `dateFromMtime`.
|
|
||||||
|
|
||||||
### Cache and local-archive persistence (`src/lib/archive-cache.ts`)
|
|
||||||
|
|
||||||
IndexedDB keys are namespaced (`archive:`, `thumb:`, `handle:`) so listing archives does not deserialize every cached thumbnail blob, and thumbnails are scoped per archive to avoid cross-archive collisions.
|
|
||||||
|
|
||||||
Restoring an archive **rehydrates URLs from `path`**: server archives rebuild HTTP URLs; local archives re-open a persisted `FileSystemDirectoryHandle` and mint fresh blob URLs. If the folder is unreachable (permission lapsed, or the browser lacks `showDirectoryPicker` — Firefox/Safari), the app re-prompts rather than rendering broken images.
|
|
||||||
|
|
||||||
### Thumbnails (`src/hooks/useThumbnailQueue.ts` + `src/lib/thumbnail-worker.ts`)
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Profile tabs (`src/lib/post-tabs.ts`)
|
|
||||||
|
|
||||||
The grid holds **everything**, reels included, and the Reels tab is a *filtered view* of that same set. 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.
|
|
||||||
|
|
||||||
This *approximates* Instagram rather than matching it. Instagram's grid includes a reel only if the creator shared it to feed — a per-post choice, measured live on 2026-08-16: `official_band` had 21 reels in its first 34 grid tiles, `4utumn07` just 1 in 214. That flag appears nowhere in an archive (JD2 stores no metadata, and Instaloader's `product_type` says what a post *is*, not whether it was shared to feed), so showing everything is the closest reachable behaviour. Instagram's "N posts" counter equals its grid, which is why the header counts `postsForTab(allPosts, 'posts')` and not `allPosts` — the raw list still holds both copies of a double-fetched post.
|
|
||||||
|
|
||||||
When checking the live site, note that grid reels link to `/reel/<code>/` while ordinary posts link to `/<user>/p/<code>/`. Matching only `/p/` silently drops every reel, which once produced a confident and completely wrong conclusion that Instagram never shows reels in the grid.
|
|
||||||
|
|
||||||
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.
|
|
||||||
- Deep-link resolution waits on the archive fetch having *settled* (`archivesFetched`), not on `isServerMode`, which is still false while the request is 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:
|
Minimal Express server, three responsibilities only:
|
||||||
|
- `GET /api/archives` — lists subdirectories of `ARCHIVES_DIR` (skipping dotfiles/`@`/`_`-prefixed dirs) as `ServerArchive[]`, guessing a thumbnail per archive.
|
||||||
|
- `GET /api/archives/:name/files` — recursively lists all files in one archive directory.
|
||||||
|
- Static-serves `ARCHIVES_DIR` under `/archives` and, in production, serves the built `dist/` frontend with an SPA fallback.
|
||||||
|
|
||||||
- Express decodes route params **after** segment matching, so `..%2f` reaches the handler as `../`. All user-supplied archive names go through `resolveArchivePath`.
|
It does no parsing of archive/JSON contents — that's entirely client-side in `useArchiveScanner`. `ARCHIVES_DIR` is resolved from the `ARCHIVES_DIR` env var (see `.env` / Docker volume mount at `/archives`).
|
||||||
- `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
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### PWA / build quirks
|
### PWA / build quirks
|
||||||
|
|
||||||
- `vite.config.ts` sets `hmr: process.env.DISABLE_HMR !== 'true'` — intentional, leave it.
|
- `vite.config.ts` sets `hmr: process.env.DISABLE_HMR !== 'true'` — this is intentionally left alone; it exists to disable file-watch flicker when running under AI Studio-style agent editing. Don't "clean up" or remove it.
|
||||||
- `workbox.navigateFallbackDenylist` excludes `/api` and `/archives` so those hit the real server.
|
- `workbox.navigateFallbackDenylist` excludes `/api` and `/archives` from the SPA fallback so those routes hit the real server/static files instead of `index.html` (needed for "open original file in new tab").
|
||||||
- Fonts and icons are vendored in `public/` — do not reintroduce CDN references; the app advertises offline support and local-only processing.
|
- Service worker uses `registerType: 'autoUpdate'` with hourly periodic checks — new deployments propagate to open clients automatically.
|
||||||
|
|||||||
@@ -54,27 +54,10 @@ If the app shows "No Archives Found" and logs `EACCES: permission denied`:
|
|||||||
chmod -R 755 /path/to/archives
|
chmod -R 755 /path/to/archives
|
||||||
```
|
```
|
||||||
2. **SELinux (Fedora/RHEL/CentOS)**: Use the `:z` flag in your volume mount as shown above.
|
2. **SELinux (Fedora/RHEL/CentOS)**: Use the `:z` flag in your volume mount as shown above.
|
||||||
3. **User Mapping**: The container runs as the non-root `node` user (UID 1000).
|
3. **User Mapping**: You can force the container to run as your host user:
|
||||||
If your archives are readable only by another account, run as that user
|
|
||||||
instead — the container needs to *list* the archive directory, so `--x`
|
|
||||||
(traverse-only) permissions are not enough:
|
|
||||||
```bash
|
```bash
|
||||||
docker run --user $(stat -c '%u:%g' /path/to/archives) ...
|
docker run --user $(id -u):$(id -g) ...
|
||||||
```
|
```
|
||||||
In Compose:
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
instaarchive:
|
|
||||||
user: "1234:1234" # a UID that can read your archives
|
|
||||||
```
|
|
||||||
|
|
||||||
### Archive Index
|
|
||||||
|
|
||||||
On first start the server walks the archive root once and caches the result,
|
|
||||||
keyed by directory mtime. This matters on network storage: for a 110k-file
|
|
||||||
archive root, listing went from ~52s per request to ~0.1s. Mount a volume at
|
|
||||||
`/cache` (or set `CACHE_DIR`) so the index survives restarts, otherwise it is
|
|
||||||
rebuilt on every start.
|
|
||||||
|
|
||||||
## Supported Archive Structure
|
## Supported Archive Structure
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.8.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.8.0",
|
"version": "1.3.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",
|
||||||
|
|||||||
+2
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.0",
|
"version": "1.3.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,8 +12,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -16,23 +16,7 @@ const PORT = process.env.PORT || 3001;
|
|||||||
const ARCHIVES_DIR = path.resolve(process.env.ARCHIVES_DIR || path.join(__dirname, '_sample-archives'));
|
const ARCHIVES_DIR = path.resolve(process.env.ARCHIVES_DIR || path.join(__dirname, '_sample-archives'));
|
||||||
|
|
||||||
console.log(`[Server] Initializing...`);
|
console.log(`[Server] Initializing...`);
|
||||||
/**
|
console.log(`[Server] Running as user: ${os.userInfo().username} (UID: ${os.userInfo().uid}, GID: ${os.userInfo().gid})`);
|
||||||
* Describe the running user without assuming it exists in /etc/passwd.
|
|
||||||
*
|
|
||||||
* `os.userInfo()` throws ENOENT for a UID with no passwd entry, which is
|
|
||||||
* exactly what happens when the container is started with `--user 1234:1234`
|
|
||||||
* (as the deployment docs suggest) — previously crashing the server at boot.
|
|
||||||
*/
|
|
||||||
const describeUser = () => {
|
|
||||||
try {
|
|
||||||
const info = os.userInfo();
|
|
||||||
return `${info.username} (UID: ${info.uid}, GID: ${info.gid})`;
|
|
||||||
} catch {
|
|
||||||
return `UID: ${typeof process.getuid === 'function' ? process.getuid() : '?'}, GID: ${typeof process.getgid === 'function' ? process.getgid() : '?'} (no passwd entry)`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`[Server] Running as user: ${describeUser()}`);
|
|
||||||
console.log(`[Server] Environment ARCHIVES_DIR: ${process.env.ARCHIVES_DIR}`);
|
console.log(`[Server] Environment ARCHIVES_DIR: ${process.env.ARCHIVES_DIR}`);
|
||||||
console.log(`[Server] Resolved ARCHIVES_DIR: ${ARCHIVES_DIR}`);
|
console.log(`[Server] Resolved ARCHIVES_DIR: ${ARCHIVES_DIR}`);
|
||||||
|
|
||||||
@@ -80,16 +64,10 @@ 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:",
|
||||||
// 'wasm-unsafe-eval' permits WebAssembly compilation without allowing
|
"script-src 'self'",
|
||||||
// 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' data:",
|
"connect-src 'self'",
|
||||||
"worker-src 'self' blob:",
|
"worker-src 'self' blob:",
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
@@ -136,7 +114,7 @@ app.get('/api/archives', (req, res) => {
|
|||||||
res.json(archives);
|
res.json(archives);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === 'EACCES') {
|
if (err.code === 'EACCES') {
|
||||||
console.error(`[API] Permission Denied! The server (${describeUser()}) cannot read ${ARCHIVES_DIR}.`);
|
console.error(`[API] Permission Denied! The server (UID ${os.userInfo().uid}) cannot read ${ARCHIVES_DIR}.`);
|
||||||
console.error(`[API] Hint: If using Linux/Docker, check folder permissions (chmod 755) or SELinux context (append :z to your volume mount).`);
|
console.error(`[API] Hint: If using Linux/Docker, check folder permissions (chmod 755) or SELinux context (append :z to your volume mount).`);
|
||||||
} else {
|
} else {
|
||||||
console.error('[API] Error listing archives:', err);
|
console.error('[API] Error listing archives:', err);
|
||||||
|
|||||||
+59
-106
@@ -17,9 +17,6 @@ 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,
|
||||||
@@ -39,8 +36,6 @@ 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';
|
||||||
@@ -65,15 +60,14 @@ export default function App() {
|
|||||||
const [hasInitialLoaded, setHasInitialLoaded] = useState(false);
|
const [hasInitialLoaded, setHasInitialLoaded] = useState(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The route as it was when the app booted.
|
* The query string 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 initialRouteRef = useRef(parseRoute(window.location.pathname, window.location.search));
|
const initialParamsRef = useRef(new URLSearchParams(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);
|
||||||
|
|
||||||
@@ -112,30 +106,6 @@ 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,
|
||||||
@@ -173,18 +143,20 @@ export default function App() {
|
|||||||
const clearCache = async (name: string) => { await deleteCachedArchive(name); await refreshCachedArchives(); };
|
const clearCache = async (name: string) => { await deleteCachedArchive(name); await refreshCachedArchives(); };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The grid shows everything, reels included, and the Reels tab is a filtered
|
* Archives with a `- reels` sidecar directory say outright which posts are
|
||||||
* view of the same set — see src/lib/post-tabs.ts for the reel test and for
|
* reels; only fall back to the "lone video" heuristic for archives that have
|
||||||
* why a reel can arrive on disk twice.
|
* no such directory.
|
||||||
*/
|
*/
|
||||||
const filteredPosts = useMemo(() => postsForTab(allPosts, activeTab), [allPosts, activeTab]);
|
const hasReelSource = useMemo(() => allPosts.some(p => p.source === 'reels'), [allPosts]);
|
||||||
|
const isReel = useCallback((p: Post) => (
|
||||||
|
hasReelSource ? p.source === 'reels' : p.media.length === 1 && p.media[0].type === 'video'
|
||||||
|
), [hasReelSource]);
|
||||||
|
|
||||||
/**
|
const filteredPosts = useMemo(() => {
|
||||||
* Instagram's "N posts" counter equals what its grid holds, so count the grid
|
if (activeTab === 'reels') return allPosts.filter(isReel);
|
||||||
* rather than `allPosts` — the raw list still holds both copies of any post
|
if (activeTab === 'posts') return allPosts.filter(p => !isReel(p));
|
||||||
* fetched into two directories.
|
return [];
|
||||||
*/
|
}, [allPosts, activeTab, isReel]);
|
||||||
const totalPosts = useMemo(() => postsForTab(allPosts, 'posts').length, [allPosts]);
|
|
||||||
|
|
||||||
/** 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(() => {
|
||||||
@@ -367,28 +339,41 @@ export default function App() {
|
|||||||
// loader below is waiting to read.
|
// loader below is waiting to read.
|
||||||
if (!hasInitialLoaded) return;
|
if (!hasInitialLoaded) return;
|
||||||
|
|
||||||
const archive = currentArchive?.name ?? (allPosts.length > 0 ? username : null) ?? null;
|
const params = new URLSearchParams(window.location.search);
|
||||||
const nextPath = buildPath({
|
if (currentArchive) params.set('a', currentArchive.name);
|
||||||
archive,
|
else if (allPosts.length > 0 && username) params.set('a', username);
|
||||||
tab: activeTab,
|
else params.delete('a');
|
||||||
post: selectedPost ? postSlug(selectedPost) : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nextPath !== window.location.pathname + window.location.search) {
|
if (activeTab !== 'posts') params.set('t', activeTab);
|
||||||
console.log(`[Permalink] Updating URL to: ${nextPath}`);
|
else params.delete('t');
|
||||||
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 route = initialRouteRef.current;
|
const params = initialParamsRef.current;
|
||||||
console.log('[Permalink] Initial route:', route);
|
const archiveName = params.get('a');
|
||||||
|
const tab = params.get('t');
|
||||||
|
console.log('[Permalink] Initial read from URL:', {
|
||||||
|
archiveName, tab, postId: params.get('p'),
|
||||||
|
});
|
||||||
|
|
||||||
if (route.tab !== 'posts') setActiveTab(route.tab);
|
if (tab && ['posts', 'reels', 'saved'].includes(tab)) {
|
||||||
|
setActiveTab(tab as 'posts' | 'reels' | 'saved');
|
||||||
|
}
|
||||||
|
|
||||||
if (!route.archive) {
|
if (!archiveName) {
|
||||||
setHasInitialLoaded(true);
|
setHasInitialLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -396,12 +381,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 === route.archive);
|
const archive = serverArchives.find(a => a.name === archiveName);
|
||||||
if (archive) {
|
if (archive) {
|
||||||
console.log(`[Permalink] Auto-loading archive: ${route.archive}`);
|
console.log(`[Permalink] Auto-loading archive: ?a=${archiveName}`);
|
||||||
loadServerArchive(archive);
|
loadServerArchive(archive);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[Permalink] No archive named "${route.archive}".`);
|
console.warn(`[Permalink] No archive named "${archiveName}".`);
|
||||||
}
|
}
|
||||||
setHasInitialLoaded(true);
|
setHasInitialLoaded(true);
|
||||||
}, [serverArchives, archivesFetched, hasInitialLoaded, loadServerArchive]);
|
}, [serverArchives, archivesFetched, hasInitialLoaded, loadServerArchive]);
|
||||||
@@ -421,14 +406,10 @@ export default function App() {
|
|||||||
if (appliedPostParamRef.current === archiveKey) return;
|
if (appliedPostParamRef.current === archiveKey) return;
|
||||||
appliedPostParamRef.current = archiveKey;
|
appliedPostParamRef.current = archiveKey;
|
||||||
|
|
||||||
const slug = initialRouteRef.current.post;
|
const postId = initialParamsRef.current.get('p');
|
||||||
if (!slug) return;
|
if (!postId) return;
|
||||||
const post = findPostBySlug(allPosts, slug);
|
const post = allPosts.find(p => p.id === postId);
|
||||||
if (!post) return;
|
if (post) setSelectedPost(post);
|
||||||
// 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 (
|
||||||
@@ -483,28 +464,17 @@ export default function App() {
|
|||||||
onLoad={() => setLastLoadedScanningImage(currentScanningImage)}
|
onLoad={() => setLastLoadedScanningImage(currentScanningImage)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/*
|
<div className="absolute inset-0 z-0">
|
||||||
The 0.4 lives on the group, not the images: two layers overlap
|
<AnimatePresence initial={false}>
|
||||||
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={src}
|
key={lastLoadedScanningImage}
|
||||||
src={src}
|
src={lastLoadedScanningImage || undefined}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 0.4 }}
|
||||||
transition={prefersReducedMotion() ? { duration: 0 } : { duration: 0.9, ease: 'easeInOut' }}
|
transition={{ duration: 1.5 }}
|
||||||
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">
|
||||||
@@ -526,7 +496,7 @@ export default function App() {
|
|||||||
{allProfilePics.length > 1 && <button onClick={cycleProfilePic} className="bg-gray-100 hover:bg-gray-200 px-4 py-1.5 rounded-lg text-sm font-semibold transition-colors flex items-center gap-2 text-black"><Layers size={16} />Next Profile Pic</button>}
|
{allProfilePics.length > 1 && <button onClick={cycleProfilePic} className="bg-gray-100 hover:bg-gray-200 px-4 py-1.5 rounded-lg text-sm font-semibold transition-colors flex items-center gap-2 text-black"><Layers size={16} />Next Profile Pic</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center md:justify-start gap-10 text-sm md:text-base text-black"><div><span className="font-semibold text-black/80 text-black">{totalPosts.toLocaleString()}</span> posts</div><div><span className="font-semibold text-black/80 text-black">{(followerCount || 0).toLocaleString()}</span> followers</div><div><span className="font-semibold text-black/80 text-black">{(followingCount || 0).toLocaleString()}</span> following</div></div>
|
<div className="flex justify-center md:justify-start gap-10 text-sm md:text-base text-black"><div><span className="font-semibold text-black/80 text-black">{allPosts.length}</span> posts</div><div><span className="font-semibold text-black/80 text-black">{(followerCount || 0).toLocaleString()}</span> followers</div><div><span className="font-semibold text-black/80 text-black">{(followingCount || 0).toLocaleString()}</span> following</div></div>
|
||||||
<div className="space-y-1 text-black/80 text-black"><div className="font-semibold text-black">{fullName || `@${username}`}</div><div className="text-gray-600 whitespace-pre-wrap max-w-sm mx-auto md:mx-0 text-sm md:text-base text-black">{bio || 'Archived profile viewer for local files.'}</div>{externalUrl && <a href={externalUrl} target="_blank" rel="noopener noreferrer" className="text-blue-900 font-semibold text-sm block hover:underline truncate max-w-[250px] text-black">{externalUrl.replace(/^https?:\/\/(www\.)?/, '')}</a>}</div>
|
<div className="space-y-1 text-black/80 text-black"><div className="font-semibold text-black">{fullName || `@${username}`}</div><div className="text-gray-600 whitespace-pre-wrap max-w-sm mx-auto md:mx-0 text-sm md:text-base text-black">{bio || 'Archived profile viewer for local files.'}</div>{externalUrl && <a href={externalUrl} target="_blank" rel="noopener noreferrer" className="text-blue-900 font-semibold text-sm block hover:underline truncate max-w-[250px] text-black">{externalUrl.replace(/^https?:\/\/(www\.)?/, '')}</a>}</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -534,11 +504,9 @@ 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 => (
|
||||||
<motion.button
|
<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'}`}
|
||||||
>
|
>
|
||||||
@@ -554,7 +522,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>
|
||||||
</motion.button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -570,7 +538,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)} 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]"))}>
|
<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]"))}>
|
||||||
<PostThumbnail
|
<PostThumbnail
|
||||||
post={post}
|
post={post}
|
||||||
thumbnailUrl={cacheHits.get(post.id)}
|
thumbnailUrl={cacheHits.get(post.id)}
|
||||||
@@ -586,20 +554,6 @@ 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
|
||||||
@@ -615,7 +569,6 @@ 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 && (
|
||||||
@@ -631,7 +584,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 · v{__APP_VERSION__}</div>
|
<div className="text-black/40 text-black">© 2026 InstaArchive Viewer</div>
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,71 +1,12 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Play, Volume2, VolumeX } from 'lucide-react';
|
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';
|
||||||
|
|
||||||
interface MediaRendererProps {
|
export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => {
|
||||||
file: MediaFile;
|
// Start muted so autoplay is not blocked by Safari/Firefox policy.
|
||||||
className?: string;
|
const [isMuted, setIsMuted] = useState(true);
|
||||||
isFullView?: boolean;
|
const sizingClass = isFullView ? "w-full h-auto block" : "w-full h-full object-cover";
|
||||||
/** 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
|
|
||||||
// generally allow it. If this particular browser still refuses, the effect
|
|
||||||
// below falls back to muted playback rather than leaving a stalled video.
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video || file.type !== 'video') return;
|
|
||||||
|
|
||||||
if (paused) {
|
|
||||||
video.pause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
video.muted = false;
|
|
||||||
video.play().catch(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setIsMuted(true);
|
|
||||||
video.muted = true;
|
|
||||||
video.play().catch(() => { /* user can start it from the controls */ });
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [file.url, file.type, paused]);
|
|
||||||
/**
|
|
||||||
* In full view the media must never outgrow the viewport.
|
|
||||||
*
|
|
||||||
* Video is sized to its own aspect within the cap (`w-auto`) so a portrait
|
|
||||||
* clip doesn't sit in a wide letterbox, while images keep filling the modal
|
|
||||||
* width and only gain a height ceiling — `object-contain` stops the cap from
|
|
||||||
* distorting anything that hits it.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
const fullViewCap = `${heightCap ?? 'max-h-[70vh] md:max-h-[calc(100vh-5rem)]'} object-contain`;
|
|
||||||
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 sizingClass = file.type === 'video' ? videoSizing : imageSizing;
|
|
||||||
const mediaStyle = { transform: 'translateZ(0)' };
|
const mediaStyle = { transform: 'translateZ(0)' };
|
||||||
|
|
||||||
if (!file.url) return <div className={cn("bg-gray-100 flex items-center justify-center text-black", sizingClass)}><Play size={24} className="text-gray-300" /></div>;
|
if (!file.url) return <div className={cn("bg-gray-100 flex items-center justify-center text-black", sizingClass)}><Play size={24} className="text-gray-300" /></div>;
|
||||||
@@ -73,7 +14,7 @@ export const MediaRenderer = ({ file, className, isFullView, paused, heightCap,
|
|||||||
if (file.type === 'video') {
|
if (file.type === 'video') {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full flex items-center justify-center group/video text-black">
|
<div className="relative w-full h-full flex items-center justify-center group/video text-black">
|
||||||
<video ref={videoRef} src={file.url} className={cn("transition-all duration-300", sizingClass, className)} style={mediaStyle} playsInline autoPlay muted={isMuted} loop controls />
|
<video src={file.url} className={cn("transition-all duration-300", sizingClass, className)} style={mediaStyle} playsInline autoPlay muted={isMuted} loop controls />
|
||||||
<button onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }} className="absolute bottom-16 right-4 z-30 bg-black/40 hover:bg-black/60 text-white p-2 rounded-full backdrop-blur-md transition-all md:opacity-0 md:group-hover/video:opacity-100">
|
<button onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }} className="absolute bottom-16 right-4 z-30 bg-black/40 hover:bg-black/60 text-white p-2 rounded-full backdrop-blur-md transition-all md:opacity-0 md:group-hover/video:opacity-100">
|
||||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
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,7 +12,6 @@ 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 {
|
||||||
@@ -31,14 +30,7 @@ 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(() => {
|
||||||
@@ -82,102 +74,55 @@ 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') paginate(1);
|
if (e.key === 'ArrowRight') onNextPost?.();
|
||||||
else if (e.key === 'ArrowLeft') paginate(-1);
|
else if (e.key === 'ArrowLeft') onPrevPost?.();
|
||||||
else if (e.key === '.') goToPost(1, 'x');
|
else if (e.key === '.') paginate(1);
|
||||||
else if (e.key === ',') goToPost(-1, 'x');
|
else if (e.key === ',') paginate(-1);
|
||||||
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, velocity = 0) => {
|
const paginate = (newDirection: number) => {
|
||||||
const nextIndex = currentIndex + newDirection;
|
const nextIndex = currentIndex + newDirection;
|
||||||
if (nextIndex >= 0 && nextIndex < post.media.length) {
|
if (nextIndex >= 0 && nextIndex < post.media.length) { setDirection(newDirection); setCurrentIndex(nextIndex); }
|
||||||
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: ({ axis, dir }: SlideMotion) =>
|
enter: (d: number) => ({ x: d > 0 ? '100%' : '-100%', opacity: 1, zIndex: 0 }),
|
||||||
axis === 'y'
|
center: { zIndex: 1, x: 0, opacity: 1 },
|
||||||
? { y: offscreen(dir), x: 0, opacity: 1, zIndex: 0 }
|
exit: (d: number) => ({ zIndex: 0, x: d < 0 ? '100%' : '-100%', opacity: 1 })
|
||||||
: { 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 }} 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}>
|
<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}>
|
||||||
<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>
|
||||||
{/* Solid pill so the arrows read against whatever sits behind them. */}
|
{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>}
|
||||||
{hasPrevPost && onPrevPost && <button aria-label="Previous post" onClick={(e) => { e.stopPropagation(); goToPost(-1, 'x'); }} className="hidden md:flex items-center justify-center fixed md:left-3 lg:left-6 top-1/2 -translate-y-1/2 z-50 p-2 rounded-full bg-white/90 hover:bg-white text-gray-800 shadow-lg transition-transform hover:scale-110 active:scale-90"><ChevronLeft size={28} strokeWidth={2} /></button>}
|
{hasNextPost && onNextPost && <button 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>}
|
||||||
{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 }) => { 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()}>
|
||||||
<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={slideMotion}>
|
<AnimatePresence initial={false} custom={direction}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`${post.id}-${currentIndex}`}
|
key={`${post.id}-${currentIndex}`}
|
||||||
custom={slideMotion}
|
custom={direction}
|
||||||
variants={variants}
|
variants={variants}
|
||||||
initial="enter"
|
initial="enter"
|
||||||
animate="center"
|
animate="center"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={reduceMotion
|
transition={{ x: { type: "spring", stiffness: 200, damping: 26, bounce: 0 } }}
|
||||||
? { 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) paginate(1, velocity.x);
|
if (s < -15000) { if (currentIndex < post.media.length - 1) paginate(1); else if (hasNextPost && onNextPost && s < -40000) onNextPost(); }
|
||||||
else if (s > 15000) paginate(-1, velocity.x);
|
else if (s > 15000) { if (currentIndex > 0) paginate(-1); else if (hasPrevPost && onPrevPost && s > 40000) onPrevPost(); }
|
||||||
}}
|
}}
|
||||||
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"
|
||||||
>
|
>
|
||||||
@@ -193,7 +138,7 @@ export const PostModal: React.FC<PostModalProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<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="w-full md: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,7 +9,6 @@ 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[];
|
||||||
@@ -27,12 +26,10 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
// Opening the reel is a user gesture, so try for sound; the effect below
|
// Start muted: Safari and Firefox refuse to autoplay audible media, which
|
||||||
// falls back to muted if the browser refuses, which would otherwise stall
|
// would stall the reel on its first video.
|
||||||
// the progress bar on the first video.
|
const [isMuted, setIsMuted] = useState(true);
|
||||||
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];
|
||||||
|
|
||||||
@@ -64,22 +61,6 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [currentStoryIndex, primary]);
|
}, [currentStoryIndex, primary]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video || primary?.type !== 'video') return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
video.muted = false;
|
|
||||||
video.play().catch(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setIsMuted(true);
|
|
||||||
video.muted = true;
|
|
||||||
video.play().catch(() => { /* leave it to the controls */ });
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [primary]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (progress >= 100) {
|
if (progress >= 100) {
|
||||||
if (currentStoryIndex < stories.length - 1) {
|
if (currentStoryIndex < stories.length - 1) {
|
||||||
@@ -112,7 +93,6 @@ 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}
|
||||||
>
|
>
|
||||||
@@ -141,11 +121,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
<ChevronRight size={32} strokeWidth={1.5} />
|
<ChevronRight size={32} strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<motion.div
|
<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()}
|
||||||
>
|
>
|
||||||
@@ -230,7 +206,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
{story.caption}
|
{story.caption}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { XzReadableStream } from 'xz-decompress';
|
|||||||
import { ArchiveFile, CacheData, Post, ServerArchive } from '../types';
|
import { ArchiveFile, CacheData, Post, ServerArchive } from '../types';
|
||||||
import { setCachedArchive, getDirectoryHandle } from '../lib/archive-cache';
|
import { setCachedArchive, getDirectoryHandle } from '../lib/archive-cache';
|
||||||
import { parseArchiveFilename, scopedPostId, EXPORT_RE, INSTALOADER_RE } from '../lib/archive-patterns';
|
import { parseArchiveFilename, scopedPostId, EXPORT_RE, INSTALOADER_RE } from '../lib/archive-patterns';
|
||||||
import { isGalleryDlSidecar, sidecarDate, sidecarIsReel } from '../lib/gallery-dl-sidecar';
|
|
||||||
import { DateSource, shouldReplaceDate } from '../lib/post-dates';
|
|
||||||
|
|
||||||
const hasDirectoryHandle = async (name: string) => Boolean(await getDirectoryHandle(name));
|
const hasDirectoryHandle = async (name: string) => Boolean(await getDirectoryHandle(name));
|
||||||
|
|
||||||
@@ -109,23 +107,11 @@ 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 compressed = await file.arrayBuffer();
|
const stream = new XzReadableStream(file.stream());
|
||||||
const stream = new XzReadableStream(new Blob([compressed]).stream());
|
const response = new Response(stream);
|
||||||
return await new Response(stream).json();
|
return await response.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; }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,17 +130,6 @@ export const useArchiveScanner = (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const postsMap = new Map<string, Partial<Post>>();
|
const postsMap = new Map<string, Partial<Post>>();
|
||||||
/**
|
|
||||||
* Which source supplied each post's date, so a better one can replace it.
|
|
||||||
* Sidecar beats filename beats mtime — see src/lib/post-dates.ts.
|
|
||||||
*/
|
|
||||||
const dateSources = new Map<string, DateSource>();
|
|
||||||
const applyDate = (postId: string, post: Partial<Post>, date: string, source: DateSource) => {
|
|
||||||
const current = post.date ? { date: post.date, source: dateSources.get(postId) ?? 'mtime' } : undefined;
|
|
||||||
if (!shouldReplaceDate(current, { date, source })) return;
|
|
||||||
post.date = date;
|
|
||||||
dateSources.set(postId, source);
|
|
||||||
};
|
|
||||||
const mediaFilesMap = new Map<string, ArchiveFile>();
|
const mediaFilesMap = new Map<string, ArchiveFile>();
|
||||||
const discoveredProfilePics: { name: string, url: string }[] = [];
|
const discoveredProfilePics: { name: string, url: string }[] = [];
|
||||||
const allImageFiles: ArchiveFile[] = [];
|
const allImageFiles: ArchiveFile[] = [];
|
||||||
@@ -313,26 +288,13 @@ export const useArchiveScanner = (
|
|||||||
}
|
}
|
||||||
else if (isStory) post.isStory = true;
|
else if (isStory) post.isStory = true;
|
||||||
|
|
||||||
// Files describing one post are scanned in directory order, not in
|
|
||||||
// order of trustworthiness, so every date goes through the ranking
|
|
||||||
// in post-dates.ts rather than last-write-wins.
|
|
||||||
applyDate(postId, post, date, parsed.dateFromMtime ? 'mtime' : 'filename');
|
|
||||||
|
|
||||||
const lowerExt = ext.toLowerCase();
|
const lowerExt = ext.toLowerCase();
|
||||||
if (lowerExt === 'txt') {
|
if (lowerExt === 'txt') {
|
||||||
try { post.caption = await file.text(); } catch(e) {}
|
try { post.caption = await file.text(); } catch(e) {}
|
||||||
} else if (lowerExt === 'json' || lowerName.endsWith('.json.xz')) {
|
} else if (lowerExt === 'json' || lowerName.endsWith('.json.xz')) {
|
||||||
try {
|
try {
|
||||||
const data = lowerName.endsWith('.xz') ? await parseXZFile(file) : JSON.parse(await file.text());
|
const data = lowerName.endsWith('.xz') ? await parseXZFile(file) : JSON.parse(await file.text());
|
||||||
if (isGalleryDlSidecar(data)) {
|
if (data) {
|
||||||
// The only format that states what a post is rather than
|
|
||||||
// leaving it to be inferred from filenames.
|
|
||||||
if (data.description) post.caption = data.description;
|
|
||||||
const reel = sidecarIsReel(data);
|
|
||||||
if (reel !== undefined) post.isReel = reel;
|
|
||||||
if (data.type === 'story') post.isStory = true;
|
|
||||||
applyDate(postId, post, sidecarDate(data), 'sidecar');
|
|
||||||
} else if (data) {
|
|
||||||
const node = data.node || data; const iphone = node.iphone_struct || {};
|
const node = data.node || data; const iphone = node.iphone_struct || {};
|
||||||
const captionText = node.edge_media_to_caption?.edges?.[0]?.node?.text || node.caption?.text || iphone.caption?.text || '';
|
const captionText = node.edge_media_to_caption?.edges?.[0]?.node?.text || node.caption?.text || iphone.caption?.text || '';
|
||||||
if (captionText) post.caption = captionText;
|
if (captionText) post.caption = captionText;
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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,6 +14,7 @@ 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.
|
||||||
@@ -54,6 +55,15 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,39 +3,39 @@ import { classifyDirectory, groupArchiveDirectories } from './archive-grouping';
|
|||||||
|
|
||||||
describe('classifyDirectory', () => {
|
describe('classifyDirectory', () => {
|
||||||
it('treats a bare profile directory as the base', () => {
|
it('treats a bare profile directory as the base', () => {
|
||||||
expect(classifyDirectory('4utumn07')).toEqual({
|
expect(classifyDirectory('0ct0ber19')).toEqual({
|
||||||
owner: '4utumn07',
|
owner: '0ct0ber19',
|
||||||
source: { kind: 'posts', dir: '4utumn07' },
|
source: { kind: 'posts', dir: '0ct0ber19' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recognises a reels sidecar', () => {
|
it('recognises a reels sidecar', () => {
|
||||||
expect(classifyDirectory('4utumn07 - reels')).toEqual({
|
expect(classifyDirectory('0ct0ber19 - reels')).toEqual({
|
||||||
owner: '4utumn07',
|
owner: '0ct0ber19',
|
||||||
source: { kind: 'reels', dir: '4utumn07 - reels' },
|
source: { kind: 'reels', dir: '0ct0ber19 - reels' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recognises a stories sidecar', () => {
|
it('recognises a stories sidecar', () => {
|
||||||
expect(classifyDirectory('story - dawn_petal')).toEqual({
|
expect(classifyDirectory('story - cher_ryppo')).toEqual({
|
||||||
owner: 'dawn_petal',
|
owner: 'cher_ryppo',
|
||||||
source: { kind: 'stories', dir: 'story - dawn_petal' },
|
source: { kind: 'stories', dir: 'story - cher_ryppo' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('splits highlight owner from title', () => {
|
it('splits highlight owner from title', () => {
|
||||||
const { owner, source } = classifyDirectory('story highlights - 4utumn07 - Sunstory');
|
const { owner, source } = classifyDirectory('story highlights - 0ct0ber19 - Heestory');
|
||||||
expect(owner).toBe('4utumn07');
|
expect(owner).toBe('0ct0ber19');
|
||||||
expect(source.kind).toBe('highlight');
|
expect(source.kind).toBe('highlight');
|
||||||
expect(source.title).toBe('Sunstory');
|
expect(source.title).toBe('Heestory');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['story highlights - theoldlyricmuseinsta - 💙1999-2005 era', 'theoldlyricmuseinsta', '💙1999-2005 era'],
|
['story highlights - theoldtaylorswiftinsta - 💙2014-1989 era', 'theoldtaylorswiftinsta', '💙2014-1989 era'],
|
||||||
['story highlights - member_theworld - [Bracket]', 'member_theworld', '[Bracket]'],
|
['story highlights - heejin_theworld - [Dall]', 'heejin_theworld', '[Dall]'],
|
||||||
['story highlights - official_band - Tour Schedule', 'official_band', 'Tour Schedule'],
|
['story highlights - official_artms - Cosmo Schedule', 'official_artms', 'Cosmo Schedule'],
|
||||||
['story highlights - 4utumn07 - Sketching⠀', '4utumn07', 'Sketching⠀'],
|
['story highlights - 0ct0ber19 - Drawheeing⠀', '0ct0ber19', 'Drawheeing⠀'],
|
||||||
['story highlights - official_band - A.B.C', 'official_band', 'A.B.C'],
|
['story highlights - official_artms - G.C.I', 'official_artms', 'G.C.I'],
|
||||||
])('handles real-world title %s', (dir, owner, title) => {
|
])('handles real-world title %s', (dir, owner, title) => {
|
||||||
const result = classifyDirectory(dir);
|
const result = classifyDirectory(dir);
|
||||||
expect(result.owner).toBe(owner);
|
expect(result.owner).toBe(owner);
|
||||||
@@ -57,25 +57,25 @@ describe('classifyDirectory', () => {
|
|||||||
|
|
||||||
describe('groupArchiveDirectories', () => {
|
describe('groupArchiveDirectories', () => {
|
||||||
const dirs = [
|
const dirs = [
|
||||||
'4utumn07',
|
'0ct0ber19',
|
||||||
'4utumn07 - reels',
|
'0ct0ber19 - reels',
|
||||||
'story - 4utumn07',
|
'story - 0ct0ber19',
|
||||||
'story highlights - 4utumn07 - Sunstory',
|
'story highlights - 0ct0ber19 - Heestory',
|
||||||
'story highlights - 4utumn07 - Sketching⠀',
|
'story highlights - 0ct0ber19 - Drawheeing⠀',
|
||||||
'kestrelsings',
|
'carlyraejepsen',
|
||||||
];
|
];
|
||||||
|
|
||||||
it('folds sidecars into their base profile', () => {
|
it('folds sidecars into their base profile', () => {
|
||||||
const groups = groupArchiveDirectories(dirs);
|
const groups = groupArchiveDirectories(dirs);
|
||||||
expect([...groups.keys()].sort()).toEqual(['4utumn07', 'kestrelsings']);
|
expect([...groups.keys()].sort()).toEqual(['0ct0ber19', 'carlyraejepsen']);
|
||||||
expect(groups.get('4utumn07')).toHaveLength(5);
|
expect(groups.get('0ct0ber19')).toHaveLength(5);
|
||||||
expect(groups.get('kestrelsings')).toHaveLength(1);
|
expect(groups.get('carlyraejepsen')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('orders sources posts, reels, stories, then highlights by title', () => {
|
it('orders sources posts, reels, stories, then highlights by title', () => {
|
||||||
const sources = groupArchiveDirectories(dirs).get('4utumn07')!;
|
const sources = groupArchiveDirectories(dirs).get('0ct0ber19')!;
|
||||||
expect(sources.map(s => s.kind)).toEqual(['posts', 'reels', 'stories', 'highlight', 'highlight']);
|
expect(sources.map(s => s.kind)).toEqual(['posts', 'reels', 'stories', 'highlight', 'highlight']);
|
||||||
expect(sources.slice(3).map(s => s.title)).toEqual(['Sketching⠀', 'Sunstory']);
|
expect(sources.slice(3).map(s => s.title)).toEqual(['Drawheeing⠀', 'Heestory']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still groups a sidecar whose base profile is missing', () => {
|
it('still groups a sidecar whose base profile is missing', () => {
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export interface ArchiveSource {
|
|||||||
/**
|
/**
|
||||||
* Sidecar directories sit next to the profile directory they belong to:
|
* Sidecar directories sit next to the profile directory they belong to:
|
||||||
*
|
*
|
||||||
* 4utumn07 -> posts (base)
|
* 0ct0ber19 -> posts (base)
|
||||||
* 4utumn07 - reels -> reels
|
* 0ct0ber19 - reels -> reels
|
||||||
* story - 4utumn07 -> stories
|
* story - 0ct0ber19 -> stories
|
||||||
* story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
|
* story highlights - 0ct0ber19 - Heestory -> highlight "Heestory"
|
||||||
*
|
*
|
||||||
* Instagram usernames cannot contain spaces, so matching the username as a
|
* Instagram usernames cannot contain spaces, so matching the username as a
|
||||||
* run of non-space characters reliably separates it from a highlight title
|
* run of non-space characters reliably separates it from a highlight title
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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,21 +33,6 @@ 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>>();
|
||||||
@@ -58,7 +43,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() && !isSystemDirectory(e.name) && !e.name.startsWith('_'))
|
.filter(e => e.isDirectory() && !/^[.@_]/.test(e.name))
|
||||||
.map(e => e.name);
|
.map(e => e.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +69,6 @@ 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);
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { canonicalItemId, parseArchiveFilename, scopedPostId } from './archive-patterns';
|
import { parseArchiveFilename, scopedPostId } from './archive-patterns';
|
||||||
|
|
||||||
describe('parseArchiveFilename — Instagram export format', () => {
|
describe('parseArchiveFilename — Instagram export format', () => {
|
||||||
it('parses a single-image post', () => {
|
it('parses a single-image post', () => {
|
||||||
expect(parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM.mp4')).toEqual({
|
expect(parseArchiveFilename('2023-04-19_0ct0ber19 - CrORBIcJJbM.mp4')).toEqual({
|
||||||
postId: 'CrORBIcJJbM',
|
postId: 'CrORBIcJJbM',
|
||||||
date: '2023-04-19',
|
date: '2023-04-19',
|
||||||
username: '4utumn07',
|
username: '0ct0ber19',
|
||||||
index: 1,
|
index: 1,
|
||||||
ext: 'mp4',
|
ext: 'mp4',
|
||||||
isStory: false,
|
isStory: false,
|
||||||
dateFromMtime: false,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses a carousel slide index', () => {
|
it('parses a carousel slide index', () => {
|
||||||
const parsed = parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE - 3.jpg');
|
const parsed = parseArchiveFilename('2023-04-12_0ct0ber19 - Cq8LrxSJAJE - 3.jpg');
|
||||||
expect(parsed).toMatchObject({ postId: 'Cq8LrxSJAJE', index: 3, ext: 'jpg' });
|
expect(parsed).toMatchObject({ postId: 'Cq8LrxSJAJE', index: 3, ext: 'jpg' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('parses caption sidecar files', () => {
|
it('parses caption sidecar files', () => {
|
||||||
expect(parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE.txt')).toMatchObject({
|
expect(parseArchiveFilename('2023-04-12_0ct0ber19 - Cq8LrxSJAJE.txt')).toMatchObject({
|
||||||
postId: 'Cq8LrxSJAJE',
|
postId: 'Cq8LrxSJAJE',
|
||||||
ext: 'txt',
|
ext: 'txt',
|
||||||
});
|
});
|
||||||
@@ -39,8 +38,8 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
|||||||
|
|
||||||
it('parses the story sidecar layout (date_user - N - shortcode)', () => {
|
it('parses the story sidecar layout (date_user - N - shortcode)', () => {
|
||||||
// Files in `story - <user>` carry a per-day ordinal before the shortcode.
|
// Files in `story - <user>` carry a per-day ordinal before the shortcode.
|
||||||
const parsed = parseArchiveFilename('2025-10-26_4utumn07 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
const parsed = parseArchiveFilename('2025-10-26_0ct0ber19 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
||||||
expect(parsed).toMatchObject({ date: '2025-10-26', username: '4utumn07', ext: 'jpg' });
|
expect(parsed).toMatchObject({ date: '2025-10-26', username: '0ct0ber19', ext: 'jpg' });
|
||||||
expect(parsed!.postId).toContain('DQRuDx9iW5Q');
|
expect(parsed!.postId).toContain('DQRuDx9iW5Q');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,9 +70,9 @@ describe('parseArchiveFilename — Instaloader format', () => {
|
|||||||
|
|
||||||
describe('parseArchiveFilename — story highlights', () => {
|
describe('parseArchiveFilename — story highlights', () => {
|
||||||
it('parses the dateless highlight layout', () => {
|
it('parses the dateless highlight layout', () => {
|
||||||
expect(parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
expect(parseArchiveFilename('0ct0ber19 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
||||||
postId: 'C5dQPEYpd9W',
|
postId: 'C5dQPEYpd9W',
|
||||||
username: '4utumn07',
|
username: '0ct0ber19',
|
||||||
ext: 'mp4',
|
ext: 'mp4',
|
||||||
isStory: false,
|
isStory: false,
|
||||||
});
|
});
|
||||||
@@ -95,7 +94,7 @@ describe('parseArchiveFilename — story highlights', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('parseArchiveFilename — non-matching files', () => {
|
describe('parseArchiveFilename — non-matching files', () => {
|
||||||
it.each(['4utumn07.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
|
it.each(['0ct0ber19.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
|
||||||
'returns null for %s',
|
'returns null for %s',
|
||||||
name => expect(parseArchiveFilename(name)).toBeNull(),
|
name => expect(parseArchiveFilename(name)).toBeNull(),
|
||||||
);
|
);
|
||||||
@@ -107,8 +106,8 @@ describe('scopedPostId', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('namespaces sidecar ids by directory', () => {
|
it('namespaces sidecar ids by directory', () => {
|
||||||
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Sunstory'))
|
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Heestory'))
|
||||||
.toBe('story highlights - u - Sunstory/C5dQ');
|
.toBe('story highlights - u - Heestory/C5dQ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the same shortcode distinct across sources', () => {
|
it('keeps the same shortcode distinct across sources', () => {
|
||||||
@@ -117,111 +116,3 @@ describe('scopedPostId', () => {
|
|||||||
expect(inPosts).not.toBe(inHighlight);
|
expect(inPosts).not.toBe(inHighlight);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* gallery-dl is replacing JDownloader as the fetcher (docs/gallery-dl.md).
|
|
||||||
* Its naming differs cosmetically, and these cases pin down that the two
|
|
||||||
* interoperate so a mixed archive parses identically.
|
|
||||||
*/
|
|
||||||
describe('gallery-dl / JDownloader naming interop', () => {
|
|
||||||
it('treats a single-media post the same with or without an index', () => {
|
|
||||||
const jd2 = parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM.mp4')!;
|
|
||||||
const gdl = parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM - 1.mp4')!;
|
|
||||||
expect(jd2.postId).toBe(gdl.postId);
|
|
||||||
expect(jd2.index).toBe(gdl.index);
|
|
||||||
expect(jd2.index).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalises zero-padded carousel indices', () => {
|
|
||||||
// JD2 pads to the width of the media count (10+ items -> "01"), and
|
|
||||||
// gallery-dl's count can be one higher, so the same post may be padded
|
|
||||||
// by one tool and not the other.
|
|
||||||
expect(parseArchiveFilename('2024-04-17_4utumn07 - C53YPQzp7Wj - 09.jpg')!.index).toBe(9);
|
|
||||||
expect(parseArchiveFilename('2024-04-17_4utumn07 - C53YPQzp7Wj - 9.jpg')!.index).toBe(9);
|
|
||||||
expect(parseArchiveFilename('2023-11-03_4utumn07 - CzM8Uf6B6H_ - 01.jpg')!.index).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads a gallery-dl story name, which carries a per-item shortcode', () => {
|
|
||||||
const p = parseArchiveFilename('2026-08-16_official_band - DcF9OyhBJ1H.jpg', 'stories')!;
|
|
||||||
expect(p.postId).toBe('DcF9OyhBJ1H');
|
|
||||||
expect(p.date).toBe('2026-08-16');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives a dated highlight a real date instead of the mtime fallback', () => {
|
|
||||||
const mtime = Date.parse('2026-08-17T00:00:00Z');
|
|
||||||
const undated = parseArchiveFilename('4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
|
||||||
const dated = parseArchiveFilename('2024-08-04_4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
|
||||||
// Same item either way, so re-fetching cannot split it into two posts.
|
|
||||||
expect(dated.postId).toBe(undated.postId);
|
|
||||||
expect(undated.date).toBe('2026-08-17');
|
|
||||||
expect(dated.date).toBe('2024-08-04');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Highlights are the only files with no date in the name, so they fall back to
|
|
||||||
* mtime — which is when the file was written, not when it was posted. Callers
|
|
||||||
* need to know the difference to let a real date win.
|
|
||||||
*/
|
|
||||||
describe('dateFromMtime', () => {
|
|
||||||
const mtime = Date.parse('2026-08-17T00:00:00Z');
|
|
||||||
|
|
||||||
it('flags an undated highlight name as mtime-dated', () => {
|
|
||||||
const p = parseArchiveFilename('4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
|
||||||
expect(p.date).toBe('2026-08-17');
|
|
||||||
expect(p.dateFromMtime).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not flag a highlight that carries its own date', () => {
|
|
||||||
const p = parseArchiveFilename('2024-08-04_4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
|
||||||
expect(p.date).toBe('2024-08-04');
|
|
||||||
expect(p.dateFromMtime).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never flags ordinary post or Instaloader names', () => {
|
|
||||||
expect(parseArchiveFilename('2023-04-19_u - ABC.mp4', 'posts', mtime)!.dateFromMtime).toBe(false);
|
|
||||||
expect(parseArchiveFilename('2024-01-01_12-00-00_UTC.jpg', 'posts', mtime)!.dateFromMtime).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves the date empty rather than guessing when no mtime is given', () => {
|
|
||||||
const p = parseArchiveFilename('4utumn07 - C-IImhvpFuk.jpg', 'highlight')!;
|
|
||||||
expect(p.date).toBe('');
|
|
||||||
expect(p.dateFromMtime).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JDownloader wrote story-shaped names for highlights during one period, so
|
|
||||||
* the same item exists under two conventions. They must be one post.
|
|
||||||
*/
|
|
||||||
describe('canonicalItemId', () => {
|
|
||||||
it('collapses the two highlight naming conventions onto one id', () => {
|
|
||||||
const dir = 'story highlights - 4utumn07 - Sunstory';
|
|
||||||
const undated = parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight', 1)!;
|
|
||||||
const dated = parseArchiveFilename('2024-04-07_4utumn07 - 01 - C5dQPEYpd9W.mp4', 'highlight')!;
|
|
||||||
expect(scopedPostId(dated.postId, 'highlight', dir))
|
|
||||||
.toBe(scopedPostId(undated.postId, 'highlight', dir));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does the same for stories', () => {
|
|
||||||
const a = parseArchiveFilename('2025-10-26_u - 2 - DQRuDx9iW5Q.jpg', 'stories')!;
|
|
||||||
expect(scopedPostId(a.postId, 'stories', 'story - u')).toBe('story - u/DQRuDx9iW5Q');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps distinct story items distinct', () => {
|
|
||||||
const a = parseArchiveFilename('2026-08-13_u - 1 - Db-UTJcCUUr.mp4', 'stories')!;
|
|
||||||
const b = parseArchiveFilename('2026-08-13_u - 2 - Db-oNJ1CWQ4.mp4', 'stories')!;
|
|
||||||
expect(scopedPostId(a.postId, 'stories', 'story - u'))
|
|
||||||
.not.toBe(scopedPostId(b.postId, 'stories', 'story - u'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a shortcode that merely starts with digits alone', () => {
|
|
||||||
expect(canonicalItemId('4utumn07')).toBe('4utumn07');
|
|
||||||
expect(canonicalItemId('C5dQPEYpd9W')).toBe('C5dQPEYpd9W');
|
|
||||||
expect(canonicalItemId('12345')).toBe('12345');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not touch posts, whose ids are permalinks', () => {
|
|
||||||
expect(scopedPostId('01 - ABC', 'posts')).toBe('01 - ABC');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -30,15 +30,6 @@ export interface ParsedFilename {
|
|||||||
index: number;
|
index: number;
|
||||||
ext: string;
|
ext: string;
|
||||||
isStory: boolean;
|
isStory: boolean;
|
||||||
/**
|
|
||||||
* True when `date` is the file's mtime rather than anything Instagram said.
|
|
||||||
*
|
|
||||||
* Only highlights fetched by JDownloader lack a date in the filename, and
|
|
||||||
* their mtime is just when the file was written. Callers should let any real
|
|
||||||
* date win over this one — the same item is often also present under a
|
|
||||||
* gallery-dl name that does carry the date.
|
|
||||||
*/
|
|
||||||
dateFromMtime: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,7 +54,6 @@ export const parseArchiveFilename = (
|
|||||||
index: indexStr ? parseInt(indexStr, 10) : 1,
|
index: indexStr ? parseInt(indexStr, 10) : 1,
|
||||||
ext,
|
ext,
|
||||||
isStory: Boolean(story),
|
isStory: Boolean(story),
|
||||||
dateFromMtime: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +67,6 @@ export const parseArchiveFilename = (
|
|||||||
index: indexStr ? parseInt(indexStr, 10) : 1,
|
index: indexStr ? parseInt(indexStr, 10) : 1,
|
||||||
ext,
|
ext,
|
||||||
isStory: Boolean(story),
|
isStory: Boolean(story),
|
||||||
dateFromMtime: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +81,6 @@ export const parseArchiveFilename = (
|
|||||||
index: 1,
|
index: 1,
|
||||||
ext,
|
ext,
|
||||||
isStory: false,
|
isStory: false,
|
||||||
dateFromMtime: Boolean(mtime),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,36 +88,12 @@ export const parseArchiveFilename = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* A leading per-day ordinal on a story or highlight id: `01 - C5dQPEYpd9W`.
|
|
||||||
*
|
|
||||||
* JDownloader wrote story-shaped names for highlights during one period of its
|
|
||||||
* life, so the same item exists as both `user - CODE.jpg` and
|
|
||||||
* `date_user - 01 - CODE.jpg`. Those parse to different ids and the viewer
|
|
||||||
* shows the item twice. The ordinal carries no information the shortcode does
|
|
||||||
* not — it is a position within a day's stories, and the shortcode is already
|
|
||||||
* unique — so it is dropped.
|
|
||||||
*/
|
|
||||||
const LEADING_ORDINAL = /^\d+ - (?=[A-Za-z0-9_-]+$)/;
|
|
||||||
|
|
||||||
/** Strip the ordinal so both naming conventions land on the same post. */
|
|
||||||
export const canonicalItemId = (postId: string): string =>
|
|
||||||
postId.replace(LEADING_ORDINAL, '');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Namespace a post ID by its source directory.
|
* Namespace a post ID by its source directory.
|
||||||
*
|
*
|
||||||
* Base-profile IDs are left untouched so existing permalinks keep working;
|
* Base-profile IDs are left untouched so existing permalinks keep working;
|
||||||
* sidecar IDs are prefixed so a shortcode appearing in both the profile and a
|
* sidecar IDs are prefixed so a shortcode appearing in both the profile and a
|
||||||
* highlight stays two distinct posts.
|
* highlight stays two distinct posts.
|
||||||
*
|
|
||||||
* Story and highlight ids are canonicalised first, so an item fetched under
|
|
||||||
* two different naming conventions is one post rather than two.
|
|
||||||
*/
|
*/
|
||||||
export const scopedPostId = (postId: string, kind: SourceKind, dir?: string): string => {
|
export const scopedPostId = (postId: string, kind: SourceKind, dir?: string): string =>
|
||||||
if (kind === 'posts') return postId;
|
kind === 'posts' ? postId : `${dir ?? kind}/${postId}`;
|
||||||
const id = (kind === 'stories' || kind === 'highlight')
|
|
||||||
? canonicalItemId(postId)
|
|
||||||
: postId;
|
|
||||||
return `${dir ?? kind}/${id}`;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
GalleryDlSidecar, isGalleryDlSidecar, sidecarDate, sidecarIsReel, sidecarSource,
|
|
||||||
} from './gallery-dl-sidecar';
|
|
||||||
|
|
||||||
// Trimmed from real files published to the archive on 2026-08-16.
|
|
||||||
const REEL: GalleryDlSidecar = {
|
|
||||||
post_shortcode: 'Db-lNCoib9m', post_id: '3962768346034323302', type: 'reel',
|
|
||||||
date: '2026-08-13 11:00:44', post_date: '2026-08-13 11:00:44',
|
|
||||||
username: 'official_band', fullname: 'Official ARTMS',
|
|
||||||
description: 'Dancing in the spotlight', count: 1, likes: 22914,
|
|
||||||
};
|
|
||||||
const FEED_VIDEO: GalleryDlSidecar = { ...REEL, post_shortcode: 'DbdG9L9jU4m', type: 'post', count: 2 };
|
|
||||||
const HIGHLIGHT: GalleryDlSidecar = {
|
|
||||||
post_shortcode: 'BATVdRZi_3', post_id: '18099435932626935', type: 'highlight',
|
|
||||||
date: '2026-08-08 16:22:09', username: 'official_band', count: 154,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('isGalleryDlSidecar', () => {
|
|
||||||
it('accepts a real sidecar', () => {
|
|
||||||
expect(isGalleryDlSidecar(REEL)).toBe(true);
|
|
||||||
expect(isGalleryDlSidecar(HIGHLIGHT)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an Instaloader GraphQL payload', () => {
|
|
||||||
expect(isGalleryDlSidecar({ node: { __typename: 'GraphVideo', shortcode: 'x' } })).toBe(false);
|
|
||||||
expect(isGalleryDlSidecar({ __typename: 'GraphImage', post_shortcode: 'x', type: 'post' })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an Instagram export manifest', () => {
|
|
||||||
expect(isGalleryDlSidecar({ media: [{ uri: 'a.jpg' }] })).toBe(false);
|
|
||||||
expect(isGalleryDlSidecar([{ media: [] }])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects junk', () => {
|
|
||||||
for (const v of [null, undefined, 0, '', 'string', {}, { post_shortcode: 'x' }]) {
|
|
||||||
expect(isGalleryDlSidecar(v)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sidecarDate', () => {
|
|
||||||
it('takes the day from the timestamp', () => {
|
|
||||||
expect(sidecarDate(REEL)).toBe('2026-08-13');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to post_date', () => {
|
|
||||||
expect(sidecarDate({ post_shortcode: 'x', post_date: '2024-01-02 03:04:05' })).toBe('2024-01-02');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty when there is no usable date', () => {
|
|
||||||
expect(sidecarDate({ post_shortcode: 'x' })).toBe('');
|
|
||||||
expect(sidecarDate({ post_shortcode: 'x', date: 'not a date' })).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sidecarIsReel', () => {
|
|
||||||
it('distinguishes a reel from an ordinary feed video', () => {
|
|
||||||
// Both are single mp4s -- the lone-video heuristic cannot tell them apart.
|
|
||||||
expect(sidecarIsReel(REEL)).toBe(true);
|
|
||||||
expect(sidecarIsReel(FEED_VIDEO)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('declines to answer for stories and highlights', () => {
|
|
||||||
expect(sidecarIsReel(HIGHLIGHT)).toBeUndefined();
|
|
||||||
expect(sidecarIsReel({ post_shortcode: 'x', type: 'story' as const })).toBeUndefined();
|
|
||||||
expect(sidecarIsReel({ post_shortcode: 'x' })).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sidecarSource', () => {
|
|
||||||
it('maps type onto the archive source kinds', () => {
|
|
||||||
expect(sidecarSource(REEL)).toBe('reels');
|
|
||||||
expect(sidecarSource(FEED_VIDEO)).toBe('posts');
|
|
||||||
expect(sidecarSource(HIGHLIGHT)).toBe('highlight');
|
|
||||||
expect(sidecarSource({ post_shortcode: 'x', type: 'story' as const })).toBe('stories');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is undefined for an unknown type', () => {
|
|
||||||
expect(sidecarSource({ post_shortcode: 'x' })).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { SourceKind } from '../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* gallery-dl `.json` metadata sidecars.
|
|
||||||
*
|
|
||||||
* Written one per post next to the media (see docs/gallery-dl.md). This is the
|
|
||||||
* only source in any archive format that states outright what a post *is* —
|
|
||||||
* `type` is Instagram's own classification, the `product_type: "clips"` signal
|
|
||||||
* carried through the listing response. Everything else the viewer knows about
|
|
||||||
* reels is guesswork from filenames and directory names.
|
|
||||||
*
|
|
||||||
* Deliberately separate from the two older JSON shapes the scanner reads:
|
|
||||||
*
|
|
||||||
* Instagram export `posts_1.json`, an array of entries with `media`
|
|
||||||
* Instaloader `.json.xz`, a GraphQL node under `node`
|
|
||||||
* gallery-dl this — flat, no wrapper
|
|
||||||
*/
|
|
||||||
export interface GalleryDlSidecar {
|
|
||||||
post_shortcode: string;
|
|
||||||
post_id?: string;
|
|
||||||
/** Instagram's own classification of the post. */
|
|
||||||
type?: 'post' | 'reel' | 'story' | 'highlight';
|
|
||||||
/** Local-time "YYYY-MM-DD HH:MM:SS" — gallery-dl is configured to emit local. */
|
|
||||||
date?: string;
|
|
||||||
post_date?: string;
|
|
||||||
username?: string;
|
|
||||||
fullname?: string;
|
|
||||||
description?: string;
|
|
||||||
count?: number;
|
|
||||||
likes?: number;
|
|
||||||
post_url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recognise a gallery-dl sidecar.
|
|
||||||
*
|
|
||||||
* Checked structurally rather than by filename, because the older formats are
|
|
||||||
* also plain `.json`. `node` and `__typename` are what an Instaloader or
|
|
||||||
* export payload carries, and their absence is what makes this shape
|
|
||||||
* unambiguous.
|
|
||||||
*/
|
|
||||||
export const isGalleryDlSidecar = (data: unknown): data is GalleryDlSidecar => {
|
|
||||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return false;
|
|
||||||
const o = data as Record<string, unknown>;
|
|
||||||
return typeof o.post_shortcode === 'string'
|
|
||||||
&& typeof o.type === 'string'
|
|
||||||
&& o.node === undefined
|
|
||||||
&& o.__typename === undefined
|
|
||||||
&& o.media === undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The ISO date (YYYY-MM-DD) a sidecar reports, or '' if it carries none. */
|
|
||||||
export const sidecarDate = (s: GalleryDlSidecar): string => {
|
|
||||||
const raw = s.date || s.post_date || '';
|
|
||||||
const day = raw.slice(0, 10);
|
|
||||||
return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the sidecar says this post is a reel.
|
|
||||||
*
|
|
||||||
* Returns undefined rather than false for stories and highlights: those are
|
|
||||||
* neither reels nor grid posts, and answering "no" would let them be counted
|
|
||||||
* as ordinary posts.
|
|
||||||
*/
|
|
||||||
export const sidecarIsReel = (s: GalleryDlSidecar): boolean | undefined => {
|
|
||||||
if (s.type === 'reel') return true;
|
|
||||||
if (s.type === 'post') return false;
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which source kind the sidecar implies, for cross-checking the directory.
|
|
||||||
*
|
|
||||||
* A reel shared to the profile grid legitimately appears under `posts`, so a
|
|
||||||
* disagreement is not an error — the directory says where the file was
|
|
||||||
* fetched from, `type` says what Instagram considers it.
|
|
||||||
*/
|
|
||||||
export const sidecarSource = (s: GalleryDlSidecar): SourceKind | undefined => {
|
|
||||||
switch (s.type) {
|
|
||||||
case 'reel': return 'reels';
|
|
||||||
case 'post': return 'posts';
|
|
||||||
case 'story': return 'stories';
|
|
||||||
case 'highlight': return 'highlight';
|
|
||||||
default: return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { DatedValue, preferDate, shouldReplaceDate } from './post-dates';
|
|
||||||
|
|
||||||
const sidecar: DatedValue = { date: '2024-04-07', source: 'sidecar' };
|
|
||||||
const filename: DatedValue = { date: '2024-04-08', source: 'filename' };
|
|
||||||
const mtime: DatedValue = { date: '2026-08-17', source: 'mtime' };
|
|
||||||
|
|
||||||
describe('date precedence', () => {
|
|
||||||
it('ranks sidecar above filename above mtime', () => {
|
|
||||||
expect(preferDate(mtime, filename)).toEqual(filename);
|
|
||||||
expect(preferDate(filename, sidecar)).toEqual(sidecar);
|
|
||||||
expect(preferDate(mtime, sidecar)).toEqual(sidecar);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never lets a weaker source overwrite a stronger one', () => {
|
|
||||||
expect(preferDate(sidecar, filename)).toEqual(sidecar);
|
|
||||||
expect(preferDate(sidecar, mtime)).toEqual(sidecar);
|
|
||||||
expect(preferDate(filename, mtime)).toEqual(filename);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the incumbent on a tie, so scan order cannot flip the date', () => {
|
|
||||||
const other: DatedValue = { date: '2020-01-01', source: 'filename' };
|
|
||||||
expect(preferDate(filename, other)).toEqual(filename);
|
|
||||||
expect(preferDate(other, filename)).toEqual(other);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts anything when nothing is held yet', () => {
|
|
||||||
expect(preferDate(undefined, mtime)).toEqual(mtime);
|
|
||||||
expect(shouldReplaceDate(undefined, mtime)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores an empty date regardless of source', () => {
|
|
||||||
const empty: DatedValue = { date: '', source: 'sidecar' };
|
|
||||||
expect(shouldReplaceDate(filename, empty)).toBe(false);
|
|
||||||
expect(preferDate(filename, empty)).toEqual(filename);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces a held-but-empty date', () => {
|
|
||||||
const empty: DatedValue = { date: '', source: 'filename' };
|
|
||||||
expect(preferDate(empty, mtime)).toEqual(mtime);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is order-independent for the full three-source case', () => {
|
|
||||||
const orders = [
|
|
||||||
[mtime, filename, sidecar],
|
|
||||||
[sidecar, mtime, filename],
|
|
||||||
[filename, sidecar, mtime],
|
|
||||||
[mtime, sidecar, filename],
|
|
||||||
];
|
|
||||||
for (const order of orders) {
|
|
||||||
const won = order.reduce<DatedValue | undefined>(
|
|
||||||
(acc, next) => preferDate(acc, next), undefined);
|
|
||||||
expect(won).toEqual(sidecar);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Where a post's date came from, and which source wins.
|
|
||||||
*
|
|
||||||
* A post is usually described by several files — media, a caption `.txt`, a
|
|
||||||
* `.json` sidecar, sometimes the same item under two naming conventions — and
|
|
||||||
* they are scanned in directory order, not in order of trustworthiness. Without
|
|
||||||
* an explicit ranking the date is decided by whichever file happened to be
|
|
||||||
* reached first.
|
|
||||||
*
|
|
||||||
* Ranked best to worst:
|
|
||||||
*
|
|
||||||
* sidecar what Instagram reported, straight from a gallery-dl `.json`
|
|
||||||
* filename a date the fetcher wrote into the name; correct, but derived
|
|
||||||
* mtime when the file was written to disk — unrelated to when it was
|
|
||||||
* posted, and only ever a last resort for JDownloader highlights,
|
|
||||||
* whose filenames carry no date at all
|
|
||||||
*/
|
|
||||||
export type DateSource = 'sidecar' | 'filename' | 'mtime';
|
|
||||||
|
|
||||||
const RANK: Record<DateSource, number> = { sidecar: 0, filename: 1, mtime: 2 };
|
|
||||||
|
|
||||||
export interface DatedValue {
|
|
||||||
date: string;
|
|
||||||
source: DateSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether `next` should replace the date currently held.
|
|
||||||
*
|
|
||||||
* Ties keep the incumbent, so scanning stays stable: two files of equal
|
|
||||||
* authority cannot flip a post's date back and forth by scan order.
|
|
||||||
*/
|
|
||||||
export const shouldReplaceDate = (
|
|
||||||
current: DatedValue | undefined,
|
|
||||||
next: DatedValue,
|
|
||||||
): boolean => {
|
|
||||||
if (!next.date) return false;
|
|
||||||
if (!current || !current.date) return true;
|
|
||||||
return RANK[next.source] < RANK[current.source];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Apply `next` if it outranks `current`, otherwise keep what we have. */
|
|
||||||
export const preferDate = (
|
|
||||||
current: DatedValue | undefined,
|
|
||||||
next: DatedValue,
|
|
||||||
): DatedValue => (shouldReplaceDate(current, next) ? next : (current ?? next));
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Once an archive carries gallery-dl sidecars, the guesswork above is replaced
|
|
||||||
* by Instagram's own classification. These are the cases the heuristic got
|
|
||||||
* wrong (see docs/gallery-dl.md).
|
|
||||||
*/
|
|
||||||
describe('explicit isReel from a sidecar', () => {
|
|
||||||
it('beats the lone-video heuristic for an ordinary feed video', () => {
|
|
||||||
// A single mp4 that Instagram calls a post, not a reel — indistinguishable
|
|
||||||
// by shape alone.
|
|
||||||
const posts = [video('DbdG9L9jU4m', { isReel: false })];
|
|
||||||
expect(postsForTab(posts, 'reels')).toEqual([]);
|
|
||||||
expect(postsForTab(posts, 'posts')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('recognises a reel that lives in the profile grid', () => {
|
|
||||||
// Shared to feed, so it sits in the base directory with source 'posts'.
|
|
||||||
const posts = [post('A'), video('C8FHM6EJl15', { source: 'posts', isReel: true })];
|
|
||||||
expect(postsForTab(posts, 'reels').map(p => p.id)).toEqual(['C8FHM6EJl15']);
|
|
||||||
expect(postsForTab(posts, 'posts')).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('beats the directory when both are present', () => {
|
|
||||||
const posts = [
|
|
||||||
video('u - reels/A', { source: 'reels', isReel: false }),
|
|
||||||
video('u - reels/B', { source: 'reels' }),
|
|
||||||
];
|
|
||||||
// A is a feed video that the reels tab happened to return; B is unlabelled
|
|
||||||
// and falls back to its directory.
|
|
||||||
expect(postsForTab(posts, 'reels').map(p => p.id)).toEqual(['u - reels/B']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back per post, so a mixed archive still works', () => {
|
|
||||||
const posts = [
|
|
||||||
video('labelled', { isReel: true }),
|
|
||||||
video('unlabelled'),
|
|
||||||
carousel('C'),
|
|
||||||
];
|
|
||||||
expect(postsForTab(posts, 'reels').map(p => p.id)).toEqual(['labelled', 'unlabelled']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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) => {
|
|
||||||
const guess = hasReelSource(posts)
|
|
||||||
? (post: Post) => post.source === 'reels'
|
|
||||||
: (post: Post) => post.media.length === 1 && post.media[0]?.type === 'video';
|
|
||||||
|
|
||||||
// `isReel` comes from a gallery-dl sidecar and is Instagram's own answer, so
|
|
||||||
// it beats both fallbacks — per post, since an archive is usually a mix of
|
|
||||||
// files fetched before and after sidecars existed.
|
|
||||||
return (post: Post) => post.isReel ?? guess(post);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 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));
|
|
||||||
};
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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] v${__APP_VERSION__} registered; hourly update checks enabled.`);
|
console.log('[PWA] Service Worker registered and update interval set.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNeedRefresh() {
|
onNeedRefresh() {
|
||||||
|
|||||||
Vendored
-9
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
+1
-6
@@ -37,12 +37,6 @@ export interface Post {
|
|||||||
isStory?: boolean;
|
isStory?: boolean;
|
||||||
/** Defaults to 'posts' for archives without sidecar directories. */
|
/** Defaults to 'posts' for archives without sidecar directories. */
|
||||||
source?: SourceKind;
|
source?: SourceKind;
|
||||||
/**
|
|
||||||
* Instagram's own answer to "is this a reel", from a gallery-dl `.json`
|
|
||||||
* sidecar. Undefined when the archive carries no such sidecar, which is when
|
|
||||||
* the viewer has to fall back to guessing — see src/lib/post-tabs.ts.
|
|
||||||
*/
|
|
||||||
isReel?: boolean;
|
|
||||||
/** Highlight this post belongs to, for source === 'highlight'. */
|
/** Highlight this post belongs to, for source === 'highlight'. */
|
||||||
highlightTitle?: string;
|
highlightTitle?: string;
|
||||||
}
|
}
|
||||||
@@ -56,6 +50,7 @@ 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,24 +3,9 @@ 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