From d396b356be86be38ceea42c74ded6df054cb4029 Mon Sep 17 00:00:00 2001 From: ergosteur <1992147+ergosteur@users.noreply.github.com> Date: Sat, 7 Mar 2026 02:28:22 -0500 Subject: [PATCH] feat: implement persistent caching, glassy scanning UI, and UI refinements --- package-lock.json | 7 + package.json | 1 + src/App.tsx | 1330 +++++++++++++-------------------------------- 3 files changed, 387 insertions(+), 951 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2968a55..f086c8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "date-fns": "^4.1.0", "dotenv": "^17.2.3", "express": "^4.21.2", + "idb-keyval": "^6.2.2", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.0", @@ -4954,6 +4955,12 @@ "dev": true, "license": "ISC" }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index 144f330..1743c65 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "date-fns": "^4.1.0", "dotenv": "^17.2.3", "express": "^4.21.2", + "idb-keyval": "^6.2.2", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.0", diff --git a/src/App.tsx b/src/App.tsx index 024c46c..04294e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,9 @@ import { MoreHorizontal, Loader2, Volume2, - VolumeX + VolumeX, + Zap, + Trash2 } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { clsx, type ClassValue } from 'clsx'; @@ -26,6 +28,7 @@ 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[]) { @@ -110,66 +113,94 @@ interface ServerArchive { 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 (
-

Your Archives

-

- Select a hosted archive to browse or upload a local directory from your computer. +

Archive Explorer

+

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

- {/* Local Upload Card */} + {/* Local Folder Card */} {/* Server Archives */} - {archives.map((archive) => ( - + + {isCached && ( + )} -
- -
-
- {archive.name} - {archive.fileCount} items -
- - ))} + ); + })}
); @@ -192,7 +223,7 @@ const StoryViewer = ({ useEffect(() => { setProgress(0); - let duration = 5000; // Default 5s for images + let duration = 5000; const interval = 50; const updateProgress = () => { @@ -250,7 +281,6 @@ const StoryViewer = ({ className="fixed inset-0 z-[100] bg-[#1a1a1a] flex items-center justify-center overflow-hidden" onClick={onClose} > - {/* Background Blur */}
- {/* Navigation Arrows (Desktop) */} - {/* Main Container */}
e.stopPropagation()} > - {/* Progress Bars */}
100 ? '1px' : (stories.length > 50 ? '2px' : '4px') }} @@ -299,7 +326,6 @@ const StoryViewer = ({ ))}
- {/* Header */}
@@ -332,7 +358,6 @@ const StoryViewer = ({
- {/* Media */}
{story.media[0].type === 'video' ? (
- {/* Interaction Areas */}
- {/* Caption Overlay */} {story.caption && (
{story.caption} @@ -371,7 +395,6 @@ const StoryViewer = ({ ); }; -// --- Cache for video thumbnails to prevent redundant processing --- const thumbnailCache = new Map(); const VideoThumbnail = ({ url, className }: { url: string; className?: string }) => { @@ -418,54 +441,30 @@ const VideoThumbnail = ({ url, className }: { url: string; className?: string }) setThumbnail(dataUrl); } } catch (err) { - console.error('Failed to capture video frame:', err); } finally { cleanup(); } }; - const handleLoadedMetadata = () => { - video.currentTime = 0.1; - }; - - const handleSeeked = () => { - captureFrame(); - }; - - const handleError = () => { - cleanup(); - }; - + const handleLoadedMetadata = () => { video.currentTime = 0.1; }; + const handleSeeked = () => { captureFrame(); }; const cleanup = () => { video.removeEventListener('loadedmetadata', handleLoadedMetadata); video.removeEventListener('seeked', handleSeeked); - video.removeEventListener('error', handleError); - video.src = ''; - video.load(); + video.src = ''; video.load(); }; video.addEventListener('loadedmetadata', handleLoadedMetadata); video.addEventListener('seeked', handleSeeked); - video.addEventListener('error', handleError); + video.addEventListener('error', cleanup); - const timeout = setTimeout(() => { - if (!thumbnailCache.has(url)) { - cleanup(); - } - }, 5000); - - return () => { - clearTimeout(timeout); - cleanup(); - }; + const timeout = setTimeout(() => { if (!thumbnailCache.has(url)) cleanup(); }, 5000); + return () => { clearTimeout(timeout); cleanup(); }; }, [url, thumbnail, isInView]); if (!thumbnail) { return ( -
+
); @@ -483,101 +482,37 @@ const VideoThumbnail = ({ url, className }: { url: string; className?: string }) 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 sizingClass = isFullView ? "w-full h-auto block" : "w-full h-full object-cover"; const mediaStyle = { transform: 'translateZ(0)' }; if (file.type === 'video') { return ( -
-
); } - return ( - - ); + return ; }; const PostModal = ({ - post, - onClose, - onNextPost, - onPrevPost, - hasNextPost, - hasPrevPost, - profilePic + post, onClose, onNextPost, onPrevPost, hasNextPost, hasPrevPost, profilePic }: { - post: Post; - onClose: () => void; - onNextPost?: () => void; - onPrevPost?: () => void; - hasNextPost?: boolean; - hasPrevPost?: boolean; - profilePic: string | null; + 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(() => setCurrentIndex(0), [post.id]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'ArrowRight') { - if (onNextPost) { - e.preventDefault(); - onNextPost(); - } - } else if (e.key === 'ArrowLeft') { - if (onPrevPost) { - e.preventDefault(); - onPrevPost(); - } - } else if (e.key === '.') { - if (currentIndex < post.media.length - 1) { - e.preventDefault(); - paginate(1); - } - } else if (e.key === ',') { - if (currentIndex > 0) { - e.preventDefault(); - paginate(-1); - } - } else if (e.key === 'Escape') { - onClose(); - } + 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); @@ -585,204 +520,56 @@ const PostModal = ({ const paginate = (newDirection: number) => { const nextIndex = currentIndex + newDirection; - if (nextIndex >= 0 && nextIndex < post.media.length) { - setDirection(newDirection); - setCurrentIndex(nextIndex); - } + if (nextIndex >= 0 && nextIndex < post.media.length) { setDirection(newDirection); setCurrentIndex(nextIndex); } }; - const variants = { - enter: (direction: number) => ({ - x: direction > 0 ? '100%' : '-100%', - }), - center: { - zIndex: 1, - x: 0, - }, - exit: (direction: number) => ({ - zIndex: 0, - x: direction < 0 ? '100%' : '-100%', - }) - }; - - const swipeConfidenceThreshold = 15000; - const interPostSwipeThreshold = 40000; - const swipePower = (offset: number, velocity: number) => { - return Math.abs(offset) * velocity; - }; + 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" - onClick={e => e.stopPropagation()} - > + + {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 swipe = swipePower(offset.x, velocity.x); - if (swipe < -swipeConfidenceThreshold) { - if (currentIndex < post.media.length - 1) { - paginate(1); - } else if (hasNextPost && onNextPost && swipe < -interPostSwipeThreshold) { - onNextPost(); - } - } else if (swipe > swipeConfidenceThreshold) { - if (currentIndex > 0) { - paginate(-1); - } else if (hasPrevPost && onPrevPost && swipe > interPostSwipeThreshold) { - onPrevPost(); - } - } - }} - className="col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing relative" - > + { + 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) => ( -
- ))} -
+ {currentIndex > 0 && } + {currentIndex < post.media.length - 1 && } +
{post.media.map((_, i) =>
)}
)}
-
-
+
-
-
-
- {profilePic ? ( - - ) : ( - {post.username[0]} - )} -
-
-
+
{profilePic ? : {post.username[0]}}
{post.username}
- -
+
-
- {profilePic ? ( - - ) : ( - {post.username[0]} - )} -
-
- {post.username} - {post.caption} -
- {format(parseISO(post.date), 'MMMM d, yyyy')} -
-
+
{profilePic ? : {post.username[0]}}
+
{post.username}{post.caption}
{format(parseISO(post.date), 'MMMM d, yyyy')}
- -
-
-
- - - -
- -
-
- Archived Post - {post.id} -
+
+
+
Archived Post{post.id}
@@ -805,826 +592,467 @@ export default function App() { 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); - useEffect(() => { - fetch('/api/archives') - .then(res => res.json()) - .then(data => { - if (Array.isArray(data) && data.length > 0) { - setServerArchives(data); - setIsServerMode(true); - } - }) - .catch(() => { - setIsServerMode(false); - }); + 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]); + 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')); - } + 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(() => { - return filteredPosts.slice(0, visiblePostsCount); - }, [filteredPosts, visiblePostsCount]); - - const postIndex = useMemo(() => { - if (!selectedPost) return -1; - return filteredPosts.findIndex(p => p.id === selectedPost.id); - }, [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 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); - } + if (file) { const url = URL.createObjectURL(file); setProfilePic(url); setAllProfilePics(prev => [url, ...prev]); } }; - const handleFiles = async (files: ArchiveFile[]) => { + 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); - setProfilePic(null); - setGridOffset(0); - console.log(`Starting scan of ${files.length} files...`); - + 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 decompressedStream = new XzReadableStream(file.stream()); - const response = new Response(decompressedStream); + const stream = new XzReadableStream(file.stream()); + const response = new Response(stream); return await response.json(); - } catch (e) { - console.error("Error decompressing XZ file:", file.name, e); - return null; + } 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 || ""; - const nodeType = obj.node_type || ""; - const productType = obj.product_type || ""; - return ( - obj.is_story === true || - obj.is_reel_media === true || - typeName.includes('Story') || - obj.audience === "MediaAudience.DEFAULT" || - nodeType === "StoryItem" || - productType === "story" || - typeName === "GraphStoryVideo" || - typeName === "GraphStoryImage" - ); + 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 = ''; + 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'; - } + 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 blob = new Blob([await file.arrayBuffer()], { type: 'image/jpeg' }); - const url = file.url || URL.createObjectURL(blob); - setProfilePic(url); + 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 (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 = exportMatch[2]; - setUsername(detectedUsername); - } + 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 (!detectedUsername && file.webkitRelativePath) { - const parts = file.webkitRelativePath.split(/[/\\]/); - if (parts.length > 1) { - detectedUsername = parts[0]; - setUsername(detectedUsername); - } - } - } - + if (loaderMatch && format === 'unknown') format = 'instaloader'; if (['jpg', 'jpeg', 'png', 'webp', 'mp4'].some(ext => lowerName.endsWith(ext))) { - const key = file.webkitRelativePath || file.name; - mediaFilesMap.set(key, file); + 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()); - + const data = jsonFile.name.endsWith('.xz') ? await parseXZFile(jsonFile) : JSON.parse(await jsonFile.text()); if (!data) continue; - - if (data.node && (data.instaloader?.node_type === 'Profile' || data.node.__typename === 'User')) { - const node = data.node; - const iphone = node.iphone_struct || {}; - setUsername(node.username || ''); - setFullName(node.full_name || ''); - setBio(node.biography || iphone.biography || ''); - setExternalUrl(node.external_url || ''); - setFollowerCount(node.edge_followed_by?.count || iphone.follower_count || 0); - setFollowingCount(node.edge_follow?.count || iphone.following_count || 0); - 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, - }; + 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; - } - } + 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 blob = new Blob([await matchedFile.arrayBuffer()], { type: matchedFile.name.endsWith('mp4') ? 'video/mp4' : 'image/jpeg' }); - const url = matchedFile.url || URL.createObjectURL(blob); 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 (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); - } + if (post.media!.length > 0) postsMap.set(postId, post); } - } catch (e) { - console.error("Error parsing JSON file:", jsonFile.name, e); - } + } catch (e) {} } } if (format !== 'json') { + setScanningPhase('Parsing'); setScannedCount(0); setTotalFiles(files.length); const CHUNK_SIZE = 100; - for (let i = 0; i < files.length; i += CHUNK_SIZE) { - const end = Math.min(i + CHUNK_SIZE, files.length); - for (let j = i; j < end; j++) { - const file = files[j]; - const lowerName = file.name.toLowerCase(); - + 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 blob = new Blob([await file.arrayBuffer()], { type: 'image/jpeg' }); - const url = file.url || URL.createObjectURL(blob); - setProfilePic(url); + 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 = ''; - let date = ''; - let user = detectedUsername || 'archived_user'; - let index = 1; - let ext = ''; - let isStory = lowerName.includes('story') || file.webkitRelativePath.toLowerCase().includes('stories'); - + 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 [_, dateMatch, userMatch, postIdMatch, indexStrMatch, storyMatch, extMatch] = match; - date = dateMatch; - user = userMatch; - postId = postIdMatch; - index = indexStrMatch ? parseInt(indexStrMatch, 10) : 1; - if (storyMatch) isStory = true; - ext = extMatch; + 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 [_, postIdMatch, indexStrMatch, storyMatch, extMatch] = match; - postId = postIdMatch; - date = postIdMatch.split('_')[0]; - index = indexStrMatch ? parseInt(indexStrMatch, 10) : 1; - if (storyMatch) isStory = true; - ext = extMatch; - } else { - 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; - } + 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')) { + 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 || {}; - 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; - } + 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 || {}; - setUsername(n.username || ''); - setFullName(n.full_name || ''); - setBio(n.biography || iph.biography || ''); - setExternalUrl(n.external_url || ''); - setFollowerCount(n.edge_followed_by?.count || iph.follower_count || 0); - setFollowingCount(n.edge_follow?.count || iph.following_count || 0); + 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 blob = new Blob([await file.arrayBuffer()], { type: lowerExt === 'mp4' ? 'video/mp4' : 'image/jpeg' }); - const url = file.url || URL.createObjectURL(blob); 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 }); - } + 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)); } } - 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, - media: sortedMedia, - thumbnail: sortedMedia[0].url, - } as 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) => a.date.localeCompare(b.date)); - setAllPosts(posts); - setAllStories(stories); - setVisiblePostsCount(90); + 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('Error processing files:', err); - } finally { - setIsScanning(false); - } + 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); + await handleFiles(archiveFiles, archive); } catch (err) { - console.error('Failed to load server archive:', err); - } finally { + 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); - }; + 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 (
- {/* Hidden Input for Directory Selection */} - handleLocalFiles(e.target.files)} - /> + handleLocalFiles(e.target.files)} /> - {/* Navigation */} -
+
{allPosts.length === 0 && !isScanning ? ( isServerMode ? ( - + ) : (
-
- -
-
-

No Archive Loaded

-

- Select the directory containing your Instagram archive files to start browsing. -

-
- +
+

No Archive Selected

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

+
) ) : isScanning ? ( -
- -
Scanning Archive...
-

Parsing media and metadata

+
+ {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...
}
+
) : ( -
- {/* Profile Header */} +
-
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} 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\.)?/, '')} - - )} -
+
{allPosts.length} posts
{followerCount.toLocaleString()} followers
{followingCount.toLocaleString()} following
+
{fullName || `@${username}`}
{bio || 'Archived profile viewer for local files.'}
{externalUrl && {externalUrl.replace(/^https?:\/\/(www\.)?/, '')}}
- {/* Tabs */}
- - - + + +
- {/* Grid */}
- {activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => ( -
- Blank -
- ))} + {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' ? ( - - ) : ( - - )} - - {/* Icons */} -
- {post.media.length > 1 && ( -
- -
- )} - {post.media.some(m => m.type === 'video') && ( -
- -
- )} -
- - {/* Hover Overlay */} -
-
- - - -
-
- - - -
-
+ 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 && ( -
- -
- )} + {filteredPosts.length > visiblePostsCount &&
}
)}
- {/* Post Modal */} - - {selectedPost && ( - setSelectedPost(null)} - onNextPost={onNextPost} - onPrevPost={onPrevPost} - hasNextPost={postIndex < filteredPosts.length - 1} - hasPrevPost={postIndex > 0} - profilePic={profilePic} - /> - )} - + {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} />} - {/* Story Viewer */} - - {showStoryViewer && allStories.length > 0 && ( - setShowStoryViewer(false)} - profilePic={profilePic} - /> - )} - - - {/* Footer */} -
-
- Meta - About - Blog - Jobs - Help - API - Privacy - Terms - Locations - Instagram Lite - Threads - Contact Uploading & Non-Users - Meta Verified -
-
© 2026 InstaArchive from Google AI Studio
-
+ {!isScanning && ( + + )}
); }