mirror of
https://github.com/emmabostian/developer-portfolios.git
synced 2025-04-21 05:38:01 +00:00
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
|
"use client";
|
||
|
|
||
|
import { useState } from 'react';
|
||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||
|
import { ExternalLinkIcon, GithubIcon, CodeIcon } from 'lucide-react';
|
||
|
|
||
|
interface Project {
|
||
|
title: string;
|
||
|
description: string;
|
||
|
techStack: string[];
|
||
|
link?: {
|
||
|
href: string;
|
||
|
label?: string;
|
||
|
};
|
||
|
}
|
||
|
|
||
|
interface ProjectsSectionProps {
|
||
|
projects: Project[];
|
||
|
}
|
||
|
|
||
|
const ProjectsSection: React.FC<ProjectsSectionProps> = ({ projects }) => {
|
||
|
const [activeFilter, setActiveFilter] = useState('All');
|
||
|
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
|
||
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||
|
|
||
|
// Extract unique tech stacks for filter
|
||
|
const techStacks = Array.from(
|
||
|
new Set(projects.flatMap(project => project.techStack))
|
||
|
);
|
||
|
|
||
|
// Filter projects based on tech stack
|
||
|
const filteredProjects = activeFilter === 'All'
|
||
|
? projects
|
||
|
: projects.filter(project => project.techStack.includes(activeFilter));
|
||
|
|
||
|
const containerVariants = {
|
||
|
hidden: { opacity: 0 },
|
||
|
visible: {
|
||
|
opacity: 1,
|
||
|
transition: {
|
||
|
staggerChildren: 0.1,
|
||
|
when: "beforeChildren"
|
||
|
},
|
||
|
},
|
||
|
};
|
||
|
|
||
|
const itemVariants = {
|
||
|
hidden: { y: 20, opacity: 0 },
|
||
|
visible: {
|
||
|
y: 0,
|
||
|
opacity: 1,
|
||
|
transition: {
|
||
|
type: "spring",
|
||
|
stiffness: 100,
|
||
|
},
|
||
|
},
|
||
|
};
|
||
|
|
||
|
// Generate a random image for each project (for demo purposes)
|
||
|
// In a real project, you'd use actual project images
|
||
|
const getProjectImage = (title: string) => {
|
||
|
const seed = title.charCodeAt(0) + title.length;
|
||
|
return `https://picsum.photos/seed/${seed}/600/400`;
|
||
|
};
|
||
|
|
||
|
return (
|
||
|
<div className="py-20 px-4 sm:px-6 md:px-12 overflow-hidden" id="projects">
|
||
|
<div className="container mx-auto max-w-6xl">
|
||
|
<motion.div
|
||
|
className="text-center mb-16"
|
||
|
initial={{ opacity: 0, y: 20 }}
|
||
|
whileInView={{ opacity: 1, y: 0 }}
|
||
|
viewport={{ once: true, margin: "-100px" }}
|
||
|
transition={{ duration: 0.5 }}
|
||
|
>
|
||
|
<h2 className="text-3xl md:text-4xl font-bold mb-4">My Projects</h2>
|
||
|
<div className="w-20 h-1 bg-gradient-to-r from-primary to-secondary mx-auto rounded-full" />
|
||
|
<p className="mt-4 text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||
|
A showcase of my technical projects and applications, demonstrating my skills and expertise in various technologies.
|
||
|
</p>
|
||
|
</motion.div>
|
||
|
|
||
|
{/* Filter controls */}
|
||
|
<motion.div
|
||
|
className="flex flex-wrap justify-center gap-3 mb-12"
|
||
|
initial={{ opacity: 0, y: 20 }}
|
||
|
whileInView={{ opacity: 1, y: 0 }}
|
||
|
viewport={{ once: true }}
|
||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||
|
>
|
||
|
<motion.button
|
||
|
className={`px-4 py-2 rounded-full transition-all duration-300 ${
|
||
|
activeFilter === 'All'
|
||
|
? "bg-primary text-white shadow-md shadow-primary/20"
|
||
|
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||
|
}`}
|
||
|
onClick={() => setActiveFilter('All')}
|
||
|
whileHover={{ scale: 1.05 }}
|
||
|
whileTap={{ scale: 0.95 }}
|
||
|
>
|
||
|
All Projects
|
||
|
</motion.button>
|
||
|
|
||
|
{techStacks.map((tech, index) => (
|
||
|
<motion.button
|
||
|
key={index}
|
||
|
className={`px-4 py-2 rounded-full transition-all duration-300 ${
|
||
|
activeFilter === tech
|
||
|
? "bg-primary text-white shadow-md shadow-primary/20"
|
||
|
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||
|
}`}
|
||
|
onClick={() => setActiveFilter(tech)}
|
||
|
whileHover={{ scale: 1.05 }}
|
||
|
whileTap={{ scale: 0.95 }}
|
||
|
>
|
||
|
{tech}
|
||
|
</motion.button>
|
||
|
))}
|
||
|
</motion.div>
|
||
|
|
||
|
{/* Projects Grid */}
|
||
|
<motion.div
|
||
|
key={activeFilter}
|
||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||
|
variants={containerVariants}
|
||
|
initial="hidden"
|
||
|
animate="visible"
|
||
|
>
|
||
|
{filteredProjects.map((project, index) => (
|
||
|
<motion.div
|
||
|
key={`${project.title}-${index}`}
|
||
|
layout
|
||
|
variants={itemVariants}
|
||
|
initial="hidden"
|
||
|
animate="visible"
|
||
|
exit="hidden"
|
||
|
className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden shadow-lg hover:shadow-xl transition-all duration-300"
|
||
|
whileHover={{ y: -10 }}
|
||
|
onHoverStart={() => setHoveredProject(project.title)}
|
||
|
onHoverEnd={() => setHoveredProject(null)}
|
||
|
onClick={() => setSelectedProject(project)}
|
||
|
>
|
||
|
<div className="relative overflow-hidden h-48">
|
||
|
<img
|
||
|
src={getProjectImage(project.title)}
|
||
|
alt={project.title}
|
||
|
className="w-full h-full object-cover transition-transform duration-500"
|
||
|
style={{
|
||
|
transform: hoveredProject === project.title ? 'scale(1.1)' : 'scale(1)'
|
||
|
}}
|
||
|
/>
|
||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent flex items-end p-4">
|
||
|
<h3 className="text-white text-xl font-bold">{project.title}</h3>
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
<div className="p-5">
|
||
|
<p className="text-gray-600 dark:text-gray-300 mb-4 line-clamp-3">
|
||
|
{project.description}
|
||
|
</p>
|
||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||
|
{project.techStack.map((tech, techIndex) => (
|
||
|
<span
|
||
|
key={techIndex}
|
||
|
className="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-full"
|
||
|
>
|
||
|
{tech}
|
||
|
</span>
|
||
|
))}
|
||
|
</div>
|
||
|
<div className="flex justify-between items-center">
|
||
|
<button
|
||
|
className="text-primary dark:text-primary-light font-medium hover:underline flex items-center gap-1"
|
||
|
onClick={(e) => {
|
||
|
e.stopPropagation();
|
||
|
setSelectedProject(project);
|
||
|
}}
|
||
|
>
|
||
|
<CodeIcon className="w-4 h-4" />
|
||
|
View Details
|
||
|
</button>
|
||
|
{project.link && (
|
||
|
<a
|
||
|
href={project.link.href}
|
||
|
target="_blank"
|
||
|
rel="noopener noreferrer"
|
||
|
className="text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary-light transition-colors"
|
||
|
onClick={(e) => e.stopPropagation()}
|
||
|
>
|
||
|
<GithubIcon className="w-5 h-5" />
|
||
|
</a>
|
||
|
)}
|
||
|
</div>
|
||
|
</div>
|
||
|
</motion.div>
|
||
|
))}
|
||
|
</motion.div>
|
||
|
|
||
|
{/* Project Modal */}
|
||
|
<AnimatePresence>
|
||
|
{selectedProject && (
|
||
|
<motion.div
|
||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||
|
initial={{ opacity: 0 }}
|
||
|
animate={{ opacity: 1 }}
|
||
|
exit={{ opacity: 0 }}
|
||
|
onClick={() => setSelectedProject(null)}
|
||
|
>
|
||
|
<motion.div
|
||
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto"
|
||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||
|
animate={{ scale: 1, opacity: 1 }}
|
||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||
|
transition={{ type: "spring", damping: 20 }}
|
||
|
onClick={(e) => e.stopPropagation()}
|
||
|
>
|
||
|
<div className="relative h-64 md:h-80">
|
||
|
<img
|
||
|
src={getProjectImage(selectedProject.title)}
|
||
|
alt={selectedProject.title}
|
||
|
className="w-full h-full object-cover"
|
||
|
/>
|
||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent flex flex-col justify-end p-6">
|
||
|
<h3 className="text-white text-2xl md:text-3xl font-bold">{selectedProject.title}</h3>
|
||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||
|
{selectedProject.techStack.map((tech, techIndex) => (
|
||
|
<span
|
||
|
key={techIndex}
|
||
|
className="px-2 py-1 text-xs bg-white/20 text-white rounded-full"
|
||
|
>
|
||
|
{tech}
|
||
|
</span>
|
||
|
))}
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
<div className="p-6">
|
||
|
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||
|
{selectedProject.description}
|
||
|
</p>
|
||
|
|
||
|
{selectedProject.link && (
|
||
|
<div className="flex justify-end">
|
||
|
<a
|
||
|
href={selectedProject.link.href}
|
||
|
target="_blank"
|
||
|
rel="noopener noreferrer"
|
||
|
className="px-4 py-2 bg-primary text-white rounded-lg flex items-center gap-2 hover:bg-primary-dark transition-colors"
|
||
|
>
|
||
|
<GithubIcon className="w-4 h-4" />
|
||
|
View on GitHub
|
||
|
</a>
|
||
|
</div>
|
||
|
)}
|
||
|
</div>
|
||
|
</motion.div>
|
||
|
</motion.div>
|
||
|
)}
|
||
|
</AnimatePresence>
|
||
|
</div>
|
||
|
</div>
|
||
|
);
|
||
|
};
|
||
|
|
||
|
export default ProjectsSection;
|