Newer
Older
My-Portfolio / frontend / src / components / sections / projects / Project.jsx
  1. import React, { useRef, useState, useEffect } from "react";
  2. import { motion } from "framer-motion";
  3. import { useParams } from "react-router-dom";
  4. import { fetchData } from "../../../api";
  5. import HoverableIconList from "./HoverableIcon";
  6. import Container from "./Container";
  7. import Slideshow from "./Slideshow";
  8. import ElementObserver from "../ElementObserver";
  9. import MouseTracker from "../../MouseTracker";
  10. import RightArrow from "../../../assets/buttons/RightArrow.svg";
  11. import LeftArrow from "../../../assets/buttons/LeftArrow.svg";
  12. import { clock_icon } from "../../../assets";
  13. import styles from "./Project.module.css";
  14. import useIsMobile from "../../../constants/useIsMobile";
  15. import { floattingImages } from "../../../constants";
  16. import FloatingSVG from "../../design/FloatingSVG";
  17. const Project = () => {
  18. const { title } = useParams();
  19. const [projectData, setProjectData] = useState(null);
  20. const [projectDetails, setProjectDetails] = useState([]);
  21. const [loading, setLoading] = useState(true);
  22. const [error, setError] = useState(null);
  23. const [hoverState, setHoverState] = useState({
  24. section: null,
  25. index: null,
  26. text: null,
  27. });
  28. const [currentIndex, setCurrentIndex] = useState(0);
  29. const [direction, setDirection] = useState(1);
  30. const intervalRef = useRef(null);
  31. const GET_PROJECTS = import.meta.env.VITE_GET_PROJECTS;
  32. const GET_WORK_PAGE = import.meta.env.VITE_GET_WORK_PAGE;
  33. const isMobile = useIsMobile();
  34. // Define selectors for the sections you want to animate
  35. const sectionSelectors = [
  36. ".description-section",
  37. ".trailer-section",
  38. ".skill-section",
  39. ".details-section",
  40. ".links-section",
  41. ];
  42. {
  43. /* Framer Motion Variants */
  44. }
  45. const containerVariants = {
  46. visible: {
  47. opacity: 1,
  48. scale: 1,
  49. transition: {
  50. staggerChildren: 0.2, // Stagger delay for each child
  51. },
  52. },
  53. hidden: {
  54. opacity: 0,
  55. scale: 0,
  56. },
  57. };
  58. const itemVariants = {
  59. visible: {
  60. opacity: 1,
  61. scale: 1,
  62. },
  63. hidden: {
  64. opacity: 0,
  65. scale: 0,
  66. },
  67. };
  68. // Use ElementObserver to track visibility of sections
  69. const isElementVisible = ElementObserver(sectionSelectors);
  70. useEffect(() => {
  71. const fetchProjectData = async () => {
  72. try {
  73. // Fetch all projects and find the one with the matching title
  74. const allProjects = await fetchData(GET_PROJECTS);
  75. const project = allProjects.find((p) => {
  76. const projectSlug = p.title
  77. .toLowerCase()
  78. .replace(/[^a-z0-9 -]/g, "")
  79. .replace(/\s+/g, "-")
  80. .replace(/-+/g, "-");
  81. return projectSlug === title;
  82. });
  83. if (project) {
  84. const data = await fetchData(`${GET_WORK_PAGE}/${project.id}`);
  85. setProjectData(data);
  86. } else {
  87. setError("Project not found");
  88. }
  89. } catch (err) {
  90. setError(err.message);
  91. } finally {
  92. setLoading(false);
  93. }
  94. };
  95. fetchProjectData();
  96. }, [title]);
  97. const resetTimer = () => {
  98. clearInterval(intervalRef.current);
  99. intervalRef.current = setInterval(() => {
  100. setDirection(1);
  101. setCurrentIndex(
  102. (prevIndex) =>
  103. (prevIndex + 1) % projectData?.repository?.commits?.length
  104. );
  105. }, 6000);
  106. };
  107. useEffect(() => {
  108. if (!projectData) return;
  109. // Reset projectDetails before adding new data
  110. setProjectDetails([]);
  111. if (projectData?.start_date) {
  112. const parseDate = (dateString) => {
  113. const parts = dateString.split(" ");
  114. if (parts.length === 3) {
  115. // Format: "Month Day, Year" (e.g., "March 15, 2024")
  116. return new Date(dateString);
  117. } else if (parts.length === 2) {
  118. // Format: "Month Year" (Default to 1st of the month)
  119. const [month, year] = parts;
  120. return new Date(`${month} 1, ${year}`);
  121. }
  122. return null;
  123. };
  124. const startDate = parseDate(projectData.start_date);
  125. const isPresent = projectData.end_date === "Present";
  126. const endDate = isPresent ? "Present" : parseDate(projectData.end_date);
  127. const lastDate = isPresent ? new Date() : endDate;
  128. const formatDate = (date) => {
  129. return date.toLocaleString("default", {
  130. month: "long",
  131. day: "numeric",
  132. year: "numeric",
  133. });
  134. };
  135. const partialFormatDate = (date) => {
  136. return date.toLocaleString("default", {
  137. month: "long",
  138. year: "numeric",
  139. });
  140. };
  141. const partialDate = isPresent
  142. ? `${partialFormatDate(startDate)} - Present`
  143. : `${partialFormatDate(startDate)} - ${partialFormatDate(endDate)}`;
  144. const date = isPresent
  145. ? `${formatDate(startDate)} - Present`
  146. : `${formatDate(startDate)} - ${formatDate(endDate)}`;
  147. const dateDetail = { text: partialDate, logo: projectData.date_logo };
  148. setProjectDetails((prevDetails) => [...prevDetails, dateDetail]);
  149. // Calculate the total time difference in days
  150. const timeDifferenceInDays = Math.floor(
  151. (lastDate - startDate) / (1000 * 60 * 60 * 24)
  152. );
  153. // Convert days to weeks and months
  154. const timeDifferenceInWeeks = Math.floor(timeDifferenceInDays / 7);
  155. const timeDifferenceInMonths =
  156. (lastDate.getFullYear() - startDate.getFullYear()) * 12 +
  157. (lastDate.getMonth() - startDate.getMonth());
  158. let durationText = "";
  159. if (timeDifferenceInMonths === 0) {
  160. // If in the same month, show weeks (or days if less than a week)
  161. if (timeDifferenceInWeeks > 0) {
  162. durationText = `${timeDifferenceInWeeks} week${
  163. timeDifferenceInWeeks > 1 ? "s" : ""
  164. }`;
  165. } else {
  166. durationText = `${timeDifferenceInDays} day${
  167. timeDifferenceInDays > 1 ? "s" : ""
  168. }`;
  169. }
  170. } else if (timeDifferenceInMonths < 12) {
  171. durationText = `${timeDifferenceInMonths} month${
  172. timeDifferenceInMonths > 1 ? "s" : ""
  173. }`;
  174. } else {
  175. const years = Math.floor(timeDifferenceInMonths / 12);
  176. const months = timeDifferenceInMonths % 12;
  177. durationText = `${years} year${years > 1 ? "s" : ""}${
  178. months > 0 ? ` ${months} month${months > 1 ? "s" : ""}` : ""
  179. }`;
  180. }
  181. const durationDetail = {
  182. text: durationText,
  183. logo: clock_icon,
  184. };
  185. setProjectDetails((prevDetails) => [...prevDetails, durationDetail]);
  186. }
  187. if (projectData?.context && projectData.context.project_context) {
  188. const contextDetail = {
  189. text: projectData.context.project_context,
  190. logo: projectData.context.context_logo,
  191. };
  192. setProjectDetails((prevDetails) => [...prevDetails, contextDetail]);
  193. }
  194. if (projectData?.category) {
  195. const detail = {
  196. text: projectData.category.name,
  197. logo: projectData.category.logo,
  198. };
  199. setProjectDetails((prevDetails) => [...prevDetails, detail]);
  200. }
  201. if (projectData?.tags && projectData.tags.length > 0) {
  202. const tagsDetails = projectData.tags
  203. .filter((tag) => tag.logo)
  204. .map((tag) => ({
  205. text: tag.name,
  206. logo: tag.logo,
  207. }));
  208. setProjectDetails((prevDetails) => [...prevDetails, ...tagsDetails]);
  209. }
  210. resetTimer(); // Start the interval when data loads
  211. return () => clearInterval(intervalRef.current); // Cleanup on unmount
  212. }, [projectData]);
  213. const nextCommit = () => {
  214. setDirection(1);
  215. setCurrentIndex(
  216. (prevIndex) => (prevIndex + 1) % projectData?.repository?.commits.length
  217. );
  218. resetTimer(); // Restart the timer
  219. };
  220. const prevCommit = () => {
  221. setDirection(-1);
  222. setCurrentIndex((prevIndex) =>
  223. prevIndex === 0
  224. ? projectData?.repository?.commits.length - 1
  225. : prevIndex - 1
  226. );
  227. resetTimer(); // Restart the timer
  228. };
  229. if (loading) return <div>Loading...</div>;
  230. if (error) return <div>Error: {error}</div>;
  231. if (!projectData) return <div>No data found</div>;
  232. return (
  233. <div className="min-h-screen text-white ">
  234. {/* MouseTracker */}
  235. {!isMobile && <MouseTracker text={hoverState.text} />}
  236. {/* Background Image */}
  237. <div className="absolute top-0 left-0 w-full h-full z-0">
  238. {projectData?.background_image ? (
  239. <img
  240. className="object-cover w-full h-full"
  241. src={projectData?.background_image}
  242. alt="Project Background"
  243. />
  244. ) : (
  245. <div className="absolute w-full h-full bg-[#181818]">
  246. {/* Add floating SVG images to the background */}
  247. {floattingImages.map((image, index) => (
  248. <FloatingSVG
  249. key={index}
  250. svg={image.svg}
  251. position={image.position}
  252. speed={image.speed}
  253. amplitude={image.amplitude}
  254. resetTrigger={true}
  255. />
  256. ))}
  257. </div>
  258. )}
  259. </div>
  260. <div className="relative z-10 w-full max-w-[100rem] mx-auto px-4 justify-center">
  261. {/* Project Logo */}
  262. <header className="flex flex-col sm:flex-row items-center gap-6 mb-10">
  263. <img
  264. src={projectData?.project_logo}
  265. alt="Project Logo"
  266. className="w-full h-full rounded-md mt-14"
  267. />
  268. <h1 className="text-2xl sm:text-4xl font-bold text-center sm:text-left">
  269. {projectData?.title}
  270. </h1>
  271. </header>
  272. {/* Project Description */}
  273. <motion.div
  274. className="description-section"
  275. initial={{ scale: 0, opacity: 0 }}
  276. animate={
  277. isElementVisible(document.querySelector(".description-section"))
  278. ? { scale: 1, opacity: 1 }
  279. : { scale: 0, opacity: 0 }
  280. }
  281. transition={{ duration: 0.5 }}
  282. >
  283. <Container className="bg-black/90 mb-10">
  284. <div className="flex flex-col lg:flex-row flex-grow sm:gap-6">
  285. {/* Thumbnail */}
  286. <img
  287. src={projectData?.project_thumbnail}
  288. alt="Project Thumbnail"
  289. className="w-full lg:w-1/3 rounded-lg object-cover aspect-video"
  290. />
  291. {/* Description */}
  292. <div className="w-full text-lg lg:text-xl mt-4 sm:mt-0">
  293. <h2 className="text-2xl sm:text-3xl lg:text-4xl font-semibold mb-2 lg:mb-6 sm:text-left text-orange-400">
  294. Description
  295. </h2>
  296. <p>{projectData?.project_description}</p>
  297. </div>
  298. </div>
  299. </Container>
  300. </motion.div>
  301. {/* Trailer */}
  302. <motion.div
  303. className="trailer-section"
  304. initial={{ scale: 0, opacity: 0 }}
  305. animate={
  306. isElementVisible(document.querySelector(".trailer-section"))
  307. ? { scale: 1, opacity: 1 }
  308. : { scale: 0, opacity: 0 }
  309. }
  310. transition={{ duration: 0.5 }}
  311. >
  312. {projectData?.trailer && (
  313. <Container className="bg-black/90 mb-10 max-w-auto mx-auto w-full">
  314. <h2 className="text-2xl sm:text-3xl lg:text-4xl font-semibold mb-6 text-center">
  315. Trailer
  316. </h2>
  317. <div className="w-full aspect-video">
  318. <iframe
  319. className="w-full h-full rounded-lg"
  320. src={projectData.trailer}
  321. title={`${projectData.title} Trailer`}
  322. allowFullScreen
  323. ></iframe>
  324. </div>
  325. </Container>
  326. )}
  327. </motion.div>
  328. {/* Tech, Languages, Tools and Databases Section */}
  329. <div className="flex flex-wrap gap-6 justify-center mb-10">
  330. <motion.div
  331. className="skill-section"
  332. initial={{ scale: 0, opacity: 0 }}
  333. animate={
  334. isElementVisible(document.querySelector(".skill-section"))
  335. ? { scale: 1, opacity: 1 }
  336. : { scale: 0, opacity: 0 }
  337. }
  338. transition={{ duration: 0.5 }}
  339. >
  340. {projectData?.skills.length > 0 && (
  341. <div className="flex flex-wrap justify-center gap-6">
  342. {projectData.skills.map((skillCategory, categoryIndex) =>
  343. Object.keys(skillCategory).map((categoryKey) => {
  344. const groupedItems = skillCategory[categoryKey].reduce(
  345. (acc, item) => {
  346. if (!acc[item.skill_name]) acc[item.skill_name] = [];
  347. acc[item.skill_name].push(item);
  348. return acc;
  349. },
  350. {}
  351. );
  352. return Object.entries(groupedItems).map(
  353. ([skillName, items], groupIndex) => (
  354. <Container
  355. key={`${categoryIndex}-${categoryKey}-${skillName}`}
  356. className="bg-black/90 p-4 min-w-[260px]"
  357. >
  358. <h3 className="text-xl text-center font-semibold text-orange-400">
  359. {skillName}
  360. </h3>
  361. <div className="flex flex-wrap gap-3">
  362. {items.map((item, itemIndex) => (
  363. <HoverableIconList
  364. key={itemIndex}
  365. items={[item]}
  366. getKey={(icon) => icon.id}
  367. sectionId={`skills-${categoryKey}-${groupIndex}`}
  368. onHoverStart={(section, index, text) =>
  369. setHoverState({ section, index, text })
  370. }
  371. onHoverEnd={() =>
  372. setHoverState({
  373. section: null,
  374. index: null,
  375. text: null,
  376. })
  377. }
  378. />
  379. ))}
  380. </div>
  381. </Container>
  382. )
  383. );
  384. })
  385. )}
  386. </div>
  387. )}
  388. </motion.div>
  389. {/* Details Section */}
  390. <motion.div
  391. className="details-section"
  392. initial={{ scale: 0, opacity: 0 }}
  393. animate={
  394. isElementVisible(document.querySelector(".details-section"))
  395. ? { scale: 1, opacity: 1 }
  396. : { scale: 0, opacity: 0 }
  397. }
  398. transition={{ duration: 0.5, delay: 0.1 }}
  399. >
  400. <div className="relative z-10 w-full flex flex-wrap sm:flex-row gap-4 md:gap-6 justify-center mb-10 px-4">
  401. {projectDetails.map((detail, index) => (
  402. <Container
  403. key={index}
  404. className="bg-black/90 max-w-[200px] min-w-[200px] px-4 py-6 flex flex-col items-center justify-center text-center transition-shadow duration-150 hover:shadow-[0_0_30px_rgba(255,255,255,0.6)]"
  405. whileHover={{ scale: 1.1 }}
  406. transition={{ duration: 0.1 }}
  407. >
  408. <img
  409. className="relative mb-4 w-16 md:w-20 object-contain"
  410. src={detail.logo}
  411. alt="Detail Logo"
  412. />
  413. <h2 className="text-base md:text-lg mb-4 text-white">
  414. {detail.text}
  415. </h2>
  416. </Container>
  417. ))}
  418. </div>
  419. </motion.div>
  420. </div>
  421. {projectData?.repository && projectData?.repository?.url && (
  422. <div className="flex flex-col items-center justify-center min-h-[300px]">
  423. <div className={styles["activity-card"]}>
  424. <h2 className="text-3xl md:4xl lg:text-4xl font-bold mb-6 text-center">
  425. Development Activity
  426. </h2>
  427. <motion.div
  428. key={currentIndex}
  429. initial={{ x: direction * 100, opacity: 0 }}
  430. animate={{ x: 0, opacity: 1 }}
  431. exit={{ x: -direction * 100, opacity: 0 }}
  432. transition={{ duration: 0.1 }}
  433. className={styles["activity-content"]}
  434. >
  435. <img
  436. src={
  437. projectData?.repository?.commits?.[currentIndex]?.logo ||
  438. "default-logo-url"
  439. }
  440. alt="GitBucket Logo"
  441. className={styles["activity-logo"]}
  442. />
  443. <div>
  444. <h3 className={styles["activity-title"]}>
  445. {projectData?.repository?.commits?.[currentIndex]
  446. ?.message || "No Title"}
  447. </h3>
  448. <p className={styles["activity-details"]}>
  449. Author:{" "}
  450. {projectData?.repository?.commits?.[currentIndex]?.author} -{" "}
  451. {new Date(
  452. projectData?.repository?.commits?.[currentIndex]?.date
  453. ).toLocaleString()}
  454. </p>
  455. </div>
  456. </motion.div>
  457. <a
  458. href={projectData?.repository?.commits?.[currentIndex]?.url}
  459. className={styles["activity-link"]}
  460. target="_blank"
  461. rel="noopener noreferrer"
  462. >
  463. View Commit
  464. </a>
  465. </div>
  466. {/* Buttons */}
  467. <div className={styles["activity-buttons"]}>
  468. <button
  469. onClick={prevCommit}
  470. className={styles["activity-button"]}
  471. >
  472. <img src={LeftArrow} alt="Previous" />
  473. </button>
  474. <button
  475. onClick={nextCommit}
  476. className={styles["activity-button"]}
  477. >
  478. <img src={RightArrow} alt="Next" />
  479. </button>
  480. </div>
  481. </div>
  482. )}
  483. {projectData?.screenshots && projectData.screenshots.length > 0 && (
  484. <Slideshow
  485. images={projectData.screenshots}
  486. projectName={projectData.project_title}
  487. />
  488. )}
  489. {/* Links Section */}
  490. <motion.div
  491. className="links-section"
  492. variants={containerVariants}
  493. initial="hidden"
  494. animate={
  495. isElementVisible(document.querySelector(".links-section"))
  496. ? "visible"
  497. : "hidden"
  498. }
  499. >
  500. {projectData?.links && projectData.links.length > 0 && (
  501. <motion.div
  502. className="relative justify-center flex flex-wrap gap-4 md:gap-6 mt-20 mb-10"
  503. variants={containerVariants}
  504. >
  505. {projectData.links.map((link, index) => (
  506. <motion.a
  507. key={link.url}
  508. href={link.url}
  509. target="_blank"
  510. rel="noopener noreferrer"
  511. variants={itemVariants}
  512. onMouseEnter={() =>
  513. setHoverState({
  514. section: "links",
  515. index,
  516. text: link.name,
  517. })
  518. }
  519. onMouseLeave={() =>
  520. setHoverState({
  521. section: null,
  522. index: null,
  523. text: null,
  524. })
  525. }
  526. >
  527. <div className={styles["button-container"]}>
  528. <img
  529. className={styles["button-logo"]}
  530. src={link.logo}
  531. alt={link.name}
  532. />
  533. </div>
  534. </motion.a>
  535. ))}
  536. </motion.div>
  537. )}
  538. </motion.div>
  539. </div>
  540. </div>
  541. );
  542. };
  543. export default Project;