diff --git a/GEMINI.md b/GEMINI.md index d0bc3d9..b70931f 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -5,16 +5,20 @@ **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 -- **Permalinks:** Full synchronization between application state and URL query parameters (`?a=`, `?t=`, `?p=`). Supports deep-linking to archives, tabs, and specific posts. -- **Persistent Caching:** Uses `idb-keyval` (IndexedDB) to cache parsed metadata and server-side media URLs. Subsequent loads of the same archive are instant. -- **Glassy Scanner UI:** A custom-built glassmorphism scanning dashboard with throttled (1s) dynamic blurred backgrounds and a high-density system log. -- **Support for Multiple Formats:** Recognizes official Instagram export structures and Instaloader regex-based naming conventions. +- **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). -- **Navigation Protection:** Intercepts browser history (`popstate`) and exit events (`beforeunload`) to prevent session loss while maintaining a "Back to Explorer" SPA flow. -- **Production-Ready Docker:** Multi-stage Docker builds using `node:slim` to serve both the Express API and the Vite-built frontend. +- **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`) @@ -22,31 +26,44 @@ - **Backend:** Express, tsx (for server-side scanning) - **Decompression:** xz-decompress (WASM) -## Known Issues -- **Generic Collection Parser:** Currently unreliable for non-Instagram archive structures (e.g., folders with arbitrary media filenames). It may fail to correctly identify or group posts in some environments. +## 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 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. -## Troubleshooting Cache (PWA) -Since the app is a PWA, the browser may cache old JavaScript bundles. If new features don't appear: -1. Open DevTools -> Application -> Service Workers. -2. Click **Unregister** for the localhost service worker. -3. Go to **Storage** and click **Clear site data**. -4. Perform a Hard Refresh (`Ctrl + Shift + R`). - ## 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) - -## 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. diff --git a/README.md b/README.md index f1ae551..d392cb9 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ A high-performance React PWA for browsing archived Instagram data with a native- ## Features -- **Persistent Caching**: Uses IndexedDB to store parsed archives locally. Subsequent loads are near-instant. -- **Permalinks**: State is synchronized with the URL, allowing you to share direct links to archives, tabs, or specific posts. -- **Glassy Scanning UI**: A modern, translucent white terminal experience with a dynamic blurred background of your media. -- **Local Privacy**: All processing is done client-side. Even when using the self-hosted version, your media is processed locally in your browser. -- **Multiple Formats**: Supports official Instagram JSON exports and Instaloader regex-based naming conventions. +- **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. - **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. @@ -78,5 +79,6 @@ archives/ **Prerequisites:** Node.js (LTS recommended) 1. **Install dependencies:** `npm install` -2. **Start dev server:** `npm run dev` -3. **Start local backend:** `npm run server` (Optional, serves `./_sample-archives`) +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) diff --git a/src/App.tsx b/src/App.tsx index 650c4ca..bea1a31 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,612 +3,34 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { Grid3X3, Play, Layers, - ChevronLeft, - ChevronRight, - X, FolderOpen, Heart, MessageCircle, Bookmark, - MoreHorizontal, Loader2, - Volume2, - VolumeX, - Zap, - Trash2 } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; -import { format, parseISO } from 'date-fns'; -// @ts-ignore -import { XzReadableStream } from 'xz-decompress'; import * as idb from 'idb-keyval'; -// --- Utilities --- -function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -// --- Types --- - -interface MediaFile { - name: string; - url: string; - type: 'image' | 'video'; - index: number; -} - -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. - */ -interface ArchiveFile { - name: string; - webkitRelativePath: string; - size: number; - text(): Promise; - arrayBuffer(): Promise; - stream(): ReadableStream; - url?: string; -} - -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(); } -} - -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; - } -} - -interface ServerArchive { - name: string; - thumbnail: string; - path: string; - fileCount: number; -} - -// --- Components --- - -const ArchiveDashboard = ({ - archives, - localArchives = [], - cachedArchives, - onSelect, - onLocalSelect, - onClearCache, - isScanning -}: { - archives: ServerArchive[]; - localArchives?: any[]; - cachedArchives: Set; - onSelect: (archive: ServerArchive) => void; - onLocalSelect: () => void; - onClearCache: (name: string) => void; - isScanning: boolean; -}) => { - return ( -
-
-

Archive Explorer

-

- Browse hosted collections or open a local archive folder. All processing happens locally in your browser for maximum privacy. -

-
- -
- {/* Local Folder Card */} - - - {/* Server Archives */} - {archives.map((archive) => { - const isCached = cachedArchives.has(archive.name); - return ( -
- - - {isCached && ( - - )} -
- ); - })} - - {/* Local Cached Archives */} - {localArchives.map((archive) => ( -
- - -
- ))} -
-
- ); -}; - -const StoryViewer = ({ - stories, - onClose, - profilePic -}: { - stories: Post[]; - onClose: () => void; - profilePic: string | null; -}) => { - const [currentStoryIndex, setCurrentStoryIndex] = useState(0); - const [progress, setProgress] = useState(0); - const [isMuted, setIsMuted] = useState(false); - const videoRef = React.useRef(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 ( - -
- -
- - - - - -
e.stopPropagation()} - > -
100 ? '1px' : (stories.length > 50 ? '2px' : '4px') }} - > - {stories.map((_, i) => ( -
-
-
- ))} -
- -
-
-
-
- {profilePic ? ( - - ) : ( - {story.username[0]} - )} -
-
-
- {story.username} - {format(parseISO(story.date), 'MMM d')} -
-
- -
- {story.media[0].type === 'video' && ( - - )} - -
-
- -
- {story.media[0].type === 'video' ? ( -
- -
-
-
-
- - {story.caption && ( -
- {story.caption} -
- )} -
- - ); -}; - -const thumbnailCache = new Map(); - -const VideoThumbnail = ({ url, className }: { url: string; className?: string }) => { - const [thumbnail, setThumbnail] = useState(thumbnailCache.get(url) || null); - const [isInView, setIsInView] = useState(false); - const containerRef = React.useRef(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 ( -
- -
- ); - - return ; -}; - -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
; - - if (file.type === 'video') { - return ( -
-
- ); - } - return ; -}; - -const PostModal = ({ - post, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic -}: { - post: Post; onClose: () => void; onNextPost?: () => void; onPrevPost?: () => void; hasNextPost?: boolean; hasPrevPost?: boolean; profilePic: string | null; -}) => { - const [currentIndex, setCurrentIndex] = useState(0); - const [direction, setDirection] = useState(0); - 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%' }), center: { zIndex: 1, x: 0 }, exit: (d: number) => ({ zIndex: 0, x: d < 0 ? '100%' : '-100%' }) }; - const swipePower = (offset: number, velocity: number) => Math.abs(offset) * velocity; - - return ( - -
- - {hasPrevPost && onPrevPost && } - {hasNextPost && onNextPost && } - { 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()}> -
-
- - { - 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"> - - - -
- {post.media.length > 1 && ( - <> - {currentIndex > 0 && } - {currentIndex < post.media.length - 1 && } -
{post.media.map((_, i) =>
)}
- - )} -
-
-
-
-
{profilePic ? : {post.username[0]}}
- {post.username} -
- -
-
-
-
{profilePic ? : {post.username[0]}}
-
{post.username}{post.caption}
{format(parseISO(post.date), 'MMMM d, yyyy')}
-
-
-
-
-
Archived Post{post.id}
-
-
- -
-
- ); -}; +import { cn } from './lib/utils'; +import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files'; +import { Post, ServerArchive } from './types'; +import { ArchiveDashboard } from './components/ArchiveDashboard'; +import { StoryViewer } from './components/StoryViewer'; +import { PostModal } from './components/PostModal'; +import { VideoThumbnail } from './components/VideoThumbnail'; +import { useArchiveScanner } from './hooks/useArchiveScanner'; export default function App() { - const [allPosts, setAllPosts] = useState([]); - const [allStories, setAllStories] = useState([]); const [showStoryViewer, setShowStoryViewer] = useState(false); const [visiblePostsCount, setVisiblePostsCount] = useState(90); - const [isScanning, setIsScanning] = useState(false); const [selectedPost, setSelectedPost] = useState(null); - const [username, setUsername] = useState(''); - const [fullName, setFullName] = useState(''); - const [bio, setBio] = useState(''); - const [followerCount, setFollowerCount] = useState(0); - const [followingCount, setFollowingCount] = useState(0); - const [externalUrl, setExternalUrl] = useState(''); - const [profilePic, setProfilePic] = useState(null); - const [allProfilePics, setAllProfilePics] = useState([]); + const [gridAspectRatio, setGridAspectRatio] = useState<'1:1' | '3:4'>('1:1'); const [gridOffset, setGridOffset] = useState(0); const [activeTab, setActiveTab] = useState<'posts' | 'reels' | 'saved'>('posts'); @@ -618,14 +40,8 @@ export default function App() { const [isServerMode, setIsServerMode] = useState(false); const [currentArchive, setCurrentArchive] = useState(null); - const [scannedCount, setScannedCount] = useState(0); - const [totalFiles, setTotalFiles] = useState(0); - const [scannedFilesLog, setScannedFilesLog] = useState([]); - const [scanningPhase, setScanningPhase] = useState<'Indexing' | 'Parsing' | 'Checking Cache' | ''>(''); - const [currentScanningImage, setCurrentScanningImage] = useState(null); - - const fileInputRef = React.useRef(null); - const profilePicInputRef = React.useRef(null); + const fileInputRef = useRef(null); + const profilePicInputRef = useRef(null); const refreshCachedArchives = useCallback(async () => { try { @@ -636,6 +52,10 @@ export default function App() { for (const key of keys) { const data: any = await idb.get(key); if (data && data.isLocal) { + // Fallback for missing allProfilePics in local cached metadata + if (!data.profileMetadata.allProfilePics) { + data.profileMetadata.allProfilePics = data.profileMetadata.profilePic ? [data.profileMetadata.profilePic] : []; + } locals.push(data); } } @@ -643,8 +63,54 @@ export default function App() { } catch (e) {} }, []); + const { + isScanning, + scanningPhase, + scannedCount, + totalFiles, + scannedFilesLog, + currentScanningImage, + allPosts, + allStories, + profileMetadata, + handleFiles, + setAllPosts, + setAllStories, + setProfileMetadata, + setIsScanning, + setScanningPhase, + resetScannerState + } = useArchiveScanner('', currentArchive, refreshCachedArchives); + + const { + username, + fullName, + bio, + followerCount, + followingCount, + externalUrl, + profilePic, + allProfilePics + } = profileMetadata; + + useEffect(() => { + fetch('/api/archives') + .then(res => { + if (res.ok) { + setIsServerMode(true); + return res.json(); + } + return []; + }) + .then(data => setServerArchives(data)) + .catch(() => setIsServerMode(false)); + }, []); + + useEffect(() => { + refreshCachedArchives(); + }, [refreshCachedArchives]); + const clearCache = async (name: string) => { await idb.del(name); await refreshCachedArchives(); }; - const resetProfileState = useCallback(() => { setUsername(''); setFullName(''); setBio(''); setFollowerCount(0); setFollowingCount(0); setExternalUrl(''); setProfilePic(null); setAllProfilePics([]); }, []); const filteredPosts = useMemo(() => { if (activeTab === 'reels') return allPosts.filter(p => p.media.length === 1 && p.media[0].type === 'video'); @@ -660,312 +126,23 @@ export default function App() { const handleProfilePicChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (file) { const url = URL.createObjectURL(file); setProfilePic(url); setAllProfilePics(prev => [url, ...prev]); } + if (file) { + const url = URL.createObjectURL(file); + setProfileMetadata(prev => ({ ...prev, profilePic: url, allProfilePics: [url, ...prev.allProfilePics] })); + } }; - const cycleProfilePic = () => { if (allProfilePics.length > 1) { const idx = allProfilePics.indexOf(profilePic || ''); setProfilePic(allProfilePics[(idx + 1) % allProfilePics.length]); } }; - - const handleFiles = useCallback(async (files: ArchiveFile[], archiveContext?: ServerArchive) => { - if (!files || files.length === 0) return; - setIsScanning(true); resetProfileState(); setScanningPhase('Indexing'); setScannedCount(0); setTotalFiles(files.length); setScannedFilesLog([]); setGridOffset(0); - 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>(); - const mediaFilesMap = new Map(); - 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 detectedUsername = archiveContext?.name || currentArchive?.name || ''; - if (detectedUsername) setUsername(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 % 50 === 0 || i === files.length - 1) { - setScannedCount(i + 1); - setScannedFilesLog(prev => [`Indexed ${file.name}`, ...prev.slice(0, 19)]); - } - 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') || (detectedUsername && lowerName === `${detectedUsername.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; - setFullName(localFullName); setBio(localBio); setExternalUrl(localExternalUrl); - setFollowerCount(localFollowerCount); setFollowingCount(localFollowingCount); - 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 = { id: postId, date, username: detectedUsername || '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); } - } - } - - 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 = detectedUsername || '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(); - 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; - for (const [baseName, groupFiles] of genericGroupingMap.entries()) { - 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)]); - } - 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: detectedUsername || '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); - setAllProfilePics(urls); localProfilePic = urls[0]; setProfilePic(localProfilePic); - } - - const finalUsername = detectedUsername || '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(b.date)); - - setAllPosts(posts); setAllStories(stories); setVisiblePostsCount(90); - console.log(`[Scanner] Finalized ${posts.length} posts and ${stories.length} stories.`); - - const archiveToCache = archiveContext || currentArchive; - const isLocal = !archiveToCache; - const cacheKey = archiveToCache ? archiveToCache.name : (detectedUsername || '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, resetProfileState, refreshCachedArchives, setCurrentScanningImage, setBio, setExternalUrl, setFollowerCount, setFollowingCount, setFullName, setProfilePic, setUsername]); + const cycleProfilePic = () => { + if (allProfilePics.length > 1) { + const idx = allProfilePics.indexOf(profilePic || ''); + setProfileMetadata(prev => ({ ...prev, profilePic: allProfilePics[(idx + 1) % allProfilePics.length] })); + } + }; const loadServerArchive = useCallback(async (archive: ServerArchive) => { console.log(`[Cache] Attempting to load archive: ${archive.name}`); setIsScanning(true); setCurrentArchive(archive); - setCurrentScanningImage(null); setScanningPhase('Checking Cache'); try { @@ -976,29 +153,27 @@ export default function App() { console.log(`[Cache] Cache hit! Restoring state...`); setAllPosts(cachedData.posts); setAllStories(cachedData.stories); - setUsername(cachedData.profileMetadata.username); - setFullName(cachedData.profileMetadata.fullName); - setBio(cachedData.profileMetadata.bio); - setFollowerCount(cachedData.profileMetadata.followerCount); - setFollowingCount(cachedData.profileMetadata.followingCount); - setExternalUrl(cachedData.profileMetadata.externalUrl); - setProfilePic(cachedData.profileMetadata.profilePic); - setAllProfilePics(cachedData.allProfilePics); + + // Handle migration from old cache schema where allProfilePics was a separate top-level key + const profileMetadata = { ...cachedData.profileMetadata }; + if (!profileMetadata.allProfilePics && cachedData.allProfilePics) { + profileMetadata.allProfilePics = cachedData.allProfilePics; + } + if (!profileMetadata.allProfilePics) { + profileMetadata.allProfilePics = profileMetadata.profilePic ? [profileMetadata.profilePic] : []; + } + + setProfileMetadata(profileMetadata); setVisiblePostsCount(90); setIsScanning(false); console.log(`[Cache] Archive ${archive.name} loaded successfully from cache.`); return; - } else { - console.log(`[Cache] Cache mismatch (file count changed). Proceeding with fresh scan.`); } - } else { - console.log(`[Cache] No cached data found for ${archive.name}.`); } console.log(`[Scanner] Starting fresh scan from server API...`); const res = await fetch(`/api/archives/${archive.name}/files`); const filePaths: string[] = await res.json(); - console.log(`[Scanner] Server reported ${filePaths.length} files.`); const archiveFiles = filePaths.map(p => { const name = p.split(/[/\\]/).pop() || p; @@ -1010,7 +185,7 @@ export default function App() { console.error('[Scanner] Failed to load server archive:', err); setIsScanning(false); } - }, [handleFiles]); + }, [handleFiles, setAllPosts, setAllStories, setProfileMetadata, setIsScanning, setScanningPhase]); const handleLocalFiles = (files: FileList | null) => { if (!files) return; const archiveFiles = Array.from(files).map(f => new LocalArchiveFile(f)); handleFiles(archiveFiles); }; const triggerFileSelect = () => fileInputRef.current?.click(); @@ -1020,8 +195,14 @@ export default function App() { const params = new URLSearchParams(window.location.search); if (currentArchive) params.set('a', currentArchive.name); else if (allPosts.length > 0 && username) params.set('a', username); + else params.delete('a'); + if (activeTab !== 'posts') params.set('t', activeTab); + else params.delete('t'); + if (selectedPost) params.set('p', selectedPost.id); + else params.delete('p'); + const newSearch = params.toString(); const currentSearch = new URLSearchParams(window.location.search).toString(); if (newSearch !== currentSearch) { @@ -1042,7 +223,7 @@ export default function App() { if (archiveName) { const archive = serverArchives.find(a => a.name === archiveName); if (archive) { - console.log(`[Permalink] Auto-loading archive: ${archiveName}`); + console.log(`[Permalink] Auto-loading archive: ?a=${archiveName}`); loadServerArchive(archive); if (tab && ['posts', 'reels', 'saved'].includes(tab)) { setActiveTab(tab as any); @@ -1067,7 +248,7 @@ export default function App() {
@@ -1116,7 +297,7 @@ export default function App() { ) : (
-
0 ? "bg-gradient-to-tr from-yellow-400 via-red-500 to-purple-600" : "bg-gray-200" )} onClick={() => allStories.length > 0 && setShowStoryViewer(true)}>
{profilePic ? {username} setProfilePic(null)} referrerPolicy="no-referrer" /> : {username[0]}}
+
0 ? "bg-gradient-to-tr from-yellow-400 via-red-500 to-purple-600" : "bg-gray-200" )} onClick={() => allStories.length > 0 && setShowStoryViewer(true)}>
{profilePic ? {username} setProfileMetadata(prev => ({ ...prev, profilePic: null }))} referrerPolicy="no-referrer" /> : {username?.[0] || 'U'}}

{username}

@@ -1126,7 +307,7 @@ export default function App() { {allProfilePics.length > 1 && }
-
{allPosts.length} posts
{followerCount.toLocaleString()} followers
{followingCount.toLocaleString()} following
+
{allPosts.length} posts
{(followerCount || 0).toLocaleString()} followers
{(followingCount || 0).toLocaleString()} following
{fullName || `@${username}`}
{bio || 'Archived profile viewer for local files.'}
{externalUrl && {externalUrl.replace(/^https?:\/\/(www\.)?/, '')}}
diff --git a/src/components/ArchiveDashboard.tsx b/src/components/ArchiveDashboard.tsx new file mode 100644 index 0000000..562d345 --- /dev/null +++ b/src/components/ArchiveDashboard.tsx @@ -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; + onSelect: (archive: ServerArchive) => void; + onLocalSelect: () => void; + onClearCache: (name: string) => void; + isScanning: boolean; +} + +export const ArchiveDashboard: React.FC = ({ + archives, + localArchives = [], + cachedArchives, + onSelect, + onLocalSelect, + onClearCache, + isScanning +}) => { + return ( +
+
+

Archive Explorer

+

+ Browse hosted collections or open a local archive folder. All processing happens locally in your browser for maximum privacy. +

+
+ +
+ {/* Local Folder Card */} + + + {/* Server Archives */} + {archives.map((archive) => { + const isCached = cachedArchives.has(archive.name); + return ( +
+ + + {isCached && ( + + )} +
+ ); + })} + + {/* Local Cached Archives */} + {localArchives.map((archive) => ( +
+ + +
+ ))} +
+
+ ); +}; diff --git a/src/components/MediaRenderer.tsx b/src/components/MediaRenderer.tsx new file mode 100644 index 0000000..5aafc58 --- /dev/null +++ b/src/components/MediaRenderer.tsx @@ -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
; + + if (file.type === 'video') { + return ( +
+
+ ); + } + return ; +}; diff --git a/src/components/PostModal.tsx b/src/components/PostModal.tsx new file mode 100644 index 0000000..a8094d2 --- /dev/null +++ b/src/components/PostModal.tsx @@ -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 = ({ + 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 ( + +
+ + {hasPrevPost && onPrevPost && } + {hasNextPost && onNextPost && } + { 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()}> +
+
+ + { + 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" + > + + + +
+ {post.media.length > 1 && ( + <> + {currentIndex > 0 && } + {currentIndex < post.media.length - 1 && } +
{post.media.map((_, i) =>
)}
+ + )} +
+
+
+
+
{profilePic ? : {post.username[0]}}
+ {post.username} +
+ +
+
+
+
{profilePic ? : {post.username[0]}}
+
{post.username}{post.caption}
{format(parseISO(post.date), 'MMMM d, yyyy')}
+
+
+
+
+
Archived Post{post.id}
+
+
+ +
+
+ ); +}; diff --git a/src/components/StoryViewer.tsx b/src/components/StoryViewer.tsx new file mode 100644 index 0000000..7f309e4 --- /dev/null +++ b/src/components/StoryViewer.tsx @@ -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 = ({ + stories, + onClose, + profilePic +}) => { + const [currentStoryIndex, setCurrentStoryIndex] = useState(0); + const [progress, setProgress] = useState(0); + const [isMuted, setIsMuted] = useState(false); + const videoRef = useRef(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 ( + +
+ +
+ + + + + +
e.stopPropagation()} + > +
100 ? '1px' : (stories.length > 50 ? '2px' : '4px') }} + > + {stories.map((_, i) => ( +
+
+
+ ))} +
+ +
+
+
+
+ {profilePic ? ( + + ) : ( + {story.username[0]} + )} +
+
+
+ {story.username} + {format(parseISO(story.date), 'MMM d')} +
+
+ +
+ {story.media[0].type === 'video' && ( + + )} + +
+
+ +
+ {story.media[0].type === 'video' ? ( +
+ +
+
+
+
+ + {story.caption && ( +
+ {story.caption} +
+ )} +
+ + ); +}; diff --git a/src/components/VideoThumbnail.tsx b/src/components/VideoThumbnail.tsx new file mode 100644 index 0000000..cc660b8 --- /dev/null +++ b/src/components/VideoThumbnail.tsx @@ -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(); + +export const VideoThumbnail = ({ url, className }: { url: string; className?: string }) => { + const [thumbnail, setThumbnail] = useState(thumbnailCache.get(url) || null); + const [isInView, setIsInView] = useState(false); + const containerRef = useRef(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 ( +
+ +
+ ); + + return ; +}; diff --git a/src/hooks/useArchiveScanner.ts b/src/hooks/useArchiveScanner.ts new file mode 100644 index 0000000..eb78984 --- /dev/null +++ b/src/hooks/useArchiveScanner.ts @@ -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 +) => { + 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([]); + const [currentScanningImage, setCurrentScanningImage] = useState(null); + + // Result state + const [allPosts, setAllPosts] = useState([]); + const [allStories, setAllStories] = useState([]); + 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>(); + const mediaFilesMap = new Map(); + 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 = { 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(); + 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 + }; +}; diff --git a/src/lib/archive-files.ts b/src/lib/archive-files.ts new file mode 100644 index 0000000..1da78b3 --- /dev/null +++ b/src/lib/archive-files.ts @@ -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; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/main.tsx b/src/main.tsx index 44f83ae..d5b002c 100644 --- a/src/main.tsx +++ b/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( diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..76642bb --- /dev/null +++ b/src/types/index.ts @@ -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; + arrayBuffer(): Promise; + stream(): ReadableStream; + url?: string; +} + +export interface ServerArchive { + name: string; + thumbnail: string; + path: string; + fileCount: number; +} diff --git a/vite.config.ts b/vite.config.ts index 0300994..c7299cc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,7 +11,7 @@ export default defineConfig(({mode}) => { react(), tailwindcss(), VitePWA({ - registerType: 'prompt', + registerType: 'autoUpdate', manifest: { name: 'InstaArchive', short_name: 'InstaArchive',