This commit is contained in:
Ladebeze66 2025-01-31 18:02:55 +00:00
parent ae85204879
commit 7e1369b329
20 changed files with 978 additions and 866 deletions

View File

@ -1,63 +1,71 @@
import Link from "next/link"; import Link from "next/link";
async function getAllCompetences() { // Fonction pour récupérer toutes les compétences depuis l'API Strapi
try { async function getAllCompetences() {
const response = await fetch("http://localhost:1337/api/competences?populate=*"); try {
if (!response.ok) { const response = await fetch("http://localhost:1337/api/competences?populate=*");
throw new Error("Failed to fetch competences"); if (!response.ok) {
} throw new Error("Failed to fetch competences");
const competences = await response.json(); }
return competences.data; const competences = await response.json();
} catch (error) { return competences.data;
console.error("Error fetching competences:", error); } catch (error) {
return []; console.error("Error fetching competences:", error);
} return [];
} }
}
export default async function Page() {
const competences = await getAllCompetences(); // Composant principal de la page des compétences
export default async function Page() {
return ( const competences = await getAllCompetences();
<div>
<h1 className="text-3xl mb-6 font-bold text-grey-700">Mes Compétences</h1> return (
<div className="grid grid-cols-2 gap-6"> <div>
{competences.map((competence) => { {/* Titre de la page */}
const picture = competence.picture?.[0]; // Récupère la première image si elle existe <h1 className="text-3xl mb-6 font-bold text-grey-700">Mes Compétences</h1>
const largeImageUrl = picture?.formats?.large?.url; // Vérifie que le format "large" existe {/* Grille pour afficher les compétences */}
const originalImageUrl = picture?.url; // URL de l'image originale <div className="grid grid-cols-2 gap-6">
{competences.map((competence) => {
// Utilisez l'URL de l'image originale si disponible, sinon l'URL de l'image large const picture = competence.picture?.[0]; // Récupère la première image si elle existe
const imageUrl = originalImageUrl const largeImageUrl = picture?.formats?.large?.url; // Vérifie que le format "large" existe
? `http://localhost:1337${originalImageUrl}` const originalImageUrl = picture?.url; // URL de l'image originale
: `http://localhost:1337${largeImageUrl}`;
// Utilisez l'URL de l'image originale si disponible, sinon l'URL de l'image large
return ( const imageUrl = originalImageUrl
<div key={competence.id} className="bg-white rounded-lg shadow-md overflow-hidden transform transition-transform duration-300 hover:scale-105 hover:shadow-lg hover:bg-blue-100"> ? `http://localhost:1337${originalImageUrl}`
<Link href={`/competences/${competence.slug}`}> : `http://localhost:1337${largeImageUrl}`;
<div className="overflow-hidden">
{imageUrl ? ( return (
<img <div key={competence.id} className="bg-white rounded-lg shadow-md overflow-hidden transform transition-transform duration-300 hover:scale-105 hover:shadow-lg hover:bg-blue-100">
src={imageUrl} {/* Lien vers la page de détail de la compétence */}
alt={picture?.name || "Competence image"} <Link href={`/competences/${competence.slug}`}>
className="w-full h-48 object-cover transform transition-transform duration-300 hover:scale-125 hover:rotate-12" <div className="overflow-hidden">
/> {/* Affichage de l'image de la compétence */}
) : ( {imageUrl ? (
<div className="bg-gray-200 text-gray-500 text-center rounded-md shadow-md p-4"> <img
Image indisponible src={imageUrl}
</div> alt={picture?.name || "Competence image"}
)} className="w-full h-48 object-cover transform transition-transform duration-300 hover:scale-125 hover:rotate-12"
</div> />
<div className="p-4"> ) : (
<p className="font-bold text-xl mb-2">{competence.name}</p> <div className="bg-gray-200 text-gray-500 text-center rounded-md shadow-md p-4">
<p className="text-gray-700">{competence.description}</p> Image indisponible
</div> </div>
</Link> )}
</div> </div>
); <div className="p-4">
})} {/* Affichage du nom de la compétence */}
</div> <p className="font-bold text-xl mb-2">{competence.name}</p>
</div> {/* Affichage de la description de la compétence */}
); <p className="text-gray-700">{competence.description}</p>
} </div>
</Link>
</div>
);
})}
</div>
</div>
);
}

View File

@ -1,24 +1,32 @@
export default async function MessagesPage() { // Composant principal de la page des messages
const res = await fetch("http://localhost:1337/api/messages"); export default async function MessagesPage() {
const { data } = await res.json(); // Récupération des messages depuis l'API Strapi
const res = await fetch("http://localhost:1337/api/messages");
return ( const { data } = await res.json();
<div className="max-w-3xl mx-auto p-6">
<h1 className="text-3xl font-bold text-center mb-6">📬 Messages reçus</h1> return (
{data.length === 0 ? ( <div className="max-w-3xl mx-auto p-6">
<p className="text-center text-gray-600">Aucun message reçu.</p> {/* Titre de la page */}
) : ( <h1 className="text-3xl font-bold text-center mb-6">📬 Messages reçus</h1>
<ul className="space-y-4">
{data.map((msg: any) => ( {/* Affichage d'un message si aucun message n'est reçu */}
<li key={msg.id} className="p-4 border rounded shadow"> {data.length === 0 ? (
<p><strong>👤 {msg.name}</strong> ({msg.email})</p> <p className="text-center text-gray-600">Aucun message reçu.</p>
<p>📅 {new Date(msg.createdAt).toLocaleString("fr-FR")}</p> ) : (
<p className="mt-2">{msg.message}</p> <ul className="space-y-4">
</li> {/* Boucle sur les messages pour les afficher */}
))} {data.map((msg: any) => (
</ul> <li key={msg.id} className="p-4 border rounded shadow">
)} {/* Affichage du nom et de l'email de l'expéditeur */}
</div> <p><strong>👤 {msg.name}</strong> ({msg.email})</p>
); {/* Affichage de la date de réception du message */}
} <p>📅 {new Date(msg.createdAt).toLocaleString("fr-FR")}</p>
{/* Affichage du contenu du message */}
<p className="mt-2">{msg.message}</p>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -1,66 +1,74 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap'); /* Importation des polices Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Exo+2:wght@400;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Audiowide&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Exo+2:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Audiowide&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap');
@tailwind base;
@tailwind components; /* Importation des styles de base, des composants et des utilitaires de Tailwind CSS */
@tailwind utilities; @tailwind base;
@tailwind components;
.bg-wallpaper { @tailwind utilities;
background-image: url('./images/wallpapersite.png');
background-size: cover; /* Classe pour définir l'image de fond */
background-position: center; .bg-wallpaper {
background-repeat: no-repeat; background-image: url('./images/wallpapersite.png'); /* Chemin de l'image de fond */
} background-size: cover; /* L'image couvre toute la zone */
background-position: center; /* L'image est centrée */
.homepage-content { background-repeat: no-repeat; /* L'image ne se répète pas */
min-height: 50vh; /* Hauteur minimale de 50% de la hauteur de la fenêtre */ }
max-height: 80vh; /* Hauteur maximale de 80% de la hauteur de la fenêtre */
} /* Classe pour définir la hauteur minimale et maximale du contenu de la page d'accueil */
.homepage-content {
.circle-one { min-height: 50vh; /* Hauteur minimale de 50% de la hauteur de la fenêtre */
animation: move1 10s linear infinite; max-height: 80vh; /* Hauteur maximale de 80% de la hauteur de la fenêtre */
} }
.circle-two { /* Classe pour animer le premier cercle */
animation: move2 10s linear infinite; .circle-one {
} animation: move1 10s linear infinite; /* Animation infinie avec une durée de 10s */
}
@keyframes move1 {
0% { /* Classe pour animer le deuxième cercle */
transform: translate(0, 0) scale 1; .circle-two {
} animation: move2 10s linear infinite; /* Animation infinie avec une durée de 10s */
25% { }
transform: translate(200px, 200px) scale(1);
} /* Définition de l'animation pour le premier cercle */
50%{ @keyframes move1 {
transform: translate(100px, 400px) scale(1.2); 0% {
} transform: translate(0, 0) scale(1); /* Position et échelle initiales */
75%{ }
transform: translate(-100px, -200px) scale(1.1); 25% {
} transform: translate(200px, 200px) scale(1); /* Déplacement et échelle */
100% { }
transform: translate(0, 0) scale(1); 50% {
} transform: translate(100px, 400px) scale(1.2); /* Déplacement et échelle */
} }
75% {
@keyframes move2 { transform: translate(-100px, -200px) scale(1.1); /* Déplacement et échelle */
0% { }
transform: translate(0, 0) scale 1; 100% {
} transform: translate(0, 0) scale(1); /* Retour à la position et échelle initiales */
25% { }
transform: translate(-30px, -300px) scale(1); }
}
50%{ /* Définition de l'animation pour le deuxième cercle */
transform: translate(-200px, -100px) scale(1.2); @keyframes move2 {
} 0% {
75%{ transform: translate(0, 0) scale(1); /* Position et échelle initiales */
transform: translate(30px, 70px) scale(1.1); }
} 25% {
100% { transform: translate(-200px, -200px) scale(1); /* Déplacement et échelle */
transform: translate(0, 0) scale(1); }
} 50% {
} transform: translate(-100px, -400px) scale(1.2); /* Déplacement et échelle */
}
75% {
transform: translate(100px, 200px) scale(1.1); /* Déplacement et échelle */
}
100% {
transform: translate(0, 0) scale(1); /* Retour à la position et échelle initiales */
}
}

View File

@ -1,74 +1,75 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; // 🟢 Import du Portal import { createPortal } from "react-dom"; // Importation de createPortal pour les modals
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation, Pagination, Autoplay } from "swiper/modules"; import { Navigation, Pagination, Autoplay } from "swiper/modules";
import "swiper/css"; import "swiper/css";
import "swiper/css/navigation"; import "swiper/css/navigation";
import "swiper/css/pagination"; import "swiper/css/pagination";
interface CarouselProps { interface CarouselProps {
images: Array<{ url: string; alt: string }>; images: Array<{ url: string; alt: string }>; // Propriétés des images du carrousel
className?: string; className?: string; // Classe CSS optionnelle pour personnaliser le style
} }
export default function Carousel({ images, className }: CarouselProps) { export default function Carousel({ images, className }: CarouselProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [selectedImage, setSelectedImage] = useState<string | null>(null); // État pour l'image sélectionnée
return ( return (
<> <>
{/* Carrousel principal */} {/* Carrousel principal */}
<div className={`relative w-full ${className || "h-64"} rounded-md shadow-md`}> <div className={`relative w-full ${className || "h-64"} rounded-md shadow-md`}>
<Swiper <Swiper
modules={[Navigation, Pagination, Autoplay]} modules={[Navigation, Pagination, Autoplay]} // Modules Swiper utilisés
spaceBetween={10} spaceBetween={10} // Espace entre les slides
slidesPerView={1} slidesPerView={1} // Nombre de slides visibles en même temps
navigation navigation // Activation de la navigation
pagination={{ clickable: true }} pagination={{ clickable: true }} // Activation de la pagination cliquable
autoplay={{ delay: 3000 }} autoplay={{ delay: 3000 }} // Activation de l'autoplay avec un délai de 3 secondes
className={`w-full ${className || "h-64"}`} className={`w-full ${className || "h-64"}`}
> >
{images.map((img, index) => ( {/* Boucle sur les images pour les afficher dans le carrousel */}
<SwiperSlide key={index} className="flex items-center justify-center h-full"> {images.map((img, index) => (
{/* Image cliquable pour affichage en plein écran */} <SwiperSlide key={index} className="flex items-center justify-center h-full">
<img {/* Image cliquable pour affichage en plein écran */}
src={img.url} <img
alt={img.alt} src={img.url}
className="w-full h-full object-cover rounded-md cursor-pointer transition-transform duration-300 hover:scale-105" alt={img.alt}
onClick={() => setSelectedImage(img.url)} // 🟢 Ouvre limage en plein écran className="w-full h-full object-cover rounded-md cursor-pointer transition-transform duration-300 hover:scale-105"
/> onClick={() => setSelectedImage(img.url)} // Ouvre limage en plein écran
</SwiperSlide> />
))} </SwiperSlide>
</Swiper> ))}
</div> </Swiper>
</div>
{/* 🟢 Modal plein écran inséré DANS `<body>` grâce à `createPortal` */}
{selectedImage && {/* Modal plein écran inséré dans <body> grâce à createPortal */}
createPortal( {selectedImage &&
<div createPortal(
className="fixed inset-0 flex items-center justify-center w-screen h-screen bg-black bg-opacity-10 backdrop-blur-2xl transition-opacity duration-300 z-[1000]" <div
onClick={() => setSelectedImage(null)} // 🔴 Fermer au clic className="fixed inset-0 flex items-center justify-center w-screen h-screen bg-black bg-opacity-10 backdrop-blur-2xl transition-opacity duration-300 z-[1000]"
> onClick={() => setSelectedImage(null)} // Fermer au clic
<div className="relative w-full max-w-6xl p-6 bg-transparent"> >
{/* Bouton de fermeture */} <div className="relative w-full max-w-6xl p-6 bg-transparent">
<button {/* Bouton de fermeture */}
className="absolute top-6 right-6 text-white text-3xl bg-gray-900/70 p-2 rounded-full" <button
onClick={() => setSelectedImage(null)} className="absolute top-6 right-6 text-white text-3xl bg-gray-900/70 p-2 rounded-full"
> onClick={() => setSelectedImage(null)} // Fermer au clic
>
</button>
</button>
{/* Image affichée en grand */}
<img {/* Image affichée en grand */}
src={selectedImage} <img
alt="Agrandissement" src={selectedImage}
className="w-full max-h-[90vh] object-contain rounded-lg shadow-lg" alt="Agrandissement"
/> className="w-full h-full object-cover rounded-md"
</div> />
</div>, </div>
document.body // 🟢 Place le `modal` en dehors de `<main>` dans `<body>` </div>,
)} document.body
</> )}
); </>
} );
}

View File

@ -1,70 +1,75 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom"; // Importation de createPortal pour les modals
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import { Navigation, Pagination, Autoplay } from "swiper/modules"; import { Navigation, Pagination, Autoplay } from "swiper/modules";
import "swiper/css"; import "swiper/css";
import "swiper/css/navigation"; import "swiper/css/navigation";
import "swiper/css/pagination"; import "swiper/css/pagination";
interface CarouselProps { interface CarouselProps {
images: Array<{ url: string; alt: string }>; images: Array<{ url: string; alt: string }>; // Propriétés des images du carrousel
className?: string; className?: string; // Classe CSS optionnelle pour personnaliser le style
} }
export default function CarouselCompetences({ images, className }: CarouselProps) { export default function CarouselCompetences({ images, className }: CarouselProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [selectedImage, setSelectedImage] = useState<string | null>(null); // État pour l'image sélectionnée
return ( return (
<> <>
{/* Carrousel compétences */} {/* Carrousel compétences */}
<div className={`relative w-full ${className || "h-64"} rounded-md shadow-md`}> <div className={`relative w-full ${className || "h-64"} rounded-md shadow-md`}>
<Swiper <Swiper
modules={[Navigation, Pagination, Autoplay]} modules={[Navigation, Pagination, Autoplay]} // Modules Swiper utilisés
spaceBetween={10} spaceBetween={10} // Espace entre les slides
slidesPerView={1} slidesPerView={1} // Nombre de slides visibles en même temps
navigation navigation // Activation de la navigation
pagination={{ clickable: true }} pagination={{ clickable: true }} // Activation de la pagination cliquable
autoplay={{ delay: 3000 }} autoplay={{ delay: 3000 }} // Activation de l'autoplay avec un délai de 3 secondes
className={`w-full ${className || "h-64"}`} className={`w-full ${className || "h-64"}`}
> >
{images.map((img, index) => ( {/* Boucle sur les images pour les afficher dans le carrousel */}
<SwiperSlide key={index} className="flex items-center justify-center h-full"> {images.map((img, index) => (
<img <SwiperSlide key={index} className="flex items-center justify-center h-full">
src={img.url} {/* Image cliquable pour affichage en plein écran */}
alt={img.alt} <img
className="w-full h-full object-cover rounded-md cursor-pointer transition-transform duration-300 hover:scale-105" src={img.url}
onClick={() => setSelectedImage(img.url)} alt={img.alt}
/> className="w-full h-full object-cover rounded-md cursor-pointer transition-transform duration-300 hover:scale-105"
</SwiperSlide> onClick={() => setSelectedImage(img.url)} // Ouvre limage en plein écran
))} />
</Swiper> </SwiperSlide>
</div> ))}
</Swiper>
{/* Modal plein écran pour agrandir les images */} </div>
{selectedImage &&
createPortal( {/* Modal plein écran pour agrandir les images */}
<div {selectedImage &&
className="fixed inset-0 flex items-center justify-center w-screen h-screen bg-black bg-opacity-10 backdrop-blur-2xl transition-opacity duration-300 z-[1000]" createPortal(
onClick={() => setSelectedImage(null)} <div
> className="fixed inset-0 flex items-center justify-center w-screen h-screen bg-black bg-opacity-10 backdrop-blur-2xl transition-opacity duration-300 z-[1000]"
<div className="relative w-full max-w-6xl p-6 bg-transparent"> onClick={() => setSelectedImage(null)} // Fermer au clic
<button >
className="absolute top-6 right-6 text-white text-3xl bg-gray-900/70 p-2 rounded-full" <div className="relative w-full max-w-6xl p-6 bg-transparent">
onClick={() => setSelectedImage(null)} {/* Bouton de fermeture */}
> <button
className="absolute top-6 right-6 text-white text-3xl bg-gray-900/70 p-2 rounded-full"
</button> onClick={() => setSelectedImage(null)} // Fermer au clic
<img >
src={selectedImage}
alt="Agrandissement" </button>
className="w-full max-h-[90vh] object-contain rounded-lg shadow-lg"
/> {/* Image affichée en grand */}
</div> <img
</div>, src={selectedImage}
document.body alt="Agrandissement"
)} className="w-full h-full object-cover rounded-md"
</> />
); </div>
} </div>,
document.body
)}
</>
);
}

View File

@ -1,102 +1,103 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { sendMessage } from "../utils/sendMessage"; import { sendMessage } from "../utils/sendMessage";
export default function ContactForm() { export default function ContactForm() {
const [name, setName] = useState(""); // États pour gérer les valeurs des champs de formulaire
const [email, setEmail] = useState(""); const [name, setName] = useState("");
const [message, setMessage] = useState(""); const [email, setEmail] = useState("");
const [status, setStatus] = useState(""); const [message, setMessage] = useState("");
const [isSuccess, setIsSuccess] = useState<boolean | null>(null); const [status, setStatus] = useState("");
const [isLoading, setIsLoading] = useState(false); // ✅ Nouvel état pour désactiver le bouton const [isSuccess, setIsSuccess] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false); // ✅ Nouvel état pour désactiver le bouton
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !email.trim() || !message.trim()) {
setStatus("❌ Tous les champs sont obligatoires."); if (!name.trim() || !email.trim() || !message.trim()) {
setIsSuccess(false); setStatus("❌ Tous les champs sont obligatoires.");
return; setIsSuccess(false);
} return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setStatus("❌ Email invalide."); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setIsSuccess(false); setStatus("❌ Email invalide.");
return; setIsSuccess(false);
} return;
}
setStatus("⏳ Envoi en cours...");
setIsSuccess(null); setStatus("⏳ Envoi en cours...");
setIsLoading(true); // ✅ Désactive le bouton pendant l'envoi setIsSuccess(null);
setIsLoading(true); // ✅ Désactive le bouton pendant l'envoi
try {
await sendMessage(name, email, message); try {
setStatus("✅ Message envoyé avec succès !"); await sendMessage(name, email, message);
setIsSuccess(true); setStatus("✅ Message envoyé avec succès !");
setName(""); setIsSuccess(true);
setEmail(""); setName("");
setMessage(""); setEmail("");
} catch (error) { setMessage("");
setStatus("❌ Erreur lors de l'envoi du message."); } catch (error) {
setIsSuccess(false); setStatus("❌ Erreur lors de l'envoi du message.");
} finally { setIsSuccess(false);
setIsLoading(false); // ✅ Réactive le bouton après l'envoi } finally {
} setIsLoading(false); // ✅ Réactive le bouton après l'envoi
}; }
};
return (
<form return (
onSubmit={handleSubmit} <form
className="max-w-lg mx-auto p-6 bg-white shadow-lg rounded-lg animate-fade-in" onSubmit={handleSubmit}
> className="max-w-lg mx-auto p-6 bg-white shadow-lg rounded-lg animate-fade-in"
<h2 className="text-2xl font-bold mb-4 text-center">📩 Contactez-moi</h2> >
<h2 className="text-2xl font-bold mb-4 text-center">📩 Contactez-moi</h2>
<input
type="text" <input
placeholder="Votre nom" type="text"
value={name} placeholder="Votre nom"
onChange={(e) => setName(e.target.value)} value={name}
className="w-full p-3 border border-gray-300 rounded mb-3 focus:outline-none focus:ring-2 focus:ring-blue-400" onChange={(e) => setName(e.target.value)}
required className="w-full p-3 border border-gray-300 rounded mb-3 focus:outline-none focus:ring-2 focus:ring-blue-400"
/> required
/>
<input
type="email" <input
placeholder="Votre email" type="email"
value={email} placeholder="Votre email"
onChange={(e) => setEmail(e.target.value)} value={email}
className="w-full p-3 border border-gray-300 rounded mb-3 focus:outline-none focus:ring-2 focus:ring-blue-400" onChange={(e) => setEmail(e.target.value)}
required className="w-full p-3 border border-gray-300 rounded mb-3 focus:outline-none focus:ring-2 focus:ring-blue-400"
/> required
/>
<textarea
placeholder="Votre message" <textarea
value={message} placeholder="Votre message"
onChange={(e) => setMessage(e.target.value)} value={message}
className="w-full p-3 border border-gray-300 rounded mb-3 focus:outline-none focus:ring-2 focus:ring-blue-400" onChange={(e) => setMessage(e.target.value)}
required className="w-full p-3 border border-gray-300 rounded mb-3 focus:outline-none focus:ring-2 focus:ring-blue-400"
/> required
/>
<button
type="submit" <button
disabled={isLoading} // ✅ Désactive le bouton pendant l'envoi type="submit"
className={`w-full py-3 rounded transition ${ disabled={isLoading} // ✅ Désactive le bouton pendant l'envoi
isLoading ? "bg-gray-400 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-600 text-white" className={`w-full py-3 rounded transition ${
}`} isLoading ? "bg-gray-400 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-600 text-white"
> }`}
{isLoading ? "⏳ Envoi..." : "Envoyer"} >
</button> {isLoading ? "⏳ Envoi..." : "Envoyer"}
</button>
{/* ✅ Affichage du message de confirmation */}
{status && ( {/* ✅ Affichage du message de confirmation */}
<p {status && (
className={`mt-4 text-center ${isSuccess ? "text-green-600" : "text-red-600"}`} <p
aria-live="polite" // ✅ Accessibilité pour les lecteurs décran className={`mt-4 text-center ${isSuccess ? "text-green-600" : "text-red-600"}`}
> aria-live="polite" // ✅ Accessibilité pour les lecteurs décran
{status} >
</p> {status}
)} </p>
</form> )}
); </form>
} );
}

View File

@ -1,54 +1,60 @@
import { fetchData } from "../utils/fetchData"; import { fetchData } from "../utils/fetchData"; // Importation de la fonction fetchData pour récupérer les données depuis l'API
import Carousel from "./Carousel"; import Carousel from "./Carousel"; // Importation du composant Carousel pour afficher les images
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown"; // Importation de ReactMarkdown pour rendre le texte riche en Markdown
interface ContentSectionProps { // Définition des propriétés du composant ContentSection
collection: string; // Nom de la collection (projects, events, blog, etc.) interface ContentSectionProps {
slug: string; collection: string; // Nom de la collection (projects, events, blog, etc.)
titleClass?: string; // Permet de modifier le style du titre slug: string; // Identifiant unique pour récupérer les données spécifiques
contentClass?: string; // Permet de modifier le style du contenu titleClass?: string; // Permet de modifier le style du titre
} contentClass?: string; // Permet de modifier le style du contenu
}
export default async function ContentSection({ collection, slug, titleClass, contentClass }: ContentSectionProps) {
const data = await fetchData(collection, slug); // Composant principal ContentSection
export default async function ContentSection({ collection, slug, titleClass, contentClass }: ContentSectionProps) {
if (!data) { // Récupération des données depuis l'API en utilisant la fonction fetchData
return <div>Contenu introuvable.</div>; const data = await fetchData(collection, slug);
}
// Affichage d'un message si les données ne sont pas disponibles
const { name, Resum: richText, picture, link, linkText } = data; if (!data) {
return <div>Contenu introuvable.</div>;
// Transformer les images de Strapi en format attendu par le carrousel }
const images = picture?.map((img: any) => ({
url: `http://localhost:1337${img?.formats?.large?.url || img?.url}`, // Déstructuration des données récupérées
alt: img.name || "Image", const { name, Resum: richText, picture, link, linkText } = data;
})) || [];
// Transformation des images de Strapi en format attendu par le carrousel
return ( const images = picture?.map((img: any) => ({
<div className="max-w-3xl mx-auto p-6"> url: `http://localhost:1337${img?.formats?.large?.url || img?.url}`, // Utilisation de l'URL de l'image en format large ou originale
<h1 className={titleClass || "text-3xl mb-6 font-bold text-gray-700"}>{name}</h1> alt: img.name || "Image", // Texte alternatif pour l'image
})) || [];
{/* Carrousel réutilisable */}
<Carousel images={images} className="w-full h-64" /> return (
<div className="max-w-3xl mx-auto p-6">
{/* Contenu en Markdown */} {/* Titre de la section */}
<div className={contentClass || "bg-gray-100 rounded-md p-4 shadow-md mt-6"}> <h1 className={titleClass || "text-3xl mb-6 font-bold text-gray-700"}>{name}</h1>
<ReactMarkdown>{richText}</ReactMarkdown>
</div> {/* Carrousel réutilisable pour afficher les images */}
<Carousel images={images} className="w-full h-64" />
{/* Lien externe */}
{link && ( {/* Contenu en Markdown */}
<div className="mt-6"> <div className={contentClass || "bg-gray-100 rounded-md p-4 shadow-md mt-6"}>
<a <ReactMarkdown>{richText}</ReactMarkdown>
href={link} </div>
target="_blank"
rel="noopener noreferrer" {/* Lien externe */}
className="text-blue-500 hover:underline transition duration-300 ease-in-out transform hover:scale-105 hover:text-blue-700" {link && (
> <div className="mt-6">
{linkText || "Voir plus/lien externe"} <a
</a> href={link}
</div> target="_blank"
)} rel="noopener noreferrer"
</div> className="text-blue-500 hover:underline transition duration-300 ease-in-out transform hover:scale-105 hover:text-blue-700"
); >
{linkText || "Voir plus/lien externe"}
</a>
</div>
)}
</div>
);
} }

View File

@ -1,91 +1,98 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import CarouselCompetences from "./CarouselCompetences"; import CarouselCompetences from "./CarouselCompetences";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw"; // ✅ Permet d'interpréter le HTML dans ReactMarkdown import rehypeRaw from "rehype-raw"; // ✅ Permet d'interpréter le HTML dans ReactMarkdown
import ModalGlossaire from "./ModalGlossaire"; import ModalGlossaire from "./ModalGlossaire";
interface ContentSectionProps { // Définition des propriétés du composant ContentSectionCompetences
competenceData: any; interface ContentSectionProps {
glossaireData: any[]; competenceData: any;
titleClass?: string; glossaireData: any[];
contentClass?: string; titleClass?: string;
} contentClass?: string;
}
// ✅ Définition du type Glossaire
interface GlossaireItem { // ✅ Définition du type Glossaire
mot_clef: string; interface GlossaireItem {
slug: string; mot_clef: string;
variantes: string[]; slug: string;
description: string; variantes: string[];
images?: any[]; description: string;
} images?: any[];
}
export default function ContentSectionCompetences({ competenceData, glossaireData, titleClass, contentClass }: ContentSectionProps) {
const [selectedMot, setSelectedMot] = useState<GlossaireItem | null>(null); // Composant principal ContentSectionCompetences
export default function ContentSectionCompetences({ competenceData, glossaireData, titleClass, contentClass }: ContentSectionProps) {
if (!competenceData) { const [selectedMot, setSelectedMot] = useState<GlossaireItem | null>(null);
return <div className="text-red-500 text-center"> Compétence introuvable.</div>;
} if (!competenceData) {
return <div className="text-red-500 text-center"> Compétence introuvable.</div>;
const { name, content, picture } = competenceData; }
const images = picture?.map((img: any) => ({ // Déstructuration des données de la compétence
url: `http://localhost:1337${img?.formats?.large?.url || img?.url}`, const { name, content, picture } = competenceData;
alt: img.name || "Image de compétence",
})) || []; // Transformation des images de Strapi en format attendu par le carrousel
const images = picture?.map((img: any) => ({
// 🔥 Transformation du texte riche avec des <span> cliquables url: `http://localhost:1337${img?.formats?.large?.url || img?.url}`,
function transformMarkdownWithKeywords(text: string) { alt: img.name || "Image de compétence",
if (!glossaireData || glossaireData.length === 0) return text; })) || [];
let modifiedText = text; // 🔥 Transformation du texte riche avec des <span> cliquables
function transformMarkdownWithKeywords(text: string) {
glossaireData.forEach(({ mot_clef, variantes }) => { if (!glossaireData || glossaireData.length === 0) return text;
const regexVariants = (variantes || []).map((v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
const regex = new RegExp(`\\b(${mot_clef}|${regexVariants})\\b`, "gi"); let modifiedText = text;
modifiedText = modifiedText.replace(regex, (match) => { glossaireData.forEach(({ mot_clef, variantes }) => {
return `<span class="keyword" data-mot="${mot_clef}" style="color: blue; cursor: pointer;">${match}</span>`; // ✅ Span cliquable const regexVariants = (variantes || []).map((v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
}); const regex = new RegExp(`\\b(${mot_clef}|${regexVariants})\\b`, "gi");
});
modifiedText = modifiedText.replace(regex, (match) => {
return modifiedText; return `<span class="keyword" data-mot="${mot_clef}" style="color: blue; cursor: pointer;">${match}</span>`; // ✅ Span cliquable
} });
});
const contentWithLinks = transformMarkdownWithKeywords(content);
return modifiedText;
// ✅ Gestion des clics sur les mots-clés }
useEffect(() => {
function handleKeywordClick(event: any) { const contentWithLinks = transformMarkdownWithKeywords(content);
const target = event.target as HTMLElement;
if (target.classList.contains("keyword")) { // ✅ Gestion des clics sur les mots-clés
const mot = target.getAttribute("data-mot"); useEffect(() => {
if (mot) { function handleKeywordClick(event: any) {
const glossaireMot = glossaireData.find((g) => g.mot_clef === mot); const target = event.target as HTMLElement;
setSelectedMot(glossaireMot || null); if (target.classList.contains("keyword")) {
} const mot = target.getAttribute("data-mot");
} if (mot) {
} const glossaireMot = glossaireData.find((g) => g.mot_clef === mot);
setSelectedMot(glossaireMot || null);
document.addEventListener("click", handleKeywordClick); }
return () => document.removeEventListener("click", handleKeywordClick); }
}, [glossaireData]); }
return ( document.addEventListener("click", handleKeywordClick);
<div className="max-w-3xl mx-auto p-6"> return () => document.removeEventListener("click", handleKeywordClick);
<h1 className={titleClass || "text-3xl mb-6 font-bold text-gray-700"}>{name}</h1> }, [glossaireData]);
<CarouselCompetences images={images} className="w-full h-64" /> return (
// ✅ Affichage de la compétence
{/* 🔥 Affichage du texte riche avec mots-clés cliquables */} <div className="max-w-3xl mx-auto p-6">
<div className={contentClass || "mt-6 text-lg text-black-700"}> {/* Titre de la section */}
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{contentWithLinks}</ReactMarkdown> {/* ✅ Permet d'interpréter le HTML */} <h1 className={titleClass || "text-3xl mb-6 font-bold text-gray-700"}>{name}</h1>
</div>
{/* Carrousel pour afficher les images */}
{/* 🚀 Modale pour afficher les infos des mots-clés */} <CarouselCompetences images={images} className="w-full h-64" />
{selectedMot && <ModalGlossaire mot={selectedMot} onClose={() => setSelectedMot(null)} />}
</div> {/* 🔥 Affichage du texte riche avec mots-clés cliquables */}
); <div className={contentClass || "mt-6 text-lg text-black-700"}>
} <ReactMarkdown rehypePlugins={[rehypeRaw]}>{contentWithLinks}</ReactMarkdown> {/* ✅ Permet d'interpréter le HTML */}
</div>
{/* 🚀 Modale pour afficher les infos des mots-clés */}
{selectedMot && <ModalGlossaire mot={selectedMot} onClose={() => setSelectedMot(null)} />}
</div>
);
}

View File

@ -1,23 +1,25 @@
import { fetchDataCompetences, fetchDataGlossaire } from "../utils/fetchDataCompetences"; import { fetchDataCompetences, fetchDataGlossaire } from "../utils/fetchDataCompetences";
import ContentSectionCompetences from "./ContentSectionCompetences"; import ContentSectionCompetences from "./ContentSectionCompetences";
interface ContentSectionProps { // Définition des propriétés du composant ContentSection
collection: string; interface ContentSectionProps {
slug: string; collection: string;
titleClass?: string; slug: string;
contentClass?: string; titleClass?: string;
} contentClass?: string;
}
export default async function ContentSectionCompetencesContainer({ collection, slug, titleClass, contentClass }: ContentSectionProps) {
const competenceData = await fetchDataCompetences(collection, slug); // Composant principal ContentSection
const glossaireData = await fetchDataGlossaire(); export default async function ContentSectionCompetencesContainer({ collection, slug, titleClass, contentClass }: ContentSectionProps) {
const competenceData = await fetchDataCompetences(collection, slug);
return ( const glossaireData = await fetchDataGlossaire();
<ContentSectionCompetences
competenceData={competenceData} return (
glossaireData={glossaireData} <ContentSectionCompetences
titleClass={titleClass} competenceData={competenceData}
contentClass={contentClass} glossaireData={glossaireData}
/> titleClass={titleClass}
); contentClass={contentClass}
} />
);
}

View File

@ -1,18 +1,26 @@
"use client" "use client"
import {useState} from "react" import { useState } from "react"; // Importation du hook useState pour gérer l'état
export default function Footer() { export default function Footer() {
const [count, setCount] = useState(0) const [count, setCount] = useState(0); // État pour suivre le nombre de clics
function handleClick() {
setCount(count + 1)} // Fonction pour gérer les clics sur le bouton
function handleClick() {
return ( setCount(count + 1); // Incrémente le compteur de clics
<footer className="bg-white/50 backdrop-blur rounded-lg"> }
<div className="max-w-4xl mx-auto flex flex-col items-center py-6 text-sm text-gray-400">
<p>&copy; {new Date().getFullYear()} Our Company.</p> return (
<p>Vous avez cliqué {count} fois sur le boutton.<button onClick={handleClick}>Click Me</button></p> <footer className="bg-white/50 backdrop-blur rounded-lg">
</div> <div className="max-w-4xl mx-auto flex flex-col items-center py-6 text-sm text-gray-400">
</footer> {/* Affichage de l'année actuelle */}
) <p>&copy; {new Date().getFullYear()} Our Company.</p>
} {/* Affichage du compteur de clics et du bouton */}
<p>
Vous avez cliqué {count} fois sur le bouton.
<button onClick={handleClick}>Click Me</button>
</p>
</div>
</footer>
);
}

View File

@ -1,58 +1,60 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { createPortal } from "react-dom"; // ✅ Insère la modale dans <body> import { createPortal } from "react-dom"; // Insère la modale dans <body>
import CarouselCompetences from "./CarouselCompetences"; import CarouselCompetences from "./CarouselCompetences"; // Importation du composant CarouselCompetences pour afficher les images
interface ModalGlossaireProps { // Définition des propriétés du composant ModalGlossaire
mot: { interface ModalGlossaireProps {
mot_clef: string; mot: {
description: string; mot_clef: string; // Mot-clé du glossaire
images?: any[]; description: string; // Description du mot-clé
}; images?: any[]; // Images associées au mot-clé
onClose: () => void; };
} onClose: () => void; // Fonction pour fermer la modale
}
export default function ModalGlossaire({ mot, onClose }: ModalGlossaireProps) {
// 🔥 Désactiver le scroll du `body` quand la modale est ouverte // Composant principal ModalGlossaire
useEffect(() => { export default function ModalGlossaire({ mot, onClose }: ModalGlossaireProps) {
document.body.classList.add("overflow-hidden"); // Désactiver le scroll du `body` quand la modale est ouverte
return () => { useEffect(() => {
document.body.classList.remove("overflow-hidden"); document.body.classList.add("overflow-hidden");
}; return () => {
}, []); document.body.classList.remove("overflow-hidden");
};
// ✅ Debug : Vérifier les images reçues }, []);
console.log("🖼️ Images reçues dans la modale :", mot.images);
// Debug : Vérifier les images reçues
// ✅ Vérifier si `mot.images` est bien un tableau et contient des images console.log("🖼️ Images reçues dans la modale :", mot.images);
const images = mot.images?.map((img: any) => {
return { // Vérifier si `mot.images` est bien un tableau et contient des images
url: `http://localhost:1337${img.formats?.large?.url || img.url}`, const images = mot.images?.map((img: any) => {
alt: img.name || "Illustration", return {
}; url: `http://localhost:1337${img.formats?.large?.url || img.url}`,
}) || []; alt: img.name || "Illustration",
};
return createPortal( }) || [];
<div className="fixed inset-0 w-screen h-screen bg-black bg-opacity-75 flex items-center justify-center z-[1000]">
<div className="bg-white p-6 rounded-lg shadow-lg w-[90vw] max-w-4xl relative"> return createPortal(
{/* Bouton de fermeture */} <div className="fixed inset-0 w-screen h-screen bg-black bg-opacity-75 flex items-center justify-center z-[1000]">
<button className="absolute top-3 right-3 text-gray-700 text-2xl" onClick={onClose}> <div className="bg-white p-6 rounded-lg shadow-lg w-[90vw] max-w-4xl relative">
{/* Bouton de fermeture */}
</button> <button className="absolute top-3 right-3 text-gray-700 text-2xl" onClick={onClose}>
{/* Titre */} </button>
<h2 className="text-3xl font-bold mb-4">{mot.mot_clef}</h2>
{/* Titre */}
{/* Description */} <h2 className="text-3xl font-bold mb-4">{mot.mot_clef}</h2>
<p className="text-gray-700 mb-6">{mot.description}</p>
{/* Description */}
{/* 🚀 Carrousel d'images si disponible */} <p className="text-gray-700 mb-6">{mot.description}</p>
{images.length > 0 ? (
<CarouselCompetences images={images} className="w-full h-80" /> {/* Carrousel d'images si disponible */}
) : ( {images.length > 0 ? (
<p className="text-gray-500 text-center">Aucune image disponible</p> <CarouselCompetences images={images} className="w-full h-80" />
)} ) : (
</div> <p className="text-gray-500">Aucune image disponible.</p>
</div>, )}
document.body // ✅ Fixe la modale au `body` pour qu'elle couvre toute la page </div>
); </div>,
} document.body
);
}

View File

@ -1,14 +1,18 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"; // Importation du composant Link de Next.js pour la navigation
import {usePathname} from "next/navigation" import { usePathname } from "next/navigation"; // Importation du hook usePathname pour obtenir le chemin actuel
export default function NavLink(props){ export default function NavLink(props) {
const pathname = usePathname() const pathname = usePathname(); // Obtention du chemin actuel
const active = pathname === props.path const active = pathname === props.path; // Vérification si le lien est actif
return(
<Link className={active ? "opacity-100" : "opacity-50 hover:opacity-65"} href={props.path}> return (
{props.text} <Link
</Link> className={active ? "opacity-100" : "opacity-50 hover:opacity-65"} // Classes CSS pour le style du lien
) href={props.path} // Chemin de navigation
>
{props.text} {/* Texte du lien */}
</Link>
);
} }

View File

@ -1,22 +1,25 @@
import ContactForm from "../components/ContactForm"; import ContactForm from "../components/ContactForm"; // Importation du composant ContactForm
export default function ContactPage() { export default function ContactPage() {
return ( return (
<div className="max-w-3xl mx-auto p-6"> <div className="max-w-3xl mx-auto p-6">
<h1 className="text-3xl font-bold text-center mb-6">Contactez-moi</h1> {/* Titre de la page */}
<p className="text-lg text-center mb-4"> <h1 className="text-3xl font-bold text-center mb-6">Contactez-moi</h1>
Vous pouvez me contacter via ce formulaire ou sur mes réseaux sociaux.
</p> {/* Texte d'introduction */}
<p className="text-lg text-center mb-4">
{/* Liens vers les réseaux sociaux */} Vous pouvez me contacter via ce formulaire ou sur mes réseaux sociaux.
<div className="flex justify-center space-x-4 mb-6"> </p>
<a href="https://linkedin.com/in/votreprofil" className="text-blue-500">LinkedIn</a>
<a href="https://twitter.com/votreprofil" className="text-blue-500">Twitter</a> {/* Liens vers les réseaux sociaux */}
<a href="mailto:votre@email.com" className="text-blue-500">Email</a> <div className="flex justify-center space-x-4 mb-6">
</div> <a href="https://linkedin.com/in/votreprofil" className="text-blue-500">LinkedIn</a>
<a href="https://twitter.com/votreprofil" className="text-blue-500">Twitter</a>
{/* Formulaire de contact */} <a href="mailto:votre@email.com" className="text-blue-500">Email</a>
<ContactForm /> </div>
</div>
); {/* Formulaire de contact */}
} <ContactForm />
</div>
);
}

View File

@ -1,37 +1,42 @@
/* Importation des styles de base, des composants et des utilitaires de Tailwind CSS */
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* Définition des variables CSS pour les couleurs de fond et de premier plan */
:root { :root {
--background: #ffffff; --background: #ffffff; /* Couleur de fond par défaut (clair) */
--foreground: #171717; --foreground: #171717; /* Couleur de premier plan par défaut (foncé) */
} }
/* Définition des variables CSS pour le mode sombre */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: #0a0a0a; --background: #0a0a0a; /* Couleur de fond pour le mode sombre */
--foreground: #ededed; --foreground: #ededed; /* Couleur de premier plan pour le mode sombre */
} }
} }
/* Styles globaux pour le corps de la page */
body { body {
color: var(--foreground); color: var(--foreground); /* Utilisation de la couleur de premier plan définie */
background: var(--background); background: var(--background); /* Utilisation de la couleur de fond définie */
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif; /* Police de caractères par défaut */
} }
/* Définition d'une animation de fondu en entrée */
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0; /* Opacité initiale à 0 (invisible) */
transform: translateY(-10px); transform: translateY(-10px); /* Déplacement initial vers le haut */
} }
to { to {
opacity: 1; opacity: 1; /* Opacité finale à 1 (visible) */
transform: translateY(0); transform: translateY(0); /* Position finale */
} }
} }
/* Classe utilitaire pour appliquer l'animation de fondu en entrée */
.animate-fade-in { .animate-fade-in {
animation: fade-in 0.5s ease-out; animation: fade-in 0.5s ease-out; /* Animation de 0.5s avec une courbe de transition */
} }

View File

@ -6,16 +6,17 @@ import "./assets/main.css";
import NavLink from "./components/NavLink"; import NavLink from "./components/NavLink";
export default function RootLayout({ children }) { export default function RootLayout({ children }) {
// États pour gérer la largeur et la hauteur du conteneur principal
const [numElements, setNumElements] = useState(0); const [numElements, setNumElements] = useState(0);
const [containerWidth, setContainerWidth] = useState("max-w-4xl"); const [containerWidth, setContainerWidth] = useState("max-w-4xl");
const [containerHeight, setContainerHeight] = useState("min-h-[50vh]"); const [containerHeight, setContainerHeight] = useState("min-h-[50vh]");
useEffect(() => { useEffect(() => {
// Supposons que children soit un tableau d'éléments // Compter le nombre d'éléments enfants
const elementsCount = React.Children.count(children); const elementsCount = React.Children.count(children);
setNumElements(elementsCount); setNumElements(elementsCount);
// Ajustez la largeur en fonction du nombre d'éléments // Ajuster la largeur et la hauteur en fonction du nombre d'éléments
if (elementsCount > 5) { if (elementsCount > 5) {
setContainerWidth("max-w-6xl"); setContainerWidth("max-w-6xl");
setContainerHeight("min-h-[80vh]"); setContainerHeight("min-h-[80vh]");
@ -31,11 +32,14 @@ export default function RootLayout({ children }) {
return ( return (
<html lang="fr"> <html lang="fr">
<body> <body>
{/* Conteneur principal avec image de fond */}
<div className="bg-wallpaper min-h-[100dvh] grid grid-rows-[auto_1fr_auto]"> <div className="bg-wallpaper min-h-[100dvh] grid grid-rows-[auto_1fr_auto]">
{/* Cercles de fond pour l'effet visuel */}
<div className="absolute z-0 inset-0 overflow-hidden"> <div className="absolute z-0 inset-0 overflow-hidden">
<div className="circle-one blur-3xl w-64 h-64 rounded-full bg-rose-400/60 top-0 right-28 absolute"></div> <div className="circle-one blur-3xl w-64 h-64 rounded-full bg-rose-400/60 top-0 right-28 absolute"></div>
<div className="circle-two blur-3xl w-64 h-64 rounded-full bg-indigo-400/60 bottom-0 left-28 absolute"></div> <div className="circle-two blur-3xl w-64 h-64 rounded-full bg-indigo-400/60 bottom-0 left-28 absolute"></div>
</div> </div>
{/* En-tête avec navigation */}
<header className="z-10 bg-white/50 backdrop-blur rounded-lg border-2 border-gray-500"> <header className="z-10 bg-white/50 backdrop-blur rounded-lg border-2 border-gray-500">
<div className="max-w-4xl mx-auto flex items-center justify-between p-4"> <div className="max-w-4xl mx-auto flex items-center justify-between p-4">
<h2 className="text-2xl font-bold">Portofolio Gras-Calvet Fernand</h2> <h2 className="text-2xl font-bold">Portofolio Gras-Calvet Fernand</h2>
@ -57,9 +61,11 @@ export default function RootLayout({ children }) {
</nav> </nav>
</div> </div>
</header> </header>
{/* Conteneur principal pour le contenu */}
<main className={`backdrop-blur z-10 ${containerWidth} ${containerHeight} mx-auto bg-white/20 rounded-xl py-7 px-8 m-6 overflow-hidden`}> <main className={`backdrop-blur z-10 ${containerWidth} ${containerHeight} mx-auto bg-white/20 rounded-xl py-7 px-8 m-6 overflow-hidden`}>
{children} {children}
</main> </main>
{/* Pied de page */}
<Footer /> <Footer />
</div> </div>
</body> </body>

View File

@ -1,5 +1,7 @@
import ContentSection from "../../components/ContentSection"; import ContentSection from "../../components/ContentSection"; // Importation du composant ContentSection
export default function Page({ params }: { params: { slug: string } }) { // Composant principal de la page de détail du projet
return <ContentSection collection="projects" slug={params.slug} />; export default function Page({ params }: { params: { slug: string } }) {
} // Rendu du composant ContentSection avec les paramètres de la collection et du slug
return <ContentSection collection="projects" slug={params.slug} />;
}

View File

@ -1,59 +1,69 @@
import Link from "next/link"; import Link from "next/link";
async function getAllprojects() { // Fonction pour récupérer tous les projets depuis l'API Strapi
try { async function getAllprojects() {
const response = await fetch("http://localhost:1337/api/projects?populate=*"); try {
if (!response.ok) { const response = await fetch("http://localhost:1337/api/projects?populate=*");
throw new Error("Failed to fetch projects"); if (!response.ok) {
} throw new Error("Failed to fetch projects");
const projects = await response.json(); }
return projects.data; const projects = await response.json();
} catch (error) { return projects.data;
console.error("Error fetching projects:", error); } catch (error) {
return []; console.error("Error fetching projects:", error);
} return [];
} }
}
export default async function Page() {
const projects = await getAllprojects(); // Composant principal de la page des projets
export default async function Page() {
return ( const projects = await getAllprojects();
<div>
<h1 className="text-3xl mb-6 font-bold text-grey-700">Portfolio formation 42</h1> return (
<div className="grid grid-cols-2 gap-6"> <div>
{projects.map((project) => { {/* Titre de la page */}
const picture = project.picture?.[0]; // Récupère la première image si elle existe <h1 className="text-3xl mb-6 font-bold text-grey-700">Portfolio formation 42</h1>
const largeImageUrl = picture?.formats?.large?.url; // Vérifie que le format "large" existe
const originalImageUrl = picture?.url; // URL de l'image originale {/* Grille pour afficher les projets */}
<div className="grid grid-cols-2 gap-6">
// Utilisez l'URL de l'image originale si disponible, sinon l'URL de l'image large {/* Boucle sur les projets pour les afficher */}
const imageUrl = originalImageUrl ? `http://localhost:1337${originalImageUrl}` : `http://localhost:1337${largeImageUrl}`; {projects.map((project) => {
const picture = project.picture?.[0]; // Récupère la première image si elle existe
return ( const largeImageUrl = picture?.formats?.large?.url; // Vérifie que le format "large" existe
<div key={project.id} className="bg-white rounded-lg shadow-md overflow-hidden transform transition-transform duration-300 hover:scale-105 hover:shadow-lg hover:bg-blue-100"> const originalImageUrl = picture?.url; // URL de l'image originale
<Link href={`/portfolio/${project.slug}`}>
<div className="overflow-hidden"> // Utilisez l'URL de l'image originale si disponible, sinon l'URL de l'image large
{imageUrl ? ( const imageUrl = originalImageUrl ? `http://localhost:1337${originalImageUrl}` : `http://localhost:1337${largeImageUrl}`;
<img
src={imageUrl} return (
alt={picture?.name || "Project image"} <div key={project.id} className="bg-white rounded-lg shadow-md overflow-hidden transform transition-transform duration-300 hover:scale-105 hover:shadow-lg hover:bg-blue-100">
className="w-full h-48 object-cover transform transition-transform duration-300 hover:scale-125 hover:rotate-12" {/* Lien vers la page de détail du projet */}
/> <Link href={`/portfolio/${project.slug}`}>
) : ( <div className="overflow-hidden">
<div className="bg-gray-200 text-gray-500 text-center rounded-md shadow-md p-4"> {/* Affichage de l'image du projet */}
Image indisponible {imageUrl ? (
</div> <img
)} src={imageUrl}
</div> alt={picture?.name || "Project image"}
<div className="p-4"> className="w-full h-48 object-cover transform transition-transform duration-300 hover:scale-125 hover:rotate-12"
<p className="font-bold text-xl mb-2">{project.name}</p> />
<p className="text-gray-700">{project.description}</p> ) : (
</div> <div className="bg-gray-200 text-gray-500 text-center rounded-md shadow-md p-4">
</Link> Image indisponible
</div> </div>
); )}
})} </div>
</div> <div className="p-4">
</div> {/* Affichage du nom du projet */}
); <p className="font-bold text-xl mb-2">{project.name}</p>
{/* Affichage de la description du projet */}
<p className="text-gray-700">{project.description}</p>
</div>
</Link>
</div>
);
})}
</div>
</div>
);
} }

View File

@ -1,24 +1,30 @@
import qs from "qs"; import qs from "qs"; // Importation de qs pour construire des requêtes de chaîne de requête
export async function fetchData(collection: string, slug: string) { // Fonction pour récupérer des données spécifiques depuis l'API Strapi
const query = qs.stringify({ export async function fetchData(collection: string, slug: string) {
filters: { slug }, // Construction de la requête avec des filtres et des relations à peupler
populate: "picture", const query = qs.stringify({
}); filters: { slug }, // Filtre basé sur le slug
populate: "picture", // On garde les images associées
try { });
const response = await fetch(`http://localhost:1337/api/${collection}?${query}`, {
cache: "no-store", try {
}); // Envoi de la requête à l'API Strapi
const response = await fetch(`http://localhost:1337/api/${collection}?${query}`, {
if (!response.ok) { cache: "no-store", // Désactivation du cache pour obtenir les données les plus récentes
throw new Error("Failed to fetch data"); });
}
// Vérification de la réponse de l'API
const data = await response.json(); if (!response.ok) {
return data.data[0] || null; throw new Error("Failed to fetch data");
} catch (error) { }
console.error(`Error fetching ${collection} data:`, error);
return null; // Récupération des données de la réponse
} const data = await response.json();
} return data.data[0] || null; // Retourne la première entrée ou null si aucune donnée n'est trouvée
} catch (error) {
// Gestion des erreurs et log des erreurs
console.error(`Error fetching ${collection} data:`, error);
return null;
}
}

View File

@ -1,54 +1,67 @@
import qs from "qs"; import qs from "qs"; // Importation de qs pour construire des requêtes de chaîne de requête
// ✅ Fonction pour récupérer une compétence spécifique // Fonction pour récupérer une compétence spécifique
export async function fetchDataCompetences(collection: string, slug: string) { export async function fetchDataCompetences(collection: string, slug: string) {
const query = qs.stringify({ // Construction de la requête avec des filtres et des relations à peupler
filters: { const query = qs.stringify({
slug: { $eq: slug }, filters: {
}, slug: { $eq: slug },
populate: "picture", // On garde les images des compétences },
}); populate: "picture", // On garde les images des compétences
});
console.log(`🛠️ Requête API Compétence : http://localhost:1337/api/${collection}?${query}`);
// Log de la requête API pour le débogage
try { console.log(`🛠️ Requête API Compétence : http://localhost:1337/api/${collection}?${query}`);
const response = await fetch(`http://localhost:1337/api/${collection}?${query}`, {
cache: "no-store", try {
}); // Envoi de la requête à l'API Strapi
const response = await fetch(`http://localhost:1337/api/${collection}?${query}`, {
if (!response.ok) { cache: "no-store",
throw new Error(`Failed to fetch competences data: ${response.status}`); });
}
// Vérification de la réponse de l'API
const data = await response.json(); if (!response.ok) {
console.log("✅ Données reçues (Compétence) :", data); throw new Error(`Failed to fetch competences data: ${response.status}`);
}
return data.data[0] || null;
} catch (error) { // Récupération des données de la réponse
console.error("❌ Erreur lors de la récupération des compétences :", error); const data = await response.json();
return null; console.log("✅ Données reçues (Compétence) :", data);
}
} // Retourne la première compétence ou null si aucune donnée n'est trouvée
return data.data[0] || null;
export async function fetchDataGlossaire() { } catch (error) {
try { // Gestion des erreurs et log des erreurs
console.log("🛠️ Requête API Glossaire : http://localhost:1337/api/glossaires?populate=images"); console.error("❌ Erreur lors de la récupération des compétences :", error);
return null;
const response = await fetch("http://localhost:1337/api/glossaires?populate=images", { }
cache: "no-store", }
});
// Fonction pour récupérer les données du glossaire
if (!response.ok) { export async function fetchDataGlossaire() {
throw new Error(`Failed to fetch glossaire data: ${response.status}`); try {
} // Log de la requête API pour le débogage
console.log("🛠️ Requête API Glossaire : http://localhost:1337/api/glossaires?populate=images");
const data = await response.json();
console.log("✅ Données reçues (Glossaire) :", data); // Envoi de la requête à l'API Strapi
const response = await fetch("http://localhost:1337/api/glossaires?populate=images", {
return data.data || []; cache: "no-store",
} catch (error) { });
console.error("❌ Erreur lors de la récupération du glossaire :", error);
return []; // Vérification de la réponse de l'API
} if (!response.ok) {
} throw new Error(`Failed to fetch glossaire data: ${response.status}`);
}
// Récupération des données de la réponse
const data = await response.json();
console.log("✅ Données reçues (Glossaire) :", data);
// Retourne les données du glossaire ou un tableau vide si aucune donnée n'est trouvée
return data.data || [];
} catch (error) {
// Gestion des erreurs et log des erreurs
console.error("❌ Erreur lors de la récupération du glossaire :", error);
return [];
}
}

View File

@ -1,32 +1,39 @@
export async function sendMessage(name: string, email: string, message: string) { // Fonction pour envoyer un message à l'API Strapi
const dateTime = new Date().toLocaleString("fr-FR", { timeZone: "Europe/Paris" }); // ✅ Date formatée en français export async function sendMessage(name: string, email: string, message: string) {
// Formatage de la date et de l'heure en français
const messageWithDate = `${message}\n\n📅 Envoyé le : ${dateTime}`; // ✅ Ajout de la date à la fin du message const dateTime = new Date().toLocaleString("fr-FR", { timeZone: "Europe/Paris" }); // ✅ Date formatée en français
console.log("📨 Envoi du message...", { name, email, messageWithDate }); // Ajout de la date à la fin du message
const messageWithDate = `${message}\n\n📅 Envoyé le : ${dateTime}`; // ✅ Ajout de la date à la fin du message
const res = await fetch("http://localhost:1337/api/messages", {
method: "POST", // Log des informations du message avant l'envoi
headers: { console.log("📨 Envoi du message...", { name, email, messageWithDate });
"Content-Type": "application/json",
}, // Envoi du message à l'API Strapi
body: JSON.stringify({ const res = await fetch("http://localhost:1337/api/messages", {
data: { method: "POST",
name: name, headers: {
email: email, "Content-Type": "application/json",
message: messageWithDate, // ✅ Message modifié avec la date },
}, body: JSON.stringify({
}), data: {
}); name: name,
email: email,
const responseData = await res.json(); message: messageWithDate, // ✅ Message modifié avec la date
},
if (!res.ok) { }),
console.error("❌ Erreur API Strapi :", responseData); });
throw new Error(`Échec de l'envoi du message: ${responseData.error.message}`);
} // Récupération de la réponse de l'API
const responseData = await res.json();
console.log("✅ Message envoyé avec succès !", responseData);
return responseData; // Gestion des erreurs de l'API
} if (!res.ok) {
console.error("❌ Erreur API Strapi :", responseData);
throw new Error(`Échec de l'envoi du message: ${responseData.error.message}`);
}
// Log de la réussite de l'envoi du message
console.log("✅ Message envoyé avec succès !", responseData);
return responseData;
}