Updates dependencies and implements portfolio

Updates eslint and typescript-eslint dependencies.

Migrates to a new portfolio structure with components, styling, route management, and internationalization.

Adds project display with multiple project types using custom components.

Adds image assets to be displayed in experience sections
This commit is contained in:
Louis
2025-10-29 00:13:50 +01:00
parent 3e3a6dd125
commit e91f55b80d
19 changed files with 3929 additions and 199 deletions

View File

@@ -60,10 +60,12 @@ function App() {
setThemeSelected(newTheme);
};
const scrollToSection = (id: string) => {
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault();
const section = document.getElementById(id);
if (section) {
section.scrollIntoView({ behavior: 'smooth' });
window.history.pushState(null, '', `#${id}`);
}
}
@@ -83,11 +85,11 @@ function App() {
Louis EMARD
</h1>
<nav className='flex gap-6 h-full justify-center items-center'>
<a onClick={() => scrollToSection('about')} className='hover:text-accent transition-colors cursor-pointer'>{t('nav.about')}</a>
<a onClick={() => scrollToSection('experience')} className='hover:text-accent transition-colors cursor-pointer'>{t('nav.projects')}</a>
<a onClick={() => scrollToSection('skills')} className='hover:text-accent transition-colors cursor-pointer'>{t('nav.skills')}</a>
<a onClick={() => scrollToSection('education')} className='hover:text-accent transition-colors cursor-pointer'>{t('nav.education')}</a>
<a onClick={() => scrollToSection('contact')} className='hover:text-accent transition-colors cursor-pointer'>{t('nav.contact')}</a>
<a href='#about' onClick={(e) => scrollToSection(e, 'about')} className='hidden md:block hover:text-accent transition-colors cursor-pointer'>{t('nav.about')}</a>
<a href='#experience' onClick={(e) => scrollToSection(e, 'experience')} className='hidden md:block hover:text-accent transition-colors cursor-pointer'>{t('nav.projects')}</a>
<a href='#skills' onClick={(e) => scrollToSection(e, 'skills')} className='hidden md:block hover:text-accent transition-colors cursor-pointer'>{t('nav.skills')}</a>
<a href='#education' onClick={(e) => scrollToSection(e, 'education')} className='hidden md:block hover:text-accent transition-colors cursor-pointer'>{t('nav.education')}</a>
<a href='#contact' onClick={(e) => scrollToSection(e, 'contact')} className='hidden md:block hover:text-accent transition-colors cursor-pointer'>{t('nav.contact')}</a>
{/* Language Toggle */}
<button
@@ -107,7 +109,7 @@ function App() {
</header>
{/* Main Content */}
<main className='bg-'>
<main>
<Hero />
<About />
<Experience />
@@ -118,7 +120,7 @@ function App() {
{/* Footer */}
<footer className='py-8 border-t border-secondary/10 bg-secondary/5'>
<div className='flex flex-col md:flex-row gap-6 justify-center items-center text-sm'>
<div className='flex gap-6 justify-center items-center text-sm'>
<a href='https://git.louisemard.dev' target='_blank' rel='noopener noreferrer' className='hover:text-accent transition-colors'>
Gitea
</a>

View File

@@ -5,13 +5,13 @@ const Education = () => {
const educations = [
{
period: '2020 - 2023',
period: '2018 - 2021',
title: 'education.degree1.title',
school: 'education.degree1.school',
description: 'education.degree1.description'
},
{
period: '2018 - 2020',
period: '2015 - 2018',
title: 'education.degree2.title',
school: 'education.degree2.school',
description: 'education.degree2.description'

View File

@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Routes, Route, useRoutes, Link, useParams, useNavigate } from 'react-router';
import { useRoutes, Link, useParams, useNavigate } from 'react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Skill } from '../Skills';
import { CustomInfra, CustomVeggie, CustomStripe, CustomDocker } from './custom';
type ProjectType = 'professional' | 'personal';
@@ -12,66 +14,105 @@ const Experience = () => {
const professionalExperiences = [
{
period: '2024',
title: 'experience.job1.title',
company: 'experience.job1.company',
description: 'experience.job1.description',
tasks: ['experience.job1.task1', 'experience.job1.task2', 'experience.job1.task3']
title: 'experience.project1.title',
company: 'experience.project1.company',
description: 'experience.project1.description',
tasks: ['experience.project1.task1', 'experience.project1.task2', 'experience.project1.task3'],
skills: ['Docker', 'Docker Compose', 'PHP', 'Apache', 'MySQL', 'VSCode'],
customContent: <CustomDocker t={t} />
},
{
period: '2024',
title: 'experience.job2.title',
company: 'experience.job2.company',
description: 'experience.job2.description',
tasks: ['experience.job2.task1', 'experience.job2.task2']
title: 'experience.project2.title',
company: 'experience.project2.company',
description: 'experience.project2.description',
tasks: ['experience.project2.task1', 'experience.project2.task2'],
skills: ['Stripe', 'PHP', 'MySQL', 'JavaScript', 'jQuery', 'Webhooks'],
customContent: <CustomStripe t={t} />
},
{
period: '2025',
title: 'experience.project3.title',
company: 'experience.project3.company',
description: 'experience.project3.description',
detailedDescription: 'experience.project3.detailedDescription',
tasks: ['experience.project3.task1', 'experience.project3.task2'],
skills: ['Stripe', 'Node.js', 'JavaScript', 'Webhooks', 'Make', 'Cloudflare'],
customContent: <CustomVeggie t={t} />
}
];
const personalExperiences = [
{
period: '2018 - 2025',
title: 'experience.job3.title',
company: 'experience.job3.company',
description: 'experience.job3.description',
tasks: ['experience.job3.task1', 'experience.job3.task2']
period: '2025',
title: 'experience.project4.title',
company: 'experience.project4.company',
description: 'experience.project4.description',
tasks: ['experience.project4.task1', 'experience.project4.task2'],
skills: ['Docker', 'Git', 'CI/CD', 'Ansible', 'Terraform'],
customContent: <CustomInfra t={t} />
}
];
const currentExperiences = activeTab === 'professional' ? professionalExperiences : personalExperiences;
const routes = useRoutes(['/', '/experiences/:xpId'].map((path, id) => ({
const routes = useRoutes(['/', '/experiences/:xpId'].map((path) => ({
path,
element: (
<>
<div className="max-w-4xl mx-auto">
<div className="max-w-4xl mx-auto px-8">
<h2 className="text-4xl font-bold mb-8 text-accent" id="experience">{t('experience.title')}</h2>
{/* Tabs */}
<div className="flex gap-4 mb-8 border-b border-secondary/20">
<button
onClick={() => setActiveTab('professional')}
className={`px-6 py-3 font-medium transition-all ${
className={`relative px-6 py-3 font-medium transition-colors ${
activeTab === 'professional'
? 'text-accent border-b-2 border-accent'
? 'text-accent'
: 'text-secondary/60 hover:text-secondary'
}`}
>
{t('experience.tabs.professional')}
{activeTab === 'professional' && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
)}
</button>
<button
onClick={() => setActiveTab('personal')}
className={`px-6 py-3 font-medium transition-all ${
className={`relative px-6 py-3 font-medium transition-colors ${
activeTab === 'personal'
? 'text-accent border-b-2 border-accent'
? 'text-accent'
: 'text-secondary/60 hover:text-secondary'
}`}
>
{t('experience.tabs.personal')}
{activeTab === 'personal' && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
)}
</button>
</div>
<div className="space-y-8">
<ExperiencesList experiences={currentExperiences} t={t} />
</div>
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="space-y-8"
>
<ExperiencesList experiences={currentExperiences} t={t} />
</motion.div>
</AnimatePresence>
</div>
</>
)
@@ -90,6 +131,9 @@ interface Experience {
company: string;
description: string;
tasks: string[];
detailedDescription?: string;
skills?: string[];
customContent?: React.ReactNode;
}
interface ExperiencesListProps {
@@ -98,24 +142,18 @@ interface ExperiencesListProps {
}
const ExperiencesList = ({ experiences, t }: ExperiencesListProps) => {
const { xpId } = useParams();
const { xpId } = useParams() as { xpId?: number };
return <div className=' flex flex-col space-y-8'>
{experiences.map((exp, index) => <ExperienceSummary key={index} index={index} exp={exp} t={t} />)}
<AnimatePresence mode="wait">
{xpId !== undefined && <DetailedExperience t={t} />}
{xpId !== undefined && <DetailedExperience t={t} exp={experiences[xpId]} />}
</AnimatePresence>
</div>
}
interface ExperienceSummaryProps {
index: number;
exp: {
period: string;
title: string;
company: string;
description: string;
tasks: string[];
};
exp: Experience;
t: (key: string) => string;
}
@@ -152,6 +190,9 @@ const ExperienceSummary = ({ index, exp, t }: ExperienceSummaryProps) => {
viewBox="0 0 24 24"
stroke="currentColor"
whileHover={{ scale: 1.2 }}
layoutId={`experience-icon-${index}`}
transition={{ type: "spring", stiffness: 350, damping: 35 }}
whileTap={{ scale: 0.9 }}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</motion.svg>
@@ -177,9 +218,10 @@ const ExperienceSummary = ({ index, exp, t }: ExperienceSummaryProps) => {
interface DetailedExperienceProps {
t: (key: string) => string;
exp: Experience;
}
const DetailedExperience = ({ t }: DetailedExperienceProps) => {
const DetailedExperience = ({ t, exp }: DetailedExperienceProps) => {
const { xpId } = useParams();
const navigate = useNavigate();
useEffect(() => {
@@ -216,47 +258,38 @@ const DetailedExperience = ({ t }: DetailedExperienceProps) => {
>
<motion.div
layoutId={`experience-${xpId}`}
className="bg-primary p-8 rounded-lg border border-accent/50 max-w-3xl w-full h-1/2 overflow-y-auto relative"
className="bg-primary p-8 rounded-lg border border-accent/50 w-4/5 h-4/5 lg:h-3/4 lg:w-6/10 overflow-y-auto relative"
transition={{ type: "spring", stiffness: 350, damping: 35 }}
>
<motion.button
onClick={() => navigate('/')}
className="absolute top-4 right-4 p-2 rounded-full hover:bg-accent/10 transition-colors group"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-accent group-hover:text-accent/80"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</motion.button>
<div className="flex flex-col md:flex-row md:justify-between md:items-start mb-6">
<div className="flex flex-row md:justify-between md:items-start mb-6">
<motion.div
layoutId={`experience-header-${xpId}`}
transition={{ type: "spring", stiffness: 350, damping: 35 }}
className="flex-1"
>
<h3 className="text-2xl font-semibold text-secondary mb-1">Dev full stack</h3>
<p className="text-accent font-medium">Meta Video</p>
<h3 className="text-xl md:text-2xl font-semibold text-secondary mb-1">{t(exp.title)}</h3>
<p className="text-accent font-medium">{t(exp.company)}</p>
</motion.div>
<div className="flex items-center gap-3 mt-2 md:mt-0">
<motion.span
layoutId={`experience-period-${xpId}`}
className="text-secondary/60 text-sm md:text-base"
<div className="flex items-start gap-3 mt-2 md:mt-0">
<motion.button
onClick={() => navigate('/')}
className="p-2 rounded-full hover:bg-accent/10 transition-colors group"
layoutId={`experience-icon-${xpId}`}
transition={{ type: "spring", stiffness: 350, damping: 35 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
2023 - 2024
</motion.span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-accent group-hover:text-accent/80"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</motion.button>
</div>
</div>
@@ -266,8 +299,19 @@ const DetailedExperience = ({ t }: DetailedExperienceProps) => {
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<h4 className="text-xl font-semibold text-secondary mb-4">Détails supplémentaires</h4>
<p className="text-secondary/70 mb-4">Contenu détaillé de l'expérience...</p>
{/* <h4 className="text-xl font-semibold text-secondary mb-4">{t('experience.additional_details')}</h4> */}
{/* <p className="text-secondary/70">{t(exp.detailedDescription!)}</p> */}
{exp.customContent}
{exp.skills && exp.skills.length > 0 && (
<div className="mb-4 mt-4">
<h5 className="text-lg font-semibold text-secondary mb-2">{t('experience.skills_used')}</h5>
<div className='flex gap-2 flex-wrap'>
{exp.skills.map((skill, skillIndex) => (
<Skill key={skillIndex} name={skill} />
))}
</div>
</div>
)}
</motion.div>
</motion.div>
</motion.div>;

View File

@@ -0,0 +1,181 @@
import { TFunction } from "i18next";
export const CustomDocker = ({ t }: { t: TFunction }) => {
return (
<div className="mt-4">
<div className="mt-8 space-y-8">
<header className="border-b border-secondary/10 pb-4">
<h2 className="text-2xl font-semibold text-secondary mb-2">{t('experience.customs.docker.title')}</h2>
<p className="text-secondary/60">{t('experience.customs.docker.subtitle')}</p>
</header>
<section className="space-y-4">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.docker.overview.title')}</h3>
<p className="text-secondary/70">{t('experience.customs.docker.overview.description')}</p>
<p className="text-secondary/70">{t('experience.customs.docker.overview.problem')}</p>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.docker.architecture.title')}</h3>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.docker.architecture.services.title')}</h4>
<ul className="space-y-2 text-secondary/70">
<li className="flex gap-2">
<span className="font-semibold text-accent">Apache/PHP 8.2:</span>
<span>{t('experience.customs.docker.architecture.services.php')}</span>
</li>
<li className="flex gap-2">
<span className="font-semibold text-accent">MySQL:</span>
<span>{t('experience.customs.docker.architecture.services.mysql')}</span>
</li>
<li className="flex gap-2">
<span className="font-semibold text-accent">PhpMyAdmin:</span>
<span>{t('experience.customs.docker.architecture.services.phpmyadmin')}</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.docker.architecture.features.title')}</h4>
<ul className="list-disc list-inside space-y-2 text-secondary/70">
<li>{t('experience.customs.docker.architecture.features.dependencies')}</li>
<li>{t('experience.customs.docker.architecture.features.repos')}</li>
<li>{t('experience.customs.docker.architecture.features.devcontainer')}</li>
<li>{t('experience.customs.docker.architecture.features.apache')}</li>
<li>{t('experience.customs.docker.architecture.features.vhosts')}</li>
<li>{t('experience.customs.docker.architecture.features.php_config')}</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.docker.architecture.devcontainer.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.docker.architecture.devcontainer.description')}</p>
<ul className="list-disc list-inside space-y-2 text-secondary/70 text-sm">
<li>{t('experience.customs.docker.architecture.devcontainer.debugging')}</li>
<li>{t('experience.customs.docker.architecture.devcontainer.composer')}</li>
<li>{t('experience.customs.docker.architecture.devcontainer.terminal')}</li>
</ul>
</div>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.docker.challenges.title')}</h3>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-2">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.docker.challenges.modules.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.docker.challenges.modules.description')}</p>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-2">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.docker.challenges.standardization.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.docker.challenges.standardization.description')}</p>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-2">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.docker.challenges.automation.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.docker.challenges.automation.description')}</p>
</div>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.docker.stack.title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.docker.stack.core.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Docker & Docker Compose</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Apache 2.4</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>PHP 8.2</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>MySQL / MariaDB</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.docker.stack.tools.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>PhpMyAdmin</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Composer</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>XDebug</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Bitbucket (SSH)</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.docker.stack.vscode.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Dev Containers</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Remote Explorer</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Docker Extension</span>
</li>
</ul>
</div>
</div>
</section>
<section className="space-y-4">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.docker.results.title')}</h3>
<ul className="space-y-2 text-secondary/70">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.docker.results.standardization')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.docker.results.onboarding')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.docker.results.debugging')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.docker.results.flexibility')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.docker.results.productivity')}</span>
</li>
</ul>
</section>
<footer className="border-t border-secondary/10 pt-6">
<p className="text-sm text-secondary/60 italic">{t('experience.customs.docker.tags')}</p>
</footer>
</div>
</div>
);
};

View File

@@ -0,0 +1,233 @@
import { TFunction } from "i18next";
import OverlayPicture from "../../utility/OverlayPicture";
export const CustomInfra = ({ t }: { t: TFunction }) => {
return (
<div className="mt-4">
<div className="mt-8 space-y-8">
<header className="border-b border-secondary/10 pb-4">
<h2 className="text-2xl font-semibold text-secondary mb-2">{t('experience.customs.infrastructure.title')}</h2>
<p className="text-secondary/60">{t('experience.customs.infrastructure.subtitle')}</p>
</header>
<section className="space-y-4">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.infrastructure.overview.title')}</h3>
<p className="text-secondary/70">{t('experience.customs.infrastructure.overview.description')}</p>
<OverlayPicture
src="/images/infra_diagram.png"
alt="Diagramme de l'infrastructure cloud automatisée"
className="mt-4 w-full"
/>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.infrastructure.architecture.title')}</h3>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.infrastructure.architecture.provisioning.title')}</h4>
<ul className="space-y-2 text-secondary/70">
<li className="flex gap-2 flex-wrap">
<span className="font-semibold text-accent">Terraform/OpenTofu:</span>
<span>{t('experience.customs.infrastructure.architecture.provisioning.terraform')}</span>
</li>
<li className="flex gap-2 flex-wrap">
<span className="font-semibold text-accent">Ansible:</span>
<span>{t('experience.customs.infrastructure.architecture.provisioning.ansible')}</span>
</li>
<li className="flex gap-2 flex-wrap">
<span className="font-semibold text-accent">DNS OVH:</span>
<span>{t('experience.customs.infrastructure.architecture.provisioning.dns')}</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.infrastructure.architecture.services.title')}</h4>
<ul className="space-y-2 text-secondary/70">
<li className="flex gap-2 flex-wrap">
<span className="font-semibold text-accent">{t('experience.customs.infrastructure.architecture.services.website.name')}:</span>
<code className="bg-secondary/10 px-2 py-0.5 rounded text-accent text-sm">louisemard.dev</code>
<span>- {t('experience.customs.infrastructure.architecture.services.website.description')}</span>
</li>
<li className="flex gap-2 flex-wrap">
<span className="font-semibold text-accent">{t('experience.customs.infrastructure.architecture.services.gitea.name')}:</span>
<code className="bg-secondary/10 px-2 py-0.5 rounded text-accent text-sm">git.louisemard.dev</code>
<span>- {t('experience.customs.infrastructure.architecture.services.gitea.description')}</span>
</li>
<li className="flex gap-2 flex-wrap">
<span className="font-semibold text-accent">{t('experience.customs.infrastructure.architecture.services.infisical.name')}:</span>
<code className="bg-secondary/10 px-2 py-0.5 rounded text-accent text-sm">vault.louisemard.dev</code>
<span>- {t('experience.customs.infrastructure.architecture.services.infisical.description')}</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.infrastructure.architecture.security.title')}</h4>
<ul className="list-disc list-inside space-y-2 text-secondary/70">
<li>{t('experience.customs.infrastructure.architecture.security.ssl')}</li>
<li>{t('experience.customs.infrastructure.architecture.security.nginx')}</li>
<li>{t('experience.customs.infrastructure.architecture.security.firewall')}</li>
<li>{t('experience.customs.infrastructure.architecture.security.docker')}</li>
</ul>
</div>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.infrastructure.challenges.title')}</h3>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-2">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.infrastructure.challenges.secrets.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.infrastructure.challenges.secrets.description')}</p>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-2">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.infrastructure.challenges.multiservice.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.infrastructure.challenges.multiservice.description')}</p>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-2">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.infrastructure.challenges.automation.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.infrastructure.challenges.automation.description')}</p>
</div>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.infrastructure.stack.title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.infrastructure.stack.infrastructure.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.infrastructure.cloud')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.infrastructure.os')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.infrastructure.iac')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.infrastructure.containers')}</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.infrastructure.stack.applications.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.applications.webserver')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.applications.gitea')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.applications.infisical')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.applications.ssl')}</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.infrastructure.stack.security.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.security.firewall')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.security.ids')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.stack.security.smtp')}</span>
</li>
</ul>
</div>
</div>
</section>
<section className="space-y-4">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.infrastructure.results.title')}</h3>
<ul className="space-y-2 text-secondary/70">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.results.automation')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.results.availability')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.results.security')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.results.maintainability')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.infrastructure.results.cost')}</span>
</li>
</ul>
</section>
<section className="space-y-4 border-t border-secondary/10 pt-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.infrastructure.links.title')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a
onClick={e => {
e.preventDefault();
alert(t('experience.customs.infrastructure.links.alert'));
}}
href="https://louisemard.dev"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-3 bg-accent/10 hover:bg-accent/20 text-accent rounded-lg transition-colors border border-accent/30 text-center font-medium"
>
{t('experience.customs.infrastructure.links.website')}
</a>
<a
href="https://git.louisemard.dev"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-3 bg-accent/10 hover:bg-accent/20 text-accent rounded-lg transition-colors border border-accent/30 text-center font-medium"
>
{t('experience.customs.infrastructure.links.gitea')}
</a>
<a
href="https://vault.louisemard.dev"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-3 bg-accent/10 hover:bg-accent/20 text-accent rounded-lg transition-colors border border-accent/30 text-center font-medium"
>
{t('experience.customs.infrastructure.links.infisical')}
</a>
<a
href="https://github.com/LouisDrame/louisemard_infrastructure"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-3 bg-accent/10 hover:bg-accent/20 text-accent rounded-lg transition-colors border border-accent/30 text-center font-medium"
>
{t('experience.customs.infrastructure.links.source')}
</a>
</div>
</section>
</div>
</div>
);
};

View File

@@ -0,0 +1,144 @@
import { TFunction } from "i18next";
export const CustomStripe = ({ t }: { t: TFunction }) => {
return (
<div className="mt-4">
<div className="mt-8 space-y-8">
<header className="border-b border-secondary/10 pb-4">
<h2 className="text-2xl font-semibold text-secondary mb-2">{t('experience.customs.stripe.title')}</h2>
<p className="text-secondary/60">{t('experience.customs.stripe.subtitle')}</p>
</header>
<section className="space-y-4">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.stripe.overview.title')}</h3>
<p className="text-secondary/70">{t('experience.customs.stripe.overview.description')}</p>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.stripe.architecture.title')}</h3>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.stripe.architecture.frontend.title')}</h4>
<ul className="space-y-2 text-secondary/70">
<li className="flex gap-2">
<span className="font-semibold text-accent">{t('experience.customs.stripe.architecture.frontend.tech')}:</span>
<span>JavaScript natif, jQuery</span>
</li>
<li className="flex gap-2">
<span className="font-semibold text-accent">{t('experience.customs.stripe.architecture.frontend.features')}:</span>
<span>{t('experience.customs.stripe.architecture.frontend.features_list')}</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.stripe.architecture.backend.title')}</h4>
<ul className="list-disc list-inside space-y-2 text-secondary/70">
<li>{t('experience.customs.stripe.architecture.backend.integration')}</li>
<li>{t('experience.customs.stripe.architecture.backend.abstraction')}</li>
<li>{t('experience.customs.stripe.architecture.backend.extensibility')}</li>
<li>{t('experience.customs.stripe.architecture.backend.logging')}</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.stripe.architecture.security.title')}</h4>
<ul className="list-disc list-inside space-y-2 text-secondary/70">
<li>{t('experience.customs.stripe.architecture.security.webhooks')}</li>
<li>{t('experience.customs.stripe.architecture.security.idempotence')}</li>
<li>{t('experience.customs.stripe.architecture.security.reconciliation')}</li>
</ul>
</div>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.stripe.challenges.title')}</h3>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-2">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.stripe.challenges.decoupling.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.stripe.challenges.decoupling.description')}</p>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-2">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.stripe.challenges.errors.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.stripe.challenges.errors.description')}</p>
</div>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.stripe.stack.title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.stripe.stack.frontend.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>JavaScript natif</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>jQuery</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.stripe.stack.backend.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>PHP</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>API Stripe</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>MySQL</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.stripe.stack.tools.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Webhooks Stripe</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.stripe.stack.tools.abstraction')}</span>
</li>
</ul>
</div>
</div>
</section>
<section className="space-y-4">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.stripe.results.title')}</h3>
<ul className="space-y-2 text-secondary/70">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.stripe.results.operational')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.stripe.results.extensible')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.stripe.results.stable')}</span>
</li>
</ul>
</section>
<footer className="border-t border-secondary/10 pt-6">
<p className="text-sm text-secondary/60 italic">{t('experience.customs.stripe.tags')}</p>
</footer>
</div>
</div>
);
};

View File

@@ -0,0 +1,167 @@
import { TFunction } from "i18next";
import OverlayPicture from "../../utility/OverlayPicture";
export const CustomVeggie = ({ t }: { t: TFunction }) => {
return (
<div className="mt-4">
<div className="mt-8 space-y-8">
<header className="border-b border-secondary/10 pb-4">
<h2 className="text-2xl font-semibold text-secondary mb-2">{t('experience.customs.veggie.title')}</h2>
<p className="text-secondary/60">{t('experience.customs.veggie.subtitle')}</p>
</header>
<section className="space-y-4">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.veggie.overview.title')}</h3>
<p className="text-secondary/70">{t('experience.customs.veggie.overview.description')}</p>
<p className="text-secondary/70">{t('experience.customs.veggie.overview.evolution')}</p>
<OverlayPicture
src="/images/les-serres-du-pre-marais.pages.dev_.png"
alt={t('experience.customs.veggie.overview.image_alt')}
className="mt-4 w-full"
/>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.veggie.architecture.title')}</h3>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.veggie.architecture.frontend.title')}</h4>
<ul className="space-y-2 text-secondary/70">
<li className="flex gap-2">
<span className="font-semibold text-accent">{t('experience.customs.veggie.architecture.frontend.hosting')}:</span>
<span>Cloudflare Pages</span>
</li>
<li className="flex gap-2">
<span className="font-semibold text-accent">{t('experience.customs.veggie.architecture.frontend.tech')}:</span>
<span>HTML/CSS/JS vanilla</span>
</li>
<li className="flex gap-2">
<span className="font-semibold text-accent">{t('experience.customs.veggie.architecture.frontend.features')}:</span>
<span>{t('experience.customs.veggie.architecture.frontend.features_list')}</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.veggie.architecture.backend.title')}</h4>
<ul className="list-disc list-inside space-y-2 text-secondary/70">
<li>{t('experience.customs.veggie.architecture.backend.webhooks')}</li>
<li>{t('experience.customs.veggie.architecture.backend.pipeline')}</li>
<li>{t('experience.customs.veggie.architecture.backend.emailing')}</li>
<li>{t('experience.customs.veggie.architecture.backend.logistics')}</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.veggie.architecture.security.title')}</h4>
<ul className="list-disc list-inside space-y-2 text-secondary/70">
<li>{t('experience.customs.veggie.architecture.security.payment')}</li>
<li>{t('experience.customs.veggie.architecture.security.access')}</li>
<li>{t('experience.customs.veggie.architecture.security.backup')}</li>
</ul>
</div>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.veggie.challenges.title')}</h3>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-2">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.veggie.challenges.iteration.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.veggie.challenges.iteration.description')}</p>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-2">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.veggie.challenges.cohorts.title')}</h4>
<p className="text-secondary/70">{t('experience.customs.veggie.challenges.cohorts.description')}</p>
</div>
</section>
<section className="space-y-6">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.veggie.stack.title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.veggie.stack.frontend.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>HTML, CSS, JavaScript</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Cloudflare Pages</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.veggie.stack.backend.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Node.js</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Stripe Webhooks</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Brevo / MailerLite</span>
</li>
</ul>
</div>
<div className="bg-primary/50 p-5 rounded-lg border border-secondary/10 space-y-3">
<h4 className="text-lg font-medium text-secondary">{t('experience.customs.veggie.stack.tools.title')}</h4>
<ul className="space-y-2 text-secondary/70 text-sm">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Stripe Payment Links</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Make (PoC)</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>Google Sheets (PoC)</span>
</li>
</ul>
</div>
</div>
</section>
<section className="space-y-4">
<h3 className="text-xl font-semibold text-accent">{t('experience.customs.veggie.results.title')}</h3>
<ul className="space-y-2 text-secondary/70">
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.veggie.results.deployment')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.veggie.results.reliability')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.veggie.results.scalability')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.veggie.results.maintenance')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-accent mt-1"></span>
<span>{t('experience.customs.veggie.results.cost')}</span>
</li>
</ul>
</section>
<footer className="border-t border-secondary/10 pt-6">
<p className="text-sm text-secondary/60 italic">{t('experience.customs.veggie.tags')}</p>
</footer>
</div>
</div>
);
};

View File

@@ -0,0 +1,4 @@
export { CustomDocker } from './CustomDocker';
export { CustomStripe } from './CustomStripe';
export { CustomVeggie } from './CustomVeggie';
export { CustomInfra } from './CustomInfra';

View File

@@ -23,8 +23,12 @@ const Hero = () => {
{t('hero.cta')}
</a>
<a
href="#projects"
className="px-8 py-3 border-2 border-accent text-accent rounded-lg hover:bg-accent hover:text-white transition-all"
href="#projects"
onClick={(e) => {
e.preventDefault();
document.querySelector('#experience')?.scrollIntoView({ behavior: 'smooth' });
}}
className="px-8 py-3 border-2 border-accent text-accent rounded-lg hover:bg-accent hover:text-white transition-all cursor-pointer"
>
{t('hero.projects')}
</a>

View File

@@ -6,11 +6,11 @@ const Skills = () => {
const skillCategories = [
{
title: 'skills.languages.title',
skills: ['Python', 'JavaScript/TypeScript', 'Java', 'C', 'SQL', 'HTML/CSS', 'Bash']
skills: ['JavaScript/TypeScript', 'PHP', 'SQL', 'HTML/CSS', 'Bash', 'Go']
},
{
title: 'skills.frameworks.title',
skills: ['React', 'Node.js', 'Django', 'FastAPI', 'TailwindCSS', 'Bootstrap']
skills: ['React', 'Node.js', 'Effect', 'Next.js', 'TailwindCSS', 'Nest.js']
},
{
title: 'skills.tools.title',
@@ -18,7 +18,7 @@ const Skills = () => {
},
{
title: 'skills.other.title',
skills: ['Agile/Scrum', 'CI/CD', 'Test unitaires', 'Architecture logicielle']
skills: ['Agile/Scrum', 'CI/CD', 'TDD', 'Architecture microservices', 'Cloud Computing', 'Ansible', 'Terraform/Opentofu']
}
];
@@ -32,12 +32,13 @@ const Skills = () => {
<h3 className="text-xl font-semibold mb-4 text-secondary">{t(category.title)}</h3>
<div className="flex flex-wrap gap-2">
{category.skills.map((skill, skillIndex) => (
<span
key={skillIndex}
className="px-4 py-2 bg-accent/10 text-accent border border-accent/20 rounded-full text-sm font-medium hover:bg-accent/20 transition-all"
>
{skill}
</span>
// <span
// key={skillIndex}
// className="px-4 py-2 bg-accent/10 text-accent border border-accent/20 rounded-full text-sm font-medium hover:bg-accent/20 transition-all"
// >
// {skill}
// </span>
<Skill key={skillIndex} name={skill} />
))}
</div>
</div>
@@ -48,4 +49,12 @@ const Skills = () => {
);
};
export const Skill = ({ name, key }: { name: string, key: number }) => {
return (
<span key={key} className="px-4 py-2 bg-accent/10 text-accent border border-accent/20 rounded-full text-sm font-medium hover:bg-accent/20 transition-all">
{name}
</span>
);
}
export default Skills;

View File

@@ -0,0 +1,76 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
interface OverlayPictureProps {
src: string;
alt: string;
className?: string;
}
const OverlayPicture: React.FC<OverlayPictureProps> = ({ src, alt, className = '' }) => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
// We don't want the escape key event to propagate to the overlay parent
// If the picture overlay is used inside another overlay, this prevents both overlays from closing
event.stopPropagation();
setIsOpen(false);
}
};
if (isOpen) {
// Use capture phase
document.addEventListener('keydown', handleEscape, true);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape, true);
document.body.style.overflow = 'unset';
};
}, [isOpen]);
return (
<>
<motion.div className={`${className} flex items-center justify-center`}>
<motion.img
src={src}
alt={alt}
className={`cursor-zoom-in flex self-center max-w-xl max-h-96 rounded-lg shadow-md object-contain`}
onClick={() => setIsOpen(true)}
transition={{ duration: 0.2 }}
layoutId='overlay-image'
/>
</motion.div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) {
setIsOpen(false);
}
}}
>
<motion.img
src={src}
alt={alt}
className="max-w-[90%] max-h-[80%] object-contain rounded-md"
// We don't want the click on the image to close the overlay
layoutId='overlay-image'
/>
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default OverlayPicture;