fork: move from guezoloic repo

This commit is contained in:
2026-01-24 13:29:26 +01:00
parent 75ebf27385
commit e7c093935e
31 changed files with 5377 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
public
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
VOLUME /usr/share/nginx/html/data
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

29
eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

87
index.html Normal file
View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Loïc GUEZO</title>
<meta name="description" content="Portfolio of Loïc GUEZO, a French student aiming to become an Embedded Systems developer. Experience in AI, Game Development, and Embedded Systems.">
<meta name="keywords" content="Loïc GUEZO, Embedded Systems developer, electronics, IoT, microcontrollers, game development, programming, France">
<meta name="author" content="Loïc GUEZO">
<meta name="robots" content="index, follow, max-image-preview:large">
<meta name="language" content="en-US">
<meta name="geo.region" content="FR">
<meta name="geo.placename" content="France">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Loïc GUEZO Portfolio">
<meta property="og:title" content="Loïc GUEZO | Portfolio">
<meta property="og:description" content="Portfolio of Loïc GUEZO | Embedded Systems, AI, and Game Development projects.">
<meta property="og:url" content="https://guezoloic.com">
<meta property="og:image" content="https://guezoloic.com/data/preview.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Portfolio of Loïc GUEZO | Embedded Systems, AI, and Game Development projects.">
<meta property="og:locale" content="fr_FR">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@GuezoLoic">
<meta name="twitter:creator" content="@GuezoLoic">
<meta name="twitter:title" content="Loïc GUEZO | Portfolio">
<meta name="twitter:description" content="Portfolio of Loïc GUEZO | Embedded Systems, AI, and Game Development projects.">
<meta name="twitter:image" content="https://guezoloic.com/data/preview.png">
<meta name="twitter:image:alt" content="Portfolio of Loïc GUEZO">
<link rel="canonical" href="https://guezoloic.com">
<link rel="icon" type="image/x-icon" href="https://guezoloic.com/data/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="https://guezoloic.com/data/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="https://guezoloic.com/data/favicon-32x32.png">
<link rel="apple-touch-icon" sizes="180x180" href="https://guezoloic.com/data/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="https://guezoloic.com/data/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="512x512" href="https://guezoloic.com/data/android-chrome-512x512.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Loïc GUEZO",
"jobTitle": "Student in Embedded Systems",
"description": "Portfolio of Loïc GUEZO, a French student aiming to become an Embedded Systems developer. Experience in AI, Game Development, and Embedded Systems.",
"url": "https://guezoloic.com",
"image": "https://guezoloic.com/profile.png",
"sameAs": [
"https://github.com/guezoloic",
"https://linkedin.com/in/guezoloic"
],
"knowsAbout": [
"Embedded Systems",
"Artificial Intelligence",
"Game Development",
"IoT",
"Programming",
"Microcontrollers"
],
"alumniOf": {
"@type": "Organization",
"name": "UPEC"
},
"nationality": {
"@type": "Country",
"name": "France"
}
}
</script>
<link href="/src/styles/style.css" rel="stylesheet">
<link rel="preload" href="/src/Main.tsx" as="script">
<link rel="modulepreload" href="/src/Main.tsx">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/Main.tsx"></script>
<noscript><h1>Hello There!</h1></noscript>
</body>
</html>

22
nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name www.guezoloic.com;
return 301 $scheme://guezoloic.com;
}
server {
listen 80;
server_name guezoloic.com;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
location /data/ {
alias /usr/share/nginx/html/data/;
# autoindex on;
}
}

3948
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "website",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"@fortawesome/react-fontawesome": "^0.2.3",
"@heroicons/react": "^2.2.0",
"@tailwindplus/elements": "^1.0.4",
"framer-motion": "^12.23.12",
"lucide-react": "^0.536.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.7.2",
"react-icons": "^5.5.0",
"react-router-dom": "^7.7.1",
"simple-icons": "^15.11.0",
"three": "^0.178.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/three": "^0.179.0",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"vite": "^7.0.4"
}
}

41
src/App.tsx Normal file
View File

@@ -0,0 +1,41 @@
import Title from "./pages/Title";
import Three from "./pages/Three";
import Navbar from "./pages/Navbar";
import About from "./pages/About";
import Skills from "./pages/Skills";
import { useState } from "react";
import './utils/translation';
import Projects from "./pages/Projects";
export type MenuState = {
about: boolean;
skills: boolean;
projects: boolean;
};
export default function App() {
const [state, setState] = useState<MenuState>({
about: false,
skills: false,
projects: false,
});
const closeSection = (key: keyof MenuState) => {
setState(prev => ({ ...prev, [key]: false }));
};
const isOpen = Object.values(state).some(value => value === true);
return (
<div className="relative w-full h-screen">
<Three />
<Title isOpen={isOpen}/>
<Navbar state={state} setState={setState} isOpen={isOpen} />
<About id="about" open={state.about} onClose={() => closeSection("about")} />
<Skills id="skills" open={state.skills} onClose={() => closeSection("skills")} />
<Projects id="projects" open={state.projects} onClose={() => closeSection("projects")} />
</div>
);
}

4
src/Main.tsx Normal file
View File

@@ -0,0 +1,4 @@
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render( <App /> );

38
src/components/Button.tsx Normal file
View 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
src/components/Lang.tsx Normal file
View File

@@ -0,0 +1,22 @@
import Button from './Button';
import i18n from "../utils/translation";
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>
)
}

View 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>
);
};

43
src/components/Window.tsx Normal file
View 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>
);
};

13
src/json/assets.json Normal file
View File

@@ -0,0 +1,13 @@
{
"model": "BASEmodel.glb",
"idle_animation": "idle.glb",
"welcome_animation": "waving.glb",
"animations": [
"StandingW_BriefcaseIdle.glb",
"Acknowledging.glb",
"ArmStretching.glb",
"OffensiveIdle.glb",
"ThoughtfulHeadShake.glb",
"DwarfIdle.glb"
]
}

139
src/json/content.json Normal file
View File

@@ -0,0 +1,139 @@
{
"name": "GUEZO Loïc",
"career": "me.career",
"profile_image": "https://guezoloic.com/data/guezoloic.png",
"navbar": {
"buttons": [
{
"label": "about.label",
"action": "about",
"icon": ""
},
{
"label": "skills.label",
"action": "skills",
"icon": "CodeBracketIcon"
},
{
"label": "projects.label",
"action": "projects",
"icon": "ServerIcon"
}
]
},
"about": [
{
"icon": "LightBulbIcon",
"text": "about.content.0"
},
{
"icon": "CodeBracketIcon",
"text": "about.content.1"
},
{
"icon": "CpuChipIcon",
"text": "about.content.2"
},
{
"icon": "PuzzlePieceIcon",
"text": "about.content.3"
},
{
"icon": "Cog6ToothIcon",
"text": "about.content.4"
},
{
"icon": "RocketLaunchIcon",
"text": "about.content.5"
},
{
"icon": "ServerStackIcon",
"text": "about.content.6"
}
],
"skills": [
{
"title": "skills.content.0",
"tags": [
{
"name": "C",
"icon": "SiC"
},
{
"name": "C++",
"icon": "SiCplusplus"
},
{
"name": "Rust",
"icon": "SiRust"
},
{
"name": "Python",
"icon": "SiPython"
},
{
"name": "Java",
"icon": "SiJava"
},
{
"name": "Bash",
"icon": "SiGnubash"
},
{
"name": "HTML/CSS/JS/TS",
"icon": "SiHtml5"
},
{
"name": "React",
"icon": "SiReact"
}
]
},
{
"title": "skills.content.1",
"tags": [
{
"name": "Git",
"icon": "SiGit"
},
{
"name": "Docker",
"icon": "SiDocker"
},
{
"name": "Android Studio",
"icon": "SiAndroid"
}
]
},
{
"title": "skills.content.2",
"tags": [
{
"name": "Linux/Linux Server",
"icon": "SiLinux"
},
{
"name": "Proxmox",
"icon": "SiProxmox"
},
{
"name": "Jetson Nano",
"icon": "SiNvidia"
},
{
"name": "Raspberry Pi",
"icon": "SiRaspberrypi"
},
{
"name": "Arduino",
"icon": "SiArduino"
},
{
"name": "ESP32",
"icon": "SiEspressif"
}
]
}
]
}

44
src/locales/en.json Normal file
View File

@@ -0,0 +1,44 @@
{
"me": {
"title": "Hello There! 👋",
"subTitle": "I'm glad you're here",
"career": "IT Student"
},
"about": {
"label": "About",
"title": "Hi, I'm Loïc! 👋",
"content": [
"Curious by nature, I like to explore what happens under all the layers of a computer system.",
"I started with simple Python programs, then I gradually discovered a fascination for low-level programming.",
"By learning the basics of Intel x86_64 assembly in my degree and touching electronics, I realized that I love learning and designing at a fundamental level.",
"Over time, I got closer and closer to low-level programming and completed a few small projects.",
"These projects allowed me to manipulate and experiment with the functioning of different hardware.",
"I am also interested in various projects, such as creating a small AI or a graphical engine on a terminal.",
"Computer tools and hardware have given me a complete vision of embedded systems, and I want to continue learning in this field.",
"I aim to combine software and hardware to design complex and efficient systems."
]
},
"skills": {
"label": "Skills",
"title": "My Skills",
"content": [
"Programming Languages",
"Tools",
"Hardware & Embedded Systems"
]
},
"projects": {
"label": "Projects",
"title": "My Projects"
},
"links": {
"label": "Links",
"title": "Links",
"descriptions": [
"",
"",
"",
""
]
}
}

44
src/locales/fr.json Normal file
View File

@@ -0,0 +1,44 @@
{
"me": {
"title": "Bienvenue ! 👋",
"subTitle": "Ravi·e de vous voir ici",
"career": "Étudiant info"
},
"about": {
"label": "À propos",
"title": "Bonjour, je suis Loïc ! 👋",
"content": [
"Curieux de nature, j'aime explorer ce qui se passe sous toutes les couches d'un système informatique.",
"J'ai commencé par de simples programmes en Python, puis j'ai progressivement découvert une fascination pour le bas niveau.",
"En apprenant les bases de l'assembleur Intel x86_64 en licence et en touchant à l'électronique, j'ai compris que j'adore apprendre et concevoir à un niveau fondamental.",
"Au fil du temps, je me suis rapproché de la programmation bas niveau et réalisé quelques petits projets.",
"Ces projets m'ont permis de manipuler et d'expérimenter le fonctionnement de différents matériels.",
"Je m'intéresse aussi à des projets variés, comme créer une petite IA ou un moteur graphique sur terminal.",
"Les outils informatique et matériels m'ont donné une vision complète des systèmes embarqués et je souhaite continuer à apprendre dans cette voie.",
"Je cherche à combiner logiciel et matériel pour concevoir des systèmes complexes et efficaces."
]
},
"skills": {
"label": "Compétences",
"title": "Mes Compétences",
"content": [
"Langages Informatique",
"Outils",
"Matériel & Systèmes embarqués"
]
},
"projects": {
"label": "Projets",
"title": "Mes Projets"
},
"links": {
"label": "Liens",
"title": "Liens",
"descriptions": [
"",
"",
"",
""
]
}
}

53
src/pages/About.tsx Normal file
View 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 "../components/Section";
import content from "../json/content.json"
import Window from "../components/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
src/pages/Navbar.tsx Normal file
View 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 "../components/Button";
import content from "../json/content.json";
import { MenuState } from "../App";
import Lang from "../components/Lang"
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
src/pages/Projects.tsx Normal file
View File

@@ -0,0 +1,82 @@
import { motion } from "framer-motion";
import React, { useEffect, useState } from "react";
import Section from "../components/Section";
import { useTranslation } from "react-i18next";
import Window from "../components/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
src/pages/Skills.tsx Normal file
View File

@@ -0,0 +1,54 @@
import React from "react";
import { motion } from "framer-motion";
import * as SiIcons from "react-icons/si";
import Section from "../components/Section";
import { useTranslation } from "react-i18next";
import content from "../json/content.json"
import Window from "../components/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>
);
};

32
src/pages/Three.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import Main from "../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>
);
};

50
src/pages/Title.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import content from "../json/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>
);
};

33
src/styles/style.css Normal file
View File

@@ -0,0 +1,33 @@
@import "tailwindcss";
* {
scrollbar-width: thin;
scrollbar-color: #2f5643 #1e3d2e;
}
html,
body {
height: 100dvh;
margin: 0;
padding: 0;
background-color: #1e3d2e;
font-family: 'Inter', sans-serif;
}
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-track {
background: #1e3d2e;
}
::-webkit-scrollbar-thumb {
background-color: #2f5643;
border-radius: 6px;
border: 3px solid #1e3d2e;
}
::-webkit-scrollbar-thumb:hover {
background-color: #3f6b55;
}

92
src/three/animQueue.ts Normal file
View File

@@ -0,0 +1,92 @@
import * as THREE from "three";
import Animation from "./animation";
import assets from "../json/assets.json"
const ANIMATION_ASSETS = assets.animations;
export default class AnimationQueue {
private animation: Animation;
private queue: THREE.AnimationAction[] = [];
private currentAction: THREE.AnimationAction | null = null;
private mixer: THREE.AnimationMixer;
private randomIntervalId: number | null = null;
constructor(animation: Animation) {
this.mixer = animation.getMixer();
this.animation = animation;
}
public onqueue(action: THREE.AnimationAction) {
this.queue.push(action);
this.tryPlayNext();
}
public replaceFirst(action: THREE.AnimationAction) {
this.queue.unshift(action);
this.tryPlayNext(true);
}
public async tryPlayNext(force: boolean = false) {
if (this.currentAction) {
if (force) {
this.currentAction.fadeOut(this.animation.getFadeout());
this.currentAction = null;
} else {
if ((this.currentAction as any).isBasicClone) {
this.currentAction.fadeOut(this.animation.getFadeout());
this.currentAction = null;
} else {
return;
}
}
}
if (!this.queue.length) this.queue.push(this.animation.getBasicAction());
const NEXTACTION = this.queue.shift()!;
NEXTACTION.reset();
NEXTACTION.setLoop(THREE.LoopOnce, 1);
NEXTACTION.clampWhenFinished = true;
NEXTACTION.fadeIn(this.animation.getFadein()).play();
const onFinish = (e: any) => {
if (e.action === this.currentAction) {
if (this.currentAction) this.currentAction.fadeOut(this.animation.getFadeout());
this.mixer.removeEventListener("finished", onFinish);
this.currentAction = null;
return this.tryPlayNext();
}
};
this.currentAction = NEXTACTION;
this.mixer.addEventListener("finished", onFinish);
}
public startRandom() {
if (this.randomIntervalId !== null) return;
this.randomIntervalId = window.setInterval(async () => {
if (!this.mixer) return;
const RANDOMINDEX = Math.floor(Math.random() * ANIMATION_ASSETS.length);
this.onqueue(await this.animation.loadAnimation(ANIMATION_ASSETS[RANDOMINDEX]));
}, 30_000);
}
public stopRandom() {
if (this.randomIntervalId !== null) {
clearInterval(this.randomIntervalId);
this.randomIntervalId = null;
}
}
public clearQueue() {
this.queue = [];
}
public stop() {
this.clearQueue();
this.currentAction?.fadeOut(this.animation.getFadeout());
this.currentAction = null;
this.stopRandom();
}
}

81
src/three/animation.ts Normal file
View File

@@ -0,0 +1,81 @@
import * as THREE from "three";
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
export default class Animation {
private mixer: THREE.AnimationMixer;
private loader: GLTFLoader;
private actions: Map<string, THREE.AnimationAction> = new Map();
private basicAction!: THREE.AnimationAction;
private fadein: number;
private fadeout: number;
constructor(
model: THREE.Object3D,
loader: GLTFLoader,
basicAction_url: string,
fadein: number = 0.5, fadeout: number = 0.8) {
this.mixer = new THREE.AnimationMixer(model);
this.loader = loader;
this.fadein = fadein;
this.fadeout = fadeout;
this.setBasicAction(basicAction_url);
}
public loadAnimation(url: string): Promise<THREE.AnimationAction> {
url = `https://guezoloic.com/data/${url}`;
return new Promise((resolve, reject) => {
if (this.actions.has(url)) return resolve(this.actions.get(url)!);
this.loader.load(
url,
(gltf: GLTF) => {
if (!gltf.animations.length) return reject(new Error(`${url} has no animations`));
let clip = gltf.animations[0];
if (clip.tracks.some(track => track.name.endsWith('.position'))) {
clip = clip.clone();
clip.tracks = clip.tracks.filter(track => !track.name.endsWith('.position'));
}
const action = this.mixer.clipAction(clip);
action.stop();
this.actions.set(url, action);
resolve(action);
},
undefined,
reject
);
});
}
public setBasicAction(url: string) {
this.loadAnimation(url).then(action => {
this.basicAction = action;
});
}
public getBasicAction(): THREE.AnimationAction {
const clipClone = this.basicAction.getClip().clone();
const action = this.mixer.clipAction(clipClone);
(action as any).isBasicClone = true;
return action;
}
public getFadein(): number { return this.fadein; }
public getFadeout(): number { return this.fadeout; }
public update(delta: number) {
this.mixer?.update(delta);
}
public getMixer(): THREE.AnimationMixer {
if (!this.mixer) throw new Error("Mixer not initialized yet!");
return this.mixer
}
}

34
src/three/camera.ts Normal file
View File

@@ -0,0 +1,34 @@
import * as THREE from "three";
export default class Camera {
private camera: THREE.PerspectiveCamera;
private box = new THREE.Box3();
private size = new THREE.Vector3();
private center = new THREE.Vector3();
constructor(camera: THREE.PerspectiveCamera, model: THREE.Object3D) {
this.camera = camera;
this.box = this.box.setFromObject(model);
}
public centerCamera(
mouvement: (box: THREE.Box3, size: THREE.Vector3, center: THREE.Vector3) => void
) {
this.box.getSize(this.size);
this.box.getCenter(this.center);
mouvement(this.box, this.size, this.center);
}
public positionCamera() {
const fov = this.camera.fov * (Math.PI / 180);
const distance = this.size.y / (2 * Math.tan(fov / 2));
this.camera.position.set(0, 0, distance * 1.2);
this.camera.lookAt(0, 0, 0);
}
public getCamera(): THREE.PerspectiveCamera {
return this.camera;
}
}

109
src/three/main.ts Normal file
View File

@@ -0,0 +1,109 @@
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import Model from "./model";
import Camera from "./camera";
import Animation from "./animation";
import assets from "../json/assets.json"
import AnimationQueue from "./animQueue";
export default class Main {
private element!: HTMLElement;
private loadingManager?: THREE.LoadingManager;
private scene!: THREE.Scene;
private renderer!: THREE.WebGLRenderer;
private camera!: THREE.PerspectiveCamera;
private controls!: OrbitControls;
private loader!: GLTFLoader;
private clock: THREE.Clock;
private animation!: Animation;
constructor(htmlelement: HTMLElement, loadingManager?: THREE.LoadingManager) {
this.element = htmlelement;
this.loadingManager = loadingManager;
this.clock = new THREE.Clock();
this.init();
}
private async init() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
75, // fov
this.element.clientWidth / this.element.clientHeight, // aspect
0.1, 1000 // near, far
);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
});
this.renderer.setSize(
this.element.clientWidth,
this.element.clientHeight
);
this.element.appendChild(this.renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 1.5);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight.position.set(5, 5, 5);
this.scene.add(directionalLight);
this.controls = new OrbitControls(this.camera, this.element);
this.controls.minPolarAngle = Math.PI / 2;
this.controls.maxPolarAngle = Math.PI / 2;
this.controls.enableZoom = false;
this.controls.enablePan = false;
this.renderer.domElement.style.touchAction = 'pan-y';
this.loader = new GLTFLoader(this.loadingManager);
window.addEventListener("resize", () => {
const WIDTH = window.innerWidth;
const HEIGHT = window.innerHeight;
this.camera.aspect = WIDTH / HEIGHT;
this.camera.updateProjectionMatrix();
this.renderer.setSize(WIDTH, HEIGHT);
});
const MODEL = await (new Model(this.loader)).init(assets.model, this.scene);
const CAMERA = new Camera(this.camera, MODEL);
CAMERA.centerCamera((_box: any, size: any, center: any) => {
MODEL.position.sub(center);
MODEL.position.y -= size.y * 0.3;
});
CAMERA.positionCamera();
// Animation
this.animation = new Animation(MODEL, this.loader, assets.idle_animation);
const ANIMATION_QUEUE = new AnimationQueue(this.animation);
ANIMATION_QUEUE.onqueue(await this.animation.loadAnimation(assets.welcome_animation));
ANIMATION_QUEUE.startRandom();
this.animate();
}
// animate must be an arrow key so that "this"
// can be pointed to the Main instance
private animate = () => {
requestAnimationFrame(this.animate);
const delta = this.clock.getDelta();
if (this.animation) this.animation.update(delta);
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
}

28
src/three/model.ts Normal file
View File

@@ -0,0 +1,28 @@
import * as THREE from "three";
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
export default class Model {
private loader: GLTFLoader;
constructor(loader: GLTFLoader) {
this.loader = loader;
}
public async init(model_url: string, scene: THREE.Scene, isVisible: boolean = true): Promise<THREE.Object3D> {
const MODEL = await this.loadModel(`https://guezoloic.com/data/${model_url}`);
MODEL.visible = isVisible;
scene.add(MODEL);
return MODEL;
}
public loadModel(model_url: string): Promise<THREE.Object3D> {
return new Promise((resolve, reject) => {
this.loader.load(
model_url,
(gltf: GLTF) => resolve(gltf.scene),
undefined,
reject
);
});
}
}

23
src/utils/translation.ts Normal file
View File

@@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from '../locales/en.json';
import fr from '../locales/fr.json';
const resources = { en: { translation: en }, fr: { translation: fr } };
const userLang = navigator.language.startsWith('fr') ? 'fr' : 'en';
i18n
.use(initReactI18next)
.init({
resources,
lng: userLang,
fallbackLng: 'en',
interpolation: { escapeValue: false },
})
;
document.documentElement.lang = i18n.language;
export default i18n;

11
vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
react()
],
})