Docker Build and Publish / build-and-push (push) Failing after 10s
The header rendered allPosts.length, which is pre-dedupe — 0ct0ber19 showed "303 posts" over a 300-tile grid. Instagram's counter equals its grid, so count the grid. Also correct CLAUDE.md. v1.7.0 claimed the grid holds everything "as on Instagram"; Instagram actually includes a reel in the grid only when the creator shared it to feed, per post. Measured live: official_artms has 21 reels in its first 34 grid tiles, 0ct0ber19 has 1 in 214. Archives carry no such flag, so showing everything approximates the behaviour rather than reproducing it. Records the DOM trap that caused the wrong reading in the first place: grid reels link to /reel/<code>/, not /<user>/p/<code>/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
640 lines
36 KiB
TypeScript
640 lines
36 KiB
TypeScript
/**
|
|
* @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, prefersReducedMotion } from './lib/motion';
|
|
import { buildPath, findPostBySlug, parseRoute, postSlug, tabForSource } from './lib/routing';
|
|
import { postsForTab } from './lib/post-tabs';
|
|
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<string | null>(null);
|
|
const [visiblePostsCount, setVisiblePostsCount] = useState(90);
|
|
const [selectedPost, setSelectedPost] = useState<Post | null>(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<ServerArchive[]>([]);
|
|
const [cachedArchives, setCachedArchives] = useState<Set<string>>(new Set());
|
|
const [localCachedArchives, setLocalCachedArchives] = useState<CacheData[]>([]);
|
|
const [isServerMode, setIsServerMode] = useState(false);
|
|
/** True once GET /api/archives has settled, successfully or not. */
|
|
const [archivesFetched, setArchivesFetched] = useState(false);
|
|
const [currentArchive, setCurrentArchive] = useState<ServerArchive | null>(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<HTMLInputElement>(null);
|
|
const profilePicInputRef = useRef<HTMLInputElement>(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<string | null>(null);
|
|
|
|
/**
|
|
* Blurred backdrops behind the scanning UI, newest last.
|
|
*
|
|
* Each new image is stacked *over* the previous one and fades in; the one
|
|
* underneath stays fully opaque until it's covered. Cross-fading by swapping
|
|
* a single element left the pale backdrop showing through mid-transition,
|
|
* which read as a white flash between every image.
|
|
*/
|
|
const [scanBackdrops, setScanBackdrops] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!lastLoadedScanningImage) return;
|
|
setScanBackdrops(prev =>
|
|
prev[prev.length - 1] === lastLoadedScanningImage
|
|
? prev
|
|
: [...prev, lastLoadedScanningImage].slice(-3),
|
|
);
|
|
}, [lastLoadedScanningImage]);
|
|
|
|
// Don't carry one archive's backdrops into the next scan.
|
|
useEffect(() => {
|
|
if (!isScanning) { setScanBackdrops([]); setLastLoadedScanningImage(null); }
|
|
}, [isScanning]);
|
|
|
|
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(); };
|
|
|
|
/**
|
|
* The grid shows everything, reels included, and the Reels tab is a filtered
|
|
* view of the same set — see src/lib/post-tabs.ts for the reel test and for
|
|
* why a reel can arrive on disk twice.
|
|
*/
|
|
const filteredPosts = useMemo(() => postsForTab(allPosts, activeTab), [allPosts, activeTab]);
|
|
|
|
/**
|
|
* Instagram's "N posts" counter equals what its grid holds, so count the grid
|
|
* rather than `allPosts` — the raw list still holds both copies of any post
|
|
* fetched into two directories.
|
|
*/
|
|
const totalPosts = useMemo(() => postsForTab(allPosts, 'posts').length, [allPosts]);
|
|
|
|
/** Story highlights, grouped into the circles shown under the bio. */
|
|
const highlightGroups = useMemo(() => {
|
|
const groups = new Map<string, Post[]>();
|
|
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 <img> 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<HTMLInputElement>) => {
|
|
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 <input webkitdirectory> 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<string | null>(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/<code>/ 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 (
|
|
<div className="min-h-screen bg-gray-50 text-gray-900 font-sans">
|
|
<input type="file" ref={fileInputRef} className="hidden" webkitdirectory="" multiple onChange={(e) => handleLocalFiles(e.target.files)} />
|
|
|
|
<nav className="sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-gray-200 h-16 flex items-center px-4 md:px-8">
|
|
<div className="max-w-5xl mx-auto w-full flex items-center justify-between text-black">
|
|
<h1 className="text-lg md:text-xl font-bold tracking-tight italic font-serif cursor-pointer text-black/80" onClick={() => { setAllPosts([]); setAllStories([]); setCurrentArchive(null); resetScannerState(); }}>InstaArchive</h1>
|
|
<div className="flex items-center gap-2 md:gap-8 text-black">
|
|
{allPosts.length > 0 && activeTab === 'posts' && (
|
|
<div className="flex items-center gap-2 md:gap-6 text-black">
|
|
<div className="flex items-center gap-1.5 md:gap-2 text-black"><span className="hidden sm:inline text-[10px] font-bold uppercase text-gray-400 tracking-wider">Bump:</span>
|
|
<div className="flex bg-gray-100 p-0.5 md:p-1 rounded-lg text-black">{[0, 1, 2].map((offset) => (<button key={offset} onClick={() => 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}</button>))}</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 md:gap-2 text-black"><span className="hidden sm:inline text-[10px] font-bold uppercase text-gray-400 tracking-wider">Grid:</span>
|
|
<div className="flex bg-gray-100 p-0.5 md:p-1 rounded-lg text-black"><button onClick={() => 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</button><button onClick={() => 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</button></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<button onClick={() => { if (allPosts.length > 0) { setAllPosts([]); setAllStories([]); setCurrentArchive(null); resetScannerState(); } else { triggerFileSelect(); } }} className="flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-semibold transition-colors"><FolderOpen size={18} /><span className="hidden sm:inline">{allPosts.length > 0 ? 'Exit Archive' : 'Load Archive'}</span><span className="sm:hidden">{allPosts.length > 0 ? 'Exit' : 'Load'}</span></button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main className="max-w-5xl mx-auto px-4 py-8 md:py-12 text-black">
|
|
{allPosts.length === 0 && !isScanning ? (
|
|
isServerMode ? (
|
|
<ArchiveDashboard
|
|
archives={serverArchives}
|
|
localArchives={localCachedArchives}
|
|
cachedArchives={cachedArchives}
|
|
onSelect={loadServerArchive}
|
|
onLocalSelect={triggerFileSelect}
|
|
onLocalCacheSelect={loadLocalCachedArchive}
|
|
onClearCache={clearCache}
|
|
isScanning={isScanning}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-20 text-center space-y-6 text-black">
|
|
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center text-gray-400 text-black"><Grid3X3 size={48} strokeWidth={1} /></div>
|
|
<div className="space-y-2 text-black"><h2 className="text-2xl font-semibold text-black/80">No Archive Selected</h2><p className="text-gray-500 max-md text-sm md:text-base text-black">Select a local archive folder to start browsing. Your files are processed locally in the browser and never uploaded.</p></div>
|
|
<button onClick={triggerFileSelect} disabled={isScanning} className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-3 rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-95 disabled:opacity-50 flex items-center gap-2">{isScanning ? <><Loader2 className="animate-spin" size={20} /><span className="animate-dots">Scanning</span></> : 'Select Local Archive Folder'}</button>
|
|
</div>
|
|
)
|
|
) : isScanning ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden text-black bg-[#f8fafc]">
|
|
{currentScanningImage && (
|
|
<img
|
|
src={currentScanningImage}
|
|
className="hidden"
|
|
onLoad={() => setLastLoadedScanningImage(currentScanningImage)}
|
|
/>
|
|
)}
|
|
{/*
|
|
The 0.4 lives on the group, not the images: two layers overlap
|
|
during a cross-fade, and fading them individually would darken the
|
|
backdrop as they cross. Inside the group each layer goes to full
|
|
opacity, so the stack is always completely covered.
|
|
*/}
|
|
<div className="absolute inset-0 z-0 opacity-40">
|
|
{scanBackdrops.map(src => (
|
|
<motion.img
|
|
key={src}
|
|
src={src}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={prefersReducedMotion() ? { duration: 0 } : { duration: 0.9, ease: 'easeInOut' }}
|
|
onAnimationComplete={() => setScanBackdrops(prev => {
|
|
// Once this layer is opaque it hides everything below it.
|
|
const i = prev.indexOf(src);
|
|
return i > 0 ? prev.slice(i) : prev;
|
|
})}
|
|
className="absolute inset-0 w-full h-full object-cover blur-[60px] scale-110"
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="absolute inset-0 bg-white/40 z-1" />
|
|
<div className="relative z-10 w-full max-w-4xl px-4 flex flex-col items-center gap-8 text-black">
|
|
<div className="text-center space-y-2 text-black"><div className="text-4xl font-bold tracking-tight italic font-serif text-black/80 drop-shadow-sm text-black">Scanning Archive...</div><div className="flex items-center justify-center gap-3 text-black"><div className="h-[1px] w-12 bg-black/10 text-black" /><p className="text-black/40 text-[10px] uppercase tracking-[0.3em] font-bold text-black">{scanningPhase === 'Indexing' ? 'Building file index' : 'Parsing metadata & media'}</p><div className="h-[1px] w-12 bg-black/10 text-black" /></div></div>
|
|
<div className="w-full max-w-2xl space-y-4 text-black"><div className="flex justify-between text-[10px] font-bold uppercase tracking-widest text-black/40 px-1 text-black"><span className="flex items-center gap-2 text-black"><Loader2 size={12} className="animate-spin text-black" />Phase: {scanningPhase}</span><span className="text-black">{scannedCount} / {totalFiles}</span></div><div className="w-full h-1.5 bg-black/5 rounded-full overflow-hidden backdrop-blur-sm border border-black/5 shadow-inner text-black"><motion.div className="h-full bg-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)] text-black" initial={{ width: 0 }} animate={{ width: `${(scannedCount / (totalFiles || 1)) * 100}%` }} transition={{ type: 'spring', bounce: 0, duration: 0.3 }} /></div></div>
|
|
<div className="w-full bg-white/40 backdrop-blur-3xl rounded-2xl shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] border border-white/40 overflow-hidden h-[500px] flex flex-col text-black"><div className="flex items-center justify-between border-b border-black/5 py-3 px-5 bg-white/20 shrink-0 text-black"><div className="flex items-center gap-2 text-black"><div className="w-2.5 h-2.5 rounded-full bg-black/10 text-black" /><div className="w-2.5 h-2.5 rounded-full bg-black/10 text-black" /><div className="w-2.5 h-2.5 rounded-full bg-black/10 text-black" /><span className="ml-3 text-black/30 uppercase tracking-[0.2em] text-[9px] font-bold text-black">System Parser Feed</span></div><div className="text-[9px] font-bold text-black/20 uppercase tracking-widest text-black">Live Output</div></div><div className="flex-1 overflow-y-auto space-y-1 scrollbar-hide p-4 text-black">{scannedFilesLog.map((log, idx) => (<div key={`${idx}-${log}`} className="flex gap-4 leading-tight text-[11px] md:text-[12px] font-medium text-black"><span className="text-black/20 shrink-0 tabular-nums text-black">[{new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}]</span><span className={cn("shrink-0 px-1.5 py-0.5 rounded-[4px] text-[9px] font-bold tracking-wider text-black", scanningPhase === 'Indexing' ? "bg-green-500/10 text-green-600/70" : "bg-blue-500/10 text-blue-600/70")}>{scanningPhase === 'Indexing' ? 'IDX' : 'PARSE'}</span><span className="truncate text-black/60 text-black">{log}</span></div>))}{scannedFilesLog.length === 0 && <div className="animate-pulse text-black/20 font-mono text-center mt-20 italic text-black">Initializing scanner context...</div>}</div></div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-12 text-black">
|
|
<header className="flex flex-col md:flex-row items-center md:items-start gap-8 md:gap-20 px-4 text-black">
|
|
<div className={cn("w-24 h-24 md:w-36 md:h-36 rounded-full p-1 cursor-pointer transition-transform active:scale-95 text-black", allStories.length > 0 ? "bg-gradient-to-tr from-yellow-400 via-red-500 to-purple-600" : "bg-gray-200" )} onClick={() => allStories.length > 0 && setShowStoryViewer(true)}><div className="w-full h-full rounded-full bg-white p-1 text-black"><div className="w-full h-full rounded-full bg-gray-100 flex items-center justify-center overflow-hidden text-black">{profilePic ? <img src={profilePic} alt={username} className="w-full h-full object-cover text-black" onError={() => setProfileMetadata(prev => ({ ...prev, profilePic: null }))} referrerPolicy="no-referrer" /> : <span className="text-3xl font-bold text-gray-400 uppercase text-black">{username?.[0] || 'U'}</span>}</div></div></div>
|
|
<div className="flex-1 space-y-6 text-center md:text-left text-black">
|
|
<div className="flex flex-col md:flex-row items-center gap-4 text-black">
|
|
<h2 className="text-2xl font-light tracking-wide text-black">{username}</h2>
|
|
<div className="flex gap-2 text-black">
|
|
<input type="file" ref={profilePicInputRef} className="hidden text-black" accept="image/*" onChange={handleProfilePicChange} />
|
|
{allProfilePics.length === 0 && <button onClick={() => profilePicInputRef.current?.click()} className="bg-gray-100 hover:bg-gray-200 px-4 py-1.5 rounded-lg text-sm font-semibold transition-colors text-black">Set Profile Picture</button>}
|
|
{allProfilePics.length > 1 && <button onClick={cycleProfilePic} className="bg-gray-100 hover:bg-gray-200 px-4 py-1.5 rounded-lg text-sm font-semibold transition-colors flex items-center gap-2 text-black"><Layers size={16} />Next Profile Pic</button>}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-center md:justify-start gap-10 text-sm md:text-base text-black"><div><span className="font-semibold text-black/80 text-black">{totalPosts.toLocaleString()}</span> posts</div><div><span className="font-semibold text-black/80 text-black">{(followerCount || 0).toLocaleString()}</span> followers</div><div><span className="font-semibold text-black/80 text-black">{(followingCount || 0).toLocaleString()}</span> following</div></div>
|
|
<div className="space-y-1 text-black/80 text-black"><div className="font-semibold text-black">{fullName || `@${username}`}</div><div className="text-gray-600 whitespace-pre-wrap max-w-sm mx-auto md:mx-0 text-sm md:text-base text-black">{bio || 'Archived profile viewer for local files.'}</div>{externalUrl && <a href={externalUrl} target="_blank" rel="noopener noreferrer" className="text-blue-900 font-semibold text-sm block hover:underline truncate max-w-[250px] text-black">{externalUrl.replace(/^https?:\/\/(www\.)?/, '')}</a>}</div>
|
|
</div>
|
|
</header>
|
|
|
|
{highlightGroups.length > 0 && (
|
|
<div className="flex gap-6 md:gap-8 overflow-x-auto scrollbar-hide px-4 pb-2">
|
|
{highlightGroups.map(group => (
|
|
<motion.button
|
|
key={group.title}
|
|
onClick={() => 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'}`}
|
|
>
|
|
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full p-[2px] bg-gray-200 group-hover/hl:bg-gray-300 transition-colors">
|
|
<div className="w-full h-full rounded-full bg-white p-[2px]">
|
|
<div className="w-full h-full rounded-full overflow-hidden bg-gray-100 flex items-center justify-center">
|
|
{group.cover ? (
|
|
<img src={group.cover} alt="" className="w-full h-full object-cover" referrerPolicy="no-referrer" loading="lazy" />
|
|
) : (
|
|
<Play size={18} className="text-gray-400" fill="currentColor" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span className="text-[11px] max-w-[80px] truncate text-gray-700">{group.title}</span>
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-t border-gray-200 flex flex-col md:flex-row items-center justify-between gap-4 text-black">
|
|
<div className="flex justify-center gap-12 flex-1 text-black">
|
|
<button onClick={() => handleTabChange('posts')} className={cn("flex items-center gap-2 py-4 border-t text-xs font-bold tracking-widest uppercase transition-all text-black", activeTab === 'posts' ? "border-black text-black" : "border-transparent text-gray-400")}><Grid3X3 size={14} />Posts</button>
|
|
<button onClick={() => handleTabChange('reels')} className={cn("flex items-center gap-2 py-4 border-t text-xs font-bold tracking-widest uppercase transition-all text-black", activeTab === 'reels' ? "border-black text-black" : "border-transparent text-gray-400")}><Play size={14} />Reels</button>
|
|
<button onClick={() => handleTabChange('saved')} className={cn("flex items-center gap-2 py-4 border-t text-xs font-bold tracking-widest uppercase transition-all text-black", activeTab === 'saved' ? "border-black text-black" : "border-transparent text-gray-400")}><Bookmark size={14} />Saved</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-[2px] md:gap-[2px] text-black">
|
|
{activeTab === 'posts' && Array.from({ length: gridOffset }).map((_, i) => (<div key={`blank-${i}`} className={cn("bg-gray-100/50 border border-dashed border-gray-200 flex items-center justify-center text-[10px] font-bold text-gray-300 uppercase tracking-tighter text-black", gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]")}>Blank</div>))}
|
|
{visiblePosts.map((post) => (
|
|
<motion.div key={post.id} layoutId={post.id} onClick={() => 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]"))}>
|
|
<PostThumbnail
|
|
post={post}
|
|
thumbnailUrl={cacheHits.get(post.id)}
|
|
onRequestThumbnail={requestThumbnail}
|
|
/>
|
|
<div className="absolute top-2 right-2 flex gap-1.5 z-10 text-black">{post.media.length > 1 && <div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm text-black"><Layers size={16} /></div>}{post.media.some(m => m.type === 'video') && <div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm text-black"><Play size={16} fill="white" /></div>}</div>
|
|
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-6 text-white font-bold z-20 text-black"><div className="flex items-center gap-2 text-black"><Heart fill="white" size={20} className="text-black" /><span>-</span></div><div className="flex items-center gap-2 text-black"><MessageCircle fill="white" size={20} className="text-black" /><span>-</span></div></div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
{filteredPosts.length > visiblePostsCount && <div className="flex justify-center pt-12 text-black"><button onClick={loadMore} className="bg-white border border-gray-200 px-8 py-2 rounded-lg font-semibold hover:bg-gray-50 transition-colors shadow-sm text-black">Load More</button></div>}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/*
|
|
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 ? (
|
|
<PostFeed
|
|
posts={filteredPosts}
|
|
initialPostId={selectedPost.id}
|
|
profilePic={profilePic}
|
|
title={activeTab === 'reels' ? 'Reels' : 'Posts'}
|
|
onClose={() => setSelectedPost(null)}
|
|
onActivePostChange={setSelectedPost}
|
|
/>
|
|
) : (
|
|
<AnimatePresence>
|
|
{selectedPost && (
|
|
<PostModal
|
|
post={selectedPost}
|
|
nextPost={postIndex < filteredPosts.length - 1 ? filteredPosts[postIndex + 1] : undefined}
|
|
prevPost={postIndex > 0 ? filteredPosts[postIndex - 1] : undefined}
|
|
onClose={() => setSelectedPost(null)}
|
|
onNextPost={onNextPost}
|
|
onPrevPost={onPrevPost}
|
|
hasNextPost={postIndex < filteredPosts.length - 1}
|
|
hasPrevPost={postIndex > 0}
|
|
profilePic={profilePic}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
)}
|
|
<AnimatePresence>{showStoryViewer && allStories.length > 0 && <StoryViewer stories={allStories} onClose={() => setShowStoryViewer(false)} profilePic={profilePic} />}</AnimatePresence>
|
|
<AnimatePresence>
|
|
{activeHighlight && (
|
|
<StoryViewer
|
|
stories={highlightGroups.find(g => g.title === activeHighlight)?.items ?? []}
|
|
title={activeHighlight}
|
|
onClose={() => setActiveHighlight(null)}
|
|
profilePic={profilePic}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{!isScanning && (
|
|
<footer className="max-w-5xl mx-auto px-4 py-12 text-center text-xs text-gray-400 space-y-4 text-black">
|
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2 uppercase tracking-tight text-black"><span>Meta</span><span>About</span><span>Blog</span><span>Jobs</span><span>Help</span><span>API</span><span>Privacy</span><span>Terms</span><span>Locations</span><span>Instagram Lite</span><span>Threads</span><span>Contact Uploading & Non-Users</span><span>Meta Verified</span></div>
|
|
<div className="text-black/40 text-black">© 2026 InstaArchive Viewer · v{__APP_VERSION__}</div>
|
|
</footer>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|