15 Commits
v1.1.1 ... main

Author SHA1 Message Date
ergosteur
b30285fe70 docs: update documentation for high-res performance and local persistence
Key changes:
- Updated README.md and GEMINI.md with details on background thumbnailing and inter-post preloading.
- Documented persistent local archive caching and smart profile fallback features.
- Added dist-server/ to .gitignore.
- Restored missing feature descriptions and troubleshooting tips in README.
2026-03-07 21:59:43 -05:00
ergosteur
20209bcad5 feat: enable persistent local archives and smart profile fallback
Key changes:
- Enabled full metadata caching for local folder archives, allowing them to load instantly from IndexedDB without re-uploading.
- Implemented oldest-image fallback for profiles missing an explicit profile picture.
- Restored folder-name-to-username detection for local archive uploads.
- Optimized scan indexing to track all image files for fallback use.
2026-03-07 21:56:25 -05:00
ergosteur
74902234b3 fix: restore white glass scanning UI and resolve small image blur bug
Key changes:
- Corrected logic in PostThumbnail to prevent blur effects on images smaller than 1MiB.
- Restored the white glass aesthetic to the scanning dashboard with improved contrast and transparency.
- Optimized scanning background transitions to ensure a smooth, flicker-free crossfade.
2026-03-07 21:46:11 -05:00
ergosteur
d62bddc3aa perf: implement background thumbnail generation and inter-post preloading
Key changes:
- Added Web Worker for background image thumbnailing with a 1MiB threshold to optimize CPU/memory usage.
- Implemented a serial task queue for memory-safe high-res image processing, preventing OOM crashes.
- Added inter-post preloading in the modal for seamless 'Previous/Next' navigation.
- Refined scanning UI with double-buffering and a dark background to completely eliminate white flashes.
- Renamed project to 'instaarchive-viewer' in package.json.
- Fixed 'Open image in new tab' by denylisting /archives and /api in PWA config.
2026-03-07 21:42:56 -05:00
ergosteur
42c13ea106 chore: bump version to 1.2.0 2026-03-07 21:16:39 -05:00
ergosteur
a4e9ce16a7 feat: modularize scanner, enhance carousel preloading, and improve PWA updates
Summary of changes:
- Extracted archive scanning logic into a modular 'useArchiveScanner' hook for better maintainability and performance.
- Refined PostModal carousel with intelligent media preloading and smoother, jitter-free transitions.
- Optimized image rendering with 'decoding=async' and removed 'black flashes' between slide changes.
- Updated PWA configuration to 'autoUpdate' with hourly periodic checks for fresh content.
- Fixed several bugs including stories sorting, permalink parameter cleanup, and profile metadata cache restoration.
- Comprehensive updates to documentation (README.md and GEMINI.md) reflecting the new architecture.
2026-03-07 21:16:28 -05:00
ergosteur
4f89a69ee3 fix: optimize scanning performance and resolve zero-post bug 2026-03-07 20:25:23 -05:00
ergosteur
147dcdf2f1 fix: restore missing UI handlers and finalize generic parser 2026-03-07 20:18:33 -05:00
ergosteur
d4e20d9b98 fix: refine dockerignore and bump version to v1.1.4 2026-03-07 20:10:40 -05:00
ergosteur
ec8c771733 feat: implement permalinks and document PWA cache troubleshooting 2026-03-07 05:20:09 -05:00
ergosteur
3784e8729b debug: add verbose logging to permalink synchronization 2026-03-07 05:12:14 -05:00
ergosteur
69d62eaa5c fix: improve permalink comparison and add debug logging 2026-03-07 05:10:43 -05:00
ergosteur
767f9c508b feat: implement permalinks for archives, tabs, and posts 2026-03-07 05:07:04 -05:00
ergosteur
103ce6f207 fix: exhaustive generic parser and implement local archive history 2026-03-07 05:05:08 -05:00
ergosteur
5267dab236 fix: address Docker EACCES errors with better logging and SELinux hints 2026-03-07 03:02:02 -05:00
22 changed files with 1879 additions and 1016 deletions

View File

@@ -1,5 +1,6 @@
node_modules node_modules
dist dist
dist-server
.git .git
.github .github
_sample-archives _sample-archives

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
node_modules/ node_modules/
build/ build/
dist/ dist/
dist-server/
coverage/ coverage/
.DS_Store .DS_Store
*.log *.log

View File

@@ -5,37 +5,65 @@
**InstaArchive** is a high-performance, React-based Progressive Web App (PWA) designed to browse and explore archived Instagram data with a native-feeling interface. It supports both local directory loading and self-hosted server modes. **InstaArchive** is a high-performance, React-based Progressive Web App (PWA) designed to browse and explore archived Instagram data with a native-feeling interface. It supports both local directory loading and self-hosted server modes.
### Key Technical Features ### Key Technical Features
- **Persistent Caching:** Uses `idb-keyval` (IndexedDB) to cache parsed metadata and server-side media URLs. Subsequent loads of the same archive are instant. - **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.
- **Glassy Scanner UI:** A custom-built glassmorphism scanning dashboard with throttled (1s) dynamic blurred backgrounds and a high-density system log. - **Persistent Caching:** Uses `idb-keyval` (IndexedDB) to cache parsed metadata. Subsequent loads are near-instant.
- **Support for Multiple Formats:** Recognizes official Instagram export structures and Instaloader regex-based naming conventions. - **Metadata:** Caches profile info and post lists for both remote AND local archives.
- **Compressed Metadata:** Support for `.json.xz` file decompression using `xz-decompress` (WASM-powered). - **Thumbnails:** High-res images (>1MiB) and videos have thumbnails generated and cached in IndexedDB with the `thumb_` prefix.
- **Navigation Protection:** Intercepts browser history (`popstate`) and exit events (`beforeunload`) to prevent session loss while maintaining a "Back to Explorer" SPA flow. - **Background Media Processing:**
- **Production-Ready Docker:** Multi-stage Docker builds using `node:slim` to serve both the Express API and the Vite-built frontend. - **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:
- **Inter-Post Preloading:** Preloads the first media of adjacent posts for instant navigation.
- **Intra-Carousel Preloading:** Intelligently preloads the current carousel's slides.
- **Seamless Transitions:** Zero-latency slide transitions without "black flashes" between images.
- **Glassy Scanner UI:** Custom-built glassmorphism scanning dashboard with double-buffering logic to ensure smooth, flicker-free background crossfades during file indexing.
- **PWA Capabilities:**
- **Auto-Updates:** Hourly periodic update checks.
- **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, TypeScript - **Frontend:** React 19, Vite 6, TypeScript
- **Styling:** Tailwind CSS (v4) - **Styling:** Tailwind CSS (v4)
- **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
### State Management
- **`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).
- **`useThumbnailQueue` Hook:** Manages the serial processing of high-resolution media.
### Cache Schema
```typescript
interface CacheData {
name: string;
isLocal: boolean;
fileCount: number;
posts: Post[]; // Cached for all archive types
stories: Post[];
profileMetadata: {
username: string;
fullName: string;
bio: string;
followerCount: number;
followingCount: number;
externalUrl: string;
profilePic: string | null;
allProfilePics: string[];
};
timestamp: number;
}
```
## 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. - `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)
## Development Conventions
- **Username Logic:** The directory name is the definitive source of truth for the account username.
- **State Management:** React `useState`, `useMemo`, and `useCallback` for optimized performance.
- **File Handling:** Uses `RemoteArchiveFile` and `LocalArchiveFile` classes to provide a unified `ArchiveFile` interface for the parser.

View File

@@ -4,12 +4,16 @@ A high-performance React PWA for browsing archived Instagram data with a native-
## Features ## Features
- **Persistent Caching**: Uses IndexedDB to store parsed archives locally. Subsequent loads are near-instant. - **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.
- **Glassy Scanning UI**: A modern, translucent white terminal experience with a dynamic blurred background of your media. - **High-Res Performance**: Handles 50MP+ images effortlessly using a background Web Worker and a memory-safe serial processing queue.
- **Local Privacy**: All processing is done client-side. Even when using the self-hosted version, your media is processed locally in your browser. - **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.
- **Multiple Formats**: Supports official Instagram JSON exports and Instaloader regex-based naming conventions. - **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.
- **Story Viewer**: Native-like story experience with segmented progress bars, auto-playback, and audio controls. - **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.
- **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.
- **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
@@ -25,6 +29,8 @@ docker run -d \
ghcr.io/ergosteur/instaarchive-viewer:latest ghcr.io/ergosteur/instaarchive-viewer:latest
``` ```
> **Note for Linux/SELinux users:** If you see "Permission Denied" in the logs, append `,z` to your volume mount: `-v /path/to/archives:/archives:ro,z`
### Docker Compose ### Docker Compose
Create a `compose.yml` file: Create a `compose.yml` file:
@@ -36,13 +42,22 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
- ./archives:/archives:ro - ./archives:/archives:ro,z # ,z handles SELinux permissions
``` ```
Run with: ### Troubleshooting Permissions
```bash
docker compose up -d If the app shows "No Archives Found" and logs `EACCES: permission denied`:
```
1. **Check Directory Permissions**: Ensure the archive folder is world-readable:
```bash
chmod -R 755 /path/to/archives
```
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:
```bash
docker run --user $(id -u):$(id -g) ...
```
## Supported Archive Structure ## Supported Archive Structure
@@ -66,5 +81,6 @@ archives/
**Prerequisites:** Node.js (LTS recommended) **Prerequisites:** Node.js (LTS recommended)
1. **Install dependencies:** `npm install` 1. **Install dependencies:** `npm install`
2. **Start dev server:** `npm run dev` 2. **Start dev server:** `npm run dev` (Frontend on port 3000)
3. **Start local backend:** `npm run server` (Optional, serves `./_sample-archives`) 3. **Start local backend:** `npm run server` (Optional, serves `./_sample-archives` on port 3001)
4. **Build production:** `npm run build` (Generates `./dist` for frontend and `./dist-server` for the API)

View File

@@ -1,11 +1,10 @@
services: services:
instaarchive: instaarchive:
image: ghcr.io/${GITHUB_REPOSITORY:-ergosteur/instaarchive-viewer}:latest image: ghcr.io/ergosteur/instaarchive-viewer:latest
build: .
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
- ./archives:/archives:ro - ./archives:/archives:ro,z
environment: environment:
- PORT=3000 - PORT=3000
- ARCHIVES_DIR=/archives - ARCHIVES_DIR=/archives

137
dist-server/server.js Normal file
View File

@@ -0,0 +1,137 @@
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}`);
});

View File

@@ -1,7 +1,7 @@
{ {
"name": "react-example", "name": "instaarchive-viewer",
"private": true, "private": true,
"version": "1.1.1", "version": "1.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port=3000 --host=0.0.0.0", "dev": "vite --port=3000 --host=0.0.0.0",

View File

@@ -3,6 +3,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import os from 'os';
dotenv.config(); dotenv.config();
@@ -14,13 +15,18 @@ 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})`);
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}`);
// Ensure archives directory exists // Ensure archives directory exists
if (!fs.existsSync(ARCHIVES_DIR)) { if (!fs.existsSync(ARCHIVES_DIR)) {
console.warn(`[Server] Warning: Archives directory not found at ${ARCHIVES_DIR}. Creating it...`); console.warn(`[Server] Warning: Archives directory not found at ${ARCHIVES_DIR}. Creating it...`);
fs.mkdirSync(ARCHIVES_DIR, { recursive: true }); try {
fs.mkdirSync(ARCHIVES_DIR, { recursive: true });
} catch (err) {
console.error(`[Server] Failed to create archives directory:`, err);
}
} else { } else {
console.log(`[Server] Archives directory exists.`); console.log(`[Server] Archives directory exists.`);
} }
@@ -37,37 +43,53 @@ app.get('/api/archives', (req, res) => {
const archives = items const archives = items
.filter(item => { .filter(item => {
const isDir = item.isDirectory(); const isDir = item.isDirectory();
if (!isDir) console.log(`[API] Skipping non-directory: ${item.name}`); const isHidden = item.name.startsWith('.') || item.name.startsWith('@') || item.name.startsWith('_');
return isDir; if (!isDir) return false;
if (isHidden) {
console.log(`[API] Skipping system/hidden directory: ${item.name}`);
return false;
}
return true;
}) })
.map(item => { .map(item => {
// Try to find a profile pic or first image for the thumbnail // Try to find a profile pic or first image for the thumbnail
const archivePath = path.join(ARCHIVES_DIR, item.name); const archivePath = path.join(ARCHIVES_DIR, item.name);
const files = fs.readdirSync(archivePath); try {
console.log(`[API] Found archive: ${item.name} (${files.length} files)`); 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`); let thumbnail = '';
if (profilePic) { const profilePic = files.find(f => f.toLowerCase().includes('_profile_pic.jpg') || f.toLowerCase() === `${item.name.toLowerCase()}.jpg`);
thumbnail = `/archives/${item.name}/${profilePic}`; if (profilePic) {
} else { thumbnail = `/archives/${item.name}/${profilePic}`;
const firstImage = files.find(f => /\.(jpg|jpeg|png|webp)$/i.test(f)); } else {
if (firstImage) thumbnail = `/archives/${item.name}/${firstImage}`; const firstImage = files.find(f => /\.(jpg|jpeg|png|webp)$/i.test(f));
} if (firstImage) thumbnail = `/archives/${item.name}/${firstImage}`;
}
return { return {
name: item.name, name: item.name,
thumbnail, thumbnail,
path: item.name, path: item.name,
fileCount: files.length 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.`); console.log(`[API] Returning ${archives.length} validated archives.`);
res.json(archives); res.json(archives);
} catch (err) { } catch (err: any) {
console.error('[API] Error listing archives:', err); if (err.code === 'EACCES') {
res.status(500).json({ error: 'Failed to list archives' }); 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' });
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
import React from 'react';
import {
FolderOpen,
Grid3X3,
Play,
Trash2,
Zap
} from 'lucide-react';
import { ServerArchive } from '../types';
interface ArchiveDashboardProps {
archives: ServerArchive[];
localArchives?: any[];
cachedArchives: Set<string>;
onSelect: (archive: ServerArchive) => void;
onLocalSelect: () => void;
onLocalCacheSelect: (archive: any) => void;
onClearCache: (name: string) => void;
isScanning: boolean;
}
export const ArchiveDashboard: React.FC<ArchiveDashboardProps> = ({
archives,
localArchives = [],
cachedArchives,
onSelect,
onLocalSelect,
onLocalCacheSelect,
onClearCache,
isScanning
}) => {
return (
<div className="max-w-6xl mx-auto px-4 py-12 space-y-12">
<div className="text-center space-y-4">
<h2 className="text-4xl font-bold tracking-tight font-serif italic text-black/80">Archive Explorer</h2>
<p className="text-gray-500 max-w-xl mx-auto text-sm md:text-base leading-relaxed">
Browse hosted collections or open a local archive folder. All processing happens locally in your browser for maximum privacy.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8">
{/* Local Folder Card */}
<button
onClick={onLocalSelect}
disabled={isScanning}
className="aspect-[3/4] rounded-xl border-2 border-dashed border-gray-200 hover:border-blue-400 hover:bg-blue-50/50 transition-all flex flex-col items-center justify-center gap-4 group disabled:opacity-50 text-black"
>
<div className="w-12 h-12 rounded-full bg-gray-100 group-hover:bg-blue-100 flex items-center justify-center text-gray-400 group-hover:text-blue-500 transition-colors shadow-inner">
<FolderOpen size={24} />
</div>
<div className="text-center px-4">
<span className="font-bold text-sm block text-black/80">Open Local Folder</span>
<span className="text-[10px] text-gray-400 uppercase tracking-widest leading-tight block mt-1">Processed in Browser</span>
</div>
</button>
{/* Server Archives */}
{archives.map((archive) => {
const isCached = cachedArchives.has(archive.name);
return (
<div key={archive.path} className="relative group text-black">
<button
onClick={() => onSelect(archive)}
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"
>
<div className="flex-1 bg-gray-100 overflow-hidden relative">
{archive.thumbnail ? (
<img src={archive.thumbnail} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-300">
<Grid3X3 size={48} strokeWidth={1} />
</div>
)}
<div className="absolute inset-0 bg-black/20 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
<Play size={32} fill="white" className="text-white" />
</div>
{isCached && (
<div className="absolute top-2 left-2 bg-blue-500 text-white p-1 rounded-md shadow-lg flex items-center gap-1 text-[8px] font-bold uppercase tracking-wider z-10 pr-2 opacity-0 group-hover:opacity-100 transition-all">
<Zap size={10} fill="currentColor" />
Cached
</div>
)}
</div>
<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="text-[10px] text-gray-400 uppercase tracking-widest">{archive.fileCount} items</span>
</div>
</button>
{isCached && (
<button
onClick={(e) => {
e.stopPropagation();
onClearCache(archive.name);
}}
className="absolute top-2 right-2 p-2 bg-white/90 hover:bg-red-50 hover:text-red-500 text-gray-400 rounded-lg shadow-sm opacity-0 group-hover:opacity-100 transition-all z-20"
title="Clear Cache"
>
<Trash2 size={14} />
</button>
)}
</div>
);
})}
{/* Local Cached Archives */}
{localArchives.map((archive) => (
<div key={archive.name} className="relative group text-black">
<button
onClick={() => onLocalCacheSelect(archive)}
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"
>
<div className="flex-1 bg-gray-100 overflow-hidden relative text-black">
{archive.profileMetadata.profilePic ? (
<img src={archive.profileMetadata.profilePic} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-300">
<Grid3X3 size={48} strokeWidth={1} />
</div>
)}
<div className="absolute inset-0 bg-black/20 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
<FolderOpen size={32} fill="white" className="text-white" />
</div>
<div className="absolute bottom-2 left-2 bg-gray-800/80 text-white px-2 py-0.5 rounded text-[8px] font-bold uppercase tracking-widest backdrop-blur-sm">
Local Cache
</div>
</div>
<div className="p-4 space-y-1 text-black">
<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} indexed</span>
</div>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onClearCache(archive.name);
}}
className="absolute top-2 right-2 p-2 bg-white/90 hover:bg-red-50 hover:text-red-500 text-gray-400 rounded-lg shadow-sm opacity-0 group-hover:opacity-100 transition-all z-20"
title="Clear Cache"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import React, { useState } from 'react';
import { Play, Volume2, VolumeX } from 'lucide-react';
import { MediaFile } from '../types';
import { cn } from '../lib/utils';
export const MediaRenderer = ({ file, className, isFullView }: { file: MediaFile; className?: string; isFullView?: boolean }) => {
const [isMuted, setIsMuted] = useState(false);
const sizingClass = isFullView ? "w-full h-auto block" : "w-full h-full object-cover";
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.type === 'video') {
return (
<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 />
<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} />}
</button>
</div>
);
}
return <img src={file.url} alt="" className={cn("transition-all duration-300", sizingClass, className)} style={mediaStyle} referrerPolicy="no-referrer" decoding="async" loading="eager" />;
};

View File

@@ -0,0 +1,165 @@
import React, { useState, useEffect } from 'react';
import {
ChevronLeft,
ChevronRight,
X,
MoreHorizontal,
Heart,
MessageCircle,
Play,
Bookmark
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { format, parseISO } from 'date-fns';
import { Post } from '../types';
import { cn } from '../lib/utils';
import { MediaRenderer } from './MediaRenderer';
interface PostModalProps {
post: Post;
nextPost?: Post;
prevPost?: Post;
onClose: () => void;
onNextPost?: () => void;
onPrevPost?: () => void;
hasNextPost?: boolean;
hasPrevPost?: boolean;
profilePic: string | null;
}
export const PostModal: React.FC<PostModalProps> = ({
post, nextPost, prevPost, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [direction, setDirection] = useState(0);
// Preloading Logic
useEffect(() => {
const controller = new AbortController();
const preloadMedia = async (url: string, type: 'image' | 'video') => {
if (!url) return;
try {
if (type === 'image') {
const img = new Image();
img.src = url;
} else {
const video = document.createElement('video');
video.src = url;
video.preload = 'auto';
}
} catch (e) {}
};
// 1. Current post: Immediate preload of first two slides
if (post.media[0]) preloadMedia(post.media[0].url, post.media[0].type);
if (post.media[1]) preloadMedia(post.media[1].url, post.media[1].type);
// 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(() => {
for (let i = 2; i < post.media.length; i++) {
if (controller.signal.aborted) break;
preloadMedia(post.media[i].url, post.media[i].type);
}
}, 1000);
return () => {
controller.abort();
clearTimeout(timeout);
};
}, [post.id, post.media, nextPost?.id, prevPost?.id]);
useEffect(() => setCurrentIndex(0), [post.id]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight') onNextPost?.();
else if (e.key === 'ArrowLeft') onPrevPost?.();
else if (e.key === '.') paginate(1);
else if (e.key === ',') paginate(-1);
else if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onNextPost, onPrevPost, currentIndex, post.media.length, onClose]);
const paginate = (newDirection: number) => {
const nextIndex = currentIndex + newDirection;
if (nextIndex >= 0 && nextIndex < post.media.length) { setDirection(newDirection); setCurrentIndex(nextIndex); }
};
const variants = {
enter: (d: number) => ({ x: d > 0 ? '100%' : '-100%', opacity: 1, zIndex: 0 }),
center: { zIndex: 1, x: 0, opacity: 1 },
exit: (d: number) => ({ zIndex: 0, x: d < 0 ? '100%' : '-100%', opacity: 1 })
};
const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity;
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}>
<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>
{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>}
{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>}
<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()}>
<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">
<AnimatePresence initial={false} custom={direction}>
<motion.div
key={`${post.id}-${currentIndex}`}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ x: { type: "spring", stiffness: 200, damping: 26, bounce: 0 } }}
drag="x"
dragDirectionLock
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.5}
onDragEnd={(e, { offset, velocity }) => {
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(); }
else if (s > 15000) { if (currentIndex > 0) paginate(-1); else if (hasPrevPost && onPrevPost && s > 40000) onPrevPost(); }
}}
className="col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing relative text-black"
>
<MediaRenderer file={post.media[currentIndex]} isFullView={true} />
</motion.div>
</AnimatePresence>
</div>
{post.media.length > 1 && (
<>
{currentIndex > 0 && <button 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>}
{currentIndex < post.media.length - 1 && <button 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 text-black">{post.media.map((_, i) => <div key={i} className={cn("w-1.5 h-1.5 rounded-full transition-all", i === currentIndex ? "bg-blue-500 scale-125" : "bg-white/40 shadow-sm")} />)}</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="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="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>
<span className="font-semibold text-sm text-black">{post.username}</span>
</div>
<MoreHorizontal size={20} className="text-gray-500 text-black" />
</div>
<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="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>
</div>
<div className="p-3 md:p-4 border-t border-gray-100 space-y-3 shrink-0 bg-white text-black">
<div className="flex items-center justify-between text-black"><div className="flex items-center gap-4 text-black"><Heart size={24} className="hover:text-gray-500 cursor-pointer text-black" /><MessageCircle size={24} className="hover:text-gray-500 cursor-pointer text-black" /><Play size={24} className="hover:text-gray-500 cursor-pointer text-black" /></div><Bookmark size={24} className="hover:text-gray-500 cursor-pointer text-black" /></div>
<div className="text-sm flex items-center gap-2 text-black"><span className="font-semibold text-black">Archived Post</span><span className="text-gray-400 font-normal text-xs text-black">{post.id}</span></div>
</div>
</div>
</motion.div>
</div>
</motion.div>
);
};

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>
);
};

View File

@@ -0,0 +1,203 @@
import React, { useState, useEffect, useRef } from 'react';
import {
ChevronLeft,
ChevronRight,
Volume2,
VolumeX,
X
} from 'lucide-react';
import { motion } from 'motion/react';
import { format, parseISO } from 'date-fns';
import { Post } from '../types';
import { cn } from '../lib/utils';
interface StoryViewerProps {
stories: Post[];
onClose: () => void;
profilePic: string | null;
}
export const StoryViewer: React.FC<StoryViewerProps> = ({
stories,
onClose,
profilePic
}) => {
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [isMuted, setIsMuted] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const story = stories[currentStoryIndex];
useEffect(() => {
setProgress(0);
let duration = 5000;
const interval = 50;
const updateProgress = () => {
if (story.media[0].type === 'video' && videoRef.current) {
const currentTime = videoRef.current.currentTime;
const totalTime = videoRef.current.duration;
if (totalTime) {
setProgress((currentTime / totalTime) * 100);
}
} else {
setProgress(prev => {
const step = (interval / duration) * 100;
if (prev >= 100) return 100;
return prev + step;
});
}
};
const timer = setInterval(() => {
updateProgress();
}, interval);
return () => clearInterval(timer);
}, [currentStoryIndex, story.media]);
useEffect(() => {
if (progress >= 100) {
if (currentStoryIndex < stories.length - 1) {
setCurrentStoryIndex(prev => prev + 1);
} else {
onClose();
}
}
}, [progress, currentStoryIndex, stories.length, onClose]);
const nextStory = () => {
if (currentStoryIndex < stories.length - 1) {
setCurrentStoryIndex(prev => prev + 1);
} else {
onClose();
}
};
const prevStory = () => {
if (currentStoryIndex > 0) {
setCurrentStoryIndex(prev => prev - 1);
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-[#1a1a1a] flex items-center justify-center overflow-hidden text-white"
onClick={onClose}
>
<div className="absolute inset-0 z-0 text-white">
<img
src={story.media[0].url}
alt=""
className="w-full h-full object-cover blur-3xl opacity-30"
/>
</div>
<button
onClick={(e) => { e.stopPropagation(); prevStory(); }}
className={cn(
"hidden md:flex absolute left-4 lg:left-20 z-50 text-white/80 hover:text-white transition-all bg-white/10 p-3 rounded-full backdrop-blur-md",
currentStoryIndex === 0 && "opacity-0 pointer-events-none"
)}
>
<ChevronLeft size={32} strokeWidth={1.5} />
</button>
<button
onClick={(e) => { e.stopPropagation(); nextStory(); }}
className="hidden md:flex absolute right-4 lg:right-20 z-50 text-white/80 hover:text-white transition-all bg-white/10 p-3 rounded-full backdrop-blur-md"
>
<ChevronRight size={32} strokeWidth={1.5} />
</button>
<div
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()}
>
<div
className="absolute top-2 left-2 right-2 z-50 flex px-1 text-white"
style={{ gap: stories.length > 100 ? '1px' : (stories.length > 50 ? '2px' : '4px') }}
>
{stories.map((_, i) => (
<div key={i} className="h-1 flex-1 bg-white/20 rounded-full overflow-hidden text-white">
<div
className="h-full bg-white transition-all duration-75 text-white"
style={{
width: i < currentStoryIndex ? '100%' : (i === currentStoryIndex ? `${progress}%` : '0%')
}}
/>
</div>
))}
</div>
<div className="absolute top-6 left-4 right-4 z-50 flex items-center justify-between text-white">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-white/10 p-0.5">
<div className="w-full h-full rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
{profilePic ? (
<img src={profilePic} alt="" className="w-full h-full object-cover text-black" referrerPolicy="no-referrer" />
) : (
<span className="text-[10px] font-bold text-black uppercase">{story.username[0]}</span>
)}
</div>
</div>
<div className="flex items-center gap-2 text-white">
<span className="text-xs font-semibold">{story.username}</span>
<span className="text-[10px] opacity-60 font-medium">{format(parseISO(story.date), 'MMM d')}</span>
</div>
</div>
<div className="flex items-center gap-1 text-white">
{story.media[0].type === 'video' && (
<button
onClick={(e) => { e.stopPropagation(); setIsMuted(!isMuted); }}
className="p-2 hover:bg-white/10 rounded-full transition-colors text-white"
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
)}
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors text-white">
<X size={24} />
</button>
</div>
</div>
<div className="w-full h-full flex items-center justify-center pointer-events-none text-white">
{story.media[0].type === 'video' ? (
<video
ref={videoRef}
src={story.media[0].url}
className="w-full h-full object-contain"
autoPlay
muted={isMuted}
playsInline
controls
onEnded={nextStory}
/>
) : (
<img
src={story.media[0].url}
alt=""
className="w-full h-full object-contain"
referrerPolicy="no-referrer"
/>
)}
</div>
<div className="absolute inset-0 z-20 flex">
<div className="w-1/4 h-full cursor-pointer" onClick={prevStory} title="Previous Story" />
<div className="w-3/4 h-full cursor-pointer" onClick={nextStory} title="Next Story" />
</div>
{story.caption && (
<div className="absolute bottom-16 left-4 right-4 z-50 bg-black/20 backdrop-blur-sm p-3 rounded-lg text-white text-xs text-center border border-white/10">
{story.caption}
</div>
)}
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,427 @@
import { useState, useCallback, useRef } from 'react';
// @ts-ignore
import { XzReadableStream } from 'xz-decompress';
import * as idb from 'idb-keyval';
import { ArchiveFile, Post, ServerArchive } from '../types';
export const useArchiveScanner = (
detectedUsername: string,
currentArchive: ServerArchive | null,
refreshCachedArchives: () => Promise<void>
) => {
const [isScanning, setIsScanning] = useState(false);
const [scanningPhase, setScanningPhase] = useState<'Indexing' | 'Parsing' | 'Checking Cache' | ''>('');
const [scannedCount, setScannedCount] = useState(0);
const [totalFiles, setTotalFiles] = useState(0);
const [scannedFilesLog, setScannedFilesLog] = useState<string[]>([]);
const [currentScanningImage, setCurrentScanningImage] = useState<string | null>(null);
// Result state
const [allPosts, setAllPosts] = useState<Post[]>([]);
const [allStories, setAllStories] = useState<Post[]>([]);
const [profileMetadata, setProfileMetadata] = useState<{
username: string;
fullName: string;
bio: string;
followerCount: number;
followingCount: number;
externalUrl: string;
profilePic: string | null;
allProfilePics: string[];
}>({
username: '',
fullName: '',
bio: '',
followerCount: 0,
followingCount: 0,
externalUrl: '',
profilePic: null,
allProfilePics: [],
});
const resetScannerState = useCallback(() => {
setAllPosts([]);
setAllStories([]);
setProfileMetadata({
username: '',
fullName: '',
bio: '',
followerCount: 0,
followingCount: 0,
externalUrl: '',
profilePic: null,
allProfilePics: [],
});
}, []);
const handleFiles = useCallback(async (files: ArchiveFile[], archiveContext?: ServerArchive) => {
if (!files || files.length === 0) return;
setIsScanning(true);
resetScannerState();
setScanningPhase('Indexing');
setScannedCount(0);
setTotalFiles(files.length);
setScannedFilesLog([]);
console.log(`[Scanner] Starting scan of ${files.length} files...`);
await new Promise(resolve => setTimeout(resolve, 100));
const parseXZFile = async (file: ArchiveFile) => {
try {
const stream = new XzReadableStream(file.stream());
const response = new Response(stream);
return await response.json();
} catch (e) { console.error(`[Scanner] XZ Parse Error:`, file.name, e); return null; }
};
let lastImageUpdateTime = 0;
const throttledSetScanningImage = (url: string) => {
const now = Date.now();
if (now - lastImageUpdateTime > 1000) {
setCurrentScanningImage(url);
lastImageUpdateTime = now;
}
};
const isImage = (name: string) => /\.(jpg|jpeg|png|webp|gif|bmp|svg|tiff)$/i.test(name);
const isVideo = (name: string) => /\.(mp4|webm|ogv|mov)$/i.test(name);
const isMedia = (name: string) => isImage(name) || isVideo(name);
try {
const postsMap = new Map<string, Partial<Post>>();
const mediaFilesMap = new Map<string, ArchiveFile>();
const discoveredProfilePics: { name: string, url: string }[] = [];
const allImageFiles: ArchiveFile[] = [];
let localFullName = '';
let localBio = '';
let localExternalUrl = '';
let localFollowerCount = 0;
let localFollowingCount = 0;
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 => {
if (!obj) return false;
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");
};
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 jsonFiles: ArchiveFile[] = [];
// Pass 1: Indexing
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (i % 100 === 0 || i === files.length - 1) {
setScannedCount(i + 1);
setScannedFilesLog(prev => [`Indexed ${file.name}`, ...prev.slice(0, 19)]);
// Yield to main thread
await new Promise(resolve => setTimeout(resolve, 0));
}
const lowerName = file.name.toLowerCase();
if (lowerName.endsWith('.json') || lowerName.endsWith('.json.xz')) {
jsonFiles.push(file);
if (lowerName.includes('posts_1') || lowerName.includes('reels_1') || lowerName.includes('stories_1')) format = 'json';
continue;
}
if (file.name.match(exportRegex)) format = 'export';
else if (file.name.match(instaloaderRegex)) format = 'instaloader';
if (lowerName.includes('_profile_pic.jpg') || (currentUsername && lowerName === `${currentUsername.toLowerCase()}.jpg`)) {
try {
const url = file.url || (await (async () => {
const blob = new Blob([await file.arrayBuffer()], { type: 'image/jpeg' });
return URL.createObjectURL(blob);
})());
discoveredProfilePics.push({ name: file.name, url });
if (format === 'unknown' && lowerName.includes('_profile_pic.jpg')) format = 'instaloader';
} catch(e) {}
}
if (isMedia(file.name)) {
mediaFilesMap.set(file.webkitRelativePath || file.name, file);
if (isImage(file.name)) allImageFiles.push(file);
}
}
console.log(`[Scanner] Format Detection Complete. Result: ${format}. Media indexed: ${mediaFilesMap.size}`);
if (jsonFiles.length > 0 && (format === 'json' || format === 'instaloader')) {
setScanningPhase('Parsing');
for (let i = 0; i < jsonFiles.length; i++) {
const jsonFile = jsonFiles[i];
setScannedCount(i + 1);
setScannedFilesLog(prev => [`Parsing ${jsonFile.name}`, ...prev.slice(0, 19)]);
try {
const data = jsonFile.name.endsWith('.xz') ? await parseXZFile(jsonFile) : JSON.parse(await jsonFile.text());
if (!data) continue;
const items = Array.isArray(data) ? data : (data.media || [data]);
const isStoriesFile = jsonFile.name.toLowerCase().includes('stories');
if (data.node && (data.instaloader?.node_type === 'Profile' || data.node.__typename === 'User')) {
const node = data.node; const iphone = node.iphone_struct || {};
localFullName = node.full_name || ''; localBio = node.biography || iphone.biography || '';
localExternalUrl = node.external_url || '';
localFollowerCount = node.edge_followed_by?.count || iphone.follower_count || 0;
localFollowingCount = node.edge_follow?.count || iphone.following_count || 0;
if (!Array.isArray(data)) continue;
}
for (const [idx, item] of items.entries()) {
const mediaList = item.media || [item];
const postId = item.node?.id || item.id || item.title || `post_${idx}_${Date.now()}`;
const date = item.creation_timestamp ? new Date(item.creation_timestamp * 1000).toISOString().split('T')[0] : (item.node?.taken_at_timestamp ? new Date(item.node.taken_at_timestamp * 1000).toISOString().split('T')[0] : new Date().toISOString().split('T')[0]);
const isStory = isStoriesFile || checkIsStory(item) || checkIsStory(item.node) || checkIsStory(data.instaloader) || checkIsStory(item.node?.iphone_struct) || checkIsStory(item.iphone_struct) || (item.media && Array.isArray(item.media) && item.media.some((m: any) => checkIsStory(m)));
const post: Partial<Post> = { id: postId, date, username: currentUsername || 'archived_user', caption: item.title || item.node?.edge_media_to_caption?.edges?.[0]?.node?.text || item.node?.caption?.text || '', media: [], isStory };
for (const [mIdx, m] of mediaList.entries()) {
const uri = m.uri; let matchedFile: ArchiveFile | undefined;
if (uri) { for (const [path, f] of mediaFilesMap.entries()) { if (path.endsWith(uri) || uri.endsWith(path)) { matchedFile = f; break; } } }
if (!matchedFile) { const id = item.node?.id || item.id; if (id) { for (const [path, f] of mediaFilesMap.entries()) { if (f.name.includes(id)) { matchedFile = f; break; } } } }
if (!matchedFile) {
const jsonBase = jsonFile.name.substring(0, jsonFile.name.lastIndexOf('.'));
for (const ext of ['mp4', 'webm', 'jpg', 'jpeg', 'png', 'webp', 'gif']) {
const possibleName = `${jsonBase}.${ext}`;
for (const [path, f] of mediaFilesMap.entries()) { if (f.name.toLowerCase() === possibleName.toLowerCase()) { matchedFile = f; break; } }
if (matchedFile) break;
}
}
if (matchedFile) {
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 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, size: matchedFile!.size } : media); }
else post.media!.push({ name: matchedFile.name, url, type, index: mIdx + 1, size: matchedFile.size });
}
}
if (post.media!.length > 0) postsMap.set(postId, post);
}
} catch (e) { console.error(`[Scanner] Error parsing JSON ${jsonFile.name}:`, e); }
// Yield to main thread
if (i % 10 === 0) await new Promise(resolve => setTimeout(resolve, 0));
}
}
if (format === 'export' || format === 'instaloader') {
setScanningPhase('Parsing');
const CHUNK_SIZE = 100;
for (let j_start = 0; j_start < files.length; j_start += CHUNK_SIZE) {
const end = Math.min(j_start + CHUNK_SIZE, files.length);
setScannedCount(j_start);
setScannedFilesLog(prev => [`Batch ${Math.floor(j_start/CHUNK_SIZE) + 1} processing...`, ...prev.slice(0, 19)]);
for (let j = j_start; j < end; j++) {
const file = files[j]; const lowerName = file.name.toLowerCase();
const expMatch = file.name.match(exportRegex);
const insMatch = file.name.match(instaloaderRegex);
if (!expMatch && !insMatch) continue;
let postId = '', date = '', user = currentUsername || 'archived_user', index = 1, ext = '', isStory = lowerName.includes('story') || file.webkitRelativePath.toLowerCase().includes('stories');
if (expMatch) {
const [_, dMatch, uMatch, pMatch, iStrMatch, sMatch, eMatch] = expMatch;
date = dMatch; user = uMatch; postId = pMatch; index = iStrMatch ? parseInt(iStrMatch, 10) : 1; if (sMatch) isStory = true; ext = eMatch;
} else if (insMatch) {
const [_, pMatch, iStrMatch, sMatch, eMatch] = insMatch;
postId = pMatch; date = pMatch.split('_')[0]; index = iStrMatch ? parseInt(iStrMatch, 10) : 1; if (sMatch) isStory = true; ext = eMatch;
}
let post = postsMap.get(postId);
if (!post) { post = { id: postId, date, username: user, caption: '', media: [], isStory }; postsMap.set(postId, post); }
else if (isStory) post.isStory = true;
const lowerExt = ext.toLowerCase();
if (lowerExt === 'txt') {
try { post.caption = await file.text(); } catch(e) {}
} else if (lowerExt === 'json' || lowerName.endsWith('.json.xz')) {
try {
const data = lowerName.endsWith('.xz') ? await parseXZFile(file) : JSON.parse(await file.text());
if (data) {
const node = data.node || data; const iphone = node.iphone_struct || {};
const captionText = node.edge_media_to_caption?.edges?.[0]?.node?.text || node.caption?.text || iphone.caption?.text || '';
if (captionText) post.caption = captionText;
if (checkIsStory(data) || checkIsStory(node) || checkIsStory(data.instaloader) || checkIsStory(iphone)) post.isStory = true;
}
} catch (e) {}
} else if (isMedia(file.name)) {
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' }));
if (type === 'image') throttledSetScanningImage(url);
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, size: file.size } : m); }
else post.media!.push({ name: file.name, url, type, index, size: file.size });
}
}
await new Promise(resolve => setTimeout(resolve, 0));
}
}
if (postsMap.size === 0) {
console.log(`[Scanner] No posts found with standard patterns. Using mediaFilesMap: ${mediaFilesMap.size}`);
setScanningPhase('Parsing');
const genericGroupingMap = new Map<string, ArchiveFile[]>();
for (const [key, file] of mediaFilesMap.entries()) {
const match = file.name.match(/^(.*?)(?:(_|-|\s)+(\d+))?\.(.+)$/);
let baseName = file.name;
if (match && match[3]) { baseName = match[1].trim(); }
else { baseName = file.name.substring(0, file.name.lastIndexOf('.')); }
if (!genericGroupingMap.has(baseName)) genericGroupingMap.set(baseName, []);
genericGroupingMap.get(baseName)!.push(file);
}
console.log(`[Scanner] Generic grouping found ${genericGroupingMap.size} base groups.`);
let processedGroups = 0;
const groupEntries = Array.from(genericGroupingMap.entries());
for (const [baseName, groupFiles] of groupEntries) {
processedGroups++;
if (processedGroups % 10 === 0 || processedGroups === genericGroupingMap.size) {
setScannedCount(Math.floor((processedGroups / (genericGroupingMap.size || 1)) * (files.length || 1)));
setScannedFilesLog(prev => [`Grouping: ${baseName}`, ...prev.slice(0, 19)]);
await new Promise(resolve => setTimeout(resolve, 0));
}
groupFiles.sort((a, b) => {
const na = a.name.match(/[_-](\d+)\.\w+$/)?.[1];
const nb = b.name.match(/[_-](\d+)\.\w+$/)?.[1];
if (na && nb) return parseInt(na, 10) - parseInt(nb, 10);
return a.name.localeCompare(b.name);
});
const CAROUSEL_MAX = 20;
for (let j = 0; j < groupFiles.length; j += CAROUSEL_MAX) {
const batch = groupFiles.slice(j, j + CAROUSEL_MAX);
const partSuffix = groupFiles.length > CAROUSEL_MAX ? `_part${Math.floor(j/CAROUSEL_MAX) + 1}` : '';
const postId = `${baseName}${partSuffix}`;
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()) {
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' }));
if (type === 'image') throttledSetScanningImage(url);
post.media.push({ name: file.name, url, type, index: idx + 1, size: file.size });
}
post.thumbnail = post.media[0].url;
postsMap.set(postId, post);
}
}
}
if (discoveredProfilePics.length > 0) {
discoveredProfilePics.sort((a, b) => b.name.localeCompare(a.name));
const urls = discoveredProfilePics.map(p => p.url);
localProfilePic = urls[0];
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 = oldestFile.url || URL.createObjectURL(new Blob([await oldestFile.arrayBuffer()], { type: 'image/jpeg' }));
localProfilePic = url;
setProfileMetadata(prev => ({ ...prev, profilePic: localProfilePic, allProfilePics: [url] }));
} catch(e) {}
}
const finalUsername = currentUsername || 'archived_user';
const allItems = Array.from(postsMap.values()).filter(p => p.media && p.media.length > 0).map(p => {
const sortedMedia = p.media!.sort((a, b) => a.index - b.index);
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 stories = allItems.filter(p => p.isStory).sort((a, b) => b.date.localeCompare(a.date)); // Fixed bug here
setAllPosts(posts);
setAllStories(stories);
setProfileMetadata(prev => ({
...prev,
username: finalUsername,
fullName: localFullName,
bio: localBio,
followerCount: localFollowerCount,
followingCount: localFollowingCount,
externalUrl: localExternalUrl,
profilePic: localProfilePic || prev.profilePic,
}));
console.log(`[Scanner] Finalized ${posts.length} posts and ${stories.length} stories.`);
const archiveToCache = archiveContext || currentArchive;
const isLocal = !archiveToCache;
const cacheKey = archiveToCache ? archiveToCache.name : (finalUsername || 'local_archive');
if (cacheKey && (posts.length > 0 || stories.length > 0)) {
console.log(`[Cache] Saving data for ${cacheKey} to persistent storage...`);
let cacheThumbnail = localProfilePic;
if (isLocal && posts.length > 0 && posts[0].media[0].type === 'image') {
try {
const img = new Image(); img.src = posts[0].media[0].url;
await new Promise((res) => { img.onload = res; img.onerror = res; });
if (img.complete && img.width > 0) {
const canvas = document.createElement('canvas'); const size = 200;
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) { ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, size, size); cacheThumbnail = canvas.toDataURL('image/jpeg', 0.7); }
}
} catch (e) {}
}
const cacheData = {
name: cacheKey, isLocal, fileCount: archiveToCache ? archiveToCache.fileCount : files.length,
posts: posts, // Enable caching for local archives
stories: stories,
profileMetadata: {
username: finalUsername,
fullName: localFullName,
bio: localBio,
followerCount: localFollowerCount,
followingCount: localFollowingCount,
externalUrl: localExternalUrl,
profilePic: isLocal ? cacheThumbnail : localProfilePic,
allProfilePics: isLocal ? (cacheThumbnail ? [cacheThumbnail] : []) : discoveredProfilePics.map(p => p.url)
},
timestamp: Date.now()
};
try {
await idb.set(cacheKey, cacheData);
console.log(`[Cache] Data saved successfully.`);
await refreshCachedArchives();
} catch (e) { console.error(`[Cache] Save error:`, e); }
}
} catch (err) { console.error(`[Scanner] Critical error during scan:`, err); } finally { setIsScanning(false); }
}, [currentArchive, detectedUsername, resetScannerState, refreshCachedArchives]);
return {
isScanning,
scanningPhase,
scannedCount,
totalFiles,
scannedFilesLog,
currentScanningImage,
allPosts,
allStories,
profileMetadata,
handleFiles,
setAllPosts,
setAllStories,
setProfileMetadata,
setIsScanning,
setScanningPhase,
setScannedCount,
setTotalFiles,
setScannedFilesLog,
setCurrentScanningImage,
resetScannerState
};
};

View File

@@ -0,0 +1,106 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import * as idb from 'idb-keyval';
interface ThumbnailRequest {
id: string;
url: string;
blob?: Blob;
}
const THUMBNAIL_WIDTH = 400;
export const useThumbnailQueue = () => {
const [cacheHits, setCacheHits] = useState<Map<string, string>>(new Map());
const queueRef = useRef<ThumbnailRequest[]>([]);
const isProcessingRef = useRef(false);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
// Initialize worker with relative URL (vite will handle this)
workerRef.current = new Worker(new URL('../lib/thumbnail-worker.ts', import.meta.url), {
type: 'module'
});
workerRef.current.onmessage = async (e) => {
const { id, blob, error } = e.data;
if (!error && blob) {
const url = URL.createObjectURL(blob);
setCacheHits(prev => new Map(prev).set(id, url));
// Persist to IndexedDB (as dataURL for simple retrieval or keep as Blob)
try {
await idb.set(`thumb_${id}`, blob);
} catch (err) {}
}
// Process next in queue
isProcessingRef.current = false;
processNext();
};
return () => {
workerRef.current?.terminate();
};
}, []);
const processNext = useCallback(async () => {
if (isProcessingRef.current || queueRef.current.length === 0 || !workerRef.current) return;
isProcessingRef.current = true;
const request = queueRef.current.shift()!;
try {
// 1. Double check cache before expensive work
const cached = await idb.get(`thumb_${request.id}`);
if (cached instanceof Blob) {
const url = URL.createObjectURL(cached);
setCacheHits(prev => new Map(prev).set(request.id, url));
isProcessingRef.current = false;
processNext();
return;
}
// 2. Fetch original if no blob provided
let blob = request.blob;
if (!blob) {
const res = await fetch(request.url);
blob = await res.blob();
}
// 3. Send to worker
workerRef.current.postMessage({
id: request.id,
blob,
width: THUMBNAIL_WIDTH
});
} catch (err) {
console.error('[ThumbnailQueue] Failed to process:', request.id, err);
isProcessingRef.current = false;
processNext();
}
}, []);
const requestThumbnail = useCallback(async (id: string, url: string, blob?: Blob) => {
// 1. Sync check state
if (cacheHits.has(id)) return;
// 2. Async check idb
const cached = await idb.get(`thumb_${id}`);
if (cached instanceof Blob) {
setCacheHits(prev => new Map(prev).set(id, URL.createObjectURL(cached)));
return;
}
// 3. Add to queue if not already there
if (!queueRef.current.some(r => r.id === id)) {
queueRef.current.push({ id, url, blob });
processNext();
}
}, [cacheHits, processNext]);
return {
cacheHits,
requestThumbnail
};
};

36
src/lib/archive-files.ts Normal file
View File

@@ -0,0 +1,36 @@
import { ArchiveFile } from '../types';
export class LocalArchiveFile implements ArchiveFile {
constructor(private file: File) {}
get name() { return this.file.name; }
get webkitRelativePath() { return this.file.webkitRelativePath; }
get size() { return this.file.size; }
text() { return this.file.text(); }
arrayBuffer() { return this.file.arrayBuffer(); }
stream() { return this.file.stream(); }
}
export class RemoteArchiveFile implements ArchiveFile {
constructor(
public name: string,
public webkitRelativePath: string,
public size: number,
public url: string
) {}
async text() {
const res = await fetch(this.url);
return res.text();
}
async arrayBuffer() {
const res = await fetch(this.url);
return res.arrayBuffer();
}
stream() {
const transform = new TransformStream();
fetch(this.url).then(res => {
if (res.body) res.body.pipeTo(transform.writable);
else transform.writable.getWriter().close();
});
return transform.readable;
}
}

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 });
}
};

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -4,7 +4,25 @@ import App from './App.tsx';
import './index.css'; import './index.css';
import { registerSW } from 'virtual:pwa-register'; import { registerSW } from 'virtual:pwa-register';
registerSW(); // Register service worker with automatic updates
// and a periodic check every hour to ensure long-running sessions stay fresh.
const updateSW = registerSW({
onRegistered(r) {
if (r) {
// Check for updates every hour
setInterval(() => {
r.update();
}, 60 * 60 * 1000);
console.log('[PWA] Service Worker registered and update interval set.');
}
},
onNeedRefresh() {
console.log('[PWA] New content available, reloading...');
},
onOfflineReady() {
console.log('[PWA] App is ready for offline use.');
}
});
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

37
src/types/index.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface MediaFile {
name: string;
url: string;
type: 'image' | 'video';
index: number;
size?: number;
}
export interface Post {
id: string;
date: string;
username: string;
caption: string;
media: MediaFile[];
thumbnail: string;
isStory?: boolean;
}
/**
* Common interface for both local File objects and remote server-side files.
*/
export interface ArchiveFile {
name: string;
webkitRelativePath: string;
size: number;
text(): Promise<string>;
arrayBuffer(): Promise<ArrayBuffer>;
stream(): ReadableStream<Uint8Array>;
url?: string;
}
export interface ServerArchive {
name: string;
thumbnail: string;
path: string;
fileCount: number;
}

View File

@@ -11,7 +11,7 @@ export default defineConfig(({mode}) => {
react(), react(),
tailwindcss(), tailwindcss(),
VitePWA({ VitePWA({
registerType: 'prompt', registerType: 'autoUpdate',
manifest: { manifest: {
name: 'InstaArchive', name: 'InstaArchive',
short_name: 'InstaArchive', short_name: 'InstaArchive',
@@ -34,6 +34,7 @@ export default defineConfig(({mode}) => {
] ]
}, },
workbox: { workbox: {
navigateFallbackDenylist: [/^\/api/, /^\/archives/],
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
runtimeCaching: [ runtimeCaching: [
{ {