diff --git a/app/[locale]/globals.css b/app/[locale]/globals.css index 69d9950..ff3e699 100644 --- a/app/[locale]/globals.css +++ b/app/[locale]/globals.css @@ -3,11 +3,60 @@ @theme { --color-brand-brown: #2C1E16; --color-brand-beige: #F5F4F0; - - --font-sans: var(--font-inter); + --color-brand-accent: #8B7355; + + --font-sans: var(--font-inter); --font-serif: var(--font-playfair); } +@keyframes fadeUp { + from { opacity: 0; transform: translateY(28px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes lineGrow { + from { width: 0%; } + to { width: 95%; } +} + +@keyframes arrowBounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(6px); } +} + +@keyframes underlineSlide { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} + +.reveal { + opacity: 0; + transform: translateY(28px); + transition: opacity 700ms cubic-bezier(0.22, 1, 0.36, 1), + transform 700ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.reveal.visible { + opacity: 1; + transform: translateY(0); +} + +@media (prefers-reduced-motion: reduce) { + .reveal { + opacity: 1; + transform: none; + transition: none; + } + .animate-arrow-bounce { + animation: none !important; + } +} + :root { --background: #ffffff; --foreground: #171717; diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 26a51ef..e6c410b 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -16,11 +16,13 @@ export default async function Home({ params }: props) { const projects = getAllProjects().filter((item) => item.ready); return ( -
+
- - - +
+ + + +
); } diff --git a/components/sections/AboutSection.tsx b/components/sections/AboutSection.tsx index 730b966..d20da82 100644 --- a/components/sections/AboutSection.tsx +++ b/components/sections/AboutSection.tsx @@ -1,100 +1,158 @@ -export default function TimelineSection() { - const steps = [ - { - title: "Les Débuts", - text: "J'ai commencé par le web en 2019 suite à un stage, je n'ai plus jamais arrêté. Aujourd'hui je veux en faire mon métier", - position: "top" as const, - left: "16%", - }, - { - title: "L'Évolution", - text: "Après un bac STI2D avec mention Très bien j'ai entamé un BUT Informatique Parcours Développement IA", - position: "bottom" as const, - left: "50%", - }, - { - title: "Aujourd'hui", - text: "Je suis entrepreneur, co-fondateur d'une startup et développeur freelance, je vis de ma passion", - position: "top" as const, - left: "84%", - }, - ]; +'use client'; - const lineTop = "58%"; - const progressWidth = "95%"; +import { useRef, useEffect, useState } from 'react'; + +const steps = [ + { + title: 'Les Débuts', + text: "J'ai commencé par le web en 2019 suite à un stage, je n'ai plus jamais arrêté. Aujourd'hui je veux en faire mon métier.", + year: '2019', + position: 'top' as const, + left: '16%', + }, + { + title: 'L\'Évolution', + text: "Après un bac STI2D avec mention Très bien j'ai entamé un BUT Informatique Parcours Développement IA.", + year: '2022', + position: 'bottom' as const, + left: '50%', + }, + { + title: 'Aujourd\'hui', + text: "Je suis entrepreneur, co-fondateur d'une startup et développeur freelance, je vis de ma passion.", + year: '2026', + position: 'top' as const, + left: '84%', + }, +]; + +const lineTop = '58%'; + +export default function TimelineSection() { + const sectionRef = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const section = sectionRef.current; + if (!section) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + section.querySelectorAll('.reveal').forEach((el) => el.classList.add('visible')); + observer.unobserve(section); + } + }, + { threshold: 0.12 } + ); + + observer.observe(section); + return () => observer.disconnect(); + }, []); return ( -
-
-

- A propos de moi -

+
+ {/* Label */} +
+ + Parcours +
-
- {/* Mobile layout */} -
- {steps.map((step, index) => ( -
-

- {step.title} -

-

- {step.text} -

-
- ))} +

+ À propos +

+ + {/* Mobile layout */} +
+ {steps.map((step, i) => ( +
+ {step.year} +

{step.title}

+

{step.text}

+ ))} +
- {/* Desktop timeline */} -
- {/* Base line */} + {/* Desktop timeline */} +
+ {/* Background track */} +
+ + {/* Animated progress line */} +
+ + {steps.map((step, i) => ( +
+ {/* Dot */}
- - {/* Brown progress */} -
- - {steps.map((step, index) => ( + className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2" + style={{ top: 0 }} + >
- {/* Point */} -
-
-
+ className="h-4 w-4 rounded-full border-2 border-brand-beige bg-brand-brown shadow-[0_0_0_3px_rgba(44,30,22,0.2)]" + style={{ + opacity: visible ? 1 : 0, + transform: visible ? 'scale(1)' : 'scale(0)', + transition: 'opacity 400ms ease, transform 500ms cubic-bezier(0.34, 1.56, 0.64, 1)', + transitionDelay: `${600 + i * 200}ms`, + }} + /> +
- {/* Card */} -
-

- {step.title} -

+ {/* Year label */} +
+ {step.year} +
-

- {step.text} -

-
-
- ))} + {/* Card */} +
+

{step.title}

+

{step.text}

+
-
+ ))}
); -} \ No newline at end of file +} diff --git a/components/sections/FeatureSection.tsx b/components/sections/FeatureSection.tsx index 67666d3..1fa4197 100644 --- a/components/sections/FeatureSection.tsx +++ b/components/sections/FeatureSection.tsx @@ -1,23 +1,81 @@ +'use client'; + +import { useRef, useEffect } from 'react'; import { FeatureColumn } from '@/components/ui/FeatureColumn'; +const features = [ + { + imagePath: '/icons/brain.svg', + title: 'Plus qu\'un simple exécutant', + text: 'Grâce à mon background en conception numérique et mobile, je ne me contente pas de coder. J\'apporte un regard critique et constructif sur l\'ensemble de votre projet.', + }, + { + imagePath: '/icons/building.svg', + title: 'Des fondations en béton', + text: 'Ma spécialité : l\'architecture. Je construis des solutions pérennes, évolutives et capables d\'absorber la croissance de votre produit dans le temps.', + }, + { + imagePath: '/icons/people.svg', + title: 'L\'utilisateur au centre', + text: 'Le code parfait n\'est rien si le produit ne répond pas à un besoin. Je place l\'expérience utilisateur et la fonctionnalité au cœur de mes développements.', + }, +]; + export default function FeatureSection() { + const sectionRef = useRef(null); + + useEffect(() => { + const section = sectionRef.current; + if (!section) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.querySelectorAll('.reveal').forEach((el) => { + el.classList.add('visible'); + }); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.15 } + ); + + observer.observe(section); + return () => observer.disconnect(); + }, []); + return ( -
- - - +
+ {/* Section label */} +
+ + Approche +
+ +
+ {features.map((feature, i) => ( +
+ {/* Icon container */} +
+ +
+ +
+ {feature.title} +
+ +
+ {feature.text} +
+
+ ))} +
); -} \ No newline at end of file +} diff --git a/components/sections/HeroSection.tsx b/components/sections/HeroSection.tsx index 7073919..45090f8 100644 --- a/components/sections/HeroSection.tsx +++ b/components/sections/HeroSection.tsx @@ -1,28 +1,153 @@ -import { FiChevronsDown } from "react-icons/fi"; +'use client'; + +import { useEffect, useRef } from 'react'; export default function HeroSection() { - return ( -
- {/* Title + image container */} -
- {/* Rotating texts */} -
-

Ensemble nous allons...

-

Construire une solution qui répond à votre problème.

+ const sectionRef = useRef(null); + + useEffect(() => { + const section = sectionRef.current; + if (!section) return; + + const items = section.querySelectorAll('.hero-reveal'); + items.forEach((el, i) => { + setTimeout(() => { + el.style.opacity = '1'; + el.style.transform = 'translateY(0)'; + }, 120 + i * 110); + }); + }, []); + + return ( +
+ {/* Main content */} +
+ + {/* Left — editorial text */} +
+ + {/* Label */} +
+ + + Développeur Full Stack & Architecte Web + +
+ + {/* Headline */} +
+

+ Ensemble,
+ construisons
+ ce qui dure. +

+
+ + {/* Description */} +
+

+ Je conçois des architectures robustes, scalables et pérennes — + des fondations qui supportent la croissance de votre produit dans le temps. +

+
+ + {/* CTA */} +
- {/* Image */} - Image of me + {/* Right — portrait */} +
+ {/* Decorative accent ring */} +
+ +
+ Mathéo Guilbert, développeur Full Stack + {/* Subtle vignette */} +
+
+
- {/* Call to scroll */} -
- -

Voir mes travaux

- + {/* Bottom bar — scroll indicator */} +
+ {/* Tech tags */} +
+ {['FastAPI', 'React', 'PostgreSQL', 'Docker'].map((tag) => ( + + {tag} + + ))} +
+ + {/* Scroll arrow */} +
+ Défiler + +
-
- ); +
+ ); } diff --git a/components/sections/WorksSection.tsx b/components/sections/WorksSection.tsx index a4ff01c..6895aa8 100644 --- a/components/sections/WorksSection.tsx +++ b/components/sections/WorksSection.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { useRef, useEffect } from 'react'; import { Project } from "@/lib/projects/types"; import { ProjectCard } from "../ui/ProjectCard"; @@ -7,18 +10,53 @@ type props = { }; export default function WorksSection({ projects, locale }: props) { - return ( -
-

Travaux

+ const sectionRef = useRef(null); - {projects.map((project, index) => ( - - ))} + useEffect(() => { + const section = sectionRef.current; + if (!section) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.querySelectorAll('.reveal').forEach((el, i) => { + setTimeout(() => el.classList.add('visible'), i * 120); + }); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.08 } + ); + + observer.observe(section); + return () => observer.disconnect(); + }, []); + + return ( +
+ {/* Section label */} +
+ + Sélection de projets +
+ +

+ Travaux +

+ +
+ {projects.map((project, index) => ( + + ))} +
); } diff --git a/components/ui/FeatureColumn.tsx b/components/ui/FeatureColumn.tsx index 6b0f640..20e8946 100644 --- a/components/ui/FeatureColumn.tsx +++ b/components/ui/FeatureColumn.tsx @@ -1,15 +1,33 @@ type props = { - imagePath : string; - title : String; - text : String; + imagePath: string; + title: string; + text: string; + delay?: number; }; -export const FeatureColumn = ({ imagePath, title, text }: props) => { +export const FeatureColumn = ({ imagePath, title, text, delay = 0 }: props) => { return ( -
- {`icon -
{title}
-
{text}
+
+ {/* Icon container */} +
+ +
+ +
+ {title} +
+ +
+ {text} +
); -}; \ No newline at end of file +}; diff --git a/components/ui/ProjectCard.tsx b/components/ui/ProjectCard.tsx index 43e870e..7e88e7f 100644 --- a/components/ui/ProjectCard.tsx +++ b/components/ui/ProjectCard.tsx @@ -1,61 +1,106 @@ +'use client'; + +import { useRef, useEffect } from 'react'; import Link from "next/link"; -import { GoArrowRight } from "react-icons/go"; import { Project } from "@/lib/projects/types"; type props = { project: Project; locale: string; isTheLast?: boolean; + index?: number; }; -export const ProjectCard = ({ project, locale, isTheLast = false }: props) => { +export const ProjectCard = ({ project, locale, isTheLast = false, index = 0 }: props) => { + const num = String(index + 1).padStart(2, '0'); + const cardRef = useRef(null); + + useEffect(() => { + const el = cardRef.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + el.classList.add('visible'); + observer.unobserve(el); + } + }, + { threshold: 0.12 } + ); + + observer.observe(el); + return () => observer.disconnect(); + }, []); return ( -
-
+
+ {/* Divider line */} +
- -
+ +
- {/* Date label */} -
- {project.dateLabel} + {/* Project number */} +
+ {num}
- {/* Left image */} -
+ {/* Left — image */} +
{project.title}
- {/* Right content */} -
-
-
{project.category}
-
{project.title}
-
-

- ( - {project.roles.map((role, index) => role + (index < project.roles.length - 1 ? ", " : ""))} - ) -

-
+ {/* Right — content */} +
+ {/* Category */} +
+ + + {project.category} +
-
{project.description}
+ {/* Title */} +
+

+ {project.title} +

+

+ ({project.roles.join(', ')}) +

+

+ {project.dateLabel} +

+
-
-

READ

- + {/* Description */} +

+ {project.description} +

+ + {/* CTA */} +
+ Lire le cas +
- {isTheLast &&
} + {isTheLast &&
}
); }; diff --git a/lib/useReveal.ts b/lib/useReveal.ts new file mode 100644 index 0000000..a1a747e --- /dev/null +++ b/lib/useReveal.ts @@ -0,0 +1,29 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +export function useReveal(threshold = 0.15) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + el.querySelectorAll('.reveal').forEach((node) => { + node.classList.add('visible'); + }); + observer.unobserve(el); + } + }, + { threshold } + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [threshold]); + + return ref; +}