mirror of
https://github.com/guezoloic/website.git
synced 2026-03-28 18:03:50 +00:00
feat: rework entire project structure
This commit is contained in:
24
app/components/layout/Section.tsx
Normal file
24
app/components/layout/Section.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, { JSX } from "react";
|
||||
|
||||
type SectionProps = {
|
||||
children: JSX.Element;
|
||||
title: string;
|
||||
id: string
|
||||
};
|
||||
|
||||
export default function Section({ children, title, id }: SectionProps) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className="my-3 relative max-w-5xl mx-auto mt-5 rounded-2xl flex flex-col gap-8 text-gray-100"
|
||||
>
|
||||
<h2
|
||||
className="text-3xl sm:text-5xl font-extrabold bg-clip-text text-transparent
|
||||
bg-gradient-to-r from-green-200 via-emerald-600 to-green-800"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
53
app/components/sections/About.tsx
Normal file
53
app/components/sections/About.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { JSX } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import * as SOLID from "@heroicons/react/24/solid";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Section from "@app/components/layout/Section";
|
||||
|
||||
import content from "@app/data/content.json"
|
||||
import Window from "@app/components/ui/Window";
|
||||
|
||||
type AboutProps = {
|
||||
id: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function About({ id, open, onClose }: AboutProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const paragraphs = content.about;
|
||||
|
||||
return (
|
||||
<Window open={open} onClose={onClose}>
|
||||
<div className="flex justify-center items-center h-full w-full md:px-6">
|
||||
<div className="max-w-3xl w-full bg-black/21 rounded-2xl p-8 space-y-4 shadow-lg">
|
||||
<Section id={id} title={t('about.title')}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{paragraphs.map((paragraph, i) => {
|
||||
const Icon = (SOLID as Record<string, React.ElementType>)[paragraph.icon];
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="flex items-start gap-3 p-4 rounded-xl hover:bg-black/30 transition-colors"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0 }}
|
||||
transition={{ duration: 0.6, delay: i * 0.08 }}
|
||||
>
|
||||
<span className="mt-1 text-emerald-400">
|
||||
<Icon className="w-6 h-6" />
|
||||
</span>
|
||||
<p className="text-sm md:text-base leading-relaxed text-white">
|
||||
{t(paragraph.text)}
|
||||
</p>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
112
app/components/sections/Navbar.tsx
Normal file
112
app/components/sections/Navbar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence, Variants } from "framer-motion";
|
||||
import * as SOLID from "@heroicons/react/24/solid";
|
||||
|
||||
import Button from "@app/components/ui/Button";
|
||||
import content from "@app/data/content.json";
|
||||
import { MenuState } from "@/app/page";
|
||||
import Lang from "@app/components/ui/LangSwitcher"
|
||||
|
||||
type NavbarProps = {
|
||||
state: MenuState;
|
||||
setState: Dispatch<SetStateAction<MenuState>>;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
const navVariants: Variants = {
|
||||
initial: { scaleY: 0.8, scaleX: 0.1, opacity: 0.7 },
|
||||
animate: { scaleY: 1, scaleX: 1, opacity: 1, transition: { duration: 0.3, ease: [0.42, 0, 0.58, 1] } },
|
||||
};
|
||||
|
||||
const exitVariants: Variants = {
|
||||
initial: { scaleY: 1, scaleX: 5, opacity: 0.7 },
|
||||
animate: { scaleY: 1, scaleX: 1, opacity: 1, transition: { duration: 0.3, ease: [0.42, 0.66, 0.58, 1] } },
|
||||
};
|
||||
|
||||
|
||||
export default function Navbar({ state, setState, isOpen }: NavbarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
const handleClick = (key: keyof MenuState) => {
|
||||
setState(prev => ({ ...prev, [key]: true }));
|
||||
};
|
||||
|
||||
const closeWindows = () => {
|
||||
setState(prev => {
|
||||
const newState: MenuState = {} as MenuState;
|
||||
for (const key in prev) newState[key as keyof MenuState] = false;
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
const mainButton = content.navbar.buttons[0];
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen ? (
|
||||
<motion.div
|
||||
key="close"
|
||||
className="fixed bottom-4 left-1/2 transform -translate-x-1/2 flex justify-center z-50"
|
||||
variants={exitVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
>
|
||||
<Button
|
||||
onClick={closeWindows}
|
||||
label="exit"
|
||||
variant="icon"
|
||||
>
|
||||
<SOLID.XMarkIcon className="w-8 h-8 text-white" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="menu"
|
||||
className="fixed bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 z-50"
|
||||
variants={navVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
>
|
||||
<Button
|
||||
onClick={() => handleClick(mainButton.action as keyof MenuState)}
|
||||
label={t(mainButton.label)}
|
||||
variant="text"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center whitespace-nowrap">
|
||||
<span className="text-base md:text-lg font-bold text-white drop-shadow-lg">
|
||||
{content.name}
|
||||
</span>
|
||||
<span className="text-xs md:text-sm text-gray-300 font-light">
|
||||
{t(content.career)}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{content.navbar.buttons.slice(1).map((btn, i) => {
|
||||
const Icon = (SOLID as Record<string, React.ElementType>)[btn.icon];
|
||||
return (
|
||||
<div className="relative group" key={i}>
|
||||
<Button
|
||||
onClick={() => handleClick(btn.action as keyof MenuState)}
|
||||
label={t(btn.label)}
|
||||
variant="icon"
|
||||
>
|
||||
{Icon && <Icon className="w-6 h-6 text-white" />}
|
||||
</Button>
|
||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 rounded-md text-xs text-white bg-black/80 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
||||
{t(btn.label)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
);
|
||||
})}
|
||||
<Lang />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
82
app/components/sections/Projects.tsx
Normal file
82
app/components/sections/Projects.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { motion } from "framer-motion";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Section from "@app/components/layout/Section";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Window from "@app/components/ui/Window";
|
||||
|
||||
interface ProjectProps {
|
||||
name: string;
|
||||
description: string;
|
||||
html_url: string;
|
||||
language: string | null;
|
||||
}
|
||||
|
||||
type ProjectsProps = {
|
||||
id: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function Projects({ id, open, onClose }: ProjectsProps) {
|
||||
const [repos, setRepos] = useState<ProjectProps[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRepos = async () => {
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/users/guezoloic/repos?per_page=100`);
|
||||
const data = await res.json();
|
||||
|
||||
const sorted = data
|
||||
.filter((repo: any) => !repo.fork)
|
||||
.sort((a: any, b: any) => b.stargazers_count - a.stargazers_count)
|
||||
.slice(0, 6)
|
||||
.map((repo: any) => ({
|
||||
name: repo.name,
|
||||
description: repo.description,
|
||||
html_url: repo.html_url,
|
||||
language: repo.language,
|
||||
}));
|
||||
|
||||
setRepos(sorted);
|
||||
} catch (err) {
|
||||
console.error("Error while loading repos", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRepos();
|
||||
}, []);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Window open={open} onClose={onClose}>
|
||||
<Section id={id} title={t("projects.title")}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{repos.map((repo, i) => (
|
||||
<motion.a
|
||||
key={repo.name}
|
||||
href={repo.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-black/30 p-6 rounded-2xl shadow-lg hover:scale-105 transition-transform duration-300 flex flex-col gap-3"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.6 }}
|
||||
transition={{ duration: 0.4, delay: i * 0.1 }}
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-white">{repo.name}</h3>
|
||||
<p className="text-gray-200 text-sm">
|
||||
{repo.description || "No description"}
|
||||
</p>
|
||||
{repo.language && (
|
||||
<span className="text-sm font-medium text-emerald-400">
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
54
app/components/sections/Skills.tsx
Normal file
54
app/components/sections/Skills.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import * as SiIcons from "react-icons/si";
|
||||
import Section from "@app/components/layout/Section";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import content from "@app/data/content.json"
|
||||
import Window from "@app/components/ui/Window";
|
||||
|
||||
type SkillsProps = {
|
||||
id: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function Skills({ id, open, onClose }: SkillsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const skillsData = content.skills;
|
||||
return (
|
||||
<Window open={open} onClose={onClose}>
|
||||
<Section id={id} title={t("skills.title")}>
|
||||
{skillsData.map((section, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 max-w-3xl w-full bg-black/20 rounded-2xl p-8 space-y-4 shadow-lg">
|
||||
<h2 className="text-xl font-semibold font-extrabold bg-clip-text text-transparent
|
||||
bg-gradient-to-r from-green-200 via-emerald-600 to-green-800">{t(section.title)}:</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.tags.map((tag, j) => {
|
||||
const Icon = (SiIcons as Record<string, React.ElementType>)[tag.icon];
|
||||
return (
|
||||
<motion.div
|
||||
key={j}
|
||||
className="flex items-center gap-2 p-2"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0 }}
|
||||
transition={{ duration: 0.5, delay: j * 0.08 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1.5">
|
||||
{Icon && <Icon className="w-5 h-5 text-emerald-400 mt-1" />}
|
||||
<span className="text-sm md:text-base">{tag.name}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
</Window>
|
||||
);
|
||||
};
|
||||
50
app/components/sections/Title.tsx
Normal file
50
app/components/sections/Title.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import content from "@app/data/content.json"
|
||||
|
||||
export default function Title({isOpen}: {isOpen: boolean}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
className="flex items-center justify-center"
|
||||
style={isOpen ? { overflow: "hidden" } : undefined}
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ amount: 0.2, once: false }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
<div className="absolute top-6 bg-transparent px-4 md:px-12 pt-6 md:pt-5 max-w-5xl flex flex-row justify-between items-center gap-15">
|
||||
|
||||
<motion.div
|
||||
className="text-x2l md:text-3xl font-bold text-white leading-tight"
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.5 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<h3>{t("me.title")}</h3>
|
||||
<p className="text-sm font-medium text-gray-400">{t("me.subTitle")}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 25 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: false, amount: 0.5 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<img
|
||||
src={content.profile_image}
|
||||
alt={content.name}
|
||||
loading="lazy"
|
||||
className="w-auto h-17 md:h-20 rounded-full object-cover shadow-2xl border-4 border-white/20 transform transition-transform duration-500 hover:scale-105 self-start block"
|
||||
width={96}
|
||||
height={96}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
};
|
||||
32
app/components/three/Three.tsx
Normal file
32
app/components/three/Three.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import Main from "@app/three/main";
|
||||
|
||||
export default function Three() {
|
||||
const mountRef = useRef<HTMLDivElement>(null); // Parent canva element
|
||||
const [loading, setLoading] = useState(true); // Loading boolean element
|
||||
useEffect(() => {
|
||||
if (!window.WebGL2RenderingContext) return;
|
||||
|
||||
const loadingManager = new THREE.LoadingManager(() => setLoading(false));
|
||||
new Main(mountRef.current!, loadingManager);
|
||||
}, []);
|
||||
|
||||
// canva must exist before loading so that Main doesn't crash
|
||||
return (
|
||||
<div className="fixed w-full h-full inset-0 z-0 overflow-hidden">
|
||||
{window.WebGL2RenderingContext ?
|
||||
<>
|
||||
<div ref={mountRef} className="top-0 left-0 w-full h-full" />
|
||||
{loading && (
|
||||
<div className="absolute top-1/2 left-1/2 w-16 h-16 -translate-x-1/2 -translate-y-1/2 border-4 border-white border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
</>
|
||||
:
|
||||
<div className="text-white text-center mt-10">
|
||||
WebGL2 is not supported.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
app/components/ui/Button.tsx
Normal file
38
app/components/ui/Button.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type ButtonProps = {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
label: string;
|
||||
variant?: "icon" | "text";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Button({ children, onClick, label, variant = "icon", className = "" }: ButtonProps) {
|
||||
const BASECLASS = "cursor-pointer flex items-center justify-center backdrop-blur-sm \
|
||||
bg-black/17 shadow-md text-white transition-all duration-200 ease-out \
|
||||
hover:bg-white/15 active:scale-95 shadow-lg shadow-black/50 \
|
||||
pointer-events-auto hover:shadow-black/0";
|
||||
|
||||
|
||||
// dictionary to choose if it's a icon or text button
|
||||
const variants: Record<typeof variant, string> = {
|
||||
icon: "rounded-full w-12 h-12 md:w-14 md:h-14 hover:scale-110",
|
||||
text: "rounded-3xl px-4 h-12 md:h-14 md:px-6 max-w-max hover:scale-105",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button onClick={onClick} aria-label={label} className={`${BASECLASS} ${variants[variant]}`}>
|
||||
{children}
|
||||
</button>
|
||||
{label && (
|
||||
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1
|
||||
rounded-md text-xs text-white bg-black/80 opacity-0
|
||||
group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
app/components/ui/LangSwitcher.tsx
Normal file
22
app/components/ui/LangSwitcher.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Button from '@app/components/ui/Button';
|
||||
|
||||
import i18n from "@app/lib/i18n";
|
||||
|
||||
export default function Lang() {
|
||||
const toggleLanguage = () => {
|
||||
const newLang = i18n.language === "fr" ? "en" : "fr";
|
||||
i18n.changeLanguage(newLang);
|
||||
};
|
||||
|
||||
const nextLangLabel = i18n.language === "fr" ? "EN" : "FR";
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={toggleLanguage}
|
||||
label={`Lang: ${nextLangLabel}`}
|
||||
variant="icon"
|
||||
>
|
||||
{nextLangLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
43
app/components/ui/Window.tsx
Normal file
43
app/components/ui/Window.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface SectionProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Window ({ open, onClose, children }: SectionProps) {
|
||||
useEffect(() => {
|
||||
if (open) document.body.style.overflow = "hidden";
|
||||
else document.body.style.overflow = "";
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-10 bg-black/30 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.3
|
||||
}}
|
||||
>
|
||||
<div className="h-full overflow-y-auto text-white">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 40 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex flex-col md:flex-row items-center justify-center gap-10 px-6 md:px-24 py-20 md:py-32"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user