364 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|