Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
877d21ff1f | ||
|
|
0cff91edae | ||
|
|
0b4b20e0ff | ||
|
|
c0b6b6cf3e | ||
|
|
ab7099220a | ||
|
|
e6663657b0 | ||
|
|
24ff2727c8 | ||
|
|
f089e49e84 | ||
|
|
1d86fa3583 | ||
|
|
0ba7a0d9ad | ||
|
|
899e8dfbbb | ||
|
|
8809b7794b | ||
|
|
8ec2c07b3f | ||
|
|
3c50be286e | ||
|
|
7fc31fed94 | ||
|
|
0ed4cf292c | ||
|
|
f51b82e37a | ||
|
|
e539677ec3 | ||
|
|
418997779c | ||
|
|
3896506600 | ||
|
|
bc0f4c230d | ||
|
|
ad2f6ec083 | ||
|
|
bdcedab2da | ||
|
|
c196f40cfd | ||
|
|
2d0501f124 | ||
|
|
be54b42e9e | ||
|
|
5842a773d3 | ||
|
|
eb66250511 | ||
|
|
c34aaae63c | ||
|
|
5bd6534adb | ||
|
|
c86ee00e9b | ||
|
|
ab0001efbc | ||
|
|
afacc61888 | ||
|
|
ee87e9b523 | ||
|
|
0e30c8b8b0 | ||
|
|
c8e3026ffd | ||
|
|
e4c53364c3 | ||
|
|
eb8adf9dc2 |
@@ -4,63 +4,96 @@ 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 (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.
|
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.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `npm install` — install dependencies
|
- `npm install` — install dependencies
|
||||||
- `npm run dev` — start Vite dev server on port 3000 (proxies `/api` and `/archives` to `http://localhost:3001`)
|
- `npm run dev` — Vite dev server on port 3000 (proxies `/api` and `/archives` to `http://localhost:3001`)
|
||||||
- `npm run server` — start the Express backend (`tsx server.ts`) on port 3001, serving archives from `ARCHIVES_DIR` (defaults to `./_sample-archives`)
|
- `npm run server` — Express backend (`tsx server.ts`) on port 3001, serving `ARCHIVES_DIR` (defaults to `./_sample-archives`)
|
||||||
- `npm run build` — build frontend to `dist/` (`vite build`) and backend to `dist-server/` (`tsc server.ts ...`)
|
- `npm run build` — frontend to `dist/`, backend to `dist-server/`
|
||||||
- `npm run lint` — type-check only (`tsc --noEmit`); there is no separate test suite or linter config
|
- `npm run lint` — type-check only (`tsc --noEmit`)
|
||||||
- `npm run clean` — remove `dist/`
|
- `npm test` / `npm run test:watch` — vitest
|
||||||
|
- `npx vitest run src/lib/archive-patterns.test.ts` — a single test file
|
||||||
|
|
||||||
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).
|
Local development usually needs both `npm run dev` and `npm run server`. Local-folder mode works without the backend; server-mode archives do not.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Two archive sources, one data model
|
### Two archive sources, one data model
|
||||||
|
|
||||||
The app supports loading archives two ways, unified behind the `ArchiveFile` interface (`src/types/index.ts`, implementations in `src/lib/archive-files.ts`):
|
Loading is unified behind the `ArchiveFile` interface (`src/types/index.ts`, implementations in `src/lib/archive-files.ts`):
|
||||||
|
|
||||||
- **`LocalArchiveFile`** — wraps a browser `File` from a local folder picker (`webkitdirectory`). Fully offline, media is never uploaded anywhere.
|
- **`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.
|
||||||
- **`RemoteArchiveFile`** — wraps a file served from the Express backend's `/archives/:name/...` static route, fetched on demand.
|
- **`RemoteArchiveFile`** — wraps a file served from `/archives/...`, fetched on demand.
|
||||||
|
|
||||||
All downstream parsing code (`useArchiveScanner`) operates only on `ArchiveFile[]` and doesn't care which backing implementation it got.
|
`revocable` tells callers whether the returned URL must be revoked. The scanner tracks every minted URL and releases them on archive teardown.
|
||||||
|
|
||||||
|
### 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`)
|
||||||
|
|
||||||
This is the core of the app — a single large `handleFiles` function that:
|
`handleFiles` indexes files, detects format, then parses via one of three largely independent paths that all write into a shared `postsMap`:
|
||||||
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`).
|
|
||||||
|
|
||||||
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`.
|
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.
|
||||||
|
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.
|
||||||
|
|
||||||
### Thumbnail generation (`src/hooks/useThumbnailQueue.ts` + `src/lib/thumbnail-worker.ts`)
|
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.
|
||||||
|
|
||||||
High-res images (>1MiB) are downscaled off the main thread:
|
### Cache and local-archive persistence (`src/lib/archive-cache.ts`)
|
||||||
- `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.
|
|
||||||
|
|
||||||
### URL state sync (`src/App.tsx`)
|
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.
|
||||||
|
|
||||||
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.
|
Restoring an archive **rehydrates URLs from `path`**: server archives rebuild HTTP URLs; local archives re-open a persisted `FileSystemDirectoryHandle` and mint fresh blob URLs. If the folder is unreachable (permission lapsed, or the browser lacks `showDirectoryPicker` — Firefox/Safari), the app re-prompts rather than rendering broken images.
|
||||||
|
|
||||||
|
### Thumbnails (`src/hooks/useThumbnailQueue.ts` + `src/lib/thumbnail-worker.ts`)
|
||||||
|
|
||||||
|
Images over 1MiB are downscaled in a Web Worker via `OffscreenCanvas`. The queue is **serial on purpose** — decoding several 50MP+ images at once OOMs the tab. `requestThumbnail` must keep a stable identity (it reads cache state through a ref), or every completed thumbnail re-runs the effect in all mounted thumbnails.
|
||||||
|
|
||||||
|
### URL state (`src/App.tsx`)
|
||||||
|
|
||||||
|
App state syncs to `?a=` / `?t=` / `?p=`. Two rules, both learned from real bugs:
|
||||||
|
|
||||||
|
- The initial query string is captured into a ref on first render; the URL is rewritten from state as soon as anything loads, so reading `window.location` later sees the rewrite, not the user's link.
|
||||||
|
- 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*, not on `isServerMode` (which is still false while in flight).
|
||||||
|
|
||||||
### Backend (`server.ts`)
|
### Backend (`server.ts`)
|
||||||
|
|
||||||
Minimal Express server, three responsibilities only:
|
Serves `/api/archives`, `/api/archives/:name/files`, static `/archives`, and the built SPA. Notes:
|
||||||
- `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.
|
|
||||||
|
|
||||||
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`).
|
- Express decodes route params **after** segment matching, so `..%2f` reaches the handler as `../`. All user-supplied archive names go through `resolveArchivePath`.
|
||||||
|
- Sets CSP and related security headers. The CSP allows `blob:`/`data:` for media and `unsafe-inline` styles (the animation library sets inline styles); scripts stay same-origin only.
|
||||||
|
- `os.userInfo()` throws for a UID with no `/etc/passwd` entry, which is what `--user 1234:1234` produces — use `describeUser()`.
|
||||||
|
|
||||||
|
### 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'` — 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.
|
- `vite.config.ts` sets `hmr: process.env.DISABLE_HMR !== 'true'` — intentional, leave it.
|
||||||
- `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").
|
- `workbox.navigateFallbackDenylist` excludes `/api` and `/archives` so those hit the real server.
|
||||||
- Service worker uses `registerType: 'autoUpdate'` with hourly periodic checks — new deployments propagate to open clients automatically.
|
- Fonts and icons are vendored in `public/` — do not reintroduce CDN references; the app advertises offline support and local-only processing.
|
||||||
|
|||||||
@@ -54,10 +54,27 @@ 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**: You can force the container to run as your host user:
|
3. **User Mapping**: The container runs as the non-root `node` user (UID 1000).
|
||||||
|
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 $(id -u):$(id -g) ...
|
docker run --user $(stat -c '%u:%g' /path/to/archives) ...
|
||||||
```
|
```
|
||||||
|
In Compose:
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
instaarchive:
|
||||||
|
user: "1234:1234" # a UID that can read your archives
|
||||||
|
```
|
||||||
|
|
||||||
|
### Archive Index
|
||||||
|
|
||||||
|
On first start the server walks the archive root once and caches the result,
|
||||||
|
keyed by directory mtime. This matters on network storage: for a 110k-file
|
||||||
|
archive root, listing went from ~52s per request to ~0.1s. Mount a volume at
|
||||||
|
`/cache` (or set `CACHE_DIR`) so the index survives restarts, otherwise it is
|
||||||
|
rebuilt on every start.
|
||||||
|
|
||||||
## Supported Archive Structure
|
## Supported Archive Structure
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.3.0",
|
"version": "1.4.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",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.0",
|
"version": "1.4.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",
|
||||||
|
|||||||
@@ -16,7 +16,23 @@ 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}`);
|
||||||
|
|
||||||
@@ -114,7 +130,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 (UID ${os.userInfo().uid}) cannot read ${ARCHIVES_DIR}.`);
|
console.error(`[API] Permission Denied! The server (${describeUser()}) 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);
|
||||||
|
|||||||
+33
-38
@@ -17,6 +17,8 @@ import {
|
|||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
|
import { PRESS } from './lib/motion';
|
||||||
|
import { 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,
|
||||||
@@ -60,13 +62,13 @@ export default function App() {
|
|||||||
const [hasInitialLoaded, setHasInitialLoaded] = useState(false);
|
const [hasInitialLoaded, setHasInitialLoaded] = useState(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The query string as it was when the app booted.
|
* The route as it was when the app booted.
|
||||||
*
|
*
|
||||||
* Captured during the first render because the URL is rewritten from app
|
* Captured during the first render because the URL is rewritten from app
|
||||||
* state as soon as anything loads; reading `window.location` later would see
|
* state as soon as anything loads; reading `window.location` later would see
|
||||||
* the rewritten value rather than the link the user actually followed.
|
* the rewritten value rather than the link the user actually followed.
|
||||||
*/
|
*/
|
||||||
const initialParamsRef = useRef(new URLSearchParams(window.location.search));
|
const initialRouteRef = useRef(parseRoute(window.location.pathname, window.location.search));
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const profilePicInputRef = useRef<HTMLInputElement>(null);
|
const profilePicInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -339,41 +341,28 @@ export default function App() {
|
|||||||
// loader below is waiting to read.
|
// loader below is waiting to read.
|
||||||
if (!hasInitialLoaded) return;
|
if (!hasInitialLoaded) return;
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const archive = currentArchive?.name ?? (allPosts.length > 0 ? username : null) ?? null;
|
||||||
if (currentArchive) params.set('a', currentArchive.name);
|
const nextPath = buildPath({
|
||||||
else if (allPosts.length > 0 && username) params.set('a', username);
|
archive,
|
||||||
else params.delete('a');
|
tab: activeTab,
|
||||||
|
post: selectedPost ? postSlug(selectedPost) : null,
|
||||||
|
});
|
||||||
|
|
||||||
if (activeTab !== 'posts') params.set('t', activeTab);
|
if (nextPath !== window.location.pathname + window.location.search) {
|
||||||
else params.delete('t');
|
console.log(`[Permalink] Updating URL to: ${nextPath}`);
|
||||||
|
window.history.replaceState(null, '', nextPath);
|
||||||
if (selectedPost) params.set('p', selectedPost.id);
|
|
||||||
else params.delete('p');
|
|
||||||
|
|
||||||
const newSearch = params.toString();
|
|
||||||
const currentSearch = new URLSearchParams(window.location.search).toString();
|
|
||||||
if (newSearch !== currentSearch) {
|
|
||||||
console.log(`[Permalink] Updating URL to: ?${newSearch}`);
|
|
||||||
const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '');
|
|
||||||
window.history.replaceState(null, '', newUrl);
|
|
||||||
}
|
}
|
||||||
}, [hasInitialLoaded, currentArchive?.name, username, allPosts.length, activeTab, selectedPost?.id]);
|
}, [hasInitialLoaded, currentArchive?.name, username, allPosts.length, activeTab, selectedPost?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasInitialLoaded) return;
|
if (hasInitialLoaded) return;
|
||||||
|
|
||||||
const params = initialParamsRef.current;
|
const route = initialRouteRef.current;
|
||||||
const archiveName = params.get('a');
|
console.log('[Permalink] Initial route:', route);
|
||||||
const tab = params.get('t');
|
|
||||||
console.log('[Permalink] Initial read from URL:', {
|
|
||||||
archiveName, tab, postId: params.get('p'),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tab && ['posts', 'reels', 'saved'].includes(tab)) {
|
if (route.tab !== 'posts') setActiveTab(route.tab);
|
||||||
setActiveTab(tab as 'posts' | 'reels' | 'saved');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!archiveName) {
|
if (!route.archive) {
|
||||||
setHasInitialLoaded(true);
|
setHasInitialLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -381,12 +370,12 @@ export default function App() {
|
|||||||
// Wait for the archive list before deciding the link is unresolvable.
|
// Wait for the archive list before deciding the link is unresolvable.
|
||||||
if (!archivesFetched) return;
|
if (!archivesFetched) return;
|
||||||
|
|
||||||
const archive = serverArchives.find(a => a.name === archiveName);
|
const archive = serverArchives.find(a => a.name === route.archive);
|
||||||
if (archive) {
|
if (archive) {
|
||||||
console.log(`[Permalink] Auto-loading archive: ?a=${archiveName}`);
|
console.log(`[Permalink] Auto-loading archive: ${route.archive}`);
|
||||||
loadServerArchive(archive);
|
loadServerArchive(archive);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[Permalink] No archive named "${archiveName}".`);
|
console.warn(`[Permalink] No archive named "${route.archive}".`);
|
||||||
}
|
}
|
||||||
setHasInitialLoaded(true);
|
setHasInitialLoaded(true);
|
||||||
}, [serverArchives, archivesFetched, hasInitialLoaded, loadServerArchive]);
|
}, [serverArchives, archivesFetched, hasInitialLoaded, loadServerArchive]);
|
||||||
@@ -406,10 +395,14 @@ export default function App() {
|
|||||||
if (appliedPostParamRef.current === archiveKey) return;
|
if (appliedPostParamRef.current === archiveKey) return;
|
||||||
appliedPostParamRef.current = archiveKey;
|
appliedPostParamRef.current = archiveKey;
|
||||||
|
|
||||||
const postId = initialParamsRef.current.get('p');
|
const slug = initialRouteRef.current.post;
|
||||||
if (!postId) return;
|
if (!slug) return;
|
||||||
const post = allPosts.find(p => p.id === postId);
|
const post = findPostBySlug(allPosts, slug);
|
||||||
if (post) setSelectedPost(post);
|
if (!post) return;
|
||||||
|
// A /p/<code>/ link carries no tab, so derive the one that contains it —
|
||||||
|
// otherwise next/prev would page through the wrong list.
|
||||||
|
setActiveTab(tabForSource(post.source));
|
||||||
|
setSelectedPost(post);
|
||||||
}, [allPosts, currentArchive?.name, username]);
|
}, [allPosts, currentArchive?.name, username]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -504,9 +497,11 @@ export default function App() {
|
|||||||
{highlightGroups.length > 0 && (
|
{highlightGroups.length > 0 && (
|
||||||
<div className="flex gap-6 md:gap-8 overflow-x-auto scrollbar-hide px-4 pb-2">
|
<div className="flex gap-6 md:gap-8 overflow-x-auto scrollbar-hide px-4 pb-2">
|
||||||
{highlightGroups.map(group => (
|
{highlightGroups.map(group => (
|
||||||
<button
|
<motion.button
|
||||||
key={group.title}
|
key={group.title}
|
||||||
onClick={() => setActiveHighlight(group.title)}
|
onClick={() => setActiveHighlight(group.title)}
|
||||||
|
whileTap={{ scale: 0.94 }}
|
||||||
|
transition={PRESS}
|
||||||
className="flex flex-col items-center gap-2 shrink-0 group/hl"
|
className="flex flex-col items-center gap-2 shrink-0 group/hl"
|
||||||
title={`${group.title} — ${group.items.length} item${group.items.length === 1 ? '' : 's'}`}
|
title={`${group.title} — ${group.items.length} item${group.items.length === 1 ? '' : 's'}`}
|
||||||
>
|
>
|
||||||
@@ -522,7 +517,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] max-w-[80px] truncate text-gray-700">{group.title}</span>
|
<span className="text-[11px] max-w-[80px] truncate text-gray-700">{group.title}</span>
|
||||||
</button>
|
</motion.button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -538,7 +533,7 @@ export default function App() {
|
|||||||
<div className="grid grid-cols-3 gap-[2px] md:gap-[2px] text-black">
|
<div className="grid grid-cols-3 gap-[2px] md:gap-[2px] text-black">
|
||||||
{activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => (<div key={`blank-${i}`} className={cn("bg-gray-100/50 border border-dashed border-gray-200 flex items-center justify-center text-[10px] font-bold text-gray-300 uppercase tracking-tighter text-black", gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]")}>Blank</div>))}
|
{activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => (<div key={`blank-${i}`} className={cn("bg-gray-100/50 border border-dashed border-gray-200 flex items-center justify-center text-[10px] font-bold text-gray-300 uppercase tracking-tighter text-black", gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]")}>Blank</div>))}
|
||||||
{visiblePosts.map((post) => (
|
{visiblePosts.map((post) => (
|
||||||
<motion.div key={post.id} layoutId={post.id} onClick={() => setSelectedPost(post)} className={cn("relative group cursor-pointer overflow-hidden bg-gray-200 transition-all duration-300 text-black", activeTab === 'reels' ? "aspect-[9/16]" : (gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]"))}>
|
<motion.div key={post.id} layoutId={post.id} onClick={() => setSelectedPost(post)} whileTap={{ scale: 0.97 }} transition={PRESS} className={cn("relative group cursor-pointer overflow-hidden bg-gray-200 transition-all duration-300 text-black", activeTab === 'reels' ? "aspect-[9/16]" : (gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]"))}>
|
||||||
<PostThumbnail
|
<PostThumbnail
|
||||||
post={post}
|
post={post}
|
||||||
thumbnailUrl={cacheHits.get(post.id)}
|
thumbnailUrl={cacheHits.get(post.id)}
|
||||||
|
|||||||
@@ -1,12 +1,45 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } 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';
|
||||||
|
|
||||||
export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => {
|
export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => {
|
||||||
// Start muted so autoplay is not blocked by Safari/Firefox policy.
|
// Try to play with sound: opening the modal is a user gesture, so browsers
|
||||||
const [isMuted, setIsMuted] = useState(true);
|
// generally allow it. If this particular browser still refuses, the effect
|
||||||
const sizingClass = isFullView ? "w-full h-auto block" : "w-full h-full object-cover";
|
// 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;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
/**
|
||||||
|
* 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 = "max-h-[70vh] md:max-h-[calc(100vh-5rem)] object-contain";
|
||||||
|
const videoSizing = isFullView ? `block w-auto max-w-full ${fullViewCap}` : "w-full h-full object-cover";
|
||||||
|
const 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>;
|
||||||
@@ -14,7 +47,7 @@ export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile
|
|||||||
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 src={file.url} className={cn("transition-all duration-300", sizingClass, className)} style={mediaStyle} playsInline autoPlay muted={isMuted} loop controls />
|
<video ref={videoRef} 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>
|
||||||
|
|||||||
+102
-18
@@ -12,6 +12,7 @@ import {
|
|||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { Post } from '../types';
|
import { Post } from '../types';
|
||||||
import { cn, formatDateSafe } from '../lib/utils';
|
import { cn, formatDateSafe } from '../lib/utils';
|
||||||
|
import { FADE, NAVIGATE, PRESENT, prefersReducedMotion, withVelocity } from '../lib/motion';
|
||||||
import { MediaRenderer } from './MediaRenderer';
|
import { MediaRenderer } from './MediaRenderer';
|
||||||
|
|
||||||
interface PostModalProps {
|
interface PostModalProps {
|
||||||
@@ -30,7 +31,14 @@ export const PostModal: React.FC<PostModalProps> = ({
|
|||||||
post, nextPost, prevPost, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic
|
post, nextPost, prevPost, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [direction, setDirection] = useState(0);
|
/**
|
||||||
|
* How the next slide/post should enter: along which axis, in which direction,
|
||||||
|
* and carrying how much velocity from the gesture that triggered it.
|
||||||
|
*/
|
||||||
|
const [slideMotion, setSlideMotion] = useState<{ axis: 'x' | 'y'; dir: number; velocity: number }>(
|
||||||
|
{ axis: 'x', dir: 0, velocity: 0 },
|
||||||
|
);
|
||||||
|
const reduceMotion = prefersReducedMotion();
|
||||||
|
|
||||||
// Preloading Logic
|
// Preloading Logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,8 +83,8 @@ export const PostModal: React.FC<PostModalProps> = ({
|
|||||||
useEffect(() => setCurrentIndex(0), [post.id]);
|
useEffect(() => setCurrentIndex(0), [post.id]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'ArrowRight') onNextPost?.();
|
if (e.key === 'ArrowRight') goToPost(1, 'x');
|
||||||
else if (e.key === 'ArrowLeft') onPrevPost?.();
|
else if (e.key === 'ArrowLeft') goToPost(-1, 'x');
|
||||||
else if (e.key === '.') paginate(1);
|
else if (e.key === '.') paginate(1);
|
||||||
else if (e.key === ',') paginate(-1);
|
else if (e.key === ',') paginate(-1);
|
||||||
else if (e.key === 'Escape') onClose();
|
else if (e.key === 'Escape') onClose();
|
||||||
@@ -85,44 +93,120 @@ export const PostModal: React.FC<PostModalProps> = ({
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onNextPost, onPrevPost, currentIndex, post.media.length, onClose]);
|
}, [onNextPost, onPrevPost, currentIndex, post.media.length, onClose]);
|
||||||
|
|
||||||
const paginate = (newDirection: number) => {
|
const paginate = (newDirection: number, velocity = 0) => {
|
||||||
const nextIndex = currentIndex + newDirection;
|
const nextIndex = currentIndex + newDirection;
|
||||||
if (nextIndex >= 0 && nextIndex < post.media.length) { setDirection(newDirection); setCurrentIndex(nextIndex); }
|
if (nextIndex >= 0 && nextIndex < post.media.length) {
|
||||||
|
setSlideMotion({ axis: 'x', dir: newDirection, velocity });
|
||||||
|
setCurrentIndex(nextIndex);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move between posts, animating along the axis the input implies: vertical
|
||||||
|
* for a touch swipe, horizontal for the desktop arrows and arrow keys.
|
||||||
|
*/
|
||||||
|
const goToPost = (dir: 1 | -1, axis: 'x' | 'y', velocity = 0) => {
|
||||||
|
if (dir > 0 ? !hasNextPost : !hasPrevPost) return;
|
||||||
|
setSlideMotion({ axis, dir, velocity });
|
||||||
|
if (dir > 0) onNextPost?.(); else onPrevPost?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
type SlideMotion = { axis: 'x' | 'y'; dir: number };
|
||||||
|
const offscreen = (dir: number) => (dir > 0 ? '100%' : '-100%');
|
||||||
const variants = {
|
const variants = {
|
||||||
enter: (d: number) => ({ x: d > 0 ? '100%' : '-100%', opacity: 1, zIndex: 0 }),
|
enter: ({ axis, dir }: SlideMotion) =>
|
||||||
center: { zIndex: 1, x: 0, opacity: 1 },
|
axis === 'y'
|
||||||
exit: (d: number) => ({ zIndex: 0, x: d < 0 ? '100%' : '-100%', opacity: 1 })
|
? { y: offscreen(dir), x: 0, opacity: 1, zIndex: 0 }
|
||||||
|
: { x: offscreen(dir), y: 0, opacity: 1, zIndex: 0 },
|
||||||
|
center: { zIndex: 1, x: 0, y: 0, opacity: 1 },
|
||||||
|
exit: ({ axis, dir }: SlideMotion) =>
|
||||||
|
axis === 'y'
|
||||||
|
? { zIndex: 0, y: offscreen(-dir), x: 0, opacity: 1 }
|
||||||
|
: { zIndex: 0, x: offscreen(-dir), y: 0, opacity: 1 },
|
||||||
};
|
};
|
||||||
const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity;
|
const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity;
|
||||||
|
|
||||||
|
/** Gesture navigation is touch-shaped below md; above it the arrows do the job. */
|
||||||
|
const [isMobile, setIsMobile] = useState(
|
||||||
|
() => typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia('(max-width: 767px)');
|
||||||
|
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
query.addEventListener('change', onChange);
|
||||||
|
return () => query.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical swipe moves between posts on touch, matching Instagram: horizontal
|
||||||
|
* belongs to the carousel and only the carousel, so reaching the last slide
|
||||||
|
* no longer flings you into the next post.
|
||||||
|
*
|
||||||
|
* Swiping down on the first post falls back to dismissing, which keeps the
|
||||||
|
* familiar drag-to-close gesture available where it can't mean "previous".
|
||||||
|
*/
|
||||||
|
const SWIPE_DISTANCE = 90;
|
||||||
|
const SWIPE_POWER = 8000;
|
||||||
|
|
||||||
|
const handleVerticalDragEnd = (offset: { y: number }, velocity: { y: number }) => {
|
||||||
|
const power = swipePower(offset.y, velocity.y);
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
if (offset.y > 200 || velocity.y > 800) onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const swipedUp = offset.y < -SWIPE_DISTANCE || power < -SWIPE_POWER;
|
||||||
|
const swipedDown = offset.y > SWIPE_DISTANCE || power > SWIPE_POWER;
|
||||||
|
|
||||||
|
if (swipedUp && hasNextPost) goToPost(1, 'y', velocity.y);
|
||||||
|
else if (swipedDown) {
|
||||||
|
if (hasPrevPost) goToPost(-1, 'y', velocity.y);
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Horizontal padding on the overlay reserves a gutter for the prev/next
|
||||||
|
* arrows so they always sit *outside* the modal. Without it the modal grows
|
||||||
|
* until it sits under them and a white chevron lands on the white caption
|
||||||
|
* panel, leaving the control invisible until hovered.
|
||||||
|
*
|
||||||
|
* overscroll-contain stops wheel events chaining through to the very long
|
||||||
|
* post grid behind the overlay.
|
||||||
|
*/
|
||||||
return (
|
return (
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-start justify-center bg-[#0c1014]/95 md:bg-[#0c1014]/70 p-0 md:p-10 overflow-y-auto text-black" onClick={onClose}>
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={FADE} className="fixed inset-0 z-50 flex items-start justify-center bg-[#0c1014]/95 md:bg-[#0c1014]/70 p-0 md:py-10 md:px-16 lg:px-24 overflow-y-auto overscroll-contain text-black" onClick={onClose}>
|
||||||
<div className="min-h-full w-full flex items-center justify-center md:py-0">
|
<div className="min-h-full w-full flex items-center justify-center md:py-0">
|
||||||
<button onClick={onClose} className="fixed top-4 right-4 text-white hover:text-gray-300 z-50 p-2 md:p-3 bg-black/20 rounded-full backdrop-blur-sm"><X size={24} className="md:w-8 md:h-8" /></button>
|
<button onClick={onClose} className="fixed top-4 right-4 text-white hover:text-gray-300 z-50 p-2 md:p-3 bg-black/20 rounded-full backdrop-blur-sm"><X size={24} className="md:w-8 md:h-8" /></button>
|
||||||
{hasPrevPost && onPrevPost && <button onClick={(e) => { e.stopPropagation(); onPrevPost(); }} className="hidden md:block fixed left-4 md:left-10 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-50 transition-transform hover:scale-110 active:scale-90"><ChevronLeft size={48} strokeWidth={1.5} /></button>}
|
{/* Solid pill so the arrows read against whatever sits behind them. */}
|
||||||
{hasNextPost && onNextPost && <button onClick={(e) => { e.stopPropagation(); onNextPost(); }} className="hidden md:block fixed right-4 md:right-10 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-50 transition-transform hover:scale-110 active:scale-90"><ChevronRight size={48} strokeWidth={1.5} /></button>}
|
{hasPrevPost && onPrevPost && <button aria-label="Previous post" onClick={(e) => { e.stopPropagation(); goToPost(-1, 'x'); }} className="hidden md:flex items-center justify-center fixed md:left-3 lg:left-6 top-1/2 -translate-y-1/2 z-50 p-2 rounded-full bg-white/90 hover:bg-white text-gray-800 shadow-lg transition-transform hover:scale-110 active:scale-90"><ChevronLeft size={28} strokeWidth={2} /></button>}
|
||||||
<motion.div drag="y" dragDirectionLock dragConstraints={{ top: 0, bottom: 0 }} dragElastic={0.15} onDragEnd={(e, { offset, velocity }) => { if (offset.y > 200 || velocity.y > 800) onClose(); }} className="bg-black flex flex-col md:flex-row w-full max-w-6xl h-auto md:rounded-sm overflow-hidden shadow-2xl relative text-black" onClick={e => e.stopPropagation()}>
|
{hasNextPost && onNextPost && <button aria-label="Next post" onClick={(e) => { e.stopPropagation(); goToPost(1, 'x'); }} className="hidden md:flex items-center justify-center fixed md:right-3 lg:right-6 top-1/2 -translate-y-1/2 z-50 p-2 rounded-full bg-white/90 hover:bg-white text-gray-800 shadow-lg transition-transform hover:scale-110 active:scale-90"><ChevronRight size={28} strokeWidth={2} /></button>}
|
||||||
|
<motion.div drag="y" dragDirectionLock dragConstraints={{ top: 0, bottom: 0 }} dragElastic={0.15} onDragEnd={(e, { offset, velocity }) => handleVerticalDragEnd(offset, velocity)} initial={reduceMotion ? false : { opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }} transition={PRESENT} className="bg-black flex flex-col md:flex-row w-full max-w-6xl h-auto md:rounded-sm overflow-hidden shadow-2xl relative text-black" onClick={e => e.stopPropagation()}>
|
||||||
<div className="relative bg-black flex items-center justify-center group overflow-hidden w-full h-auto text-black">
|
<div className="relative bg-black flex items-center justify-center group overflow-hidden w-full h-auto text-black">
|
||||||
<div className="w-full grid grid-cols-1 grid-rows-1 text-black">
|
<div className="w-full grid grid-cols-1 grid-rows-1 text-black">
|
||||||
<AnimatePresence initial={false} custom={direction}>
|
<AnimatePresence initial={false} custom={slideMotion}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`${post.id}-${currentIndex}`}
|
key={`${post.id}-${currentIndex}`}
|
||||||
custom={direction}
|
custom={slideMotion}
|
||||||
variants={variants}
|
variants={variants}
|
||||||
initial="enter"
|
initial="enter"
|
||||||
animate="center"
|
animate="center"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={{ x: { type: "spring", stiffness: 200, damping: 26, bounce: 0 } }}
|
transition={reduceMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: { x: withVelocity(slideMotion.velocity, NAVIGATE), y: withVelocity(slideMotion.velocity, NAVIGATE) }}
|
||||||
|
|
||||||
drag="x"
|
drag="x"
|
||||||
dragDirectionLock
|
dragDirectionLock
|
||||||
dragConstraints={{ left: 0, right: 0 }}
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
dragElastic={0.5}
|
dragElastic={0.5}
|
||||||
onDragEnd={(e, { offset, velocity }) => {
|
onDragEnd={(e, { offset, velocity }) => {
|
||||||
|
// Carousel only. Crossing into the next post from the last
|
||||||
|
// slide made a horizontal flick mean two different things.
|
||||||
const s = swipePower(offset.x, velocity.x);
|
const s = swipePower(offset.x, velocity.x);
|
||||||
if (s < -15000) { if (currentIndex < post.media.length - 1) paginate(1); else if (hasNextPost && onNextPost && s < -40000) onNextPost(); }
|
if (s < -15000) paginate(1, velocity.x);
|
||||||
else if (s > 15000) { if (currentIndex > 0) paginate(-1); else if (hasPrevPost && onPrevPost && s > 40000) onPrevPost(); }
|
else if (s > 15000) paginate(-1, velocity.x);
|
||||||
}}
|
}}
|
||||||
className="col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing relative text-black"
|
className="col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing relative text-black"
|
||||||
>
|
>
|
||||||
@@ -138,7 +222,7 @@ export const PostModal: React.FC<PostModalProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full md:w-96 bg-white flex flex-col border-l border-gray-200 overflow-hidden shrink-0 text-black">
|
<div className="w-full md:w-80 lg:w-96 bg-white flex flex-col border-l border-gray-200 overflow-hidden shrink-0 text-black">
|
||||||
<div className="p-3 md:p-4 border-b border-gray-100 flex items-center justify-between shrink-0 text-black">
|
<div className="p-3 md:p-4 border-b border-gray-100 flex items-center justify-between shrink-0 text-black">
|
||||||
<div className="flex items-center gap-3 text-black">
|
<div className="flex items-center gap-3 text-black">
|
||||||
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-yellow-400 to-purple-600 p-0.5 text-black"><div className="w-full h-full rounded-full bg-white p-0.5 text-black"><div className="w-full h-full rounded-full bg-gray-200 flex items-center justify-center overflow-hidden text-[10px] font-bold uppercase text-black">{profilePic ? <img src={profilePic} alt="" className="w-full h-full object-cover text-black" referrerPolicy="no-referrer" /> : <span className="text-black">{post.username[0]}</span>}</div></div></div>
|
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-yellow-400 to-purple-600 p-0.5 text-black"><div className="w-full h-full rounded-full bg-white p-0.5 text-black"><div className="w-full h-full rounded-full bg-gray-200 flex items-center justify-center overflow-hidden text-[10px] font-bold uppercase text-black">{profilePic ? <img src={profilePic} alt="" className="w-full h-full object-cover text-black" referrerPolicy="no-referrer" /> : <span className="text-black">{post.username[0]}</span>}</div></div></div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Post } from '../types';
|
import { Post } from '../types';
|
||||||
import { cn, formatDateSafe } from '../lib/utils';
|
import { cn, formatDateSafe } from '../lib/utils';
|
||||||
|
import { FADE, PRESENT, prefersReducedMotion } from '../lib/motion';
|
||||||
|
|
||||||
interface StoryViewerProps {
|
interface StoryViewerProps {
|
||||||
stories: Post[];
|
stories: Post[];
|
||||||
@@ -26,10 +27,12 @@ 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);
|
||||||
// Start muted: Safari and Firefox refuse to autoplay audible media, which
|
// Opening the reel is a user gesture, so try for sound; the effect below
|
||||||
// would stall the reel on its first video.
|
// falls back to muted if the browser refuses, which would otherwise stall
|
||||||
const [isMuted, setIsMuted] = useState(true);
|
// the progress bar on the first video.
|
||||||
|
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];
|
||||||
|
|
||||||
@@ -61,6 +64,22 @@ 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) {
|
||||||
@@ -93,6 +112,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
transition={FADE}
|
||||||
className="fixed inset-0 z-[100] bg-[#1a1a1a] flex items-center justify-center overflow-hidden text-white"
|
className="fixed inset-0 z-[100] bg-[#1a1a1a] flex items-center justify-center overflow-hidden text-white"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
@@ -121,7 +141,11 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
<ChevronRight size={32} strokeWidth={1.5} />
|
<ChevronRight size={32} strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<motion.div
|
||||||
|
initial={reduceMotion ? false : { scale: 0.94, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={reduceMotion ? { opacity: 0 } : { scale: 0.94, opacity: 0 }}
|
||||||
|
transition={PRESENT}
|
||||||
className="relative w-full h-full md:h-[90vh] md:max-w-[45vh] bg-black overflow-hidden md:rounded-lg shadow-2xl z-10 text-white"
|
className="relative w-full h-full md:h-[90vh] md:max-w-[45vh] bg-black overflow-hidden md:rounded-lg shadow-2xl z-10 text-white"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -206,7 +230,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
|
|||||||
{story.caption}
|
{story.caption}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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('0ct0ber19')).toEqual({
|
expect(classifyDirectory('4utumn07')).toEqual({
|
||||||
owner: '0ct0ber19',
|
owner: '4utumn07',
|
||||||
source: { kind: 'posts', dir: '0ct0ber19' },
|
source: { kind: 'posts', dir: '4utumn07' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recognises a reels sidecar', () => {
|
it('recognises a reels sidecar', () => {
|
||||||
expect(classifyDirectory('0ct0ber19 - reels')).toEqual({
|
expect(classifyDirectory('4utumn07 - reels')).toEqual({
|
||||||
owner: '0ct0ber19',
|
owner: '4utumn07',
|
||||||
source: { kind: 'reels', dir: '0ct0ber19 - reels' },
|
source: { kind: 'reels', dir: '4utumn07 - reels' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recognises a stories sidecar', () => {
|
it('recognises a stories sidecar', () => {
|
||||||
expect(classifyDirectory('story - cher_ryppo')).toEqual({
|
expect(classifyDirectory('story - dawn_petal')).toEqual({
|
||||||
owner: 'cher_ryppo',
|
owner: 'dawn_petal',
|
||||||
source: { kind: 'stories', dir: 'story - cher_ryppo' },
|
source: { kind: 'stories', dir: 'story - dawn_petal' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('splits highlight owner from title', () => {
|
it('splits highlight owner from title', () => {
|
||||||
const { owner, source } = classifyDirectory('story highlights - 0ct0ber19 - Heestory');
|
const { owner, source } = classifyDirectory('story highlights - 4utumn07 - Sunstory');
|
||||||
expect(owner).toBe('0ct0ber19');
|
expect(owner).toBe('4utumn07');
|
||||||
expect(source.kind).toBe('highlight');
|
expect(source.kind).toBe('highlight');
|
||||||
expect(source.title).toBe('Heestory');
|
expect(source.title).toBe('Sunstory');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['story highlights - theoldtaylorswiftinsta - 💙2014-1989 era', 'theoldtaylorswiftinsta', '💙2014-1989 era'],
|
['story highlights - theoldlyricmuseinsta - 💙1999-2005 era', 'theoldlyricmuseinsta', '💙1999-2005 era'],
|
||||||
['story highlights - heejin_theworld - [Dall]', 'heejin_theworld', '[Dall]'],
|
['story highlights - member_theworld - [Bracket]', 'member_theworld', '[Bracket]'],
|
||||||
['story highlights - official_artms - Cosmo Schedule', 'official_artms', 'Cosmo Schedule'],
|
['story highlights - official_band - Tour Schedule', 'official_band', 'Tour Schedule'],
|
||||||
['story highlights - 0ct0ber19 - Drawheeing⠀', '0ct0ber19', 'Drawheeing⠀'],
|
['story highlights - 4utumn07 - Sketching⠀', '4utumn07', 'Sketching⠀'],
|
||||||
['story highlights - official_artms - G.C.I', 'official_artms', 'G.C.I'],
|
['story highlights - official_band - A.B.C', 'official_band', 'A.B.C'],
|
||||||
])('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 = [
|
||||||
'0ct0ber19',
|
'4utumn07',
|
||||||
'0ct0ber19 - reels',
|
'4utumn07 - reels',
|
||||||
'story - 0ct0ber19',
|
'story - 4utumn07',
|
||||||
'story highlights - 0ct0ber19 - Heestory',
|
'story highlights - 4utumn07 - Sunstory',
|
||||||
'story highlights - 0ct0ber19 - Drawheeing⠀',
|
'story highlights - 4utumn07 - Sketching⠀',
|
||||||
'carlyraejepsen',
|
'kestrelsings',
|
||||||
];
|
];
|
||||||
|
|
||||||
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(['0ct0ber19', 'carlyraejepsen']);
|
expect([...groups.keys()].sort()).toEqual(['4utumn07', 'kestrelsings']);
|
||||||
expect(groups.get('0ct0ber19')).toHaveLength(5);
|
expect(groups.get('4utumn07')).toHaveLength(5);
|
||||||
expect(groups.get('carlyraejepsen')).toHaveLength(1);
|
expect(groups.get('kestrelsings')).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('0ct0ber19')!;
|
const sources = groupArchiveDirectories(dirs).get('4utumn07')!;
|
||||||
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(['Drawheeing⠀', 'Heestory']);
|
expect(sources.slice(3).map(s => s.title)).toEqual(['Sketching⠀', 'Sunstory']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still groups a sidecar whose base profile is missing', () => {
|
it('still groups a sidecar whose base profile is missing', () => {
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export interface ArchiveSource {
|
|||||||
/**
|
/**
|
||||||
* Sidecar directories sit next to the profile directory they belong to:
|
* Sidecar directories sit next to the profile directory they belong to:
|
||||||
*
|
*
|
||||||
* 0ct0ber19 -> posts (base)
|
* 4utumn07 -> posts (base)
|
||||||
* 0ct0ber19 - reels -> reels
|
* 4utumn07 - reels -> reels
|
||||||
* story - 0ct0ber19 -> stories
|
* story - 4utumn07 -> stories
|
||||||
* story highlights - 0ct0ber19 - Heestory -> highlight "Heestory"
|
* story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
|
||||||
*
|
*
|
||||||
* 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
|
||||||
|
|||||||
@@ -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_0ct0ber19 - CrORBIcJJbM.mp4')).toEqual({
|
expect(parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM.mp4')).toEqual({
|
||||||
postId: 'CrORBIcJJbM',
|
postId: 'CrORBIcJJbM',
|
||||||
date: '2023-04-19',
|
date: '2023-04-19',
|
||||||
username: '0ct0ber19',
|
username: '4utumn07',
|
||||||
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_0ct0ber19 - Cq8LrxSJAJE - 3.jpg');
|
const parsed = parseArchiveFilename('2023-04-12_4utumn07 - 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_0ct0ber19 - Cq8LrxSJAJE.txt')).toMatchObject({
|
expect(parseArchiveFilename('2023-04-12_4utumn07 - 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_0ct0ber19 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
const parsed = parseArchiveFilename('2025-10-26_4utumn07 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
||||||
expect(parsed).toMatchObject({ date: '2025-10-26', username: '0ct0ber19', ext: 'jpg' });
|
expect(parsed).toMatchObject({ date: '2025-10-26', username: '4utumn07', 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('0ct0ber19 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
expect(parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
||||||
postId: 'C5dQPEYpd9W',
|
postId: 'C5dQPEYpd9W',
|
||||||
username: '0ct0ber19',
|
username: '4utumn07',
|
||||||
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(['0ct0ber19.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
|
it.each(['4utumn07.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 - Heestory'))
|
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Sunstory'))
|
||||||
.toBe('story highlights - u - Heestory/C5dQ');
|
.toBe('story highlights - u - Sunstory/C5dQ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the same shortcode distinct across sources', () => {
|
it('keeps the same shortcode distinct across sources', () => {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Transition } from 'motion/react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared motion vocabulary, tuned to feel like a native iOS app.
|
||||||
|
*
|
||||||
|
* Two rules do most of the work:
|
||||||
|
* - UIKit animates with springs, not fixed-duration easing, so gestures hand
|
||||||
|
* their exit velocity to the animation and motion continues rather than
|
||||||
|
* restarting.
|
||||||
|
* - iOS springs are critically damped. They settle firmly with no visible
|
||||||
|
* bounce; overshoot reads as "web animation", not "native".
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The curve UIKit uses for sheet presentation. */
|
||||||
|
export const IOS_EASE = [0.32, 0.72, 0, 1] as const;
|
||||||
|
|
||||||
|
/** Moving between peers: carousel slides, next/previous post. */
|
||||||
|
export const NAVIGATE: Transition = { type: 'spring', stiffness: 420, damping: 40, mass: 1 };
|
||||||
|
|
||||||
|
/** Presenting or dismissing a surface. Slightly softer than navigation. */
|
||||||
|
export const PRESENT: Transition = { type: 'spring', stiffness: 320, damping: 34, mass: 1 };
|
||||||
|
|
||||||
|
/** Backdrops and cross-fades, where a spring would feel fussy. */
|
||||||
|
export const FADE: Transition = { duration: 0.28, ease: IOS_EASE };
|
||||||
|
|
||||||
|
/** Touch-down feedback. Fast enough to feel like a direct response. */
|
||||||
|
export const PRESS: Transition = { type: 'spring', stiffness: 600, damping: 30 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continue a drag into its animation.
|
||||||
|
*
|
||||||
|
* Handing the gesture's exit velocity to the spring is what separates "the
|
||||||
|
* sheet kept moving because I flicked it" from "the sheet started a new
|
||||||
|
* animation once I let go".
|
||||||
|
*/
|
||||||
|
export const withVelocity = (velocity: number, base: Transition = NAVIGATE): Transition => ({
|
||||||
|
...base,
|
||||||
|
velocity,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** True when the viewer has asked the OS to reduce motion. */
|
||||||
|
export const prefersReducedMotion = () =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './routing';
|
||||||
|
import { Post } from '../types';
|
||||||
|
|
||||||
|
const post = (id: string, source?: Post['source']): Post => ({
|
||||||
|
id, date: '2024-01-01', username: 'u', caption: '', media: [], thumbnail: '', source,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRoute', () => {
|
||||||
|
it('reads the explorer root', () => {
|
||||||
|
expect(parseRoute('/')).toEqual({ archive: null, tab: 'posts', post: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a profile', () => {
|
||||||
|
expect(parseRoute('/4utumn07/')).toEqual({ archive: '4utumn07', tab: 'posts', post: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a profile without a trailing slash', () => {
|
||||||
|
expect(parseRoute('/4utumn07')).toEqual({ archive: '4utumn07', tab: 'posts', post: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a tab', () => {
|
||||||
|
expect(parseRoute('/4utumn07/reels/').tab).toBe('reels');
|
||||||
|
expect(parseRoute('/4utumn07/saved/').tab).toBe('saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a post in Instagram form', () => {
|
||||||
|
expect(parseRoute('/4utumn07/p/Db5tIoRCcvm/')).toEqual({
|
||||||
|
archive: '4utumn07', tab: 'posts', post: 'Db5tIoRCcvm',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes archive names containing spaces', () => {
|
||||||
|
expect(parseRoute('/Heejin_Bubble%20heejinmedia/').archive).toBe('Heejin_Bubble heejinmedia');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat reserved prefixes as archives', () => {
|
||||||
|
for (const path of ['/api/archives', '/archives/x/y.jpg', '/assets/index.js']) {
|
||||||
|
expect(parseRoute(path).archive).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still understands the legacy query form', () => {
|
||||||
|
expect(parseRoute('/', '?a=4utumn07&t=reels&p=ABC')).toEqual({
|
||||||
|
archive: '4utumn07', tab: 'reels', post: 'ABC',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an unknown tab', () => {
|
||||||
|
expect(parseRoute('/', '?a=u&t=bogus').tab).toBe('posts');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildPath', () => {
|
||||||
|
it.each([
|
||||||
|
[{ archive: null, tab: 'posts', post: null }, '/'],
|
||||||
|
[{ archive: '4utumn07', tab: 'posts', post: null }, '/4utumn07/'],
|
||||||
|
[{ archive: '4utumn07', tab: 'reels', post: null }, '/4utumn07/reels/'],
|
||||||
|
[{ archive: '4utumn07', tab: 'posts', post: 'Db5tIoRCcvm' }, '/4utumn07/p/Db5tIoRCcvm/'],
|
||||||
|
] as const)('builds %j', (route, expected) => {
|
||||||
|
expect(buildPath(route as any)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the tab from a post URL, matching Instagram', () => {
|
||||||
|
expect(buildPath({ archive: 'u', tab: 'reels', post: 'ABC' })).toBe('/u/p/ABC/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes archive names with spaces', () => {
|
||||||
|
expect(buildPath({ archive: 'a b', tab: 'posts', post: null })).toBe('/a%20b/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through parseRoute', () => {
|
||||||
|
for (const route of [
|
||||||
|
{ archive: '4utumn07', tab: 'posts' as const, post: null },
|
||||||
|
{ archive: '4utumn07', tab: 'reels' as const, post: null },
|
||||||
|
{ archive: 'Heejin_Bubble heejinmedia', tab: 'posts' as const, post: null },
|
||||||
|
]) {
|
||||||
|
expect(parseRoute(buildPath(route))).toEqual(route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postSlug / findPostBySlug', () => {
|
||||||
|
it('uses the bare shortcode for base posts', () => {
|
||||||
|
expect(postSlug(post('Db5tIoRCcvm'))).toBe('Db5tIoRCcvm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips the sidecar directory from the slug', () => {
|
||||||
|
expect(postSlug(post('story highlights - u - Sunstory/C5dQPEYpd9W'))).toBe('C5dQPEYpd9W');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a slug back to its post', () => {
|
||||||
|
const posts = [post('AAA'), post('4utumn07 - reels/BBB', 'reels')];
|
||||||
|
expect(findPostBySlug(posts, 'BBB')?.id).toBe('4utumn07 - reels/BBB');
|
||||||
|
expect(findPostBySlug(posts, 'AAA')?.id).toBe('AAA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers an exact id match over a shortcode match', () => {
|
||||||
|
const posts = [post('x/ABC'), post('ABC')];
|
||||||
|
expect(findPostBySlug(posts, 'ABC')?.id).toBe('ABC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an unknown slug', () => {
|
||||||
|
expect(findPostBySlug([post('AAA')], 'ZZZ')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tabForSource', () => {
|
||||||
|
it('sends reels to the reels tab and everything else to posts', () => {
|
||||||
|
expect(tabForSource('reels')).toBe('reels');
|
||||||
|
expect(tabForSource('posts')).toBe('posts');
|
||||||
|
expect(tabForSource(undefined)).toBe('posts');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Post, SourceKind } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instagram-shaped paths.
|
||||||
|
*
|
||||||
|
* / the archive explorer
|
||||||
|
* /<archive>/ a profile, posts tab
|
||||||
|
* /<archive>/reels/ a profile, reels tab
|
||||||
|
* /<archive>/saved/
|
||||||
|
* /<archive>/p/<shortcode>/ a single post
|
||||||
|
*
|
||||||
|
* The older `?a=&t=&p=` query form is still parsed so existing links keep
|
||||||
|
* working; it is never written back.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Tab = 'posts' | 'reels' | 'saved';
|
||||||
|
|
||||||
|
const TABS: Tab[] = ['posts', 'reels', 'saved'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path prefixes the app must never treat as an archive name, or a profile
|
||||||
|
* called "api" would shadow the backend.
|
||||||
|
*/
|
||||||
|
const RESERVED = new Set(['api', 'archives', 'assets', 'p', 'fonts', 'sw.js', 'manifest.webmanifest']);
|
||||||
|
|
||||||
|
export interface Route {
|
||||||
|
archive: string | null;
|
||||||
|
tab: Tab;
|
||||||
|
/** Post shortcode, i.e. the trailing segment of a post id. */
|
||||||
|
post: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A post's URL slug.
|
||||||
|
*
|
||||||
|
* Sidecar posts carry a directory-scoped id (`story highlights - u - H/ABC`)
|
||||||
|
* so ids stay unique across sources, but only the shortcode belongs in a URL.
|
||||||
|
*/
|
||||||
|
export const postSlug = (post: Pick<Post, 'id'>): string => {
|
||||||
|
const tail = post.id.split('/').pop() ?? post.id;
|
||||||
|
return encodeURIComponent(tail);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Find the post a slug refers to, preferring an exact id match. */
|
||||||
|
export const findPostBySlug = (posts: Post[], slug: string): Post | undefined => {
|
||||||
|
const decoded = decodeURIComponent(slug);
|
||||||
|
return posts.find(p => p.id === decoded)
|
||||||
|
?? posts.find(p => (p.id.split('/').pop() ?? p.id) === decoded);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Which tab shows a given post, so a deep link lands on the right one. */
|
||||||
|
export const tabForSource = (source?: SourceKind): Tab => (source === 'reels' ? 'reels' : 'posts');
|
||||||
|
|
||||||
|
export const parseRoute = (pathname: string, search = ''): Route => {
|
||||||
|
const segments = pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
||||||
|
|
||||||
|
if (segments.length && !RESERVED.has(segments[0])) {
|
||||||
|
const [archive, second, third] = segments;
|
||||||
|
|
||||||
|
if (second === 'p' && third) return { archive, tab: 'posts', post: third };
|
||||||
|
if (second && TABS.includes(second as Tab)) return { archive, tab: second as Tab, post: null };
|
||||||
|
return { archive, tab: 'posts', post: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy query form: ?a=<archive>&t=<tab>&p=<post id>
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const archive = params.get('a');
|
||||||
|
const tab = params.get('t');
|
||||||
|
return {
|
||||||
|
archive: archive || null,
|
||||||
|
tab: tab && TABS.includes(tab as Tab) ? (tab as Tab) : 'posts',
|
||||||
|
post: params.get('p'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPath = ({ archive, tab, post }: Route): string => {
|
||||||
|
if (!archive) return '/';
|
||||||
|
|
||||||
|
const base = `/${encodeURIComponent(archive)}`;
|
||||||
|
// A post URL omits the tab, matching Instagram; the tab is re-derived from
|
||||||
|
// the post itself when the link is opened.
|
||||||
|
if (post) return `${base}/p/${post}/`;
|
||||||
|
if (tab !== 'posts') return `${base}/${tab}/`;
|
||||||
|
return `${base}/`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user