/** * @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 } 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'; // --- 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; } // --- Components --- const StoryViewer = ({ stories, onClose }: { stories: Post[]; onClose: () => void; }) => { const [currentStoryIndex, setCurrentStoryIndex] = useState(0); const [progress, setProgress] = useState(0); const story = stories[currentStoryIndex]; useEffect(() => { setProgress(0); const duration = 5000; // 5 seconds per story const interval = 50; const step = (interval / duration) * 100; const timer = setInterval(() => { setProgress(prev => { if (prev >= 100) { if (currentStoryIndex < stories.length - 1) { setCurrentStoryIndex(prev => prev + 1); return 0; } else { onClose(); return 100; } } return prev + step; }); }, interval); return () => clearInterval(timer); }, [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()} > {/* Progress Bars */} {stories.map((_, i) => ( ))} {/* Header */} {story.username[0]} {story.username} {format(parseISO(story.date), 'MMM d')} {/* Media */} {story.media[0].type === 'video' ? ( ) : ( )} {/* Navigation Areas */} {/* Caption */} {story.caption && ( {story.caption} )} ); }; // --- Cache for video thumbnails to prevent redundant processing --- 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' } // Start loading before it's actually in view ); 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) { console.error('Failed to capture video frame:', err); } finally { cleanup(); } }; const handleLoadedMetadata = () => { video.currentTime = 0.1; }; const handleSeeked = () => { captureFrame(); }; const handleError = () => { cleanup(); }; const cleanup = () => { video.removeEventListener('loadedmetadata', handleLoadedMetadata); video.removeEventListener('seeked', handleSeeked); video.removeEventListener('error', handleError); video.src = ''; video.load(); }; video.addEventListener('loadedmetadata', handleLoadedMetadata); video.addEventListener('seeked', handleSeeked); video.addEventListener('error', handleError); 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 sizingClass = isFullView ? "w-full h-auto block" : "w-full h-full object-cover"; const mediaStyle = { transform: 'translateZ(0)' }; if (file.type === 'video') { return ( ); } return ( ); }; const PostModal = ({ post, onClose, initialFullView = false, onNextPost, onPrevPost, hasNextPost, hasPrevPost }: { post: Post; onClose: () => void; initialFullView?: boolean; onNextPost?: () => void; onPrevPost?: () => void; hasNextPost?: boolean; hasPrevPost?: boolean; }) => { const [currentIndex, setCurrentIndex] = useState(0); const [isFullView, setIsFullView] = useState(initialFullView); const [direction, setDirection] = useState(0); // Reset currentIndex when post changes 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(); } }; 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: (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; // Increased from 10000 for more intentional swipes const interPostSwipeThreshold = 40000; // Higher threshold for switching between different posts const swipePower = (offset: number, velocity: number) => { return Math.abs(offset) * velocity; }; return ( {/* Post Navigation Buttons - Hidden on Mobile */} {hasPrevPost && onPrevPost && ( { e.stopPropagation(); onPrevPost(); }} className="hidden md:block fixed left-4 md:left-10 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-50 transition-transform hover:scale-110 active:scale-90" > )} {hasNextPost && onNextPost && ( { e.stopPropagation(); onNextPost(); }} className="hidden md:block fixed right-4 md:right-10 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 z-50 transition-transform hover:scale-110 active:scale-90" > )} { // Higher threshold for vertical dismissal to prevent accidental triggers during scroll 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()} > {/* Media Section */} { 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={cn( "col-start-1 row-start-1 w-full flex items-center justify-center cursor-grab active:cursor-grabbing", isFullView ? "relative" : "h-full" )} > {/* Full/Square Toggle Overlay */} { e.stopPropagation(); setIsFullView(!isFullView); }} className="absolute top-4 left-4 md:top-4 md:right-4 bg-black/40 hover:bg-black/60 text-white p-2 rounded-full backdrop-blur-md transition-all flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider z-30 md:opacity-0 md:group-hover:opacity-100" title={isFullView ? "Crop to Square" : "View Original Aspect Ratio"} > {isFullView ? : } {isFullView ? "Square" : "Full"} {post.media.length > 1 && ( <> {currentIndex > 0 && ( { e.stopPropagation(); paginate(-1); }} className="hidden md:block absolute left-4 top-1/2 -translate-y-1/2 bg-white/20 hover:bg-white/40 text-white p-2 rounded-full backdrop-blur-md transition-all opacity-0 group-hover:opacity-100 z-30" > )} {currentIndex < post.media.length - 1 && ( { e.stopPropagation(); paginate(1); }} className="hidden md:block absolute right-4 top-1/2 -translate-y-1/2 bg-white/20 hover:bg-white/40 text-white p-2 rounded-full backdrop-blur-md transition-all opacity-0 group-hover:opacity-100 z-30" > )} {post.media.map((_, i) => ( ))} > )} {/* Info Section */} {post.username[0]} {post.username} {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 [gridAspectRatio, setGridAspectRatio] = useState<'1:1' | '3:4'>('1:1'); const [gridOffset, setGridOffset] = useState(0); const [activeTab, setActiveTab] = useState<'posts' | 'reels' | 'saved'>('posts'); const fileInputRef = React.useRef(null); const profilePicInputRef = React.useRef(null); 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 []; // Saved tab is empty for now }, [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 handleProfilePicChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { const url = URL.createObjectURL(file); setProfilePic(url); } }; const handleFiles = async (files: FileList | null) => { if (!files) return; setIsScanning(true); setProfilePic(null); setGridOffset(0); console.log(`Starting scan of ${files.length} files...`); await new Promise(resolve => setTimeout(resolve, 100)); try { const postsMap = new Map>(); const mediaFilesMap = new Map(); // Format 1: Instagram Export (e.g., 2021-01-01_username - ID - 1.jpg) const exportRegex = /^(\d{4}-\d{2}-\d{2})_(.+?) - ([a-zA-Z0-9_-]+)(?: - (\d+))?(?: - (story))?\.(.+)$/; // Format 2: Instaloader (e.g., 2017-03-31_12-42-56_UTC.jpg or 2020-12-05_22-11-27_UTC_1.jpg) 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 || typeName.includes('Story') || obj.audience === "MediaAudience.DEFAULT" || nodeType === "StoryItem" || productType === "story" ); }; let detectedUsername = ''; let format: 'export' | 'instaloader' | 'json' | 'unknown' = 'unknown'; let jsonFiles: File[] = []; // First pass: detect format and collect files for (let i = 0; i < files.length; i++) { const file = files[i]; const lowerName = file.name.toLowerCase(); // Check for official JSON format or profile JSON if (lowerName.endsWith('.json')) { 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'))) { // Likely Instaloader or other JSON-per-post format format = 'json'; } continue; } // Check for profile pic in Instaloader format if (lowerName.includes('_profile_pic.jpg')) { setProfilePic(URL.createObjectURL(file)); if (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) { 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); } } } // Store all media files for JSON format lookup if (['jpg', 'jpeg', 'png', 'webp', 'mp4'].some(ext => lowerName.endsWith(ext))) { // Store by relative path or just name const key = file.webkitRelativePath || file.name; mediaFilesMap.set(key, file); } } console.log(`Detected format: ${format}, Username: ${detectedUsername}`); if (format === 'json') { // Handle JSON format (Official Instagram Export) for (const jsonFile of jsonFiles) { try { const content = await jsonFile.text(); const data = JSON.parse(content); // Check if it's a profile JSON 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; } // Official Instagram JSON structure is usually an array of objects // Instaloader JSON is usually a single object const items = Array.isArray(data) ? data : (data.media || [data]); const isStoriesFile = jsonFile.name.toLowerCase().includes('stories'); items.forEach((item: any, idx: number) => { 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) || (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, }; mediaList.forEach((m: any, mIdx: number) => { const uri = m.uri; let matchedFile: File | undefined; // 1. Try URI matching (Official Export) if (uri) { for (const [path, file] of mediaFilesMap.entries()) { if (path.endsWith(uri) || uri.endsWith(path)) { matchedFile = file; break; } } } // 2. Try ID matching (Instaloader/Generic) if (!matchedFile) { const id = item.node?.id || item.id; if (id) { for (const [path, file] of mediaFilesMap.entries()) { if (file.name.includes(id)) { matchedFile = file; break; } } } } // 3. Try JSON filename matching (Instaloader) 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, file] of mediaFilesMap.entries()) { if (file.name.toLowerCase() === possibleName.toLowerCase()) { matchedFile = file; break; } } if (matchedFile) break; } } if (matchedFile) { const url = URL.createObjectURL(matchedFile); const type = matchedFile.name.toLowerCase().endsWith('mp4') ? 'video' : 'image'; post.media!.push({ name: matchedFile.name, url, type, index: mIdx + 1 }); } }); if (post.media!.length > 0) { postsMap.set(postId, post); } }); } catch (e) { console.error("Error parsing JSON file:", jsonFile.name, e); } } } else { // Handle Regex formats (Export or Instaloader) let matchedCount = 0; 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(); if (format === 'export' && detectedUsername && lowerName === `${detectedUsername.toLowerCase()}.jpg`) { setProfilePic(URL.createObjectURL(file)); 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'); 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; } else if (format === 'instaloader') { const match = file.name.match(instaloaderRegex); if (!match) continue; const [_, postIdMatch, indexStrMatch, storyMatch, extMatch] = match; postId = postIdMatch; date = postId.split('_')[0]; index = indexStrMatch ? parseInt(indexStrMatch, 10) : 1; if (storyMatch) isStory = true; ext = extMatch; } else { continue; } matchedCount++; let post = postsMap.get(postId); if (!post) { post = { id: postId, date, username: user, caption: '', media: [], isStory, }; postsMap.set(postId, post); } else if (isStory) { // Update isStory flag if any file associated with this post indicates it's a story post.isStory = true; } const lowerExt = ext.toLowerCase(); if (lowerExt === 'txt') { const text = await file.text(); post.caption = text; } else if (lowerExt === 'json') { try { const content = await file.text(); const data = JSON.parse(content); // Extract caption const node = data.node || data; const captionText = node.edge_media_to_caption?.edges?.[0]?.node?.text || node.caption?.text || node.iphone_struct?.caption?.text || ""; if (captionText) post.caption = captionText; // Update isStory if JSON confirms it if (checkIsStory(data) || checkIsStory(data.node) || checkIsStory(data.instaloader)) { post.isStory = true; } // Profile info check in case it's a profile JSON if (data.node && (data.instaloader?.node_type === 'Profile' || data.node.__typename === 'User')) { const n = data.node; const iphone = n.iphone_struct || {}; setUsername(n.username || ''); setFullName(n.full_name || ''); setBio(n.biography || iphone.biography || ''); setExternalUrl(n.external_url || ''); setFollowerCount(n.edge_followed_by?.count || iphone.follower_count || 0); setFollowingCount(n.edge_follow?.count || iphone.following_count || 0); } } catch (e) {} } else if (['jpg', 'jpeg', 'png', 'webp', 'mp4'].includes(lowerExt)) { const url = URL.createObjectURL(file); const type = lowerExt === 'mp4' ? 'video' : 'image'; 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; }); 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)); console.log(`Finalized ${posts.length} posts and ${stories.length} stories.`); setAllPosts(posts); setAllStories(stories); setVisiblePostsCount(90); } catch (err) { console.error('Error processing files:', err); } finally { setIsScanning(false); } }; const triggerFileSelect = () => { fileInputRef.current?.click(); }; const loadMore = () => { setVisiblePostsCount(prev => prev + 90); }; return ( {/* Hidden Input for Directory Selection */} handleFiles(e.target.files)} /> {/* Navigation */} InstaArchive {allPosts.length > 0 && activeTab === 'posts' && ( Bump: {[0, 1, 2].map((offset) => ( setGridOffset(offset)} className={cn( "px-2 md:px-2.5 py-0.5 rounded-md text-[10px] font-bold uppercase transition-all", gridOffset === offset ? "bg-white shadow-sm text-black" : "text-gray-500 hover:text-gray-700" )} > {offset} ))} Grid: setGridAspectRatio('1:1')} className={cn( "px-2 md:px-2.5 py-0.5 rounded-md text-[10px] font-bold uppercase transition-all", gridAspectRatio === '1:1' ? "bg-white shadow-sm text-black" : "text-gray-500 hover:text-gray-700" )} > 1:1 setGridAspectRatio('3:4')} className={cn( "px-2 md:px-2.5 py-0.5 rounded-md text-[10px] font-bold uppercase transition-all", gridAspectRatio === '3:4' ? "bg-white shadow-sm text-black" : "text-gray-500 hover:text-gray-700" )} > 3:4 )} {allPosts.length > 0 ? 'Change Directory' : 'Load Archive'} {allPosts.length > 0 ? 'Change' : 'Load'} {allPosts.length === 0 ? ( No Archive Loaded Select the directory containing your Instagram archive files to start browsing. {isScanning ? ( <> Scanning > ) : 'Select Archive Directory'} ) : ( {/* 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 ? ( setProfilePic(null)} referrerPolicy="no-referrer" /> ) : ( {username[0]} )} {username} profilePicInputRef.current?.click()} className="bg-gray-100 hover:bg-gray-200 px-4 py-1.5 rounded-lg text-sm font-semibold transition-colors" > Set Profile Picture View Archive {allPosts.length} posts {followerCount.toLocaleString()} followers {followingCount.toLocaleString()} following {fullName || `@${username}`} {bio || 'Archived profile viewer for local files.'} {externalUrl && ( {externalUrl.replace(/^https?:\/\/(www\.)?/, '')} )} {/* Tabs */} handleTabChange('posts')} className={cn( "flex items-center gap-2 py-4 border-t text-xs font-bold tracking-widest uppercase transition-all", activeTab === 'posts' ? "border-black text-black" : "border-transparent text-gray-400" )} > Posts handleTabChange('reels')} className={cn( "flex items-center gap-2 py-4 border-t text-xs font-bold tracking-widest uppercase transition-all", activeTab === 'reels' ? "border-black text-black" : "border-transparent text-gray-400" )} > Reels handleTabChange('saved')} className={cn( "flex items-center gap-2 py-4 border-t text-xs font-bold tracking-widest uppercase transition-all", activeTab === 'saved' ? "border-black text-black" : "border-transparent text-gray-400" )} > Saved {/* Grid */} {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 */} - - ))} {/* Pagination Button */} {allPosts.length > visiblePostsCount && ( Load More Posts )} )} {/* Post Detail Modal */} {selectedPost && ( setSelectedPost(null)} initialFullView={activeTab === 'reels' || gridAspectRatio === '3:4'} onNextPost={onNextPost} onPrevPost={onPrevPost} hasNextPost={postIndex < filteredPosts.length - 1} hasPrevPost={postIndex > 0} /> )} {/* Story Viewer */} {showStoryViewer && allStories.length > 0 && ( setShowStoryViewer(false)} /> )} {/* Footer */} ); }
Select the directory containing your Instagram archive files to start browsing.