parsaaghayi-front/src/app/(routes)/(website)/components/projects/projects.tsx

364 lines
16 KiB
TypeScript

"use client";
import {
CategoryResponse,
Project,
ProjectCategory,
ProjectResponse,
} from "@/app/types/types";
import { getCookie } from "cookies-next";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
interface MyProjectsProps {
dict: {
website: {
homePage: {
projects: {
title: string;
description: string;
};
};
};
};
}
const MAX_PROJECTS = 6;
const MAX_CATEGORIES = 5;
const toPersianDigits = (str: string | number): string =>
String(str).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]);
export default function Projects({ dict }: MyProjectsProps) {
const [categories, setCategories] = useState<ProjectCategory[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [loading, setLoading] = useState(true);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const locale = (getCookie("selectedLocale") || getCookie("defaultLocale") || "en") as string;
const isRTL = locale === "fa";
useEffect(() => {
const initialCategory = locale === "fa" ? "جدیدترین‌ها" : "Newest";
setSelectedCategory(initialCategory);
const fetchProjects = async () => {
setLoading(true);
try {
const res = await fetch(
`/api/website/projects/translation/${locale}?sort=desc&per_page=40`,
);
const data = await res.json();
const resProjects: Project[] =
(data.projects as ProjectResponse).data || [];
const resCategories: CategoryResponse[] = data.categories || [];
setProjects(resProjects);
const uniqueCategories: ProjectCategory[] = [
{
label: initialCategory,
active: true,
},
...resCategories
.slice(0, MAX_CATEGORIES)
.map((category: CategoryResponse) => ({
label: category.title,
active: false,
})),
];
setCategories(uniqueCategories);
} catch {
setProjects([]);
}
setLoading(false);
};
fetchProjects();
}, [locale]);
const groupedProjects = useMemo(
() => projects.reduce<Record<string, Project[]>>((acc, project) => {
const category =
project.category ||
(locale === "fa" ? "بدون دسته‌بندی" : "Uncategorized");
if (!acc[category]) acc[category] = [];
acc[category].push(project);
return acc;
}, {}),
[projects, locale],
);
const filteredProjects: Project[] = useMemo(() => {
const newestLabel = locale === "fa" ? "جدیدترین‌ها" : "Newest";
if (selectedCategory === newestLabel) {
return projects.slice(0, MAX_PROJECTS);
}
return groupedProjects[selectedCategory]?.slice(0, MAX_PROJECTS) || [];
}, [selectedCategory, projects, groupedProjects, locale]);
const currentModalIndex = useMemo(
() => filteredProjects.findIndex((p) => p.id === selectedProject?.id),
[filteredProjects, selectedProject],
);
const handlePrev = useCallback(() => {
if (currentModalIndex > 0) {
setSelectedProject(filteredProjects[currentModalIndex - 1]);
}
}, [currentModalIndex, filteredProjects]);
const handleNext = useCallback(() => {
if (currentModalIndex < filteredProjects.length - 1) {
setSelectedProject(filteredProjects[currentModalIndex + 1]);
}
}, [currentModalIndex, filteredProjects]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") setSelectedProject(null);
if (e.key === "ArrowLeft") handlePrev();
if (e.key === "ArrowRight") handleNext();
}, [handlePrev, handleNext]);
const renderSkeletons = (count: number) => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{Array.from({ length: count }, (_, index) => (
<div key={index} className="flex flex-col gap-4">
<Skeleton containerClassName="w-full rounded-2xl" height={250} />
<Skeleton containerClassName="w-full" height={32} />
<Skeleton containerClassName="w-full" height={60} />
</div>
))}
</div>
);
return (
<section
className="relative flex w-full max-w-[1200px] flex-col items-center py-20"
id="projects"
>
<div className="text-center mb-16 space-y-4">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
className="text-4xl md:text-5xl font-bold text-foreground tracking-tight"
>
{dict.website.homePage.projects.title}
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
viewport={{ once: true }}
className="text-lg text-muted-foreground max-w-2xl mx-auto text-balance"
>
{dict.website.homePage.projects.description}
</motion.p>
</div>
<div className="flex flex-wrap items-center justify-center gap-3 mb-12">
{categories.length > 0
? categories.map((category, index) => (
<motion.button
key={index}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setSelectedCategory(category.label)}
className={`px-5 py-2 rounded-full text-sm font-medium transition-all duration-300 ${
selectedCategory === category.label
? "bg-primary text-primary-foreground shadow-lg shadow-primary/30"
: "bg-muted text-muted-foreground hover:bg-muted-foreground/20"
}`}
>
{category.label}
</motion.button>
))
: renderSkeletons(4)}
</div>
<motion.div
layout
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full"
>
<AnimatePresence mode="popLayout">
{filteredProjects.length > 0
? filteredProjects.map((project) => (
<motion.div
key={project.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.3 }}
onClick={() => setSelectedProject(project)}
className="group relative rounded-3xl overflow-hidden glass-panel cursor-pointer"
>
<div className="relative h-64 w-full overflow-hidden">
{project.images.map((image, idx) => (
<Image
key={idx}
src={process.env.NEXT_PUBLIC_BACK_URL + "/images/projects/" + image.src}
alt={`Image for ${project.title}`}
fill
className={`object-cover transition-transform duration-500 group-hover:scale-110 ${
idx === 0 ? "opacity-100" : "opacity-0"
}`}
/>
))}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-300 flex flex-col justify-end p-6 text-white">
<span className="text-xs font-mono text-primary mb-2 uppercase tracking-widest">
{project.category}
</span>
<span className="text-lg font-bold">
{isRTL ? "مشاهده جزییات" : "View Details"}
</span>
</div>
</div>
<div className="p-6">
<h5 className="text-sm font-mono text-primary mb-1">
{project.category}
</h5>
<p className="text-xl font-bold text-foreground group-hover:text-primary transition-colors">
{project.title}
</p>
</div>
</motion.div>
))
: loading
? renderSkeletons(3)
: (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-full bg-muted flex items-center justify-center mb-6">
<svg className="w-10 h-10 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h3 className="text-xl font-bold text-foreground mb-2">
{isRTL ? "پروژه‌ای یافت نشد" : "No projects found"}
</h3>
<p className="text-muted-foreground max-w-md">
{isRTL ? "در حال حاضر پروژه‌ای در این دسته‌بندی وجود ندارد. لطفاً دسته‌بندی دیگری را انتخاب کنید." : "No projects in this category yet. Please try another category."}
</p>
</div>
)}
</AnimatePresence>
</motion.div>
<AnimatePresence>
{selectedProject && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={() => setSelectedProject(null)}
onKeyDown={handleKeyDown}
tabIndex={0}
>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ duration: 0.3 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-5xl max-h-[90vh] rounded-3xl bg-background border border-border shadow-2xl overflow-hidden"
>
<button
onClick={() => setSelectedProject(null)}
className="absolute top-4 end-4 z-10 w-10 h-10 rounded-full bg-background/80 backdrop-blur-sm border border-border flex items-center justify-center text-foreground hover:text-primary transition-colors"
aria-label="Close modal"
>
<X className="w-5 h-5" />
</button>
<div className="flex flex-col lg:flex-row h-full max-h-[90vh]">
{/* Left: Image */}
<div className="relative w-full lg:w-1/2 h-64 lg:h-auto min-h-[300px]">
{selectedProject.images.map((image, idx) => (
<Image
key={idx}
src={process.env.NEXT_PUBLIC_BACK_URL + "/images/projects/" + image.src}
alt={`Image for ${selectedProject.title}`}
fill
className={`object-cover ${idx === 0 ? "opacity-100" : "opacity-0"}`}
/>
))}
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent" />
</div>
{/* Right: Details */}
<div className="flex flex-col w-full lg:w-1/2 p-8 lg:p-10 overflow-hidden">
<div className="flex-grow overflow-y-auto custom-scrollbar pr-2">
<span className="text-xs font-mono text-primary uppercase tracking-widest">
{selectedProject.category}
</span>
<h3 className="text-2xl lg:text-3xl font-bold text-foreground mt-2 mb-6">
{selectedProject.title}
</h3>
<div className="flex flex-wrap gap-2 mb-6">
<span className="px-3 py-1 rounded-full text-xs font-mono border border-primary/30 bg-primary/10 text-primary">
{selectedProject.category}
</span>
<span className="px-3 py-1 rounded-full text-xs font-mono border border-muted-foreground/30 bg-muted text-muted-foreground">
{isRTL ? "طراحی وب" : "Web Design"}
</span>
<span className="px-3 py-1 rounded-full text-xs font-mono border border-muted-foreground/30 bg-muted text-muted-foreground">
{isRTL ? "توسعه" : "Development"}
</span>
</div>
<p className="text-muted-foreground leading-relaxed text-sm md:text-base">
{isRTL
? "این پروژه با استفاده از آخرین تکنولوژی‌های وب توسعه یافته است. تیم ما با دقت و توجه به جزئیات، راهکاری scalable و maintainable ارائه داده است که نیازهای کسب و کار را به بهترین شکل برآورده می‌کند. از طراحی رابط کاربری گرفته تا بهینه‌سازی performance، تمامی جنبه‌های این پروژه با بالاترین استانداردها پیاده‌سازی شده است."
: "This project was built using the latest web technologies. Our team delivered a scalable and maintainable solution with meticulous attention to detail, meeting business requirements effectively. From UI/UX design to performance optimization, every aspect was implemented to the highest standards."}
</p>
</div>
{/* Navigation */}
<div className="flex items-center justify-between pt-6 mt-6 border-t border-border flex-shrink-0">
<button
onClick={isRTL ? handleNext : handlePrev}
disabled={isRTL ? currentModalIndex >= filteredProjects.length - 1 : currentModalIndex <= 0}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-muted text-foreground hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className={`w-5 h-5 ${isRTL ? "rotate-180" : ""}`} />
<span className="text-sm font-medium">
{isRTL ? "بعدی" : "Previous"}
</span>
</button>
<span className="text-sm text-muted-foreground">
{isRTL
? toPersianDigits(`${currentModalIndex + 1} / ${filteredProjects.length}`)
: `${currentModalIndex + 1} / ${filteredProjects.length}`}
</span>
<button
onClick={isRTL ? handlePrev : handleNext}
disabled={isRTL ? currentModalIndex <= 0 : currentModalIndex >= filteredProjects.length - 1}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-muted text-foreground hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<span className="text-sm font-medium">
{isRTL ? "قبلی" : "Next"}
</span>
<ChevronRight className={`w-5 h-5 ${isRTL ? "rotate-180" : ""}`} />
</button>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}