/** * @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 { cn } from './lib/utils'; import { PRESS } from './lib/motion'; import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing'; import { LocalArchiveFile, RemoteArchiveFile } from './lib/archive-files'; import { deleteCachedArchive, getCachedArchive, listCachedArchives, listCachedArchiveNames, migrateLegacyCache, restoreArchive, saveDirectoryHandle, } from './lib/archive-cache'; import { filesFromDirectory, isDirectoryPickerSupported, pickDirectory, } from './lib/directory-handle'; import { CacheData, Post, ServerArchive, ServerArchiveFile } from './types'; import { ArchiveDashboard } from './components/ArchiveDashboard'; import { StoryViewer } from './components/StoryViewer'; import { PostModal } from './components/PostModal'; import { PostFeed } from './components/PostFeed'; import { useIsMobile } from './hooks/useIsMobile'; 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 [activeHighlight, setActiveHighlight] = useState(null); 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); /** True once GET /api/archives has settled, successfully or not. */ const [archivesFetched, setArchivesFetched] = useState(false); const [currentArchive, setCurrentArchive] = useState(null); const [hasInitialLoaded, setHasInitialLoaded] = useState(false); /** * The route as it was when the app booted. * * Captured during the first render because the URL is rewritten from app * state as soon as anything loads; reading `window.location` later would see * the rewritten value rather than the link the user actually followed. */ const initialRouteRef = useRef(parseRoute(window.location.pathname, window.location.search)); const isMobile = useIsMobile(); const fileInputRef = useRef(null); const profilePicInputRef = useRef(null); const refreshCachedArchives = useCallback(async () => { try { // Names come from key prefixes, so listing no longer deserializes every // cached thumbnail blob just to find out which entries are archives. setCachedArchives(new Set(await listCachedArchiveNames())); setLocalCachedArchives((await listCachedArchives()).filter(a => a.isLocal)); } catch (e) { console.error('[Cache] Failed to list cached archives:', e); } }, []); const { isScanning, scanningPhase, scannedCount, totalFiles, scannedFilesLog, currentScanningImage, allPosts, allStories, allHighlights, setAllHighlights, profileMetadata, handleFiles, setAllPosts, setAllStories, setProfileMetadata, setIsScanning, setScanningPhase, resetScannerState, registerUrl } = useArchiveScanner('', currentArchive, refreshCachedArchives); const [lastLoadedScanningImage, setLastLoadedScanningImage] = useState(null); const { username, fullName, bio, followerCount, followingCount, externalUrl, profilePic, allProfilePics } = profileMetadata; // Thumbnails are keyed per archive, so the queue is scoped to the open one. const { cacheHits, requestThumbnail } = useThumbnailQueue(currentArchive?.name ?? username ?? ''); useEffect(() => { fetch('/api/archives') .then(res => { if (res.ok) { setIsServerMode(true); return res.json(); } return []; }) .then(data => setServerArchives(Array.isArray(data) ? data : [])) .catch(() => setIsServerMode(false)) // Deep-link resolution waits on this rather than on `isServerMode`, which // is still false while the request is in flight. .finally(() => setArchivesFetched(true)); }, []); useEffect(() => { migrateLegacyCache().finally(refreshCachedArchives); }, [refreshCachedArchives]); const clearCache = async (name: string) => { await deleteCachedArchive(name); await refreshCachedArchives(); }; /** * Archives with a `- reels` sidecar directory say outright which posts are * reels; only fall back to the "lone video" heuristic for archives that have * no such directory. */ const hasReelSource = useMemo(() => allPosts.some(p => p.source === 'reels'), [allPosts]); const isReel = useCallback((p: Post) => ( hasReelSource ? p.source === 'reels' : p.media.length === 1 && p.media[0].type === 'video' ), [hasReelSource]); const filteredPosts = useMemo(() => { if (activeTab === 'reels') return allPosts.filter(isReel); if (activeTab === 'posts') return allPosts.filter(p => !isReel(p)); return []; }, [allPosts, activeTab, isReel]); /** Story highlights, grouped into the circles shown under the bio. */ const highlightGroups = useMemo(() => { const groups = new Map(); for (const item of allHighlights) { const title = item.highlightTitle?.trim() || 'Highlights'; if (!groups.has(title)) groups.set(title, []); groups.get(title)!.push(item); } return Array.from(groups, ([title, items]) => ({ title, items, // The cover must be a still: most highlight items are videos, and a video // URL in an renders as a broken image. cover: items.find(i => i.media[0]?.type === 'image')?.thumbnail, })); }, [allHighlights]); 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 getCachedArchive(archive.name); if (cachedData) { // Invalidate on the directory-mtime signature; fall back to file count // for entries cached before signatures existed. const fresh = archive.signature ? cachedData.signature === archive.signature : cachedData.fileCount === archive.fileCount; console.log(`[Cache] Cached ${archive.name}: signature ${cachedData.signature} vs ${archive.signature} -> ${fresh ? 'fresh' : 'stale'}`); if (fresh) { console.log(`[Cache] Cache hit! Restoring state...`); const restored = await restoreArchive(cachedData, registerUrl); if (restored) { setAllPosts(restored.posts); setAllStories(restored.stories); setAllHighlights(restored.highlights); setProfileMetadata({ ...restored.profileMetadata, allProfilePics: restored.profileMetadata.allProfilePics ?? (restored.profileMetadata.profilePic ? [restored.profileMetadata.profilePic] : []), }); 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/${encodeURIComponent(archive.name)}/files`); const entries: (ServerArchiveFile | string)[] = await res.json(); const archiveFiles = entries.map(entry => { // Older servers returned bare path strings relative to the archive dir. const legacy = typeof entry === 'string'; const filePath = legacy ? entry : entry.path; const url = legacy ? `/archives/${encodeURI(`${archive.name}/${filePath}`)}` : `/archives/${encodeURI(filePath)}`; const name = filePath.split(/[/\\]/).pop() || filePath; const source = legacy ? undefined : { kind: entry.kind, dir: filePath.split('/')[0], title: entry.title }; return new RemoteArchiveFile(name, filePath, legacy ? 0 : entry.size, url, source, legacy ? undefined : entry.mtime); }); await handleFiles(archiveFiles, archive); } catch (err) { console.error('[Scanner] Failed to load server archive:', err); setIsScanning(false); } }, [handleFiles, registerUrl, setAllPosts, setAllStories, setAllHighlights, setProfileMetadata, setIsScanning, setScanningPhase]); /** * Open a local archive straight from cache. * * The cached posts carry file *paths*, not URLs — blob: URLs do not survive a * reload — so this re-opens the stored directory handle and mints fresh URLs * for those paths. No re-parsing happens, which is what keeps it instant. * * If the folder can no longer be reached (permission revoked, folder moved, * or the browser never supported directory handles) we fall back to asking * for the folder again rather than rendering an archive of broken images. */ const loadLocalCachedArchive = useCallback(async (archive: CacheData) => { console.log(`[Cache] Loading local archive from cache: ${archive.name}`); setIsScanning(true); setCurrentArchive(null); setScanningPhase('Checking Cache'); try { const restored = await restoreArchive(archive, registerUrl); if (!restored) { console.warn(`[Cache] Folder for ${archive.name} unavailable; re-prompting.`); setIsScanning(false); await openLocalFolder(archive.name); return; } setAllPosts(restored.posts); setAllStories(restored.stories); setAllHighlights(restored.highlights); setProfileMetadata({ ...restored.profileMetadata, allProfilePics: restored.profileMetadata.allProfilePics ?? (restored.profileMetadata.profilePic ? [restored.profileMetadata.profilePic] : []), }); setVisiblePostsCount(90); setIsScanning(false); console.log(`[Cache] Local archive ${archive.name} restored from cache.`); } catch (err) { console.error('[Cache] Failed to restore local archive:', err); setIsScanning(false); } }, [registerUrl, setAllPosts, setAllStories, setAllHighlights, setProfileMetadata, setIsScanning, setScanningPhase]); const handleLocalFiles = (files: FileList | null) => { if (!files) return; handleFiles(Array.from(files).map(f => new LocalArchiveFile(f))); }; /** * Prefer the File System Access API so the folder can be reopened later * without a re-prompt; fall back to elsewhere * (Firefox and Safari have no showDirectoryPicker), where the archive is * re-scanned from scratch on every visit. */ const openLocalFolder = useCallback(async (expectedName?: string) => { if (!isDirectoryPickerSupported()) { fileInputRef.current?.click(); return; } const handle = await pickDirectory(); if (!handle) return; if (expectedName && handle.name !== expectedName) { console.warn(`[Cache] Picked "${handle.name}" but expected "${expectedName}"; scanning as picked.`); } // Persist before scanning so the scan records that a handle exists. await saveDirectoryHandle(handle.name, handle); const files = await filesFromDirectory(handle); await handleFiles(files); }, [handleFiles]); const triggerFileSelect = () => { void openLocalFolder(); }; const loadMore = () => setVisiblePostsCount(prev => prev + 90); useEffect(() => { // Hold the URL until the deep link has been consumed, otherwise this effect // runs on mount with nothing loaded yet and erases the very parameters the // loader below is waiting to read. if (!hasInitialLoaded) return; const archive = currentArchive?.name ?? (allPosts.length > 0 ? username : null) ?? null; const nextPath = buildPath({ archive, tab: activeTab, post: selectedPost ? postSlug(selectedPost) : null, }); if (nextPath !== window.location.pathname + window.location.search) { console.log(`[Permalink] Updating URL to: ${nextPath}`); window.history.replaceState(null, '', nextPath); } }, [hasInitialLoaded, currentArchive?.name, username, allPosts.length, activeTab, selectedPost?.id]); useEffect(() => { if (hasInitialLoaded) return; const route = initialRouteRef.current; console.log('[Permalink] Initial route:', route); if (route.tab !== 'posts') setActiveTab(route.tab); if (!route.archive) { setHasInitialLoaded(true); return; } // Wait for the archive list before deciding the link is unresolvable. if (!archivesFetched) return; const archive = serverArchives.find(a => a.name === route.archive); if (archive) { console.log(`[Permalink] Auto-loading archive: ${route.archive}`); loadServerArchive(archive); } else { console.warn(`[Permalink] No archive named "${route.archive}".`); } setHasInitialLoaded(true); }, [serverArchives, archivesFetched, hasInitialLoaded, loadServerArchive]); /** * Apply a `?p=` deep link exactly once per opened archive. * * Keying on the archive rather than on `selectedPost` matters: re-reading the * URL whenever the selection changes would reopen the post the user just * closed, and only worked before because the URL-writing effect happened to * be declared first and had already stripped the param. */ const appliedPostParamRef = useRef(null); useEffect(() => { const archiveKey = currentArchive?.name ?? username; if (!archiveKey || allPosts.length === 0) return; if (appliedPostParamRef.current === archiveKey) return; appliedPostParamRef.current = archiveKey; const slug = initialRouteRef.current.post; if (!slug) return; const post = findPostBySlug(allPosts, slug); if (!post) return; // A /p// link carries no tab, so derive the one that contains it — // otherwise next/prev would page through the wrong list. setActiveTab(tabForSource(post.source)); setSelectedPost(post); }, [allPosts, currentArchive?.name, username]); 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\.)?/, '')}}
{highlightGroups.length > 0 && (
{highlightGroups.map(group => ( setActiveHighlight(group.title)} whileTap={{ scale: 0.94 }} transition={PRESS} className="flex flex-col items-center gap-2 shrink-0 group/hl" title={`${group.title} — ${group.items.length} item${group.items.length === 1 ? '' : 's'}`} >
{group.cover ? ( ) : ( )}
{group.title}
))}
)}
{activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => (
Blank
))} {visiblePosts.map((post) => ( setSelectedPost(post)} whileTap={{ scale: 0.97 }} transition={PRESS} 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 &&
}
)}
{/* Mobile opens a real scrolling feed page, the way Instagram does; desktop keeps the modal, where a centred sheet with side arrows fits the pointer. */} {selectedPost && isMobile ? ( setSelectedPost(null)} onActivePostChange={setSelectedPost} /> ) : ( {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} />} {activeHighlight && ( g.title === activeHighlight)?.items ?? []} title={activeHighlight} onClose={() => setActiveHighlight(null)} profilePic={profilePic} /> )} {!isScanning && ( )}
); }