mirror of
https://github.com/ergosteur/instaarchive-viewer.git
synced 2026-07-04 11:07:15 -04:00
Sets up a new React PWA project with Vite, Tailwind CSS, and basic PWA features. Includes essential files like README, .gitignore, package.json, and initial app structure.
1364 lines
51 KiB
TypeScript
1364 lines
51 KiB
TypeScript
/**
|
|
* @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 (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
className="fixed inset-0 z-[100] bg-black flex items-center justify-center"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="relative w-full max-w-md aspect-[9/16] bg-gray-900 overflow-hidden md:rounded-xl shadow-2xl"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* Progress Bars */}
|
|
<div className="absolute top-4 left-4 right-4 z-50 flex gap-1">
|
|
{stories.map((_, i) => (
|
|
<div key={i} className="h-0.5 flex-1 bg-white/30 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-white transition-all duration-50"
|
|
style={{
|
|
width: i < currentStoryIndex ? '100%' : (i === currentStoryIndex ? `${progress}%` : '0%')
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="absolute top-8 left-4 right-4 z-50 flex items-center justify-between text-white">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-[10px] font-bold text-black uppercase">
|
|
{story.username[0]}
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-semibold">{story.username}</span>
|
|
<span className="text-[10px] opacity-70">{format(parseISO(story.date), 'MMM d')}</span>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors">
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Media */}
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
{story.media[0].type === 'video' ? (
|
|
<video
|
|
src={story.media[0].url}
|
|
className="w-full h-full object-cover"
|
|
autoPlay
|
|
muted
|
|
playsInline
|
|
onEnded={nextStory}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={story.media[0].url}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Navigation Areas */}
|
|
<div className="absolute inset-0 flex">
|
|
<div className="w-1/3 h-full cursor-pointer" onClick={prevStory} />
|
|
<div className="w-2/3 h-full cursor-pointer" onClick={nextStory} />
|
|
</div>
|
|
|
|
{/* Caption */}
|
|
{story.caption && (
|
|
<div className="absolute bottom-10 left-4 right-4 z-50 text-white text-sm text-center drop-shadow-md">
|
|
{story.caption}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
// --- Cache for video thumbnails to prevent redundant processing ---
|
|
const thumbnailCache = new Map<string, string>();
|
|
|
|
const VideoThumbnail = ({ url, className }: { url: string; className?: string }) => {
|
|
const [thumbnail, setThumbnail] = useState<string | null>(thumbnailCache.get(url) || null);
|
|
const [isInView, setIsInView] = useState(false);
|
|
const containerRef = React.useRef<HTMLDivElement>(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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn("w-full h-full bg-gray-100 flex items-center justify-center", className)}
|
|
>
|
|
<Play size={20} className="text-gray-300" fill="currentColor" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<img
|
|
src={thumbnail}
|
|
alt=""
|
|
className={cn("w-full h-full object-cover transition-transform duration-500 group-hover:scale-110", className)}
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<video
|
|
src={file.url}
|
|
className={cn(
|
|
"transition-all duration-300",
|
|
sizingClass,
|
|
className
|
|
)}
|
|
style={mediaStyle}
|
|
controls
|
|
playsInline
|
|
autoPlay
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<img
|
|
src={file.url}
|
|
alt=""
|
|
className={cn(
|
|
"transition-all duration-300",
|
|
sizingClass,
|
|
className
|
|
)}
|
|
style={mediaStyle}
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 flex items-start justify-center bg-[#0c1014]/95 md:bg-[#0c1014]/70 p-0 md:p-10 overflow-y-auto"
|
|
style={{ colorScheme: 'dark' }}
|
|
onClick={onClose}
|
|
>
|
|
<div className="min-h-full w-full flex items-center justify-center md:py-0">
|
|
<button
|
|
onClick={onClose}
|
|
className="fixed top-4 right-4 text-white hover:text-gray-300 z-50 p-2 md:p-3 bg-black/20 rounded-full backdrop-blur-sm"
|
|
>
|
|
<X size={24} className="md:w-8 md:h-8" />
|
|
</button>
|
|
|
|
{/* Post Navigation Buttons - Hidden on Mobile */}
|
|
{hasPrevPost && onPrevPost && (
|
|
<button
|
|
onClick={(e) => { 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"
|
|
>
|
|
<ChevronLeft size={48} strokeWidth={1.5} />
|
|
</button>
|
|
)}
|
|
|
|
{hasNextPost && onNextPost && (
|
|
<button
|
|
onClick={(e) => { 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"
|
|
>
|
|
<ChevronRight size={48} strokeWidth={1.5} />
|
|
</button>
|
|
)}
|
|
|
|
<motion.div
|
|
drag="y"
|
|
dragDirectionLock
|
|
dragConstraints={{ top: 0, bottom: 0 }}
|
|
dragElastic={0.15}
|
|
onDragEnd={(e, { offset, velocity }) => {
|
|
// 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 */}
|
|
<div className={cn(
|
|
"relative bg-black flex items-center justify-center group overflow-hidden",
|
|
isFullView ? "w-full h-auto" : "w-full aspect-square"
|
|
)}>
|
|
<div className={cn(
|
|
"w-full grid grid-cols-1 grid-rows-1",
|
|
isFullView ? "" : "absolute inset-0 h-full"
|
|
)}>
|
|
<AnimatePresence initial={false} custom={direction}>
|
|
<motion.div
|
|
key={`${post.id}-${currentIndex}`}
|
|
custom={direction}
|
|
variants={variants}
|
|
initial="enter"
|
|
animate="center"
|
|
exit="exit"
|
|
transition={{
|
|
x: { type: "spring", stiffness: 300, damping: 30 }
|
|
}}
|
|
drag="x"
|
|
dragDirectionLock
|
|
dragConstraints={{ left: 0, right: 0 }}
|
|
dragElastic={0.5}
|
|
onDragEnd={(e, { offset, velocity }) => {
|
|
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"
|
|
)}
|
|
>
|
|
<MediaRenderer file={post.media[currentIndex]} isFullView={isFullView} />
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Full/Square Toggle Overlay */}
|
|
<button
|
|
onClick={(e) => {
|
|
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 ? <Grid3X3 size={16} /> : <Layers size={16} />}
|
|
<span className="hidden sm:inline">{isFullView ? "Square" : "Full"}</span>
|
|
</button>
|
|
|
|
{post.media.length > 1 && (
|
|
<>
|
|
{currentIndex > 0 && (
|
|
<button
|
|
onClick={(e) => { 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"
|
|
>
|
|
<ChevronLeft size={24} />
|
|
</button>
|
|
)}
|
|
{currentIndex < post.media.length - 1 && (
|
|
<button
|
|
onClick={(e) => { 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"
|
|
>
|
|
<ChevronRight size={24} />
|
|
</button>
|
|
)}
|
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-1.5 z-30">
|
|
{post.media.map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"w-1.5 h-1.5 rounded-full transition-all",
|
|
i === currentIndex ? "bg-blue-500 scale-125" : "bg-white/40 shadow-sm"
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info Section */}
|
|
<div className="w-full md:w-96 bg-white flex flex-col border-l border-gray-200 overflow-hidden shrink-0">
|
|
<div className="p-3 md:p-4 border-b border-gray-100 flex items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-yellow-400 to-purple-600 p-0.5">
|
|
<div className="w-full h-full rounded-full bg-white p-0.5">
|
|
<div className="w-full h-full rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
|
<span className="text-[10px] font-bold uppercase">{post.username[0]}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span className="font-semibold text-sm">{post.username}</span>
|
|
</div>
|
|
<MoreHorizontal size={20} className="text-gray-500" />
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-3 md:p-4 space-y-4 min-h-0 md:max-h-[60vh]">
|
|
<div className="flex gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-gray-200 flex-shrink-0 flex items-center justify-center text-[10px] font-bold uppercase">
|
|
{post.username[0]}
|
|
</div>
|
|
<div className="text-sm">
|
|
<span className="font-semibold mr-2">{post.username}</span>
|
|
<span className="whitespace-pre-wrap">{post.caption}</span>
|
|
<div className="mt-2 text-xs text-gray-500 uppercase tracking-tight">
|
|
{format(parseISO(post.date), 'MMMM d, yyyy')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3 md:p-4 border-t border-gray-100 space-y-3 shrink-0 bg-white">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Heart size={24} className="hover:text-gray-500 cursor-pointer" />
|
|
<MessageCircle size={24} className="hover:text-gray-500 cursor-pointer" />
|
|
<Play size={24} className="hover:text-gray-500 cursor-pointer" />
|
|
</div>
|
|
<Bookmark size={24} className="hover:text-gray-500 cursor-pointer" />
|
|
</div>
|
|
<div className="text-sm flex items-center gap-2">
|
|
<span className="font-semibold">Archived Post</span>
|
|
<span className="text-gray-400 font-normal text-xs">{post.id}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
export default function App() {
|
|
const [allPosts, setAllPosts] = useState<Post[]>([]);
|
|
const [allStories, setAllStories] = useState<Post[]>([]);
|
|
const [showStoryViewer, setShowStoryViewer] = useState(false);
|
|
const [visiblePostsCount, setVisiblePostsCount] = useState(90);
|
|
const [isScanning, setIsScanning] = useState(false);
|
|
const [selectedPost, setSelectedPost] = useState<Post | null>(null);
|
|
const [username, setUsername] = useState<string>('');
|
|
const [fullName, setFullName] = useState<string>('');
|
|
const [bio, setBio] = useState<string>('');
|
|
const [followerCount, setFollowerCount] = useState<number>(0);
|
|
const [followingCount, setFollowingCount] = useState<number>(0);
|
|
const [externalUrl, setExternalUrl] = useState<string>('');
|
|
const [profilePic, setProfilePic] = useState<string | 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 fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
const profilePicInputRef = React.useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
|
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<string, Partial<Post>>();
|
|
const mediaFilesMap = new Map<string, File>();
|
|
|
|
// 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<Post> = {
|
|
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 (
|
|
<div className="min-h-screen bg-gray-50 text-gray-900 font-sans">
|
|
{/* Hidden Input for Directory Selection */}
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
className="hidden"
|
|
// @ts-ignore - webkitdirectory is non-standard but widely supported
|
|
webkitdirectory=""
|
|
directory=""
|
|
multiple
|
|
onChange={(e) => handleFiles(e.target.files)}
|
|
/>
|
|
|
|
{/* Navigation */}
|
|
<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">
|
|
<h1 className="text-lg md:text-xl font-bold tracking-tight italic font-serif">InstaArchive</h1>
|
|
|
|
<div className="flex items-center gap-2 md:gap-8">
|
|
{allPosts.length > 0 && activeTab === 'posts' && (
|
|
<div className="flex items-center gap-2 md:gap-6">
|
|
<div className="flex items-center gap-1.5 md:gap-2">
|
|
<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">
|
|
{[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">
|
|
<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">
|
|
<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={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 ? 'Change Directory' : 'Load Archive'}</span>
|
|
<span className="sm:hidden">{allPosts.length > 0 ? 'Change' : 'Load'}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main className="max-w-5xl mx-auto px-4 py-8 md:py-12">
|
|
{allPosts.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20 text-center space-y-6">
|
|
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center text-gray-400">
|
|
<Grid3X3 size={48} strokeWidth={1} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h2 className="text-2xl font-semibold">No Archive Loaded</h2>
|
|
<p className="text-gray-500 max-w-md">
|
|
Select the directory containing your Instagram archive files to start browsing.
|
|
</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 Archive Directory'}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-12">
|
|
{/* Profile Header */}
|
|
<header className="flex flex-col md:flex-row items-center md:items-start gap-8 md:gap-20 px-4">
|
|
<div
|
|
className={cn(
|
|
"w-24 h-24 md:w-36 md:h-36 rounded-full p-1 cursor-pointer transition-transform active:scale-95",
|
|
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">
|
|
<div className="w-full h-full rounded-full bg-gray-100 flex items-center justify-center overflow-hidden">
|
|
{profilePic ? (
|
|
<img
|
|
src={profilePic}
|
|
alt={username}
|
|
className="w-full h-full object-cover"
|
|
onError={() => setProfilePic(null)}
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
) : (
|
|
<span className="text-3xl font-bold text-gray-400 uppercase">
|
|
{username[0]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-6 text-center md:text-left">
|
|
<div className="flex flex-col md:flex-row items-center gap-4">
|
|
<h2 className="text-2xl font-light tracking-wide">{username}</h2>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="file"
|
|
ref={profilePicInputRef}
|
|
className="hidden"
|
|
accept="image/*"
|
|
onChange={handleProfilePicChange}
|
|
/>
|
|
<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"
|
|
>
|
|
Set Profile Picture
|
|
</button>
|
|
<button className="bg-gray-100 hover:bg-gray-200 px-4 py-1.5 rounded-lg text-sm font-semibold transition-colors">
|
|
View Archive
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-center md:justify-start gap-10">
|
|
<div><span className="font-semibold">{allPosts.length}</span> posts</div>
|
|
<div><span className="font-semibold">{followerCount.toLocaleString()}</span> followers</div>
|
|
<div><span className="font-semibold">{followingCount.toLocaleString()}</span> following</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<div className="font-semibold">{fullName || `@${username}`}</div>
|
|
<div className="text-gray-600 whitespace-pre-wrap max-w-sm mx-auto md:mx-0">
|
|
{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]"
|
|
>
|
|
{externalUrl.replace(/^https?:\/\/(www\.)?/, '')}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-t border-gray-200 flex flex-col md:flex-row items-center justify-between gap-4">
|
|
<div className="flex justify-center gap-12 flex-1">
|
|
<button
|
|
onClick={() => 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"
|
|
)}
|
|
>
|
|
<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",
|
|
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",
|
|
activeTab === 'saved' ? "border-black text-black" : "border-transparent text-gray-400"
|
|
)}
|
|
>
|
|
<Bookmark size={14} />
|
|
Saved
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Grid */}
|
|
<div className="grid grid-cols-3 gap-[2px] md:gap-[2px]">
|
|
{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",
|
|
gridAspectRatio === '1:1' ? "aspect-square" : "aspect-[3/4]"
|
|
)}
|
|
>
|
|
Blank
|
|
</div>
|
|
))}
|
|
{visiblePosts.map((post) => (
|
|
<motion.div
|
|
key={post.id}
|
|
layoutId={post.id}
|
|
onClick={() => 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' ? (
|
|
<VideoThumbnail url={post.media[0].url} />
|
|
) : (
|
|
<img
|
|
src={post.thumbnail}
|
|
alt=""
|
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
)}
|
|
|
|
{/* Icons */}
|
|
<div className="absolute top-2 right-2 flex gap-1.5 z-10">
|
|
{post.media.length > 1 && (
|
|
<div className="bg-black/40 backdrop-blur-md p-1 rounded-md text-white shadow-sm">
|
|
<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">
|
|
<Play size={16} fill="white" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Hover Overlay */}
|
|
<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">
|
|
<div className="flex items-center gap-2">
|
|
<Heart fill="white" size={20} />
|
|
<span>-</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<MessageCircle fill="white" size={20} />
|
|
<span>-</span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pagination Button */}
|
|
{allPosts.length > visiblePostsCount && (
|
|
<div className="flex justify-center pt-8">
|
|
<button
|
|
onClick={loadMore}
|
|
className="bg-white border border-gray-200 hover:bg-gray-50 px-8 py-3 rounded-xl font-semibold shadow-sm transition-all active:scale-95"
|
|
>
|
|
Load More Posts
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Post Detail Modal */}
|
|
<AnimatePresence>
|
|
{selectedPost && (
|
|
<PostModal
|
|
post={selectedPost}
|
|
onClose={() => setSelectedPost(null)}
|
|
initialFullView={activeTab === 'reels' || gridAspectRatio === '3:4'}
|
|
onNextPost={onNextPost}
|
|
onPrevPost={onPrevPost}
|
|
hasNextPost={postIndex < filteredPosts.length - 1}
|
|
hasPrevPost={postIndex > 0}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Story Viewer */}
|
|
<AnimatePresence>
|
|
{showStoryViewer && allStories.length > 0 && (
|
|
<StoryViewer
|
|
stories={allStories}
|
|
onClose={() => setShowStoryViewer(false)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Footer */}
|
|
<footer className="max-w-5xl mx-auto px-4 py-12 text-center text-xs text-gray-400 space-y-4">
|
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2 uppercase tracking-tight">
|
|
<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>© 2026 InstaArchive from Google AI Studio</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|