Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f5d19ce4 | ||
|
|
1b4aba54d6 | ||
|
|
b30285fe70 | ||
|
|
20209bcad5 | ||
|
|
74902234b3 | ||
|
|
d62bddc3aa | ||
|
|
42c13ea106 | ||
|
|
a4e9ce16a7 | ||
|
|
4f89a69ee3 | ||
|
|
147dcdf2f1 | ||
|
|
d4e20d9b98 | ||
|
|
ec8c771733 | ||
|
|
3784e8729b | ||
|
|
69d62eaa5c | ||
|
|
767f9c508b | ||
|
|
103ce6f207 | ||
|
|
5267dab236 | ||
|
|
ebf2bf660a | ||
|
|
c0f3523a9c | ||
|
|
6f5021638c | ||
|
|
67f7750157 | ||
|
|
9e306eb85e | ||
|
|
b2da08d52d | ||
|
|
d7c13ecc19 | ||
|
|
d396b356be | ||
|
|
e23dfe4474 | ||
|
|
41e7c5e206 | ||
|
|
f685eaebd7 | ||
|
|
47e44ec5e9 | ||
|
|
cd7dc5f981 | ||
|
|
a724e5bc87 |
@@ -4,96 +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
|
|
||||||
|
|
||||||
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`)
|
|
||||||
|
|
||||||
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`)
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -54,27 +54,10 @@ If the app shows "No Archives Found" and logs `EACCES: permission denied`:
|
|||||||
chmod -R 755 /path/to/archives
|
chmod -R 755 /path/to/archives
|
||||||
```
|
```
|
||||||
2. **SELinux (Fedora/RHEL/CentOS)**: Use the `:z` flag in your volume mount as shown above.
|
2. **SELinux (Fedora/RHEL/CentOS)**: Use the `:z` flag in your volume mount as shown above.
|
||||||
3. **User Mapping**: The container runs as the non-root `node` user (UID 1000).
|
3. **User Mapping**: You can force the container to run as your host user:
|
||||||
If your archives are readable only by another account, run as that user
|
|
||||||
instead — the container needs to *list* the archive directory, so `--x`
|
|
||||||
(traverse-only) permissions are not enough:
|
|
||||||
```bash
|
```bash
|
||||||
docker run --user $(stat -c '%u:%g' /path/to/archives) ...
|
docker run --user $(id -u):$(id -g) ...
|
||||||
```
|
```
|
||||||
In Compose:
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
instaarchive:
|
|
||||||
user: "1234:1234" # a UID that can read your archives
|
|
||||||
```
|
|
||||||
|
|
||||||
### Archive Index
|
|
||||||
|
|
||||||
On first start the server walks the archive root once and caches the result,
|
|
||||||
keyed by directory mtime. This matters on network storage: for a 110k-file
|
|
||||||
archive root, listing went from ~52s per request to ~0.1s. Mount a volume at
|
|
||||||
`/cache` (or set `CACHE_DIR`) so the index survives restarts, otherwise it is
|
|
||||||
rebuilt on every start.
|
|
||||||
|
|
||||||
## Supported Archive Structure
|
## Supported Archive Structure
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.3.2",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"version": "1.3.2",
|
"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",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "instaarchive-viewer",
|
"name": "instaarchive-viewer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.2",
|
"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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,45 +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';
|
||||||
|
|
||||||
export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => {
|
export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => {
|
||||||
// Try to play with sound: opening the modal is a user gesture, so browsers
|
// Start muted so autoplay is not blocked by Safari/Firefox policy.
|
||||||
// generally allow it. If this particular browser still refuses, the effect
|
const [isMuted, setIsMuted] = useState(true);
|
||||||
// below falls back to muted playback rather than leaving a stalled video.
|
const sizingClass = isFullView ? "w-full h-auto block" : "w-full h-full object-cover";
|
||||||
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>;
|
||||||
@@ -47,7 +14,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 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>
|
||||||
|
|||||||
@@ -26,10 +26,9 @@ 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 story = stories[currentStoryIndex];
|
const story = stories[currentStoryIndex];
|
||||||
const primary = story?.media?.[0];
|
const primary = story?.media?.[0];
|
||||||
@@ -62,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) {
|
||||||
|
|||||||
@@ -3,39 +3,39 @@ import { classifyDirectory, groupArchiveDirectories } from './archive-grouping';
|
|||||||
|
|
||||||
describe('classifyDirectory', () => {
|
describe('classifyDirectory', () => {
|
||||||
it('treats a bare profile directory as the base', () => {
|
it('treats a bare profile directory as the base', () => {
|
||||||
expect(classifyDirectory('4utumn07')).toEqual({
|
expect(classifyDirectory('0ct0ber19')).toEqual({
|
||||||
owner: '4utumn07',
|
owner: '0ct0ber19',
|
||||||
source: { kind: 'posts', dir: '4utumn07' },
|
source: { kind: 'posts', dir: '0ct0ber19' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recognises a reels sidecar', () => {
|
it('recognises a reels sidecar', () => {
|
||||||
expect(classifyDirectory('4utumn07 - reels')).toEqual({
|
expect(classifyDirectory('0ct0ber19 - reels')).toEqual({
|
||||||
owner: '4utumn07',
|
owner: '0ct0ber19',
|
||||||
source: { kind: 'reels', dir: '4utumn07 - reels' },
|
source: { kind: 'reels', dir: '0ct0ber19 - reels' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recognises a stories sidecar', () => {
|
it('recognises a stories sidecar', () => {
|
||||||
expect(classifyDirectory('story - dawn_petal')).toEqual({
|
expect(classifyDirectory('story - cher_ryppo')).toEqual({
|
||||||
owner: 'dawn_petal',
|
owner: 'cher_ryppo',
|
||||||
source: { kind: 'stories', dir: 'story - dawn_petal' },
|
source: { kind: 'stories', dir: 'story - cher_ryppo' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('splits highlight owner from title', () => {
|
it('splits highlight owner from title', () => {
|
||||||
const { owner, source } = classifyDirectory('story highlights - 4utumn07 - Sunstory');
|
const { owner, source } = classifyDirectory('story highlights - 0ct0ber19 - Heestory');
|
||||||
expect(owner).toBe('4utumn07');
|
expect(owner).toBe('0ct0ber19');
|
||||||
expect(source.kind).toBe('highlight');
|
expect(source.kind).toBe('highlight');
|
||||||
expect(source.title).toBe('Sunstory');
|
expect(source.title).toBe('Heestory');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['story highlights - theoldlyricmuseinsta - 💙1999-2005 era', 'theoldlyricmuseinsta', '💙1999-2005 era'],
|
['story highlights - theoldtaylorswiftinsta - 💙2014-1989 era', 'theoldtaylorswiftinsta', '💙2014-1989 era'],
|
||||||
['story highlights - member_theworld - [Bracket]', 'member_theworld', '[Bracket]'],
|
['story highlights - heejin_theworld - [Dall]', 'heejin_theworld', '[Dall]'],
|
||||||
['story highlights - official_band - Tour Schedule', 'official_band', 'Tour Schedule'],
|
['story highlights - official_artms - Cosmo Schedule', 'official_artms', 'Cosmo Schedule'],
|
||||||
['story highlights - 4utumn07 - Sketching⠀', '4utumn07', 'Sketching⠀'],
|
['story highlights - 0ct0ber19 - Drawheeing⠀', '0ct0ber19', 'Drawheeing⠀'],
|
||||||
['story highlights - official_band - A.B.C', 'official_band', 'A.B.C'],
|
['story highlights - official_artms - G.C.I', 'official_artms', 'G.C.I'],
|
||||||
])('handles real-world title %s', (dir, owner, title) => {
|
])('handles real-world title %s', (dir, owner, title) => {
|
||||||
const result = classifyDirectory(dir);
|
const result = classifyDirectory(dir);
|
||||||
expect(result.owner).toBe(owner);
|
expect(result.owner).toBe(owner);
|
||||||
@@ -57,25 +57,25 @@ describe('classifyDirectory', () => {
|
|||||||
|
|
||||||
describe('groupArchiveDirectories', () => {
|
describe('groupArchiveDirectories', () => {
|
||||||
const dirs = [
|
const dirs = [
|
||||||
'4utumn07',
|
'0ct0ber19',
|
||||||
'4utumn07 - reels',
|
'0ct0ber19 - reels',
|
||||||
'story - 4utumn07',
|
'story - 0ct0ber19',
|
||||||
'story highlights - 4utumn07 - Sunstory',
|
'story highlights - 0ct0ber19 - Heestory',
|
||||||
'story highlights - 4utumn07 - Sketching⠀',
|
'story highlights - 0ct0ber19 - Drawheeing⠀',
|
||||||
'kestrelsings',
|
'carlyraejepsen',
|
||||||
];
|
];
|
||||||
|
|
||||||
it('folds sidecars into their base profile', () => {
|
it('folds sidecars into their base profile', () => {
|
||||||
const groups = groupArchiveDirectories(dirs);
|
const groups = groupArchiveDirectories(dirs);
|
||||||
expect([...groups.keys()].sort()).toEqual(['4utumn07', 'kestrelsings']);
|
expect([...groups.keys()].sort()).toEqual(['0ct0ber19', 'carlyraejepsen']);
|
||||||
expect(groups.get('4utumn07')).toHaveLength(5);
|
expect(groups.get('0ct0ber19')).toHaveLength(5);
|
||||||
expect(groups.get('kestrelsings')).toHaveLength(1);
|
expect(groups.get('carlyraejepsen')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('orders sources posts, reels, stories, then highlights by title', () => {
|
it('orders sources posts, reels, stories, then highlights by title', () => {
|
||||||
const sources = groupArchiveDirectories(dirs).get('4utumn07')!;
|
const sources = groupArchiveDirectories(dirs).get('0ct0ber19')!;
|
||||||
expect(sources.map(s => s.kind)).toEqual(['posts', 'reels', 'stories', 'highlight', 'highlight']);
|
expect(sources.map(s => s.kind)).toEqual(['posts', 'reels', 'stories', 'highlight', 'highlight']);
|
||||||
expect(sources.slice(3).map(s => s.title)).toEqual(['Sketching⠀', 'Sunstory']);
|
expect(sources.slice(3).map(s => s.title)).toEqual(['Drawheeing⠀', 'Heestory']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still groups a sidecar whose base profile is missing', () => {
|
it('still groups a sidecar whose base profile is missing', () => {
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export interface ArchiveSource {
|
|||||||
/**
|
/**
|
||||||
* Sidecar directories sit next to the profile directory they belong to:
|
* Sidecar directories sit next to the profile directory they belong to:
|
||||||
*
|
*
|
||||||
* 4utumn07 -> posts (base)
|
* 0ct0ber19 -> posts (base)
|
||||||
* 4utumn07 - reels -> reels
|
* 0ct0ber19 - reels -> reels
|
||||||
* story - 4utumn07 -> stories
|
* story - 0ct0ber19 -> stories
|
||||||
* story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
|
* story highlights - 0ct0ber19 - Heestory -> highlight "Heestory"
|
||||||
*
|
*
|
||||||
* Instagram usernames cannot contain spaces, so matching the username as a
|
* Instagram usernames cannot contain spaces, so matching the username as a
|
||||||
* run of non-space characters reliably separates it from a highlight title
|
* run of non-space characters reliably separates it from a highlight title
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user