Compare commits

..
16 Commits
Author SHA1 Message Date
ergosteurandClaude Opus 5 106d3f6691 fix: never descend into NAS metadata directories when indexing
Docker Build and Publish / build-and-push (push) Failing after 11s
The archive root was filtered by prefix, but the recursive walk below it was
not, so anything inside a profile directory got indexed. NAS filesystems put
sidecar metadata *inside* every folder rather than only at the share root:
Synology writes @eaDir (thumbnails and indexing data), #recycle holds
deletions, .sync is Resilio state. On the live share those account for 12,516
of 123,023 files.

None currently sit inside a profile directory, so nothing was miscounted yet —
but the moment that share gets indexed for Photos, every generated thumbnail
would be counted as archive media and stat'd one by one over the network, which
is the cost the index exists to avoid.

One isSystemDirectory rule now applies at every level, and the root listing uses
it too instead of keeping a second copy of the pattern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 11:57:29 -04:00
ergosteurandClaude Opus 5 92a4ada3c2 feat: generate JDownloader crawljobs from the archives on disk
The manual flow is: paste a profile URL into JDownloader, paste the /reels URL
separately (the profile page misses some reels), set the output folder by hand,
repeat per profile. scripts/jd2-sync.ts emits one crawljob per source with the
folder already pointed at the right directory, so folder-watch picks up the
whole batch at once.

Profiles and sidecars are derived with the same grouping logic the server uses,
so output folders always match what the viewer expects to find. --download-base
maps the path for a JDownloader running on another machine (Windows paths
included), since it typically runs on a desktop against the share.

Directories that aren't Instagram profiles are skipped: an archive root also
collects tool output and exports from other services, and pointing a crawl at
those spends requests on instagram.com to be told the profile doesn't exist —
exactly the traffic worth not spending. Filtering is by username shape, plus
--skip and a .jd2ignore file for names that look like usernames but aren't.

Defaults are conservative: chunks=1, because multi-chunk ranged requests are the
one CDN-side pattern that doesn't resemble a browser, and links park in the
LinkGrabber for review rather than auto-starting.

Only posts and reels are emitted; highlight URLs need a numeric id and story
URLs expire, so those stay manual.

Format verified against JDownloader's own explain.txt for the folderwatch
extension, read from the daily SVN mirror rather than one of the decade-stale
GitHub copies.

Also refreshes CLAUDE.md, whose URL-state section still described the query
parameters replaced in 1.4.0, and documents the mobile feed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 11:41:23 -04:00
ergosteurandClaude Opus 5 c54f8d5b09 feat: mobile opens posts as a scrolling feed instead of a modal
Docker Build and Publish / build-and-push (push) Failing after 10s
Tapping a post on a phone now opens a real feed page — header, media, actions,
caption, next post peeking in below — scrolled with the browser's own vertical
scrolling rather than swipe gestures. Desktop keeps the modal, where a centred
sheet with side arrows suits a pointer.

Only a window of posts is mounted: a profile here holds up to 1129 posts and
mounting them all would mean as many full-size images. The window grows in both
directions as you scroll. Growing upwards shifts everything below it, so the
scroll offset is corrected in the same frame, before paint — measured against
the real archive, an anchored post moves exactly one screen per scroll with no
jump.

Only the post crossing the viewport centre plays its video; the rest stay
paused, so a feed of reels doesn't play ten at once. The URL tracks that same
post, so scrolling updates /<archive>/p/<shortcode>/ the way Instagram does,
and the back button returns to the grid with its scroll position intact.

Feed video sizes to the container width rather than its intrinsic size: a
<video> reports 300x150 until metadata loads, which made it render narrow and
then jump to full width. It also gets a taller height ceiling than the modal so
ordinary portrait media fills the width instead of sitting in side bars.

The carousel is extracted into a shared MediaCarousel used by both surfaces, so
horizontal paging behaves identically; touch-action keeps vertical scrolling
passing through to the feed. PostModal loses its now-dead mobile swipe branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 10:23:33 -04:00
ergosteurandClaude Opus 5 877d21ff1f feat: Instagram-shaped URLs, Instagram-shaped gestures, iOS-feel animations
Docker Build and Publish / build-and-push (push) Failing after 10s
Navigation gestures
Horizontal swipe used to advance the carousel and then, on the last slide,
fling you into the next post — one gesture meaning two things. Horizontal is
now carousel-only. On touch, vertical swipe moves between posts (down on the
first post still dismisses, keeping drag-to-close where it can't mean
"previous"). Desktop keeps the arrows outside the modal.

URLs
Permalinks now mirror Instagram:

  /<archive>/                 profile
  /<archive>/reels/           tab
  /<archive>/p/<shortcode>/   post

A post URL carries no tab, as on Instagram; the tab is re-derived from the
post's source, so opening a reel link lands on the Reels tab with next/prev
paging through reels. Sidecar posts keep directory-scoped ids internally but
expose only the shortcode. The old ?a=&t=&p= form is still parsed so existing
links keep working, and reserved prefixes (api, archives, assets…) can never be
mistaken for a profile name.

Animations
Adds a shared motion vocabulary tuned to feel native: critically damped springs
rather than fixed-duration easing, and gestures hand their exit velocity to the
animation so a flick continues instead of restarting. Post transitions animate
along the axis the input implies — vertical for a swipe, horizontal for the
arrows. Modal and story viewer present/dismiss with a scale, tiles and
highlight circles get touch-down feedback, and prefers-reduced-motion is
honoured throughout.

Adds 22 routing tests (58 total).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 10:01:04 -04:00
ergosteurandClaude Opus 5 0cff91edae fix: keep post nav arrows outside the modal and stop scroll chaining
Docker Build and Publish / build-and-push (push) Failing after 11s
The prev/next arrows are fixed to the viewport edges while the modal grows to
fill the available width, so below roughly 1200px the modal slid underneath
them and a white chevron landed on the white caption panel — invisible until
hovered. The overlay now reserves a horizontal gutter (md:px-16 lg:px-24) so
the arrows always sit outside the modal, and they get a solid white pill with a
dark chevron so they read against anything behind them. Verified clearing the
modal at 768, 1024, 1440 and 1920px.

The caption sidebar shrinks to w-80 at md so the narrower modal doesn't squeeze
the media pane.

Also adds overscroll-contain to the overlay: the wheel previously chained
through to the post grid behind it, scrolling the background while a post was
open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 09:05:24 -04:00
ergosteurandClaude Opus 5 0b4b20e0ff feat: fit full-view media to the viewport and play with sound
Docker Build and Publish / build-and-push (push) Failing after 10s
Media in the post modal used w-full/h-auto, so a portrait video or image grew
taller than the screen (a 720x1280 reel rendered 768x1365 in a 786px viewport)
and forced the modal to scroll. Full view now caps height to the viewport minus
the modal's own padding. Video sizes to its own aspect within the cap so a
portrait clip isn't letterboxed edge to edge; images keep filling the modal
width and only gain a height ceiling.

Opening the modal or a story reel is a user gesture, so playback now starts
unmuted and only falls back to muted if the browser actually refuses the
play() promise — previously it always started muted, and the earlier
muted-by-default fix meant a blocked video could stall the story progress bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 08:50:49 -04:00
ergosteurandClaude Opus 5 c0b6b6cf3e docs: update CLAUDE.md for the archive index, sidecars and cache rehydration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 02:14:11 -04:00
ergosteurandClaude Opus 5 ab7099220a chore: bump version to 1.3.1
Docker Build and Publish / build-and-push (push) Failing after 10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 02:08:35 -04:00
ergosteurandClaude Opus 5 e6663657b0 fix: don't crash at boot when running under a UID with no passwd entry
os.userInfo() throws ERR_SYSTEM_ERROR (uv_os_get_passwd) for a UID that has no
/etc/passwd entry, which is exactly what `docker run --user 1234:1234` produces
— the very workaround the README recommends. Combined with the switch to a
non-root image user, this crashed the server on startup for any deployment that
needed a custom UID to read its archives.

Also document the non-root default and the /cache index volume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 02:08:35 -04:00
ergosteurandClaude Opus 5 24ff2727c8 Merge branch 'review-fixes': security, performance and sidecar archive support
Docker Build and Publish / build-and-push (push) Failing after 10s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 01:59:41 -04:00
ergosteurandClaude Opus 5 f089e49e84 chore: bump version to 1.3.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 01:59:41 -04:00
ergosteurandClaude Opus 5 1d86fa3583 fix: security, performance and correctness pass; add sidecar archive support
Security
- Fix path traversal in GET /api/archives/:name/files. Express decodes route
  params after segment matching, so `..%2f..%2fetc` escaped ARCHIVES_DIR and
  returned a recursive listing of arbitrary directories.
- Add CSP and baseline security headers; disable x-powered-by.
- Stop baking GEMINI_API_KEY into the client bundle (the SDK was unused).
- Run the container as `node` instead of root.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 01:59:19 -04:00
ergosteur 0ba7a0d9ad docs: update documentation for high-res performance and local persistence
Docker Build and Publish / build-and-push (push) Failing after 9s
Key changes:
- Updated README.md and GEMINI.md with details on background thumbnailing and inter-post preloading.
- Documented persistent local archive caching and smart profile fallback features.
- Added dist-server/ to .gitignore.
- Restored missing feature descriptions and troubleshooting tips in README.
2026-03-07 21:59:43 -05:00
ergosteur 899e8dfbbb feat: enable persistent local archives and smart profile fallback
Key changes:
- Enabled full metadata caching for local folder archives, allowing them to load instantly from IndexedDB without re-uploading.
- Implemented oldest-image fallback for profiles missing an explicit profile picture.
- Restored folder-name-to-username detection for local archive uploads.
- Optimized scan indexing to track all image files for fallback use.
2026-03-07 21:56:25 -05:00
ergosteur 8809b7794b fix: restore white glass scanning UI and resolve small image blur bug
Key changes:
- Corrected logic in PostThumbnail to prevent blur effects on images smaller than 1MiB.
- Restored the white glass aesthetic to the scanning dashboard with improved contrast and transparency.
- Optimized scanning background transitions to ensure a smooth, flicker-free crossfade.
2026-03-07 21:46:11 -05:00
ergosteur 8ec2c07b3f perf: implement background thumbnail generation and inter-post preloading
Key changes:
- Added Web Worker for background image thumbnailing with a 1MiB threshold to optimize CPU/memory usage.
- Implemented a serial task queue for memory-safe high-res image processing, preventing OOM crashes.
- Added inter-post preloading in the modal for seamless 'Previous/Next' navigation.
- Refined scanning UI with double-buffering and a dark background to completely eliminate white flashes.
- Renamed project to 'instaarchive-viewer' in package.json.
- Fixed 'Open image in new tab' by denylisting /archives and /api in PWA config.
2026-03-07 21:42:56 -05:00
52 changed files with 3579 additions and 1734 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules/ node_modules/
build/ build/
dist/ dist/
dist-server/
coverage/ coverage/
.DS_Store .DS_Store
*.log *.log
+108
View File
@@ -0,0 +1,108 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 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.
## Commands
- `npm install` — install dependencies
- `npm run dev` — 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 build` — frontend to `dist/`, backend to `dist-server/`
- `npm run lint` — type-check only (`tsc --noEmit`)
- `npm test` / `npm run test:watch` — vitest
- `npx vitest run src/lib/archive-patterns.test.ts` — a single test file
- `npm run jd2 -- --archives <dir> --dry-run` — generate JDownloader `.crawljob`
files for every profile on disk (see `scripts/jd2-sync.ts`)
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
### Two archive sources, one data model
Loading is 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.
- **`RemoteArchiveFile`** — wraps a file served from `/archives/...`, 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.
### 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`)
`handleFiles` indexes files, detects format, then parses via one of three largely independent paths that all write into a shared `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.
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.
### Cache and local-archive persistence (`src/lib/archive-cache.ts`)
IndexedDB keys are namespaced (`archive:`, `thumb:`, `handle:`) so listing archives does not deserialize every cached thumbnail blob, and thumbnails are scoped per archive to avoid cross-archive collisions.
Restoring an archive **rehydrates URLs from `path`**: server archives rebuild HTTP URLs; local archives re-open a persisted `FileSystemDirectoryHandle` and mint fresh blob URLs. If the folder is unreachable (permission lapsed, or the browser lacks `showDirectoryPicker` — Firefox/Safari), the app re-prompts rather than rendering broken images.
### Thumbnails (`src/hooks/useThumbnailQueue.ts` + `src/lib/thumbnail-worker.ts`)
Images over 1MiB are downscaled in a Web Worker via `OffscreenCanvas`. The queue is **serial on purpose** — decoding several 50MP+ images at once OOMs the tab. `requestThumbnail` must keep a stable identity (it reads cache state through a ref), or every completed thumbnail re-runs the effect in all mounted thumbnails.
### URL state (`src/App.tsx`, `src/lib/routing.ts`)
Paths mirror Instagram: `/<archive>/`, `/<archive>/reels/`, `/<archive>/p/<shortcode>/`. The old `?a=&t=&p=` form is still parsed for existing links but never written. Reserved prefixes (`api`, `archives`, `assets`…) can't be mistaken for a profile name.
A post URL carries no tab, as on Instagram — the tab is re-derived from the post's `source`, so a reel link lands on the Reels tab and pages through reels. Sidecar posts keep directory-scoped ids internally but expose only the shortcode.
Three rules, all learned from real bugs:
- The initial route is captured into a ref on first render; the URL is rewritten from state as soon as anything loads, so reading `window.location` later sees the rewrite, not the user's link.
- URL writing is gated on `hasInitialLoaded`, otherwise it erases the deep link before the loader consumes it.
- Deep-link resolution waits on the archive fetch having *settled* (`archivesFetched`), not on `isServerMode`, which is still false while the request is in flight.
### Mobile feed (`src/components/PostFeed.tsx`)
Below `md`, opening a post renders a scrolling feed page rather than the modal (`useIsMobile` decides). Only a window of posts is mounted; it grows both ways, and prepending corrects `scrollTop` in a `useLayoutEffect` so content doesn't jump. Only the post crossing the viewport centre plays its video and drives the URL. Desktop keeps `PostModal`; both share `MediaCarousel`.
### Backend (`server.ts`)
Serves `/api/archives`, `/api/archives/:name/files`, static `/archives`, and the built SPA. Notes:
- Express decodes route params **after** segment matching, so `..%2f` reaches the handler as `../`. All user-supplied archive names go through `resolveArchivePath`.
- 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
- `vite.config.ts` sets `hmr: process.env.DISABLE_HMR !== 'true'` — intentional, leave it.
- `workbox.navigateFallbackDenylist` excludes `/api` and `/archives` so those hit the real server.
- Fonts and icons are vendored in `public/` — do not reintroduce CDN references; the app advertises offline support and local-only processing.
+11 -4
View File
@@ -18,6 +18,9 @@ FROM node:20-slim AS runtime
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV ARCHIVES_DIR=/archives ENV ARCHIVES_DIR=/archives
# Where the archive index is persisted; mount a volume here so a restart does
# not have to re-walk the whole archive root.
ENV CACHE_DIR=/cache
WORKDIR /app WORKDIR /app
@@ -25,12 +28,16 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
# Copy built assets and server # Copy built assets and server. The whole dist-server tree is needed: the
# server imports shared archive-grouping logic emitted alongside it.
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY --from=build /app/dist-server/server.js ./server.js COPY --from=build /app/dist-server/ ./
# Ensure archives directory exists # Ensure archives and cache directories exist, writable by the runtime user.
RUN mkdir -p /archives RUN mkdir -p /archives /cache && chown node:node /cache
# Drop root: the server reads the archives volume and writes only its index.
USER node
EXPOSE 3000 EXPOSE 3000
+24 -24
View File
@@ -6,16 +6,20 @@
### Key Technical Features ### Key Technical Features
- **Permalinks:** Full synchronization between application state and URL query parameters (`?a=`, `?t=`, `?p=`). Supports deep-linking to archives, tabs, and specific posts. URL parameters are automatically cleaned when navigating back to the archive explorer. - **Permalinks:** Full synchronization between application state and URL query parameters (`?a=`, `?t=`, `?p=`). Supports deep-linking to archives, tabs, and specific posts. URL parameters are automatically cleaned when navigating back to the archive explorer.
- **Persistent Caching:** Uses `idb-keyval` (IndexedDB) to cache parsed metadata. Subsequent loads of the same archive are near-instant. The cache schema includes `profileMetadata` with consolidated user info and profile picture history. - **Persistent Caching:** Uses `idb-keyval` (IndexedDB) to cache parsed metadata. Subsequent loads are near-instant.
- **Modular Scanning Logic:** High-performance archive scanning encapsulated in the `useArchiveScanner` hook. It handles multi-format detection (Instagram Export, Instaloader, JSON), batch processing, and yields to the main thread to prevent UI freezing. - **Metadata:** Caches profile info and post lists for both remote AND local archives.
- **Thumbnails:** High-res images (>1MiB) and videos have thumbnails generated and cached in IndexedDB with the `thumb_` prefix.
- **Background Media Processing:**
- **High-Res Images:** A dedicated Web Worker (`thumbnail-worker.ts`) handles image resizing using `OffscreenCanvas` and `createImageBitmap` to prevent main-thread jank.
- **Serial Queue:** A memory-safe queue ensures only one high-res image is decoded at a time, preventing Out-of-Memory (OOM) crashes on 50MP+ files.
- **High-Performance Carousel:** Advanced `PostModal` with: - **High-Performance Carousel:** Advanced `PostModal` with:
- **Preloading:** Intelligently preloads the first two slides immediately, followed by a background preload of the entire carousel. - **Inter-Post Preloading:** Preloads the first media of adjacent posts for instant navigation.
- **Seamless Transitions:** Zero-latency slide transitions with optimized Framer Motion variants, removing "black flashes" between images. - **Intra-Carousel Preloading:** Intelligently preloads the current carousel's slides.
- **Async Decoding:** Utilizes `decoding="async"` to offload image processing from the main thread. - **Seamless Transitions:** Zero-latency slide transitions without "black flashes" between images.
- **Glassy Scanner UI:** Custom-built glassmorphism scanning dashboard with throttled (1s) dynamic blurred backgrounds and a high-density system log. - **Glassy Scanner UI:** Custom-built glassmorphism scanning dashboard with double-buffering logic to ensure smooth, flicker-free background crossfades during file indexing.
- **PWA Auto-Updates:** Configured with `autoUpdate` behavior and a periodic (hourly) update check to ensure long-running sessions and installed PWAs always have the latest code. - **PWA Capabilities:**
- **Compressed Metadata:** Support for `.json.xz` file decompression using `xz-decompress` (WASM-powered). - **Auto-Updates:** Hourly periodic update checks.
- **Production-Ready Docker:** Multi-stage Docker builds using `node:slim` serving both the Express API and the Vite-built frontend. - **Navigation Fix:** `navigateFallbackDenylist` allows direct server access to `/archives/` and `/api/` (enabling "Open in new tab" for original files).
### Main Technologies ### Main Technologies
- **Frontend:** React 19, Vite 6, TypeScript - **Frontend:** React 19, Vite 6, TypeScript
@@ -23,14 +27,14 @@
- **Icons:** Lucide React - **Icons:** Lucide React
- **Animations:** Framer Motion (`motion/react`) - **Animations:** Framer Motion (`motion/react`)
- **Persistence:** IndexedDB (`idb-keyval`) - **Persistence:** IndexedDB (`idb-keyval`)
- **Backend:** Express, tsx (for server-side scanning) - **Backend:** Express, tsx
- **Decompression:** xz-decompress (WASM) - **Workers:** Web Workers for background image processing.
## Architecture ## Architecture
### State Management ### State Management
- **`useArchiveScanner` Hook:** Centralized logic for parsing archives and managing results (`allPosts`, `allStories`, `profileMetadata`). - **`useArchiveScanner` Hook:** Centralized logic for parsing and caching. It handles folder-name-to-username detection and "Smart Fallback" profile pictures (using the oldest image if no profile pic is found).
- **Archive Interface:** Unified `ArchiveFile` interface implemented by `LocalArchiveFile` (for browser `File` objects) and `RemoteArchiveFile` (for server-side assets). - **`useThumbnailQueue` Hook:** Manages the serial processing of high-resolution media.
### Cache Schema ### Cache Schema
```typescript ```typescript
@@ -38,8 +42,8 @@ interface CacheData {
name: string; name: string;
isLocal: boolean; isLocal: boolean;
fileCount: number; fileCount: number;
posts: Post[]; // Remote archives only (Local archives re-parsed for security) posts: Post[]; // Cached for all archive types
stories: Post[]; // Remote archives only stories: Post[];
profileMetadata: { profileMetadata: {
username: string; username: string;
fullName: string; fullName: string;
@@ -55,15 +59,11 @@ interface CacheData {
``` ```
## Commands ## Commands
- `npm install`: Install project dependencies. - `npm install`: Install dependencies.
- `npm run dev`: Start the local development server on port 3000. - `npm run dev`: Start dev server (Port 3000).
- `npm run build`: Generate the production-ready build in the `dist` folder and server in `dist-server`. - `npm run build`: Build frontend (`dist/`) and server (`dist-server/`).
- `npm run server`: Start the backend server to scan `./_sample-archives`. - `npm run server`: Start production-ready backend.
- `npm run lint`: Execute TypeScript type-checking. - `npm run lint`: Execute TypeScript type-checking.
## Production Deployment ## Production Deployment
The project is containerized and available on GHCR. It expects a volume mount at `/archives` containing subdirectories for each user. The project is containerized. It expects a volume mount at `/archives` containing subdirectories for each user.
### Key Environment Variables
- `PORT`: Server port (default: 3000)
- `ARCHIVES_DIR`: Path to the archives collection (default: /archives)
+25 -6
View File
@@ -4,14 +4,16 @@ A high-performance React PWA for browsing archived Instagram data with a native-
## Features ## Features
- **Advanced Carousel**: Seamless, zero-latency transitions between slides with intelligent preloading. Images use asynchronous decoding to keep the UI smooth during motion. - **Advanced Carousel**: Seamless, zero-latency transitions between slides with intelligent preloading. Navigating between different posts is now near-instant thanks to inter-post background preloading.
- **Persistent Caching**: Uses IndexedDB to store parsed archives locally. Subsequent loads are near-instant, with full support for profile metadata and profile picture history. - **High-Res Performance**: Handles 50MP+ images effortlessly using a background Web Worker and a memory-safe serial processing queue.
- **Persistent Local Caching**: Uses IndexedDB to store parsed archives and generated thumbnails. **Local folders** now load instantly from cache on return visits without needing to re-upload files.
- **Permalinks**: State is synchronized with the URL, allowing you to share direct links to archives, tabs, or specific posts. Navigating back to the explorer cleans up URL parameters automatically. - **Permalinks**: State is synchronized with the URL, allowing you to share direct links to archives, tabs, or specific posts. Navigating back to the explorer cleans up URL parameters automatically.
- **Glassy Scanning UI**: A modern, translucent white terminal experience with a dynamic blurred background generated from your media during scanning. - **Glassy Scanning UI**: A refined, translucent white terminal experience with flicker-free, double-buffered dynamic blurred backgrounds.
- **PWA with Auto-Update**: Fully offline-capable and installable. Clients automatically receive updates when a new version is deployed to the server. - **PWA with Auto-Update**: Fully offline-capable and installable. Clients automatically receive updates when a new version is deployed to the server.
- **Local Privacy**: All processing is done client-side. Even when using the self-hosted version, your media is processed locally in your browser and never uploaded. - **Local Privacy**: All processing is done client-side. Even when using the self-hosted version, your media is processed locally in your browser and never uploaded.
- **Story Viewer**: Native-like story experience with segmented progress bars, auto-playback, and audio controls. - **Smart Fallbacks**: Automatically detects usernames from folder names and uses the oldest archive image as a profile picture if one is missing.
- **Customizable Grid**: 1:1 or 3:4 aspect ratios with adjustable "bumps" for aesthetic alignment. - **Customizable Grid**: 1:1 or 3:4 aspect ratios with adjustable "bumps" for aesthetic alignment.
- **Story Viewer**: Native-like story experience with segmented progress bars, auto-playback, and audio controls.
- **Navigation Protection**: Intercepts accidental browser "Back" or "Refresh" actions to protect your current session. - **Navigation Protection**: Intercepts accidental browser "Back" or "Refresh" actions to protect your current session.
## Deployment ## Deployment
@@ -52,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
+6
View File
@@ -5,7 +5,13 @@ services:
- "3000:3000" - "3000:3000"
volumes: volumes:
- ./archives:/archives:ro,z - ./archives:/archives:ro,z
# Persists the archive index so restarts don't re-walk every file.
- instaarchive-cache:/cache
environment: environment:
- PORT=3000 - PORT=3000
- ARCHIVES_DIR=/archives - ARCHIVES_DIR=/archives
- CACHE_DIR=/cache
restart: unless-stopped restart: unless-stopped
volumes:
instaarchive-cache:
-137
View File
@@ -1,137 +0,0 @@
import express from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import os from 'os';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
const ARCHIVES_DIR = path.resolve(process.env.ARCHIVES_DIR || path.join(__dirname, '_sample-archives'));
console.log(`[Server] Initializing...`);
console.log(`[Server] Running as user: ${os.userInfo().username} (UID: ${os.userInfo().uid}, GID: ${os.userInfo().gid})`);
console.log(`[Server] Environment ARCHIVES_DIR: ${process.env.ARCHIVES_DIR}`);
console.log(`[Server] Resolved ARCHIVES_DIR: ${ARCHIVES_DIR}`);
// Ensure archives directory exists
if (!fs.existsSync(ARCHIVES_DIR)) {
console.warn(`[Server] Warning: Archives directory not found at ${ARCHIVES_DIR}. Creating it...`);
try {
fs.mkdirSync(ARCHIVES_DIR, { recursive: true });
}
catch (err) {
console.error(`[Server] Failed to create archives directory:`, err);
}
}
else {
console.log(`[Server] Archives directory exists.`);
}
app.use(express.json());
// API: List archives (subdirectories in ARCHIVES_DIR)
app.get('/api/archives', (req, res) => {
try {
console.log(`[API] Listing archives from ${ARCHIVES_DIR}...`);
const items = fs.readdirSync(ARCHIVES_DIR, { withFileTypes: true });
console.log(`[API] Found ${items.length} total items in archives directory.`);
const archives = items
.filter(item => {
const isDir = item.isDirectory();
const isHidden = item.name.startsWith('.') || item.name.startsWith('@') || item.name.startsWith('_');
if (!isDir)
return false;
if (isHidden) {
console.log(`[API] Skipping system/hidden directory: ${item.name}`);
return false;
}
return true;
})
.map(item => {
// Try to find a profile pic or first image for the thumbnail
const archivePath = path.join(ARCHIVES_DIR, item.name);
try {
const files = fs.readdirSync(archivePath);
console.log(`[API] Found archive: ${item.name} (${files.length} files)`);
let thumbnail = '';
const profilePic = files.find(f => f.toLowerCase().includes('_profile_pic.jpg') || f.toLowerCase() === `${item.name.toLowerCase()}.jpg`);
if (profilePic) {
thumbnail = `/archives/${item.name}/${profilePic}`;
}
else {
const firstImage = files.find(f => /\.(jpg|jpeg|png|webp)$/i.test(f));
if (firstImage)
thumbnail = `/archives/${item.name}/${firstImage}`;
}
return {
name: item.name,
thumbnail,
path: item.name,
fileCount: files.length
};
}
catch (e) {
console.error(`[API] Could not read subdirectory ${item.name}:`, e);
return null;
}
})
.filter(Boolean);
console.log(`[API] Returning ${archives.length} validated archives.`);
res.json(archives);
}
catch (err) {
if (err.code === 'EACCES') {
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).`);
}
else {
console.error('[API] Error listing archives:', err);
}
res.status(500).json({ error: 'Permission denied or failed to list archives' });
}
});
// API: List all files in an archive (recursive)
app.get('/api/archives/:name/files', (req, res) => {
const archiveName = req.params.name;
const archivePath = path.join(ARCHIVES_DIR, archiveName);
if (!fs.existsSync(archivePath)) {
return res.status(404).json({ error: 'Archive not found' });
}
try {
const walk = (dir, base = '') => {
let results = [];
const list = fs.readdirSync(dir);
list.forEach(file => {
const filePath = path.join(dir, file);
const relativePath = path.join(base, file);
const stat = fs.statSync(filePath);
if (stat && stat.isDirectory()) {
results = results.concat(walk(filePath, relativePath));
}
else {
results.push(relativePath);
}
});
return results;
};
const files = walk(archivePath);
res.json(files);
}
catch (err) {
console.error('Error listing files:', err);
res.status(500).json({ error: 'Failed to list files' });
}
});
// Serve archive files
app.use('/archives', express.static(ARCHIVES_DIR));
// Serve production frontend
const distPath = path.join(__dirname, 'dist');
if (fs.existsSync(distPath)) {
app.use(express.static(distPath));
app.get('*', (req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
console.log(`Serving archives from: ${ARCHIVES_DIR}`);
});
+472 -1171
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "react-example", "name": "instaarchive-viewer",
"private": true, "private": true,
"version": "1.2.0", "version": "1.5.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port=3000 --host=0.0.0.0", "dev": "vite --port=3000 --host=0.0.0.0",
@@ -10,13 +10,14 @@
"preview": "vite preview", "preview": "vite preview",
"server": "tsx server.ts", "server": "tsx server.ts",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"jd2": "tsx scripts/jd2-sync.ts"
}, },
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"better-sqlite3": "^12.4.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
@@ -34,10 +35,13 @@
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0", "vite": "^6.2.0",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.7"
} }
} }
+56
View File
@@ -0,0 +1,56 @@
/* Self-hosted subset of Inter + Playfair Display.
Vendored so the PWA works offline and makes no third-party requests. */
/* latin-ext */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(/fonts/inter-300_700-normal-b6db4a.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(/fonts/inter-300_700-normal-6ab57b.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Playfair Display';
font-style: italic;
font-weight: 400 900;
font-display: swap;
src: url(/fonts/playfair-display-400_900-italic-2d6d99.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Playfair Display';
font-style: italic;
font-weight: 400 900;
font-display: swap;
src: url(/fonts/playfair-display-400_900-italic-d14361.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400 900;
font-display: swap;
src: url(/fonts/playfair-display-400_900-normal-ca7410.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400 900;
font-display: swap;
src: url(/fonts/playfair-display-400_900-normal-61a963.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+118 -73
View File
@@ -4,6 +4,7 @@ import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import os from 'os'; import os from 'os';
import { ArchiveIndex } from './src/lib/archive-index.js';
dotenv.config(); dotenv.config();
@@ -15,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}`);
@@ -31,60 +48,89 @@ if (!fs.existsSync(ARCHIVES_DIR)) {
console.log(`[Server] Archives directory exists.`); console.log(`[Server] Archives directory exists.`);
} }
const INDEX_PATH = process.env.ARCHIVE_INDEX_PATH
|| path.join(process.env.CACHE_DIR || os.tmpdir(), 'instaarchive-index.json');
const index = new ArchiveIndex(ARCHIVES_DIR, INDEX_PATH);
// Warm in the background: the first walk of a large archive root is slow, but
// everything after it is served from directory-mtime-keyed cache.
index.load()
.then(() => index.warm())
.catch(err => console.error('[Index] Warm failed:', err));
// Don't advertise the framework.
app.disable('x-powered-by');
/**
* Baseline security headers.
*
* The CSP allows blob: and data: because archive media is rendered from object
* URLs and cached thumbnails, and 'unsafe-inline' for styles because the
* animation library sets inline styles. Scripts stay restricted to same-origin,
* and no third-party origins are permitted at all — the app bundles its own
* fonts and icons.
*/
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), interest-cohort=()');
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"img-src 'self' blob: data:",
"media-src 'self' blob: data:",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"font-src 'self'",
"connect-src 'self'",
"worker-src 'self' blob:",
"frame-ancestors 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; '));
next();
});
app.use(express.json()); app.use(express.json());
// API: List archives (subdirectories in ARCHIVES_DIR) /**
* Resolve a user-supplied archive name to an absolute path inside ARCHIVES_DIR.
*
* Express decodes route params *after* segment matching, so a name like
* `..%2f..%2fetc` arrives here as `../../etc` and would otherwise escape the
* archives root. Returns null for anything that resolves outside it.
*/
const resolveArchivePath = (archiveName: string): string | null => {
if (!archiveName || archiveName.includes('\0')) return null;
const resolved = path.resolve(ARCHIVES_DIR, archiveName);
if (resolved !== ARCHIVES_DIR && !resolved.startsWith(ARCHIVES_DIR + path.sep)) {
console.warn(`[Security] Rejected archive name escaping ARCHIVES_DIR: ${archiveName}`);
return null;
}
return resolved;
};
// API: List archives, grouped by profile. Costs one stat per source directory.
app.get('/api/archives', (req, res) => { app.get('/api/archives', (req, res) => {
try { try {
console.log(`[API] Listing archives from ${ARCHIVES_DIR}...`); const groups = index.groups();
const items = fs.readdirSync(ARCHIVES_DIR, { withFileTypes: true }); const archives = Array.from(groups.entries()).map(([owner, sources]) => ({
console.log(`[API] Found ${items.length} total items in archives directory.`); name: owner,
thumbnail: index.thumbnailFor(owner, sources),
const archives = items path: owner,
.filter(item => { // Null until that profile has been indexed; the client treats it as unknown.
const isDir = item.isDirectory(); fileCount: index.countFor(sources),
const isHidden = item.name.startsWith('.') || item.name.startsWith('@') || item.name.startsWith('_'); // Directory mtimes: cheap to compute and enough to invalidate a stale cache.
if (!isDir) return false; signature: index.signatureFor(sources),
if (isHidden) { sources,
console.log(`[API] Skipping system/hidden directory: ${item.name}`); }));
return false; console.log(`[API] Returning ${archives.length} archives.`);
}
return true;
})
.map(item => {
// Try to find a profile pic or first image for the thumbnail
const archivePath = path.join(ARCHIVES_DIR, item.name);
try {
const files = fs.readdirSync(archivePath);
console.log(`[API] Found archive: ${item.name} (${files.length} files)`);
let thumbnail = '';
const profilePic = files.find(f => f.toLowerCase().includes('_profile_pic.jpg') || f.toLowerCase() === `${item.name.toLowerCase()}.jpg`);
if (profilePic) {
thumbnail = `/archives/${item.name}/${profilePic}`;
} else {
const firstImage = files.find(f => /\.(jpg|jpeg|png|webp)$/i.test(f));
if (firstImage) thumbnail = `/archives/${item.name}/${firstImage}`;
}
return {
name: item.name,
thumbnail,
path: item.name,
fileCount: files.length
};
} catch (e) {
console.error(`[API] Could not read subdirectory ${item.name}:`, e);
return null;
}
})
.filter(Boolean);
console.log(`[API] Returning ${archives.length} validated archives.`);
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);
@@ -93,33 +139,26 @@ app.get('/api/archives', (req, res) => {
} }
}); });
// API: List all files in an archive (recursive) /**
app.get('/api/archives/:name/files', (req, res) => { * List every file belonging to a profile, across its base and sidecar dirs.
*
* Paths are relative to ARCHIVES_DIR (so they include the source directory) and
* each entry carries its source kind, letting the client route posts, reels,
* stories and highlights without re-deriving the naming rules.
*
* Served from the directory index; only a directory whose mtime changed is
* re-walked.
*/
app.get('/api/archives/:name/files', async (req, res) => {
const archiveName = req.params.name; const archiveName = req.params.name;
const archivePath = path.join(ARCHIVES_DIR, archiveName); if (!resolveArchivePath(archiveName)) {
return res.status(400).json({ error: 'Invalid archive name' });
if (!fs.existsSync(archivePath)) {
return res.status(404).json({ error: 'Archive not found' });
} }
try { try {
const walk = (dir: string, base: string = ''): string[] => { const files = await index.filesFor(archiveName);
let results: string[] = []; if (!files) return res.status(404).json({ error: 'Archive not found' });
const list = fs.readdirSync(dir); void index.save();
list.forEach(file => {
const filePath = path.join(dir, file);
const relativePath = path.join(base, file);
const stat = fs.statSync(filePath);
if (stat && stat.isDirectory()) {
results = results.concat(walk(filePath, relativePath));
} else {
results.push(relativePath);
}
});
return results;
};
const files = walk(archivePath);
res.json(files); res.json(files);
} catch (err) { } catch (err) {
console.error('Error listing files:', err); console.error('Error listing files:', err);
@@ -127,8 +166,14 @@ app.get('/api/archives/:name/files', (req, res) => {
} }
}); });
// Serve archive files // Serve archive files. Archive contents are immutable in practice, so cache
app.use('/archives', express.static(ARCHIVES_DIR)); // them aggressively; the client busts its own cache via fileCount.
app.use('/archives', express.static(ARCHIVES_DIR, {
maxAge: '1y',
immutable: true,
index: false,
dotfiles: 'ignore',
}));
// Serve production frontend // Serve production frontend
const distPath = path.join(__dirname, 'dist'); const distPath = path.join(__dirname, 'dist');
+360 -104
View File
@@ -15,19 +15,38 @@ import {
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import * as idb from 'idb-keyval';
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 { Post, ServerArchive } from './types'; import {
deleteCachedArchive,
getCachedArchive,
listCachedArchives,
listCachedArchiveNames,
migrateLegacyCache,
restoreArchive,
saveDirectoryHandle,
} from './lib/archive-cache';
import {
filesFromDirectory,
isDirectoryPickerSupported,
pickDirectory,
} from './lib/directory-handle';
import { CacheData, Post, ServerArchive, ServerArchiveFile } from './types';
import { ArchiveDashboard } from './components/ArchiveDashboard'; import { ArchiveDashboard } from './components/ArchiveDashboard';
import { StoryViewer } from './components/StoryViewer'; import { StoryViewer } from './components/StoryViewer';
import { PostModal } from './components/PostModal'; import { PostModal } from './components/PostModal';
import { VideoThumbnail } from './components/VideoThumbnail'; import { PostFeed } from './components/PostFeed';
import { useIsMobile } from './hooks/useIsMobile';
import { PostThumbnail } from './components/PostThumbnail';
import { useArchiveScanner } from './hooks/useArchiveScanner'; import { useArchiveScanner } from './hooks/useArchiveScanner';
import { useThumbnailQueue } from './hooks/useThumbnailQueue';
export default function App() { export default function App() {
const [showStoryViewer, setShowStoryViewer] = useState(false); const [showStoryViewer, setShowStoryViewer] = useState(false);
const [activeHighlight, setActiveHighlight] = useState<string | null>(null);
const [visiblePostsCount, setVisiblePostsCount] = useState(90); const [visiblePostsCount, setVisiblePostsCount] = useState(90);
const [selectedPost, setSelectedPost] = useState<Post | null>(null); const [selectedPost, setSelectedPost] = useState<Post | null>(null);
@@ -36,31 +55,36 @@ export default function App() {
const [activeTab, setActiveTab] = useState<'posts' | 'reels' | 'saved'>('posts'); const [activeTab, setActiveTab] = useState<'posts' | 'reels' | 'saved'>('posts');
const [serverArchives, setServerArchives] = useState<ServerArchive[]>([]); const [serverArchives, setServerArchives] = useState<ServerArchive[]>([]);
const [cachedArchives, setCachedArchives] = useState<Set<string>>(new Set()); const [cachedArchives, setCachedArchives] = useState<Set<string>>(new Set());
const [localCachedArchives, setLocalCachedArchives] = useState<any[]>([]); const [localCachedArchives, setLocalCachedArchives] = useState<CacheData[]>([]);
const [isServerMode, setIsServerMode] = useState(false); const [isServerMode, setIsServerMode] = useState(false);
/** True once GET /api/archives has settled, successfully or not. */
const [archivesFetched, setArchivesFetched] = useState(false);
const [currentArchive, setCurrentArchive] = useState<ServerArchive | null>(null); const [currentArchive, setCurrentArchive] = useState<ServerArchive | null>(null);
const [hasInitialLoaded, setHasInitialLoaded] = useState(false);
/**
* The route as it was when the app booted.
*
* Captured during the first render because the URL is rewritten from app
* state as soon as anything loads; reading `window.location` later would see
* the rewritten value rather than the link the user actually followed.
*/
const initialRouteRef = useRef(parseRoute(window.location.pathname, window.location.search));
const isMobile = useIsMobile();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const profilePicInputRef = useRef<HTMLInputElement>(null); const profilePicInputRef = useRef<HTMLInputElement>(null);
const refreshCachedArchives = useCallback(async () => { const refreshCachedArchives = useCallback(async () => {
try { try {
const keys = await idb.keys(); // Names come from key prefixes, so listing no longer deserializes every
setCachedArchives(new Set(keys.map(String))); // cached thumbnail blob just to find out which entries are archives.
setCachedArchives(new Set(await listCachedArchiveNames()));
const locals: any[] = []; setLocalCachedArchives((await listCachedArchives()).filter(a => a.isLocal));
for (const key of keys) { } catch (e) {
const data: any = await idb.get(key); console.error('[Cache] Failed to list cached archives:', e);
if (data && data.isLocal) { }
// Fallback for missing allProfilePics in local cached metadata
if (!data.profileMetadata.allProfilePics) {
data.profileMetadata.allProfilePics = data.profileMetadata.profilePic ? [data.profileMetadata.profilePic] : [];
}
locals.push(data);
}
}
setLocalCachedArchives(locals);
} catch (e) {}
}, []); }, []);
const { const {
@@ -72,6 +96,8 @@ export default function App() {
currentScanningImage, currentScanningImage,
allPosts, allPosts,
allStories, allStories,
allHighlights,
setAllHighlights,
profileMetadata, profileMetadata,
handleFiles, handleFiles,
setAllPosts, setAllPosts,
@@ -79,9 +105,12 @@ export default function App() {
setProfileMetadata, setProfileMetadata,
setIsScanning, setIsScanning,
setScanningPhase, setScanningPhase,
resetScannerState resetScannerState,
registerUrl
} = useArchiveScanner('', currentArchive, refreshCachedArchives); } = useArchiveScanner('', currentArchive, refreshCachedArchives);
const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState<string | null>(null);
const { const {
username, username,
fullName, fullName,
@@ -93,6 +122,9 @@ export default function App() {
allProfilePics allProfilePics
} = profileMetadata; } = profileMetadata;
// Thumbnails are keyed per archive, so the queue is scoped to the open one.
const { cacheHits, requestThumbnail } = useThumbnailQueue(currentArchive?.name ?? username ?? '');
useEffect(() => { useEffect(() => {
fetch('/api/archives') fetch('/api/archives')
.then(res => { .then(res => {
@@ -102,21 +134,51 @@ export default function App() {
} }
return []; return [];
}) })
.then(data => setServerArchives(data)) .then(data => setServerArchives(Array.isArray(data) ? data : []))
.catch(() => setIsServerMode(false)); .catch(() => setIsServerMode(false))
// Deep-link resolution waits on this rather than on `isServerMode`, which
// is still false while the request is in flight.
.finally(() => setArchivesFetched(true));
}, []); }, []);
useEffect(() => { useEffect(() => {
refreshCachedArchives(); migrateLegacyCache().finally(refreshCachedArchives);
}, [refreshCachedArchives]); }, [refreshCachedArchives]);
const clearCache = async (name: string) => { await idb.del(name); await refreshCachedArchives(); }; const clearCache = async (name: string) => { await deleteCachedArchive(name); await refreshCachedArchives(); };
/**
* Archives with a `- reels` sidecar directory say outright which posts are
* reels; only fall back to the "lone video" heuristic for archives that have
* no such directory.
*/
const hasReelSource = useMemo(() => allPosts.some(p => p.source === 'reels'), [allPosts]);
const isReel = useCallback((p: Post) => (
hasReelSource ? p.source === 'reels' : p.media.length === 1 && p.media[0].type === 'video'
), [hasReelSource]);
const filteredPosts = useMemo(() => { const filteredPosts = useMemo(() => {
if (activeTab === 'reels') return allPosts.filter(p => p.media.length === 1 && p.media[0].type === 'video'); if (activeTab === 'reels') return allPosts.filter(isReel);
if (activeTab === 'posts') return allPosts.filter(p => !(p.media.length === 1 && p.media[0].type === 'video')); if (activeTab === 'posts') return allPosts.filter(p => !isReel(p));
return []; return [];
}, [allPosts, activeTab]); }, [allPosts, activeTab, isReel]);
/** Story highlights, grouped into the circles shown under the bio. */
const highlightGroups = useMemo(() => {
const groups = new Map<string, Post[]>();
for (const item of allHighlights) {
const title = item.highlightTitle?.trim() || 'Highlights';
if (!groups.has(title)) groups.set(title, []);
groups.get(title)!.push(item);
}
return Array.from(groups, ([title, items]) => ({
title,
items,
// The cover must be a still: most highlight items are videos, and a video
// URL in an <img> renders as a broken image.
cover: items.find(i => i.media[0]?.type === 'image')?.thumbnail,
}));
}, [allHighlights]);
const handleTabChange = (tab: 'posts' | 'reels' | 'saved') => { setActiveTab(tab); setVisiblePostsCount(90); }; const handleTabChange = (tab: 'posts' | 'reels' | 'saved') => { setActiveTab(tab); setVisiblePostsCount(90); };
const visiblePosts = useMemo(() => filteredPosts.slice(0, visiblePostsCount), [filteredPosts, visiblePostsCount]); const visiblePosts = useMemo(() => filteredPosts.slice(0, visiblePostsCount), [filteredPosts, visiblePostsCount]);
@@ -146,38 +208,50 @@ export default function App() {
setScanningPhase('Checking Cache'); setScanningPhase('Checking Cache');
try { try {
const cachedData = await idb.get(archive.name); const cachedData = await getCachedArchive(archive.name);
if (cachedData) { if (cachedData) {
console.log(`[Cache] Found cached data for ${archive.name}. File count: ${cachedData.fileCount} (Server has: ${archive.fileCount})`); // Invalidate on the directory-mtime signature; fall back to file count
if (cachedData.fileCount === archive.fileCount) { // for entries cached before signatures existed.
const fresh = archive.signature
? cachedData.signature === archive.signature
: cachedData.fileCount === archive.fileCount;
console.log(`[Cache] Cached ${archive.name}: signature ${cachedData.signature} vs ${archive.signature} -> ${fresh ? 'fresh' : 'stale'}`);
if (fresh) {
console.log(`[Cache] Cache hit! Restoring state...`); console.log(`[Cache] Cache hit! Restoring state...`);
setAllPosts(cachedData.posts); const restored = await restoreArchive(cachedData, registerUrl);
setAllStories(cachedData.stories); if (restored) {
setAllPosts(restored.posts);
// Handle migration from old cache schema where allProfilePics was a separate top-level key setAllStories(restored.stories);
const profileMetadata = { ...cachedData.profileMetadata }; setAllHighlights(restored.highlights);
if (!profileMetadata.allProfilePics && cachedData.allProfilePics) { setProfileMetadata({
profileMetadata.allProfilePics = cachedData.allProfilePics; ...restored.profileMetadata,
allProfilePics: restored.profileMetadata.allProfilePics
?? (restored.profileMetadata.profilePic ? [restored.profileMetadata.profilePic] : []),
});
setVisiblePostsCount(90);
setIsScanning(false);
console.log(`[Cache] Archive ${archive.name} loaded successfully from cache.`);
return;
} }
if (!profileMetadata.allProfilePics) {
profileMetadata.allProfilePics = profileMetadata.profilePic ? [profileMetadata.profilePic] : [];
}
setProfileMetadata(profileMetadata);
setVisiblePostsCount(90);
setIsScanning(false);
console.log(`[Cache] Archive ${archive.name} loaded successfully from cache.`);
return;
} }
} }
console.log(`[Scanner] Starting fresh scan from server API...`); console.log(`[Scanner] Starting fresh scan from server API...`);
const res = await fetch(`/api/archives/${archive.name}/files`); const res = await fetch(`/api/archives/${encodeURIComponent(archive.name)}/files`);
const filePaths: string[] = await res.json(); const entries: (ServerArchiveFile | string)[] = await res.json();
const archiveFiles = filePaths.map(p => { const archiveFiles = entries.map(entry => {
const name = p.split(/[/\\]/).pop() || p; // Older servers returned bare path strings relative to the archive dir.
return new RemoteArchiveFile(name, p, 0, `/archives/${archive.name}/${p}`); const legacy = typeof entry === 'string';
const filePath = legacy ? entry : entry.path;
const url = legacy
? `/archives/${encodeURI(`${archive.name}/${filePath}`)}`
: `/archives/${encodeURI(filePath)}`;
const name = filePath.split(/[/\\]/).pop() || filePath;
const source = legacy
? undefined
: { kind: entry.kind, dir: filePath.split('/')[0], title: entry.title };
return new RemoteArchiveFile(name, filePath, legacy ? 0 : entry.size, url, source, legacy ? undefined : entry.mtime);
}); });
await handleFiles(archiveFiles, archive); await handleFiles(archiveFiles, archive);
@@ -185,62 +259,154 @@ export default function App() {
console.error('[Scanner] Failed to load server archive:', err); console.error('[Scanner] Failed to load server archive:', err);
setIsScanning(false); setIsScanning(false);
} }
}, [handleFiles, setAllPosts, setAllStories, setProfileMetadata, setIsScanning, setScanningPhase]); }, [handleFiles, registerUrl, setAllPosts, setAllStories, setAllHighlights, setProfileMetadata, setIsScanning, setScanningPhase]);
const handleLocalFiles = (files: FileList | null) => { if (!files) return; const archiveFiles = Array.from(files).map(f => new LocalArchiveFile(f)); handleFiles(archiveFiles); }; /**
const triggerFileSelect = () => fileInputRef.current?.click(); * Open a local archive straight from cache.
*
* The cached posts carry file *paths*, not URLs — blob: URLs do not survive a
* reload — so this re-opens the stored directory handle and mints fresh URLs
* for those paths. No re-parsing happens, which is what keeps it instant.
*
* If the folder can no longer be reached (permission revoked, folder moved,
* or the browser never supported directory handles) we fall back to asking
* for the folder again rather than rendering an archive of broken images.
*/
const loadLocalCachedArchive = useCallback(async (archive: CacheData) => {
console.log(`[Cache] Loading local archive from cache: ${archive.name}`);
setIsScanning(true);
setCurrentArchive(null);
setScanningPhase('Checking Cache');
try {
const restored = await restoreArchive(archive, registerUrl);
if (!restored) {
console.warn(`[Cache] Folder for ${archive.name} unavailable; re-prompting.`);
setIsScanning(false);
await openLocalFolder(archive.name);
return;
}
setAllPosts(restored.posts);
setAllStories(restored.stories);
setAllHighlights(restored.highlights);
setProfileMetadata({
...restored.profileMetadata,
allProfilePics: restored.profileMetadata.allProfilePics
?? (restored.profileMetadata.profilePic ? [restored.profileMetadata.profilePic] : []),
});
setVisiblePostsCount(90);
setIsScanning(false);
console.log(`[Cache] Local archive ${archive.name} restored from cache.`);
} catch (err) {
console.error('[Cache] Failed to restore local archive:', err);
setIsScanning(false);
}
}, [registerUrl, setAllPosts, setAllStories, setAllHighlights, setProfileMetadata, setIsScanning, setScanningPhase]);
const handleLocalFiles = (files: FileList | null) => {
if (!files) return;
handleFiles(Array.from(files).map(f => new LocalArchiveFile(f)));
};
/**
* Prefer the File System Access API so the folder can be reopened later
* without a re-prompt; fall back to <input webkitdirectory> elsewhere
* (Firefox and Safari have no showDirectoryPicker), where the archive is
* re-scanned from scratch on every visit.
*/
const openLocalFolder = useCallback(async (expectedName?: string) => {
if (!isDirectoryPickerSupported()) {
fileInputRef.current?.click();
return;
}
const handle = await pickDirectory();
if (!handle) return;
if (expectedName && handle.name !== expectedName) {
console.warn(`[Cache] Picked "${handle.name}" but expected "${expectedName}"; scanning as picked.`);
}
// Persist before scanning so the scan records that a handle exists.
await saveDirectoryHandle(handle.name, handle);
const files = await filesFromDirectory(handle);
await handleFiles(files);
}, [handleFiles]);
const triggerFileSelect = () => { void openLocalFolder(); };
const loadMore = () => setVisiblePostsCount(prev => prev + 90); const loadMore = () => setVisiblePostsCount(prev => prev + 90);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); // Hold the URL until the deep link has been consumed, otherwise this effect
if (currentArchive) params.set('a', currentArchive.name); // runs on mount with nothing loaded yet and erases the very parameters the
else if (allPosts.length > 0 && username) params.set('a', username); // loader below is waiting to read.
else params.delete('a'); if (!hasInitialLoaded) return;
if (activeTab !== 'posts') params.set('t', activeTab); const archive = currentArchive?.name ?? (allPosts.length > 0 ? username : null) ?? null;
else params.delete('t'); const nextPath = buildPath({
archive,
tab: activeTab,
post: selectedPost ? postSlug(selectedPost) : null,
});
if (selectedPost) params.set('p', selectedPost.id); if (nextPath !== window.location.pathname + window.location.search) {
else params.delete('p'); console.log(`[Permalink] Updating URL to: ${nextPath}`);
window.history.replaceState(null, '', nextPath);
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);
} }
}, [currentArchive?.name, username, allPosts.length, activeTab, selectedPost?.id]); }, [hasInitialLoaded, currentArchive?.name, username, allPosts.length, activeTab, selectedPost?.id]);
const [hasInitialLoaded, setHasInitialLoaded] = useState(false);
useEffect(() => { useEffect(() => {
if (hasInitialLoaded || serverArchives.length === 0) return; if (hasInitialLoaded) return;
const params = new URLSearchParams(window.location.search);
const archiveName = params.get('a'); const route = initialRouteRef.current;
const tab = params.get('t'); console.log('[Permalink] Initial route:', route);
const postId = params.get('p');
console.log('[Permalink] Initial read from URL:', { archiveName, tab, postId }); if (route.tab !== 'posts') setActiveTab(route.tab);
if (archiveName) {
const archive = serverArchives.find(a => a.name === archiveName); if (!route.archive) {
if (archive) { setHasInitialLoaded(true);
console.log(`[Permalink] Auto-loading archive: ?a=${archiveName}`); return;
loadServerArchive(archive); }
if (tab && ['posts', 'reels', 'saved'].includes(tab)) {
setActiveTab(tab as any); // Wait for the archive list before deciding the link is unresolvable.
} if (!archivesFetched) return;
}
const archive = serverArchives.find(a => a.name === route.archive);
if (archive) {
console.log(`[Permalink] Auto-loading archive: ${route.archive}`);
loadServerArchive(archive);
} else {
console.warn(`[Permalink] No archive named "${route.archive}".`);
} }
setHasInitialLoaded(true); setHasInitialLoaded(true);
}, [serverArchives, hasInitialLoaded, loadServerArchive]); }, [serverArchives, archivesFetched, hasInitialLoaded, loadServerArchive]);
/**
* Apply a `?p=` deep link exactly once per opened archive.
*
* Keying on the archive rather than on `selectedPost` matters: re-reading the
* URL whenever the selection changes would reopen the post the user just
* closed, and only worked before because the URL-writing effect happened to
* be declared first and had already stripped the param.
*/
const appliedPostParamRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const archiveKey = currentArchive?.name ?? username;
const postId = params.get('p'); if (!archiveKey || allPosts.length === 0) return;
if (postId && allPosts.length > 0 && !selectedPost) { if (appliedPostParamRef.current === archiveKey) return;
const post = allPosts.find(p => p.id === postId); appliedPostParamRef.current = archiveKey;
if (post) setSelectedPost(post);
} const slug = initialRouteRef.current.post;
}, [allPosts, selectedPost]); if (!slug) return;
const post = findPostBySlug(allPosts, slug);
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]);
return ( return (
<div className="min-h-screen bg-gray-50 text-gray-900 font-sans"> <div className="min-h-screen bg-gray-50 text-gray-900 font-sans">
@@ -274,6 +440,7 @@ export default function App() {
cachedArchives={cachedArchives} cachedArchives={cachedArchives}
onSelect={loadServerArchive} onSelect={loadServerArchive}
onLocalSelect={triggerFileSelect} onLocalSelect={triggerFileSelect}
onLocalCacheSelect={loadLocalCachedArchive}
onClearCache={clearCache} onClearCache={clearCache}
isScanning={isScanning} isScanning={isScanning}
/> />
@@ -285,9 +452,27 @@ export default function App() {
</div> </div>
) )
) : isScanning ? ( ) : isScanning ? (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden text-black"> <div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden text-black bg-[#f8fafc]">
<AnimatePresence>{currentScanningImage && (<motion.div key={currentScanningImage} initial={{ opacity: 0 }} animate={{ opacity: 0.4 }} exit={{ opacity: 0 }} className="absolute inset-0 z-0 text-black"><img src={currentScanningImage} alt="" className="w-full h-full object-cover blur-[100px] scale-110 text-black" /></motion.div>)}</AnimatePresence> {currentScanningImage && (
<div className="absolute inset-0 bg-white/20 z-1 text-black" /> <img
src={currentScanningImage}
className="hidden"
onLoad={() => setLastLoadedScanningImage(currentScanningImage)}
/>
)}
<div className="absolute inset-0 z-0">
<AnimatePresence initial={false}>
<motion.img
key={lastLoadedScanningImage}
src={lastLoadedScanningImage || undefined}
initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }}
transition={{ duration: 1.5 }}
className="absolute inset-0 w-full h-full object-cover blur-[60px] scale-110"
/>
</AnimatePresence>
</div>
<div className="absolute inset-0 bg-white/40 z-1" />
<div className="relative z-10 w-full max-w-4xl px-4 flex flex-col items-center gap-8 text-black"> <div className="relative z-10 w-full max-w-4xl px-4 flex flex-col items-center gap-8 text-black">
<div className="text-center space-y-2 text-black"><div className="text-4xl font-bold tracking-tight italic font-serif text-black/80 drop-shadow-sm text-black">Scanning Archive...</div><div className="flex items-center justify-center gap-3 text-black"><div className="h-[1px] w-12 bg-black/10 text-black" /><p className="text-black/40 text-[10px] uppercase tracking-[0.3em] font-bold text-black">{scanningPhase === 'Indexing' ? 'Building file index' : 'Parsing metadata & media'}</p><div className="h-[1px] w-12 bg-black/10 text-black" /></div></div> <div className="text-center space-y-2 text-black"><div className="text-4xl font-bold tracking-tight italic font-serif text-black/80 drop-shadow-sm text-black">Scanning Archive...</div><div className="flex items-center justify-center gap-3 text-black"><div className="h-[1px] w-12 bg-black/10 text-black" /><p className="text-black/40 text-[10px] uppercase tracking-[0.3em] font-bold text-black">{scanningPhase === 'Indexing' ? 'Building file index' : 'Parsing metadata & media'}</p><div className="h-[1px] w-12 bg-black/10 text-black" /></div></div>
<div className="w-full max-w-2xl space-y-4 text-black"><div className="flex justify-between text-[10px] font-bold uppercase tracking-widest text-black/40 px-1 text-black"><span className="flex items-center gap-2 text-black"><Loader2 size={12} className="animate-spin text-black" />Phase: {scanningPhase}</span><span className="text-black">{scannedCount} / {totalFiles}</span></div><div className="w-full h-1.5 bg-black/5 rounded-full overflow-hidden backdrop-blur-sm border border-black/5 shadow-inner text-black"><motion.div className="h-full bg-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)] text-black" initial={{ width: 0 }} animate={{ width: `${(scannedCount / (totalFiles || 1)) * 100}%` }} transition={{ type: 'spring', bounce: 0, duration: 0.3 }} /></div></div> <div className="w-full max-w-2xl space-y-4 text-black"><div className="flex justify-between text-[10px] font-bold uppercase tracking-widest text-black/40 px-1 text-black"><span className="flex items-center gap-2 text-black"><Loader2 size={12} className="animate-spin text-black" />Phase: {scanningPhase}</span><span className="text-black">{scannedCount} / {totalFiles}</span></div><div className="w-full h-1.5 bg-black/5 rounded-full overflow-hidden backdrop-blur-sm border border-black/5 shadow-inner text-black"><motion.div className="h-full bg-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)] text-black" initial={{ width: 0 }} animate={{ width: `${(scannedCount / (totalFiles || 1)) * 100}%` }} transition={{ type: 'spring', bounce: 0, duration: 0.3 }} /></div></div>
@@ -312,6 +497,34 @@ export default function App() {
</div> </div>
</header> </header>
{highlightGroups.length > 0 && (
<div className="flex gap-6 md:gap-8 overflow-x-auto scrollbar-hide px-4 pb-2">
{highlightGroups.map(group => (
<motion.button
key={group.title}
onClick={() => setActiveHighlight(group.title)}
whileTap={{ scale: 0.94 }}
transition={PRESS}
className="flex flex-col items-center gap-2 shrink-0 group/hl"
title={`${group.title}${group.items.length} item${group.items.length === 1 ? '' : 's'}`}
>
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full p-[2px] bg-gray-200 group-hover/hl:bg-gray-300 transition-colors">
<div className="w-full h-full rounded-full bg-white p-[2px]">
<div className="w-full h-full rounded-full overflow-hidden bg-gray-100 flex items-center justify-center">
{group.cover ? (
<img src={group.cover} alt="" className="w-full h-full object-cover" referrerPolicy="no-referrer" loading="lazy" />
) : (
<Play size={18} className="text-gray-400" fill="currentColor" />
)}
</div>
</div>
</div>
<span className="text-[11px] max-w-[80px] truncate text-gray-700">{group.title}</span>
</motion.button>
))}
</div>
)}
<div className="border-t border-gray-200 flex flex-col md:flex-row items-center justify-between gap-4 text-black"> <div className="border-t border-gray-200 flex flex-col md:flex-row items-center justify-between gap-4 text-black">
<div className="flex justify-center gap-12 flex-1 text-black"> <div className="flex justify-center gap-12 flex-1 text-black">
<button onClick={() => handleTabChange('posts')} className={cn("flex items-center gap-2 py-4 border-t text-xs font-bold tracking-widest uppercase transition-all text-black", activeTab === 'posts' ? "border-black text-black" : "border-transparent text-gray-400")}><Grid3X3 size={14} />Posts</button> <button onClick={() => handleTabChange('posts')} className={cn("flex items-center gap-2 py-4 border-t text-xs font-bold tracking-widest uppercase transition-all text-black", activeTab === 'posts' ? "border-black text-black" : "border-transparent text-gray-400")}><Grid3X3 size={14} />Posts</button>
@@ -323,24 +536,67 @@ 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]"))}>
{post.media[0].type === 'video' ? <VideoThumbnail url={post.media[0].url} /> : <img src={post.thumbnail} alt="" className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110 text-black" referrerPolicy="no-referrer" />} <PostThumbnail
post={post}
thumbnailUrl={cacheHits.get(post.id)}
onRequestThumbnail={requestThumbnail}
/>
<div className="absolute top-2 right-2 flex gap-1.5 z-10 text-black">{post.media.length > 1 && <div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm text-black"><Layers size={16} /></div>}{post.media.some(m => m.type === 'video') && <div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm text-black"><Play size={16} fill="white" /></div>}</div> <div className="absolute top-2 right-2 flex gap-1.5 z-10 text-black">{post.media.length > 1 && <div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm text-black"><Layers size={16} /></div>}{post.media.some(m => m.type === 'video') && <div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm text-black"><Play size={16} fill="white" /></div>}</div>
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-6 text-white font-bold z-20 text-black"><div className="flex items-center gap-2 text-black"><Heart fill="white" size={20} className="text-black" /><span>-</span></div><div className="flex items-center gap-2 text-black"><MessageCircle fill="white" size={20} className="text-black" /><span>-</span></div></div> <div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-6 text-white font-bold z-20 text-black"><div className="flex items-center gap-2 text-black"><Heart fill="white" size={20} className="text-black" /><span>-</span></div><div className="flex items-center gap-2 text-black"><MessageCircle fill="white" size={20} className="text-black" /><span>-</span></div></div>
</motion.div> </motion.div>
))} ))}
</div> </div>
{filteredPosts.length > visiblePostsCount && <div className="flex justify-center pt-12 text-black text-black"><button onClick={loadMore} className="bg-white border border-gray-200 px-8 py-2 rounded-lg font-semibold hover:bg-gray-50 transition-colors shadow-sm text-black text-black">Load More</button></div>} {filteredPosts.length > visiblePostsCount && <div className="flex justify-center pt-12 text-black"><button onClick={loadMore} className="bg-white border border-gray-200 px-8 py-2 rounded-lg font-semibold hover:bg-gray-50 transition-colors shadow-sm text-black">Load More</button></div>}
</div> </div>
)} )}
</main> </main>
<AnimatePresence>{selectedPost && <PostModal post={selectedPost} onClose={() => setSelectedPost(null)} onNextPost={onNextPost} onPrevPost={onPrevPost} hasNextPost={postIndex < filteredPosts.length - 1} hasPrevPost={postIndex > 0} profilePic={profilePic} />}</AnimatePresence> {/*
Mobile opens a real scrolling feed page, the way Instagram does; desktop
keeps the modal, where a centred sheet with side arrows fits the pointer.
*/}
{selectedPost && isMobile ? (
<PostFeed
posts={filteredPosts}
initialPostId={selectedPost.id}
profilePic={profilePic}
title={activeTab === 'reels' ? 'Reels' : 'Posts'}
onClose={() => setSelectedPost(null)}
onActivePostChange={setSelectedPost}
/>
) : (
<AnimatePresence>
{selectedPost && (
<PostModal
post={selectedPost}
nextPost={postIndex < filteredPosts.length - 1 ? filteredPosts[postIndex + 1] : undefined}
prevPost={postIndex > 0 ? filteredPosts[postIndex - 1] : undefined}
onClose={() => setSelectedPost(null)}
onNextPost={onNextPost}
onPrevPost={onPrevPost}
hasNextPost={postIndex < filteredPosts.length - 1}
hasPrevPost={postIndex > 0}
profilePic={profilePic}
/>
)}
</AnimatePresence>
)}
<AnimatePresence>{showStoryViewer && allStories.length > 0 && <StoryViewer stories={allStories} onClose={() => setShowStoryViewer(false)} profilePic={profilePic} />}</AnimatePresence> <AnimatePresence>{showStoryViewer && allStories.length > 0 && <StoryViewer stories={allStories} onClose={() => setShowStoryViewer(false)} profilePic={profilePic} />}</AnimatePresence>
<AnimatePresence>
{activeHighlight && (
<StoryViewer
stories={highlightGroups.find(g => g.title === activeHighlight)?.items ?? []}
title={activeHighlight}
onClose={() => setActiveHighlight(null)}
profilePic={profilePic}
/>
)}
</AnimatePresence>
{!isScanning && ( {!isScanning && (
<footer className="max-w-5xl mx-auto px-4 py-12 text-center text-xs text-gray-400 space-y-4 text-black"> <footer className="max-w-5xl mx-auto px-4 py-12 text-center text-xs text-gray-400 space-y-4 text-black">
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2 uppercase tracking-tight text-black text-black"><span>Meta</span><span>About</span><span>Blog</span><span>Jobs</span><span>Help</span><span>API</span><span>Privacy</span><span>Terms</span><span>Locations</span><span>Instagram Lite</span><span>Threads</span><span>Contact Uploading & Non-Users</span><span>Meta Verified</span></div> <div className="flex flex-wrap justify-center gap-x-4 gap-y-2 uppercase tracking-tight text-black"><span>Meta</span><span>About</span><span>Blog</span><span>Jobs</span><span>Help</span><span>API</span><span>Privacy</span><span>Terms</span><span>Locations</span><span>Instagram Lite</span><span>Threads</span><span>Contact Uploading & Non-Users</span><span>Meta Verified</span></div>
<div className="text-black/40 text-black">© 2026 InstaArchive Viewer</div> <div className="text-black/40 text-black">© 2026 InstaArchive Viewer</div>
</footer> </footer>
)} )}
+10 -4
View File
@@ -6,14 +6,15 @@ import {
Trash2, Trash2,
Zap Zap
} from 'lucide-react'; } from 'lucide-react';
import { ServerArchive } from '../types'; import { CacheData, ServerArchive } from '../types';
interface ArchiveDashboardProps { interface ArchiveDashboardProps {
archives: ServerArchive[]; archives: ServerArchive[];
localArchives?: any[]; localArchives?: CacheData[];
cachedArchives: Set<string>; cachedArchives: Set<string>;
onSelect: (archive: ServerArchive) => void; onSelect: (archive: ServerArchive) => void;
onLocalSelect: () => void; onLocalSelect: () => void;
onLocalCacheSelect: (archive: CacheData) => void;
onClearCache: (name: string) => void; onClearCache: (name: string) => void;
isScanning: boolean; isScanning: boolean;
} }
@@ -24,6 +25,7 @@ export const ArchiveDashboard: React.FC<ArchiveDashboardProps> = ({
cachedArchives, cachedArchives,
onSelect, onSelect,
onLocalSelect, onLocalSelect,
onLocalCacheSelect,
onClearCache, onClearCache,
isScanning isScanning
}) => { }) => {
@@ -83,7 +85,11 @@ export const ArchiveDashboard: React.FC<ArchiveDashboardProps> = ({
</div> </div>
<div className="p-4 space-y-1"> <div className="p-4 space-y-1">
<span className="font-bold text-sm block truncate uppercase tracking-tight text-black/80">{archive.name}</span> <span className="font-bold text-sm block truncate uppercase tracking-tight text-black/80">{archive.name}</span>
<span className="text-[10px] text-gray-400 uppercase tracking-widest">{archive.fileCount} items</span> <span className="text-[10px] text-gray-400 uppercase tracking-widest">
{archive.fileCount === null
? `${archive.sources?.length ?? 1} source${(archive.sources?.length ?? 1) === 1 ? '' : 's'}`
: `${archive.fileCount} items`}
</span>
</div> </div>
</button> </button>
@@ -107,7 +113,7 @@ export const ArchiveDashboard: React.FC<ArchiveDashboardProps> = ({
{localArchives.map((archive) => ( {localArchives.map((archive) => (
<div key={archive.name} className="relative group text-black"> <div key={archive.name} className="relative group text-black">
<button <button
onClick={onLocalSelect} onClick={() => onLocalCacheSelect(archive)}
disabled={isScanning} disabled={isScanning}
className="w-full aspect-[3/4] rounded-xl overflow-hidden bg-white shadow-sm border border-gray-100 hover:shadow-xl hover:scale-[1.02] transition-all flex flex-col text-left disabled:opacity-50" className="w-full aspect-[3/4] rounded-xl overflow-hidden bg-white shadow-sm border border-gray-100 hover:shadow-xl hover:scale-[1.02] transition-all flex flex-col text-left disabled:opacity-50"
> >
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
interface Props { children: React.ReactNode }
interface State { error: Error | null }
/**
* Keeps one bad archive item from blanking the whole app.
*
* Post data is derived from filenames and arbitrary archive JSON, so a single
* malformed record used to be able to throw during render and take the entire
* tree down with it.
*/
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('[ErrorBoundary] Render failed:', error, info.componentStack);
}
render() {
if (!this.state.error) return this.props.children;
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
<div className="max-w-md w-full bg-white rounded-2xl shadow-sm border border-gray-100 p-8 space-y-4 text-center">
<div className="w-12 h-12 mx-auto rounded-full bg-red-50 text-red-500 flex items-center justify-center">
<AlertTriangle size={24} />
</div>
<h2 className="text-xl font-semibold text-black/80">Something went wrong</h2>
<p className="text-sm text-gray-500">
This archive could not be rendered. Reloading usually clears it; if it
persists, clear the cached copy from the archive explorer.
</p>
<pre className="text-[11px] text-left text-gray-400 bg-gray-50 rounded-lg p-3 overflow-x-auto">
{this.state.error.message}
</pre>
<button
onClick={() => window.location.reload()}
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-lg text-sm font-semibold transition-colors"
>
Reload
</button>
</div>
</div>
);
}
}
+67
View File
@@ -0,0 +1,67 @@
import React from 'react';
import { Bookmark, Heart, MessageCircle, MoreHorizontal, Send } from 'lucide-react';
import { Post } from '../types';
import { formatDateSafe } from '../lib/utils';
import { MediaCarousel } from './MediaCarousel';
interface FeedPostProps {
post: Post;
profilePic: string | null;
/** Off-screen posts keep their video paused. */
paused: boolean;
}
/**
* One post in the mobile feed, laid out like Instagram's: header, media,
* action row, then caption.
*
* Media is capped below full viewport height so the next post always peeks in
* at the bottom — that overlap is what tells you the page scrolls rather than
* pages.
*/
export const FeedPost: React.FC<FeedPostProps> = ({ post, profilePic, paused }) => (
<article className="bg-white border-b border-gray-200">
<header className="flex items-center justify-between px-3 py-2.5">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-yellow-400 to-purple-600 p-0.5 shrink-0">
<div className="w-full h-full rounded-full bg-white p-0.5">
<div className="w-full h-full rounded-full bg-gray-200 overflow-hidden flex items-center justify-center text-[10px] font-bold uppercase">
{profilePic
? <img src={profilePic} alt="" className="w-full h-full object-cover" referrerPolicy="no-referrer" />
: <span>{post.username[0]}</span>}
</div>
</div>
</div>
<span className="font-semibold text-sm truncate">{post.username}</span>
</div>
<MoreHorizontal size={20} className="text-gray-500 shrink-0" />
</header>
{/*
Taller ceiling than the modal so ordinary portrait media (9:16 reels,
4:5 photos) fills the feed width instead of sitting in side bars, while
still stopping anything extreme from swallowing the screen.
*/}
<MediaCarousel post={post} paused={paused} heightCap="max-h-[85vh]" fillWidth className="bg-black" />
<div className="px-3 pt-3 pb-1 flex items-center justify-between">
<div className="flex items-center gap-4">
<Heart size={24} className="cursor-pointer" />
<MessageCircle size={24} className="cursor-pointer" />
<Send size={24} className="cursor-pointer" />
</div>
<Bookmark size={24} className="cursor-pointer" />
</div>
{post.caption && (
<div className="px-3 pb-1 text-sm">
<span className="font-semibold mr-2">{post.username}</span>
<span className="whitespace-pre-wrap">{post.caption}</span>
</div>
)}
<div className="px-3 pb-3 pt-1 text-[10px] uppercase tracking-wide text-gray-400">
{formatDateSafe(post.date, 'MMMM d, yyyy')}
</div>
</article>
);
+112
View File
@@ -0,0 +1,112 @@
import React, { useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { Post } from '../types';
import { cn } from '../lib/utils';
import { NAVIGATE, prefersReducedMotion, withVelocity } from '../lib/motion';
import { MediaRenderer } from './MediaRenderer';
interface MediaCarouselProps {
post: Post;
/** Pause video even when this slide is on screen (feed: only one plays). */
paused?: boolean;
/** Override the media height ceiling (the feed allows taller media). */
heightCap?: string;
/** Size video to the container width (see MediaRenderer). */
fillWidth?: boolean;
className?: string;
}
/**
* The horizontal slide strip for one post.
*
* Shared by the desktop modal and the mobile feed so a carousel behaves the
* same in both. Horizontal drag belongs to the carousel and never navigates
* between posts — vertical movement is the page's to handle.
*/
export const MediaCarousel: React.FC<MediaCarouselProps> = ({ post, paused, heightCap, fillWidth, className }) => {
const [index, setIndex] = useState(0);
const [slide, setSlide] = useState<{ dir: number; velocity: number }>({ dir: 0, velocity: 0 });
const reduceMotion = prefersReducedMotion();
useEffect(() => setIndex(0), [post.id]);
const paginate = (dir: number, velocity = 0) => {
const next = index + dir;
if (next < 0 || next >= post.media.length) return;
setSlide({ dir, velocity });
setIndex(next);
};
const variants = {
enter: (d: number) => ({ x: d > 0 ? '100%' : '-100%', opacity: 1, zIndex: 0 }),
center: { x: 0, opacity: 1, zIndex: 1 },
exit: (d: number) => ({ x: d < 0 ? '100%' : '-100%', opacity: 1, zIndex: 0 }),
};
const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity;
const current = post.media[index];
return (
<div className={cn('relative bg-black flex items-center justify-center group overflow-hidden w-full', className)}>
<div className="w-full grid grid-cols-1 grid-rows-1">
<AnimatePresence initial={false} custom={slide.dir}>
<motion.div
key={`${post.id}-${index}`}
custom={slide.dir}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={reduceMotion ? { duration: 0 } : withVelocity(slide.velocity, NAVIGATE)}
drag={post.media.length > 1 ? 'x' : false}
dragDirectionLock
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.5}
onDragEnd={(e, { offset, velocity }) => {
const power = swipePower(offset.x, velocity.x);
if (power < -15000) paginate(1, velocity.x);
else if (power > 15000) paginate(-1, velocity.x);
}}
className="col-start-1 row-start-1 w-full flex items-center justify-center relative touch-pan-y"
>
{current && <MediaRenderer file={current} isFullView paused={paused} heightCap={heightCap} fillWidth={fillWidth} />}
</motion.div>
</AnimatePresence>
</div>
{post.media.length > 1 && (
<>
{index > 0 && (
<button
aria-label="Previous photo"
onClick={(e) => { e.stopPropagation(); paginate(-1); }}
className="hidden md:block absolute left-4 top-1/2 -translate-y-1/2 bg-white/20 hover:bg-white/40 text-white p-2 rounded-full backdrop-blur-md transition-all opacity-0 group-hover:opacity-100 z-30"
>
<ChevronLeft size={24} />
</button>
)}
{index < post.media.length - 1 && (
<button
aria-label="Next photo"
onClick={(e) => { e.stopPropagation(); paginate(1); }}
className="hidden md:block absolute right-4 top-1/2 -translate-y-1/2 bg-white/20 hover:bg-white/40 text-white p-2 rounded-full backdrop-blur-md transition-all opacity-0 group-hover:opacity-100 z-30"
>
<ChevronRight size={24} />
</button>
)}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-1.5 z-30">
{post.media.map((_, i) => (
<div
key={i}
className={cn(
'w-1.5 h-1.5 rounded-full transition-all',
i === index ? 'bg-blue-500 scale-125' : 'bg-white/40 shadow-sm',
)}
/>
))}
</div>
</>
)}
</div>
);
};
+64 -4
View File
@@ -1,11 +1,71 @@
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 }) => { interface MediaRendererProps {
file: MediaFile;
className?: string;
isFullView?: boolean;
/** Hold playback: the feed keeps every off-screen video paused. */
paused?: boolean;
/** Cap media height to this instead of the default full-view ceiling. */
heightCap?: string;
/**
* Size video to the container width rather than its own intrinsic size.
*
* A <video> reports 300x150 until metadata loads, so `w-auto` makes it render
* narrow and then jump to full width. The feed needs a stable width more than
* it needs a snug fit.
*/
fillWidth?: boolean;
}
export const MediaRenderer = ({ file, className, isFullView, paused, heightCap, fillWidth }: MediaRendererProps) => {
// Try to play with sound: opening the modal is a user gesture, so browsers
// generally allow it. If this particular browser still refuses, the effect
// below falls back to muted playback rather than leaving a stalled video.
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const sizingClass = isFullView ? "w-full h-auto block" : "w-full h-full object-cover"; const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video || file.type !== 'video') return;
if (paused) {
video.pause();
return;
}
let cancelled = false;
video.muted = false;
video.play().catch(() => {
if (cancelled) return;
setIsMuted(true);
video.muted = true;
video.play().catch(() => { /* user can start it from the controls */ });
});
return () => { cancelled = true; };
}, [file.url, file.type, paused]);
/**
* In full view the media must never outgrow the viewport.
*
* Video is sized to its own aspect within the cap (`w-auto`) so a portrait
* clip doesn't sit in a wide letterbox, while images keep filling the modal
* width and only gain a height ceiling — `object-contain` stops the cap from
* distorting anything that hits it.
*
* The desktop cap subtracts the modal's own padding (md:p-10 = 2.5rem each
* side); mobile leaves room for the caption panel stacked underneath.
*/
const fullViewCap = `${heightCap ?? 'max-h-[70vh] md:max-h-[calc(100vh-5rem)]'} object-contain`;
const videoFullView = fillWidth
? `block w-full h-auto ${fullViewCap}`
: `block w-auto max-w-full ${fullViewCap}`;
const videoSizing = isFullView ? videoFullView : "w-full h-full object-cover";
const imageSizing = isFullView ? `block w-full h-auto ${fullViewCap}` : "w-full h-full object-cover";
const sizingClass = file.type === 'video' ? videoSizing : imageSizing;
const mediaStyle = { transform: 'translateZ(0)' }; const mediaStyle = { transform: 'translateZ(0)' };
if (!file.url) return <div className={cn("bg-gray-100 flex items-center justify-center text-black", sizingClass)}><Play size={24} className="text-gray-300" /></div>; if (!file.url) return <div className={cn("bg-gray-100 flex items-center justify-center text-black", sizingClass)}><Play size={24} className="text-gray-300" /></div>;
@@ -13,7 +73,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>
+150
View File
@@ -0,0 +1,150 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ChevronLeft } from 'lucide-react';
import { Post } from '../types';
import { FeedPost } from './FeedPost';
interface PostFeedProps {
posts: Post[];
/** Post the feed should open at. */
initialPostId: string;
profilePic: string | null;
onClose: () => void;
/** Fires as the post crossing the viewport centre changes. */
onActivePostChange: (post: Post) => void;
title?: string;
}
/** Posts added each time the feed grows in either direction. */
const BATCH = 6;
/** Render this many ahead of the entry point so the first scroll is smooth. */
const LOOKAHEAD = 3;
/**
* The mobile post view: a real scrolling feed, not a modal.
*
* Only a window of posts around the entry point is mounted — a profile can hold
* thousands, and mounting them all would mean thousands of full-size images.
* The window grows in both directions as you scroll; growing *upwards* shifts
* everything below it, so the scroll position is corrected in the same frame to
* keep the content under your thumb still.
*/
export const PostFeed: React.FC<PostFeedProps> = ({
posts, initialPostId, profilePic, onClose, onActivePostChange, title,
}) => {
const initialIndex = useMemo(() => {
const found = posts.findIndex(p => p.id === initialPostId);
return found === -1 ? 0 : found;
}, [posts, initialPostId]);
const [range, setRange] = useState(() => ({
start: initialIndex,
end: Math.min(posts.length, initialIndex + LOOKAHEAD + 1),
}));
const [activeId, setActiveId] = useState(initialPostId);
const scrollRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
const bottomSentinelRef = useRef<HTMLDivElement>(null);
/** Distance from the bottom of the content, captured before a prepend. */
const anchorRef = useRef<number | null>(null);
const visible = posts.slice(range.start, range.end);
const extendDown = useCallback(() => {
setRange(r => (r.end >= posts.length ? r : { ...r, end: Math.min(posts.length, r.end + BATCH) }));
}, [posts.length]);
const extendUp = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setRange(r => {
if (r.start === 0) return r;
// Measure from the bottom: prepending changes scrollHeight, but the
// distance between our position and the end of the content does not.
anchorRef.current = el.scrollHeight - el.scrollTop;
return { ...r, start: Math.max(0, r.start - BATCH) };
});
}, []);
// Restore the scroll position in the same frame the prepended posts appear,
// before the browser paints, so nothing visibly jumps.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el && anchorRef.current !== null) {
el.scrollTop = el.scrollHeight - anchorRef.current;
anchorRef.current = null;
}
}, [range.start]);
// Grow the window when either end comes into view.
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
if (entry.target === bottomSentinelRef.current) extendDown();
if (entry.target === topSentinelRef.current) extendUp();
}
}, { root, rootMargin: '600px 0px' });
if (topSentinelRef.current) observer.observe(topSentinelRef.current);
if (bottomSentinelRef.current) observer.observe(bottomSentinelRef.current);
return () => observer.disconnect();
}, [extendDown, extendUp]);
// Track the post crossing the viewport centre. The negative margins collapse
// the root to a thin band, so exactly one post qualifies at a time.
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const id = (entry.target as HTMLElement).dataset.postId;
if (id) setActiveId(id);
}
}, { root, rootMargin: '-45% 0px -45% 0px', threshold: 0 });
root.querySelectorAll('[data-post-id]').forEach(el => observer.observe(el));
return () => observer.disconnect();
}, [visible.length, range.start]);
useEffect(() => {
const post = posts.find(p => p.id === activeId);
if (post) onActivePostChange(post);
}, [activeId, posts, onActivePostChange]);
// Escape closes, matching the modal it replaces.
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 bg-white flex flex-col">
<header className="flex items-center gap-3 px-2 h-12 border-b border-gray-200 bg-white/95 backdrop-blur-md shrink-0">
<button onClick={onClose} aria-label="Back" className="p-2 -ml-1 active:opacity-60">
<ChevronLeft size={24} />
</button>
<span className="font-semibold text-base">{title ?? 'Posts'}</span>
</header>
<div ref={scrollRef} className="flex-1 overflow-y-auto overscroll-contain">
<div ref={topSentinelRef} aria-hidden />
{visible.map(post => (
<div key={post.id} data-post-id={post.id}>
<FeedPost post={post} profilePic={profilePic} paused={post.id !== activeId} />
</div>
))}
<div ref={bottomSentinelRef} aria-hidden />
{range.end >= posts.length && (
<div className="py-10 text-center text-xs uppercase tracking-widest text-gray-400">
End of {title?.toLowerCase() ?? 'posts'}
</div>
)}
</div>
</div>
);
};
+98 -43
View File
@@ -10,13 +10,15 @@ import {
Bookmark Bookmark
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { format, parseISO } from 'date-fns';
import { Post } from '../types'; import { Post } from '../types';
import { cn } 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 {
post: Post; post: Post;
nextPost?: Post;
prevPost?: Post;
onClose: () => void; onClose: () => void;
onNextPost?: () => void; onNextPost?: () => void;
onPrevPost?: () => void; onPrevPost?: () => void;
@@ -26,40 +28,49 @@ interface PostModalProps {
} }
export const PostModal: React.FC<PostModalProps> = ({ export const PostModal: React.FC<PostModalProps> = ({
post, 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(() => {
const controller = new AbortController(); const controller = new AbortController();
const preloadMedia = async (index: number) => {
if (index < 0 || index >= post.media.length) return; const preloadMedia = async (url: string, type: 'image' | 'video') => {
const media = post.media[index]; if (!url) return;
if (!media.url) return;
try { try {
if (media.type === 'image') { if (type === 'image') {
const img = new Image(); const img = new Image();
img.src = media.url; img.src = url;
} else { } else {
const video = document.createElement('video'); const video = document.createElement('video');
video.src = media.url; video.src = url;
video.preload = 'auto'; video.preload = 'auto';
} }
} catch (e) {} } catch (e) {}
}; };
// 1. Immediate preload of first two slides // 1. Current post: Immediate preload of first two slides
preloadMedia(0); if (post.media[0]) preloadMedia(post.media[0].url, post.media[0].type);
preloadMedia(1); if (post.media[1]) preloadMedia(post.media[1].url, post.media[1].type);
// 2. Delayed preload of the rest to stay out of the way of initial render // 2. Next/Prev posts: Preload their first slides
if (nextPost?.media[0]) preloadMedia(nextPost.media[0].url, nextPost.media[0].type);
if (prevPost?.media[0]) preloadMedia(prevPost.media[0].url, prevPost.media[0].type);
// 3. Current post: Delayed preload of the rest
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
for (let i = 2; i < post.media.length; i++) { for (let i = 2; i < post.media.length; i++) {
if (controller.signal.aborted) break; if (controller.signal.aborted) break;
preloadMedia(i); preloadMedia(post.media[i].url, post.media[i].type);
} }
}, 1000); }, 1000);
@@ -67,13 +78,13 @@ export const PostModal: React.FC<PostModalProps> = ({
controller.abort(); controller.abort();
clearTimeout(timeout); clearTimeout(timeout);
}; };
}, [post.id, post.media]); }, [post.id, post.media, nextPost?.id, prevPost?.id]);
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();
@@ -82,44 +93,88 @@ 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);
}
}; };
const variants = { /**
enter: (d: number) => ({ x: d > 0 ? '100%' : '-100%', opacity: 1, zIndex: 0 }), * Move between posts, animating along the axis the input implies: vertical
center: { zIndex: 1, x: 0, opacity: 1 }, * for a touch swipe, horizontal for the desktop arrows and arrow keys.
exit: (d: number) => ({ zIndex: 0, x: d < 0 ? '100%' : '-100%', opacity: 1 }) */
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 = {
enter: ({ axis, dir }: SlideMotion) =>
axis === 'y'
? { y: offscreen(dir), x: 0, opacity: 1, zIndex: 0 }
: { x: offscreen(dir), y: 0, opacity: 1, zIndex: 0 },
center: { zIndex: 1, x: 0, y: 0, opacity: 1 },
exit: ({ axis, dir }: SlideMotion) =>
axis === 'y'
? { zIndex: 0, y: offscreen(-dir), x: 0, opacity: 1 }
: { zIndex: 0, x: offscreen(-dir), y: 0, opacity: 1 },
}; };
const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity; const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity;
/**
* Desktop-only surface: mobile opens PostFeed instead, so the only vertical
* gesture left here is drag-to-dismiss.
*/
const handleVerticalDragEnd = (offset: { y: number }, velocity: { y: number }) => {
if (offset.y > 200 || velocity.y > 800) onClose();
};
/*
* Horizontal padding on the overlay reserves a gutter for the prev/next
* arrows so they always sit *outside* the modal. Without it the modal grows
* until it sits under them and a white chevron lands on the white caption
* panel, leaving the control invisible until hovered.
*
* overscroll-contain stops wheel events chaining through to the very long
* post grid behind the overlay.
*/
return ( return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-start justify-center bg-[#0c1014]/95 md:bg-[#0c1014]/70 p-0 md:p-10 overflow-y-auto text-black" onClick={onClose}> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={FADE} className="fixed inset-0 z-50 flex items-start justify-center bg-[#0c1014]/95 md:bg-[#0c1014]/70 p-0 md:py-10 md:px-16 lg:px-24 overflow-y-auto overscroll-contain text-black" onClick={onClose}>
<div className="min-h-full w-full flex items-center justify-center md:py-0"> <div className="min-h-full w-full flex items-center justify-center md:py-0">
<button onClick={onClose} className="fixed top-4 right-4 text-white hover:text-gray-300 z-50 p-2 md:p-3 bg-black/20 rounded-full backdrop-blur-sm"><X size={24} className="md:w-8 md:h-8" /></button> <button onClick={onClose} className="fixed top-4 right-4 text-white hover:text-gray-300 z-50 p-2 md:p-3 bg-black/20 rounded-full backdrop-blur-sm"><X size={24} className="md:w-8 md:h-8" /></button>
{hasPrevPost && onPrevPost && <button onClick={(e) => { e.stopPropagation(); onPrevPost(); }} className="hidden md:block fixed left-4 md:left-10 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-50 transition-transform hover:scale-110 active:scale-90"><ChevronLeft size={48} strokeWidth={1.5} /></button>} {/* Solid pill so the arrows read against whatever sits behind them. */}
{hasNextPost && onNextPost && <button onClick={(e) => { e.stopPropagation(); onNextPost(); }} className="hidden md:block fixed right-4 md:right-10 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-50 transition-transform hover:scale-110 active:scale-90"><ChevronRight size={48} strokeWidth={1.5} /></button>} {hasPrevPost && onPrevPost && <button aria-label="Previous post" onClick={(e) => { e.stopPropagation(); goToPost(-1, 'x'); }} className="hidden md:flex items-center justify-center fixed md:left-3 lg:left-6 top-1/2 -translate-y-1/2 z-50 p-2 rounded-full bg-white/90 hover:bg-white text-gray-800 shadow-lg transition-transform hover:scale-110 active:scale-90"><ChevronLeft size={28} strokeWidth={2} /></button>}
<motion.div drag="y" dragDirectionLock dragConstraints={{ top: 0, bottom: 0 }} dragElastic={0.15} onDragEnd={(e, { offset, velocity }) => { if (offset.y > 200 || velocity.y > 800) onClose(); }} className="bg-black flex flex-col md:flex-row w-full max-w-6xl h-auto md:rounded-sm overflow-hidden shadow-2xl relative text-black" onClick={e => e.stopPropagation()}> {hasNextPost && onNextPost && <button aria-label="Next post" onClick={(e) => { e.stopPropagation(); goToPost(1, 'x'); }} className="hidden md:flex items-center justify-center fixed md:right-3 lg:right-6 top-1/2 -translate-y-1/2 z-50 p-2 rounded-full bg-white/90 hover:bg-white text-gray-800 shadow-lg transition-transform hover:scale-110 active:scale-90"><ChevronRight size={28} strokeWidth={2} /></button>}
<motion.div drag="y" dragDirectionLock dragConstraints={{ top: 0, bottom: 0 }} dragElastic={0.15} onDragEnd={(e, { offset, velocity }) => handleVerticalDragEnd(offset, velocity)} initial={reduceMotion ? false : { opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }} transition={PRESENT} className="bg-black flex flex-col md:flex-row w-full max-w-6xl h-auto md:rounded-sm overflow-hidden shadow-2xl relative text-black" onClick={e => e.stopPropagation()}>
<div className="relative bg-black flex items-center justify-center group overflow-hidden w-full h-auto text-black"> <div className="relative bg-black flex items-center justify-center group overflow-hidden w-full h-auto text-black">
<div className="w-full grid grid-cols-1 grid-rows-1 text-black"> <div className="w-full grid grid-cols-1 grid-rows-1 text-black">
<AnimatePresence initial={false} custom={direction}> <AnimatePresence initial={false} custom={slideMotion}>
<motion.div <motion.div
key={`${post.id}-${currentIndex}`} key={`${post.id}-${currentIndex}`}
custom={direction} custom={slideMotion}
variants={variants} variants={variants}
initial="enter" initial="enter"
animate="center" animate="center"
exit="exit" exit="exit"
transition={{ x: { type: "spring", stiffness: 200, damping: 26, bounce: 0 } }} transition={reduceMotion
? { duration: 0 }
: { x: withVelocity(slideMotion.velocity, NAVIGATE), y: withVelocity(slideMotion.velocity, NAVIGATE) }}
drag="x" drag="x"
dragDirectionLock dragDirectionLock
dragConstraints={{ left: 0, right: 0 }} dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.5} dragElastic={0.5}
onDragEnd={(e, { offset, velocity }) => { onDragEnd={(e, { offset, velocity }) => {
// Carousel only. Crossing into the next post from the last
// slide made a horizontal flick mean two different things.
const s = swipePower(offset.x, velocity.x); const s = swipePower(offset.x, velocity.x);
if (s < -15000) { if (currentIndex < post.media.length - 1) paginate(1); else if (hasNextPost && onNextPost && s < -40000) onNextPost(); } if (s < -15000) paginate(1, velocity.x);
else if (s > 15000) { if (currentIndex > 0) paginate(-1); else if (hasPrevPost && onPrevPost && s > 40000) onPrevPost(); } else if (s > 15000) paginate(-1, velocity.x);
}} }}
className="col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing relative text-black" className="col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing relative text-black"
> >
@@ -135,7 +190,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>
@@ -146,7 +201,7 @@ export const PostModal: React.FC<PostModalProps> = ({
<div className="flex-1 overflow-y-auto p-3 md:p-4 space-y-4 min-h-0 md:max-h-[60vh] text-black"> <div className="flex-1 overflow-y-auto p-3 md:p-4 space-y-4 min-h-0 md:max-h-[60vh] text-black">
<div className="flex gap-3 text-black"> <div className="flex gap-3 text-black">
<div className="w-8 h-8 rounded-full bg-gray-200 flex-shrink-0 flex items-center justify-center text-[10px] font-bold uppercase overflow-hidden text-black">{profilePic ? <img src={profilePic} alt="" className="w-full h-full object-cover" referrerPolicy="no-referrer" /> : <span className="text-black">{post.username[0]}</span>}</div> <div className="w-8 h-8 rounded-full bg-gray-200 flex-shrink-0 flex items-center justify-center text-[10px] font-bold uppercase overflow-hidden text-black">{profilePic ? <img src={profilePic} alt="" className="w-full h-full object-cover" referrerPolicy="no-referrer" /> : <span className="text-black">{post.username[0]}</span>}</div>
<div className="text-sm text-black"><span className="font-semibold mr-2 text-black">{post.username}</span><span className="whitespace-pre-wrap text-black">{post.caption}</span><div className="mt-2 text-xs text-gray-500 uppercase tracking-tight text-black">{format(parseISO(post.date), 'MMMM d, yyyy')}</div></div> <div className="text-sm text-black"><span className="font-semibold mr-2 text-black">{post.username}</span><span className="whitespace-pre-wrap text-black">{post.caption}</span><div className="mt-2 text-xs text-gray-500 uppercase tracking-tight text-black">{formatDateSafe(post.date, 'MMMM d, yyyy')}</div></div>
</div> </div>
</div> </div>
<div className="p-3 md:p-4 border-t border-gray-100 space-y-3 shrink-0 bg-white text-black"> <div className="p-3 md:p-4 border-t border-gray-100 space-y-3 shrink-0 bg-white text-black">
+124
View File
@@ -0,0 +1,124 @@
import React, { useState, useEffect, useRef } from 'react';
import { Play, Image as ImageIcon } from 'lucide-react';
import { cn } from '../lib/utils';
import { Post } from '../types';
interface PostThumbnailProps {
post: Post;
className?: string;
thumbnailUrl?: string; // High-res thumbnail from queue
onRequestThumbnail: (id: string, url: string) => void;
}
const videoThumbnailCache = new Map<string, string>();
export const PostThumbnail = ({ post, className, thumbnailUrl, onRequestThumbnail }: PostThumbnailProps) => {
const [videoThumbnail, setVideoThumbnail] = useState<string | null>(videoThumbnailCache.get(post.media[0].url) || null);
const [isInView, setIsInView] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const mainMedia = post.media[0];
const isVideo = mainMedia.type === 'video';
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
}, { rootMargin: '400px' }); // Larger margin for smoother scrolling
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!isInView) return;
if (isVideo) {
if (videoThumbnail) return;
const video = document.createElement('video');
video.src = `${mainMedia.url}#t=0.1`;
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
const captureFrame = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx && video.videoWidth > 0) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
videoThumbnailCache.set(mainMedia.url, dataUrl);
setVideoThumbnail(dataUrl);
}
} catch (err) {} finally { cleanup(); }
};
const handleLoadedMetadata = () => video.currentTime = 0.1;
const handleSeeked = () => captureFrame();
const cleanup = () => {
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('seeked', handleSeeked);
video.removeAttribute('src');
video.load();
};
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('error', cleanup);
const timeout = setTimeout(() => cleanup(), 5000);
return () => { clearTimeout(timeout); cleanup(); };
} else {
// Request high-res image thumbnailing only if size > 1MiB
const ONE_MIB = 1024 * 1024;
if (mainMedia.size && mainMedia.size > ONE_MIB) {
onRequestThumbnail(post.id, mainMedia.url);
}
}
}, [isInView, isVideo, mainMedia.url, mainMedia.size, post.id, onRequestThumbnail, videoThumbnail]);
// Determine if we are actually expecting a high-res thumbnail
const ONE_MIB = 1024 * 1024;
const isHighRes = !isVideo && mainMedia.size && mainMedia.size > ONE_MIB;
const isGenerating = isHighRes && !thumbnailUrl;
// Use high-res thumbnail if available, then video thumb, then original
const displayUrl = thumbnailUrl || videoThumbnail || post.thumbnail;
if (!displayUrl && isVideo) {
return (
<div ref={containerRef} className={cn("w-full h-full bg-gray-100 flex items-center justify-center text-black", className)}>
<Play size={20} className="text-gray-300" fill="currentColor" />
</div>
);
}
if (!displayUrl) {
return (
<div ref={containerRef} className={cn("w-full h-full bg-gray-50 flex items-center justify-center text-black", className)}>
<ImageIcon size={20} className="text-gray-200" />
</div>
);
}
return (
<div ref={containerRef} className="w-full h-full">
<img
src={displayUrl}
alt=""
className={cn(
"w-full h-full object-cover transition-all duration-700",
className,
isGenerating ? "blur-sm scale-105" : "blur-0 scale-100"
)}
referrerPolicy="no-referrer"
loading="lazy"
/>
</div>
);
};
+52 -19
View File
@@ -7,34 +7,42 @@ import {
X X
} from 'lucide-react'; } from 'lucide-react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { format, parseISO } from 'date-fns';
import { Post } from '../types'; import { Post } from '../types';
import { cn } from '../lib/utils'; import { cn, formatDateSafe } from '../lib/utils';
import { FADE, PRESENT, prefersReducedMotion } from '../lib/motion';
interface StoryViewerProps { interface StoryViewerProps {
stories: Post[]; stories: Post[];
onClose: () => void; onClose: () => void;
profilePic: string | null; profilePic: string | null;
/** Highlight name, shown in place of the date when viewing a highlight. */
title?: string;
} }
export const StoryViewer: React.FC<StoryViewerProps> = ({ export const StoryViewer: React.FC<StoryViewerProps> = ({
stories, stories,
onClose, onClose,
profilePic profilePic,
title
}) => { }) => {
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
// falls back to muted if the browser refuses, which would otherwise stall
// the progress bar on the first video.
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const reduceMotion = prefersReducedMotion();
const story = stories[currentStoryIndex]; const story = stories[currentStoryIndex];
const primary = story?.media?.[0];
useEffect(() => { useEffect(() => {
setProgress(0); setProgress(0);
let duration = 5000; let duration = 5000;
const interval = 50; const interval = 50;
const updateProgress = () => { const updateProgress = () => {
if (story.media[0].type === 'video' && videoRef.current) { if (primary?.type === 'video' && videoRef.current) {
const currentTime = videoRef.current.currentTime; const currentTime = videoRef.current.currentTime;
const totalTime = videoRef.current.duration; const totalTime = videoRef.current.duration;
if (totalTime) { if (totalTime) {
@@ -54,7 +62,23 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
}, interval); }, interval);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [currentStoryIndex, story.media]); }, [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) {
@@ -80,18 +104,22 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
} }
}; };
// An empty or exhausted reel has nothing to show; bail before dereferencing.
if (!story || !primary) return null;
return ( return (
<motion.div <motion.div
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}
> >
<div className="absolute inset-0 z-0 text-white"> <div className="absolute inset-0 z-0 text-white">
<img <img
src={story.media[0].url} src={primary.url}
alt="" alt=""
className="w-full h-full object-cover blur-3xl opacity-30" className="w-full h-full object-cover blur-3xl opacity-30"
/> />
</div> </div>
@@ -113,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()}
> >
@@ -146,12 +178,13 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
</div> </div>
<div className="flex items-center gap-2 text-white"> <div className="flex items-center gap-2 text-white">
<span className="text-xs font-semibold">{story.username}</span> <span className="text-xs font-semibold">{story.username}</span>
<span className="text-[10px] opacity-60 font-medium">{format(parseISO(story.date), 'MMM d')}</span> {title && <span className="text-[10px] opacity-80 font-medium truncate max-w-[120px]">{title}</span>}
<span className="text-[10px] opacity-60 font-medium">{formatDateSafe(story.date, 'MMM d')}</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-1 text-white"> <div className="flex items-center gap-1 text-white">
{story.media[0].type === 'video' && ( {primary.type === 'video' && (
<button <button
onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }} onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }}
className="p-2 hover:bg-white/10 rounded-full transition-colors text-white" className="p-2 hover:bg-white/10 rounded-full transition-colors text-white"
@@ -166,10 +199,10 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
</div> </div>
<div className="w-full h-full flex items-center justify-center pointer-events-none text-white"> <div className="w-full h-full flex items-center justify-center pointer-events-none text-white">
{story.media[0].type === 'video' ? ( {primary.type === 'video' ? (
<video <video
ref={videoRef} ref={videoRef}
src={story.media[0].url} src={primary.url}
className="w-full h-full object-contain" className="w-full h-full object-contain"
autoPlay autoPlay
muted={isMuted} muted={isMuted}
@@ -179,8 +212,8 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
/> />
) : ( ) : (
<img <img
src={story.media[0].url} src={primary.url}
alt="" alt=""
className="w-full h-full object-contain" className="w-full h-full object-contain"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
@@ -197,7 +230,7 @@ export const StoryViewer: React.FC<StoryViewerProps> = ({
{story.caption} {story.caption}
</div> </div>
)} )}
</div> </motion.div>
</motion.div> </motion.div>
); );
}; };
-59
View File
@@ -1,59 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Play } from 'lucide-react';
import { cn } from '../lib/utils';
const thumbnailCache = new Map<string, string>();
export const VideoThumbnail = ({ url, className }: { url: string; className?: string }) => {
const [thumbnail, setThumbnail] = useState<string | null>(thumbnailCache.get(url) || null);
const [isInView, setIsInView] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (thumbnail || !containerRef.current) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) { setIsInView(true); observer.disconnect(); }
}, { rootMargin: '200px' });
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [thumbnail]);
useEffect(() => {
if (thumbnail || !isInView) return;
const video = document.createElement('video');
video.src = `${url}#t=0.1`; video.preload = 'metadata'; video.muted = true; video.playsInline = true;
const captureFrame = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx && video.videoWidth > 0) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
thumbnailCache.set(url, dataUrl); setThumbnail(dataUrl);
}
} catch (err) {} finally { cleanup(); }
};
const handleLoadedMetadata = () => video.currentTime = 0.1;
const handleSeeked = () => captureFrame();
const cleanup = () => {
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('seeked', handleSeeked);
video.removeAttribute('src');
video.load();
};
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('error', cleanup);
const timeout = setTimeout(() => { if (!thumbnailCache.has(url)) cleanup(); }, 5000);
return () => { clearTimeout(timeout); cleanup(); };
}, [url, thumbnail, isInView]);
if (!thumbnail) return (
<div ref={containerRef} className={cn("w-full h-full bg-gray-100 flex items-center justify-center text-black", className)}>
<Play size={20} className="text-gray-300" fill="currentColor" />
</div>
);
return <img src={thumbnail} alt="" className={cn("w-full h-full object-cover transition-transform duration-500 group-hover:scale-110", className)} referrerPolicy="no-referrer" />;
};
+129 -49
View File
@@ -1,8 +1,11 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
// @ts-ignore // @ts-ignore
import { XzReadableStream } from 'xz-decompress'; import { XzReadableStream } from 'xz-decompress';
import * as idb from 'idb-keyval'; import { ArchiveFile, CacheData, Post, ServerArchive } from '../types';
import { ArchiveFile, Post, ServerArchive } from '../types'; import { setCachedArchive, getDirectoryHandle } from '../lib/archive-cache';
import { parseArchiveFilename, scopedPostId, EXPORT_RE, INSTALOADER_RE } from '../lib/archive-patterns';
const hasDirectoryHandle = async (name: string) => Boolean(await getDirectoryHandle(name));
export const useArchiveScanner = ( export const useArchiveScanner = (
detectedUsername: string, detectedUsername: string,
@@ -19,6 +22,7 @@ export const useArchiveScanner = (
// Result state // Result state
const [allPosts, setAllPosts] = useState<Post[]>([]); const [allPosts, setAllPosts] = useState<Post[]>([]);
const [allStories, setAllStories] = useState<Post[]>([]); const [allStories, setAllStories] = useState<Post[]>([]);
const [allHighlights, setAllHighlights] = useState<Post[]>([]);
const [profileMetadata, setProfileMetadata] = useState<{ const [profileMetadata, setProfileMetadata] = useState<{
username: string; username: string;
fullName: string; fullName: string;
@@ -39,9 +43,33 @@ export const useArchiveScanner = (
allProfilePics: [], allProfilePics: [],
}); });
/**
* Blob URLs minted for the archive currently in state. They stay alive as long
* as that archive is on screen and are released when it is torn down —
* otherwise every archive ever opened stays resident for the tab's lifetime.
*/
const createdUrlsRef = useRef<string[]>([]);
const revokeCreatedUrls = useCallback(() => {
for (const url of createdUrlsRef.current) {
try { URL.revokeObjectURL(url); } catch { /* already gone */ }
}
createdUrlsRef.current = [];
}, []);
// Release the last archive's URLs when the app unmounts.
useEffect(() => revokeCreatedUrls, [revokeCreatedUrls]);
/** Hand ownership of an externally-minted blob URL to the scanner's cleanup. */
const registerUrl = useCallback((url: string) => {
createdUrlsRef.current.push(url);
}, []);
const resetScannerState = useCallback(() => { const resetScannerState = useCallback(() => {
revokeCreatedUrls();
setAllPosts([]); setAllPosts([]);
setAllStories([]); setAllStories([]);
setAllHighlights([]);
setProfileMetadata({ setProfileMetadata({
username: '', username: '',
fullName: '', fullName: '',
@@ -52,7 +80,7 @@ export const useArchiveScanner = (
profilePic: null, profilePic: null,
allProfilePics: [], allProfilePics: [],
}); });
}, []); }, [revokeCreatedUrls]);
const handleFiles = useCallback(async (files: ArchiveFile[], archiveContext?: ServerArchive) => { const handleFiles = useCallback(async (files: ArchiveFile[], archiveContext?: ServerArchive) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@@ -66,6 +94,19 @@ export const useArchiveScanner = (
console.log(`[Scanner] Starting scan of ${files.length} files...`); console.log(`[Scanner] Starting scan of ${files.length} files...`);
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
/**
* Mint a URL for a media file, remembering it if it needs revoking later.
* Synchronous and allocation-free: no file contents are read here.
*/
const mintUrl = (file: ArchiveFile, mimeHint?: string) => {
const url = file.createObjectUrl(mimeHint);
if (file.revocable) createdUrlsRef.current.push(url);
return url;
};
/** Stable identity for a media file, used to rehydrate URLs after a reload. */
const mediaPath = (file: ArchiveFile) => file.webkitRelativePath || file.name;
const parseXZFile = async (file: ArchiveFile) => { const parseXZFile = async (file: ArchiveFile) => {
try { try {
const stream = new XzReadableStream(file.stream()); const stream = new XzReadableStream(file.stream());
@@ -91,6 +132,7 @@ export const useArchiveScanner = (
const postsMap = new Map<string, Partial<Post>>(); const postsMap = new Map<string, Partial<Post>>();
const mediaFilesMap = new Map<string, ArchiveFile>(); const mediaFilesMap = new Map<string, ArchiveFile>();
const discoveredProfilePics: { name: string, url: string }[] = []; const discoveredProfilePics: { name: string, url: string }[] = [];
const allImageFiles: ArchiveFile[] = [];
let localFullName = ''; let localFullName = '';
let localBio = ''; let localBio = '';
@@ -99,15 +141,23 @@ export const useArchiveScanner = (
let localFollowingCount = 0; let localFollowingCount = 0;
let localProfilePic: string | null = null; let localProfilePic: string | null = null;
const exportRegex = /^(\d{4}-\d{2}-\d{2})_(.+?) - (.+?)(?: - (\d+))?(?: - (story))?\.(.+)$/;
const instaloaderRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_UTC)(?:_(\d+))?(?:_(story))?\.(.+)$/;
const checkIsStory = (obj: any): boolean => { const checkIsStory = (obj: any): boolean => {
if (!obj) return false; if (!obj) return false;
const typeName = obj.__typename || obj.typename || ''; const typeName = obj.__typename || obj.typename || '';
return (obj.is_story === true || obj.is_reel_media === true || typeName.includes('Story') || obj.audience === "MediaAudience.DEFAULT" || obj.node_type === "StoryItem" || obj.product_type === "story" || typeName === "GraphStoryVideo" || typeName === "GraphStoryImage"); return (obj.is_story === true || obj.is_reel_media === true || typeName.includes('Story') || obj.audience === "MediaAudience.DEFAULT" || obj.node_type === "StoryItem" || obj.product_type === "story" || typeName === "GraphStoryVideo" || typeName === "GraphStoryImage");
}; };
let currentUsername = archiveContext?.name || currentArchive?.name || detectedUsername || ''; let currentUsername = archiveContext?.name || currentArchive?.name || detectedUsername;
// If still no username (likely local folder), try to extract from path
if (!currentUsername && files[0]?.webkitRelativePath) {
const pathParts = files[0].webkitRelativePath.split(/[/\\]/);
if (pathParts.length > 1) {
currentUsername = pathParts[0];
}
}
if (!currentUsername) currentUsername = 'archived_user';
let format: 'export' | 'instaloader' | 'json' | 'unknown' = 'unknown'; let format: 'export' | 'instaloader' | 'json' | 'unknown' = 'unknown';
let jsonFiles: ArchiveFile[] = []; let jsonFiles: ArchiveFile[] = [];
@@ -129,15 +179,15 @@ export const useArchiveScanner = (
continue; continue;
} }
if (file.name.match(exportRegex)) format = 'export'; if (EXPORT_RE.test(file.name)) format = 'export';
else if (file.name.match(instaloaderRegex)) format = 'instaloader'; else if (INSTALOADER_RE.test(file.name)) format = 'instaloader';
// Highlight items match neither pattern, so a profile made up only of
// sidecar directories would otherwise never reach the filename parser.
else if (format === 'unknown' && file.source && file.source.kind !== 'posts') format = 'export';
if (lowerName.includes('_profile_pic.jpg') || (currentUsername && lowerName === `${currentUsername.toLowerCase()}.jpg`)) { if (lowerName.includes('_profile_pic.jpg') || (currentUsername && lowerName === `${currentUsername.toLowerCase()}.jpg`)) {
try { try {
const url = file.url || (await (async () => { const url = mintUrl(file, 'image/jpeg');
const blob = new Blob([await file.arrayBuffer()], { type: 'image/jpeg' });
return URL.createObjectURL(blob);
})());
discoveredProfilePics.push({ name: file.name, url }); discoveredProfilePics.push({ name: file.name, url });
if (format === 'unknown' && lowerName.includes('_profile_pic.jpg')) format = 'instaloader'; if (format === 'unknown' && lowerName.includes('_profile_pic.jpg')) format = 'instaloader';
} catch(e) {} } catch(e) {}
@@ -145,6 +195,7 @@ export const useArchiveScanner = (
if (isMedia(file.name)) { if (isMedia(file.name)) {
mediaFilesMap.set(file.webkitRelativePath || file.name, file); mediaFilesMap.set(file.webkitRelativePath || file.name, file);
if (isImage(file.name)) allImageFiles.push(file);
} }
} }
@@ -193,10 +244,10 @@ export const useArchiveScanner = (
if (matchedFile) { if (matchedFile) {
const type = isVideo(matchedFile.name) ? 'video' : 'image'; const type = isVideo(matchedFile.name) ? 'video' : 'image';
const url = matchedFile.url || URL.createObjectURL(new Blob([await matchedFile.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' })); const url = mintUrl(matchedFile, type === 'video' ? 'video/mp4' : 'image/jpeg');
const existingMedia = post.media!.find(media => media.index === mIdx + 1); const existingMedia = post.media!.find(media => media.index === mIdx + 1);
if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(media => media.index === mIdx + 1 ? { name: matchedFile!.name, url, type, index: mIdx + 1 } : media); } if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(media => media.index === mIdx + 1 ? { name: matchedFile!.name, path: mediaPath(matchedFile!), url, type, index: mIdx + 1, size: matchedFile!.size } : media); }
else post.media!.push({ name: matchedFile.name, url, type, index: mIdx + 1 }); else post.media!.push({ name: matchedFile.name, path: mediaPath(matchedFile), url, type, index: mIdx + 1, size: matchedFile.size });
} }
} }
if (post.media!.length > 0) postsMap.set(postId, post); if (post.media!.length > 0) postsMap.set(postId, post);
@@ -216,21 +267,25 @@ export const useArchiveScanner = (
setScannedFilesLog(prev => [`Batch ${Math.floor(j_start/CHUNK_SIZE) + 1} processing...`, ...prev.slice(0, 19)]); setScannedFilesLog(prev => [`Batch ${Math.floor(j_start/CHUNK_SIZE) + 1} processing...`, ...prev.slice(0, 19)]);
for (let j = j_start; j < end; j++) { for (let j = j_start; j < end; j++) {
const file = files[j]; const lowerName = file.name.toLowerCase(); const file = files[j]; const lowerName = file.name.toLowerCase();
const expMatch = file.name.match(exportRegex); const kind = file.source?.kind ?? 'posts';
const insMatch = file.name.match(instaloaderRegex); const parsed = parseArchiveFilename(file.name, kind, file.mtime);
if (!expMatch && !insMatch) continue; if (!parsed) continue;
let postId = '', date = '', user = currentUsername || 'archived_user', index = 1, ext = '', isStory = lowerName.includes('story') || file.webkitRelativePath.toLowerCase().includes('stories'); const { date, index, ext } = parsed;
if (expMatch) { const user = parsed.username || currentUsername || 'archived_user';
const [_, dMatch, uMatch, pMatch, iStrMatch, sMatch, eMatch] = expMatch; let isStory = parsed.isStory
date = dMatch; user = uMatch; postId = pMatch; index = iStrMatch ? parseInt(iStrMatch, 10) : 1; if (sMatch) isStory = true; ext = eMatch; || lowerName.includes('story')
} else if (insMatch) { || file.webkitRelativePath.toLowerCase().includes('stories');
const [_, pMatch, iStrMatch, sMatch, eMatch] = insMatch;
postId = pMatch; date = pMatch.split('_')[0]; index = iStrMatch ? parseInt(iStrMatch, 10) : 1; if (sMatch) isStory = true; ext = eMatch; const postId = scopedPostId(parsed.postId, kind, file.source?.dir);
} if (kind === 'stories') isStory = true;
if (kind === 'highlight') isStory = false;
let post = postsMap.get(postId); let post = postsMap.get(postId);
if (!post) { post = { id: postId, date, username: user, caption: '', media: [], isStory }; postsMap.set(postId, post); } if (!post) {
post = { id: postId, date, username: user, caption: '', media: [], isStory, source: kind, highlightTitle: file.source?.title };
postsMap.set(postId, post);
}
else if (isStory) post.isStory = true; else if (isStory) post.isStory = true;
const lowerExt = ext.toLowerCase(); const lowerExt = ext.toLowerCase();
@@ -248,11 +303,11 @@ export const useArchiveScanner = (
} catch (e) {} } catch (e) {}
} else if (isMedia(file.name)) { } else if (isMedia(file.name)) {
const type = isVideo(file.name) ? 'video' : 'image'; const type = isVideo(file.name) ? 'video' : 'image';
const url = file.url || URL.createObjectURL(new Blob([await file.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' })); const url = mintUrl(file, type === 'video' ? 'video/mp4' : 'image/jpeg');
if (type === 'image') throttledSetScanningImage(url); if (type === 'image') throttledSetScanningImage(url);
const existingMedia = post.media!.find(m => m.index === index); const existingMedia = post.media!.find(m => m.index === index);
if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(m => m.index === index ? { name: file.name, url, type, index } : m); } if (existingMedia) { if (type === 'video' && existingMedia.type === 'image') post.media = post.media!.map(m => m.index === index ? { name: file.name, path: mediaPath(file), url, type, index, size: file.size } : m); }
else post.media!.push({ name: file.name, url, type, index }); else post.media!.push({ name: file.name, path: mediaPath(file), url, type, index, size: file.size });
} }
} }
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
@@ -295,9 +350,9 @@ export const useArchiveScanner = (
const post: Post = { id: postId, date: new Date().toISOString().split('T')[0], username: currentUsername || 'archived_user', caption: baseName, media: [], thumbnail: '' }; const post: Post = { id: postId, date: new Date().toISOString().split('T')[0], username: currentUsername || 'archived_user', caption: baseName, media: [], thumbnail: '' };
for (const [idx, file] of batch.entries()) { for (const [idx, file] of batch.entries()) {
const type = isVideo(file.name) ? 'video' : 'image'; const type = isVideo(file.name) ? 'video' : 'image';
const url = file.url || URL.createObjectURL(new Blob([await file.arrayBuffer()], { type: type === 'video' ? 'video/mp4' : 'image/jpeg' })); const url = mintUrl(file, type === 'video' ? 'video/mp4' : 'image/jpeg');
if (type === 'image') throttledSetScanningImage(url); if (type === 'image') throttledSetScanningImage(url);
post.media.push({ name: file.name, url, type, index: idx + 1 }); post.media.push({ name: file.name, path: mediaPath(file), url, type, index: idx + 1, size: file.size });
} }
post.thumbnail = post.media[0].url; post.thumbnail = post.media[0].url;
postsMap.set(postId, post); postsMap.set(postId, post);
@@ -310,6 +365,15 @@ export const useArchiveScanner = (
const urls = discoveredProfilePics.map(p => p.url); const urls = discoveredProfilePics.map(p => p.url);
localProfilePic = urls[0]; localProfilePic = urls[0];
setProfileMetadata(prev => ({ ...prev, profilePic: localProfilePic, allProfilePics: urls })); setProfileMetadata(prev => ({ ...prev, profilePic: localProfilePic, allProfilePics: urls }));
} else if (allImageFiles.length > 0) {
// Fallback: Use oldest image in archive as profile pic
allImageFiles.sort((a, b) => a.name.localeCompare(b.name));
const oldestFile = allImageFiles[0];
try {
const url = mintUrl(oldestFile, 'image/jpeg');
localProfilePic = url;
setProfileMetadata(prev => ({ ...prev, profilePic: localProfilePic, allProfilePics: [url] }));
} catch(e) {}
} }
const finalUsername = currentUsername || 'archived_user'; const finalUsername = currentUsername || 'archived_user';
@@ -318,11 +382,17 @@ export const useArchiveScanner = (
return { ...p, username: (p.username === 'archived_user' || !p.username) ? finalUsername : p.username, media: sortedMedia, thumbnail: sortedMedia[0].url } as Post; return { ...p, username: (p.username === 'archived_user' || !p.username) ? finalUsername : p.username, media: sortedMedia, thumbnail: sortedMedia[0].url } as Post;
}); });
const posts = allItems.filter(p => !p.isStory).sort((a, b) => b.date.localeCompare(a.date)); const byNewest = (a: Post, b: Post) => b.date.localeCompare(a.date);
const stories = allItems.filter(p => p.isStory).sort((a, b) => b.date.localeCompare(a.date)); // Fixed bug here // Highlights are story-shaped but live behind their own circles, so they
// are kept out of both the grid and the profile-ring story reel.
const highlights = allItems.filter(p => p.source === 'highlight').sort(byNewest);
const rest = allItems.filter(p => p.source !== 'highlight');
const posts = rest.filter(p => !p.isStory).sort(byNewest);
const stories = rest.filter(p => p.isStory).sort(byNewest);
setAllPosts(posts); setAllPosts(posts);
setAllStories(stories); setAllStories(stories);
setAllHighlights(highlights);
setProfileMetadata(prev => ({ setProfileMetadata(prev => ({
...prev, ...prev,
username: finalUsername, username: finalUsername,
@@ -356,23 +426,30 @@ export const useArchiveScanner = (
} catch (e) {} } catch (e) {}
} }
const cacheData = { const cacheData: CacheData = {
name: cacheKey, isLocal, fileCount: archiveToCache ? archiveToCache.fileCount : files.length, name: cacheKey, isLocal,
posts: isLocal ? [] : posts, stories: isLocal ? [] : stories, fileCount: (archiveToCache ? archiveToCache.fileCount : files.length) ?? files.length,
profileMetadata: { signature: archiveToCache?.signature,
username: finalUsername, posts: posts,
fullName: localFullName, stories: stories,
bio: localBio, highlights: highlights,
followerCount: localFollowerCount, profileMetadata: {
followingCount: localFollowingCount, username: finalUsername,
externalUrl: localExternalUrl, fullName: localFullName,
bio: localBio,
followerCount: localFollowerCount,
followingCount: localFollowingCount,
externalUrl: localExternalUrl,
// Local blob: URLs die with the document, so persist a data: URL for
// the dashboard card instead. Media URLs are rehydrated from `path`.
profilePic: isLocal ? cacheThumbnail : localProfilePic, profilePic: isLocal ? cacheThumbnail : localProfilePic,
allProfilePics: isLocal ? (cacheThumbnail ? [cacheThumbnail] : []) : discoveredProfilePics.map(p => p.url) allProfilePics: isLocal ? (cacheThumbnail ? [cacheThumbnail] : []) : discoveredProfilePics.map(p => p.url)
}, },
timestamp: Date.now() hasDirectoryHandle: isLocal ? await hasDirectoryHandle(cacheKey) : false,
timestamp: Date.now()
}; };
try { try {
await idb.set(cacheKey, cacheData); await setCachedArchive(cacheData);
console.log(`[Cache] Data saved successfully.`); console.log(`[Cache] Data saved successfully.`);
await refreshCachedArchives(); await refreshCachedArchives();
} catch (e) { console.error(`[Cache] Save error:`, e); } } catch (e) { console.error(`[Cache] Save error:`, e); }
@@ -389,10 +466,12 @@ export const useArchiveScanner = (
currentScanningImage, currentScanningImage,
allPosts, allPosts,
allStories, allStories,
allHighlights,
profileMetadata, profileMetadata,
handleFiles, handleFiles,
setAllPosts, setAllPosts,
setAllStories, setAllStories,
setAllHighlights,
setProfileMetadata, setProfileMetadata,
setIsScanning, setIsScanning,
setScanningPhase, setScanningPhase,
@@ -400,6 +479,7 @@ export const useArchiveScanner = (
setTotalFiles, setTotalFiles,
setScannedFilesLog, setScannedFilesLog,
setCurrentScanningImage, setCurrentScanningImage,
resetScannerState resetScannerState,
registerUrl
}; };
}; };
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
/** Matches Tailwind's `md` breakpoint, the point where the layout splits. */
const MOBILE_QUERY = '(max-width: 767px)';
/**
* True on phone-sized viewports.
*
* Drives more than styling: mobile opens posts as a scrollable feed page while
* desktop uses the modal, so this needs to be real state rather than a CSS
* media query.
*/
export const useIsMobile = () => {
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && window.matchMedia(MOBILE_QUERY).matches,
);
useEffect(() => {
const query = window.matchMedia(MOBILE_QUERY);
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
query.addEventListener('change', onChange);
setIsMobile(query.matches);
return () => query.removeEventListener('change', onChange);
}, []);
return isMobile;
};
+128
View File
@@ -0,0 +1,128 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import * as idb from 'idb-keyval';
import { thumbKey } from '../lib/archive-cache';
interface ThumbnailRequest {
id: string;
key: string;
url: string;
blob?: Blob;
}
const THUMBNAIL_WIDTH = 400;
export const useThumbnailQueue = (archiveName: string) => {
const [cacheHits, setCacheHits] = useState<Map<string, string>>(new Map());
const queueRef = useRef<ThumbnailRequest[]>([]);
const isProcessingRef = useRef(false);
const workerRef = useRef<Worker | null>(null);
/**
* Mirrors `cacheHits` for reads inside callbacks.
*
* `requestThumbnail` is a dependency of every PostThumbnail effect, so it must
* keep a stable identity closing over `cacheHits` state directly would give
* it a new identity per completed thumbnail and re-run the effect in all
* ~90 mounted thumbnails each time.
*/
const cacheHitsRef = useRef<Map<string, string>>(new Map());
/** Blob URLs handed out for thumbnails, released when the archive changes. */
const createdUrlsRef = useRef<string[]>([]);
const publish = useCallback((id: string, url: string) => {
createdUrlsRef.current.push(url);
cacheHitsRef.current.set(id, url);
setCacheHits(new Map(cacheHitsRef.current));
}, []);
const processNext = useCallback(async () => {
if (isProcessingRef.current || queueRef.current.length === 0 || !workerRef.current) return;
isProcessingRef.current = true;
const request = queueRef.current.shift()!;
try {
// Re-check the store: the entry may have landed since being queued.
const cached = await idb.get(request.key);
if (cached instanceof Blob) {
publish(request.id, URL.createObjectURL(cached));
isProcessingRef.current = false;
processNext();
return;
}
let blob = request.blob;
if (!blob) {
const res = await fetch(request.url);
blob = await res.blob();
}
// One image at a time: decoding several 50MP+ files concurrently is a
// reliable way to OOM the tab.
workerRef.current.postMessage({ id: request.key, blob, width: THUMBNAIL_WIDTH });
} catch (err) {
console.error('[ThumbnailQueue] Failed to process:', request.id, err);
isProcessingRef.current = false;
processNext();
}
}, [publish]);
useEffect(() => {
workerRef.current = new Worker(new URL('../lib/thumbnail-worker.ts', import.meta.url), {
type: 'module'
});
workerRef.current.onmessage = async (e) => {
const { id: key, blob, error } = e.data;
if (!error && blob) {
const id = key.split(':').slice(2).join(':');
publish(id, URL.createObjectURL(blob));
try {
await idb.set(key, blob);
} catch (err) { /* quota exceeded; the in-memory hit still stands */ }
}
isProcessingRef.current = false;
processNext();
};
return () => {
workerRef.current?.terminate();
};
}, [publish, processNext]);
// Switching archives invalidates every thumbnail URL handed out so far.
useEffect(() => {
return () => {
queueRef.current = [];
for (const url of createdUrlsRef.current) {
try { URL.revokeObjectURL(url); } catch { /* already gone */ }
}
createdUrlsRef.current = [];
cacheHitsRef.current = new Map();
setCacheHits(new Map());
};
}, [archiveName]);
const requestThumbnail = useCallback(async (id: string, url: string, blob?: Blob) => {
if (cacheHitsRef.current.has(id)) return;
const key = thumbKey(archiveName, id);
const cached = await idb.get(key);
if (cached instanceof Blob) {
publish(id, URL.createObjectURL(cached));
return;
}
if (!queueRef.current.some(r => r.id === id)) {
queueRef.current.push({ id, key, url, blob });
processNext();
}
}, [archiveName, publish, processNext]);
return {
cacheHits,
requestThumbnail
};
};
+2 -1
View File
@@ -1,4 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400..900;1,400..900&display=swap'); /* Self-hosted: no third-party font requests, works fully offline. */
@import url('/fonts/fonts.css');
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
+170
View File
@@ -0,0 +1,170 @@
import * as idb from 'idb-keyval';
import { CacheData, Post } from '../types';
import { DirectoryHandle, ensureReadPermission, filesFromDirectory } from './directory-handle';
import { LocalArchiveFile } from './archive-files';
/**
* Persistent archive cache.
*
* Keys are namespaced so that listing archives does not require deserializing
* every thumbnail blob in the store: `archive:` entries are metadata, `thumb:`
* entries are image blobs, `handle:` entries are directory handles.
*/
const ARCHIVE_PREFIX = 'archive:';
const THUMB_PREFIX = 'thumb:';
const HANDLE_PREFIX = 'handle:';
export const archiveKey = (name: string) => `${ARCHIVE_PREFIX}${name}`;
export const handleKey = (name: string) => `${HANDLE_PREFIX}${name}`;
/** Thumbnails are scoped per archive; post IDs alone collide across archives. */
export const thumbKey = (archive: string, postId: string) => `${THUMB_PREFIX}${archive}:${postId}`;
export const getCachedArchive = (name: string): Promise<CacheData | undefined> =>
idb.get(archiveKey(name));
export const setCachedArchive = (data: CacheData) => idb.set(archiveKey(data.name), data);
/** Names of all cached archives, without loading their contents. */
export const listCachedArchiveNames = async (): Promise<string[]> =>
(await idb.keys())
.map(String)
.filter(k => k.startsWith(ARCHIVE_PREFIX))
.map(k => k.slice(ARCHIVE_PREFIX.length));
export const listCachedArchives = async (): Promise<CacheData[]> => {
const names = await listCachedArchiveNames();
const entries = await Promise.all(names.map(getCachedArchive));
return entries.filter((e): e is CacheData => Boolean(e));
};
/** Remove an archive along with its handle and every thumbnail it owns. */
export const deleteCachedArchive = async (name: string) => {
const thumbPrefix = `${THUMB_PREFIX}${name}:`;
const stale = (await idb.keys()).map(String).filter(k => k.startsWith(thumbPrefix));
await idb.delMany([archiveKey(name), handleKey(name), ...stale]);
};
export const saveDirectoryHandle = (name: string, handle: DirectoryHandle) =>
idb.set(handleKey(name), handle);
export const getDirectoryHandle = (name: string): Promise<DirectoryHandle | undefined> =>
idb.get(handleKey(name));
/**
* One-time migration from the flat key layout (archive name as a bare key,
* `thumb_<postId>` for thumbnails).
*
* Old local entries are dropped rather than migrated: their media URLs are
* dead blob: URLs, so restoring them would render an archive of broken images.
*/
export const migrateLegacyCache = async () => {
const keys = (await idb.keys()).map(String);
const legacyThumbs = keys.filter(k => k.startsWith('thumb_'));
const legacyArchives = keys.filter(
k => !k.startsWith(ARCHIVE_PREFIX) && !k.startsWith(THUMB_PREFIX) &&
!k.startsWith(HANDLE_PREFIX) && !k.startsWith('thumb_')
);
if (!legacyThumbs.length && !legacyArchives.length) return;
const drop: string[] = [...legacyThumbs];
for (const key of legacyArchives) {
const data = await idb.get(key);
drop.push(key);
if (data && typeof data === 'object' && 'posts' in data && !(data as CacheData).isLocal) {
// Server archives keep working: their URLs are plain HTTP paths.
await setCachedArchive({ ...(data as CacheData), name: key });
}
}
await idb.delMany(drop);
console.log(`[Cache] Migrated legacy cache: dropped ${drop.length} stale keys.`);
};
/**
* Rebuild a server archive's media URLs, which are stable HTTP paths.
*
* `path` is relative to the archives root and already carries the source
* directory (which may be a sidecar such as `story - user`), so it is not
* prefixed with the archive name. Entries cached before `path` existed fall
* back to their stored URL.
*/
const rehydrateRemote = (posts: Post[]): Post[] =>
posts.map(post => {
const media = post.media.map(m => ({
...m,
url: m.path ? `/archives/${encodeURI(m.path)}` : m.url,
}));
return { ...post, media, thumbnail: media[0]?.url ?? post.thumbnail };
});
/**
* Rebuild a local archive's media URLs from a live directory handle, minting
* fresh blob: URLs for the paths recorded at scan time.
*
* Returns null when the folder is no longer reachable (permission declined, or
* the handle no longer resolves), signalling the caller to re-prompt.
*/
const rehydrateLocal = async (
posts: Post[],
handle: DirectoryHandle,
onUrl: (url: string) => void,
): Promise<Post[] | null> => {
if (!(await ensureReadPermission(handle))) return null;
let files: LocalArchiveFile[];
try {
files = await filesFromDirectory(handle);
} catch (err) {
console.warn('[Cache] Directory handle no longer readable:', err);
return null;
}
const byPath = new Map(files.map(f => [f.webkitRelativePath, f]));
return posts.map(post => {
const media = post.media.map(m => {
const file = byPath.get(m.path);
if (!file) return { ...m, url: '' };
const url = file.createObjectUrl(m.type === 'video' ? 'video/mp4' : 'image/jpeg');
onUrl(url);
return { ...m, url };
});
return { ...post, media, thumbnail: media[0]?.url ?? '' };
});
};
export interface RestoredArchive {
posts: Post[];
stories: Post[];
highlights: Post[];
profileMetadata: CacheData['profileMetadata'];
}
/**
* Turn a cache entry back into displayable state.
*
* `onUrl` receives every blob: URL minted so the caller can revoke them later.
* Returns null if a local archive's folder can no longer be reached.
*/
export const restoreArchive = async (
data: CacheData,
onUrl: (url: string) => void,
): Promise<RestoredArchive | null> => {
if (!data.isLocal) {
return {
posts: rehydrateRemote(data.posts),
stories: rehydrateRemote(data.stories),
highlights: rehydrateRemote(data.highlights ?? []),
profileMetadata: data.profileMetadata,
};
}
const handle = await getDirectoryHandle(data.name);
if (!handle) return null;
const posts = await rehydrateLocal(data.posts, handle, onUrl);
if (!posts) return null;
const stories = (await rehydrateLocal(data.stories, handle, onUrl)) ?? [];
const highlights = (await rehydrateLocal(data.highlights ?? [], handle, onUrl)) ?? [];
return { posts, stories, highlights, profileMetadata: data.profileMetadata };
};
+38 -4
View File
@@ -1,21 +1,51 @@
import { ArchiveFile } from '../types'; import { ArchiveFile, ArchiveSource } from '../types';
export class LocalArchiveFile implements ArchiveFile { export class LocalArchiveFile implements ArchiveFile {
constructor(private file: File) {} /** Blob URLs minted here are revocable and must be released when done. */
readonly revocable = true;
/**
* @param explicitPath Set when the file came from the File System Access API,
* whose File objects carry an empty webkitRelativePath.
*/
constructor(private file: File, private explicitPath?: string) {}
get name() { return this.file.name; } get name() { return this.file.name; }
get webkitRelativePath() { return this.file.webkitRelativePath; } get webkitRelativePath() { return this.explicitPath ?? this.file.webkitRelativePath; }
get size() { return this.file.size; } get size() { return this.file.size; }
text() { return this.file.text(); } text() { return this.file.text(); }
arrayBuffer() { return this.file.arrayBuffer(); } arrayBuffer() { return this.file.arrayBuffer(); }
stream() { return this.file.stream(); } stream() { return this.file.stream(); }
/**
* A blob: URL backed directly by the on-disk File.
*
* Deliberately does NOT go through arrayBuffer() a File is already a Blob,
* so this hands the browser a disk-backed handle instead of pulling the whole
* file into memory. Doing otherwise means a 20GB archive tries to become 20GB
* of resident blobs.
*
* When the picker gave us no MIME type, slice() re-tags the blob with a hint.
* slice() is a zero-copy view, so this stays memory-free either way.
*/
createObjectUrl(mimeHint?: string) {
const source = this.file.type || !mimeHint
? this.file
: this.file.slice(0, this.file.size, mimeHint);
return URL.createObjectURL(source);
}
} }
export class RemoteArchiveFile implements ArchiveFile { export class RemoteArchiveFile implements ArchiveFile {
/** Served over HTTP; there is no object URL to release. */
readonly revocable = false;
constructor( constructor(
public name: string, public name: string,
public webkitRelativePath: string, public webkitRelativePath: string,
public size: number, public size: number,
public url: string public url: string,
public source?: ArchiveSource,
public mtime?: number
) {} ) {}
async text() { async text() {
const res = await fetch(this.url); const res = await fetch(this.url);
@@ -33,4 +63,8 @@ export class RemoteArchiveFile implements ArchiveFile {
}); });
return transform.readable; return transform.readable;
} }
createObjectUrl() {
return this.url;
}
} }
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { classifyDirectory, groupArchiveDirectories } from './archive-grouping';
describe('classifyDirectory', () => {
it('treats a bare profile directory as the base', () => {
expect(classifyDirectory('4utumn07')).toEqual({
owner: '4utumn07',
source: { kind: 'posts', dir: '4utumn07' },
});
});
it('recognises a reels sidecar', () => {
expect(classifyDirectory('4utumn07 - reels')).toEqual({
owner: '4utumn07',
source: { kind: 'reels', dir: '4utumn07 - reels' },
});
});
it('recognises a stories sidecar', () => {
expect(classifyDirectory('story - dawn_petal')).toEqual({
owner: 'dawn_petal',
source: { kind: 'stories', dir: 'story - dawn_petal' },
});
});
it('splits highlight owner from title', () => {
const { owner, source } = classifyDirectory('story highlights - 4utumn07 - Sunstory');
expect(owner).toBe('4utumn07');
expect(source.kind).toBe('highlight');
expect(source.title).toBe('Sunstory');
});
it.each([
['story highlights - theoldlyricmuseinsta - 💙1999-2005 era', 'theoldlyricmuseinsta', '💙1999-2005 era'],
['story highlights - member_theworld - [Bracket]', 'member_theworld', '[Bracket]'],
['story highlights - official_band - Tour Schedule', 'official_band', 'Tour Schedule'],
['story highlights - 4utumn07 - Sketching', '4utumn07', 'Sketching'],
['story highlights - official_band - A.B.C', 'official_band', 'A.B.C'],
])('handles real-world title %s', (dir, owner, title) => {
const result = classifyDirectory(dir);
expect(result.owner).toBe(owner);
expect(result.source.title).toBe(title);
});
it('keeps titles containing " - " intact', () => {
// The username is matched as a non-space run, so only the first separator
// splits owner from title.
const { owner, source } = classifyDirectory('story highlights - user - a - b');
expect(owner).toBe('user');
expect(source.title).toBe('a - b');
});
it('does not mistake a profile with spaces for a sidecar', () => {
expect(classifyDirectory('Heejin_Bubble heejinmedia').source.kind).toBe('posts');
});
});
describe('groupArchiveDirectories', () => {
const dirs = [
'4utumn07',
'4utumn07 - reels',
'story - 4utumn07',
'story highlights - 4utumn07 - Sunstory',
'story highlights - 4utumn07 - Sketching',
'kestrelsings',
];
it('folds sidecars into their base profile', () => {
const groups = groupArchiveDirectories(dirs);
expect([...groups.keys()].sort()).toEqual(['4utumn07', 'kestrelsings']);
expect(groups.get('4utumn07')).toHaveLength(5);
expect(groups.get('kestrelsings')).toHaveLength(1);
});
it('orders sources posts, reels, stories, then highlights by title', () => {
const sources = groupArchiveDirectories(dirs).get('4utumn07')!;
expect(sources.map(s => s.kind)).toEqual(['posts', 'reels', 'stories', 'highlight', 'highlight']);
expect(sources.slice(3).map(s => s.title)).toEqual(['Sketching', 'Sunstory']);
});
it('still groups a sidecar whose base profile is missing', () => {
const groups = groupArchiveDirectories(['story - orphan']);
expect(groups.get('orphan')).toEqual([{ kind: 'stories', dir: 'story - orphan' }]);
});
it('is stable for an empty archive root', () => {
expect(groupArchiveDirectories([]).size).toBe(0);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Archive directory naming rules.
*
* Shared by the server (to fold sidecar directories into one profile) and the
* test suite. Kept free of Node built-ins so it can be imported from either.
*/
export type SourceKind = 'posts' | 'reels' | 'stories' | 'highlight';
export interface ArchiveSource {
kind: SourceKind;
/** Directory name relative to the archives root. */
dir: string;
/** Highlight title, for kind === 'highlight'. */
title?: string;
}
/**
* Sidecar directories sit next to the profile directory they belong to:
*
* 4utumn07 -> posts (base)
* 4utumn07 - reels -> reels
* story - 4utumn07 -> stories
* story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
*
* Instagram usernames cannot contain spaces, so matching the username as a
* run of non-space characters reliably separates it from a highlight title
* (titles may themselves contain spaces, dashes and emoji).
*/
export const classifyDirectory = (dirName: string): { owner: string; source: ArchiveSource } => {
const highlight = /^story highlights - ([^ ]+) - (.+)$/.exec(dirName);
if (highlight) {
return { owner: highlight[1], source: { kind: 'highlight', dir: dirName, title: highlight[2] } };
}
const stories = /^story - ([^ ]+)$/.exec(dirName);
if (stories) {
return { owner: stories[1], source: { kind: 'stories', dir: dirName } };
}
const reels = /^([^ ]+) - reels$/.exec(dirName);
if (reels) {
return { owner: reels[1], source: { kind: 'reels', dir: dirName } };
}
return { owner: dirName, source: { kind: 'posts', dir: dirName } };
};
const RANK: Record<SourceKind, number> = { posts: 0, reels: 1, stories: 2, highlight: 3 };
/**
* Group the archive root's directories by profile.
*
* A sidecar whose owner has no base directory still forms a group of its own,
* so nothing becomes invisible just because the base profile is missing.
*/
export const groupArchiveDirectories = (dirNames: string[]): Map<string, ArchiveSource[]> => {
const groups = new Map<string, ArchiveSource[]>();
for (const dirName of dirNames) {
const { owner, source } = classifyDirectory(dirName);
if (!groups.has(owner)) groups.set(owner, []);
groups.get(owner)!.push(source);
}
for (const sources of groups.values()) {
sources.sort((a, b) => RANK[a.kind] - RANK[b.kind] || (a.title ?? '').localeCompare(b.title ?? ''));
}
return groups;
};
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { isSystemDirectory } from './archive-index';
describe('isSystemDirectory', () => {
it.each([
['@eaDir', 'Synology thumbnail/index metadata, written inside every folder'],
['@tmp', 'Synology scratch'],
['.sync', 'Resilio state'],
['.DS_Store', 'macOS'],
['#recycle', 'Synology deletions'],
['#snapshot', 'Synology snapshots'],
])('skips %s (%s)', name => {
expect(isSystemDirectory(name)).toBe(true);
});
it.each([
'4utumn07',
'4utumn07 - reels',
'story - dawn_petal',
'story highlights - official_band - A.B.C',
'story highlights - theoldlyricmuseinsta - 💙1999-2005 era',
'Heejin_Bubble heejinmedia',
'gallery-dl',
'posts',
])('keeps %s', name => {
expect(isSystemDirectory(name)).toBe(false);
});
it('does not treat a leading underscore as a system directory', () => {
// `_gemini-plans` is filtered separately at the archive root only; nothing
// below the root should be excluded just for starting with an underscore.
expect(isSystemDirectory('_gemini-plans')).toBe(false);
});
});
+248
View File
@@ -0,0 +1,248 @@
import fs from 'fs';
import fsp from 'fs/promises';
import path from 'path';
import { ArchiveSource, SourceKind, groupArchiveDirectories } from './archive-grouping.js';
/**
* On-disk archive index.
*
* Archives live on network storage where per-file `stat` costs ~1.4ms and does
* not parallelise well, so walking every file on each request is unaffordable:
* measured against a real 110k-file archive root, listing took ~52s.
*
* Directory `stat` is effectively free, so each source directory is indexed
* once and re-used until its mtime changes. The index is warmed in the
* background at startup and persisted, making steady-state requests instant.
*/
export interface IndexedFile {
path: string;
size: number;
mtime: number;
kind: SourceKind;
title?: string;
}
interface DirIndex {
dir: string;
/** Directory mtime the index was built from; the cache key. */
mtimeMs: number;
files: IndexedFile[];
}
const MEDIA_RE = /\.(jpg|jpeg|png|webp|gif|bmp|tiff|mp4|webm|ogv|mov)$/i;
const STAT_CONCURRENCY = 16;
/**
* Directories the walk must never descend into.
*
* NAS filesystems scatter sidecar metadata *inside* every folder, not just at
* the share root: Synology writes `@eaDir` (thumbnails and indexing data),
* `#recycle` holds deletions, and `.sync` is Resilio's state. Indexing those
* would count NAS thumbnails as archive media and spend a stat on each one
* measured on a real share, `@eaDir` accounted for 12,516 of 123,023 files.
*
* The archive root is already filtered by prefix; this is the same rule applied
* at every level below it.
*/
export const isSystemDirectory = (name: string): boolean =>
name.startsWith('@') || name.startsWith('.') || name === '#recycle' || name === '#snapshot';
export class ArchiveIndex {
private dirs = new Map<string, DirIndex>();
private inFlight = new Map<string, Promise<DirIndex>>();
private dirty = false;
constructor(private archivesDir: string, private cachePath: string) {}
/** Visible (non-system) directories at the archive root. */
private listRootDirs(): string[] {
return fs.readdirSync(this.archivesDir, { withFileTypes: true })
.filter(e => e.isDirectory() && !isSystemDirectory(e.name) && !e.name.startsWith('_'))
.map(e => e.name);
}
groups(): Map<string, ArchiveSource[]> {
return groupArchiveDirectories(this.listRootDirs());
}
private dirMtime(dir: string): number {
try {
return fs.statSync(path.join(this.archivesDir, dir)).mtimeMs;
} catch {
return 0;
}
}
/** Recursively list relative file paths without stat()ing them. */
private walk(absDir: string, base = ''): string[] {
let out: string[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(absDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (isSystemDirectory(entry.name)) continue;
const rel = base ? `${base}/${entry.name}` : entry.name;
if (entry.isDirectory()) out = out.concat(this.walk(path.join(absDir, entry.name), rel));
else if (entry.isFile()) out.push(rel);
}
return out;
}
private async buildDir(source: ArchiveSource): Promise<DirIndex> {
const started = Date.now();
const mtimeMs = this.dirMtime(source.dir);
const absDir = path.join(this.archivesDir, source.dir);
const relPaths = this.walk(absDir);
// Only media needs a size (the client thumbnails anything over 1MiB), and
// only highlights need an mtime (their filenames carry no date). Skipping
// the rest avoids thousands of pointless round trips.
const needsStat = (rel: string) => MEDIA_RE.test(rel) || source.kind === 'highlight';
const files: IndexedFile[] = relPaths.map(rel => ({
path: `${source.dir}/${rel}`,
size: 0,
mtime: 0,
kind: source.kind,
...(source.title ? { title: source.title } : {}),
}));
const targets = files.filter((_, i) => needsStat(relPaths[i]));
let cursor = 0;
const worker = async () => {
while (cursor < targets.length) {
const file = targets[cursor++];
try {
const stat = await fsp.stat(path.join(this.archivesDir, file.path));
file.size = stat.size;
file.mtime = stat.mtimeMs;
} catch { /* raced with a delete */ }
}
};
await Promise.all(Array.from({ length: STAT_CONCURRENCY }, worker));
console.log(
`[Index] ${source.dir}: ${files.length} files (${targets.length} statted) in ${((Date.now() - started) / 1000).toFixed(1)}s`
);
this.dirty = true;
return { dir: source.dir, mtimeMs, files };
}
/** Index for one source directory, rebuilding only if its mtime moved. */
private async ensureDir(source: ArchiveSource): Promise<DirIndex> {
const cached = this.dirs.get(source.dir);
const mtimeMs = this.dirMtime(source.dir);
if (cached && cached.mtimeMs === mtimeMs) return cached;
// Collapse concurrent requests for the same directory into one walk.
const existing = this.inFlight.get(source.dir);
if (existing) return existing;
const build = this.buildDir(source).then(index => {
this.dirs.set(source.dir, index);
this.inFlight.delete(source.dir);
return index;
}).catch(err => {
this.inFlight.delete(source.dir);
throw err;
});
this.inFlight.set(source.dir, build);
return build;
}
/** All files for one profile, across its base and sidecar directories. */
async filesFor(owner: string): Promise<IndexedFile[] | null> {
const sources = this.groups().get(owner);
if (!sources?.length) return null;
const indexes = await Promise.all(sources.map(s => this.ensureDir(s)));
return indexes.flatMap(i => i.files);
}
/**
* A cheap change signature for a profile, used by the client to decide
* whether its cached copy is stale. Built from directory mtimes only, so it
* costs one stat per source directory rather than a full walk.
*/
signatureFor(sources: ArchiveSource[]): string {
return sources.map(s => `${s.dir}:${this.dirMtime(s.dir)}`).join('|');
}
/** File count for a profile, if its directories are already indexed. */
countFor(sources: ArchiveSource[]): number | null {
let total = 0;
for (const source of sources) {
const cached = this.dirs.get(source.dir);
if (!cached) return null;
total += cached.files.length;
}
return total;
}
/**
* Best-effort profile picture.
*
* Probes the conventional filenames first (one stat each) and only falls back
* to the indexed listing, so an unindexed archive still gets a thumbnail
* without triggering a walk.
*/
thumbnailFor(owner: string, sources: ArchiveSource[]): string {
const base = sources.find(s => s.kind === 'posts') ?? sources[0];
if (!base) return '';
for (const candidate of [`${owner}.jpg`, `${owner}_profile_pic.jpg`, `${owner}.jpeg`, `${owner}.png`]) {
if (fs.existsSync(path.join(this.archivesDir, base.dir, candidate))) {
return `/archives/${encodeURI(`${base.dir}/${candidate}`)}`;
}
}
const cached = this.dirs.get(base.dir);
if (cached) {
const pick = cached.files.find(f => /_profile_pic\.jpg$/i.test(f.path))
?? cached.files.find(f => /\.(jpg|jpeg|png|webp)$/i.test(f.path));
if (pick) return `/archives/${encodeURI(pick.path)}`;
}
return '';
}
/** Walk every directory once, in the background, so first opens are fast. */
async warm(): Promise<void> {
const started = Date.now();
const sources = [...this.groups().values()].flat();
console.log(`[Index] Warming ${sources.length} source directories...`);
for (const source of sources) {
try {
await this.ensureDir(source);
} catch (err) {
console.error(`[Index] Failed to index ${source.dir}:`, err);
}
}
await this.save();
console.log(`[Index] Warm complete in ${((Date.now() - started) / 1000).toFixed(1)}s`);
}
async load(): Promise<void> {
try {
const raw = await fsp.readFile(this.cachePath, 'utf8');
const parsed: DirIndex[] = JSON.parse(raw);
for (const entry of parsed) this.dirs.set(entry.dir, entry);
console.log(`[Index] Loaded ${this.dirs.size} directories from ${this.cachePath}`);
} catch {
console.log('[Index] No usable index cache; will build from scratch.');
}
}
async save(): Promise<void> {
if (!this.dirty) return;
try {
await fsp.writeFile(this.cachePath, JSON.stringify([...this.dirs.values()]), 'utf8');
this.dirty = false;
console.log(`[Index] Persisted ${this.dirs.size} directories to ${this.cachePath}`);
} catch (err) {
console.warn('[Index] Could not persist index (continuing in memory):', err);
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import { parseArchiveFilename, scopedPostId } from './archive-patterns';
describe('parseArchiveFilename — Instagram export format', () => {
it('parses a single-image post', () => {
expect(parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM.mp4')).toEqual({
postId: 'CrORBIcJJbM',
date: '2023-04-19',
username: '4utumn07',
index: 1,
ext: 'mp4',
isStory: false,
});
});
it('parses a carousel slide index', () => {
const parsed = parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE - 3.jpg');
expect(parsed).toMatchObject({ postId: 'Cq8LrxSJAJE', index: 3, ext: 'jpg' });
});
it('groups a carousel under one post id', () => {
const ids = ['1', '2', '3'].map(
n => parseArchiveFilename(`2023-04-12_user - Cq8LrxSJAJE - ${n}.jpg`)!.postId,
);
expect(new Set(ids).size).toBe(1);
});
it('parses caption sidecar files', () => {
expect(parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE.txt')).toMatchObject({
postId: 'Cq8LrxSJAJE',
ext: 'txt',
});
});
it('flags an explicit story suffix', () => {
expect(parseArchiveFilename('2023-04-12_user - ABC - story.jpg')?.isStory).toBe(true);
});
it('parses the story sidecar layout (date_user - N - shortcode)', () => {
// Files in `story - <user>` carry a per-day ordinal before the shortcode.
const parsed = parseArchiveFilename('2025-10-26_4utumn07 - 2 - DQRuDx9iW5Q.jpg', 'stories');
expect(parsed).toMatchObject({ date: '2025-10-26', username: '4utumn07', ext: 'jpg' });
expect(parsed!.postId).toContain('DQRuDx9iW5Q');
});
it('gives each story item a distinct id', () => {
const a = parseArchiveFilename('2026-08-13_u - 1 - Db-UTJcCUUr.mp4', 'stories')!.postId;
const b = parseArchiveFilename('2026-08-13_u - 2 - Db-oNJ1CWQ4.mp4', 'stories')!.postId;
expect(a).not.toBe(b);
});
});
describe('parseArchiveFilename — Instaloader format', () => {
it('parses a timestamped filename', () => {
expect(parseArchiveFilename('2024-01-01_12-00-00_UTC.jpg')).toMatchObject({
postId: '2024-01-01_12-00-00_UTC',
date: '2024-01-01',
index: 1,
});
});
it('parses the carousel suffix', () => {
expect(parseArchiveFilename('2024-01-01_12-00-00_UTC_2.jpg')?.index).toBe(2);
});
it('flags the story suffix', () => {
expect(parseArchiveFilename('2024-01-01_12-00-00_UTC_story.jpg')?.isStory).toBe(true);
});
});
describe('parseArchiveFilename — story highlights', () => {
it('parses the dateless highlight layout', () => {
expect(parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
postId: 'C5dQPEYpd9W',
username: '4utumn07',
ext: 'mp4',
isStory: false,
});
});
it('dates a highlight from mtime when the filename has none', () => {
const mtime = Date.UTC(2024, 4, 17, 12, 0, 0);
expect(parseArchiveFilename('user - ABC.jpg', 'highlight', mtime)?.date).toBe('2024-05-17');
});
it('leaves the date empty when no mtime is available', () => {
expect(parseArchiveFilename('user - ABC.jpg', 'highlight')?.date).toBe('');
});
it('does not apply the loose highlight pattern outside highlight directories', () => {
// Would otherwise swallow ordinary "a - b.jpg" filenames.
expect(parseArchiveFilename('user - ABC.jpg', 'posts')).toBeNull();
});
});
describe('parseArchiveFilename — non-matching files', () => {
it.each(['4utumn07.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
'returns null for %s',
name => expect(parseArchiveFilename(name)).toBeNull(),
);
});
describe('scopedPostId', () => {
it('leaves base-profile ids untouched so permalinks keep working', () => {
expect(scopedPostId('Cq8LrxSJAJE', 'posts')).toBe('Cq8LrxSJAJE');
});
it('namespaces sidecar ids by directory', () => {
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Sunstory'))
.toBe('story highlights - u - Sunstory/C5dQ');
});
it('keeps the same shortcode distinct across sources', () => {
const inPosts = scopedPostId('ABC', 'posts');
const inHighlight = scopedPostId('ABC', 'highlight', 'story highlights - u - H');
expect(inPosts).not.toBe(inHighlight);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { SourceKind } from '../types';
/**
* Filename parsing rules for the archive formats the viewer understands.
*
* Kept as pure functions so the riskiest part of the scanner deriving post
* identity, date and carousel order from a filename can be tested directly.
*/
/** Instagram export: `2023-04-12_user - Cq8LrxSJAJE - 2.jpg` */
export const EXPORT_RE = /^(\d{4}-\d{2}-\d{2})_(.+?) - (.+?)(?: - (\d+))?(?: - (story))?\.(.+)$/;
/** Instaloader: `2024-01-01_12-00-00_UTC_2.jpg` */
export const INSTALOADER_RE = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_UTC)(?:_(\d+))?(?:_(story))?\.(.+)$/;
/**
* Story highlight: `user - C5dQPEYpd9W.mp4` no date prefix.
*
* Loose enough to match ordinary filenames, so it is only applied to files the
* server has already tagged as coming from a highlight directory.
*/
export const HIGHLIGHT_RE = /^(.+?) - ([A-Za-z0-9_-]+)\.(\w+)$/;
export interface ParsedFilename {
postId: string;
/** ISO date (YYYY-MM-DD), or '' when the filename carries none. */
date: string;
username: string;
/** 1-based carousel position. */
index: number;
ext: string;
isStory: boolean;
}
/**
* Parse an archive filename into post identity.
*
* `kind` selects which patterns apply; `mtime` supplies a date for formats that
* have none (highlights), so those items still sort and render sensibly.
* Returns null when no pattern matches e.g. a profile picture.
*/
export const parseArchiveFilename = (
fileName: string,
kind: SourceKind = 'posts',
mtime?: number,
): ParsedFilename | null => {
const exp = EXPORT_RE.exec(fileName);
if (exp) {
const [, date, username, postId, indexStr, story, ext] = exp;
return {
postId,
date,
username,
index: indexStr ? parseInt(indexStr, 10) : 1,
ext,
isStory: Boolean(story),
};
}
const ins = INSTALOADER_RE.exec(fileName);
if (ins) {
const [, postId, indexStr, story, ext] = ins;
return {
postId,
date: postId.split('_')[0],
username: '',
index: indexStr ? parseInt(indexStr, 10) : 1,
ext,
isStory: Boolean(story),
};
}
if (kind === 'highlight') {
const hl = HIGHLIGHT_RE.exec(fileName);
if (hl) {
const [, username, shortcode, ext] = hl;
return {
postId: shortcode,
date: mtime ? new Date(mtime).toISOString().split('T')[0] : '',
username,
index: 1,
ext,
isStory: false,
};
}
}
return null;
};
/**
* Namespace a post ID by its source directory.
*
* Base-profile IDs are left untouched so existing permalinks keep working;
* sidecar IDs are prefixed so a shortcode appearing in both the profile and a
* highlight stays two distinct posts.
*/
export const scopedPostId = (postId: string, kind: SourceKind, dir?: string): string =>
kind === 'posts' ? postId : `${dir ?? kind}/${postId}`;
+86
View File
@@ -0,0 +1,86 @@
import { LocalArchiveFile } from './archive-files';
/**
* File System Access API helpers.
*
* A `blob:` URL dies with the document, so a cached local archive whose media
* URLs are blob: URLs is worthless after a reload. A FileSystemDirectoryHandle,
* by contrast, is structured-cloneable and survives in IndexedDB so we can
* re-open the same folder on a return visit and mint fresh URLs from it.
*
* Only Chromium implements showDirectoryPicker today; callers must handle the
* unsupported case by falling back to the <input webkitdirectory> flow.
*/
// Minimal typings — TS's lib.dom does not ship these in the configured version.
type PermissionState = 'granted' | 'denied' | 'prompt';
interface FileSystemHandlePermissionDescriptor { mode?: 'read' | 'readwrite' }
export interface DirectoryHandle {
name: string;
kind: 'directory';
values(): AsyncIterableIterator<DirectoryHandle | FileHandle>;
queryPermission?(d?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
requestPermission?(d?: FileSystemHandlePermissionDescriptor): Promise<PermissionState>;
}
interface FileHandle {
name: string;
kind: 'file';
getFile(): Promise<File>;
}
export const isDirectoryPickerSupported = () =>
typeof window !== 'undefined' && 'showDirectoryPicker' in window;
export const pickDirectory = async (): Promise<DirectoryHandle | null> => {
if (!isDirectoryPickerSupported()) return null;
try {
return await (window as any).showDirectoryPicker({ mode: 'read' });
} catch (err) {
// AbortError simply means the user dismissed the picker.
return null;
}
};
/**
* Confirm we may still read this handle. Returns false when the user declines
* or the grant has lapsed, in which case the caller should re-prompt.
*
* `requestPermission` must be called from a user gesture, so only call this
* while handling a click.
*/
export const ensureReadPermission = async (handle: DirectoryHandle): Promise<boolean> => {
try {
if (!handle.queryPermission) return true;
if ((await handle.queryPermission({ mode: 'read' })) === 'granted') return true;
if (!handle.requestPermission) return false;
return (await handle.requestPermission({ mode: 'read' })) === 'granted';
} catch {
return false;
}
};
/**
* Recursively collect every file in the directory.
*
* Paths are prefixed with the root directory's name so they line up with the
* `webkitRelativePath` values produced by <input webkitdirectory>, keeping
* cached media paths valid regardless of which picker created them.
*/
export const filesFromDirectory = async (handle: DirectoryHandle): Promise<LocalArchiveFile[]> => {
const out: LocalArchiveFile[] = [];
const walk = async (dir: DirectoryHandle, prefix: string) => {
for await (const entry of dir.values()) {
const entryPath = `${prefix}/${entry.name}`;
if (entry.kind === 'directory') {
await walk(entry as DirectoryHandle, entryPath);
} else {
const file = await (entry as FileHandle).getFile();
out.push(new LocalArchiveFile(file, entryPath));
}
}
};
await walk(handle, handle.name);
return out;
};
+44
View File
@@ -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;
+114
View File
@@ -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');
});
});
+85
View File
@@ -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}/`;
};
+42
View File
@@ -0,0 +1,42 @@
/**
* Thumbnail Generation Worker
* Uses OffscreenCanvas and createImageBitmap for high-performance,
* background-thread image resizing.
*/
self.onmessage = async (e: MessageEvent) => {
const { id, blob, width } = e.data;
try {
// 1. Create a bitmap from the blob (native browser decoding)
// We resize it DURING the decode step for maximum efficiency
const bitmap = await createImageBitmap(blob, {
resizeWidth: width,
resizeQuality: 'medium'
});
// 2. Use OffscreenCanvas to draw the resized bitmap
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get OffscreenCanvas context');
}
ctx.drawImage(bitmap, 0, 0);
// 3. Convert to a small JPEG blob
const thumbnailBlob = await canvas.convertToBlob({
type: 'image/jpeg',
quality: 0.7
});
// 4. Release bitmap memory
bitmap.close();
// 5. Send result back
self.postMessage({ id, blob: thumbnailBlob });
} catch (err: any) {
self.postMessage({ id, error: err.message });
}
};
+20
View File
@@ -1,6 +1,26 @@
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { format, parseISO } from 'date-fns';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/**
* Format an archive date, tolerating junk.
*
* Dates are derived from filenames and arbitrary archive JSON, and date-fns
* `format` throws a RangeError on an invalid date which would take down the
* whole modal for one malformed name. Story highlights in particular carry no
* date at all when file mtimes are unavailable.
*/
export function formatDateSafe(date: string | undefined, pattern: string): string {
if (!date) return '';
try {
const parsed = parseISO(date);
if (Number.isNaN(parsed.getTime())) return '';
return format(parsed, pattern);
} catch {
return '';
}
}
+4 -1
View File
@@ -1,6 +1,7 @@
import {StrictMode} from 'react'; import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client'; import {createRoot} from 'react-dom/client';
import App from './App.tsx'; import App from './App.tsx';
import { ErrorBoundary } from './components/ErrorBoundary';
import './index.css'; import './index.css';
import { registerSW } from 'virtual:pwa-register'; import { registerSW } from 'virtual:pwa-register';
@@ -26,6 +27,8 @@ const updateSW = registerSW({
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>, </StrictMode>,
); );
+88
View File
@@ -1,8 +1,30 @@
export interface MediaFile { export interface MediaFile {
name: string; name: string;
/**
* Path relative to the archive root (matching webkitRelativePath for local
* folders). Unlike `url`, this survives a page reload, so it is what the
* cache persists and what URLs are rehydrated from.
*/
path: string;
url: string; url: string;
type: 'image' | 'video'; type: 'image' | 'video';
index: number; index: number;
size?: number;
}
/**
* Which sidecar directory a post came from. Archives store reels, stories and
* each story highlight in directories alongside the base profile; the viewer
* folds them into one profile and routes them by kind.
*/
export type SourceKind = 'posts' | 'reels' | 'stories' | 'highlight';
export interface ArchiveSource {
kind: SourceKind;
/** Directory name relative to the archives root. */
dir: string;
/** Highlight title, for kind === 'highlight'. */
title?: string;
} }
export interface Post { export interface Post {
@@ -13,6 +35,10 @@ export interface Post {
media: MediaFile[]; media: MediaFile[];
thumbnail: string; thumbnail: string;
isStory?: boolean; isStory?: boolean;
/** Defaults to 'posts' for archives without sidecar directories. */
source?: SourceKind;
/** Highlight this post belongs to, for source === 'highlight'. */
highlightTitle?: string;
} }
/** /**
@@ -26,11 +52,73 @@ export interface ArchiveFile {
arrayBuffer(): Promise<ArrayBuffer>; arrayBuffer(): Promise<ArrayBuffer>;
stream(): ReadableStream<Uint8Array>; stream(): ReadableStream<Uint8Array>;
url?: string; url?: string;
/**
* A URL pointing at this file's contents. Local files mint a disk-backed
* blob: URL (no data is read into memory); remote files return their HTTP URL.
*/
createObjectUrl(mimeHint?: string): string;
/** True when createObjectUrl() returns a blob: URL that must be revoked. */
readonly revocable: boolean;
/** Which sidecar directory this file came from, when known. */
source?: ArchiveSource;
/** Last-modified time (ms). Used to date items whose filename has no date. */
mtime?: number;
} }
export interface ServerArchive { export interface ServerArchive {
name: string; name: string;
thumbnail: string; thumbnail: string;
path: string; path: string;
/** Null until the server has indexed this profile. */
fileCount: number | null;
/**
* Directory-mtime signature. Cheap for the server to compute and sufficient
* to detect changes, unlike a file count that would require a full walk.
*/
signature?: string;
/** Base profile plus any sidecar directories folded into it. */
sources?: ArchiveSource[];
}
/** One entry from GET /api/archives/:name/files. */
export interface ServerArchiveFile {
path: string;
size: number;
mtime: number;
kind: SourceKind;
title?: string;
}
export interface ProfileMetadata {
username: string;
fullName: string;
bio: string;
followerCount: number;
followingCount: number;
externalUrl: string;
profilePic: string | null;
allProfilePics: string[];
}
/** Shape of an archive entry persisted in IndexedDB. */
export interface CacheData {
name: string;
isLocal: boolean;
fileCount: number; fileCount: number;
/** Server archives: the signature this entry was built from. */
signature?: string;
posts: Post[];
stories: Post[];
/** Story-highlight items, grouped by `highlightTitle`. */
highlights?: Post[];
profileMetadata: ProfileMetadata;
timestamp: number;
/**
* Local archives only: whether a FileSystemDirectoryHandle was stored
* alongside this entry, meaning media URLs can be rehydrated without
* re-prompting for the folder.
*/
hasDirectoryHandle?: boolean;
/** Path of the profile picture, for rehydration (local archives). */
profilePicPath?: string;
} }
+12
View File
@@ -0,0 +1,12 @@
import 'react';
declare module 'react' {
interface InputHTMLAttributes<T> {
/**
* Non-standard attribute that makes a file input select a whole directory.
* Supported in Chromium and WebKit; used as the fallback picker where the
* File System Access API is unavailable.
*/
webkitdirectory?: string;
}
}
+8 -25
View File
@@ -1,11 +1,10 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import {defineConfig, loadEnv} from 'vite'; import {defineConfig} from 'vite';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig(({mode}) => { export default defineConfig(() => {
const env = loadEnv(mode, '.', '');
return { return {
plugins: [ plugins: [
react(), react(),
@@ -21,42 +20,26 @@ export default defineConfig(({mode}) => {
display: 'standalone', display: 'standalone',
icons: [ icons: [
{ {
src: 'https://cdn-icons-png.flaticon.com/512/174/174855.png', src: '/icon-512.png',
sizes: '512x512', sizes: '512x512',
type: 'image/png', type: 'image/png',
purpose: 'any maskable' purpose: 'any maskable'
}, },
{ {
src: 'https://cdn-icons-png.flaticon.com/192/174/174855.png', src: '/icon-192.png',
sizes: '192x192', sizes: '192x192',
type: 'image/png' type: 'image/png'
} }
] ]
}, },
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'], navigateFallbackDenylist: [/^\/api/, /^\/archives/],
runtimeCaching: [ // Fonts and icons are bundled locally, so everything the shell needs
{ // is precached and no runtime third-party caching rule is required.
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}']
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
} }
}) })
], ],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, '.'), '@': path.resolve(__dirname, '.'),