✨ 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>
This commit is contained in:
@@ -92,3 +92,52 @@ function getAllTechnologies(): array
|
|||||||
sort($technologies);
|
sort($technologies);
|
||||||
return $technologies;
|
return $technologies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le HTML pour une image projet optimisée
|
||||||
|
* Utilise <picture> pour WebP avec fallback JPG
|
||||||
|
*
|
||||||
|
* @param string $filename Nom du fichier image (ex: project-thumb.webp)
|
||||||
|
* @param string $alt Texte alternatif
|
||||||
|
* @param int $width Largeur en pixels
|
||||||
|
* @param int $height Hauteur en pixels
|
||||||
|
* @param bool $lazy Activer le lazy loading (défaut: true)
|
||||||
|
* @param string $class Classes CSS additionnelles
|
||||||
|
* @return string HTML de l'image
|
||||||
|
*/
|
||||||
|
function projectImage(string $filename, string $alt, int $width, int $height, bool $lazy = true, string $class = ''): string
|
||||||
|
{
|
||||||
|
$alt = htmlspecialchars($alt, ENT_QUOTES, 'UTF-8');
|
||||||
|
$class = htmlspecialchars($class, ENT_QUOTES, 'UTF-8');
|
||||||
|
$lazyAttr = $lazy ? 'loading="lazy"' : '';
|
||||||
|
|
||||||
|
// Détermine les chemins WebP et fallback
|
||||||
|
$basePath = '/assets/img/projects/';
|
||||||
|
$webpFile = $filename;
|
||||||
|
|
||||||
|
// Si le fichier n'est pas .webp, on essaie de trouver la version .webp
|
||||||
|
if (!str_ends_with($filename, '.webp')) {
|
||||||
|
$webpFile = preg_replace('/\.(jpg|jpeg|png)$/i', '.webp', $filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: remplace .webp par .jpg
|
||||||
|
$fallbackFile = str_replace('.webp', '.jpg', $webpFile);
|
||||||
|
|
||||||
|
// Image par défaut si fichier manquant
|
||||||
|
$defaultImage = $basePath . 'default-project.svg';
|
||||||
|
|
||||||
|
return <<<HTML
|
||||||
|
<picture>
|
||||||
|
<source srcset="{$basePath}{$webpFile}" type="image/webp">
|
||||||
|
<img
|
||||||
|
src="{$basePath}{$fallbackFile}"
|
||||||
|
alt="{$alt}"
|
||||||
|
width="{$width}"
|
||||||
|
height="{$height}"
|
||||||
|
{$lazyAttr}
|
||||||
|
class="{$class}"
|
||||||
|
onerror="this.onerror=null; this.src='{$defaultImage}';"
|
||||||
|
>
|
||||||
|
</picture>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
<!-- Boutons d'action -->
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<p class="text-text-secondary text-center">
|
<!-- Navigation bas de page -->
|
||||||
Page en construction - Story 3.4
|
<footer class="mt-16 pt-8 border-t border-border flex flex-wrap justify-between items-center gap-4">
|
||||||
</p>
|
<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>
|
</div>
|
||||||
</section>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<?php include_template('footer'); ?>
|
<?php include_template('footer'); ?>
|
||||||
|
|||||||
@@ -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'); ?>
|
||||||
|
|||||||
57
templates/project-card-compact.php
Normal file
57
templates/project-card-compact.php
Normal 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>
|
||||||
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user