Compare commits

..
31 Commits
Author SHA1 Message Date
ergosteurandClaude Opus 5 97f5d19ce4 chore: bump version to 1.3.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 01:59:41 -04:00
ergosteurandClaude Opus 5 1b4aba54d6 fix: security, performance and correctness pass; add sidecar archive support
Security
- Fix path traversal in GET /api/archives/:name/files. Express decodes route
  params after segment matching, so `..%2f..%2fetc` escaped ARCHIVES_DIR and
  returned a recursive listing of arbitrary directories.
- Add CSP and baseline security headers; disable x-powered-by.
- Stop baking GEMINI_API_KEY into the client bundle (the SDK was unused).
- Run the container as `node` instead of root.

Performance
- Add a directory-mtime-keyed archive index, warmed in the background and
  persisted. Listing 110k files went from ~52s to ~0.1s; the largest archive
  (24k files) serves in ~0.3s. Per-file stat over CIFS costs ~1.4ms and does
  not parallelise, so it is now done once rather than per request.
- Build media URLs from the File directly instead of
  `new Blob([await file.arrayBuffer()])`, which read every media file fully
  into memory (a 20GB archive tried to become 20GB of resident blobs).
- Track and revoke object URLs; previously none were ever revoked.
- Give `requestThumbnail` a stable identity so a completed thumbnail stops
  re-running the effect in every mounted thumbnail.
- Namespace IndexedDB keys so listing archives no longer deserializes every
  cached thumbnail blob, and thumbnails no longer collide across archives.
- Serve real file sizes: RemoteArchiveFile was constructed with size 0, which
  silently disabled high-res thumbnailing for every server archive.

Correctness
- Local archives cached media as blob: URLs, which die with the document, so
  a cached local archive restored as an archive of broken images. Media now
  carries a stable path and is rehydrated from a persisted directory handle
  (File System Access API), falling back to re-prompting for the folder.
- Fix permalinks: the URL-writing effect erased ?a= on mount before the
  archive list arrived to consume it, so deep links never resolved.
- Make cache invalidation detect nested changes via a directory signature.
- Add an error boundary and tolerate unparseable dates, which previously
  threw a RangeError and blanked the app.
- Default video to muted so autoplay is not blocked by Safari/Firefox.

Features
- Fold sidecar directories into their base profile: `<user> - reels`,
  `story - <user>` and `story highlights - <user> - <title>` now appear as
  reels, the story ring and Instagram-style highlight circles rather than as
  separate archives.

Housekeeping
- Add @types/react; React was previously type-checked against its JavaScript
  source, so `npm run lint` gave almost no type safety on components.
- Vendor fonts and PWA icons locally; the app made third-party CDN requests
  despite advertising offline support and local-only processing.
- Drop unused better-sqlite3 (a native module that broke `npm install`).
- Add vitest with 36 tests over the filename and directory-naming rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 01:59:19 -04:00
ergosteur b30285fe70 docs: update documentation for high-res performance and local persistence
Docker Build and Publish / build-and-push (push) Failing after 10s
Key changes:
- Updated README.md and GEMINI.md with details on background thumbnailing and inter-post preloading.
- Documented persistent local archive caching and smart profile fallback features.
- Added dist-server/ to .gitignore.
- Restored missing feature descriptions and troubleshooting tips in README.
2026-03-07 21:59:43 -05:00
ergosteur 20209bcad5 feat: enable persistent local archives and smart profile fallback
Key changes:
- Enabled full metadata caching for local folder archives, allowing them to load instantly from IndexedDB without re-uploading.
- Implemented oldest-image fallback for profiles missing an explicit profile picture.
- Restored folder-name-to-username detection for local archive uploads.
- Optimized scan indexing to track all image files for fallback use.
2026-03-07 21:56:25 -05:00
ergosteur 74902234b3 fix: restore white glass scanning UI and resolve small image blur bug
Key changes:
- Corrected logic in PostThumbnail to prevent blur effects on images smaller than 1MiB.
- Restored the white glass aesthetic to the scanning dashboard with improved contrast and transparency.
- Optimized scanning background transitions to ensure a smooth, flicker-free crossfade.
2026-03-07 21:46:11 -05:00
ergosteur d62bddc3aa perf: implement background thumbnail generation and inter-post preloading
Key changes:
- Added Web Worker for background image thumbnailing with a 1MiB threshold to optimize CPU/memory usage.
- Implemented a serial task queue for memory-safe high-res image processing, preventing OOM crashes.
- Added inter-post preloading in the modal for seamless 'Previous/Next' navigation.
- Refined scanning UI with double-buffering and a dark background to completely eliminate white flashes.
- Renamed project to 'instaarchive-viewer' in package.json.
- Fixed 'Open image in new tab' by denylisting /archives and /api in PWA config.
2026-03-07 21:42:56 -05:00
ergosteur 42c13ea106 chore: bump version to 1.2.0
Docker Build and Publish / build-and-push (push) Failing after 9s
2026-03-07 21:16:39 -05:00
ergosteur a4e9ce16a7 feat: modularize scanner, enhance carousel preloading, and improve PWA updates
Summary of changes:
- Extracted archive scanning logic into a modular 'useArchiveScanner' hook for better maintainability and performance.
- Refined PostModal carousel with intelligent media preloading and smoother, jitter-free transitions.
- Optimized image rendering with 'decoding=async' and removed 'black flashes' between slide changes.
- Updated PWA configuration to 'autoUpdate' with hourly periodic checks for fresh content.
- Fixed several bugs including stories sorting, permalink parameter cleanup, and profile metadata cache restoration.
- Comprehensive updates to documentation (README.md and GEMINI.md) reflecting the new architecture.
2026-03-07 21:16:28 -05:00
ergosteur 4f89a69ee3 fix: optimize scanning performance and resolve zero-post bug
Docker Build and Publish / build-and-push (push) Failing after 10s
2026-03-07 20:25:23 -05:00
ergosteur 147dcdf2f1 fix: restore missing UI handlers and finalize generic parser
Docker Build and Publish / build-and-push (push) Failing after 10s
2026-03-07 20:18:33 -05:00
ergosteur d4e20d9b98 fix: refine dockerignore and bump version to v1.1.4
Docker Build and Publish / build-and-push (push) Failing after 10s
2026-03-07 20:10:40 -05:00
ergosteur ec8c771733 feat: implement permalinks and document PWA cache troubleshooting
Docker Build and Publish / build-and-push (push) Failing after 10s
2026-03-07 05:20:09 -05:00
ergosteur 3784e8729b debug: add verbose logging to permalink synchronization 2026-03-07 05:12:14 -05:00
ergosteur 69d62eaa5c fix: improve permalink comparison and add debug logging 2026-03-07 05:10:43 -05:00
ergosteur 767f9c508b feat: implement permalinks for archives, tabs, and posts 2026-03-07 05:07:04 -05:00
ergosteur 103ce6f207 fix: exhaustive generic parser and implement local archive history 2026-03-07 05:05:08 -05:00
ergosteur 5267dab236 fix: address Docker EACCES errors with better logging and SELinux hints
Docker Build and Publish / build-and-push (push) Failing after 9s
2026-03-07 03:02:02 -05:00
ergosteur ebf2bf660a fix: improve Docker archive discovery and switch to compiled server
Docker Build and Publish / build-and-push (push) Failing after 1m6s
2026-03-07 02:57:19 -05:00
ergosteur c0f3523a9c docs: update README and GEMINI with Docker usage and new features 2026-03-07 02:50:18 -05:00
ergosteur 6f5021638c feat: add Dockerfile and GitHub Actions workflow for GHCR deployment
Docker Build and Publish / build-and-push (push) Canceled after 11s
2026-03-07 02:45:17 -05:00
ergosteur 67f7750157 feat: refine navigation protection to only warn when leaving the app 2026-03-07 02:41:16 -05:00
ergosteur 9e306eb85e feat: add explicit confirmation for back button and refresh in archives 2026-03-07 02:39:39 -05:00
ergosteur b2da08d52d feat: add navigation protection and refine cached badge visibility 2026-03-07 02:37:17 -05:00
ergosteur d7c13ecc19 fix: resolve Firefox media warnings by improving video cleanup 2026-03-07 02:30:46 -05:00
ergosteur d396b356be feat: implement persistent caching, glassy scanning UI, and UI refinements 2026-03-07 02:28:22 -05:00
ergosteur e23dfe4474 feat: implement self-hostable mode with server-side directory scanning 2026-03-07 00:59:31 -05:00
ergosteur 41e7c5e206 docs: update README and GEMINI.md, remove AI Studio boilerplate and .env.example 2026-03-07 00:36:46 -05:00
ergosteur f685eaebd7 feat: enhance story viewer and media playback experience 2026-03-07 00:31:04 -05:00
ergosteur 47e44ec5e9 feat: improve archive parsing, add .json.xz support, and fix profile pic display 2026-03-07 00:03:54 -05:00
ergosteur cd7dc5f981 feat: Initialize InstaArchive PWA project
Sets up a new React PWA project with Vite, Tailwind CSS, and basic PWA features. Includes essential files like README, .gitignore, package.json, and initial app structure.
2026-03-06 22:41:48 -05:00
ergosteurandGitHub a724e5bc87 Initial commit 2026-03-06 22:41:33 -05:00
24 changed files with 199 additions and 1112 deletions
+33 -76
View File
@@ -4,106 +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`)
### Cache and local-archive persistence (`src/lib/archive-cache.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.
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. ### URL state sync (`src/App.tsx`)
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. 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.
### 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.
### 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`).
- Sets CSP and related security headers. The CSP allows `blob:`/`data:` for media and `unsafe-inline` styles (the animation library sets inline styles); scripts stay same-origin only.
- `os.userInfo()` throws for a UID with no `/etc/passwd` entry, which is what `--user 1234:1234` produces — use `describeUser()`.
### Deployment
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.
+2 -19
View File
@@ -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
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "instaarchive-viewer", "name": "instaarchive-viewer",
"version": "1.6.0", "version": "1.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "instaarchive-viewer", "name": "instaarchive-viewer",
"version": "1.6.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
View File
@@ -1,7 +1,7 @@
{ {
"name": "instaarchive-viewer", "name": "instaarchive-viewer",
"private": true, "private": true,
"version": "1.6.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",
+2 -18
View File
@@ -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}`);
@@ -130,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);
+60 -108
View File
@@ -17,8 +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 { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files'; import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files';
import { import {
deleteCachedArchive, deleteCachedArchive,
@@ -38,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';
@@ -64,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);
@@ -111,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,
@@ -368,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;
} }
@@ -397,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]);
@@ -422,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 (
@@ -484,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">
@@ -535,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'}`}
> >
@@ -555,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>
)} )}
@@ -571,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)}
@@ -587,36 +554,21 @@ export default function App() {
)} )}
</main> </main>
{/* <AnimatePresence>
Mobile opens a real scrolling feed page, the way Instagram does; desktop {selectedPost && (
keeps the modal, where a centred sheet with side arrows fits the pointer. <PostModal
*/} post={selectedPost}
{selectedPost && isMobile ? ( nextPost={postIndex < filteredPosts.length - 1 ? filteredPosts[postIndex + 1] : undefined}
<PostFeed prevPost={postIndex > 0 ? filteredPosts[postIndex - 1] : undefined}
posts={filteredPosts} onClose={() => setSelectedPost(null)}
initialPostId={selectedPost.id} onNextPost={onNextPost}
profilePic={profilePic} onPrevPost={onPrevPost}
title={activeTab === 'reels' ? 'Reels' : 'Posts'} hasNextPost={postIndex < filteredPosts.length - 1}
onClose={() => setSelectedPost(null)} hasPrevPost={postIndex > 0}
onActivePostChange={setSelectedPost} profilePic={profilePic}
/> />
) : ( )}
<AnimatePresence> </AnimatePresence>
{selectedPost && (
<PostModal
post={selectedPost}
nextPost={postIndex < filteredPosts.length - 1 ? filteredPosts[postIndex + 1] : undefined}
prevPost={postIndex > 0 ? filteredPosts[postIndex - 1] : undefined}
onClose={() => setSelectedPost(null)}
onNextPost={onNextPost}
onPrevPost={onPrevPost}
hasNextPost={postIndex < filteredPosts.length - 1}
hasPrevPost={postIndex > 0}
profilePic={profilePic}
/>
)}
</AnimatePresence>
)}
<AnimatePresence>{showStoryViewer && allStories.length > 0 && <StoryViewer stories={allStories} onClose={() => setShowStoryViewer(false)} profilePic={profilePic} />}</AnimatePresence> <AnimatePresence>{showStoryViewer && allStories.length > 0 && <StoryViewer stories={allStories} onClose={() => setShowStoryViewer(false)} profilePic={profilePic} />}</AnimatePresence>
<AnimatePresence> <AnimatePresence>
{activeHighlight && ( {activeHighlight && (
-67
View File
@@ -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>
);
-112
View File
@@ -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>
);
};
+6 -65
View File
@@ -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>
-150
View File
@@ -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>
);
};
+20 -75
View File
@@ -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>
+5 -29
View File
@@ -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>
); );
}; };
+3 -15
View File
@@ -107,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; }
}; };
-27
View File
@@ -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;
};
+10
View File
@@ -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;
} }
+28 -28
View File
@@ -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', () => {
+4 -4
View File
@@ -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
-34
View File
@@ -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);
});
});
+1 -17
View File
@@ -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);
+11 -11
View File
@@ -3,10 +3,10 @@ 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,
@@ -14,7 +14,7 @@ describe('parseArchiveFilename — Instagram export format', () => {
}); });
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' });
}); });
@@ -26,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',
}); });
@@ -38,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');
}); });
@@ -70,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,
}); });
@@ -94,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(),
); );
@@ -106,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', () => {
-44
View File
@@ -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;
-114
View File
@@ -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');
});
});
-85
View File
@@ -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
View File
@@ -50,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