Files
portfolio/app/projects/page.tsx
2026-01-12 14:36:10 +00:00

309 lines
13 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { motion } from 'framer-motion';
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from 'lucide-react';
import Link from 'next/link';
import { useLocale, useTranslations } from "next-intl";
interface Project {
id: number;
slug: string;
title: string;
description: string;
content: string;
tags: string[];
featured: boolean;
category: string;
date: string;
github?: string;
live?: string;
imageUrl?: string;
}
const ProjectsPage = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [filteredProjects, setFilteredProjects] = useState<Project[]>([]);
const [categories, setCategories] = useState<string[]>(["All"]);
const [selectedCategory, setSelectedCategory] = useState("All");
const [searchQuery, setSearchQuery] = useState("");
const [mounted, setMounted] = useState(false);
const locale = useLocale();
const t = useTranslations("common");
// Load projects from API
useEffect(() => {
const loadProjects = async () => {
try {
const response = await fetch('/api/projects?published=true');
if (response.ok) {
const data = await response.json();
const loadedProjects = data.projects || [];
setProjects(loadedProjects);
// Extract unique categories
const uniqueCategories = ["All", ...Array.from(new Set(loadedProjects.map((p: Project) => p.category))) as string[]];
setCategories(uniqueCategories);
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Error loading projects:', error);
}
}
};
loadProjects();
setMounted(true);
}, []);
// Filter projects
useEffect(() => {
let result = projects;
if (selectedCategory !== "All") {
result = result.filter(project => project.category === selectedCategory);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(project =>
project.title.toLowerCase().includes(query) ||
project.description.toLowerCase().includes(query) ||
project.tags.some(tag => tag.toLowerCase().includes(query))
);
}
setFilteredProjects(result);
}, [projects, selectedCategory, searchQuery]);
if (!mounted) {
return null;
}
return (
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
<div className="max-w-7xl mx-auto px-4">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="mb-12"
>
<Link
href={`/${locale}`}
className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-800 transition-colors mb-8 group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span>{t("backToHome")}</span>
</Link>
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
My Projects
</h1>
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">
Explore my portfolio of projects, from web applications to mobile apps.
Each project showcases different skills and technologies.
</p>
</motion.div>
{/* Filters & Search */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="mb-12 flex flex-col md:flex-row gap-6 justify-between items-start md:items-center"
>
{/* Categories */}
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-5 py-2 rounded-full text-sm font-medium transition-all duration-200 border ${
selectedCategory === category
? 'bg-stone-800 text-stone-50 border-stone-800 shadow-md'
: 'bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300'
}`}
>
{category}
</button>
))}
</div>
{/* Search */}
<div className="relative w-full md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
<input
type="text"
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-white border border-stone-200 rounded-full text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:border-stone-400 transition-all"
/>
</div>
</motion.div>
{/* Projects Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProjects.map((project, index) => (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: index * 0.1 }}
whileHover={{ y: -8 }}
className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-all duration-500"
>
{/* Image / Fallback / Cover Area */}
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
{project.imageUrl ? (
<>
<img
src={project.imageUrl}
alt={project.title}
className="w-full h-full object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</>
) : (
<div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" />
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
<div className="relative z-10">
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
{project.title.charAt(0)}
</span>
</div>
</div>
)}
{/* Texture/Grain Overlay */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
{/* Animated Shine Effect */}
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
{project.featured && (
<div className="absolute top-3 left-3 z-20">
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
Featured
</div>
</div>
)}
{/* Overlay Links */}
<div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="GitHub"
onClick={(e) => e.stopPropagation()}
>
<Github size={20} />
</a>
)}
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="Live Demo"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={20} />
</a>
)}
</div>
</div>
<div className="p-6 flex flex-col flex-1">
{/* Stretched Link covering the whole card (including image area) */}
<Link
href={`/${locale}/projects/${project.slug}`}
className="absolute inset-0 z-10"
aria-label={`View project ${project.title}`}
/>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
{project.title}
</h3>
<div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100">
<Calendar size={12} />
<span>{new Date(project.date).getFullYear()}</span>
</div>
</div>
<p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">
{project.description}
</p>
<div className="flex flex-wrap gap-2 mb-6">
{project.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
>
{tag}
</span>
))}
{project.tags.length > 4 && (
<span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
)}
</div>
<div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
<div className="flex gap-3">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<Github size={18} />
</a>
)}
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={18} />
</a>
)}
</div>
</div>
</div>
</motion.div>
))}
</div>
{filteredProjects.length === 0 && (
<div className="text-center py-20">
<p className="text-stone-500 text-lg">No projects found matching your criteria.</p>
<button
onClick={() => {setSelectedCategory("All"); setSearchQuery("");}}
className="mt-4 text-stone-800 font-medium hover:underline"
>
Clear filters
</button>
</div>
)}
</div>
</div>
);
};
export default ProjectsPage;