mirror of
https://github.com/ergosteur/instaarchive-viewer.git
synced 2026-07-04 11:07:15 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42c13ea106 | ||
|
|
a4e9ce16a7 | ||
|
|
4f89a69ee3 | ||
|
|
147dcdf2f1 | ||
|
|
d4e20d9b98 | ||
|
|
ec8c771733 | ||
|
|
3784e8729b | ||
|
|
69d62eaa5c | ||
|
|
767f9c508b | ||
|
|
103ce6f207 | ||
|
|
5267dab236 | ||
|
|
ebf2bf660a | ||
|
|
c0f3523a9c |
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-server
|
||||
.git
|
||||
.github
|
||||
_sample-archives
|
||||
|
||||
@@ -7,7 +7,7 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source and build frontend
|
||||
# Copy source and build (frontend and server)
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -27,12 +27,12 @@ RUN npm ci --omit=dev
|
||||
|
||||
# Copy built assets and server
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/server.ts ./
|
||||
COPY --from=build /app/dist-server/server.js ./server.js
|
||||
|
||||
# Ensure archives directory exists
|
||||
RUN mkdir -p /archives
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Start server using tsx
|
||||
CMD ["npx", "tsx", "server.ts"]
|
||||
# Start server
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
76
GEMINI.md
76
GEMINI.md
@@ -2,38 +2,68 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
**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 allows users to load local archive directories (either official Instagram exports or Instaloader format) and browse posts, reels, and stories.
|
||||
**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
|
||||
- **Local Archive Loading:** Uses the `webkitdirectory` API to scan and process local files securely on the client-side.
|
||||
- **Support for Multiple Formats:** Recognizes official Instagram export structures and Instaloader regex-based naming conventions.
|
||||
- **Compressed Metadata**: Support for `.json.xz` file decompression using `xz-decompress` (WASM-powered).
|
||||
- **Dynamic Media Grid**: Customizable grid layouts (1:1 and 3:4 aspect ratios) with adjustable offsets ("bumps") for aesthetic alignment.
|
||||
- **Story Viewer**: Native-like story experience with segmented progress bars, automated playback, audio controls, and chronological sorting.
|
||||
- **Auto-Unmute**: Videos default to unmuted when opened in full view for a better user experience.
|
||||
- **PWA Ready**: Built with `vite-plugin-pwa` for offline capabilities and a standalone application experience.
|
||||
- **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.
|
||||
- **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.
|
||||
- **High-Performance Carousel:** Advanced `PostModal` with:
|
||||
- **Preloading:** Intelligently preloads the first two slides immediately, followed by a background preload of the entire carousel.
|
||||
- **Seamless Transitions:** Zero-latency slide transitions with optimized Framer Motion variants, removing "black flashes" between images.
|
||||
- **Async Decoding:** Utilizes `decoding="async"` to offload image processing from the main thread.
|
||||
- **Glassy Scanner UI:** Custom-built glassmorphism scanning dashboard with throttled (1s) dynamic blurred backgrounds and a high-density system log.
|
||||
- **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.
|
||||
- **Compressed Metadata:** Support for `.json.xz` file decompression using `xz-decompress` (WASM-powered).
|
||||
- **Production-Ready Docker:** Multi-stage Docker builds using `node:slim` serving both the Express API and the Vite-built frontend.
|
||||
|
||||
### Main Technologies
|
||||
- **Frontend:** React 19, Vite, TypeScript
|
||||
- **Frontend:** React 19, Vite 6, TypeScript
|
||||
- **Styling:** Tailwind CSS (v4)
|
||||
- **Icons:** Lucide React
|
||||
- **Animations:** Framer Motion (`motion/react`)
|
||||
- **Utility:** Date-fns, clsx, tailwind-merge
|
||||
- **Decompression**: xz-decompress (WASM)
|
||||
- **Persistence:** IndexedDB (`idb-keyval`)
|
||||
- **Backend:** Express, tsx (for server-side scanning)
|
||||
- **Decompression:** xz-decompress (WASM)
|
||||
|
||||
## Architecture
|
||||
|
||||
### State Management
|
||||
- **`useArchiveScanner` Hook:** Centralized logic for parsing archives and managing results (`allPosts`, `allStories`, `profileMetadata`).
|
||||
- **Archive Interface:** Unified `ArchiveFile` interface implemented by `LocalArchiveFile` (for browser `File` objects) and `RemoteArchiveFile` (for server-side assets).
|
||||
|
||||
### Cache Schema
|
||||
```typescript
|
||||
interface CacheData {
|
||||
name: string;
|
||||
isLocal: boolean;
|
||||
fileCount: number;
|
||||
posts: Post[]; // Remote archives only (Local archives re-parsed for security)
|
||||
stories: Post[]; // Remote archives only
|
||||
profileMetadata: {
|
||||
username: string;
|
||||
fullName: string;
|
||||
bio: string;
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
externalUrl: string;
|
||||
profilePic: string | null;
|
||||
allProfilePics: string[];
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
- `npm install`: Install project dependencies.
|
||||
- `npm run dev`: Start the local development server on port 3000.
|
||||
- `npm run build`: Generate the production-ready build in the `dist` folder.
|
||||
- `npm run preview`: Locally preview the production build.
|
||||
- `npm run lint`: Execute TypeScript type-checking (`tsc --noEmit`).
|
||||
- `npm run build`: Generate the production-ready build in the `dist` folder and server in `dist-server`.
|
||||
- `npm run server`: Start the backend server to scan `./_sample-archives`.
|
||||
- `npm run lint`: Execute TypeScript type-checking.
|
||||
|
||||
## Development Conventions
|
||||
- **Component Architecture:** Functional components with modern hooks.
|
||||
- **State Management**: React `useState`, `useMemo`, and `useCallback` for optimized performance during large archive scans.
|
||||
- **File Handling:** Privacy-focused client-side scanning of archive directories.
|
||||
- **Project Structure:**
|
||||
- `src/App.tsx`: Main entry point containing application logic and UI components.
|
||||
- `src/main.tsx`: React DOM mounting.
|
||||
- `src/index.css`: Global styles and Tailwind imports.
|
||||
- `vite.config.ts`: Project build and PWA configuration.
|
||||
## Production Deployment
|
||||
The project is containerized and available on GHCR. 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)
|
||||
|
||||
105
README.md
105
README.md
@@ -4,40 +4,81 @@ A high-performance React PWA for browsing archived Instagram data with a native-
|
||||
|
||||
## Features
|
||||
|
||||
- **Local Privacy**: All processing is done client-side using browser APIs. Your data never leaves your computer.
|
||||
- **Multiple Formats**: Supports official Instagram JSON exports and Instaloader regex-based naming conventions.
|
||||
- **Metadata Support**: Robust parsing of `.json` and `.json.xz` files for captions, timestamps, and story metadata.
|
||||
- **Advanced Carousel**: Seamless, zero-latency transitions between slides with intelligent preloading. Images use asynchronous decoding to keep the UI smooth during motion.
|
||||
- **Persistent Caching**: Uses IndexedDB to store parsed archives locally. Subsequent loads are near-instant, with full support for profile metadata and profile picture history.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Story Viewer**: Native-like story experience with segmented progress bars, auto-playback, and audio controls.
|
||||
- **Media Grid**: Customizable 1:1 or 3:4 grid views with adjustable offsets for aesthetic alignment.
|
||||
- **Auto-Deduplication**: Intelligently prefers video files over thumbnail images for the same post.
|
||||
- **Customizable Grid**: 1:1 or 3:4 aspect ratios with adjustable "bumps" for aesthetic alignment.
|
||||
- **Navigation Protection**: Intercepts accidental browser "Back" or "Refresh" actions to protect your current session.
|
||||
|
||||
## Run Locally
|
||||
## Deployment
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
The easiest way to run InstaArchive is using Docker.
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-v /path/to/your/archives:/archives:ro \
|
||||
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
|
||||
|
||||
Create a `compose.yml` file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
instaarchive:
|
||||
image: ghcr.io/ergosteur/instaarchive-viewer:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./archives:/archives:ro,z # ,z handles SELinux permissions
|
||||
```
|
||||
|
||||
### Troubleshooting Permissions
|
||||
|
||||
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
|
||||
|
||||
Place your archive folders inside the mounted `/archives` directory. The directory name will be used as the account username.
|
||||
|
||||
### Example Structure:
|
||||
```text
|
||||
archives/
|
||||
├── wanderlust_explorer/ # Instaloader format
|
||||
│ ├── 2024-01-01_12-00-00_UTC.jpg
|
||||
│ ├── 2024-01-01_12-00-00_UTC.json.xz
|
||||
│ └── wanderlust_explorer_profile_pic.jpg
|
||||
└── pixel_architect/ # Instagram Export format
|
||||
├── 2023-12-25_pixel_architect - post_123.jpg
|
||||
├── 2023-12-25_pixel_architect - post_123.json
|
||||
└── pixel_architect.jpg
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
**Prerequisites:** Node.js (LTS recommended)
|
||||
|
||||
1. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Start the development server:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Open in browser:**
|
||||
Navigate to `http://localhost:3000` and select your Instagram archive directory.
|
||||
|
||||
## Building for Production
|
||||
|
||||
To generate a production-ready build in the `dist` folder:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
To preview the build:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
1. **Install dependencies:** `npm install`
|
||||
2. **Start dev server:** `npm run dev` (Frontend on port 3000)
|
||||
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)
|
||||
|
||||
11
compose.yml
Normal file
11
compose.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
instaarchive:
|
||||
image: ghcr.io/ergosteur/instaarchive-viewer:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./archives:/archives:ro,z
|
||||
environment:
|
||||
- PORT=3000
|
||||
- ARCHIVES_DIR=/archives
|
||||
restart: unless-stopped
|
||||
137
dist-server/server.js
Normal file
137
dist-server/server.js
Normal 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}`);
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"build": "vite build && npm run build:server",
|
||||
"build:server": "tsc server.ts --esModuleInterop --module ESNext --target ES2022 --moduleResolution bundler --outDir dist-server",
|
||||
"preview": "vite preview",
|
||||
"server": "tsx server.ts",
|
||||
"clean": "rm -rf dist",
|
||||
|
||||
82
server.ts
82
server.ts
@@ -3,6 +3,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
import os from 'os';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -11,12 +12,23 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const ARCHIVES_DIR = 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] 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(`Warning: Archives directory not found at ${ARCHIVES_DIR}. Creating it...`);
|
||||
fs.mkdirSync(ARCHIVES_DIR, { recursive: true });
|
||||
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());
|
||||
@@ -24,34 +36,60 @@ 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 => item.isDirectory())
|
||||
.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);
|
||||
const files = fs.readdirSync(archivePath);
|
||||
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}`;
|
||||
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);
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
thumbnail,
|
||||
path: item.name,
|
||||
fileCount: files.length
|
||||
};
|
||||
});
|
||||
console.log(`[API] Returning ${archives.length} validated archives.`);
|
||||
res.json(archives);
|
||||
} catch (err) {
|
||||
console.error('Error listing archives:', err);
|
||||
res.status(500).json({ error: 'Failed to list archives' });
|
||||
} catch (err: any) {
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
1144
src/App.tsx
1144
src/App.tsx
File diff suppressed because it is too large
Load Diff
149
src/components/ArchiveDashboard.tsx
Normal file
149
src/components/ArchiveDashboard.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
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;
|
||||
onClearCache: (name: string) => void;
|
||||
isScanning: boolean;
|
||||
}
|
||||
|
||||
export const ArchiveDashboard: React.FC<ArchiveDashboardProps> = ({
|
||||
archives,
|
||||
localArchives = [],
|
||||
cachedArchives,
|
||||
onSelect,
|
||||
onLocalSelect,
|
||||
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={onLocalSelect}
|
||||
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>
|
||||
);
|
||||
};
|
||||
24
src/components/MediaRenderer.tsx
Normal file
24
src/components/MediaRenderer.tsx
Normal 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" />;
|
||||
};
|
||||
161
src/components/PostModal.tsx
Normal file
161
src/components/PostModal.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
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;
|
||||
onClose: () => void;
|
||||
onNextPost?: () => void;
|
||||
onPrevPost?: () => void;
|
||||
hasNextPost?: boolean;
|
||||
hasPrevPost?: boolean;
|
||||
profilePic: string | null;
|
||||
}
|
||||
|
||||
export const PostModal: React.FC<PostModalProps> = ({
|
||||
post, 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 (index: number) => {
|
||||
if (index < 0 || index >= post.media.length) return;
|
||||
const media = post.media[index];
|
||||
if (!media.url) return;
|
||||
|
||||
try {
|
||||
if (media.type === 'image') {
|
||||
const img = new Image();
|
||||
img.src = media.url;
|
||||
} else {
|
||||
const video = document.createElement('video');
|
||||
video.src = media.url;
|
||||
video.preload = 'auto';
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
// 1. Immediate preload of first two slides
|
||||
preloadMedia(0);
|
||||
preloadMedia(1);
|
||||
|
||||
// 2. Delayed preload of the rest to stay out of the way of initial render
|
||||
const timeout = setTimeout(() => {
|
||||
for (let i = 2; i < post.media.length; i++) {
|
||||
if (controller.signal.aborted) break;
|
||||
preloadMedia(i);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [post.id, post.media]);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
203
src/components/StoryViewer.tsx
Normal file
203
src/components/StoryViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
59
src/components/VideoThumbnail.tsx
Normal file
59
src/components/VideoThumbnail.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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" />;
|
||||
};
|
||||
405
src/hooks/useArchiveScanner.ts
Normal file
405
src/hooks/useArchiveScanner.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
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 }[] = [];
|
||||
|
||||
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 || '';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 } : media); }
|
||||
else post.media!.push({ name: matchedFile.name, url, type, index: mIdx + 1 });
|
||||
}
|
||||
}
|
||||
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 } : m); }
|
||||
else post.media!.push({ name: file.name, url, type, index });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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 }));
|
||||
}
|
||||
|
||||
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: isLocal ? [] : posts, stories: isLocal ? [] : 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
|
||||
};
|
||||
};
|
||||
36
src/lib/archive-files.ts
Normal file
36
src/lib/archive-files.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
20
src/main.tsx
20
src/main.tsx
@@ -4,7 +4,25 @@ import App from './App.tsx';
|
||||
import './index.css';
|
||||
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(
|
||||
<StrictMode>
|
||||
|
||||
36
src/types/index.ts
Normal file
36
src/types/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface MediaFile {
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'image' | 'video';
|
||||
index: 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;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default defineConfig(({mode}) => {
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'InstaArchive',
|
||||
short_name: 'InstaArchive',
|
||||
|
||||
Reference in New Issue
Block a user