perf: create optimized thumbnails for project cards - 96% size reduction (615KB -> 24KB) for card preview images
@ -11,6 +11,7 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"optimize-images": "node scripts/optimize-images.js",
|
||||
"create-thumbnails": "node scripts/create-thumbnails.js",
|
||||
"analyze": "node scripts/analyze-bundle.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
BIN
public/images/projects/cloud_1_thumb.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/images/projects/cub3d_thumb.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/images/projects/homemade_nas_thumb.webp
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/images/projects/minishell_thumb.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/images/projects/pong_thumb.webp
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/images/sites/etsidemain/mookup/3-devices-white_thumb.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
83
scripts/create-thumbnails.js
Normal file
@ -0,0 +1,83 @@
|
||||
import sharp from 'sharp';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { join, extname, dirname, basename } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const publicDir = join(__dirname, '..', 'public', 'images');
|
||||
|
||||
// Liste des images utilisées dans les cartes projet (miniatures)
|
||||
const thumbnailImages = [
|
||||
'projects/homemade_nas.webp',
|
||||
'projects/pong.webp',
|
||||
'projects/cloud_1.webp',
|
||||
'projects/minishell.webp',
|
||||
'projects/cub3d.webp',
|
||||
'sites/avopieces/mookup/3-devices-white (1).webp',
|
||||
'sites/etsidemain/mookup/3-devices-white.webp'
|
||||
];
|
||||
|
||||
async function createThumbnail(filePath, size = 128) {
|
||||
const ext = extname(filePath);
|
||||
const dir = dirname(filePath);
|
||||
const name = basename(filePath, ext);
|
||||
const thumbnailPath = join(dir, `${name}_thumb${ext}`);
|
||||
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
const originalSize = stats.size;
|
||||
|
||||
// Créer une miniature carrée de 128x128
|
||||
await sharp(filePath)
|
||||
.resize(size, size, {
|
||||
fit: 'cover',
|
||||
position: 'center'
|
||||
})
|
||||
.webp({
|
||||
quality: 85,
|
||||
effort: 6
|
||||
})
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
const newStats = await stat(thumbnailPath);
|
||||
const newSize = newStats.size;
|
||||
const reduction = ((originalSize - newSize) / originalSize * 100).toFixed(2);
|
||||
|
||||
console.log(`✅ ${basename(filePath)}`);
|
||||
console.log(` Original: ${(originalSize / 1024).toFixed(2)} KB`);
|
||||
console.log(` Miniature: ${(newSize / 1024).toFixed(2)} KB`);
|
||||
console.log(` Réduction: ${reduction}%\n`);
|
||||
|
||||
return { originalSize, newSize };
|
||||
} catch (error) {
|
||||
console.error(`❌ Erreur avec ${filePath}:`, error.message);
|
||||
return { originalSize: 0, newSize: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🖼️ Création des miniatures pour les cartes projet...\n');
|
||||
|
||||
let totalOriginal = 0;
|
||||
let totalNew = 0;
|
||||
|
||||
for (const imagePath of thumbnailImages) {
|
||||
const fullPath = join(publicDir, imagePath);
|
||||
const { originalSize, newSize } = await createThumbnail(fullPath);
|
||||
totalOriginal += originalSize;
|
||||
totalNew += newSize;
|
||||
}
|
||||
|
||||
const totalReduction = ((totalOriginal - totalNew) / totalOriginal * 100).toFixed(2);
|
||||
|
||||
console.log('=' .repeat(50));
|
||||
console.log(`📊 RÉSUMÉ DES MINIATURES:`);
|
||||
console.log(` Taille originale: ${(totalOriginal / 1024).toFixed(2)} KB`);
|
||||
console.log(` Taille miniatures: ${(totalNew / 1024).toFixed(2)} KB`);
|
||||
console.log(` Économie totale: ${totalReduction}%`);
|
||||
console.log(` ${(totalOriginal - totalNew) / 1024} KB économisés! 🎉`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@ -28,22 +28,22 @@ export const ProjectCard = ({ title, description, icon, image, technologies, del
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay }}
|
||||
whileHover={{ y: -5 }}
|
||||
whileHover={{ y: -5, transition: { duration: 0.2 } }}
|
||||
onClick={handleClick}
|
||||
className={projectId ? "cursor-pointer" : ""}
|
||||
>
|
||||
<Card className="h-full hover:shadow-lg transition-all duration-300 border-border/50 bg-card/50 backdrop-blur relative overflow-hidden group">
|
||||
<Card className="h-full hover:shadow-lg transition-all duration-200 border-border/50 bg-card/50 backdrop-blur relative overflow-hidden group">
|
||||
{/* Indicateur cliquable en bas à droite */}
|
||||
{projectId && (
|
||||
<div className="absolute bottom-4 right-4 w-8 h-8 rounded-full bg-primary/10 group-hover:bg-primary/20 flex items-center justify-center transition-all duration-300 group-hover:scale-110">
|
||||
<ArrowRight className="w-4 h-4 text-primary group-hover:translate-x-0.5 transition-transform duration-300" />
|
||||
<div className="absolute bottom-4 right-4 w-8 h-8 rounded-full bg-primary/10 group-hover:bg-primary/20 flex items-center justify-center transition-all duration-200 group-hover:scale-110">
|
||||
<ArrowRight className="w-4 h-4 text-primary group-hover:translate-x-0.5 transition-transform duration-200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{image && (
|
||||
<div className="absolute top-4 right-4 w-16 h-16 rounded-lg overflow-hidden border-2 border-background/20 shadow-lg">
|
||||
<img
|
||||
src={image}
|
||||
src={image.replace('.webp', '_thumb.webp')}
|
||||
alt={`${title} preview`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||