1565 lines
40 KiB
Markdown
1565 lines
40 KiB
Markdown
# Portfolio Développeur Web - Frontend Architecture Document
|
|
|
|
## Change Log
|
|
|
|
| Date | Version | Description | Author |
|
|
|------|---------|-------------|--------|
|
|
| 2026-01-22 | 1.0 | Création initiale | Winston (Architect) |
|
|
|
|
---
|
|
|
|
## 1. Template and Framework Selection
|
|
|
|
### 1.1 Stack Front-End confirmé
|
|
|
|
| Aspect | Choix | Justification |
|
|
|--------|-------|---------------|
|
|
| **Markup** | HTML5 sémantique + PHP | SEO, accessibilité, dynamisme JSON |
|
|
| **Styling** | Tailwind CSS (CLI build, purge automatique) | Utility-first, dev rapide, <50kb objectif |
|
|
| **Scripting** | JavaScript ES6+ vanilla | Pas de framework, simplicité, performance |
|
|
| **Bundler** | Tailwind CLI + PostCSS uniquement | Léger, pas de webpack nécessaire |
|
|
| **Starter template** | Aucun - construction from scratch | Contrôle total, pas de dépendances inutiles |
|
|
|
|
### 1.2 Contraintes imposées par le PRD
|
|
|
|
- Pas de framework JS (React, Vue, Angular exclus)
|
|
- Build uniquement pour le CSS (Tailwind CLI)
|
|
- Node.js en dev uniquement, pas en production
|
|
- Hébergement sur serveur personnel (nginx + PHP-FPM)
|
|
|
|
### 1.3 Rationale
|
|
|
|
**Choix de ne pas utiliser de framework JS :**
|
|
- Le portfolio est principalement statique avec dynamisme côté PHP (JSON → templates)
|
|
- Le JS vanilla suffit pour : menu hamburger, localStorage formulaire, validation, animations
|
|
- Avantages : performance optimale, pas de bundle JS lourd, maintenance simplifiée
|
|
- Trade-off accepté : moins de réactivité côté client, mais adapté au use-case
|
|
|
|
**Choix de Tailwind CSS :**
|
|
- Utility-first = développement rapide
|
|
- Purge automatique = CSS minimal en production (<50kb objectif)
|
|
- Bien documenté, compatible avec PHP/HTML pur
|
|
|
|
---
|
|
|
|
## 2. Frontend Tech Stack
|
|
|
|
| Catégorie | Technologie | Version | Purpose | Rationale |
|
|
|-----------|-------------|---------|---------|-----------|
|
|
| **Markup** | HTML5 + PHP | 8.x | Structure pages + templates dynamiques | SEO, accessibilité, dynamisme JSON |
|
|
| **Styling** | Tailwind CSS | 3.x | Framework CSS utility-first | Purge auto, dev rapide, <50kb objectif |
|
|
| **Build Tool** | Tailwind CLI | 3.x | Compilation CSS | Léger, pas de webpack nécessaire |
|
|
| **Scripting** | JavaScript ES6+ | Vanilla | Interactions client | Menu mobile, localStorage, validation, animations |
|
|
| **Icons** | Heroicons | 2.x | Iconographie cohérente | Recommandé par spec UX, SVG inline |
|
|
| **Fonts** | Inter + JetBrains Mono | Variable | Typographie | Lisibilité, style moderne/technique |
|
|
| **Form Protection** | reCAPTCHA v3 | - | Anti-spam invisible | Zéro friction UX |
|
|
| **Dev Server** | PHP built-in / nginx | 8.x | Développement local | Simple à configurer |
|
|
|
|
### 2.1 Dépendances npm (dev only)
|
|
|
|
```json
|
|
{
|
|
"devDependencies": {
|
|
"tailwindcss": "^3.4.0",
|
|
"postcss": "^8.4.0",
|
|
"autoprefixer": "^10.4.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.2 Dépendances Composer (PHP)
|
|
|
|
```json
|
|
{
|
|
"require": {
|
|
"php": ">=8.0",
|
|
"vlucas/phpdotenv": "^5.6"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Project Structure
|
|
|
|
```
|
|
/portfolio
|
|
├── index.php # Point d'entrée + router front controller
|
|
├── config.php # Charge .env et définit les constantes
|
|
├── composer.json # Dépendances PHP
|
|
├── composer.lock
|
|
├── vendor/ # Dépendances Composer (gitignore)
|
|
│
|
|
├── .env # Variables sensibles (gitignore)
|
|
├── .env.example # Template sans valeurs sensibles
|
|
│
|
|
├── api/
|
|
│ └── contact.php # Endpoint formulaire de contact
|
|
│
|
|
├── pages/
|
|
│ ├── home.php # Page d'accueil (hero + sections rapides)
|
|
│ ├── projects.php # Liste projets vedettes + secondaires
|
|
│ ├── project-single.php # Page projet individuelle (template)
|
|
│ ├── skills.php # Compétences & outils
|
|
│ ├── about.php # Me Découvrir
|
|
│ ├── contact.php # Formulaire de contact
|
|
│ └── 404.php # Page erreur 404
|
|
│
|
|
├── templates/
|
|
│ ├── header.php # <head>, meta tags, CSS link
|
|
│ ├── footer.php # Scripts JS, copyright
|
|
│ ├── navbar.php # Navigation sticky + CTA
|
|
│ ├── project-card.php # Carte projet réutilisable
|
|
│ ├── project-card-compact.php # Carte projet secondaire (liste)
|
|
│ ├── testimonial.php # Bloc témoignage
|
|
│ └── breadcrumb.php # Fil d'Ariane (pages projet)
|
|
│
|
|
├── includes/
|
|
│ ├── router.php # Logique de routage (<50 lignes)
|
|
│ ├── functions.php # Helpers (getProjects, getTestimonials, etc.)
|
|
│ └── contact-handler.php # Traitement formulaire + envoi email
|
|
│
|
|
├── data/
|
|
│ ├── projects.json # Données des projets (mini-CMS)
|
|
│ └── testimonials.json # Témoignages clients/employeurs
|
|
│
|
|
├── assets/
|
|
│ ├── css/
|
|
│ │ ├── input.css # Source Tailwind (@tailwind directives)
|
|
│ │ └── output.css # CSS compilé (généré)
|
|
│ ├── js/
|
|
│ │ ├── main.js # Script principal (menu, animations)
|
|
│ │ ├── contact-form.js # Validation + localStorage + AJAX
|
|
│ │ └── state.js # AppState (localStorage) + UIState
|
|
│ ├── img/
|
|
│ │ ├── logo.svg # Logo du portfolio
|
|
│ │ ├── hero/ # Images hero (photo/illustration)
|
|
│ │ └── projects/ # Thumbnails et screenshots projets
|
|
│ └── fonts/
|
|
│ ├── inter-var.woff2 # Police principale (variable)
|
|
│ └── jetbrains-mono-var.woff2 # Police code (variable)
|
|
│
|
|
├── logs/ # Logs d'erreurs (gitignore)
|
|
│ └── .gitkeep
|
|
│
|
|
├── tailwind.config.js # Configuration Tailwind (couleurs, fonts)
|
|
├── postcss.config.js # Config PostCSS (autoprefixer)
|
|
├── package.json # Scripts npm (build, watch)
|
|
├── package-lock.json
|
|
├── nginx.conf.example # Template configuration nginx
|
|
├── .gitignore
|
|
└── README.md
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Component Standards
|
|
|
|
### 4.1 Template de Composant PHP
|
|
|
|
```php
|
|
<?php
|
|
/**
|
|
* Composant : Project Card
|
|
* Usage : <?php include_template('project-card', ['project' => $project]); ?>
|
|
*
|
|
* @param array $project - Données du projet depuis JSON
|
|
*/
|
|
|
|
// Valeurs par défaut et extraction
|
|
$title = $project['title'] ?? 'Sans titre';
|
|
$slug = $project['slug'] ?? '#';
|
|
$thumbnail = $project['thumbnail'] ?? 'default-project.webp';
|
|
$technologies = $project['technologies'] ?? [];
|
|
$maxTechs = 4;
|
|
?>
|
|
|
|
<article class="project-card group bg-surface rounded-lg overflow-hidden transition-all duration-200 hover:-translate-y-1 hover:shadow-xl">
|
|
<a href="/projet/<?= htmlspecialchars($slug) ?>" class="block">
|
|
<picture>
|
|
<source srcset="/assets/img/projects/<?= htmlspecialchars($thumbnail) ?>" type="image/webp">
|
|
<img
|
|
src="/assets/img/projects/<?= htmlspecialchars(str_replace('.webp', '.jpg', $thumbnail)) ?>"
|
|
alt="Aperçu du projet <?= htmlspecialchars($title) ?>"
|
|
width="400"
|
|
height="225"
|
|
loading="lazy"
|
|
class="w-full aspect-video object-cover"
|
|
>
|
|
</picture>
|
|
|
|
<div class="p-4">
|
|
<h3 class="text-lg font-semibold text-text-primary group-hover:text-primary transition-colors">
|
|
<?= htmlspecialchars($title) ?>
|
|
</h3>
|
|
|
|
<div class="flex flex-wrap gap-2 mt-3">
|
|
<?php foreach (array_slice($technologies, 0, $maxTechs) as $tech): ?>
|
|
<span class="badge"><?= htmlspecialchars($tech) ?></span>
|
|
<?php endforeach; ?>
|
|
|
|
<?php if (count($technologies) > $maxTechs): ?>
|
|
<span class="badge badge-muted">+<?= count($technologies) - $maxTechs ?></span>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</article>
|
|
```
|
|
|
|
### 4.2 Helper pour inclure les templates
|
|
|
|
```php
|
|
// includes/functions.php
|
|
|
|
/**
|
|
* Inclut un template avec des données
|
|
* @param string $name Nom du template (sans .php)
|
|
* @param array $data Variables à passer au template
|
|
*/
|
|
function include_template(string $name, array $data = []): void
|
|
{
|
|
extract($data);
|
|
include __DIR__ . "/../templates/{$name}.php";
|
|
}
|
|
```
|
|
|
|
### 4.3 Conventions de Nommage
|
|
|
|
| Élément | Convention | Exemple |
|
|
|---------|------------|---------|
|
|
| **Fichiers PHP pages** | kebab-case | `project-single.php` |
|
|
| **Fichiers PHP templates** | kebab-case | `project-card.php` |
|
|
| **Fonctions PHP** | camelCase | `getProjectBySlug()` |
|
|
| **Variables PHP** | camelCase | `$projectData` |
|
|
| **Fichiers JS** | kebab-case | `contact-form.js` |
|
|
| **Fonctions JS** | camelCase | `validateForm()` |
|
|
| **Classes JS** | PascalCase | `FormValidator` |
|
|
| **Classes CSS custom** | kebab-case | `.project-card` |
|
|
| **Classes Tailwind** | Utility classes | `bg-surface text-primary` |
|
|
| **IDs HTML** | kebab-case | `id="contact-form"` |
|
|
| **Data attributes** | kebab-case | `data-project-slug` |
|
|
| **Images** | kebab-case + slug | `ecommerce-xyz-thumb.webp` |
|
|
| **Constantes PHP** | SCREAMING_SNAKE | `RECAPTCHA_SITE_KEY` |
|
|
|
|
### 4.4 Classes CSS Personnalisées
|
|
|
|
```css
|
|
/* assets/css/input.css */
|
|
|
|
@layer components {
|
|
/* Boutons */
|
|
.btn {
|
|
@apply inline-flex items-center justify-center px-6 py-3
|
|
font-medium rounded-lg transition-all duration-150
|
|
focus:outline-none focus:ring-2 focus:ring-offset-2
|
|
focus:ring-offset-background;
|
|
}
|
|
|
|
.btn-primary {
|
|
@apply btn bg-primary text-background
|
|
hover:bg-primary-light focus:ring-primary;
|
|
}
|
|
|
|
.btn-secondary {
|
|
@apply btn border-2 border-primary text-primary
|
|
hover:bg-primary hover:text-background focus:ring-primary;
|
|
}
|
|
|
|
.btn-ghost {
|
|
@apply btn text-primary hover:text-primary-light;
|
|
}
|
|
|
|
/* Badges */
|
|
.badge {
|
|
@apply inline-block px-2 py-1 text-xs font-medium
|
|
bg-surface-light text-text-secondary rounded;
|
|
}
|
|
|
|
.badge-muted {
|
|
@apply bg-border text-text-muted;
|
|
}
|
|
|
|
/* Inputs */
|
|
.input {
|
|
@apply w-full px-4 py-3 bg-surface border border-border rounded-lg
|
|
text-text-primary placeholder-text-muted
|
|
focus:outline-none focus:border-primary focus:ring-2
|
|
focus:ring-primary/20 transition-all duration-150;
|
|
}
|
|
|
|
.input-error {
|
|
@apply border-error focus:border-error focus:ring-error/20;
|
|
}
|
|
|
|
/* Labels */
|
|
.label {
|
|
@apply block text-sm font-medium text-text-secondary mb-2;
|
|
}
|
|
|
|
.label-required::after {
|
|
@apply text-error ml-1;
|
|
content: '*';
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. State Management
|
|
|
|
### 5.1 Vue d'ensemble
|
|
|
|
| Couche | Type de données | Méthode de gestion |
|
|
|--------|-----------------|-------------------|
|
|
| **PHP (Serveur)** | Projets, témoignages | Fichiers JSON (lecture seule) |
|
|
| **JS (Client)** | État UI temporaire | Variables JS en mémoire |
|
|
| **JS (Client)** | Données persistantes | localStorage |
|
|
|
|
### 5.2 Structure des données JSON
|
|
|
|
**`data/projects.json` :**
|
|
|
|
```json
|
|
{
|
|
"projects": [
|
|
{
|
|
"id": 1,
|
|
"title": "Site E-commerce XYZ",
|
|
"slug": "ecommerce-xyz",
|
|
"category": "vedette",
|
|
"thumbnail": "ecommerce-xyz-thumb.webp",
|
|
"url": "https://example.com",
|
|
"technologies": ["PHP", "JavaScript", "Tailwind CSS", "MySQL"],
|
|
"context": "Description du contexte et des besoins client...",
|
|
"solution": "Explication technique des choix...",
|
|
"teamwork": "Rôle dans l'équipe, organisation...",
|
|
"duration": "3 mois",
|
|
"screenshots": [
|
|
"ecommerce-xyz-screen-1.webp",
|
|
"ecommerce-xyz-screen-2.webp"
|
|
]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**`data/testimonials.json` :**
|
|
|
|
```json
|
|
{
|
|
"testimonials": [
|
|
{
|
|
"id": 1,
|
|
"quote": "Excellent travail, livraison dans les délais...",
|
|
"author_name": "Marie Dupont",
|
|
"author_role": "Directrice Marketing",
|
|
"author_company": "Entreprise XYZ",
|
|
"author_photo": "marie-dupont.webp",
|
|
"project_slug": "ecommerce-xyz",
|
|
"date": "2025-06-15",
|
|
"featured": true
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### 5.3 Fonctions PHP d'accès aux données
|
|
|
|
```php
|
|
// includes/functions.php
|
|
|
|
/**
|
|
* Charge et parse un fichier JSON
|
|
*/
|
|
function loadJsonData(string $filename): array
|
|
{
|
|
$path = __DIR__ . "/../data/{$filename}";
|
|
|
|
if (!file_exists($path)) {
|
|
return [];
|
|
}
|
|
|
|
$content = file_get_contents($path);
|
|
$data = json_decode($content, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
error_log("JSON parse error in {$filename}: " . json_last_error_msg());
|
|
return [];
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Récupère tous les projets
|
|
*/
|
|
function getProjects(): array
|
|
{
|
|
$data = loadJsonData('projects.json');
|
|
return $data['projects'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* Récupère les projets par catégorie
|
|
*/
|
|
function getProjectsByCategory(string $category): array
|
|
{
|
|
return array_filter(getProjects(), fn($p) => $p['category'] === $category);
|
|
}
|
|
|
|
/**
|
|
* Récupère un projet par son slug
|
|
*/
|
|
function getProjectBySlug(string $slug): ?array
|
|
{
|
|
$projects = getProjects();
|
|
foreach ($projects as $project) {
|
|
if ($project['slug'] === $slug) {
|
|
return $project;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Récupère tous les témoignages
|
|
*/
|
|
function getTestimonials(): array
|
|
{
|
|
$data = loadJsonData('testimonials.json');
|
|
return $data['testimonials'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* Récupère les témoignages mis en avant
|
|
*/
|
|
function getFeaturedTestimonials(): array
|
|
{
|
|
return array_filter(getTestimonials(), fn($t) => $t['featured'] === true);
|
|
}
|
|
|
|
/**
|
|
* Récupère le témoignage lié à un projet
|
|
*/
|
|
function getTestimonialByProject(string $projectSlug): ?array
|
|
{
|
|
$testimonials = getTestimonials();
|
|
foreach ($testimonials as $testimonial) {
|
|
if (($testimonial['project_slug'] ?? '') === $projectSlug) {
|
|
return $testimonial;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
```
|
|
|
|
### 5.4 État côté client (JavaScript)
|
|
|
|
```javascript
|
|
// assets/js/state.js
|
|
|
|
/**
|
|
* Gestionnaire d'état simple pour le localStorage
|
|
*/
|
|
const AppState = {
|
|
STORAGE_KEY: 'portfolio_contact_form',
|
|
|
|
saveFormData(data) {
|
|
try {
|
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
|
|
} catch (e) {
|
|
console.warn('localStorage non disponible');
|
|
}
|
|
},
|
|
|
|
getFormData() {
|
|
try {
|
|
const data = localStorage.getItem(this.STORAGE_KEY);
|
|
return data ? JSON.parse(data) : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
clearFormData() {
|
|
try {
|
|
localStorage.removeItem(this.STORAGE_KEY);
|
|
} catch (e) {
|
|
// Silencieux
|
|
}
|
|
},
|
|
|
|
isStorageAvailable() {
|
|
try {
|
|
const test = '__storage_test__';
|
|
localStorage.setItem(test, test);
|
|
localStorage.removeItem(test);
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* État UI en mémoire (non persisté)
|
|
*/
|
|
const UIState = {
|
|
mobileMenuOpen: false,
|
|
|
|
toggleMobileMenu() {
|
|
this.mobileMenuOpen = !this.mobileMenuOpen;
|
|
return this.mobileMenuOpen;
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 6. API Integration
|
|
|
|
### 6.1 Vue d'ensemble des intégrations
|
|
|
|
| Intégration | Type | Méthode | Endpoint/Service |
|
|
|-------------|------|---------|------------------|
|
|
| Formulaire contact | Interne | POST (AJAX) | `/api/contact.php` |
|
|
| reCAPTCHA v3 | Externe | JS + POST | Google API |
|
|
| Email | Externe | SMTP/mail() | Serveur mail |
|
|
|
|
### 6.2 Endpoint Formulaire de Contact
|
|
|
|
```php
|
|
<?php
|
|
// api/contact.php
|
|
|
|
require_once __DIR__ . '/../vendor/autoload.php';
|
|
require_once __DIR__ . '/../config.php';
|
|
require_once __DIR__ . '/../includes/functions.php';
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
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 {
|
|
// 1. Valider le token CSRF
|
|
if (!validateCsrfToken($input['csrf_token'] ?? '')) {
|
|
throw new Exception('Token de sécurité invalide');
|
|
}
|
|
|
|
// 2. Valider reCAPTCHA
|
|
$recaptchaScore = verifyRecaptcha($input['recaptcha_token'] ?? '');
|
|
if ($recaptchaScore < 0.5) {
|
|
throw new Exception('Vérification anti-spam échouée');
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'message' => 'Votre message a bien été envoyé !'
|
|
]);
|
|
|
|
} catch (Exception $e) {
|
|
http_response_code(400);
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
}
|
|
```
|
|
|
|
### 6.3 Fonctions de validation
|
|
|
|
```php
|
|
// includes/functions.php (suite)
|
|
|
|
/**
|
|
* Génère un token CSRF
|
|
*/
|
|
function generateCsrfToken(): string
|
|
{
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
if (empty($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
return $_SESSION['csrf_token'];
|
|
}
|
|
|
|
/**
|
|
* Valide un token CSRF
|
|
*/
|
|
function validateCsrfToken(string $token): bool
|
|
{
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
return hash_equals($_SESSION['csrf_token'] ?? '', $token);
|
|
}
|
|
|
|
/**
|
|
* Vérifie le token reCAPTCHA v3 auprès de Google
|
|
*/
|
|
function verifyRecaptcha(string $token): float
|
|
{
|
|
if (empty($token)) {
|
|
return 0.0;
|
|
}
|
|
|
|
$response = file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, stream_context_create([
|
|
'http' => [
|
|
'method' => 'POST',
|
|
'header' => 'Content-Type: application/x-www-form-urlencoded',
|
|
'content' => http_build_query([
|
|
'secret' => RECAPTCHA_SECRET_KEY,
|
|
'response' => $token,
|
|
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? ''
|
|
])
|
|
]
|
|
]));
|
|
|
|
if ($response === false) {
|
|
error_log('reCAPTCHA verification failed: unable to connect');
|
|
return 0.0;
|
|
}
|
|
|
|
$result = json_decode($response, true);
|
|
|
|
if (!($result['success'] ?? false)) {
|
|
error_log('reCAPTCHA verification failed: ' . json_encode($result['error-codes'] ?? []));
|
|
return 0.0;
|
|
}
|
|
|
|
return (float) ($result['score'] ?? 0.0);
|
|
}
|
|
|
|
/**
|
|
* Valide et nettoie les données du formulaire
|
|
*/
|
|
function validateContactData(array $input): array
|
|
{
|
|
$errors = [];
|
|
|
|
$required = ['nom', 'prenom', 'email', 'categorie', 'objet', 'message'];
|
|
foreach ($required as $field) {
|
|
if (empty(trim($input[$field] ?? ''))) {
|
|
$errors[] = "Le champ {$field} est requis";
|
|
}
|
|
}
|
|
|
|
if (!filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
|
|
$errors[] = "L'adresse email n'est pas valide";
|
|
}
|
|
|
|
$validCategories = ['projet', 'poste', 'autre'];
|
|
if (!in_array($input['categorie'] ?? '', $validCategories)) {
|
|
$errors[] = "Catégorie invalide";
|
|
}
|
|
|
|
if (strlen($input['message'] ?? '') > 5000) {
|
|
$errors[] = "Le message est trop long (max 5000 caractères)";
|
|
}
|
|
|
|
if (!empty($errors)) {
|
|
throw new Exception(implode(', ', $errors));
|
|
}
|
|
|
|
return [
|
|
'nom' => htmlspecialchars(trim($input['nom']), ENT_QUOTES, 'UTF-8'),
|
|
'prenom' => htmlspecialchars(trim($input['prenom']), ENT_QUOTES, 'UTF-8'),
|
|
'email' => filter_var(trim($input['email']), FILTER_SANITIZE_EMAIL),
|
|
'entreprise' => htmlspecialchars(trim($input['entreprise'] ?? ''), ENT_QUOTES, 'UTF-8'),
|
|
'categorie' => $input['categorie'],
|
|
'objet' => htmlspecialchars(trim($input['objet']), ENT_QUOTES, 'UTF-8'),
|
|
'message' => htmlspecialchars(trim($input['message']), ENT_QUOTES, 'UTF-8'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Envoie l'email de contact
|
|
*/
|
|
function sendContactEmail(array $data): bool
|
|
{
|
|
$categorieLabels = [
|
|
'projet' => 'Projet freelance',
|
|
'poste' => 'Proposition de poste',
|
|
'autre' => 'Autre'
|
|
];
|
|
|
|
$subject = "[Portfolio] {$categorieLabels[$data['categorie']]} - {$data['objet']}";
|
|
|
|
$body = "
|
|
Nouveau message depuis le portfolio
|
|
|
|
---------------------------------
|
|
Nom : {$data['prenom']} {$data['nom']}
|
|
Email : {$data['email']}
|
|
Entreprise : {$data['entreprise']}
|
|
Catégorie : {$categorieLabels[$data['categorie']]}
|
|
---------------------------------
|
|
|
|
Objet : {$data['objet']}
|
|
|
|
Message :
|
|
{$data['message']}
|
|
|
|
---------------------------------
|
|
Envoyé le : " . date('d/m/Y à H:i') . "
|
|
IP : " . ($_SERVER['REMOTE_ADDR'] ?? 'inconnue');
|
|
|
|
$headers = [
|
|
'From' => CONTACT_EMAIL,
|
|
'Reply-To' => $data['email'],
|
|
'Content-Type' => 'text/plain; charset=UTF-8',
|
|
'X-Mailer' => 'PHP/' . phpversion()
|
|
];
|
|
|
|
return mail(
|
|
CONTACT_EMAIL,
|
|
$subject,
|
|
$body,
|
|
$headers
|
|
);
|
|
}
|
|
```
|
|
|
|
### 6.4 Client JavaScript (AJAX)
|
|
|
|
```javascript
|
|
// assets/js/contact-form.js
|
|
|
|
const ContactAPI = {
|
|
endpoint: '/api/contact.php',
|
|
|
|
async submit(formData, csrfToken, recaptchaToken) {
|
|
const payload = {
|
|
...formData,
|
|
csrf_token: csrfToken,
|
|
recaptcha_token: recaptchaToken
|
|
};
|
|
|
|
const response = await fetch(this.endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
throw new Error(data.error || 'Une erreur est survenue');
|
|
}
|
|
|
|
return data;
|
|
}
|
|
};
|
|
|
|
const RecaptchaService = {
|
|
siteKey: null,
|
|
|
|
init(siteKey) {
|
|
this.siteKey = siteKey;
|
|
},
|
|
|
|
async getToken(action = 'contact') {
|
|
return new Promise((resolve, reject) => {
|
|
if (!window.grecaptcha) {
|
|
console.warn('reCAPTCHA non disponible');
|
|
resolve('');
|
|
return;
|
|
}
|
|
|
|
grecaptcha.ready(() => {
|
|
grecaptcha.execute(this.siteKey, { action })
|
|
.then(resolve)
|
|
.catch(reject);
|
|
});
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Routing
|
|
|
|
### 7.1 Structure des URLs
|
|
|
|
| URL | Page | Description |
|
|
|-----|------|-------------|
|
|
| `/` | `pages/home.php` | Page d'accueil |
|
|
| `/projets` | `pages/projects.php` | Liste des projets |
|
|
| `/projet/{slug}` | `pages/project-single.php` | Page projet individuelle |
|
|
| `/competences` | `pages/skills.php` | Compétences & outils |
|
|
| `/a-propos` | `pages/about.php` | Me Découvrir |
|
|
| `/contact` | `pages/contact.php` | Formulaire de contact |
|
|
| `/api/contact` | `api/contact.php` | Endpoint formulaire (POST) |
|
|
| `/*` | `pages/404.php` | Page non trouvée |
|
|
|
|
### 7.2 Router PHP (< 50 lignes)
|
|
|
|
```php
|
|
<?php
|
|
// includes/router.php
|
|
|
|
class Router
|
|
{
|
|
private array $routes = [];
|
|
private string $basePath;
|
|
|
|
public function __construct(string $basePath = '')
|
|
{
|
|
$this->basePath = $basePath;
|
|
}
|
|
|
|
public function add(string $pattern, string $handler): self
|
|
{
|
|
$regex = preg_replace('/\{(\w+)\}/', '([^/]+)', $pattern);
|
|
$regex = '#^' . $regex . '$#';
|
|
$this->routes[$regex] = $handler;
|
|
return $this;
|
|
}
|
|
|
|
public function resolve(string $uri): array
|
|
{
|
|
$uri = parse_url($uri, PHP_URL_PATH);
|
|
$uri = rtrim($uri, '/') ?: '/';
|
|
|
|
foreach ($this->routes as $regex => $handler) {
|
|
if (preg_match($regex, $uri, $matches)) {
|
|
array_shift($matches);
|
|
return [$handler, $matches];
|
|
}
|
|
}
|
|
|
|
return ['pages/404.php', []];
|
|
}
|
|
|
|
public function dispatch(): void
|
|
{
|
|
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
|
[$handler, $params] = $this->resolve($uri);
|
|
|
|
$GLOBALS['routeParams'] = $params;
|
|
|
|
require __DIR__ . '/../' . $handler;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 7.3 Point d'entrée (index.php)
|
|
|
|
```php
|
|
<?php
|
|
// index.php - Front Controller
|
|
|
|
require_once __DIR__ . '/vendor/autoload.php';
|
|
require_once __DIR__ . '/config.php';
|
|
require_once __DIR__ . '/includes/functions.php';
|
|
require_once __DIR__ . '/includes/router.php';
|
|
|
|
session_start();
|
|
|
|
$router = new Router();
|
|
|
|
$router
|
|
->add('/', 'pages/home.php')
|
|
->add('/projets', 'pages/projects.php')
|
|
->add('/projet/{slug}', 'pages/project-single.php')
|
|
->add('/competences', 'pages/skills.php')
|
|
->add('/a-propos', 'pages/about.php')
|
|
->add('/contact', 'pages/contact.php');
|
|
|
|
$router->dispatch();
|
|
```
|
|
|
|
### 7.4 Helpers URL
|
|
|
|
```php
|
|
// includes/functions.php (suite)
|
|
|
|
function projectUrl(string $slug): string
|
|
{
|
|
return '/projet/' . urlencode($slug);
|
|
}
|
|
|
|
function absoluteUrl(string $path): string
|
|
{
|
|
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
|
return "{$protocol}://{$host}{$path}";
|
|
}
|
|
|
|
function isCurrentUrl(string $path): bool
|
|
{
|
|
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
|
$currentPath = rtrim($currentPath, '/') ?: '/';
|
|
$path = rtrim($path, '/') ?: '/';
|
|
return $currentPath === $path;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Styling Guidelines
|
|
|
|
### 8.1 Configuration Tailwind
|
|
|
|
```javascript
|
|
// tailwind.config.js
|
|
|
|
/** @type {import('tailwindcss').Config} */
|
|
module.exports = {
|
|
content: [
|
|
'./*.php',
|
|
'./pages/**/*.php',
|
|
'./templates/**/*.php',
|
|
'./assets/js/**/*.js'
|
|
],
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
primary: {
|
|
DEFAULT: '#FA784F',
|
|
light: '#FB9570',
|
|
dark: '#E5623A',
|
|
},
|
|
background: '#17171F',
|
|
surface: {
|
|
DEFAULT: '#1E1E28',
|
|
light: '#2A2A36',
|
|
},
|
|
border: '#3A3A48',
|
|
text: {
|
|
primary: '#F5F5F7',
|
|
secondary: '#A1A1AA',
|
|
muted: '#71717A',
|
|
},
|
|
success: '#34D399',
|
|
warning: '#FBBF24',
|
|
error: '#F87171',
|
|
info: '#60A5FA',
|
|
},
|
|
fontFamily: {
|
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
|
mono: ['JetBrains Mono', 'monospace'],
|
|
},
|
|
fontSize: {
|
|
'display': ['2.5rem', { lineHeight: '1.2', fontWeight: '700' }],
|
|
'heading': ['2rem', { lineHeight: '1.3', fontWeight: '600' }],
|
|
'subheading': ['1.5rem', { lineHeight: '1.4', fontWeight: '600' }],
|
|
'body': ['1rem', { lineHeight: '1.6', fontWeight: '400' }],
|
|
'small': ['0.875rem', { lineHeight: '1.5', fontWeight: '400' }],
|
|
},
|
|
maxWidth: {
|
|
'content': '1280px',
|
|
},
|
|
boxShadow: {
|
|
'card': '0 4px 20px rgba(0, 0, 0, 0.25)',
|
|
'card-hover': '0 10px 40px rgba(0, 0, 0, 0.3)',
|
|
'input-focus': '0 0 0 3px rgba(250, 120, 79, 0.2)',
|
|
},
|
|
},
|
|
},
|
|
plugins: [],
|
|
}
|
|
```
|
|
|
|
### 8.2 Fichier CSS Principal
|
|
|
|
```css
|
|
/* assets/css/input.css */
|
|
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
@layer base {
|
|
html {
|
|
@apply scroll-smooth;
|
|
}
|
|
|
|
body {
|
|
@apply bg-background text-text-primary font-sans antialiased;
|
|
}
|
|
|
|
h1 { @apply text-display text-text-primary; }
|
|
h2 { @apply text-heading text-text-primary; }
|
|
h3 { @apply text-subheading text-text-primary; }
|
|
p { @apply text-body text-text-secondary; }
|
|
|
|
a {
|
|
@apply text-primary hover:text-primary-light transition-colors duration-150;
|
|
}
|
|
|
|
:focus-visible {
|
|
@apply outline-none ring-2 ring-primary ring-offset-2 ring-offset-background;
|
|
}
|
|
|
|
::selection {
|
|
@apply bg-primary/30 text-text-primary;
|
|
}
|
|
}
|
|
|
|
@layer components {
|
|
.container-content {
|
|
@apply max-w-content mx-auto px-4 sm:px-6 lg:px-8;
|
|
}
|
|
|
|
.btn {
|
|
@apply inline-flex items-center justify-center gap-2
|
|
px-6 py-3 font-medium rounded-lg
|
|
transition-all duration-150
|
|
focus:outline-none focus:ring-2 focus:ring-offset-2
|
|
focus:ring-offset-background
|
|
disabled:opacity-50 disabled:cursor-not-allowed;
|
|
}
|
|
|
|
.btn-primary {
|
|
@apply btn bg-primary text-background
|
|
hover:bg-primary-light active:bg-primary-dark
|
|
focus:ring-primary;
|
|
}
|
|
|
|
.btn-secondary {
|
|
@apply btn border-2 border-primary text-primary bg-transparent
|
|
hover:bg-primary hover:text-background
|
|
focus:ring-primary;
|
|
}
|
|
|
|
.btn-ghost {
|
|
@apply btn text-primary bg-transparent
|
|
hover:text-primary-light hover:bg-surface-light
|
|
focus:ring-primary;
|
|
}
|
|
|
|
.badge {
|
|
@apply inline-flex items-center px-2.5 py-1
|
|
text-xs font-medium rounded
|
|
bg-surface-light text-text-secondary;
|
|
}
|
|
|
|
.badge-primary {
|
|
@apply bg-primary/20 text-primary;
|
|
}
|
|
|
|
.badge-muted {
|
|
@apply bg-border text-text-muted;
|
|
}
|
|
|
|
.card {
|
|
@apply bg-surface rounded-lg overflow-hidden
|
|
border border-border/50
|
|
transition-all duration-200;
|
|
}
|
|
|
|
.card-interactive {
|
|
@apply card cursor-pointer
|
|
hover:-translate-y-1 hover:shadow-card-hover
|
|
hover:border-border;
|
|
}
|
|
|
|
.card-body {
|
|
@apply p-4 sm:p-6;
|
|
}
|
|
|
|
.input {
|
|
@apply w-full px-4 py-3
|
|
bg-surface border border-border rounded-lg
|
|
text-text-primary placeholder-text-muted
|
|
transition-all duration-150
|
|
focus:outline-none focus:border-primary focus:shadow-input-focus;
|
|
}
|
|
|
|
.input-error {
|
|
@apply border-error focus:border-error
|
|
focus:shadow-[0_0_0_3px_rgba(248,113,113,0.2)];
|
|
}
|
|
|
|
.textarea {
|
|
@apply input min-h-[150px] resize-y;
|
|
}
|
|
|
|
.label {
|
|
@apply block text-sm font-medium text-text-secondary mb-2;
|
|
}
|
|
|
|
.label-required::after {
|
|
content: '*';
|
|
@apply text-error ml-1;
|
|
}
|
|
|
|
.error-message {
|
|
@apply text-sm text-error mt-1.5 flex items-center gap-1;
|
|
}
|
|
|
|
.section {
|
|
@apply py-16 sm:py-24;
|
|
}
|
|
|
|
.section-header {
|
|
@apply text-center mb-12;
|
|
}
|
|
|
|
.section-title {
|
|
@apply text-heading mb-4;
|
|
}
|
|
|
|
.section-subtitle {
|
|
@apply text-body text-text-secondary max-w-2xl mx-auto;
|
|
}
|
|
|
|
.testimonial {
|
|
@apply bg-surface-light rounded-lg p-6 border-l-4 border-primary;
|
|
}
|
|
|
|
.breadcrumb {
|
|
@apply flex items-center gap-2 text-sm text-text-muted;
|
|
}
|
|
|
|
.breadcrumb-link {
|
|
@apply text-text-secondary hover:text-primary transition-colors;
|
|
}
|
|
|
|
.breadcrumb-current {
|
|
@apply text-text-primary;
|
|
}
|
|
}
|
|
|
|
@layer utilities {
|
|
.animate-fade-in {
|
|
animation: fadeIn 0.6s ease-out forwards;
|
|
}
|
|
|
|
.animate-fade-in-up {
|
|
animation: fadeInUp 0.6s ease-out forwards;
|
|
}
|
|
|
|
.animation-delay-100 { animation-delay: 100ms; }
|
|
.animation-delay-200 { animation-delay: 200ms; }
|
|
.animation-delay-300 { animation-delay: 300ms; }
|
|
|
|
.aspect-thumbnail {
|
|
aspect-ratio: 16 / 9;
|
|
}
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
@keyframes fadeInUp {
|
|
from { opacity: 0; transform: translateY(20px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after {
|
|
animation-duration: 0.01ms !important;
|
|
transition-duration: 0.01ms !important;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 8.3 Scripts npm
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"dev": "tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --watch",
|
|
"build": "tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --minify"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Testing Requirements
|
|
|
|
### 9.1 Vue d'ensemble des tests
|
|
|
|
| Type | Outil/Méthode | Objectif | Fréquence |
|
|
|------|---------------|----------|-----------|
|
|
| **Validation HTML** | W3C Validator | Structure sémantique valide | Chaque page |
|
|
| **Validation CSS** | W3C CSS Validator | CSS sans erreurs | Après build |
|
|
| **Tests responsive** | DevTools + appareils | Affichage multi-écrans | Chaque feature |
|
|
| **Tests performance** | Lighthouse | Score > 90 | Avant déploiement |
|
|
| **Tests formulaire** | Manuel | Envoi/réception email | Après modification |
|
|
| **Tests sécurité** | Manuel + outils | XSS, CSRF, injection | Avant déploiement |
|
|
| **Tests accessibilité** | Lighthouse + axe | WCAG 2.1 AA | Chaque page |
|
|
|
|
### 9.2 Objectifs Lighthouse (PRD NFR9-12)
|
|
|
|
| Métrique | Objectif | Maximum |
|
|
|----------|----------|---------|
|
|
| Performance | > 90 | > 80 |
|
|
| Accessibility | > 90 | > 85 |
|
|
| Best Practices | > 90 | > 85 |
|
|
| SEO | > 90 | > 85 |
|
|
|
|
### 9.3 Core Web Vitals
|
|
|
|
| Métrique | Objectif | Maximum |
|
|
|----------|----------|---------|
|
|
| FCP (First Contentful Paint) | < 1.5s | < 2.5s |
|
|
| LCP (Largest Contentful Paint) | < 2.5s | < 4s |
|
|
| CLS (Cumulative Layout Shift) | < 0.1 | < 0.25 |
|
|
| TTI (Time to Interactive) | < 3s | < 5s |
|
|
|
|
### 9.4 Matrice de compatibilité navigateurs
|
|
|
|
| Navigateur | Version | Support |
|
|
|------------|---------|---------|
|
|
| Chrome | 90+ | Complet |
|
|
| Firefox | 90+ | Complet |
|
|
| Safari | 14+ | Complet |
|
|
| Edge | 90+ | Complet |
|
|
| Samsung Internet | 15+ | Complet |
|
|
| IE 11 | - | Non supporté |
|
|
|
|
---
|
|
|
|
## 10. Environment Configuration
|
|
|
|
### 10.1 Variables d'environnement (.env.example)
|
|
|
|
```env
|
|
# Application
|
|
APP_ENV=development
|
|
APP_DEBUG=true
|
|
APP_URL=http://localhost:8000
|
|
|
|
# reCAPTCHA v3
|
|
RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
|
|
RECAPTCHA_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
|
|
|
|
# Contact Email
|
|
CONTACT_EMAIL=contact@example.com
|
|
|
|
# SMTP (optionnel)
|
|
SMTP_ENABLED=false
|
|
SMTP_HOST=smtp.example.com
|
|
SMTP_PORT=587
|
|
SMTP_USERNAME=
|
|
SMTP_PASSWORD=
|
|
SMTP_ENCRYPTION=tls
|
|
|
|
# Sécurité
|
|
APP_SECRET=your_random_secret_key_here
|
|
```
|
|
|
|
### 10.2 Configuration PHP (config.php)
|
|
|
|
```php
|
|
<?php
|
|
require_once __DIR__ . '/vendor/autoload.php';
|
|
|
|
use Dotenv\Dotenv;
|
|
|
|
$dotenv = Dotenv::createImmutable(__DIR__);
|
|
$dotenv->load();
|
|
|
|
$dotenv->required([
|
|
'APP_ENV',
|
|
'APP_DEBUG',
|
|
'RECAPTCHA_SITE_KEY',
|
|
'RECAPTCHA_SECRET_KEY',
|
|
'CONTACT_EMAIL',
|
|
])->notEmpty();
|
|
|
|
// Constantes globales
|
|
define('APP_ENV', $_ENV['APP_ENV']);
|
|
define('APP_DEBUG', filter_var($_ENV['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN));
|
|
define('APP_URL', $_ENV['APP_URL'] ?? 'http://localhost:8000');
|
|
define('APP_SECRET', $_ENV['APP_SECRET'] ?? 'insecure-default-key');
|
|
|
|
define('RECAPTCHA_SITE_KEY', $_ENV['RECAPTCHA_SITE_KEY']);
|
|
define('RECAPTCHA_SECRET_KEY', $_ENV['RECAPTCHA_SECRET_KEY']);
|
|
define('RECAPTCHA_THRESHOLD', 0.5);
|
|
|
|
define('CONTACT_EMAIL', $_ENV['CONTACT_EMAIL']);
|
|
|
|
define('SMTP_ENABLED', filter_var($_ENV['SMTP_ENABLED'] ?? false, FILTER_VALIDATE_BOOLEAN));
|
|
define('SMTP_HOST', $_ENV['SMTP_HOST'] ?? '');
|
|
define('SMTP_PORT', (int) ($_ENV['SMTP_PORT'] ?? 587));
|
|
define('SMTP_USERNAME', $_ENV['SMTP_USERNAME'] ?? '');
|
|
define('SMTP_PASSWORD', $_ENV['SMTP_PASSWORD'] ?? '');
|
|
define('SMTP_ENCRYPTION', $_ENV['SMTP_ENCRYPTION'] ?? 'tls');
|
|
|
|
define('ROOT_PATH', __DIR__);
|
|
define('PAGES_PATH', __DIR__ . '/pages');
|
|
define('TEMPLATES_PATH', __DIR__ . '/templates');
|
|
define('DATA_PATH', __DIR__ . '/data');
|
|
define('ASSETS_PATH', __DIR__ . '/assets');
|
|
|
|
// Configuration PHP selon environnement
|
|
if (APP_DEBUG) {
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', '1');
|
|
} else {
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', '0');
|
|
ini_set('log_errors', '1');
|
|
ini_set('error_log', ROOT_PATH . '/logs/php-errors.log');
|
|
}
|
|
|
|
date_default_timezone_set('Europe/Paris');
|
|
mb_internal_encoding('UTF-8');
|
|
|
|
ini_set('session.cookie_httponly', '1');
|
|
ini_set('session.cookie_secure', APP_ENV === 'production' ? '1' : '0');
|
|
ini_set('session.cookie_samesite', 'Lax');
|
|
```
|
|
|
|
### 10.3 Configuration Nginx (production)
|
|
|
|
```nginx
|
|
server {
|
|
listen 80;
|
|
server_name monportfolio.fr www.monportfolio.fr;
|
|
return 301 https://$server_name$request_uri;
|
|
}
|
|
|
|
server {
|
|
listen 443 ssl http2;
|
|
server_name monportfolio.fr www.monportfolio.fr;
|
|
|
|
ssl_certificate /etc/letsencrypt/live/monportfolio.fr/fullchain.pem;
|
|
ssl_certificate_key /etc/letsencrypt/live/monportfolio.fr/privkey.pem;
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
|
|
root /var/www/portfolio;
|
|
index index.php;
|
|
charset utf-8;
|
|
|
|
# Headers de sécurité
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header X-Frame-Options "DENY" always;
|
|
add_header X-XSS-Protection "1; mode=block" always;
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
|
|
# Bloquer fichiers sensibles
|
|
location ~ /\.(env|git|htaccess) { deny all; return 404; }
|
|
location ^~ /vendor/ { deny all; return 404; }
|
|
location ^~ /node_modules/ { deny all; return 404; }
|
|
location ^~ /logs/ { deny all; return 404; }
|
|
location ^~ /data/ { deny all; return 404; }
|
|
|
|
# Assets statiques
|
|
location /assets/ {
|
|
expires 1y;
|
|
add_header Cache-Control "public, immutable";
|
|
gzip_static on;
|
|
}
|
|
|
|
# API
|
|
location ^~ /api/ {
|
|
try_files $uri /api/index.php?$query_string;
|
|
location ~ \.php$ {
|
|
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
|
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
|
include fastcgi_params;
|
|
}
|
|
}
|
|
|
|
# Router PHP
|
|
location / {
|
|
try_files $uri $uri/ /index.php?$query_string;
|
|
}
|
|
|
|
location ~ \.php$ {
|
|
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
|
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
|
include fastcgi_params;
|
|
}
|
|
|
|
# Compression
|
|
gzip on;
|
|
gzip_vary on;
|
|
gzip_min_length 1024;
|
|
gzip_types text/plain text/css text/javascript application/javascript application/json image/svg+xml;
|
|
}
|
|
```
|
|
|
|
### 10.4 Fichier .gitignore
|
|
|
|
```gitignore
|
|
# Environnement
|
|
.env
|
|
!.env.example
|
|
|
|
# Dépendances
|
|
/vendor/
|
|
/node_modules/
|
|
|
|
# Build
|
|
/assets/css/output.css
|
|
|
|
# Logs
|
|
/logs/*.log
|
|
|
|
# IDE & OS
|
|
.idea/
|
|
.vscode/
|
|
.DS_Store
|
|
Thumbs.db
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Frontend Developer Standards
|
|
|
|
### 11.1 Règles critiques (à ne jamais violer)
|
|
|
|
#### Sécurité
|
|
|
|
1. **TOUJOURS échapper les sorties PHP**
|
|
```php
|
|
<?= htmlspecialchars($var, ENT_QUOTES, 'UTF-8') ?>
|
|
```
|
|
|
|
2. **JAMAIS faire confiance aux entrées utilisateur** - Valider côté serveur
|
|
|
|
3. **TOUJOURS valider le token CSRF sur les formulaires POST**
|
|
|
|
4. **JAMAIS exposer les erreurs PHP en production**
|
|
|
|
#### Performance
|
|
|
|
5. **TOUJOURS utiliser lazy loading sur les images below-the-fold**
|
|
```html
|
|
<img loading="lazy" ...>
|
|
```
|
|
|
|
6. **TOUJOURS spécifier width/height sur les images** (évite CLS)
|
|
|
|
7. **TOUJOURS utiliser le format WebP avec fallback**
|
|
|
|
8. **JAMAIS charger de JS bloquant dans le `<head>`**
|
|
|
|
#### Accessibilité
|
|
|
|
9. **TOUJOURS associer les labels aux inputs**
|
|
```html
|
|
<label for="email">Email</label><input id="email">
|
|
```
|
|
|
|
10. **TOUJOURS fournir un texte alt pour les images informatives**
|
|
|
|
11. **JAMAIS supprimer le focus outline sans alternative**
|
|
|
|
12. **TOUJOURS respecter la hiérarchie des titres** (H1 → H2 → H3)
|
|
|
|
13. **UN SEUL H1 par page**
|
|
|
|
14. **TOUJOURS utiliser les landmarks HTML5** (`<header>`, `<nav>`, `<main>`, `<footer>`)
|
|
|
|
### 11.2 Quick Reference
|
|
|
|
#### Commandes fréquentes
|
|
|
|
```bash
|
|
# Développement
|
|
php -S localhost:8000 # Serveur PHP local
|
|
npm run dev # Watch Tailwind CSS
|
|
|
|
# Build
|
|
npm run build # Compile + minify CSS
|
|
|
|
# Composer
|
|
composer install # Installer dépendances
|
|
```
|
|
|
|
#### Patterns d'import PHP
|
|
|
|
```php
|
|
// Dans index.php
|
|
require_once __DIR__ . '/vendor/autoload.php';
|
|
require_once __DIR__ . '/config.php';
|
|
require_once __DIR__ . '/includes/functions.php';
|
|
require_once __DIR__ . '/includes/router.php';
|
|
|
|
// Dans les pages
|
|
include_template('header', ['title' => $pageTitle]);
|
|
include_template('navbar');
|
|
include_template('footer');
|
|
|
|
// Récupération de données
|
|
$projects = getProjects();
|
|
$project = getProjectBySlug($slug);
|
|
```
|
|
|
|
#### Patterns Tailwind courants
|
|
|
|
```html
|
|
<!-- Container -->
|
|
<div class="container-content">
|
|
|
|
<!-- Grille responsive -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
|
|
<!-- Carte interactive -->
|
|
<article class="card-interactive">
|
|
<div class="card-body">
|
|
|
|
<!-- Boutons -->
|
|
<a href="/contact" class="btn-primary">Me contacter</a>
|
|
<a href="/projets" class="btn-secondary">Voir les projets</a>
|
|
|
|
<!-- Badge -->
|
|
<span class="badge">PHP</span>
|
|
```
|
|
|
|
#### Checklist avant commit
|
|
|
|
- [ ] Code testé localement
|
|
- [ ] Pas d'erreurs dans la console
|
|
- [ ] CSS compilé (`npm run build`)
|
|
- [ ] Responsive vérifié
|
|
- [ ] Données sensibles non commitées
|
|
|
|
---
|
|
|
|
## 12. Ressources
|
|
|
|
| Ressource | URL |
|
|
|-----------|-----|
|
|
| Tailwind CSS Docs | https://tailwindcss.com/docs |
|
|
| Heroicons | https://heroicons.com |
|
|
| W3C Validator | https://validator.w3.org |
|
|
| Can I Use | https://caniuse.com |
|
|
| PHP Manual | https://www.php.net/manual/fr |
|
|
|
|
---
|
|
|
|
*Document généré par Winston (Architect) - Méthode BMAD*
|