/** * @license * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { Grid3X3, Play, Layers, FolderOpen, Heart, MessageCircle, Bookmark, Loader2, } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import * as idb from 'idb-keyval'; import { cn } from './lib/utils'; import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files'; import { Post, ServerArchive } from './types'; import { ArchiveDashboard } from './components/ArchiveDashboard'; import { StoryViewer } from './components/StoryViewer'; import { PostModal } from './components/PostModal'; import { PostThumbnail } from './components/PostThumbnail'; import { useArchiveScanner } from './hooks/useArchiveScanner'; import { useThumbnailQueue } from './hooks/useThumbnailQueue'; export default function App() { const [showStoryViewer, setShowStoryViewer] = useState(false); const [visiblePostsCount, setVisiblePostsCount] = useState(90); const [selectedPost, setSelectedPost] = 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 [serverArchives, setServerArchives] = useState([]); const [cachedArchives, setCachedArchives] = useState>(new Set()); const [localCachedArchives, setLocalCachedArchives] = useState([]); const [isServerMode, setIsServerMode] = useState(false); const [currentArchive, setCurrentArchive] = useState(null); const fileInputRef = useRef(null); const profilePicInputRef = useRef(null); const { cacheHits, requestThumbnail } = useThumbnailQueue(); const refreshCachedArchives = useCallback(async () => { try { const keys = await idb.keys(); setCachedArchives(new Set(keys.map(String))); const locals: any[] = []; for (const key of keys) { const data: any = await idb.get(key); if (data && data.isLocal) { // Fallback for missing allProfilePics in local cached metadata if (!data.profileMetadata.allProfilePics) { data.profileMetadata.allProfilePics = data.profileMetadata.profilePic ? [data.profileMetadata.profilePic] : []; } locals.push(data); } } setLocalCachedArchives(locals); } catch (e) {} }, []); const { isScanning, scanningPhase, scannedCount, totalFiles, scannedFilesLog, currentScanningImage, allPosts, allStories, profileMetadata, handleFiles, setAllPosts, setAllStories, setProfileMetadata, setIsScanning, setScanningPhase, resetScannerState } = useArchiveScanner('', currentArchive, refreshCachedArchives); const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState(null); const { username, fullName, bio, followerCount, followingCount, externalUrl, profilePic, allProfilePics } = profileMetadata; useEffect(() => { fetch('/api/archives') .then(res => { if (res.ok) { setIsServerMode(true); return res.json(); } return []; }) .then(data => setServerArchives(data)) .catch(() => setIsServerMode(false)); }, []); useEffect(() => { refreshCachedArchives(); }, [refreshCachedArchives]); const clearCache = async (name: string) => { await idb.del(name); await refreshCachedArchives(); }; const 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); setProfileMetadata(prev => ({ ...prev, profilePic: url, allProfilePics: [url, ...prev.allProfilePics] })); } }; const cycleProfilePic = () => { if (allProfilePics.length > 1) { const idx = allProfilePics.indexOf(profilePic || ''); setProfileMetadata(prev => ({ ...prev, profilePic: allProfilePics[(idx + 1) % allProfilePics.length] })); } }; const loadServerArchive = useCallback(async (archive: ServerArchive) => { console.log(`[Cache] Attempting to load archive: ${archive.name}`); setIsScanning(true); setCurrentArchive(archive); 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); // Handle migration from old cache schema where allProfilePics was a separate top-level key const profileMetadata = { ...cachedData.profileMetadata }; if (!profileMetadata.allProfilePics && cachedData.allProfilePics) { profileMetadata.allProfilePics = cachedData.allProfilePics; } if (!profileMetadata.allProfilePics) { profileMetadata.allProfilePics = profileMetadata.profilePic ? [profileMetadata.profilePic] : []; } setProfileMetadata(profileMetadata); setVisiblePostsCount(90); setIsScanning(false); console.log(`[Cache] Archive ${archive.name} loaded successfully from cache.`); return; } } console.log(`[Scanner] Starting fresh scan from server API...`); const res = await fetch(`/api/archives/${archive.name}/files`); const filePaths: string[] = await res.json(); 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); } }, [handleFiles, setAllPosts, setAllStories, setProfileMetadata, setIsScanning, setScanningPhase]); const handleLocalFiles = (files: FileList | null) => { if (!files) return; const archiveFiles = Array.from(files).map(f => new LocalArchiveFile(f)); handleFiles(archiveFiles); }; const triggerFileSelect = () => fileInputRef.current?.click(); const loadMore = () => setVisiblePostsCount(prev => prev + 90); useEffect(() => { const params = new URLSearchParams(window.location.search); if (currentArchive) params.set('a', currentArchive.name); else if (allPosts.length > 0 && username) params.set('a', username); else params.delete('a'); if (activeTab !== 'posts') params.set('t', activeTab); else params.delete('t'); if (selectedPost) params.set('p', selectedPost.id); else params.delete('p'); const newSearch = params.toString(); const currentSearch = new URLSearchParams(window.location.search).toString(); if (newSearch !== currentSearch) { console.log(`[Permalink] Updating URL to: ?${newSearch}`); const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : ''); window.history.replaceState(null, '', newUrl); } }, [currentArchive?.name, username, allPosts.length, activeTab, selectedPost?.id]); const [hasInitialLoaded, setHasInitialLoaded] = useState(false); useEffect(() => { if (hasInitialLoaded || serverArchives.length === 0) return; const params = new URLSearchParams(window.location.search); const archiveName = params.get('a'); const tab = params.get('t'); const postId = params.get('p'); console.log('[Permalink] Initial read from URL:', { archiveName, tab, postId }); if (archiveName) { const archive = serverArchives.find(a => a.name === archiveName); if (archive) { console.log(`[Permalink] Auto-loading archive: ?a=${archiveName}`); loadServerArchive(archive); if (tab && ['posts', 'reels', 'saved'].includes(tab)) { setActiveTab(tab as any); } } } setHasInitialLoaded(true); }, [serverArchives, hasInitialLoaded, loadServerArchive]); useEffect(() => { const params = new URLSearchParams(window.location.search); const postId = params.get('p'); if (postId && allPosts.length > 0 && !selectedPost) { const post = allPosts.find(p => p.id === postId); if (post) setSelectedPost(post); } }, [allPosts, selectedPost]); 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 && ( setLastLoadedScanningImage(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} setProfileMetadata(prev => ({ ...prev, profilePic: null }))} referrerPolicy="no-referrer" /> : {username?.[0] || 'U'}}

{username}

{allProfilePics.length === 0 && } {allProfilePics.length > 1 && }
{allPosts.length} posts
{(followerCount || 0).toLocaleString()} followers
{(followingCount || 0).toLocaleString()} following
{fullName || `@${username}`}
{bio || 'Archived profile viewer for local files.'}
{externalUrl && {externalUrl.replace(/^https?:\/\/(www\.)?/, '')}}
{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 text-black", activeTab === 'reels' ? "aspect-[9/16]" : (gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]"))}>
{post.media.length > 1 &&
}{post.media.some(m => m.type === 'video') &&
}
-
-
))}
{filteredPosts.length > visiblePostsCount &&
}
)}
{selectedPost && ( 0 ? filteredPosts[postIndex - 1] : undefined} onClose={() => 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 && ( )}
); }