Compare commits

...

10 Commits

Author SHA1 Message Date
cbd57f3140 🗃️ Ajout d'un avis dans la section témoignages 2026-01-25 23:38:54 +01:00
f578bccb59 🔥 Suppression de données non valide sur le portfolio 2026-01-24 04:45:14 +01:00
6934b675c1 🗃️ Ajout d'un avis dans la section testimognials 2026-01-24 04:44:31 +01:00
f7d80a311a 💄 Suppression visuelle du badge Google Recaptchat 2026-01-24 04:43:45 +01:00
db285e2006 🚑️ Ajout du fonctionnement du formulaire de contact en production, utilisation de PHPMailer. 2026-01-24 03:48:37 +01:00
9180f116ec ✉️ Feature: Epic 5 - Formulaire de Contact (Stories 5.1-5.7)
- Formulaire HTML5 avec validation (nom, prénom, email, entreprise, catégorie, objet, message)
- Validation JavaScript côté client (FormValidator)
- Persistance localStorage des données (AppState)
- Intégration reCAPTCHA v3 avec dégradation gracieuse
- Traitement PHP sécurisé (CSRF, validation, envoi email)
- Feedback utilisateur AJAX (succès/erreur)
- Liens contact secondaires (LinkedIn, GitHub, Email protégé)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 01:43:13 +01:00
08402e3ed2 Feature: Epic 4 - Compétences & Me Découvrir (Stories 4.1-4.5)
- Page Compétences: Technologies groupées par catégorie avec compteur projets
- Page Compétences: Outils démontrables avec liens + autres outils avec tooltips
- Page Me Découvrir: Parcours (timeline 4 étapes) + Motivations
- Page Me Découvrir: Passions (musique, gaming, open source)
- Témoignages: JSON dynamique, template réutilisable, section sur about + home
- Fonctions: getToolIcon(), getTestimonials(), getFeaturedTestimonials()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:35:35 +01:00
1711f8f723 Feature: Pages projets complètes + Optimisation images (Stories 3.4-3.6)
Story 3.4 - Page projet individuelle:
- Breadcrumb, header avec badges technologies
- Boutons "Voir en ligne" / "GitHub"
- Sections: Contexte, Solution, Travail d'équipe
- Galerie screenshots, sidebar durée
- Navigation retour + CTA contact

Story 3.5 - Projets secondaires:
- Section "Autres projets" sur /projets
- Template project-card-compact.php
- Format liste avec lien externe direct

Story 3.6 - Optimisation images:
- Fonction projectImage() avec <picture> WebP + fallback JPG
- Dimensions explicites (400x225, 800x450, 1200x675)
- Lazy loading configurable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:59:13 +01:00
013b98ad3c Merge pull request #1 from itsSkycel/claude/write-project-readme-V6vnv
📝 Docs: Rédaction README complet avec méthode B-MAD
2026-01-23 00:13:44 +01:00
Claude
2e9db3c092 📝 Docs: Rédaction README complet avec méthode B-MAD
- Présentation du projet portfolio
- Explication de l'usage de la méthode B-MAD avec ClaudeCode
- Lien vers le dépôt GitHub de la méthode B-MAD
- Documentation complète des fonctionnalités
- Instructions d'installation et de personnalisation
- Structure du projet détaillée
2026-01-22 23:12:05 +00:00
42 changed files with 2733 additions and 63 deletions

203
README.md
View File

@@ -1,3 +1,202 @@
# Portfolio-Skycel # Portfolio Développeur Web - Skycel
Dépot de mon site portfolio personnel en tant que développeur web. Un portfolio moderne et responsive développé en PHP et Tailwind CSS, présentant mes projets, compétences et parcours en tant que développeur web full-stack.
## 🎯 À propos de ce projet
Ce projet a été réalisé comme **test d'application de la méthode B-MAD** (Build with Minimal Autonomous Direction) avec **ClaudeCode**, une approche innovante pour le développement assisté par IA.
La méthode B-MAD permet de créer des applications complètes en définissant des stories structurées et en laissant l'IA gérer l'implémentation technique de manière autonome.
🔗 **En savoir plus sur la méthode B-MAD** : [B-MAD Framework sur GitHub](https://github.com/gclaudedev/b-mad-framework)
## ✨ Fonctionnalités
- **Page d'accueil** : Hero section avec présentation et navigation rapide
- **Projets** : Galerie de projets avec fiches détaillées (contexte, solution, technologies, captures d'écran)
- **Compétences** : Présentation des technologies maîtrisées
- **À propos** : Parcours et motivations
- **Contact** : Formulaire de contact
- **Responsive Design** : Interface optimisée pour mobile, tablette et desktop
- **Navigation fluide** : Menu burger mobile et navbar responsive
- **Routing personnalisé** : Système de routing PHP avec URLs propres
## 🛠️ Stack Technique
### Backend
- **PHP 8.0+** : Langage principal
- **Router personnalisé** : Gestion des routes sans framework
- **Composer** : Gestionnaire de dépendances
### Frontend
- **Tailwind CSS 3.4** : Framework CSS utility-first
- **JavaScript Vanilla** : Interactivité (menu mobile, animations)
- **Design System** : Variables CSS personnalisées pour le theming
### Données
- **JSON** : Stockage des données de projets
- **Structure modulaire** : Séparation templates/pages/includes
### Outils
- **Git** : Versioning
- **npm** : Scripts de build pour Tailwind
- **Apache/Nginx** : Serveur web avec URL rewriting
## 📁 Structure du Projet
```
ClaudeCode-Portfolio/
├── assets/
│ ├── css/ # Styles Tailwind (input/output)
│ ├── images/ # Images et assets
│ └── js/ # Scripts JavaScript
├── data/
│ └── projects.json # Données des projets
├── includes/
│ ├── functions.php # Fonctions helpers
│ └── router.php # Système de routing
├── pages/
│ ├── home.php # Page d'accueil
│ ├── projects.php # Liste des projets
│ ├── project-single.php # Détail d'un projet
│ ├── skills.php # Compétences
│ ├── about.php # À propos
│ ├── contact.php # Contact
│ └── 404.php # Page d'erreur
├── templates/
│ ├── header.php # En-tête HTML
│ ├── navbar.php # Barre de navigation
│ ├── footer.php # Pied de page
│ └── project-card.php # Composant carte projet
├── index.php # Front controller
├── .htaccess # Configuration Apache
├── nginx.conf.example # Configuration Nginx
├── composer.json # Dépendances PHP
└── package.json # Dépendances npm
```
## 🚀 Installation
### Prérequis
- PHP 8.0 ou supérieur
- Composer
- Node.js et npm
- Serveur web (Apache ou Nginx)
### Étapes
1. **Cloner le dépôt**
```bash
git clone https://github.com/Skycel9/Portfolio.git
cd Portfolio
```
2. **Installer les dépendances PHP**
```bash
composer install
```
3. **Installer les dépendances npm**
```bash
npm install
```
4. **Configurer l'environnement**
```bash
cp .env.example .env
# Éditer .env avec vos paramètres
```
5. **Compiler Tailwind CSS**
```bash
# Mode développement (watch)
npm run dev
# Mode production (minifié)
npm run build
```
6. **Configurer le serveur web**
**Apache** : Le fichier `.htaccess` est déjà configuré
**Nginx** : Utiliser `nginx.conf.example` comme base
7. **Accéder au site**
```
http://localhost/Portfolio
```
## 🎨 Personnalisation
### Modifier les projets
Éditer le fichier `data/projects.json` pour ajouter/modifier vos projets :
```json
{
"id": 1,
"title": "Titre du projet",
"slug": "url-friendly-slug",
"category": "vedette",
"thumbnail": "image-thumb.webp",
"url": "https://projet-demo.com",
"github": "https://github.com/user/repo",
"technologies": ["PHP", "React", "MySQL"],
"context": "Description du contexte...",
"solution": "Description de la solution...",
"teamwork": "Informations sur le travail d'équipe",
"duration": "2 mois",
"screenshots": ["screen-1.webp", "screen-2.webp"]
}
```
### Personnaliser les couleurs
Modifier le fichier `tailwind.config.js` pour changer le thème de couleurs.
### Modifier le contenu
Les pages sont dans le dossier `pages/`. Modifier directement le HTML/PHP selon vos besoins.
## 📝 Scripts disponibles
```bash
npm run dev # Compiler Tailwind en mode watch
npm run build # Compiler Tailwind en mode production
```
## 🔧 Configuration Serveur
### Apache
Le fichier `.htaccess` gère automatiquement le URL rewriting.
### Nginx
Utiliser la configuration du fichier `nginx.conf.example` :
- Redirection de toutes les requêtes vers `index.php`
- Gestion des fichiers statiques
- Headers de sécurité
## 📄 Licence
Ce projet est sous licence ISC. Voir le fichier [LICENSE](LICENSE) pour plus de détails.
## 👤 Auteur
**Skycel9**
- GitHub : [@Skycel9](https://github.com/Skycel9)
- Portfolio : [Skycel9/Portfolio](https://github.com/Skycel9/Portfolio)
## 🙏 Remerciements
- **ClaudeCode** pour l'assistance au développement via la méthode B-MAD
- **Tailwind CSS** pour le framework CSS
- La communauté open source pour les outils utilisés
---
*Développé avec ❤️ et la méthode B-MAD*

61
api/contact.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
/**
* Endpoint de traitement du formulaire de contact
*/
require_once __DIR__ . '/../includes/config.php';
require_once __DIR__ . '/../includes/functions.php';
ini_set('display_errors', 1);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
//header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Méthode non autorisée']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Données invalides']);
exit;
}
try {
if (!verifyCsrfToken($input['csrf_token'] ?? '')) {
throw new Exception('Token de sécurité invalide. Veuillez rafraîchir la page.');
}
$recaptchaScore = verifyRecaptcha($input['recaptcha_token'] ?? '');
if ($recaptchaScore < RECAPTCHA_THRESHOLD) {
error_log("reCAPTCHA score trop bas: {$recaptchaScore}");
throw new Exception('Vérification anti-spam échouée. Veuillez réessayer.');
}
$data = validateContactData($input);
$sent = sendContactEmail($data);
if (!$sent) {
throw new Exception('Erreur lors de l\'envoi du message. Veuillez réessayer plus tard.');
}
echo json_encode([
'success' => true,
'message' => 'Votre message a bien été envoyé ! Je vous répondrai dans les meilleurs délais.'
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}

70
api/contact.php.old Normal file
View File

@@ -0,0 +1,70 @@
<?php
/**
* Endpoint de traitement du formulaire de contact
* Reçoit les données en JSON, valide, vérifie reCAPTCHA, envoie l'email
*/
require_once __DIR__ . '/../includes/config.php';
require_once __DIR__ . '/../includes/functions.php';
// Démarrer la session pour le CSRF
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Headers
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
// Uniquement POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Méthode non autorisée']);
exit;
}
// Récupérer les données JSON
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Données invalides']);
exit;
}
try {
// 1. Valider le token CSRF
if (!verifyCsrfToken($input['csrf_token'] ?? '')) {
throw new Exception('Token de sécurité invalide. Veuillez rafraîchir la page.');
}
// 2. Vérifier reCAPTCHA
$recaptchaScore = verifyRecaptcha($input['recaptcha_token'] ?? '');
if ($recaptchaScore < RECAPTCHA_THRESHOLD) {
error_log("reCAPTCHA score trop bas: {$recaptchaScore}");
throw new Exception('Vérification anti-spam échouée. Veuillez réessayer.');
}
// 3. Valider et nettoyer les données
$data = validateContactData($input);
// 4. Envoyer l'email
$sent = sendContactEmail($data);
if (!$sent) {
throw new Exception('Erreur lors de l\'envoi du message. Veuillez réessayer plus tard.');
}
// 5. Succès
echo json_encode([
'success' => true,
'message' => 'Votre message a bien été envoyé ! Je vous répondrai dans les meilleurs délais.'
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}

View File

@@ -46,7 +46,7 @@
} }
.btn-primary { .btn-primary {
@apply btn bg-primary text-background @apply btn bg-primary text-background hover:text-background
hover:bg-primary-light active:bg-primary-dark hover:bg-primary-light active:bg-primary-dark
focus:ring-primary; focus:ring-primary;
} }
@@ -99,7 +99,7 @@
.input { .input {
@apply w-full px-4 py-3 @apply w-full px-4 py-3
bg-surface border border-border rounded-lg bg-surface border border-border rounded-lg
text-text-primary placeholder-text-muted text-text-muted placeholder-text-muted
transition-all duration-150 transition-all duration-150
focus:outline-none focus:border-primary focus:shadow-input-focus; focus:outline-none focus:border-primary focus:shadow-input-focus;
} }
@@ -208,3 +208,11 @@
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/** === Custom CSS === **/
.grecaptcha-badge {
display: none;
visibility: hidden;
opacity: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

561
assets/js/contact-form.js Normal file
View File

@@ -0,0 +1,561 @@
/**
* Validation du formulaire de contact
* JavaScript vanilla - pas de dépendances
*/
/**
* Service reCAPTCHA v3
* Gestion du token avec dégradation gracieuse
*/
const RecaptchaService = {
siteKey: null,
init() {
this.siteKey = window.RECAPTCHA_SITE_KEY || null;
},
isAvailable() {
return this.siteKey && typeof grecaptcha !== 'undefined';
},
/**
* Obtient un token reCAPTCHA
* @param {string} action - Action à valider (ex: 'contact')
* @returns {Promise<string>} - Token ou chaîne vide si indisponible
*/
async getToken(action = 'contact') {
// Dégradation gracieuse si reCAPTCHA non disponible
if (!this.isAvailable()) {
console.warn('reCAPTCHA non disponible, envoi sans protection');
return '';
}
return new Promise((resolve) => {
grecaptcha.ready(() => {
grecaptcha.execute(this.siteKey, { action })
.then(token => resolve(token))
.catch(error => {
console.error('Erreur reCAPTCHA:', error);
resolve(''); // Permettre l'envoi quand même
});
});
});
}
};
class FormValidator {
constructor(formId) {
this.form = document.getElementById(formId);
if (!this.form) return;
this.submitBtn = document.getElementById('submit-btn');
this.submitText = document.getElementById('submit-text');
this.submitLoading = document.getElementById('submit-loading');
this.fields = {};
this.errors = {};
this.init();
}
init() {
// Définir les règles de validation
this.rules = {
nom: {
required: true,
minLength: 2,
maxLength: 100,
message: 'Veuillez entrer votre nom (2 caractères minimum)'
},
prenom: {
required: true,
minLength: 2,
maxLength: 100,
message: 'Veuillez entrer votre prénom (2 caractères minimum)'
},
email: {
required: true,
email: true,
message: 'Veuillez entrer une adresse email valide'
},
categorie: {
required: true,
message: 'Veuillez sélectionner une catégorie'
},
objet: {
required: true,
minLength: 5,
maxLength: 200,
message: 'Veuillez entrer un objet (5 caractères minimum)'
},
message: {
required: true,
minLength: 20,
maxLength: 5000,
message: 'Veuillez entrer votre message (20 caractères minimum)'
}
};
// Récupérer les champs
Object.keys(this.rules).forEach(fieldName => {
this.fields[fieldName] = this.form.querySelector(`[name="${fieldName}"]`);
});
this.bindEvents();
}
bindEvents() {
// Validation au blur
Object.keys(this.fields).forEach(fieldName => {
const field = this.fields[fieldName];
if (field) {
field.addEventListener('blur', () => this.validateField(fieldName));
field.addEventListener('input', () => {
// Effacer l'erreur pendant la saisie
if (this.errors[fieldName]) {
this.clearError(fieldName);
}
});
}
});
// Validation à la soumission
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
// Compteur de caractères pour le message
const messageField = this.fields.message;
if (messageField) {
messageField.addEventListener('input', () => this.updateCharCount());
}
// Bouton reset
this.form.addEventListener('reset', () => {
setTimeout(() => {
this.clearAllErrors();
this.updateCharCount();
}, 10);
});
}
validateField(fieldName) {
const field = this.fields[fieldName];
const rule = this.rules[fieldName];
if (!field || !rule) return true;
const value = field.value.trim();
let isValid = true;
let errorMessage = '';
// Required
if (rule.required && !value) {
isValid = false;
errorMessage = rule.message;
}
// Min length
if (isValid && rule.minLength && value.length < rule.minLength) {
isValid = false;
errorMessage = rule.message;
}
// Max length
if (isValid && rule.maxLength && value.length > rule.maxLength) {
isValid = false;
errorMessage = `Maximum ${rule.maxLength} caractères`;
}
// Email format
if (isValid && rule.email && value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
isValid = false;
errorMessage = rule.message;
}
}
// Afficher ou masquer l'erreur
if (isValid) {
this.clearError(fieldName);
} else {
this.showError(fieldName, errorMessage);
}
this.errors[fieldName] = !isValid;
this.updateSubmitButton();
return isValid;
}
validateAll() {
let allValid = true;
Object.keys(this.rules).forEach(fieldName => {
if (!this.validateField(fieldName)) {
allValid = false;
}
});
return allValid;
}
showError(fieldName, message) {
const field = this.fields[fieldName];
const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`);
if (field) {
field.classList.add('border-red-500', 'focus:ring-red-500/50', 'focus:border-red-500');
field.classList.remove('border-border', 'focus:ring-primary/50', 'focus:border-primary');
field.setAttribute('aria-invalid', 'true');
}
if (errorEl) {
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
}
clearError(fieldName) {
const field = this.fields[fieldName];
const errorEl = this.form.querySelector(`[data-error="${fieldName}"]`);
if (field) {
field.classList.remove('border-red-500', 'focus:ring-red-500/50', 'focus:border-red-500');
field.classList.add('border-border', 'focus:ring-primary/50', 'focus:border-primary');
field.removeAttribute('aria-invalid');
}
if (errorEl) {
errorEl.textContent = '';
errorEl.classList.add('hidden');
}
this.errors[fieldName] = false;
this.updateSubmitButton();
}
clearAllErrors() {
Object.keys(this.rules).forEach(fieldName => {
this.clearError(fieldName);
});
}
updateSubmitButton() {
const hasErrors = Object.values(this.errors).some(err => err);
if (this.submitBtn) {
this.submitBtn.disabled = hasErrors;
}
}
updateCharCount() {
const messageField = this.fields.message;
const countEl = document.getElementById('message-count');
if (messageField && countEl) {
countEl.textContent = messageField.value.length;
}
}
setLoading(isLoading) {
if (this.submitBtn) {
this.submitBtn.disabled = isLoading;
}
if (this.submitText) {
this.submitText.classList.toggle('hidden', isLoading);
}
if (this.submitLoading) {
this.submitLoading.classList.toggle('hidden', !isLoading);
if (isLoading) {
this.submitLoading.classList.add('flex');
} else {
this.submitLoading.classList.remove('flex');
}
}
}
handleSubmit(e) {
e.preventDefault();
if (!this.validateAll()) {
// Focus sur le premier champ en erreur
const firstError = Object.keys(this.errors).find(key => this.errors[key]);
if (firstError && this.fields[firstError]) {
this.fields[firstError].focus();
}
return;
}
// Si valide, déclencher l'envoi
this.form.dispatchEvent(new CustomEvent('validSubmit', {
detail: this.getFormData()
}));
}
getFormData() {
const formData = {};
Object.keys(this.fields).forEach(fieldName => {
if (this.fields[fieldName]) {
formData[fieldName] = this.fields[fieldName].value.trim();
}
});
// Ajouter le champ entreprise (optionnel)
const entreprise = this.form.querySelector('[name="entreprise"]');
if (entreprise) {
formData.entreprise = entreprise.value.trim();
}
// Ajouter le token CSRF
const csrfToken = this.form.querySelector('[name="csrf_token"]');
if (csrfToken) {
formData.csrf_token = csrfToken.value;
}
return formData;
}
}
/**
* Gestionnaire de persistance du formulaire
* Sauvegarde/restaure les données via localStorage
*/
class ContactFormPersistence {
constructor(formId) {
this.form = document.getElementById(formId);
if (!this.form) return;
this.debounceTimer = null;
this.init();
}
init() {
this.loadSavedData();
this.bindEvents();
}
bindEvents() {
// Sauvegarder à chaque modification (avec debounce de 500ms)
this.form.addEventListener('input', () => {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.saveData(), 500);
});
// Bouton effacer
const clearBtn = document.getElementById('clear-form-btn');
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.preventDefault();
this.clearForm();
});
}
// Écouter l'envoi réussi (dispatché par le handler AJAX)
this.form.addEventListener('formSuccess', () => {
AppState.clearFormData();
});
}
saveData() {
const formData = new FormData(this.form);
const data = {};
formData.forEach((value, key) => {
data[key] = value;
});
AppState.saveFormData(data);
}
loadSavedData() {
const savedData = AppState.getFormData();
if (!savedData) return;
Object.keys(savedData).forEach(key => {
const field = this.form.querySelector(`[name="${key}"]`);
if (field && savedData[key]) {
field.value = savedData[key];
}
});
// Mettre à jour le compteur de caractères
const messageField = this.form.querySelector('[name="message"]');
const countEl = document.getElementById('message-count');
if (messageField && countEl) {
countEl.textContent = messageField.value.length;
}
}
clearForm() {
// Confirmation
if (!confirm('Êtes-vous sûr de vouloir effacer le formulaire ?')) {
return;
}
// Vider le localStorage
AppState.clearFormData();
// Réinitialiser le formulaire
this.form.reset();
// Réinitialiser le compteur
const countEl = document.getElementById('message-count');
if (countEl) {
countEl.textContent = '0';
}
// Effacer les erreurs visuelles
this.form.querySelectorAll('.border-red-500').forEach(el => {
el.classList.remove('border-red-500', 'focus:ring-red-500/50', 'focus:border-red-500');
el.classList.add('border-border', 'focus:ring-primary/50', 'focus:border-primary');
el.removeAttribute('aria-invalid');
});
this.form.querySelectorAll('[data-error]').forEach(el => {
el.classList.add('hidden');
el.textContent = '';
});
// Réactiver le bouton submit
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.disabled = false;
}
}
}
/**
* Gestionnaire d'envoi AJAX du formulaire
* Gère le loading, succès et erreurs
*/
class ContactFormSubmit {
constructor(formId) {
this.form = document.getElementById(formId);
if (!this.form) return;
this.submitBtn = document.getElementById('submit-btn');
this.submitText = document.getElementById('submit-text');
this.submitLoading = document.getElementById('submit-loading');
this.successMessage = document.getElementById('success-message');
this.errorMessage = document.getElementById('error-message');
this.errorText = document.getElementById('error-text');
this.isSubmitting = false;
this.init();
}
init() {
// Écouter l'événement de validation réussie
this.form.addEventListener('validSubmit', (e) => this.handleSubmit(e.detail));
}
async handleSubmit(formData) {
if (this.isSubmitting) return;
this.setLoadingState(true);
this.hideMessages();
try {
// Ajouter le token CSRF
const csrfInput = this.form.querySelector('[name="csrf_token"]');
if (csrfInput) {
formData.csrf_token = csrfInput.value;
}
// Obtenir le token reCAPTCHA
formData.recaptcha_token = await RecaptchaService.getToken('contact');
// Envoyer la requête
const response = await fetch('/api/contact.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.success) {
this.handleSuccess(result.message);
} else {
this.handleError(result.error || 'Une erreur est survenue');
}
} catch (error) {
console.error('Erreur envoi formulaire:', error);
this.handleError('Impossible de contacter le serveur. Vérifiez votre connexion.');
} finally {
this.setLoadingState(false);
}
}
setLoadingState(loading) {
this.isSubmitting = loading;
if (this.submitBtn) {
this.submitBtn.disabled = loading;
}
if (this.submitText && this.submitLoading) {
if (loading) {
this.submitText.classList.add('hidden');
this.submitLoading.classList.remove('hidden');
this.submitLoading.classList.add('flex');
} else {
this.submitText.classList.remove('hidden');
this.submitLoading.classList.add('hidden');
this.submitLoading.classList.remove('flex');
}
}
}
handleSuccess(message) {
// Masquer le formulaire
this.form.classList.add('hidden');
// Afficher le message de succès
if (this.successMessage) {
this.successMessage.classList.remove('hidden');
// Scroll vers le message
this.successMessage.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Vider le localStorage
AppState.clearFormData();
// Réinitialiser le formulaire (pour un éventuel nouvel envoi)
this.form.reset();
// Déclencher l'événement de succès
this.form.dispatchEvent(new CustomEvent('formSuccess'));
}
handleError(message) {
// Afficher le message d'erreur
if (this.errorMessage && this.errorText) {
this.errorText.textContent = message;
this.errorMessage.classList.remove('hidden');
// Scroll vers le message
this.errorMessage.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Les données sont conservées dans le formulaire pour réessayer
}
hideMessages() {
if (this.successMessage) {
this.successMessage.classList.add('hidden');
}
if (this.errorMessage) {
this.errorMessage.classList.add('hidden');
}
}
}
// Initialisation
document.addEventListener('DOMContentLoaded', () => {
RecaptchaService.init();
window.contactFormValidator = new FormValidator('contact-form');
window.contactFormPersistence = new ContactFormPersistence('contact-form');
window.contactFormSubmit = new ContactFormSubmit('contact-form');
});

View File

@@ -2,9 +2,9 @@
// Script principal du portfolio // Script principal du portfolio
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
console.log('Portfolio chargé');
initMobileMenu(); initMobileMenu();
initNavbarScroll(); initNavbarScroll();
initEmailProtection();
}); });
/** /**
@@ -85,3 +85,21 @@ function initNavbarScroll() {
} }
}, { passive: true }); }, { passive: true });
} }
/**
* Protection de l'email contre le scraping
* Reconstruit l'adresse email à partir de data-attributes
*/
function initEmailProtection() {
const emailLink = document.getElementById('email-link');
if (!emailLink) return;
const user = emailLink.dataset.user;
const domain = emailLink.dataset.domain;
if (user && domain) {
const email = `${user}@${domain}`;
emailLink.href = `mailto:${email}`;
emailLink.title = email;
}
}

76
assets/js/state.js Normal file
View File

@@ -0,0 +1,76 @@
/**
* Gestionnaire d'état pour le localStorage
* Persiste les données du formulaire de contact
*/
const AppState = {
STORAGE_KEY: 'portfolio_contact_form',
// Champs à ne jamais stocker (sécurité)
EXCLUDED_FIELDS: ['csrf_token', 'password', 'recaptcha_token'],
/**
* Vérifie si localStorage est disponible
*/
isStorageAvailable() {
try {
const test = '__storage_test__';
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch (e) {
return false;
}
},
/**
* Sauvegarde les données du formulaire
* @param {Object} data - Données du formulaire
*/
saveFormData(data) {
if (!this.isStorageAvailable()) return;
try {
// Filtrer les champs exclus
const filteredData = {};
Object.keys(data).forEach(key => {
if (!this.EXCLUDED_FIELDS.includes(key)) {
filteredData[key] = data[key];
}
});
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filteredData));
} catch (e) {
console.warn('Impossible de sauvegarder dans localStorage:', e);
}
},
/**
* Charge les données sauvegardées
* @returns {Object|null} Données ou null si absentes
*/
getFormData() {
if (!this.isStorageAvailable()) return null;
try {
const data = localStorage.getItem(this.STORAGE_KEY);
return data ? JSON.parse(data) : null;
} catch (e) {
console.warn('Impossible de charger depuis localStorage:', e);
return null;
}
},
/**
* Efface les données sauvegardées
*/
clearFormData() {
if (!this.isStorageAvailable()) return;
try {
localStorage.removeItem(this.STORAGE_KEY);
} catch (e) {
// Silencieux
}
}
};

View File

@@ -4,6 +4,7 @@
"type": "project", "type": "project",
"require": { "require": {
"php": ">=8.0", "php": ">=8.0",
"vlucas/phpdotenv": "^5.6" "vlucas/phpdotenv": "^5.6",
"phpmailer/phpmailer": "^7.0"
} }
} }

84
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "adbddd7a48b14ed78896b2d6c5ef28e9", "content-hash": "ef9466a44690e608fe2d148c314ef38c",
"packages": [ "packages": [
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
@@ -68,6 +68,88 @@
], ],
"time": "2025-12-27T19:43:20+00:00" "time": "2025-12-27T19:43:20+00:00"
}, },
{
"name": "phpmailer/phpmailer",
"version": "v7.0.2",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088",
"reference": "ebf1655bd5b99b3f97e1a3ec0a69e5f4cd7ea088",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-filter": "*",
"ext-hash": "*",
"php": ">=5.5.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^10.0.0@dev",
"squizlabs/php_codesniffer": "^3.13.5",
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example",
"ext-imap": "Needed to support advanced email address parsing according to RFC822",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
"psr/log": "For optional PSR-3 debug logging",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
},
"type": "library",
"autoload": {
"psr-4": {
"PHPMailer\\PHPMailer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-only"
],
"authors": [
{
"name": "Marcus Bointon",
"email": "phpmailer@synchromedia.co.uk"
},
{
"name": "Jim Jagielski",
"email": "jimjag@gmail.com"
},
{
"name": "Andy Prevost",
"email": "codeworxtech@users.sourceforge.net"
},
{
"name": "Brent R. Matzelle"
}
],
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
"source": "https://github.com/PHPMailer/PHPMailer/tree/v7.0.2"
},
"funding": [
{
"url": "https://github.com/Synchro",
"type": "github"
}
],
"time": "2026-01-09T18:02:33+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.5", "version": "1.9.5",

View File

@@ -2,17 +2,17 @@
"projects": [ "projects": [
{ {
"id": 1, "id": 1,
"title": "Site E-commerce XYZ", "title": "Aéroclub de Saint-Dié-des-Vosges - Site association d'aéronotique",
"slug": "ecommerce-xyz", "slug": "acsaintdie",
"category": "vedette", "category": "vedette",
"thumbnail": "ecommerce-xyz-thumb.webp", "thumbnail": "acsaintdie-thumb.webp",
"url": "https://example.com", "url": "https://acsaintdie.fr",
"github": "https://github.com/user/project", "github": "",
"technologies": ["PHP", "JavaScript", "Tailwind CSS", "MySQL"], "technologies": ["PHP", "JavaScript", "MySQL", "Wordpress"],
"context": "Client souhaitant moderniser sa boutique en ligne pour améliorer l'expérience utilisateur et augmenter les conversions.", "context": "Dans le cadre de mon alternance chez kiwi-studio, un client a souhaité une refonte complète de son site internet. Le tout en ayant la possibilité d'ajouter du contenu facilement, pour ce faire, il a choisi Wordpress.",
"solution": "Développement d'une solution e-commerce sur mesure avec panier persistant, paiement sécurisé Stripe, et interface d'administration.", "solution": "Développement d'un thème personnalisé pour Wordpress, intégrant différents plugin custom pour la gestion de la galerie ou des différents type de post.",
"teamwork": "Projet réalisé en collaboration avec un designer UI/UX. J'ai pris en charge l'intégration et le développement backend.", "teamwork": "Projet réalisé en collaboration avec un designer UI/UX. J'ai pris en charge l'intégration et le développement backend.",
"duration": "3 mois", "duration": "5 mois",
"screenshots": [ "screenshots": [
"ecommerce-xyz-screen-1.webp", "ecommerce-xyz-screen-1.webp",
"ecommerce-xyz-screen-2.webp", "ecommerce-xyz-screen-2.webp",

59
data/testimonials.json Normal file
View File

@@ -0,0 +1,59 @@
{
"testimonials": [
{
"id": 1,
"quote": "Excellent travail ! Le site a été livré dans les délais avec une qualité irréprochable. Communication fluide tout au long du projet.",
"author_name": "Marie Dupont",
"author_role": "Directrice Marketing",
"author_company": "Entreprise XYZ",
"author_photo": null,
"project_slug": "ecommerce-xyz",
"date": "2025-06-15",
"featured": true
},
{
"id": 2,
"quote": "Un développeur rigoureux et créatif. Il a su comprendre nos besoins et proposer des solutions adaptées à notre budget.",
"author_name": "Jean Martin",
"author_role": "CEO",
"author_company": "Startup ABC",
"author_photo": null,
"project_slug": "app-gestion",
"date": "2025-03-20",
"featured": true
},
{
"id": 3,
"quote": "Travail soigné et professionnel. Je recommande vivement pour tout projet web.",
"author_name": "Sophie Leroy",
"author_role": "Gérante",
"author_company": "Restaurant Le Bon Goût",
"author_photo": null,
"project_slug": null,
"date": "2024-11-10",
"featured": false
},
{
"id": 4,
"quote": "Célian travaille avec passion. Il a toujours su structurer les tâches qu'on lui confie et s'organise pour mener à bien les missions. Doué d'un bon sens de l'humour, il sait également prendre conscience des impératifs et du sérieux nécessaire au bon fonctionnement des équipes",
"author_name": "Pierre Florentin",
"author_role": "Gérant",
"author_company": "Kiwi-Studio",
"author_photo": "pierre-florentin-pp.webp",
"project_slug": "acsaintdie",
"date": "2026-01-21",
"featured": false
},
{
"id": 5,
"quote": "Cela fait maintenant 3 ans que j'utilise les services d'hébergement d'Araneite. J'utilise l'adresse mail de manière professionnelle et l'hébergement pour me faire un site portfolio. Je n'ai rien à redire sur le service. J'ai accès à tous mes identifiants pour avoir un contrôle autonome. En 3 ans, je n'ai eu qu'un problème de connexion au serveur, et celui-ci a été réglé très rapidement par l'administrateur. Le service est peu cher et suffit amplement pour avoir une présence en ligne. j'en suis très content.",
"author_name": "Corentin Adam",
"author_role": "Freelance - Motion design / VFX / audiovisuel",
"author_company": "",
"author_photo": "corentin-adam-pp.webp",
"project_slug": "",
"date": "2024-04-25",
"featured": false
}
]
}

71
includes/config.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
/**
* Configuration de l'application
* Charge les variables d'environnement depuis .env
*/
/**
* Charge un fichier .env et définit les variables d'environnement
* @param string $path Chemin vers le fichier .env
*/
function loadEnv(string $path): void
{
if (!file_exists($path)) {
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
// Ignorer les commentaires
if (str_starts_with(trim($line), '#')) {
continue;
}
// Parser KEY=value
if (str_contains($line, '=')) {
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
// Supprimer les guillemets si présents
if (preg_match('/^["\'](.*)["\']\s*$/', $value, $matches)) {
$value = $matches[1];
}
$_ENV[$key] = $value;
putenv("{$key}={$value}");
}
}
}
// Charger le fichier .env
loadEnv(__DIR__ . '/../.env');
// Définir les constantes de configuration
define('APP_ENV', $_ENV['APP_ENV'] ?? 'production');
define('APP_DEBUG', ($_ENV['APP_DEBUG'] ?? 'false') === 'true');
define('APP_URL', $_ENV['APP_URL'] ?? 'http://localhost');
// reCAPTCHA v3
define('RECAPTCHA_SITE_KEY', $_ENV['RECAPTCHA_SITE_KEY'] ?? '');
define('RECAPTCHA_SECRET_KEY', $_ENV['RECAPTCHA_SECRET_KEY'] ?? '');
define('RECAPTCHA_THRESHOLD', 0.5); // Score minimum (0.0 à 1.0)
// Contact
define('CONTACT_EMAIL', $_ENV['CONTACT_EMAIL'] ?? '');
// SMTP
define('SMTP_HOST', $_ENV['SMTP_HOST'] ?? '127.0.0.1');
define('SMTP_PORT', (int) $_ENV['SMTP_PORT'] ?? '1025');
define('SMTP_USERNAME', $_ENV['SMTP_USERNAME'] ?? '');
define('SMTP_PASSWORD', $_ENV['SMTP_PASSWORD'] ?? '');
define('SMTP_ENCRYPTION', $_ENV['SMTP_ENCRYPTION'] ?? 'none'); // none|tls|ssl
define('MAIL_FROM_ADDRESS', $_ENV['MAIL_FROM_ADDRESS'] ?? SMTP_USERNAME);
define('MAIL_FROM_NAME', $_ENV['MAIL_FROM_NAME'] ?? 'Portfolio - Contact');
// Sécurité
define('APP_SECRET', $_ENV['APP_SECRET'] ?? '');

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,7 @@
* Front Controller - Point d'entrée unique * Front Controller - Point d'entrée unique
*/ */
require_once __DIR__ . '/includes/config.php';
require_once __DIR__ . '/includes/functions.php'; require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/router.php'; require_once __DIR__ . '/includes/router.php';

View File

@@ -1,10 +1,10 @@
<?php <?php
/** /**
* Page à propos * Page Me Découvrir
*/ */
$pageTitle = 'À propos'; $pageTitle = 'Me Découvrir';
$pageDescription = 'Découvrez mon parcours, mes motivations et ce qui me passionne.'; $pageDescription = 'Découvrez mon parcours, mes motivations et ce qui me passionne en tant que développeur web.';
$currentPage = 'a-propos'; $currentPage = 'a-propos';
include_template('header', compact('pageTitle', 'pageDescription')); include_template('header', compact('pageTitle', 'pageDescription'));
@@ -12,19 +12,242 @@ include_template('navbar', compact('currentPage'));
?> ?>
<main> <main>
<!-- Section Hero / Qui je suis -->
<section class="section"> <section class="section">
<div class="container-content">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<!-- Photo -->
<div class="order-2 lg:order-1">
<div class="aspect-square max-w-md mx-auto lg:mx-0 rounded-2xl overflow-hidden bg-surface-alt">
<!-- Placeholder si pas de photo -->
<div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/20 to-primary/5">
<svg class="w-32 h-32 text-primary/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
</div>
</div>
<!-- Texte -->
<div class="order-1 lg:order-2">
<h1 class="text-3xl lg:text-display font-bold text-text-primary mb-6">
Bonjour, je suis <span class="text-primary">Célian</span>
</h1>
<p class="text-xl text-text-secondary mb-6 leading-relaxed">
Développeur web passionné basé dans le <strong class="text-text-primary">Grand Est, France</strong>.
Je crée des expériences numériques qui allient performance,
accessibilité et design soigné.
</p>
<p class="text-text-secondary leading-relaxed">
Je transforme des idées en solutions web concrètes.
Mon approche : comprendre les besoins, proposer des solutions pragmatiques,
et livrer un travail dont je suis fier.
</p>
</div>
</div>
</div>
</section>
<!-- Mon Parcours -->
<section class="section bg-surface">
<div class="container-content"> <div class="container-content">
<div class="section-header"> <div class="section-header">
<h1 class="section-title">À propos</h1> <h2 class="section-title">Mon Parcours</h2>
</div>
<div class="max-w-3xl mx-auto">
<!-- Timeline -->
<div class="space-y-8">
<!-- Étape 1 -->
<div class="flex gap-6">
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
<span class="text-primary font-bold">1</span>
</div>
<div>
<h3 class="text-lg font-semibold text-text-primary mb-2">Découverte du code</h3>
<p class="text-text-secondary leading-relaxed">
Mes premiers pas dans le développement, par curiosité et passion pour la technologie.
J'ai commencé par des projets personnels, apprenant HTML, CSS et JavaScript.
</p>
</div>
</div>
<!-- Étape 2 -->
<div class="flex gap-6">
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
<span class="text-primary font-bold">2</span>
</div>
<div>
<h3 class="text-lg font-semibold text-text-primary mb-2">Formation et spécialisation</h3>
<p class="text-text-secondary leading-relaxed">
Approfondissement des compétences à travers une formation dédiée.
Découverte du backend avec PHP, des bases de données et des bonnes pratiques de développement.
</p>
</div>
</div>
<!-- Étape 3 -->
<div class="flex gap-6">
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
<span class="text-primary font-bold">3</span>
</div>
<div>
<h3 class="text-lg font-semibold text-text-primary mb-2">Projets concrets</h3>
<p class="text-text-secondary leading-relaxed">
Réalisation de projets pour des clients et des projets personnels.
Chaque projet est une opportunité d'apprendre et de s'améliorer.
</p>
</div>
</div>
<!-- Étape 4 -->
<div class="flex gap-6">
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary flex items-center justify-center">
<span class="text-white font-bold">4</span>
</div>
<div>
<h3 class="text-lg font-semibold text-text-primary mb-2">Aujourd'hui</h3>
<p class="text-text-secondary leading-relaxed">
Je continue à me former et à explorer de nouvelles technologies.
Mon objectif : créer des sites web performants, accessibles et agréables à utiliser.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Pourquoi ce métier -->
<section class="section">
<div class="container-content">
<div class="max-w-3xl mx-auto text-center">
<h2 class="text-2xl lg:text-heading font-bold text-text-primary mb-8">Pourquoi le Développement Web ?</h2>
<div class="space-y-6 text-text-secondary text-lg leading-relaxed">
<p>
Ce qui me passionne dans le développement, c'est la possibilité de
<strong class="text-text-primary">créer quelque chose à partir de rien</strong>.
Une idée, du code, et soudain un site web existe et aide des gens.
</p>
<p>
J'aime particulièrement le challenge de rendre les choses
<strong class="text-text-primary">simples pour l'utilisateur</strong>,
même quand elles sont complexes sous le capot.
</p>
<p>
Mon objectif : livrer un travail dont je suis fier, avec des solutions
qui durent dans le temps et qui sont agréables à utiliser.
</p>
</div>
</div>
</div>
</section>
<!-- En dehors du code -->
<section class="section bg-surface">
<div class="container-content">
<div class="section-header">
<h2 class="section-title">En Dehors du Code</h2>
<p class="section-subtitle"> <p class="section-subtitle">
Mon parcours et mes motivations Parce qu'un développeur a aussi une vie en dehors de l'écran.
</p> </p>
</div> </div>
<p class="text-text-secondary text-center"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
Page en construction - Epic 4 <!-- Passion 1 : Musique -->
<div class="card group overflow-hidden">
<div class="aspect-video overflow-hidden bg-gradient-to-br from-purple-500/20 to-pink-500/20 flex items-center justify-center">
<svg class="w-16 h-16 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
</div>
<div class="card-body">
<h3 class="text-lg font-semibold text-text-primary mb-2">Musique</h3>
<p class="text-text-secondary text-sm">
Passionné par la création musicale et la MAO.
La musique développe la créativité et la rigueur, des qualités que j'applique aussi dans le code.
</p> </p>
</div> </div>
</div>
<!-- Passion 2 : Gaming -->
<div class="card group overflow-hidden">
<div class="aspect-video overflow-hidden bg-gradient-to-br from-green-500/20 to-cyan-500/20 flex items-center justify-center">
<svg class="w-16 h-16 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"/>
</svg>
</div>
<div class="card-body">
<h3 class="text-lg font-semibold text-text-primary mb-2">Jeux vidéo</h3>
<p class="text-text-secondary text-sm">
Le gaming a éveillé ma curiosité pour l'informatique.
Aujourd'hui, c'est aussi une source d'inspiration pour l'UX et le game design.
</p>
</div>
</div>
<!-- Passion 3 : Projets Open Source -->
<div class="card group overflow-hidden">
<div class="aspect-video overflow-hidden bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
<svg class="w-16 h-16 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
</div>
<div class="card-body">
<h3 class="text-lg font-semibold text-text-primary mb-2">Projets Open Source</h3>
<p class="text-text-secondary text-sm mb-3">
Je développe des projets personnels et explore de nouvelles technologies sur mon temps libre.
</p>
<a href="https://github.com/skycel" target="_blank" rel="noopener" class="text-primary text-sm hover:underline inline-flex items-center gap-1">
Voir sur GitHub
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
</div>
</div>
</div>
</div>
</section>
<!-- Témoignages -->
<?php $testimonials = getTestimonials(); ?>
<?php if (!empty($testimonials)): ?>
<section class="section">
<div class="container-content">
<div class="section-header">
<h2 class="section-title">Ce Qu'ils Disent</h2>
<p class="section-subtitle">
Retours de clients et collaborateurs.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<?php foreach ($testimonials as $testimonial): ?>
<?php include_template('testimonial', ['testimonial' => $testimonial, 'showProjectLink' => true]); ?>
<?php endforeach; ?>
</div>
</div>
</section>
<?php endif; ?>
<!-- CTA -->
<section class="section bg-surface">
<div class="container-content text-center">
<h2 class="text-2xl lg:text-heading font-bold text-text-primary mb-4">Envie d'en savoir plus ?</h2>
<p class="text-text-secondary mb-8">
Découvrez mes réalisations ou contactez-moi directement.
</p>
<div class="flex flex-wrap justify-center gap-4">
<a href="/projets" class="btn-primary">Voir mes projets</a>
<a href="/contact" class="btn-secondary">Me contacter</a>
</div>
</div>
</section> </section>
</main> </main>

View File

@@ -1,12 +1,15 @@
<?php <?php
/** /**
* Page contact * Page Contact
*/ */
$pageTitle = 'Contact'; $pageTitle = 'Contact';
$pageDescription = 'Contactez-moi pour discuter de votre projet web.'; $pageDescription = 'Contactez-moi pour discuter de votre projet web ou d\'une opportunité professionnelle.';
$currentPage = 'contact'; $currentPage = 'contact';
// Générer le token CSRF
$csrfToken = generateCsrfToken();
include_template('header', compact('pageTitle', 'pageDescription')); include_template('header', compact('pageTitle', 'pageDescription'));
include_template('navbar', compact('currentPage')); include_template('navbar', compact('currentPage'));
?> ?>
@@ -14,18 +17,260 @@ include_template('navbar', compact('currentPage'));
<main> <main>
<section class="section"> <section class="section">
<div class="container-content"> <div class="container-content">
<div class="section-header"> <div class="max-w-2xl mx-auto">
<h1 class="section-title">Contact</h1> <!-- Header -->
<p class="section-subtitle"> <div class="text-center mb-12">
Discutons de votre projet <h1 class="text-3xl lg:text-display font-bold text-text-primary mb-4">Me Contacter</h1>
<p class="text-xl text-text-secondary">
Une question, un projet ? Parlons-en !
</p> </p>
</div> </div>
<p class="text-text-secondary text-center"> <!-- Formulaire -->
Page en construction - Epic 5 <form
id="contact-form"
method="POST"
action="/api/contact.php"
class="space-y-6"
>
<!-- Token CSRF -->
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken) ?>">
<!-- Nom & Prénom (côte à côte sur desktop) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<!-- Nom -->
<div>
<label for="nom" class="block text-sm font-medium text-text-primary mb-2">
Nom <span class="text-primary">*</span>
</label>
<input
type="text"
id="nom"
name="nom"
class="w-full px-4 py-3 bg-surface-alt border border-border rounded-lg text-text-muted placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
required
maxlength="100"
autocomplete="family-name"
placeholder="Dupont"
>
<p class="text-error text-sm mt-1 hidden" data-error="nom"></p>
</div>
<!-- Prénom -->
<div>
<label for="prenom" class="block text-sm font-medium text-text-primary mb-2">
Prénom <span class="text-primary">*</span>
</label>
<input
type="text"
id="prenom"
name="prenom"
class="w-full px-4 py-3 bg-surface-alt border border-border rounded-lg text-text-muted placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
required
maxlength="100"
autocomplete="given-name"
placeholder="Marie"
>
<p class="text-error text-sm mt-1 hidden" data-error="prenom"></p>
</div>
</div>
<!-- Email & Entreprise -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-text-primary mb-2">
Email <span class="text-primary">*</span>
</label>
<input
type="email"
id="email"
name="email"
class="w-full px-4 py-3 bg-surface-alt border border-border rounded-lg text-text-muted placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
required
maxlength="255"
autocomplete="email"
placeholder="marie.dupont@example.com"
>
<p class="text-error text-sm mt-1 hidden" data-error="email"></p>
</div>
<!-- Entreprise (optionnel) -->
<div>
<label for="entreprise" class="block text-sm font-medium text-text-primary mb-2">
Entreprise <span class="text-text-muted">(optionnel)</span>
</label>
<input
type="text"
id="entreprise"
name="entreprise"
class="w-full px-4 py-3 bg-surface-alt border border-border rounded-lg text-text-muted placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
maxlength="200"
autocomplete="organization"
placeholder="Nom de votre entreprise"
>
</div>
</div>
<!-- Catégorie -->
<div>
<label for="categorie" class="block text-sm font-medium text-text-primary mb-2">
Catégorie <span class="text-primary">*</span>
</label>
<select
id="categorie"
name="categorie"
class="w-full px-4 py-3 bg-surface-alt border border-border rounded-lg text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
required
>
<option value="" disabled selected>Sélectionnez une catégorie...</option>
<option value="projet">Je souhaite parler de mon projet</option>
<option value="poste">Je souhaite vous proposer un poste</option>
<option value="autre">J'ai une autre idée en tête</option>
</select>
<p class="text-error text-sm mt-1 hidden" data-error="categorie"></p>
</div>
<!-- Objet -->
<div>
<label for="objet" class="block text-sm font-medium text-text-primary mb-2">
Objet <span class="text-primary">*</span>
</label>
<input
type="text"
id="objet"
name="objet"
class="w-full px-4 py-3 bg-surface-alt border border-border rounded-lg text-text-muted placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
required
maxlength="200"
placeholder="Résumez votre demande en quelques mots"
>
<p class="text-error text-sm mt-1 hidden" data-error="objet"></p>
</div>
<!-- Message -->
<div>
<label for="message" class="block text-sm font-medium text-text-primary mb-2">
Message <span class="text-primary">*</span>
</label>
<textarea
id="message"
name="message"
class="w-full px-4 py-3 bg-surface-alt border border-border rounded-lg text-text-muted placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors resize-y min-h-[150px]"
required
maxlength="5000"
rows="6"
placeholder="Décrivez votre projet ou votre demande..."
></textarea>
<p class="text-error text-sm mt-1 hidden" data-error="message"></p>
<p class="text-xs text-text-muted mt-1">
<span id="message-count">0</span> / 5000 caractères
</p> </p>
</div> </div>
<!-- Boutons -->
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<button type="submit" id="submit-btn" class="btn-primary flex-1 justify-center">
<span id="submit-text">Envoyer le message</span>
<span id="submit-loading" class="hidden items-center gap-2">
<svg class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Envoi en cours...
</span>
</button>
<button type="button" id="clear-form-btn" class="btn-secondary">
Effacer
</button>
</div>
</form>
<!-- Message de succès (caché par défaut) -->
<div id="success-message" class="hidden mt-8 p-6 bg-green-500/10 border border-green-500/30 rounded-lg text-center">
<svg class="w-12 h-12 text-green-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="text-lg font-semibold text-text-primary mb-2">Message envoyé avec succès !</h3>
<p class="text-text-secondary mb-4">
Merci pour votre message. Je vous répondrai dans les meilleurs délais.
</p>
<p class="text-sm text-text-muted">
Si vous ne recevez pas de réponse sous 48h, pensez à vérifier vos spams.
</p>
</div>
<!-- Message d'erreur global (caché par défaut) -->
<div id="error-message" class="hidden mt-8 p-6 bg-red-500/10 border border-red-500/30 rounded-lg">
<div class="flex items-start gap-4">
<svg class="w-6 h-6 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<h3 class="font-semibold text-red-500 mb-1">Erreur</h3>
<p class="text-text-secondary" id="error-text"></p>
<p class="text-sm text-text-muted mt-2">
Vos données ont été conservées. Vous pouvez réessayer.
</p>
</div>
</div>
</div>
<!-- Liens secondaires -->
<div class="mt-16 pt-8 border-t border-border">
<h2 class="text-lg font-semibold text-center text-text-primary mb-6">Retrouvez-moi aussi sur</h2>
<div class="flex flex-wrap justify-center gap-4">
<!-- LinkedIn -->
<a
href="https://linkedin.com/in/celian-music"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 px-5 py-3 bg-surface-alt rounded-lg border border-border hover:border-primary/50 transition-colors group"
aria-label="Profil LinkedIn"
>
<svg class="w-5 h-5 text-[#0A66C2]" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
<span class="font-medium text-text-primary group-hover:text-primary transition-colors">LinkedIn</span>
</a>
<!-- GitHub -->
<a
href="https://github.com/skycel"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 px-5 py-3 bg-surface-alt rounded-lg border border-border hover:border-primary/50 transition-colors group"
aria-label="Profil GitHub"
>
<svg class="w-5 h-5 text-text-primary" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span class="font-medium text-text-primary group-hover:text-primary transition-colors">GitHub</span>
</a>
<!-- Email (protégé contre le scraping) -->
<a
href="#"
id="email-link"
class="flex items-center gap-3 px-5 py-3 bg-surface-alt rounded-lg border border-border hover:border-primary/50 transition-colors group"
aria-label="Envoyer un email"
data-user="music.music"
data-domain="music.music"
>
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<span class="font-medium text-text-primary group-hover:text-primary transition-colors">Email</span>
</a>
</div>
</div>
</div>
</div>
</section> </section>
</main> </main>
<?php include_template('footer'); ?> <?php include_template('footer'); ?>
<script src="/assets/js/state.js" defer></script>
<script src="/assets/js/contact-form.js" defer></script>

View File

@@ -22,7 +22,7 @@ include_template('navbar', compact('currentPage'));
<!-- Nom --> <!-- Nom -->
<h1 class="text-4xl sm:text-5xl lg:text-display font-bold text-text-primary mb-6 animate-fade-in animation-delay-100"> <h1 class="text-4xl sm:text-5xl lg:text-display font-bold text-text-primary mb-6 animate-fade-in animation-delay-100">
Prénom <span class="text-primary">NOM</span> Célian <span class="text-primary">Burst</span>
</h1> </h1>
<!-- Titre --> <!-- Titre -->
@@ -106,6 +106,36 @@ include_template('navbar', compact('currentPage'));
</div> </div>
</div> </div>
</section> </section>
<!-- Témoignages Featured -->
<?php $featuredTestimonials = array_slice(array_values(getFeaturedTestimonials()), 0, 2); ?>
<?php if (!empty($featuredTestimonials)): ?>
<section class="section">
<div class="container-content">
<div class="section-header">
<h2 class="section-title">Ce Qu'ils Disent</h2>
<p class="section-subtitle">
Retours de clients et collaborateurs.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<?php foreach ($featuredTestimonials as $testimonial): ?>
<?php include_template('testimonial', ['testimonial' => $testimonial, 'showProjectLink' => false]); ?>
<?php endforeach; ?>
</div>
<div class="text-center mt-8">
<a href="/a-propos#temoignages" class="text-primary hover:underline inline-flex items-center gap-2">
Voir tous les témoignages
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
</section>
<?php endif; ?>
</main> </main>
<?php include_template('footer'); ?> <?php include_template('footer'); ?>

View File

@@ -1,9 +1,17 @@
<?php <?php
/** /**
* Page projet individuel * Page projet individuelle
*/ */
// Récupérer le slug depuis le router
$slug = $GLOBALS['routeParams'][0] ?? null; $slug = $GLOBALS['routeParams'][0] ?? null;
if (!$slug) {
http_response_code(404);
include __DIR__ . '/404.php';
exit;
}
$project = getProjectBySlug($slug); $project = getProjectBySlug($slug);
if (!$project) { if (!$project) {
@@ -13,7 +21,7 @@ if (!$project) {
} }
$pageTitle = $project['title']; $pageTitle = $project['title'];
$pageDescription = $project['context']; $pageDescription = $project['context'] ?? "Découvrez le projet {$project['title']}";
$currentPage = 'projets'; $currentPage = 'projets';
include_template('header', compact('pageTitle', 'pageDescription')); include_template('header', compact('pageTitle', 'pageDescription'));
@@ -21,20 +29,156 @@ include_template('navbar', compact('currentPage'));
?> ?>
<main> <main>
<section class="section"> <article class="section">
<div class="container-content"> <div class="container-content">
<div class="section-header"> <!-- Breadcrumb -->
<h1 class="section-title"><?= htmlspecialchars($project['title']) ?></h1> <nav class="flex items-center gap-2 text-sm mb-8" aria-label="Fil d'Ariane">
<p class="section-subtitle"> <a href="/" class="text-text-secondary hover:text-primary transition-colors">Accueil</a>
<?= htmlspecialchars($project['duration']) ?> <span class="text-text-secondary">/</span>
</p> <a href="/projets" class="text-text-secondary hover:text-primary transition-colors">Projets</a>
<span class="text-text-secondary">/</span>
<span class="text-text-primary font-medium"><?= htmlspecialchars($project['title']) ?></span>
</nav>
<!-- Header du projet -->
<header class="mb-12">
<h1 class="text-3xl lg:text-display font-bold text-text-primary mb-4">
<?= htmlspecialchars($project['title']) ?>
</h1>
<!-- Technologies -->
<div class="flex flex-wrap gap-2 mb-6">
<?php foreach ($project['technologies'] ?? [] as $tech): ?>
<span class="badge"><?= htmlspecialchars($tech) ?></span>
<?php endforeach; ?>
</div> </div>
<p class="text-text-secondary text-center"> <!-- Boutons d'action -->
Page en construction - Story 3.4 <div class="flex flex-wrap gap-4">
<?php if (!empty($project['url'])): ?>
<a href="<?= htmlspecialchars($project['url']) ?>" target="_blank" rel="noopener" class="btn-primary inline-flex items-center gap-2">
Voir le projet en ligne
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
<?php endif; ?>
<?php if (!empty($project['github'])): ?>
<a href="<?= htmlspecialchars($project['github']) ?>" target="_blank" rel="noopener" class="btn-secondary inline-flex items-center gap-2">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Voir sur GitHub
</a>
<?php endif; ?>
<?php if (empty($project['url']) && empty($project['github'])): ?>
<span class="text-text-secondary italic">Projet non disponible en ligne</span>
<?php endif; ?>
</div>
</header>
<!-- Image principale -->
<?php if (!empty($project['thumbnail'])): ?>
<div class="mb-12 rounded-lg overflow-hidden bg-surface-alt">
<?= projectImage(
$project['thumbnail'],
$project['title'],
1200,
675,
false,
"w-full"
) ?>
</div>
<?php endif; ?>
<!-- Contenu -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
<!-- Colonne principale -->
<div class="lg:col-span-2 space-y-10">
<!-- Contexte -->
<?php if (!empty($project['context'])): ?>
<section>
<h2 class="text-xl lg:text-heading font-semibold text-text-primary mb-4">Contexte</h2>
<p class="text-text-secondary leading-relaxed">
<?= nl2br(htmlspecialchars($project['context'])) ?>
</p> </p>
</section>
<?php endif; ?>
<!-- Solution technique -->
<?php if (!empty($project['solution'])): ?>
<section>
<h2 class="text-xl lg:text-heading font-semibold text-text-primary mb-4">Solution Technique</h2>
<p class="text-text-secondary leading-relaxed">
<?= nl2br(htmlspecialchars($project['solution'])) ?>
</p>
</section>
<?php endif; ?>
<!-- Travail d'équipe -->
<?php if (!empty($project['teamwork'])): ?>
<section>
<h2 class="text-xl lg:text-heading font-semibold text-text-primary mb-4">Travail d'Équipe</h2>
<p class="text-text-secondary leading-relaxed">
<?= nl2br(htmlspecialchars($project['teamwork'])) ?>
</p>
</section>
<?php endif; ?>
<!-- Galerie -->
<?php if (!empty($project['screenshots'])): ?>
<section>
<h2 class="text-xl lg:text-heading font-semibold text-text-primary mb-4">Captures d'écran</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<?php foreach ($project['screenshots'] as $screenshot): ?>
<div class="rounded-lg overflow-hidden bg-surface-alt">
<?= projectImage(
$screenshot,
"Capture d'écran - " . $project['title'],
800,
450,
true,
"w-full h-auto"
) ?>
</div>
<?php endforeach; ?>
</div> </div>
</section> </section>
<?php endif; ?>
</div>
<!-- Sidebar -->
<aside class="space-y-6">
<!-- Durée -->
<div class="card">
<div class="card-body">
<h3 class="text-sm font-medium text-text-secondary mb-1">Durée du projet</h3>
<p class="text-lg font-semibold text-text-primary">
<?= htmlspecialchars($project['duration'] ?? 'Non spécifiée') ?>
</p>
</div>
</div>
<!-- Placeholder témoignage (Story 4.5) -->
</aside>
</div>
<!-- Navigation bas de page -->
<footer class="mt-16 pt-8 border-t border-border flex flex-wrap justify-between items-center gap-4">
<a href="/projets" class="inline-flex items-center gap-2 text-text-secondary hover:text-primary transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Retour aux projets
</a>
<a href="/contact" class="btn-primary">
Me contacter
</a>
</footer>
</div>
</article>
</main> </main>
<?php include_template('footer'); ?> <?php include_template('footer'); ?>

View File

@@ -8,6 +8,7 @@ $pageDescription = 'Découvrez mes réalisations web : sites vitrines, e-commerc
$currentPage = 'projets'; $currentPage = 'projets';
$featuredProjects = getProjectsByCategory('vedette'); $featuredProjects = getProjectsByCategory('vedette');
$secondaryProjects = getProjectsByCategory('secondaire');
include_template('header', compact('pageTitle', 'pageDescription')); include_template('header', compact('pageTitle', 'pageDescription'));
include_template('navbar', compact('currentPage')); include_template('navbar', compact('currentPage'));
@@ -39,7 +40,22 @@ include_template('navbar', compact('currentPage'));
</div> </div>
</section> </section>
<!-- Section projets secondaires (Story 3.5) --> <!-- Section projets secondaires -->
<?php if (!empty($secondaryProjects)): ?>
<section class="section pt-0">
<div class="container-content">
<hr class="border-border mb-12">
<h2 class="text-xl lg:text-heading font-semibold text-text-primary mb-8">Autres projets</h2>
<div class="space-y-4">
<?php foreach ($secondaryProjects as $project): ?>
<?php include_template('project-card-compact', ['project' => $project]); ?>
<?php endforeach; ?>
</div>
</div>
</section>
<?php endif; ?>
</main> </main>
<?php include_template('footer'); ?> <?php include_template('footer'); ?>

View File

@@ -1,12 +1,35 @@
<?php <?php
/** /**
* Page compétences * Page Compétences
*/ */
$pageTitle = 'Compétences'; $pageTitle = 'Compétences';
$pageDescription = 'Mes compétences techniques : langages, frameworks et outils maîtrisés.'; $pageDescription = 'Mes compétences techniques en développement web : langages, frameworks et outils.';
$currentPage = 'competences'; $currentPage = 'competences';
// Récupérer le comptage des technologies
$techCount = getProjectCountByTech();
// Catégoriser les technologies
$categories = [
'Frontend' => [
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>',
'techs' => ['HTML', 'CSS', 'JavaScript', 'TypeScript', 'Angular', 'Vue.js', 'Tailwind CSS', 'Bootstrap', 'SASS']
],
'Backend' => [
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>',
'techs' => ['PHP', 'Node.js', 'Laravel', 'Symfony']
],
'Base de données' => [
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>',
'techs' => ['MySQL/MariaDB', 'SQLite']
],
'DevOps & Outils' => [
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
'techs' => ['Git', 'Linux', 'Nginx', 'Apache', 'CI/CD']
],
];
include_template('header', compact('pageTitle', 'pageDescription')); include_template('header', compact('pageTitle', 'pageDescription'));
include_template('navbar', compact('currentPage')); include_template('navbar', compact('currentPage'));
?> ?>
@@ -17,14 +40,174 @@ include_template('navbar', compact('currentPage'));
<div class="section-header"> <div class="section-header">
<h1 class="section-title">Compétences</h1> <h1 class="section-title">Compétences</h1>
<p class="section-subtitle"> <p class="section-subtitle">
Technologies et outils maîtrisés Technologies que j'utilise au quotidien, liées à mes projets réels.
</p> </p>
</div> </div>
<p class="text-text-secondary text-center"> <!-- Technologies par catégorie -->
Page en construction - Epic 4 <div class="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
<?php foreach ($categories as $category => $data): ?>
<div class="card">
<div class="card-body">
<div class="flex items-center gap-3 mb-6">
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<?= $data['icon'] ?>
</svg>
</div>
<h2 class="text-xl font-semibold text-text-primary"><?= htmlspecialchars($category) ?></h2>
</div>
<div class="flex flex-wrap gap-3">
<?php foreach ($data['techs'] as $tech): ?>
<?php $count = $techCount[$tech] ?? 0; ?>
<?php if ($count > 0): ?>
<span class="group flex items-center gap-2 px-4 py-2 bg-surface-alt rounded-lg border border-transparent hover:border-primary/30 transition-colors cursor-default"
title="<?= $count ?> projet<?= $count > 1 ? 's' : '' ?>">
<span class="font-medium text-text-primary">
<?= htmlspecialchars($tech) ?>
</span>
<span class="text-xs px-2 py-0.5 bg-primary/20 text-primary rounded-full">
<?= $count ?>
</span>
</span>
<?php else: ?>
<span class="flex items-center gap-2 px-4 py-2 bg-surface-alt/50 rounded-lg text-text-secondary">
<?= htmlspecialchars($tech) ?>
</span>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
<!-- Section Outils Démontrables (Story 4.2) -->
<?php
// Outils démontrables avec preuves vérifiables
$demonstrableTools = [
[
'name' => 'GitHub',
'icon' => 'github',
'url' => 'https://github.com/skycel',
'description' => 'Historique de commits et projets publics'
],
[
'name' => 'VS Code',
'icon' => 'vscode',
'url' => null,
'description' => 'Éditeur principal, visible dans le code source'
],
[
'name' => 'Figma',
'icon' => 'figma',
'url' => null,
'description' => 'Maquettes et prototypes de projets'
],
[
'name' => 'Docker',
'icon' => 'docker',
'url' => null,
'description' => 'Conteneurisation des environnements'
],
[
'name' => 'Linux',
'icon' => 'linux',
'url' => null,
'description' => 'Administration serveur et développement'
],
];
// Autres outils utilisés régulièrement
$otherTools = [
['name' => 'Photoshop', 'context' => 'Retouche d\'images et création graphique'],
['name' => 'Insomnia', 'context' => 'Test d\'APIs REST et GraphQL'],
['name' => 'DBeaver', 'context' => 'Administration de bases de données'],
['name' => 'FileZilla', 'context' => 'Transfert FTP/SFTP'],
['name' => 'Notion', 'context' => 'Organisation et documentation'],
];
?>
<section class="section bg-surface">
<div class="container-content">
<div class="section-header">
<h2 class="section-title">Outils Démontrables</h2>
<p class="section-subtitle">
Outils accompagnés de preuves vérifiables ou visibles dans mes projets.
</p> </p>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<?php foreach ($demonstrableTools as $tool): ?>
<?php if ($tool['url']): ?>
<a href="<?= htmlspecialchars($tool['url']) ?>"
target="_blank"
rel="noopener"
class="card group hover:border-primary/30 transition-colors">
<div class="card-body flex items-start gap-4">
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 text-primary">
<?= getToolIcon($tool['icon']) ?>
</div>
<div class="flex-grow">
<h3 class="font-semibold text-text-primary group-hover:text-primary transition-colors flex items-center gap-2">
<?= htmlspecialchars($tool['name']) ?>
<svg class="w-4 h-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</h3>
<p class="text-sm text-text-secondary mt-1">
<?= htmlspecialchars($tool['description']) ?>
</p>
</div>
</div>
</a>
<?php else: ?>
<div class="card">
<div class="card-body flex items-start gap-4">
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 text-primary">
<?= getToolIcon($tool['icon']) ?>
</div>
<div class="flex-grow">
<h3 class="font-semibold text-text-primary">
<?= htmlspecialchars($tool['name']) ?>
</h3>
<p class="text-sm text-text-secondary mt-1">
<?= htmlspecialchars($tool['description']) ?>
</p>
</div>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
</section>
<!-- Section Autres Outils -->
<section class="section">
<div class="container-content">
<div class="section-header">
<h2 class="section-title">Autres Outils</h2>
<p class="section-subtitle">
Outils utilisés régulièrement dans mes projets.
</p>
</div>
<div class="flex flex-wrap gap-3">
<?php foreach ($otherTools as $tool): ?>
<span class="group relative px-4 py-2 bg-surface-alt rounded-lg text-text-secondary cursor-help border border-transparent hover:border-border transition-colors">
<?= htmlspecialchars($tool['name']) ?>
<!-- Tooltip -->
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-surface-light text-text-primary text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap shadow-lg z-10">
<?= htmlspecialchars($tool['context']) ?>
</span>
</span>
<?php endforeach; ?>
</div>
</div>
</section> </section>
</main> </main>

View File

@@ -14,6 +14,10 @@ $currentYear = date('Y');
</footer> </footer>
<!-- Scripts --> <!-- Scripts -->
<?php if (defined('RECAPTCHA_SITE_KEY') && RECAPTCHA_SITE_KEY): ?>
<script>window.RECAPTCHA_SITE_KEY = '<?= htmlspecialchars(RECAPTCHA_SITE_KEY) ?>';</script>
<script src="https://www.google.com/recaptcha/api.js?render=<?= htmlspecialchars(RECAPTCHA_SITE_KEY) ?>" async defer></script>
<?php endif; ?>
<script src="/assets/js/main.js" defer></script> <script src="/assets/js/main.js" defer></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,57 @@
<?php
/**
* Carte projet compacte (projets secondaires)
* @param array $project Données du projet
*/
$title = $project['title'] ?? 'Sans titre';
$context = $project['context'] ?? '';
$url = $project['url'] ?? null;
$technologies = $project['technologies'] ?? [];
$maxTechs = 3;
// Tronquer la description à ~100 caractères
$shortContext = strlen($context) > 100
? substr($context, 0, 100) . '...'
: $context;
?>
<article class="card hover:border-primary/30 transition-colors">
<div class="card-body flex flex-col sm:flex-row sm:items-center gap-4">
<!-- Titre et description -->
<div class="flex-grow min-w-0">
<?php if ($url): ?>
<a href="<?= htmlspecialchars($url, ENT_QUOTES, 'UTF-8') ?>"
target="_blank"
rel="noopener"
class="text-lg font-semibold text-text-primary hover:text-primary transition-colors inline-flex items-center gap-2">
<?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
<?php else: ?>
<h3 class="text-lg font-semibold text-text-primary">
<?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?>
</h3>
<?php endif; ?>
<?php if ($shortContext): ?>
<p class="text-text-secondary text-sm mt-1 truncate">
<?= htmlspecialchars($shortContext, ENT_QUOTES, 'UTF-8') ?>
</p>
<?php endif; ?>
</div>
<!-- Technologies -->
<div class="flex flex-wrap gap-2 sm:flex-shrink-0">
<?php foreach (array_slice($technologies, 0, $maxTechs) as $tech): ?>
<span class="badge text-xs"><?= htmlspecialchars($tech, ENT_QUOTES, 'UTF-8') ?></span>
<?php endforeach; ?>
<?php if (count($technologies) > $maxTechs): ?>
<span class="badge badge-muted text-xs">+<?= count($technologies) - $maxTechs ?></span>
<?php endif; ?>
</div>
</div>
</article>

View File

@@ -15,15 +15,14 @@ $maxTechs = 4;
<a href="/projet/<?= htmlspecialchars($slug, ENT_QUOTES, 'UTF-8') ?>" class="block"> <a href="/projet/<?= htmlspecialchars($slug, ENT_QUOTES, 'UTF-8') ?>" class="block">
<!-- Thumbnail --> <!-- Thumbnail -->
<div class="aspect-video overflow-hidden rounded-t-lg bg-surface-alt"> <div class="aspect-video overflow-hidden rounded-t-lg bg-surface-alt">
<img <?= projectImage(
src="/assets/img/projects/<?= htmlspecialchars($thumbnail, ENT_QUOTES, 'UTF-8') ?>" $thumbnail,
alt="Aperçu du projet <?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?>" "Aperçu du projet " . $title,
width="400" 400,
height="225" 225,
loading="lazy" true,
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" "w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
onerror="this.src='/assets/img/projects/default-project.webp'; this.onerror=null;" ) ?>
>
</div> </div>
<!-- Contenu --> <!-- Contenu -->

67
templates/testimonial.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
/**
* Composant témoignage
* @param array $testimonial Données du témoignage
* @param bool $showProjectLink Afficher le lien vers le projet (défaut: true)
*/
$quote = $testimonial['quote'] ?? '';
$authorName = $testimonial['author_name'] ?? 'Anonyme';
$authorRole = $testimonial['author_role'] ?? '';
$authorCompany = $testimonial['author_company'] ?? '';
$authorPhoto = $testimonial['author_photo'] ?? null;
$projectSlug = $testimonial['project_slug'] ?? null;
$showProjectLink = $showProjectLink ?? true;
?>
<blockquote class="card h-full flex flex-col">
<div class="card-body flex flex-col h-full">
<!-- Guillemets décoratifs -->
<svg class="w-8 h-8 text-primary/30 mb-4 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z"/>
</svg>
<!-- Citation -->
<p class="text-text-primary leading-relaxed mb-6 italic flex-grow">
"<?= htmlspecialchars($quote) ?>"
</p>
<!-- Auteur -->
<footer class="flex items-center gap-4 mt-auto">
<?php if ($authorPhoto): ?>
<img
src="/assets/img/testimonials/<?= htmlspecialchars($authorPhoto) ?>"
alt="<?= htmlspecialchars($authorName) ?>"
class="w-12 h-12 rounded-full object-cover"
loading="lazy"
>
<?php else: ?>
<div class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center flex-shrink-0">
<span class="text-primary font-semibold text-lg">
<?= strtoupper(mb_substr($authorName, 0, 1)) ?>
</span>
</div>
<?php endif; ?>
<div>
<p class="font-semibold text-text-primary"><?= htmlspecialchars($authorName) ?></p>
<p class="text-sm text-text-secondary">
<?= htmlspecialchars($authorRole) ?>
<?php if ($authorCompany): ?>
<span class="text-text-muted">—</span> <?= htmlspecialchars($authorCompany) ?>
<?php endif; ?>
</p>
</div>
</footer>
<!-- Lien vers le projet -->
<?php if ($showProjectLink && $projectSlug): ?>
<a href="/projet/<?= htmlspecialchars($projectSlug) ?>" class="inline-flex items-center gap-1 text-primary text-sm mt-4 hover:underline">
Voir le projet
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
<?php endif; ?>
</div>
</blockquote>