look good

This commit is contained in:
kinou-p 2025-10-02 11:40:21 +02:00
parent 5ae1cdd980
commit 38c4dd1b7d
23 changed files with 1045 additions and 57 deletions

View File

@ -127,6 +127,8 @@ Les traductions sont centralisées dans `src/utils/translations.ts`. Pour ajoute
3. **Cloud-1** - Infrastructure automatisée avec Docker/Ansible
4. **Minishell** - Réimplémentation d'un shell bash en C
5. **Cube3D** - Moteur 3D RayCaster
6. **etsidemain.com** - Site vitrine pour conseil en transformation régénérative
7. **avopieces.fr** - Plateforme juridique IA pour procédures de divorce
## 📱 Responsive Design

View File

@ -15,9 +15,22 @@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@lovable_dev" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-5V6TCG4C');</script>
<!-- End Google Tag Manager -->
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-5V6TCG4C"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@ -0,0 +1,10 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" fill="#1f2937" rx="8"/>
<rect x="8" y="8" width="48" height="32" fill="#374151" rx="4"/>
<polygon points="20,16 36,16 40,24 32,24 28,32 24,24 16,24" fill="#fbbf24"/>
<rect x="8" y="44" width="48" height="12" fill="#4b5563" rx="2"/>
<rect x="12" y="48" width="8" height="4" fill="#9ca3af" rx="1"/>
<rect x="24" y="48" width="8" height="4" fill="#9ca3af" rx="1"/>
<rect x="36" y="48" width="8" height="4" fill="#9ca3af" rx="1"/>
<rect x="48" y="48" width="8" height="4" fill="#9ca3af" rx="1"/>
</svg>

After

Width:  |  Height:  |  Size: 627 B

View File

@ -0,0 +1,12 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" fill="#1e40af" rx="8"/>
<rect x="8" y="8" width="48" height="12" fill="#3b82f6" rx="2"/>
<rect x="8" y="24" width="48" height="12" fill="#60a5fa" rx="2"/>
<rect x="8" y="40" width="48" height="12" fill="#93c5fd" rx="2"/>
<circle cx="12" cy="14" r="2" fill="#ffffff"/>
<circle cx="12" cy="30" r="2" fill="#ffffff"/>
<circle cx="12" cy="46" r="2" fill="#ffffff"/>
<rect x="20" y="12" width="20" height="4" fill="#ffffff" rx="1"/>
<rect x="20" y="28" width="20" height="4" fill="#ffffff" rx="1"/>
<rect x="20" y="44" width="20" height="4" fill="#ffffff" rx="1"/>
</svg>

After

Width:  |  Height:  |  Size: 699 B

View File

@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" fill="#7c3aed" rx="8"/>
<polygon points="32,8 52,24 52,40 32,56 12,40 12,24" fill="#a855f7"/>
<polygon points="32,16 44,26 44,38 32,48 20,38 20,26" fill="#c084fc"/>
<polygon points="32,24 36,27 36,37 32,40 28,37 28,27" fill="#ddd6fe"/>
<rect x="30" y="20" width="4" height="24" fill="#8b5cf6"/>
<rect x="20" y="30" width="24" height="4" fill="#8b5cf6"/>
</svg>

After

Width:  |  Height:  |  Size: 485 B

View File

@ -0,0 +1,9 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" fill="#059669" rx="8"/>
<circle cx="32" cy="32" r="24" fill="#10b981"/>
<path d="M20 32 Q32 20 44 32 Q32 44 20 32" fill="#34d399"/>
<circle cx="24" cy="28" r="3" fill="#ffffff"/>
<circle cx="40" cy="28" r="3" fill="#ffffff"/>
<path d="M24 40 Q32 36 40 40" stroke="#ffffff" stroke-width="2" fill="none"/>
<rect x="28" y="12" width="8" height="4" fill="#065f46" rx="2"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B

View File

@ -0,0 +1,11 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" fill="#0c0c0c" rx="8"/>
<rect x="4" y="8" width="56" height="48" fill="#1a1a1a" rx="2"/>
<text x="8" y="20" font-family="monospace" font-size="6" fill="#10b981">$</text>
<rect x="12" y="16" width="24" height="2" fill="#10b981" rx="1"/>
<text x="8" y="28" font-family="monospace" font-size="6" fill="#10b981">$</text>
<rect x="12" y="24" width="36" height="2" fill="#10b981" rx="1"/>
<text x="8" y="36" font-family="monospace" font-size="6" fill="#10b981">$</text>
<rect x="12" y="32" width="20" height="2" fill="#10b981" rx="1"/>
<rect x="8" y="44" width="2" height="2" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 724 B

View File

@ -0,0 +1,12 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" fill="#1e293b" rx="8"/>
<rect x="8" y="8" width="48" height="48" fill="#334155" rx="4"/>
<rect x="12" y="12" width="8" height="8" fill="#06b6d4" rx="2"/>
<rect x="24" y="12" width="8" height="8" fill="#06b6d4" rx="2"/>
<rect x="36" y="12" width="8" height="8" fill="#06b6d4" rx="2"/>
<rect x="48" y="12" width="8" height="8" fill="#06b6d4" rx="2"/>
<rect x="12" y="24" width="44" height="4" fill="#475569" rx="2"/>
<rect x="12" y="32" width="44" height="4" fill="#475569" rx="2"/>
<rect x="12" y="40" width="32" height="4" fill="#475569" rx="2"/>
<circle cx="48" cy="48" r="8" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 733 B

View File

@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" fill="#0f172a" rx="8"/>
<rect x="8" y="16" width="48" height="32" fill="#1e293b" rx="4"/>
<circle cx="20" cy="32" r="8" fill="#f59e0b"/>
<circle cx="44" cy="32" r="8" fill="#ef4444"/>
<rect x="16" y="52" width="32" height="4" fill="#334155" rx="2"/>
<rect x="24" y="8" width="16" height="4" fill="#334155" rx="2"/>
</svg>

After

Width:  |  Height:  |  Size: 446 B

View File

@ -0,0 +1,173 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Cookie, X, Settings } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
import { useCookieBanner } from "@/contexts/CookieBannerContext";
export const CookieBanner = () => {
const { t } = useLanguage();
const { isVisible, hideBanner } = useCookieBanner();
const [showDetails, setShowDetails] = useState(false);
const acceptAllCookies = () => {
localStorage.setItem("cookieConsent", "accepted");
localStorage.setItem("analyticsEnabled", "true");
hideBanner();
// Activer Google Tag Manager
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'update', {
'analytics_storage': 'granted'
});
}
};
const acceptNecessaryOnly = () => {
localStorage.setItem("cookieConsent", "necessary");
localStorage.setItem("analyticsEnabled", "false");
hideBanner();
// Désactiver les cookies analytiques
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'update', {
'analytics_storage': 'denied'
});
}
};
const rejectAll = () => {
localStorage.setItem("cookieConsent", "rejected");
localStorage.setItem("analyticsEnabled", "false");
hideBanner();
// Désactiver tous les cookies non nécessaires
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'update', {
'analytics_storage': 'denied'
});
}
};
return (
<AnimatePresence>
{isVisible && (
<>
{/* Overlay pour mobile */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-background/50 backdrop-blur-sm z-40 md:hidden"
/>
{/* Bannière de cookies */}
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed bottom-0 left-0 right-0 z-50 p-4 md:p-6"
>
<Card className="mx-auto max-w-4xl border-border/50 bg-card/95 backdrop-blur-lg shadow-2xl">
<div className="p-4 md:p-6">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Cookie className="w-5 h-5 text-primary" />
</div>
<div>
<h3 className="font-semibold text-lg">Gestion des cookies</h3>
<p className="text-sm text-muted-foreground">
Nous utilisons des cookies pour améliorer votre expérience
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => hideBanner()}
className="shrink-0"
>
<X className="w-4 h-4" />
</Button>
</div>
{/* Contenu */}
<div className="mb-6">
<p className="text-sm text-muted-foreground mb-3">
Ce site utilise des cookies pour analyser le trafic et personnaliser le contenu.
Les cookies analytiques nous aident à comprendre comment vous utilisez notre site.
</p>
{showDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="space-y-3 mb-4"
>
<div className="p-3 bg-muted/50 rounded-lg">
<h4 className="font-medium text-sm mb-1">🍪 Cookies nécessaires</h4>
<p className="text-xs text-muted-foreground">
Requis pour le fonctionnement de base du site (préférences, sécurité)
</p>
</div>
<div className="p-3 bg-muted/50 rounded-lg">
<h4 className="font-medium text-sm mb-1">📊 Cookies analytiques</h4>
<p className="text-xs text-muted-foreground">
Google Analytics pour mesurer l'audience et améliorer le site
</p>
</div>
</motion.div>
)}
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-2 sm:justify-between sm:items-center">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDetails(!showDetails)}
className="text-xs sm:order-first"
>
<Settings className="w-3 h-3 mr-1" />
{showDetails ? "Masquer les détails" : "Voir les détails"}
</Button>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
size="sm"
onClick={rejectAll}
className="text-xs"
>
Tout refuser
</Button>
<Button
variant="outline"
size="sm"
onClick={acceptNecessaryOnly}
className="text-xs"
>
Nécessaires seulement
</Button>
<Button
size="sm"
onClick={acceptAllCookies}
className="text-xs"
>
Tout accepter
</Button>
</div>
</div>
</div>
</Card>
</motion.div>
</>
)}
</AnimatePresence>
);
};

179
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,179 @@
import { motion } from "framer-motion";
import { useState } from "react";
import { useLanguage } from "@/contexts/LanguageContext";
import { useCookieBanner } from "@/contexts/CookieBannerContext";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { LegalNoticeModal } from "@/components/LegalNoticeModal";
import { Github, Mail, ExternalLink, Cookie, FileText } from "lucide-react";
export const Footer = () => {
const { t } = useLanguage();
const { showBanner } = useCookieBanner();
const [showLegalNotice, setShowLegalNotice] = useState(false);
const reopenCookiePreferences = () => {
// Simplement rouvrir la bannière sans recharger
showBanner();
};
const currentYear = new Date().getFullYear();
const footerLinks = [
{
icon: <FileText className="w-4 h-4" />,
label: "Mentions légales",
onClick: () => setShowLegalNotice(true)
},
{
icon: <Cookie className="w-4 h-4" />,
label: "Gestion des cookies",
onClick: reopenCookiePreferences
}
];
const socialLinks = [
{
icon: <Github className="w-5 h-5" />,
label: "GitHub",
href: "https://github.com/kinou-p"
},
{
icon: <Mail className="w-5 h-5" />,
label: "Email",
href: "mailto:contact@apommier.com"
}
];
return (
<footer className="bg-background/50 backdrop-blur-sm border-t border-border/50">
<div className="container mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
{/* Colonne 1: Logo et description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="space-y-4"
>
<div className="text-2xl font-bold text-gradient">
Alexandre Pommier
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
Étudiant développeur à 42, passionné par les technologies web et systèmes.
Créateur de solutions innovantes pour un avenir numérique.
</p>
<div className="flex items-center gap-3">
{socialLinks.map((link, index) => (
<motion.a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.1 }}
whileHover={{ scale: 1.1 }}
className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary hover:bg-primary/20 transition-colors"
title={link.label}
>
{link.icon}
</motion.a>
))}
</div>
</motion.div>
{/* Colonne 2: Navigation rapide */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="space-y-4"
>
<h3 className="font-semibold text-lg">Navigation</h3>
<nav className="space-y-2">
{["home", "projects", "skills", "contact"].map((item) => (
<Button
key={item}
variant="ghost"
size="sm"
onClick={() => {
const element = document.getElementById(item);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}}
className="justify-start h-auto p-2 text-muted-foreground hover:text-foreground"
>
{t(`nav.${item}`)}
</Button>
))}
</nav>
</motion.div>
{/* Colonne 3: Informations légales */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
className="space-y-4"
>
<h3 className="font-semibold text-lg">Informations</h3>
<nav className="space-y-2">
{footerLinks.map((link, index) => (
<Button
key={link.label}
variant="ghost"
size="sm"
onClick={link.onClick}
className="justify-start h-auto p-2 text-muted-foreground hover:text-foreground"
>
<span className="mr-2">{link.icon}</span>
{link.label}
</Button>
))}
</nav>
</motion.div>
</div>
<Separator className="mb-6" />
{/* Bas du footer */}
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground"
>
<div className="text-center md:text-left">
© {currentYear} Alexandre Pommier. Tous droits réservés.
</div>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1">
Construit avec
<a
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
>
React <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
</motion.div>
</div>
{/* Modal mentions légales */}
<LegalNoticeModal
isOpen={showLegalNotice}
onClose={() => setShowLegalNotice(false)}
/>
</footer>
);
};

View File

@ -15,6 +15,10 @@ export const Header = () => {
}
};
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<motion.header
initial={{ y: -100 }}
@ -22,14 +26,16 @@ export const Header = () => {
className="fixed top-0 left-0 right-0 z-50 border-b border-border/40 bg-background/80 backdrop-blur-lg"
>
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<motion.div
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-2xl font-bold text-gradient"
onClick={scrollToTop}
className="text-2xl font-bold text-gradient hover:scale-105 transition-transform duration-200 cursor-pointer"
aria-label="Retour en haut de page"
>
AP
</motion.div>
</motion.button>
<nav className="hidden md:flex items-center gap-8">
{["home", "projects", "skills", "contact"].map((item, i) => (

View File

@ -0,0 +1,169 @@
import { motion } from "framer-motion";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
interface LegalNoticeModalProps {
isOpen: boolean;
onClose: () => void;
}
export const LegalNoticeModal = ({ isOpen, onClose }: LegalNoticeModalProps) => {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">Mentions Légales</DialogTitle>
<DialogDescription>
Informations légales et conditions d'utilisation du site
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-full max-h-[60vh]">
<div className="space-y-6 pr-4">
<motion.section
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<h3 className="font-semibold text-lg mb-3 flex items-center gap-2">
🏢 Éditeur du site
</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p><strong>Nom :</strong> Alexandre Pommier</p>
<p><strong>Statut :</strong> Étudiant à l'École 42</p>
<p><strong>Email :</strong> contact@apommier.com</p>
<p><strong>GitHub :</strong> @kinou-p</p>
</div>
</motion.section>
<Separator />
<motion.section
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<h3 className="font-semibold text-lg mb-3 flex items-center gap-2">
🌐 Hébergement
</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p><strong>Hébergeur :</strong> Serveur personnel d'Alexandre Pommier</p>
<p><strong>Type :</strong> Hébergement privé</p>
<p><strong>Responsable :</strong> Alexandre Pommier</p>
</div>
</motion.section>
<Separator />
<motion.section
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h3 className="font-semibold text-lg mb-3 flex items-center gap-2">
📝 Propriété intellectuelle
</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
Le contenu de ce site portfolio (textes, images, design, code source)
est la propriété exclusive d'Alexandre Pommier et est protégé par le droit d'auteur.
</p>
<p>
<strong>Reproduction interdite :</strong> Toute reproduction, distribution,
modification ou utilisation du contenu sans autorisation expresse est interdite.
</p>
<p>
<strong>Code source :</strong> Le code source de ce site est propriétaire
et non distribué sous licence libre.
</p>
</div>
</motion.section>
<Separator />
<motion.section
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<h3 className="font-semibold text-lg mb-3 flex items-center gap-2">
🔒 Données personnelles et cookies
</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
<strong>Collecte de données :</strong> Ce site ne collecte aucune donnée
personnelle directement. Seules les données de navigation anonymisées
peuvent être collectées via Google Analytics (avec votre consentement).
</p>
<p>
<strong>Cookies :</strong> Utilisation de cookies techniques nécessaires
au fonctionnement du site et de cookies analytiques (Google Analytics)
soumis à votre consentement.
</p>
<p>
<strong>Vos droits :</strong> Vous pouvez à tout moment modifier vos
préférences de cookies via le bouton dédié dans l'en-tête du site.
</p>
</div>
</motion.section>
<Separator />
<motion.section
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<h3 className="font-semibold text-lg mb-3 flex items-center gap-2">
Responsabilité
</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
Les informations diffusées sur ce site sont présentées à titre informatif.
Alexandre Pommier s'efforce d'assurer l'exactitude et la mise à jour des
informations, mais ne peut garantir l'exactitude, la précision ou
l'exhaustivité des informations.
</p>
<p>
En conséquence, l'utilisateur reconnaît utiliser ces informations sous
sa responsabilité exclusive.
</p>
</div>
</motion.section>
<Separator />
<motion.section
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
>
<h3 className="font-semibold text-lg mb-3 flex items-center gap-2">
📧 Contact
</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
Pour toute question relative aux présentes mentions légales ou
à l'utilisation du site, vous pouvez me contacter :
</p>
<p><strong>Email :</strong> contact@apommier.com</p>
<p><strong>GitHub :</strong> github.com/kinou-p</p>
</div>
</motion.section>
<div className="text-xs text-muted-foreground mt-8 pt-4 border-t">
<p>Dernière mise à jour : Octobre 2025</p>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};

View File

@ -5,10 +5,11 @@ interface ProjectCardProps {
title: string;
description: string;
icon: React.ReactNode;
image?: string;
delay?: number;
}
export const ProjectCard = ({ title, description, icon, delay = 0 }: ProjectCardProps) => {
export const ProjectCard = ({ title, description, icon, image, delay = 0 }: ProjectCardProps) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
@ -17,12 +18,21 @@ export const ProjectCard = ({ title, description, icon, delay = 0 }: ProjectCard
transition={{ duration: 0.5, delay }}
whileHover={{ y: -5 }}
>
<Card className="h-full hover:shadow-lg transition-all duration-300 border-border/50 bg-card/50 backdrop-blur">
<Card className="h-full hover:shadow-lg transition-all duration-300 border-border/50 bg-card/50 backdrop-blur relative overflow-hidden">
{image && (
<div className="absolute top-4 right-4 w-16 h-16 rounded-lg overflow-hidden border-2 border-background/20 shadow-lg">
<img
src={image}
alt={`${title} preview`}
className="w-full h-full object-cover"
/>
</div>
)}
<CardHeader>
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
{icon}
</div>
<CardTitle className="text-xl">{title}</CardTitle>
<CardTitle className="text-xl pr-20">{title}</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-base leading-relaxed">

View File

@ -1,23 +1,27 @@
import { motion } from "framer-motion";
import { useLanguage } from "@/contexts/LanguageContext";
import { Mail, Phone, Github } from "lucide-react";
import { Mail, Github, Send, Share2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";
export const ContactSection = () => {
const { t } = useLanguage();
const [formData, setFormData] = useState({
name: "",
email: "",
subject: "",
message: "",
});
const contacts = [
{
icon: <Phone className="w-6 h-6" />,
label: t("contact.phone"),
value: "06.52.40.38.30",
href: "tel:+33652403830",
},
{
icon: <Mail className="w-6 h-6" />,
label: t("contact.email"),
value: "apommier@student.42.fr",
href: "mailto:apommier@student.42.fr",
value: "contact@apommier.com",
href: "mailto:contact@apommier.com",
},
{
icon: <Github className="w-6 h-6" />,
@ -27,6 +31,42 @@ export const ContactSection = () => {
},
];
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Créer un lien mailto avec les données du formulaire
const subject = encodeURIComponent(formData.subject || "Contact depuis le portfolio");
const body = encodeURIComponent(
`Nom: ${formData.name}\nEmail: ${formData.email}\n\nMessage:\n${formData.message}`
);
window.location.href = `mailto:contact@apommier.com?subject=${subject}&body=${body}`;
};
const handleShare = async () => {
const shareData = {
title: "Portfolio d'Alexandre Pommier",
text: "Découvrez le portfolio d'Alexandre Pommier, étudiant développeur à 42",
url: window.location.href,
};
try {
if (navigator.share) {
await navigator.share(shareData);
} else {
// Fallback : copier l'URL dans le presse-papiers
await navigator.clipboard.writeText(window.location.href);
// Vous pouvez ajouter ici une notification toast pour informer l'utilisateur
alert("Lien copié dans le presse-papiers !");
}
} catch (error) {
console.log("Erreur lors du partage:", error);
}
};
return (
<section id="contact" className="py-20 md:py-32">
<div className="container mx-auto px-4">
@ -45,36 +85,132 @@ export const ContactSection = () => {
</p>
</motion.div>
<div className="max-w-3xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
{contacts.map((contact, index) => (
<motion.a
key={contact.label}
href={contact.href}
target="_blank"
rel="noopener noreferrer"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -5 }}
>
<Card className="h-full hover:shadow-lg transition-all duration-300 border-border/50 bg-card/50 backdrop-blur cursor-pointer">
<CardContent className="pt-6 text-center space-y-3">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mx-auto text-primary">
{contact.icon}
<div className="max-w-4xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Informations de contact */}
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="space-y-6"
>
<h3 className="text-2xl font-semibold mb-6">Restons en contact</h3>
<div className="space-y-4">
{contacts.map((contact, index) => (
<motion.a
key={contact.label}
href={contact.href}
target="_blank"
rel="noopener noreferrer"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ x: 5 }}
>
<Card className="hover:shadow-md transition-all duration-300 border-border/50 bg-card/50 backdrop-blur cursor-pointer">
<CardContent className="p-4 flex items-center space-x-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary flex-shrink-0">
{contact.icon}
</div>
<div>
<p className="text-sm text-muted-foreground">
{contact.label}
</p>
<p className="font-medium">{contact.value}</p>
</div>
</CardContent>
</Card>
</motion.a>
))}
{/* Bouton de partage */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<Button
onClick={handleShare}
variant="outline"
className="w-full p-4 h-auto justify-start space-x-4 hover:shadow-md transition-all duration-300 border-border/50 bg-card/50 backdrop-blur"
>
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary flex-shrink-0">
<Share2 className="w-6 h-6" />
</div>
<div className="text-left">
<p className="text-sm text-muted-foreground">
Partager
</p>
<p className="font-medium">Partager mon portfolio</p>
</div>
</Button>
</motion.div>
</div>
</motion.div>
{/* Formulaire de contact */}
<motion.div
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Card className="border-border/50 bg-card/50 backdrop-blur">
<CardContent className="p-6">
<h3 className="text-2xl font-semibold mb-6">Envoyez-moi un message</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Input
name="name"
placeholder="Votre nom"
value={formData.name}
onChange={handleInputChange}
required
/>
</div>
<div>
<Input
name="email"
type="email"
placeholder="Votre email"
value={formData.email}
onChange={handleInputChange}
required
/>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">
{contact.label}
</p>
<p className="font-medium">{contact.value}</p>
<Input
name="subject"
placeholder="Sujet"
value={formData.subject}
onChange={handleInputChange}
required
/>
</div>
</CardContent>
</Card>
</motion.a>
))}
<div>
<Textarea
name="message"
placeholder="Votre message..."
rows={5}
value={formData.message}
onChange={handleInputChange}
required
/>
</div>
<Button type="submit" className="w-full" size="lg">
<Send className="w-4 h-4 mr-2" />
Envoyer le message
</Button>
</form>
</CardContent>
</Card>
</motion.div>
</div>
</div>
</section>
);
};
};

View File

@ -0,0 +1,84 @@
import { motion } from "framer-motion";
import { useLanguage } from "@/contexts/LanguageContext";
import { Mail, Github, Send } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";
export const ContactSection = () => {
const { t } = useLanguage();
const contacts = [
{
icon: <Phone className="w-6 h-6" />,
label: t("contact.phone"),
value: "06.52.40.38.30",
href: "tel:+33652403830",
},
{
icon: <Mail className="w-6 h-6" />,
label: t("contact.email"),
value: "contact@apommier.com",
href: "mailto:contact@apommier.com",
},
{
icon: <Github className="w-6 h-6" />,
label: t("contact.github"),
value: "kinou-p",
href: "https://github.com/kinou-p",
},
];
return (
<section id="contact" className="py-20 md:py-32">
<div className="container mx-auto px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-16"
>
<h2 className="text-4xl md:text-5xl font-bold mb-4">
{t("contact.title")}
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{t("contact.subtitle")}
</p>
</motion.div>
<div className="max-w-3xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
{contacts.map((contact, index) => (
<motion.a
key={contact.label}
href={contact.href}
target="_blank"
rel="noopener noreferrer"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -5 }}
>
<Card className="h-full hover:shadow-lg transition-all duration-300 border-border/50 bg-card/50 backdrop-blur cursor-pointer">
<CardContent className="pt-6 text-center space-y-3">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mx-auto text-primary">
{contact.icon}
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">
{contact.label}
</p>
<p className="font-medium">{contact.value}</p>
</div>
</CardContent>
</Card>
</motion.a>
))}
</div>
</div>
</section>
);
};

View File

@ -70,12 +70,15 @@ export const HeroSection = () => {
transition={{ duration: 0.5, delay: 0.8 }}
className="pt-16"
>
<motion.div
<motion.button
animate={{ y: [0, 10, 0] }}
transition={{ duration: 1.5, repeat: Infinity }}
onClick={() => scrollToSection("projects")}
className="p-2 rounded-full hover:bg-primary/10 transition-colors duration-200 cursor-pointer"
aria-label="Scroll to projects"
>
<ArrowDown className="h-6 w-6 mx-auto text-muted-foreground" />
</motion.div>
<ArrowDown className="h-8 w-8 mx-auto text-muted-foreground hover:text-primary transition-colors duration-200" />
</motion.button>
</motion.div>
</div>
</div>

View File

@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { useLanguage } from "@/contexts/LanguageContext";
import { ProjectCard } from "@/components/ProjectCard";
import { Server, Gamepad2, Cloud, Terminal, Box } from "lucide-react";
import { Server, Gamepad2, Cloud, Terminal, Box, Globe, Scale } from "lucide-react";
export const ProjectsSection = () => {
const { t } = useLanguage();
@ -10,22 +10,37 @@ export const ProjectsSection = () => {
{
key: "nas",
icon: <Server className="w-6 h-6 text-primary" />,
image: "/images/projects/nas.svg",
},
{
key: "transcendence",
icon: <Gamepad2 className="w-6 h-6 text-primary" />,
image: "/images/projects/transcendence.svg",
},
{
key: "cloud",
icon: <Cloud className="w-6 h-6 text-primary" />,
image: "/images/projects/cloud.svg",
},
{
key: "minishell",
icon: <Terminal className="w-6 h-6 text-primary" />,
image: "/images/projects/minishell.svg",
},
{
key: "cube3d",
icon: <Box className="w-6 h-6 text-primary" />,
image: "/images/projects/cube3d.svg",
},
{
key: "etsidemain",
icon: <Globe className="w-6 h-6 text-primary" />,
image: "/images/projects/etsidemain.svg",
},
{
key: "avopieces",
icon: <Scale className="w-6 h-6 text-primary" />,
image: "/images/projects/avopieces.svg",
},
];
@ -54,6 +69,7 @@ export const ProjectsSection = () => {
title={t(`projects.items.${project.key}.title`)}
description={t(`projects.items.${project.key}.description`)}
icon={project.icon}
image={project.image}
delay={index * 0.1}
/>
))}

View File

@ -0,0 +1,51 @@
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
interface CookieBannerContextType {
isVisible: boolean;
showBanner: () => void;
hideBanner: () => void;
}
const CookieBannerContext = createContext<CookieBannerContextType | undefined>(undefined);
export const useCookieBanner = () => {
const context = useContext(CookieBannerContext);
if (!context) {
throw new Error("useCookieBanner must be used within a CookieBannerProvider");
}
return context;
};
interface CookieBannerProviderProps {
children: ReactNode;
}
export const CookieBannerProvider = ({ children }: CookieBannerProviderProps) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Vérifier si l'utilisateur a déjà fait un choix
const cookieConsent = localStorage.getItem("cookieConsent");
if (!cookieConsent) {
// Délai de 2 secondes avant d'afficher la bannière
const timer = setTimeout(() => {
setIsVisible(true);
}, 2000);
return () => clearTimeout(timer);
}
}, []);
const showBanner = () => {
setIsVisible(true);
};
const hideBanner = () => {
setIsVisible(false);
};
return (
<CookieBannerContext.Provider value={{ isVisible, showBanner, hideBanner }}>
{children}
</CookieBannerContext.Provider>
);
};

View File

@ -1,5 +1,9 @@
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { initializeGTM } from "./utils/gtm.ts";
// Initialiser Google Tag Manager
initializeGTM();
createRoot(document.getElementById("root")!).render(<App />);

View File

@ -1,26 +1,33 @@
import { Header } from "@/components/Header";
import { Footer } from "@/components/Footer";
import { ScrollProgress } from "@/components/ScrollProgress";
import { CookieBanner } from "@/components/CookieBanner";
import { HeroSection } from "@/components/sections/HeroSection";
import { ProjectsSection } from "@/components/sections/ProjectsSection";
import { SkillsSection } from "@/components/sections/SkillsSection";
import { ContactSection } from "@/components/sections/ContactSection";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { LanguageProvider } from "@/contexts/LanguageContext";
import { CookieBannerProvider } from "@/contexts/CookieBannerContext";
const Index = () => {
return (
<ThemeProvider>
<LanguageProvider>
<div className="min-h-screen">
<ScrollProgress />
<Header />
<main>
<HeroSection />
<ProjectsSection />
<SkillsSection />
<ContactSection />
</main>
</div>
<CookieBannerProvider>
<div className="min-h-screen">
<ScrollProgress />
<Header />
<main>
<HeroSection />
<ProjectsSection />
<SkillsSection />
<ContactSection />
</main>
<Footer />
<CookieBanner />
</div>
</CookieBannerProvider>
</LanguageProvider>
</ThemeProvider>
);

51
src/utils/gtm.ts Normal file
View File

@ -0,0 +1,51 @@
// Configuration et initialisation de Google Tag Manager avec gestion du consentement
declare global {
interface Window {
gtag: (...args: any[]) => void;
dataLayer: any[];
}
}
// Initialiser le dataLayer si il n'existe pas
window.dataLayer = window.dataLayer || [];
// Configuration du consentement par défaut (refusé jusqu'à acceptation)
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('consent', 'default', {
'analytics_storage': 'denied',
'ad_storage': 'denied',
'functionality_storage': 'granted',
'security_storage': 'granted',
});
// Vérifier si l'utilisateur a déjà donné son consentement
const cookieConsent = localStorage.getItem('cookieConsent');
const analyticsEnabled = localStorage.getItem('analyticsEnabled');
if (cookieConsent === 'accepted' && analyticsEnabled === 'true') {
window.gtag('consent', 'update', {
'analytics_storage': 'granted'
});
}
}
export const initializeGTM = () => {
// Cette fonction peut être utilisée pour des initialisations supplémentaires si nécessaire
console.log('Google Tag Manager initialized');
};
export const trackEvent = (eventName: string, parameters?: Record<string, any>) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', eventName, parameters);
}
};
export const trackPageView = (pageName: string) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('config', 'GTM-5V6TCG4C', {
page_title: pageName,
page_location: window.location.href,
});
}
};

View File

@ -36,6 +36,14 @@ export const translations = {
title: "Cube3D",
description: "Moteur RayCaster 3D inspiré de Wolfenstein 3D, développé avec MiniLibX et algorithmes graphiques avancés.",
},
etsidemain: {
title: "etsidemain.com",
description: "Site web vitrine pour cabinet de conseil en transformation régénérative. Design moderne et responsive avec animations CSS, formulaire de contact et optimisations SEO.",
},
avopieces: {
title: "avopieces.fr",
description: "Plateforme juridique intelligente pour le cabinet AvoCab, spécialisée dans les procédures de divorce. Intègre un chatbot IA analysant les documents uploadés, système de gestion de comptes client/admin, prise de RDV en ligne et vitrine du cabinet.",
},
},
},
skills: {
@ -47,7 +55,6 @@ export const translations = {
contact: {
title: "Contact",
subtitle: "N'hésitez pas à me contacter",
phone: "Téléphone",
email: "Email",
github: "GitHub",
},
@ -89,6 +96,14 @@ export const translations = {
title: "Cube3D",
description: "3D RayCaster engine inspired by Wolfenstein 3D, developed with MiniLibX and advanced graphics algorithms.",
},
etsidemain: {
title: "etsidemain.com",
description: "Showcase website for regenerative transformation consulting firm. Modern responsive design with CSS animations, contact form and SEO optimizations.",
},
avopieces: {
title: "avopieces.fr",
description: "Intelligent legal platform for AvoCab law firm, specialized in divorce procedures. Features AI chatbot analyzing uploaded documents, client/admin account management system, online appointment booking and law firm showcase.",
},
},
},
skills: {
@ -100,7 +115,6 @@ export const translations = {
contact: {
title: "Contact",
subtitle: "Feel free to reach out",
phone: "Phone",
email: "Email",
github: "GitHub",
},