feat: Enhance portfolio with SEO meta tags, implement API for experie… (#2)

* feat: Enhance portfolio with SEO meta tags, implement API for experiences and projects, and refactor components for improved structure

* fix: address Copilot review comments on PR #2

- Migrate Grid callers to the System Grid size prop (GridWrapper)
- Render experience icons from exp.type instead of exp.icon
- Show experience date range (dateS-dateE) on desktop and mobile
- Select experience description from the active i18n language
- Restore technology chips from exp.technologies
- Rename uesExperience hook to useExperience
- Normalize getProjets response to the { data, count } shape
- Return ExperienceFull from getExperience
- Type useEditProjet/updateProjet with ProjetEdit
- Fetch project details from the API in Projet page (useProjet)
- Replace ExperienceType enum with const object (erasableSyntaxOnly)
- Sync document lang attribute with the active language
This commit is contained in:
SYLVAIN TROUILH
2026-09-12 20:29:55 +00:00
committed by GitHub
parent bdb9866164
commit cf68d2d2e4
18 changed files with 294 additions and 145 deletions
+3 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@@ -11,6 +11,8 @@
href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap"
rel="stylesheet"> rel="stylesheet">
<title>Portfolio TROUILH Sylvain</title> <title>Portfolio TROUILH Sylvain</title>
<meta name="description" content="Découvrez le portfolio de Sylvain Trouilh, un développeur passionné. Explorez ses projets, compétences et expériences dans le domaine du développement web. Contactez-le pour collaborer sur des projets passionnants." />
<meta name="keywords" content="Portfolio, Sylvain Trouilh, Développeur, Projets, Compétences, Expériences, Développement Web, Contact" />
</head> </head>
<body> <body>
+9
View File
@@ -4,6 +4,15 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
gzip on;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_buffers 16 8k;
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
types { types {
application/javascript mjs; application/javascript mjs;
+17 -4
View File
@@ -1,16 +1,29 @@
import axios from "axios"; import axios from "axios";
import type { ProjetCreate, Projetfull, Projets, Projetshort } from "../../types/projet"; import type { ProjetCreate, ProjetEdit, Projetfull, Projets, Projetshort } from "../types/projet";
export async function getProjets(limit?: number, page?: number): Promise<Projets> { export async function getProjets(limit?: number, page?: number): Promise<Projets> {
const url = import.meta.env.VITE_API_URL + "/projets"; const url = import.meta.env.VITE_API_URL + "/projets";
const projets = await axios.get(url, { const response = await axios.get(url, {
params: { params: {
nb: limit, nb: limit,
page: page, page: page,
}, },
}); });
return projets.data;
// Normalise la réponse de l'API pour exposer la forme { data, count }.
// Accepte { data, count }, { projets, count } ou un simple tableau.
const body = response.data;
if (body && Array.isArray(body.data)) {
return body as Projets;
}
if (body && Array.isArray(body.projets)) {
return { data: body.projets, count: body.count ?? body.projets.length };
}
return {
data: Array.isArray(body) ? body : [],
count: Array.isArray(body) ? body.length : 0,
};
} }
export async function getProjet(id: number): Promise<Projetfull> { export async function getProjet(id: number): Promise<Projetfull> {
@@ -30,7 +43,7 @@ export async function deleteProjet(id: number): Promise<void> {
await axios.delete(url); await axios.delete(url);
} }
export async function updateProjet(projet: Projetshort): Promise<Projetshort> { export async function updateProjet(projet: ProjetEdit): Promise<Projetfull> {
const url = import.meta.env.VITE_API_URL + "/projet/" + projet.id; const url = import.meta.env.VITE_API_URL + "/projet/" + projet.id;
const updatedprojet = await axios.put(url, projet); const updatedprojet = await axios.put(url, projet);
return updatedprojet.data; return updatedprojet.data;
+26
View File
@@ -0,0 +1,26 @@
import axios from "axios";
import type { Experiencecreate, ExperienceFull, Experiences } from "../types/experience";
export async function getExperiences(): Promise<Experiences> {
const url = import.meta.env.VITE_API_URL + "/experiences";
const experiences = await axios.get(url);
return experiences.data;
}
export async function getExperience(id: number): Promise<ExperienceFull> {
const url = import.meta.env.VITE_API_URL + "/experience/" + id;
const experience = await axios.get(url);
return experience.data;
}
export async function createExperience(experience: Experiencecreate): Promise<Experiencecreate> {
const url = import.meta.env.VITE_API_URL + "/experiences";
const newexperience = await axios.post(url, experience);
return newexperience.data;
}
export async function deleteExperience(id: number): Promise<void> {
const url = import.meta.env.VITE_API_URL + "/experience/" + id;
await axios.delete(url);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { Grid as MuiGrid } from "@mui/material"; import Grid from "@mui/system/Grid";
export default function GridWrapper(props: any) { export default function GridWrapper(props: any) {
return <MuiGrid {...props} />; return <Grid {...props} />;
} }
+42
View File
@@ -0,0 +1,42 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createExperience, deleteExperience, getExperience, getExperiences } from '../api/experience';
import type { Experiencecreate } from '../types/experience';
export function useExperiences() {
return useQuery({
queryKey: ['experiences'],
queryFn: () => getExperiences(),
})
}
export function useExperience(id: number | undefined) {
return useQuery({
queryKey: ['experience', id],
queryFn: () => getExperience(id!),
enabled: !!id,
})
}
export default function useCreateExperience() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newExperience: Experiencecreate) => createExperience(newExperience),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['experiences'] });
},
})
}
export function useDeleteExperience() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteExperience(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['experiences'] });
},
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { fetchIpPublic } from '../assets/api/ipPublic'; import { fetchIpPublic } from '../api/ipPublic';
export function useIpPublic() { export function useIpPublic() {
+3 -3
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createProjet, getProjet, getProjets, updateProjet } from '../assets/api/Projet'; import { createProjet, getProjet, getProjets, updateProjet } from '../api/Projet';
import type { ProjetCreate } from '../types/projet'; import type { ProjetCreate, ProjetEdit } from '../types/projet';
export function useProjets(limit?: number, page?: number) { export function useProjets(limit?: number, page?: number) {
@@ -33,7 +33,7 @@ export default function useCreateProjet() {
export function useEditProjet() { export function useEditProjet() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (projet: any) => updateProjet(projet), mutationFn: (projet: ProjetEdit) => updateProjet(projet),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projets'] }); queryClient.invalidateQueries({ queryKey: ['projets'] });
}, },
+6
View File
@@ -20,4 +20,10 @@ i18n
defaultNS: 'translation' defaultNS: 'translation'
}); });
// Synchronise l'attribut lang du document avec la langue active (SEO/accessibilité)
i18n.on('languageChanged', (lng: string) => {
document.documentElement.lang = lng;
});
document.documentElement.lang = i18n.language;
export default i18n; export default i18n;
+1 -1
View File
@@ -25,7 +25,7 @@ export default function AllProjet() {
sx={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }} sx={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}
> >
{projets && {projets &&
projets.projets.map((projet) => ( projets.data.map((projet) => (
<Card sx={{ minWidth: 275, mt: 5 }} key={projet.id}> <Card sx={{ minWidth: 275, mt: 5 }} key={projet.id}>
<CardContent> <CardContent>
<Box <Box
+5 -8
View File
@@ -1,5 +1,5 @@
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
// import { useProjet } from "../hook/useProjet"; import { useProjet } from "../hook/useProjet";
import { import {
Box, Box,
Container, Container,
@@ -15,7 +15,6 @@ import Grid from "../components/GridWrapper";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import GitHubIcon from "@mui/icons-material/GitHub"; import GitHubIcon from "@mui/icons-material/GitHub";
import LanguageIcon from "@mui/icons-material/Language"; import LanguageIcon from "@mui/icons-material/Language";
import { apiprojet } from "../types/projet.ts";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@@ -23,9 +22,7 @@ export default function Projet() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); // Permet de gérer le bouton "Retour" const navigate = useNavigate(); // Permet de gérer le bouton "Retour"
const isLoading = false; const { data, isLoading } = useProjet(id);
const data = apiprojet.projets.find((p) => p.id === Number(id));
const primaryBlue = "#7ab2cb"; const primaryBlue = "#7ab2cb";
// 1. ÉTAT DE CHARGEMENT PROPRE // 1. ÉTAT DE CHARGEMENT PROPRE
@@ -128,7 +125,7 @@ export default function Projet() {
</Box> </Box>
<Grid container spacing={4} alignItems="flex-start"> <Grid container spacing={4} alignItems="flex-start">
{/* COLONNE GAUCHE (Texte) : Prend 7 colonnes sur 12 */} {/* COLONNE GAUCHE (Texte) : Prend 7 colonnes sur 12 */}
<Grid xs={12} md={7} component="div"> <Grid size={{ xs: 12, md: 7 }} component="div">
<Typography <Typography
variant="h2" variant="h2"
sx={{ sx={{
@@ -181,7 +178,7 @@ export default function Projet() {
{/* J'ai utilisé alignItems="stretch" pour que les deux cartes aient la même hauteur naturellement */} {/* J'ai utilisé alignItems="stretch" pour que les deux cartes aient la même hauteur naturellement */}
<Grid container spacing={4} alignItems="stretch"> <Grid container spacing={4} alignItems="stretch">
{/* TECHNOLOGIES */} {/* TECHNOLOGIES */}
<Grid xs={12} md={6} component="div"> <Grid size={{ xs: 12, md: 6 }} component="div">
<Paper <Paper
elevation={0} elevation={0}
sx={{ sx={{
@@ -225,7 +222,7 @@ export default function Projet() {
</Grid> </Grid>
{/* LIENS */} {/* LIENS */}
<Grid xs={12} md={6} component="div"> <Grid size={{ xs: 12, md: 6 }} component="div">
<Paper <Paper
elevation={0} elevation={0}
sx={{ sx={{
+33 -14
View File
@@ -17,11 +17,13 @@ import {
} from "@mui/lab"; } from "@mui/lab";
import LaptopMacIcon from "@mui/icons-material/LaptopMac"; import LaptopMacIcon from "@mui/icons-material/LaptopMac";
import SchoolIcon from "@mui/icons-material/School"; import SchoolIcon from "@mui/icons-material/School";
import VolunteerActivismIcon from "@mui/icons-material/VolunteerActivism";
import WorkIcon from "@mui/icons-material/Work"; import WorkIcon from "@mui/icons-material/Work";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useExperiences } from "../../hook/useExperience";
// Tes données // Tes données
const experiences = [ /* const experiences = [
{ {
title: "Développeur Front-end (stage 4 mois) ", title: "Développeur Front-end (stage 4 mois) ",
company: "TotalEnergies", company: "TotalEnergies",
@@ -55,13 +57,30 @@ const experiences = [
desc: "Apprentissage des bases de l'ingénierie logicielle et gestion de projet.", desc: "Apprentissage des bases de l'ingénierie logicielle et gestion de projet.",
}, },
]; ];
*/
const primaryBlue = "#7ab2cb";
function getExperienceIcon(type: string) {
switch (String(type).toLowerCase()) {
case "job":
return <WorkIcon fontSize="small" />;
case "internship":
return <LaptopMacIcon fontSize="small" />;
case "volunteer":
return <VolunteerActivismIcon fontSize="small" />;
default:
return <SchoolIcon fontSize="small" />;
}
}
export default function ExperienceTimeline() { export default function ExperienceTimeline() {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
// Détecte si on est sur mobile pour changer l'affichage // Détecte si on est sur mobile pour changer l'affichage
const isMobile = useMediaQuery(theme.breakpoints.down("md")); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const primaryBlue = "#7ab2cb"; const { data: apiExperiences } = useExperiences();
// Sélectionne la description selon la langue active (repli sur le français)
const isEnglish = i18n.language?.toLowerCase().startsWith("en");
return ( return (
<Box sx={{ bgcolor: "#fff" }} id="experience"> <Box sx={{ bgcolor: "#fff" }} id="experience">
@@ -86,9 +105,8 @@ export default function ExperienceTimeline() {
position={isMobile ? "right" : "alternate"} position={isMobile ? "right" : "alternate"}
sx={{ bgcolor: "#fafbfc" }} sx={{ bgcolor: "#fafbfc" }}
> >
{experiences.map((exp, index) => ( {apiExperiences?.data.map((exp, index) => (
<TimelineItem key={index}> <TimelineItem key={index}>
{/* LA DATE (s'affiche en face du contenu sur PC) */}
<TimelineOppositeContent <TimelineOppositeContent
sx={{ sx={{
m: "auto 0", m: "auto 0",
@@ -98,10 +116,10 @@ export default function ExperienceTimeline() {
fontSize: "1.1rem", fontSize: "1.1rem",
}} }}
> >
{exp.date} {exp.dateS}
{exp.dateE ? `${exp.dateE}` : ""}
</TimelineOppositeContent> </TimelineOppositeContent>
{/* LE SÉPARATEUR CENTRAL */}
<TimelineSeparator> <TimelineSeparator>
<TimelineConnector sx={{ bgcolor: primaryBlue }} /> <TimelineConnector sx={{ bgcolor: primaryBlue }} />
<TimelineDot <TimelineDot
@@ -111,12 +129,11 @@ export default function ExperienceTimeline() {
boxShadow: "0 0 0 4px rgba(122, 178, 203, 0.2)", // Petit effet de halo autour du point boxShadow: "0 0 0 4px rgba(122, 178, 203, 0.2)", // Petit effet de halo autour du point
}} }}
> >
{exp.icon} {getExperienceIcon(exp.type)}
</TimelineDot> </TimelineDot>
<TimelineConnector sx={{ bgcolor: primaryBlue }} /> <TimelineConnector sx={{ bgcolor: primaryBlue }} />
</TimelineSeparator> </TimelineSeparator>
{/* LE CONTENU (LA CARTE) */}
<TimelineContent sx={{ py: "12px", px: 2 }}> <TimelineContent sx={{ py: "12px", px: 2 }}>
<Paper <Paper
elevation={3} elevation={3}
@@ -134,7 +151,6 @@ export default function ExperienceTimeline() {
borderTop: `4px solid ${primaryBlue}`, // Petite touche de couleur en haut de la carte borderTop: `4px solid ${primaryBlue}`, // Petite touche de couleur en haut de la carte
}} }}
> >
{/* Sur mobile, on affiche la date DANS la carte car il n'y a pas de place à côté */}
{isMobile && ( {isMobile && (
<Typography <Typography
variant="caption" variant="caption"
@@ -145,7 +161,8 @@ export default function ExperienceTimeline() {
fontWeight: "bold", fontWeight: "bold",
}} }}
> >
{exp.date} {exp.dateS}
{exp.dateE ? `${exp.dateE}` : ""}
</Typography> </Typography>
)} )}
@@ -160,14 +177,15 @@ export default function ExperienceTimeline() {
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" paragraph> <Typography variant="body2" color="text.secondary" paragraph>
{exp.desc} {(isEnglish ? exp.descriptionEn : exp.descriptionFr) ||
exp.descriptionFr}
</Typography> </Typography>
{/* Chips technologies */} {exp.technologies && exp.technologies.length > 0 && (
<Box <Box
sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mt: 1 }} sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mt: 1 }}
> >
{exp.technos.map((tech, i) => ( {exp.technologies.map((tech, i) => (
<Chip <Chip
key={i} key={i}
label={tech} label={tech}
@@ -176,6 +194,7 @@ export default function ExperienceTimeline() {
/> />
))} ))}
</Box> </Box>
)}
</Paper> </Paper>
</TimelineContent> </TimelineContent>
</TimelineItem> </TimelineItem>
+11 -14
View File
@@ -1,17 +1,15 @@
import { Box, Stack, Typography } from "@mui/material"; import { Box, Stack, Typography, Button } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Card_Projet from "../../components/Card_Projet"; import Card_Projet from "../../components/Card_Projet";
//import { useProjets } from "../../hook/useProjet"; import { useProjets } from "../../hook/useProjet";
//import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { Projetshort } from "../../types/projet"; import type { Projetshort } from "../../types/projet";
import { apiprojet } from "../../types/projet.ts";
export default function ProjetSection() { export default function ProjetSection() {
const { t } = useTranslation(); const { t } = useTranslation();
//const navigate = useNavigate(); const navigate = useNavigate();
// const { data: apiprojet, isLoading } = useProjets(3); const { data: apiprojet } = useProjets(3);
const projets = Array.isArray(apiprojet?.data) ? apiprojet.data : [];
const isLoading = false;
return ( return (
<Box component="section"> <Box component="section">
@@ -36,9 +34,8 @@ export default function ProjetSection() {
mb: 4, mb: 4,
}} }}
> >
{!isLoading && {Array.isArray(projets)
(Array.isArray(apiprojet?.projets) ? projets.map((projet: Projetshort) => (
? apiprojet.projets.map((projet: Projetshort) => (
<Card_Projet <Card_Projet
key={projet.id} key={projet.id}
name={projet.name} name={projet.name}
@@ -48,8 +45,8 @@ export default function ProjetSection() {
left={projet.id % 2 === 0} left={projet.id % 2 === 0}
/> />
)) ))
: null)} : null}
{/* <Button <Button
variant="contained" variant="contained"
sx={{ backgroundColor: "#7ab2cb", mb: 4 }} sx={{ backgroundColor: "#7ab2cb", mb: 4 }}
onClick={() => { onClick={() => {
@@ -59,7 +56,7 @@ export default function ProjetSection() {
<Typography sx={{ textDecoration: "none" }}> <Typography sx={{ textDecoration: "none" }}>
{t("SEE_ALL_PROJECTS")} {t("SEE_ALL_PROJECTS")}
</Typography> </Typography>
</Button> */} </Button>
</Stack> </Stack>
</Box> </Box>
); );
+1 -1
View File
@@ -61,7 +61,7 @@ export default function SkillsSection() {
<Stack spacing={4} sx={{ pl: 12 }}> <Stack spacing={4} sx={{ pl: 12 }}>
<Grid container spacing={5} sx={{ mt: 2 }}> <Grid container spacing={5} sx={{ mt: 2 }}>
{skillCategories.map((category, index) => ( {skillCategories.map((category, index) => (
<Grid xs={12} md={4} key={index} component="div"> <Grid size={{ xs: 12, md: 4 }} key={index} component="div">
<Box> <Box>
<Typography <Typography
variant="h6" variant="h6"
+34
View File
@@ -0,0 +1,34 @@
import type { arrayApi } from "./types"
export type ExperienceFull = {
id: number,
title: string,
company: string,
descriptionFr: string,
descriptionEn: string,
dateS: string,
dateE: string,
type: string
technologies?: string[];
}
export type Experiencecreate = {
title: string,
company: string,
descriptionFr: string,
descriptionEn: string,
dateS: string,
dateE: string,
type: string
}
export type Experiences = arrayApi<ExperienceFull>;
export const ExperienceType = {
JOB: "job",
INTERNSHIP: "internship",
VOLUNTEER: "volunteer",
OTHER: "other"
} as const;
+3 -5
View File
@@ -21,10 +21,7 @@ export type Projetfull = {
technologies?: string[]; technologies?: string[];
} }
export type Projets = { export type Projets = arrayApi<Projetshort>;
projets: Projetshort[];
count: number;
}
export type ProjetCreate = { export type ProjetCreate = {
@@ -46,6 +43,7 @@ export type ProjetEdit = {
import * as yup from "yup"; import * as yup from "yup";
import type { arrayApi } from "./types";
export const schemaCreateProjet = yup.object().shape({ export const schemaCreateProjet = yup.object().shape({
name: yup.string().required("Le nom du projet est requis"), name: yup.string().required("Le nom du projet est requis"),
@@ -79,7 +77,7 @@ export const apiprojet = {
longdescriptionen: longdescriptionen:
"This personal portfolio project was designed to showcase my skills, experiences, and achievements in a professional and attractive manner. Developed with React for a dynamic user interface and Material-UI for a modern design, it offers smooth and responsive navigation across all devices. The Go API efficiently manages project data, ensuring optimal performance. This portfolio highlights my key projects, technical skills, and professional background, providing visitors with a comprehensive overview of my expertise in web development.", "This personal portfolio project was designed to showcase my skills, experiences, and achievements in a professional and attractive manner. Developed with React for a dynamic user interface and Material-UI for a modern design, it offers smooth and responsive navigation across all devices. The Go API efficiently manages project data, ensuring optimal performance. This portfolio highlights my key projects, technical skills, and professional background, providing visitors with a comprehensive overview of my expertise in web development.",
linkGithub: "https://github.com/sylvaintr/portfolio-react", linkGithub: "https://github.com/sylvaintr/portfolio-react",
linkWeb: "http://localhost:5173/", linkWeb: "https://portefolio.sylvaintrouilh.fr/",
}, },
{ {
id: 2, id: 2,
+6
View File
@@ -0,0 +1,6 @@
export interface arrayApi<T> {
data: T[];
count: number;
}