/** * @license * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useCallback, useMemo, useEffect } 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, cachedArchives, onSelect, onLocalSelect, onClearCache, isScanning }: { archives: ServerArchive[]; 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 && ( )}
); })}
); }; 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); // Using removeAttribute is safer than src = '' to stop loading without warnings 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)' }; // Guard against empty URLs 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">
{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}
); }; 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'); const [serverArchives, setServerArchives] = useState([]); const [cachedArchives, setCachedArchives] = useState>(new Set()); 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' | ''>(''); const [currentScanningImage, setCurrentScanningImage] = useState(null); const fileInputRef = React.useRef(null); const profilePicInputRef = React.useRef(null); const refreshCachedArchives = useCallback(async () => { try { const keys = await idb.keys(); setCachedArchives(new Set(keys.map(String))); } catch (e) {} }, []); 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([]); }, []); useEffect(() => { fetch('/api/archives').then(res => res.json()).then(data => { if (Array.isArray(data) && data.length > 0) { setServerArchives(data); setIsServerMode(true); } }).catch(() => setIsServerMode(false)); refreshCachedArchives(); }, [refreshCachedArchives]); // Intercept back button and page exit when in an archive useEffect(() => { const inArchive = allPosts.length > 0; const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (inArchive) { const message = 'Are you sure you want to leave? Your current archive session will be cleared.'; e.preventDefault(); e.returnValue = message; // Standard for most browsers return message; // For some older browsers } }; const handlePopState = (e: PopStateEvent) => { if (inArchive) { if (window.confirm('Exit current archive and return to explorer?')) { // Exit the archive setAllPosts([]); setAllStories([]); setCurrentArchive(null); resetProfileState(); } else { // Push the state back so the URL stays the same and we can intercept again window.history.pushState(null, ''); } } }; if (inArchive) { window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('popstate', handlePopState); // Add a history entry so the back button has something to pop window.history.pushState(null, ''); } return () => { window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('popstate', handlePopState); }; }, [allPosts.length, resetProfileState]); const filteredPosts = useMemo(() => { if (activeTab === 'reels') return allPosts.filter(p => p.media.length === 1 && p.media[0].type === 'video'); if (activeTab === 'posts') return allPosts.filter(p => !(p.media.length === 1 && p.media[0].type === 'video')); return []; }, [allPosts, activeTab]); const handleTabChange = (tab: 'posts' | 'reels' | 'saved') => { setActiveTab(tab); setVisiblePostsCount(90); }; const visiblePosts = useMemo(() => filteredPosts.slice(0, visiblePostsCount), [filteredPosts, visiblePostsCount]); const postIndex = useMemo(() => selectedPost ? filteredPosts.findIndex(p => p.id === selectedPost.id) : -1, [selectedPost, filteredPosts]); const onNextPost = useCallback(() => { if (postIndex < filteredPosts.length - 1) setSelectedPost(filteredPosts[postIndex + 1]); }, [postIndex, filteredPosts]); const onPrevPost = useCallback(() => { if (postIndex > 0) setSelectedPost(filteredPosts[postIndex - 1]); }, [postIndex, filteredPosts]); const handleProfilePicChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { const url = URL.createObjectURL(file); setProfilePic(url); setAllProfilePics(prev => [url, ...prev]); } }; const cycleProfilePic = () => { if (allProfilePics.length > 1) { const idx = allProfilePics.indexOf(profilePic || ''); setProfilePic(allProfilePics[(idx + 1) % allProfilePics.length]); } }; const handleFiles = async (files: ArchiveFile[], archiveContext?: ServerArchive) => { if (!files || files.length === 0) return; setIsScanning(true); resetProfileState(); setScanningPhase('Indexing'); setScannedCount(0); setTotalFiles(files.length); setScannedFilesLog([]); setGridOffset(0); 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) { return null; } }; let lastImageUpdateTime = 0; const throttledSetScanningImage = (url: string) => { const now = Date.now(); if (now - lastImageUpdateTime > 1000) { setCurrentScanningImage(url); lastImageUpdateTime = now; } }; try { const postsMap = new Map>(); const mediaFilesMap = new Map(); const discoveredProfilePics: { name: string, url: string }[] = []; // Local metadata tracking to avoid stale React state in the cache object 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[] = []; let i = 0; for (const file of files) { i++; if (i % 50 === 0 || i === files.length) { setScannedCount(i); 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'; else if (format === 'unknown' && (lowerName.includes('story') || lowerName.includes('post'))) format = 'json'; continue; } if (lowerName.includes('_profile_pic.jpg')) { const url = file.url || URL.createObjectURL(new Blob([await file.arrayBuffer()], { type: 'image/jpeg' })); discoveredProfilePics.push({ name: file.name, url }); if (!detectedUsername && file.webkitRelativePath) { const parts = file.webkitRelativePath.split(/[/\\]/); if (parts.length > 1) { detectedUsername = parts[0]; setUsername(detectedUsername); } } if (format === 'unknown') format = 'instaloader'; continue; } const exportMatch = file.name.match(exportRegex); if (exportMatch) { if (!detectedUsername || detectedUsername === currentArchive?.name) { detectedUsername = exportMatch[2]; setUsername(detectedUsername); } format = 'export'; } const loaderMatch = file.name.match(instaloaderRegex); if (loaderMatch && format === 'unknown') format = 'instaloader'; if (['jpg', 'jpeg', 'png', 'webp', 'mp4'].some(ext => lowerName.endsWith(ext))) { mediaFilesMap.set(file.webkitRelativePath || file.name, file); } } if (format === 'json' || format === 'instaloader') { setScanningPhase('Parsing'); setTotalFiles(jsonFiles.length); let filesProcessed = 0; for (const jsonFile of jsonFiles) { filesProcessed++; 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 (jsonFiles.length === 1) setTotalFiles(items.length); else { setScannedCount(filesProcessed); setScannedFilesLog(prev => [`Parsing ${jsonFile.name}`, ...prev.slice(0, 19)]); } 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()) { if (jsonFiles.length === 1 && (idx % 10 === 0 || idx === items.length - 1)) { setScannedCount(idx + 1); setScannedFilesLog(prev => [`Parsing metadata for ${item.id || item.node?.id || 'item'}`, ...prev.slice(0, 19)]); } 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', 'jpg', 'jpeg', 'png', 'webp']) { 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 = matchedFile.name.toLowerCase().endsWith('mp4') ? '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) {} } } if (format !== 'json') { setScanningPhase('Parsing'); setScannedCount(0); setTotalFiles(files.length); 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 => [`Processing batch ${Math.floor(j_start/CHUNK_SIZE) + 1}...`, ...prev.slice(0, 19)]); for (let j = j_start; j < end; j++) { const file = files[j]; const lowerName = file.name.toLowerCase(); if (detectedUsername && lowerName === `${detectedUsername.toLowerCase()}.jpg`) { const url = file.url || URL.createObjectURL(new Blob([await file.arrayBuffer()], { type: 'image/jpeg' })); if (!discoveredProfilePics.some(p => p.name === file.name)) discoveredProfilePics.push({ name: file.name, url }); continue; } let postId = '', date = '', user = detectedUsername || 'archived_user', index = 1, ext = '', isStory = lowerName.includes('story') || file.webkitRelativePath.toLowerCase().includes('stories'); if (format === 'export') { const match = file.name.match(exportRegex); if (!match) continue; const [_, dMatch, uMatch, pMatch, iStrMatch, sMatch, eMatch] = match; date = dMatch; user = uMatch; postId = pMatch; index = iStrMatch ? parseInt(iStrMatch, 10) : 1; if (sMatch) isStory = true; ext = eMatch; } else if (format === 'instaloader') { const match = file.name.match(instaloaderRegex); if (!match) continue; const [_, pMatch, iStrMatch, sMatch, eMatch] = match; postId = pMatch; date = pMatch.split('_')[0]; index = iStrMatch ? parseInt(iStrMatch, 10) : 1; if (sMatch) isStory = true; ext = eMatch; } else continue; 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') post.caption = await file.text(); else if (lowerExt === 'json' || lowerName.endsWith('.json.xz')) { try { const data = lowerName.endsWith('.xz') ? await parseXZFile(file) : JSON.parse(await file.text()); if (!data) continue; const node = data.node || data; const iphone = node.iphone_struct || {}; if (node.edge_media_to_caption?.edges?.[0]?.node?.text) post.caption = node.edge_media_to_caption.edges[0].node.text; else if (node.caption?.text) post.caption = node.caption.text; else if (iphone.caption?.text) post.caption = iphone.caption.text; if (checkIsStory(data) || checkIsStory(node) || checkIsStory(data.instaloader) || checkIsStory(iphone)) post.isStory = true; if (data.node && (data.instaloader?.node_type === 'Profile' || data.node.__typename === 'User')) { const n = data.node; const iph = n.iphone_struct || {}; localFullName = n.full_name || ''; localBio = n.biography || iph.biography || ''; localExternalUrl = n.external_url || ''; localFollowerCount = n.edge_followed_by?.count || iph.follower_count || 0; localFollowingCount = n.edge_follow?.count || iph.following_count || 0; setFullName(localFullName); setBio(localBio); setExternalUrl(localExternalUrl); setFollowerCount(localFollowerCount); setFollowingCount(localFollowingCount); } } catch (e) {} } else if (['jpg', 'jpeg', 'png', 'webp', 'mp4'].includes(lowerExt)) { const type = lowerExt === 'mp4' ? '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 (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) => a.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; if (archiveToCache) { if (posts.length === 0 && stories.length === 0) { console.warn(`[Cache] Skipping cache save for ${archiveToCache.name} because no items were found.`); return; } console.log(`[Cache] Saving data for ${archiveToCache.name} to persistent storage...`); const cacheData = { name: archiveToCache.name, fileCount: archiveToCache.fileCount, posts, stories, profileMetadata: { username: finalUsername, fullName: localFullName, bio: localBio, followerCount: localFollowerCount, followingCount: localFollowingCount, externalUrl: localExternalUrl, profilePic: localProfilePic }, allProfilePics: discoveredProfilePics.map(p => p.url), timestamp: Date.now() }; try { await idb.set(archiveToCache.name, cacheData); console.log(`[Cache] Data for ${archiveToCache.name} saved successfully.`); await refreshCachedArchives(); } catch (e) { console.error(`[Cache] Failed to save to IndexedDB:`, e); } } } catch (err) { console.error(`[Scanner] Critical error during scan:`, err); } finally { setIsScanning(false); } }; const loadServerArchive = async (archive: ServerArchive) => { console.log(`[Cache] Attempting to load archive: ${archive.name}`); setIsScanning(true); setCurrentArchive(archive); setCurrentScanningImage(null); setScanningPhase('Checking Cache'); try { const cachedData = await idb.get(archive.name); if (cachedData) { console.log(`[Cache] Found cached data for ${archive.name}. File count: ${cachedData.fileCount} (Server has: ${archive.fileCount})`); if (cachedData.fileCount === archive.fileCount) { 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); 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; return new RemoteArchiveFile(name, p, 0, `/archives/${archive.name}/${p}`); }); await handleFiles(archiveFiles, archive); } catch (err) { console.error('[Scanner] Failed to load server archive:', err); setIsScanning(false); } }; 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(); const loadMore = () => setVisiblePostsCount(prev => prev + 90); return (
handleLocalFiles(e.target.files)} />
{allPosts.length === 0 && !isScanning ? ( isServerMode ? ( ) : (

No Archive Selected

Select a local archive folder to start browsing. Your files are processed locally in the browser and never uploaded.

) ) : isScanning ? (
{currentScanningImage && ()}
Scanning Archive...

{scanningPhase === 'Indexing' ? 'Building file index' : 'Parsing metadata & media'}

Phase: {scanningPhase}{scannedCount} / {totalFiles}
System Parser Feed
Live Output
{scannedFilesLog.map((log, idx) => (
[{new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}]{scanningPhase === 'Indexing' ? 'IDX' : 'PARSE'}{log}
))}{scannedFilesLog.length === 0 &&
Initializing scanner context...
}
) : (
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]}}

{username}

{allProfilePics.length === 0 && } {allProfilePics.length > 1 && }
{allPosts.length} posts
{followerCount.toLocaleString()} followers
{followingCount.toLocaleString()} following
{fullName || `@${username}`}
{bio || 'Archived profile viewer for local files.'}
{externalUrl && {externalUrl.replace(/^https?:\/\/(www\.)?/, '')}}
{activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => (
Blank
))} {visiblePosts.map((post) => ( setSelectedPost(post)} className={cn("relative group cursor-pointer overflow-hidden bg-gray-200 transition-all duration-300", activeTab === 'reels' ? "aspect-[9/16]" : (gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]"))}> {post.media[0].type === 'video' ? : }
{post.media.length > 1 &&
}{post.media.some(m => m.type === 'video') &&
}
-
-
))}
{filteredPosts.length > visiblePostsCount &&
}
)}
{selectedPost && setSelectedPost(null)} onNextPost={onNextPost} onPrevPost={onPrevPost} hasNextPost={postIndex < filteredPosts.length - 1} hasPrevPost={postIndex > 0} profilePic={profilePic} />} {showStoryViewer && allStories.length > 0 && setShowStoryViewer(false)} profilePic={profilePic} />} {!isScanning && ( )}
); }