Compare commits

..

9 Commits

54 changed files with 2775 additions and 238 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,8 @@
{
"hash": "a5aca759",
"configHash": "b6b52121",
"lockfileHash": "e3b0c442",
"browserHash": "5526a657",
"optimized": {},
"chunks": {}
}

3
.vite/deps/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -1,105 +0,0 @@
# Configuration EmailJS pour le Portfolio
Ce guide vous explique comment configurer EmailJS pour que le formulaire de contact de votre portfolio fonctionne réellement.
## 1. Créer un compte EmailJS
1. Allez sur [EmailJS.com](https://www.emailjs.com/)
2. Créez un compte gratuit (jusqu'à 200 emails/mois)
3. Connectez-vous à votre dashboard
## 2. Configurer le service email
1. Dans votre dashboard EmailJS, allez dans **Email Services**
2. Cliquez sur **Add New Service**
3. Choisissez votre fournisseur email (Gmail, Outlook, etc.)
4. Suivez les instructions pour connecter votre compte email
5. Notez votre **Service ID** (ex: `service_xyz123`)
## 3. Créer un template d'email
1. Allez dans **Email Templates**
2. Cliquez sur **Create New Template**
3. Configurez votre template avec ces variables :
- `{{from_name}}` - Nom de l'expéditeur
- `{{from_email}}` - Email de l'expéditeur
- `{{subject}}` - Sujet du message
- `{{message}}` - Corps du message
Exemple de template :
```
Nouveau message depuis votre portfolio
De: {{from_name}} ({{from_email}})
Sujet: {{subject}}
Message:
{{message}}
---
Ce message a été envoyé depuis votre formulaire de contact.
```
4. Notez votre **Template ID** (ex: `template_abc456`)
## 4. Obtenir votre clé publique
1. Allez dans **Account** > **General**
2. Trouvez votre **Public Key** (ex: `user_def789`)
## 5. Configurer les variables d'environnement
Créez un fichier `.env` à la racine de votre projet :
```env
VITE_EMAILJS_SERVICE_ID=your_service_id_here
VITE_EMAILJS_TEMPLATE_ID=your_template_id_here
VITE_EMAILJS_PUBLIC_KEY=your_public_key_here
```
## 6. Sécurité et limitations
### Version gratuite d'EmailJS :
- 200 emails par mois maximum
- Pas de limitation de domaine
- Support communautaire
### Pour la production :
- Considérez un plan payant pour plus d'emails
- Ajoutez une validation côté serveur si nécessaire
- Implémentez un captcha pour éviter le spam
## 7. Test du formulaire
1. Remplissez les variables d'environnement
2. Redémarrez votre serveur de développement
3. Testez le formulaire de contact
4. Vérifiez la réception de l'email
## 8. Dépannage
### Erreurs communes :
**"Invalid template ID"**
- Vérifiez que le Template ID est correct
- Assurez-vous que le template est publié
**"Forbidden"**
- Vérifiez votre Public Key
- Assurez-vous que le service est actif
**Email non reçu**
- Vérifiez vos spams
- Vérifiez la configuration du service email
- Testez depuis le dashboard EmailJS
### Fallback automatique
Si EmailJS ne fonctionne pas, le système bascule automatiquement vers `mailto:` pour ouvrir le client email par défaut de l'utilisateur.
## 9. Fichiers modifiés
- `src/services/emailService.ts` - Service d'envoi d'emails
- `src/components/Contact.tsx` - Intégration du service
- `src/styles/components/_contact.scss` - Styles des messages d'erreur
Le formulaire de contact est maintenant prêt à fonctionner avec une vraie logique d'envoi d'emails !

4
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
.env
.env.example
.env.local

1769
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "xeewy-server",
"version": "1.0.0",
"description": "Backend API for xeewy.be",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon src/index.ts",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"mysql2": "^3.11.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.8.6",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}

17
backend/src/config/db.ts Normal file
View File

@@ -0,0 +1,17 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: Number(process.env.DB_PORT) || 3306,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
export default pool;

32
backend/src/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import pool from './config/db';
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
// Basic health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date() });
});
// Database connection test
app.get('/api/test-db', async (req, res) => {
try {
const [rows] = await pool.query('SELECT 1 + 1 AS solution');
res.json({ status: 'connected', result: rows });
} catch (error: any) {
console.error('Database connection error:', error);
res.status(500).json({ status: 'error', message: error.message });
}
});
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});

18
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}

View File

View File

@@ -12,7 +12,8 @@
"framer-motion": "^12.23.24",
"lucide-react": "^0.553.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-router-dom": "^7.10.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -2308,6 +2309,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3362,6 +3376,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
"license": "MIT",
"dependencies": {
"react-router": "7.10.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3501,6 +3553,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -14,7 +14,8 @@
"framer-motion": "^12.23.24",
"lucide-react": "^0.553.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-router-dom": "^7.10.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

72
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,72 @@
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { LanguageProvider } from './contexts/LanguageContext';
import Header from './components/Header';
import Footer from './components/Footer';
import Home from './components/Home';
import TravelMate from './components/TravelMate';
import Policies from './components/Policies';
import ScrollToTop from './components/ScrollToTop';
import './styles/main.scss';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/fr" replace />} />
<Route path="/:lang/*" element={
<LanguageProvider>
<AppContent />
</LanguageProvider>
} />
</Routes>
</BrowserRouter>
);
}
function AppContent() {
const [darkMode, setDarkMode] = useState(false);
const useParamsLang = useParams();
// Sync URL language with context if needed, or validate it
useEffect(() => {
// Optional: Check if useParamsLang.lang is valid, else redirect
}, [useParamsLang]);
useEffect(() => {
const savedTheme = localStorage.getItem('darkMode');
if (savedTheme) {
setDarkMode(JSON.parse(savedTheme));
} else {
setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches);
}
}, []);
useEffect(() => {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
}, [darkMode]);
const toggleDarkMode = () => {
setDarkMode(!darkMode);
};
return (
<>
<ScrollToTop />
<div className={`app ${darkMode ? 'dark' : 'light'}`} style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Header darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
<main style={{ flex: 1 }}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="travelmate" element={<TravelMate />} />
<Route path="travelmate/policies" element={<Policies />} />
</Routes>
</main>
<Footer />
</div>
</>
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

View File

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -27,6 +27,21 @@ const Contact = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Vérification du délai de 15 minutes par email
const storageKey = `lastMessageTime_${formData.email}`;
const lastMessageTime = localStorage.getItem(storageKey);
if (lastMessageTime) {
const timeSinceLastMessage = Date.now() - parseInt(lastMessageTime, 10);
const fifteenMinutes = 15 * 60 * 1000;
if (timeSinceLastMessage < fifteenMinutes) {
const remainingMinutes = Math.ceil((fifteenMinutes - timeSinceLastMessage) / 60000);
setError(`Veuillez attendre ${remainingMinutes} minutes avant d'envoyer un nouveau message avec cette adresse email.`);
return;
}
}
setIsSubmitting(true);
setError(null);
@@ -41,6 +56,7 @@ const Contact = () => {
const result = await sendContactEmail(contactData);
if (result.success) {
localStorage.setItem(`lastMessageTime_${formData.email}`, Date.now().toString());
setIsSubmitted(true);
setFormData({ name: '', email: '', subject: '', message: '' });
@@ -89,7 +105,7 @@ const Contact = () => {
{
icon: <Github size={24} />,
name: "GitHub",
url: "https://github.com/Dayron-HELHa", // Remplacez par votre profil
url: "https://git.xeewy.be/Xeewy", // Remplacez par votre profil
color: "#333"
},
{
@@ -357,18 +373,6 @@ const Contact = () => {
</motion.div>
</motion.div>
{/* Footer */}
<motion.footer
className="contact-footer"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.5 }}
viewport={{ once: true }}
>
<p>
© 2025 Dayron Van Leemput.
</p>
</motion.footer>
</div>
</section>
);

View File

@@ -0,0 +1,28 @@
import { motion } from 'framer-motion';
const Footer = () => {
return (
<motion.footer
className="footer"
style={{
padding: '2rem 0',
textAlign: 'center',
borderTop: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))',
background: 'var(--bg-secondary, rgba(0, 0, 0, 0.2))',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
viewport={{ once: true }}
>
<p style={{ opacity: 0.7, margin: 0 }}>
© {new Date().getFullYear()} Dayron Van Leemput.
</p>
</motion.footer>
);
};
export default Footer;

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { motion } from 'framer-motion';
import { Menu, X, Sun, Moon, Globe } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import { useNavigate, useLocation, Link } from 'react-router-dom';
interface HeaderProps {
darkMode: boolean;
@@ -11,6 +12,8 @@ interface HeaderProps {
const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { language, setLanguage, t } = useLanguage();
const navigate = useNavigate();
const location = useLocation();
const menuItems = [
{ id: 'home', name: t('nav.home'), href: '#hero' },
@@ -21,12 +24,26 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
{ id: 'contact', name: t('nav.contact'), href: '#contact' }
];
const scrollToSection = (href: string) => {
const handleNavigation = (href: string) => {
setIsMenuOpen(false);
if (location.pathname === `/${language}` || location.pathname === `/${language}/`) {
// Already on home, just scroll
const element = document.querySelector(href);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
setIsMenuOpen(false);
} else {
// Not on home, navigate then scroll (using a simple timeout for simplicity or hash)
navigate(`/${language}`);
// Small timeout to allow navigation to render Home before scrolling
setTimeout(() => {
const element = document.querySelector(href);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
}
};
const toggleLanguage = () => {
@@ -46,9 +63,12 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<a href="#hero" onClick={(e) => { e.preventDefault(); scrollToSection('#hero'); }}>
<Link to={`/${language}`} onClick={(e) => {
e.preventDefault();
handleNavigation('#hero');
}}>
Dayron Van Leemput
</a>
</Link>
</motion.div>
{/* Navigation desktop */}
@@ -64,7 +84,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
href={item.href}
onClick={(e) => {
e.preventDefault();
scrollToSection(item.href);
handleNavigation(item.href);
}}
>
{item.name}
@@ -123,7 +143,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
href={item.href}
onClick={(e) => {
e.preventDefault();
scrollToSection(item.href);
handleNavigation(item.href);
}}
>
{item.name}

View File

@@ -89,7 +89,7 @@ const Hero = () => {
transition={{ duration: 0.8, delay: 1.2 }}
>
<motion.a
href="https://github.com/Dayron-HELHa" // Remplacez par votre profil GitHub
href="https://git.xeewy.be/Xeewy" // Remplacez par votre profil GitHub
target="_blank"
rel="noopener noreferrer"
className="social-link"

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react';
import Hero from './Hero';
import About from './About';
import Skills from './Skills';
import Projects from './Projects';
import Education from './Education';
import Contact from './Contact';
const Home = () => {
useEffect(() => {
document.title = "Portfolio - Dayron Van Leemput";
}, []);
return (
<>
<Hero />
<About />
<Skills />
<Projects />
<Education />
<Contact />
</>
);
};
export default Home;

View File

@@ -0,0 +1,148 @@
import { motion } from 'framer-motion';
import { useLanguage } from '../contexts/LanguageContext';
import { Link } from 'react-router-dom';
import { ArrowLeft, ExternalLink, Camera, MapPin, Bell } from 'lucide-react';
const Policies = () => {
const { t, language } = useLanguage();
const sections = [2, 3, 4, 5, 6]; // Sections after the permission cards (1 is before)
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const sectionVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
return (
<div className="policies-page" style={{ paddingTop: '100px', minHeight: '100vh', paddingBottom: '50px' }}>
<div className="container" style={{ maxWidth: '800px', margin: '0 auto', padding: '0 20px' }}>
<Link
to={`/${language}/travelmate`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
marginBottom: '40px',
color: 'var(--text-color)',
textDecoration: 'none',
fontSize: '1rem',
opacity: 0.8
}}
>
<ArrowLeft size={18} />
{t('policies.back')}
</Link>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Header */}
<motion.div variants={sectionVariants} style={{ marginBottom: '3rem', textAlign: 'center' }}>
<h1 style={{ fontSize: '2.5rem', marginBottom: '1rem', fontWeight: 'bold' }}>{t('policies.title')}</h1>
<p style={{ fontSize: '1.2rem', opacity: 0.7 }}>{t('policies.intro')}</p>
</motion.div>
{/* Section 1: Collection (Text) */}
<motion.div variants={sectionVariants} className="policy-section" style={{ marginBottom: '2.5rem' }}>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', fontWeight: '700' }}>
{t('policies.section.1.title')}
</h2>
<p style={{ lineHeight: '1.8', opacity: 0.9, fontSize: '1rem' }}>
{t('policies.section.1.content')}
</p>
</motion.div>
{/* Visual Permission Cards */}
<motion.div variants={sectionVariants} style={{ marginBottom: '4rem' }}>
<h3 style={{ fontSize: '1.2rem', marginBottom: '1.5rem', opacity: 0.8 }}>{t('policies.permissions.title')}</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1.5rem' }}>
{/* Camera */}
<div style={{ background: 'var(--card-bg, rgba(255,255,255,0.05))', padding: '1.5rem', borderRadius: '1rem', border: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1rem', color: 'var(--primary-color)' }}>
<Camera size={24} />
<h4 style={{ margin: 0, fontSize: '1.1rem' }}>{t('policies.data.camera')}</h4>
</div>
<p style={{ opacity: 0.7, fontSize: '0.9rem', lineHeight: '1.5' }}>{t('policies.data.camera.desc')}</p>
</div>
{/* GPS */}
<div style={{ background: 'var(--card-bg, rgba(255,255,255,0.05))', padding: '1.5rem', borderRadius: '1rem', border: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1rem', color: 'var(--primary-color)' }}>
<MapPin size={24} />
<h4 style={{ margin: 0, fontSize: '1.1rem' }}>{t('policies.data.gps')}</h4>
</div>
<p style={{ opacity: 0.7, fontSize: '0.9rem', lineHeight: '1.5' }}>{t('policies.data.gps.desc')}</p>
</div>
{/* Notifications */}
<div style={{ background: 'var(--card-bg, rgba(255,255,255,0.05))', padding: '1.5rem', borderRadius: '1rem', border: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1rem', color: 'var(--primary-color)' }}>
<Bell size={24} />
<h4 style={{ margin: 0, fontSize: '1.1rem' }}>{t('policies.data.notif')}</h4>
</div>
<p style={{ opacity: 0.7, fontSize: '0.9rem', lineHeight: '1.5' }}>{t('policies.data.notif.desc')}</p>
</div>
</div>
</motion.div>
{/* Remaining Sections (Loop) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '2.5rem' }}>
{sections.map((num) => (
<motion.div key={num} variants={sectionVariants} className="policy-section">
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', fontWeight: '700' }}>
{t(`policies.section.${num}.title`)}
</h2>
<p style={{ lineHeight: '1.8', opacity: 0.9, fontSize: '1rem' }}>
{t(`policies.section.${num}.content`)}
</p>
</motion.div>
))}
</div>
{/* Google Policy Button */}
<motion.div variants={sectionVariants} style={{ marginTop: '5rem', textAlign: 'center' }}>
<a
href="https://policies.google.com/privacy"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '10px',
background: 'transparent',
border: '1px solid var(--primary-color)',
color: 'var(--primary-color)',
padding: '12px 30px',
borderRadius: '50px',
textDecoration: 'none',
fontWeight: '600',
transition: 'all 0.3s ease'
}}
className="hover-scale"
>
<ExternalLink size={18} />
{t('policies.googleBtn')}
</a>
</motion.div>
</motion.div>
</div>
</div>
);
};
export default Policies;

View File

@@ -1,9 +1,10 @@
import { motion } from 'framer-motion';
import { ExternalLink, Github, MapPin, Wine } from 'lucide-react';
import { ExternalLink, MapPin, Wine } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
import { Link } from 'react-router-dom';
const Projects = () => {
const { t } = useLanguage();
const { t, language } = useLanguage();
const projects = [
{
id: 1,
@@ -20,8 +21,7 @@ const Projects = () => {
color: "#4CAF50",
icon: <MapPin size={24} />,
links: {
github: "https://github.com/Dayron-HELHa/travel_mate", // Remplacez par votre lien GitHub
demo: "#"
demo: "/travelmate"
},
image: "/travel-mate-preview.png" // Ajoutez votre image dans le dossier public
},
@@ -39,7 +39,6 @@ const Projects = () => {
color: "#E91E63", // A distinct color
icon: <Wine size={24} />,
links: {
github: "#", // Assuming private or not provided
demo: "https://shelbys.be"
},
image: "/shelbys-preview.png" // Placeholder, user might need to add this
@@ -59,7 +58,6 @@ const Projects = () => {
color: "#2196F3",
icon: <ExternalLink size={24} />,
links: {
github: "https://github.com/Dayron-HELHa/xeewy.eu", // Remplacez par votre lien
demo: "https://xeewy.be" // Remplacez par votre lien
}
}
@@ -183,20 +181,20 @@ const Projects = () => {
<div className="project-footer">
<div className="project-links">
{project.links.github !== "#" && (
<motion.a
href={project.links.github}
target="_blank"
rel="noopener noreferrer"
className="project-link"
{project.links.demo !== "#" && (
project.links.demo.startsWith('/') ? (
<Link to={`/${language}${project.links.demo}`} style={{ textDecoration: 'none' }}>
<motion.div
className="project-link primary"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<Github size={20} />
{t('projects.btn.code')}
</motion.a>
)}
{project.links.demo !== "#" && (
<ExternalLink size={20} />
{t('projects.btn.viewProject')}
</motion.div>
</Link>
) : (
<motion.a
href={project.links.demo}
target="_blank"
@@ -208,6 +206,7 @@ const Projects = () => {
<ExternalLink size={20} />
{t('projects.btn.viewProject')}
</motion.a>
)
)}
</div>
</div>

View File

@@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
const ScrollToTop = () => {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo({
top: 0,
left: 0,
behavior: 'instant',
});
}, [pathname]);
return null;
};
export default ScrollToTop;

View File

@@ -0,0 +1,271 @@
import { useEffect } from 'react';
import { motion } from 'framer-motion';
import { useLanguage } from '../contexts/LanguageContext';
import { Link, Outlet } from 'react-router-dom';
import { Shield, Smartphone, Map, DollarSign, Users, Globe, Code } from 'lucide-react';
import appIcon from '../assets/app_icon.png';
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
const FeatureCard = ({ title, icon: Icon, description }: { title: string, icon: any, description: string }) => (
<motion.div
variants={itemVariants}
className="feature-card"
style={{
background: 'var(--card-bg, rgba(255, 255, 255, 0.03))', // Slightly more transparent for glass effect
backdropFilter: 'blur(10px)', // Glassmorphism
padding: '2rem',
borderRadius: '1.5rem',
border: '1px solid var(--border-color, rgba(255, 255, 255, 0.08))',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center', // Center items horizontally
textAlign: 'center', // Center text
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
}}
whileHover={{
y: -5,
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
borderColor: 'var(--primary-color, #4f46e5)'
}}
>
<div style={{
marginBottom: '1.5rem',
background: 'var(--primary-color-alpha, rgba(79, 70, 229, 0.1))',
width: 'fit-content',
padding: '12px',
borderRadius: '12px'
}}>
<Icon size={32} color="var(--primary-color)" />
</div>
<h3 style={{
marginBottom: '1rem',
fontSize: '1.5rem',
fontWeight: '600',
color: 'var(--text-color)' // Explicit color for dark mode
}}>
{title}
</h3>
<p style={{
opacity: 0.8,
lineHeight: '1.7',
flex: 1,
fontSize: '1rem',
color: 'var(--text-color)' // Explicit color
}}>
{description}
</p>
</motion.div>
);
const TravelMate = () => {
const { t, language } = useLanguage();
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2
}
}
};
useEffect(() => {
document.title = "Travel Mate";
}, []);
return (
<div className="travel-mate-page" style={{ paddingTop: '100px', minHeight: '100vh', paddingBottom: '50px' }}>
<div className="container" style={{ maxWidth: '1400px', margin: '0 auto', padding: '0 20px' }}>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Header Section */}
<motion.div variants={itemVariants} style={{ textAlign: 'center', marginBottom: '4rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<motion.img
src={appIcon}
alt="Travel Mate Icon"
style={{ width: '120px', height: '120px', borderRadius: '24px', marginBottom: '2rem', boxShadow: '0 10px 30px rgba(0,0,0,0.2)' }}
whileHover={{ scale: 1.05, rotate: 5 }}
transition={{ type: "spring", stiffness: 300 }}
/>
<h1 className="gradient-text" style={{ fontSize: '4rem', fontWeight: '800', marginBottom: '1rem' }}>
{t('travelmate.page.mainTitle')}
</h1>
<p style={{ fontSize: '1.5rem', opacity: 0.7, maxWidth: '600px', margin: '0 auto 2rem auto' }}>
{t('travelmate.page.subtitle')}
</p>
{/* View Code Button */}
<motion.a
href="https://git.xeewy.be/Xeewy/TravelMate"
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '10px',
textDecoration: 'none',
fontSize: '1.1rem',
padding: '0.8rem 1.5rem',
borderRadius: '50px'
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Code size={20} />
{t('travelmate.viewCode') || "Voir le code"}
</motion.a>
</motion.div>
{/* Description as Intro */}
<motion.div variants={itemVariants} style={{ marginBottom: '5rem', maxWidth: '800px', margin: '0 auto 5rem auto', textAlign: 'center' }}>
<p style={{
lineHeight: '1.8',
fontSize: '1.4rem',
opacity: 0.9,
fontStyle: 'italic',
color: 'var(--text-color)'
}}>
"{t('travelmate.page.intro')}"
</p>
</motion.div>
{/* Highlights Sections */}
<motion.div variants={itemVariants} style={{ marginBottom: '6rem' }}>
<h2 style={{
textAlign: 'center',
marginBottom: '4rem',
fontSize: '2.5rem',
fontWeight: '700',
color: 'var(--text-color)'
}}>{t('travelmate.highlights.title')}</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '2.5rem' }}>
{[1, 2, 3, 4].map((num) => (
<FeatureCard
key={num}
title={t(`travelmate.highlight.${num}.title`)}
description={t(`travelmate.highlight.${num}.desc`)}
icon={
num === 1 ? Users :
num === 2 ? DollarSign :
num === 3 ? Map :
Smartphone
}
/>
))}
</div>
</motion.div>
{/* Conclusion */}
<motion.div variants={itemVariants} style={{ marginBottom: '6rem', textAlign: 'center', maxWidth: '800px', margin: '0 auto 6rem auto' }}>
<p style={{
fontSize: '1.8rem',
fontWeight: 'bold',
color: 'var(--primary-color)',
lineHeight: '1.4'
}}>
{t('travelmate.page.conclusion')}
</p>
</motion.div>
{/* Tech Stack */}
<motion.div variants={itemVariants} style={{ marginBottom: '5rem' }}>
<h2 style={{ textAlign: 'center', marginBottom: '3rem' }}>{t('travelmate.tech.title')}</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '2rem' }}>
{/* Frontend */}
<div style={{ padding: '1.5rem' }}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1.5rem', color: 'var(--primary-color)' }}>
<Smartphone /> {t('travelmate.tech.frontend')}
</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
{[1, 2, 3].map(i => (
<li key={i} style={{ marginBottom: '0.8rem', paddingLeft: '1rem', borderLeft: '2px solid var(--primary-color)' }}>
{t(`travelmate.tech.frontend.${i}`)}
</li>
))}
</ul>
</div>
{/* Backend */}
<div style={{ padding: '1.5rem' }}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1.5rem', color: 'var(--primary-color)' }}>
<Globe /> {t('travelmate.tech.backend')}
</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
{[1, 2, 3, 4].map(i => (
<li key={i} style={{ marginBottom: '0.8rem', paddingLeft: '1rem', borderLeft: '2px solid var(--primary-color)' }}>
{t(`travelmate.tech.backend.${i}`)}
</li>
))}
</ul>
</div>
{/* API */}
<div style={{ padding: '1.5rem' }}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1.5rem', color: 'var(--primary-color)' }}>
<Code /> {t('travelmate.tech.api')}
</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
{[1, 2, 3].map(i => (
<li key={i} style={{ marginBottom: '0.8rem', paddingLeft: '1rem', borderLeft: '2px solid var(--primary-color)' }}>
{t(`travelmate.tech.api.${i}`)}
</li>
))}
</ul>
</div>
</div>
</motion.div>
{/* Policies CTA */}
<motion.div
variants={itemVariants}
style={{
padding: '2rem',
background: 'var(--card-bg, rgba(255, 255, 255, 0.05))',
borderRadius: '1rem',
border: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))',
textAlign: 'center',
maxWidth: '600px',
margin: '0 auto'
}}
>
<Shield className="text-primary" size={48} style={{ marginBottom: '1rem', color: 'var(--primary-color)' }} />
<h3 style={{ marginBottom: '1.5rem' }}>
{t('policies.title')}
</h3>
<Link
to={`/${language}/travelmate/policies`}
className="btn btn-secondary"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '10px',
textDecoration: 'none'
}}
>
{t('travelmate.policies.link')}
</Link>
</motion.div>
</motion.div>
<Outlet />
</div>
</div>
);
};
export default TravelMate;

View File

@@ -2,7 +2,7 @@
// CONTEXTE DE LANGUE
// ========================
import React, { createContext, useContext, useState } from 'react';
import React, { createContext, useContext } from 'react';
import type { ReactNode } from 'react';
// Types pour les langues supportées
@@ -139,9 +139,77 @@ const translations = {
'education.highschool.highlights.1': 'Sciences (Physique, Chimie, Biologie)',
'education.highschool.highlights.2': 'Langues',
// Travel Mate Page
'travelmate.page.mainTitle': 'Travel Mate 🌍',
'travelmate.page.subtitle': 'Le compagnon de voyage indispensable',
'travelmate.page.intro': 'Redécouvrez le voyage en groupe avec Travel Mate. Plus qu\'une simple application, c\'est votre copilote pour des aventures sans friction. Oubliez les tableaux Excel complexes et les débats sur "qui doit combien". Concentrez-vous sur l\'essentiel : créer des souvenirs inoubliables.',
'travelmate.highlights.title': '✨ Points Forts',
'travelmate.highlight.1.title': 'Gestion de Groupe Simplifiée',
'travelmate.highlight.1.desc': 'Créez votre voyage et invitez vos compagnons en un clic via un lien unique. L\'organisation démarre instantanément.',
'travelmate.highlight.2.title': 'Dépenses Maîtrisées (Split)',
'travelmate.highlight.2.desc': 'Suivez les dépenses en temps réel et laissez l\'application équilibrer les comptes automatiquement. Fini les calculs compliqués !',
'travelmate.highlight.3.title': 'Planification Collaborative',
'travelmate.highlight.3.desc': 'Un agenda partagé et une carte interactive pour que chacun puisse proposer et visualiser les activités du groupe.',
'travelmate.highlight.4.title': 'Communication Fluide',
'travelmate.highlight.4.desc': 'Un chat intégré pour centraliser les discussions et garder tout le monde sur la même longueur d\'onde.',
'travelmate.page.conclusion': 'Prêt à partir ? Avec Travel Mate, l\'aventure commence dès l\'organisation. Voyagez l\'esprit léger, on s\'occupe du reste.',
'travelmate.tech.title': '🛠️ Technologies utilisées',
'travelmate.tech.frontend': 'Frontend Mobile (Flutter)',
'travelmate.tech.frontend.1': 'Architecture BLoC pour une gestion d\'état robuste',
'travelmate.tech.frontend.2': 'Interface utilisateur fluide et réactive',
'travelmate.tech.frontend.3': 'Intégration native (Caméra, GPS, Notifications)',
'travelmate.tech.backend': 'Backend (Firebase)',
'travelmate.tech.backend.1': 'Authentication (Google, Apple, Email)',
'travelmate.tech.backend.2': 'Firestore (Base de données temps réel)',
'travelmate.tech.backend.3': 'Cloud Functions (Logique serveur)',
'travelmate.tech.backend.4': 'Storage (Médias)',
'travelmate.tech.api': 'APIs & Outils',
'travelmate.tech.api.1': 'Google Maps API / Mapbox',
'travelmate.tech.api.2': 'Stripe (tbc) pour les paiements',
'travelmate.tech.api.3': 'CI/CD avec Gitea Actions',
'travelmate.viewCode': 'Voir le code',
'travelmate.policies.link': 'Voir les politiques de confidentialité',
// Policies Page
'policies.back': 'Retour',
'policies.title': 'Politiques de confidentialité',
'policies.intro': 'Votre vie privée est importante pour nous. Voici comment nous protégeons vos données.',
'policies.section.1.title': 'Collecte d\'informations',
'policies.section.1.content': 'Nous collectons des informations que vous nous fournissez directement, comme votre nom, adresse e-mail et préférences de voyage.',
'policies.permissions.title': 'Permissions Appareil',
'policies.data.camera': 'Caméra & Galerie',
'policies.data.camera.desc': 'Pour ajouter des photos aux souvenirs ou scanner des factures.',
'policies.data.gps': 'Localisation (GPS)',
'policies.data.gps.desc': 'Pour la carte interactive et les suggestions d\'activités à proximité.',
'policies.data.notif': 'Notifications',
'policies.data.notif.desc': 'Pour vous alerter des nouveaux messages, dépenses ou changements de programme.',
'policies.section.2.title': 'Utilisation des données',
'policies.section.2.content': 'Vos données sont utilisées pour améliorer votre expérience utilisateur et vous proposer des recommandations personnalisées.',
'policies.section.3.title': 'Protection des données',
'policies.section.3.content': 'Nous mettons en place des mesures de sécurité appropriées pour protéger vos informations personnelles.',
'policies.section.4.title': 'Partage des données',
'policies.section.4.content': 'Nous ne partageons pas vos informations personnelles avec des tiers sans votre consentement explicite.',
'policies.section.5.title': 'Vos droits',
'policies.section.5.content': 'Vous avez le droit d\'accéder, de corriger ou de supprimer vos données personnelles à tout moment. Veuillez nous contacter pour toute demande.',
'policies.section.6.title': 'Nous contacter',
'policies.section.6.content': 'Pour toute question concernant cette politique de confidentialité, veuillez nous contacter à support@travelmate.com',
'policies.googleBtn': 'Politique de confidentialité Google',
// Contact
'contact.title': 'Contactez-moi',
'contact.subtitle': 'Une question, un projet ou simplement envie d\'échanger ? N\'hésitez pas à me contacter !',
'contact.subtitle': 'Une question, un projet ou simplement envie de discuter ? N\'hésitez pas à me contacter !',
'contact.form.name': 'Nom complet',
'contact.form.email': 'Email',
'contact.form.subject': 'Sujet',
@@ -150,7 +218,7 @@ const translations = {
'contact.form.sending': 'Envoi en cours...',
'contact.success': 'Message envoyé avec succès ! Je vous répondrai bientôt.',
'contact.stayInTouch': 'Restons en contact',
'contact.intro': 'Je suis toujours intéressé par de nouveaux projets, des collaborations ou simplement des discussions autour de la technologie. N\'hésitez pas à me contacter !',
'contact.intro': 'Je suis toujours intéressé par de nouveaux projets, des collaborations ou simplement des discussions sur la technologie. N\'hésitez pas à me contacter !',
'contact.findMeOn': 'Retrouvez-moi aussi sur :',
'contact.sendMessage': 'Envoyez-moi un message',
},
@@ -276,6 +344,75 @@ const translations = {
'education.highschool.highlights.1': 'Sciences (Physics, Chemistry, Biology)',
'education.highschool.highlights.2': 'Languages',
// Travel Mate Page
'travelmate.page.mainTitle': 'Travel Mate 🌍',
'travelmate.page.subtitle': 'The essential travel companion',
'travelmate.page.intro': 'Rediscover group travel with Travel Mate. More than just an app, it\'s your essential co-pilot for friction-free adventures. Forget complex spreadsheets and debates about "who owes what". Focus on what matters: making unforgettable memories.',
'travelmate.highlights.title': '✨ Key Highlights',
'travelmate.highlight.1.title': 'Simplified Group Management',
'travelmate.highlight.1.desc': 'Create your trip and invite companions with a single click via a unique link. Organization starts instantly.',
'travelmate.highlight.2.title': 'Mastered Expenses (Split)',
'travelmate.highlight.2.desc': 'Track expenses in real-time and let the app balance accounts automatically. No more complicated math!',
'travelmate.highlight.3.title': 'Collaborative Planning',
'travelmate.highlight.3.desc': 'A shared agenda and interactive map so everyone can suggest and visualize group activities.',
'travelmate.highlight.4.title': 'Seamless Communication',
'travelmate.highlight.4.desc': 'An integrated chat to centralize discussions and keep everyone on the same page.',
'travelmate.page.conclusion': 'Ready to go? With Travel Mate, the adventure begins with the planning. Travel with peace of mind, we\'ll handle the rest.',
'travelmate.tech.title': '🛠️ Technologies Used',
'travelmate.tech.frontend': 'Mobile Frontend (Flutter)',
'travelmate.tech.frontend.1': 'BLoC Architecture for robust state management',
'travelmate.tech.frontend.2': 'Fluid and responsive User Interface',
'travelmate.tech.frontend.3': 'Native Integration (Camera, GPS, Notifications)',
'travelmate.tech.backend': 'Backend (Firebase)',
'travelmate.tech.backend.1': 'Authentication (Google, Apple, Email)',
'travelmate.tech.backend.2': 'Firestore (Real-time Database)',
'travelmate.tech.backend.3': 'Cloud Functions (Server Logic)',
'travelmate.tech.backend.4': 'Firebase Cloud Messaging - Notifications push',
'travelmate.tech.backend.5': 'Storage (Media)',
'travelmate.tech.api': 'APIs & Tools',
'travelmate.tech.api.1': 'Google Maps API / Mapbox',
'travelmate.tech.api.2': 'Stripe (tbc) for payments',
'travelmate.tech.api.3': 'CI/CD with Gitea Actions',
'travelmate.viewCode': 'View Code',
'travelmate.policies.link': 'View Privacy Policy',
// Policies Page
'policies.back': 'Back',
'policies.title': 'Privacy Policy',
'policies.intro': 'Your privacy is important to us. Here is how we protect your data.',
'policies.section.1.title': 'Information Collection',
'policies.section.1.content': 'We collect information that you provide directly to us, such as your name, email address, and travel preferences.',
'policies.permissions.title': 'Device Permissions',
'policies.data.camera': 'Camera & Gallery',
'policies.data.camera.desc': 'To add photos to memories or scan receipts.',
'policies.data.gps': 'Location (GPS)',
'policies.data.gps.desc': 'For the interactive map and nearby activity suggestions.',
'policies.data.notif': 'Notifications',
'policies.data.notif.desc': 'To alert you of new messages, expenses, or schedule changes.',
'policies.section.2.title': 'Data Usage',
'policies.section.2.content': 'Your data is used to improve your user experience and offer you personalized recommendations.',
'policies.section.3.title': 'Data Protection',
'policies.section.3.content': 'We implement appropriate security measures to protect your personal information.',
'policies.section.4.title': 'Data Sharing',
'policies.section.4.content': 'We do not share your personal information with third parties without your explicit consent.',
'policies.section.5.title': 'Your Rights',
'policies.section.5.content': 'You have the right to access, correct, or delete your personal data at any time. Please contact us for any request.',
'policies.section.6.title': 'Contact Us',
'policies.section.6.content': 'For any questions regarding this privacy policy, please contact us at support@travelmate.com',
'policies.googleBtn': 'Google Privacy Policy',
// Contact
'contact.title': 'Contact me',
'contact.subtitle': 'A question, a project or just want to chat? Feel free to contact me!',
@@ -301,16 +438,48 @@ interface LanguageProviderProps {
children: ReactNode;
}
import { useNavigate, useParams, useLocation } from 'react-router-dom';
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
const [language, setLanguage] = useState<Language>('fr');
const { lang } = useParams<{ lang: string }>();
const navigate = useNavigate();
const location = useLocation();
// Validate language, default to 'fr' if undefined or invalid (though App.tsx handles defaults)
const currentLanguage: Language = (lang === 'en' || lang === 'fr') ? lang : 'fr';
const setLanguage = (newLang: Language) => {
if (newLang === currentLanguage) return;
// Replace the language segment in the URL
// Assumes the first segment is the language
const currentPath = location.pathname;
const segments = currentPath.split('/');
// segments[0] is empty string (before first slash)
// segments[1] is the language
if (segments[1] === 'fr' || segments[1] === 'en') {
segments[1] = newLang;
} else {
// Should not happen if routing is correct, but safe fallback
segments.splice(1, 0, newLang);
}
const newPath = segments.join('/');
navigate(newPath + location.search + location.hash);
};
// Update HTML lang attribute
React.useEffect(() => {
document.documentElement.lang = currentLanguage;
}, [currentLanguage]);
// Fonction de traduction
const t = (key: string): string => {
return translations[language][key as keyof typeof translations['fr']] || key;
return translations[currentLanguage][key as keyof typeof translations['fr']] || key;
};
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
<LanguageContext.Provider value={{ language: currentLanguage, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);

View File

@@ -1,51 +0,0 @@
import { useState, useEffect } from 'react';
import { LanguageProvider } from './contexts/LanguageContext';
import Header from './components/Header';
import Hero from './components/Hero';
import About from './components/About';
import Skills from './components/Skills';
import Projects from './components/Projects';
import Education from './components/Education';
import Contact from './components/Contact';
import './styles/main.scss';
function App() {
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
const savedTheme = localStorage.getItem('darkMode');
if (savedTheme) {
setDarkMode(JSON.parse(savedTheme));
} else {
// Détection automatique du thème préféré de l'utilisateur
setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches);
}
}, []);
useEffect(() => {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
}, [darkMode]);
const toggleDarkMode = () => {
setDarkMode(!darkMode);
};
return (
<LanguageProvider>
<div className={`app ${darkMode ? 'dark' : 'light'}`}>
<Header darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
<main>
<Hero />
<About />
<Skills />
<Projects />
<Education />
<Contact />
</main>
</div>
</LanguageProvider>
);
}
export default App;