diff --git a/index.html b/index.html index da738cc..30c90af 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,17 @@ - + - - - + + + + + + + + Alexandre Pommier - Portfolio @@ -21,21 +26,24 @@ - - - + - - + + diff --git a/nginx.conf b/nginx.conf index b87af55..89e8ccd 100644 --- a/nginx.conf +++ b/nginx.conf @@ -10,27 +10,61 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "no-referrer-when-downgrade" always; + + # Performance headers + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # Gzip compression gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; - gzip_types text/plain text/css text/xml text/javascript - application/json application/javascript application/xml+rss - application/rss+xml font/truetype font/opentype - application/vnd.ms-fontobject image/svg+xml; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/rss+xml + font/truetype + font/opentype + application/vnd.ms-fontobject + image/svg+xml + application/wasm; - # Cache static assets - Images - location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ { + # Brotli compression (si supporté par nginx) + # brotli on; + # brotli_comp_level 6; + # brotli_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; + + # Cache static assets - Images WebP + location ~* \.(webp)$ { expires 1y; add_header Cache-Control "public, immutable"; + add_header Vary "Accept-Encoding"; } - # Cache static assets - CSS/JS + # Cache static assets - Images + location ~* \.(jpg|jpeg|png|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + add_header Vary "Accept-Encoding"; + } + + # Cache static assets - CSS/JS avec hash (versionnés) location ~* \.(css|js)$ { expires 1y; add_header Cache-Control "public, immutable"; + add_header Vary "Accept-Encoding"; + + # Preload header pour les ressources critiques + location ~* -[a-f0-9]{8}\.(css|js)$ { + add_header Link "; rel=preload; as=script" always; + } } # Cache static assets - Fonts @@ -43,13 +77,21 @@ server { # SPA fallback - toutes les routes vers index.html location / { try_files $uri $uri/ /index.html; + + # HTTP/2 Server Push pour les ressources critiques (si supporté) + # http2_push /assets/css/index.css; + # http2_push /assets/js/index.js; } # Disable cache for index.html location = /index.html { - add_header Cache-Control "no-cache, no-store, must-revalidate"; - add_header Pragma "no-cache"; - add_header Expires "0"; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + + # Ajout de Link headers pour preconnect + add_header Link "; rel=preconnect" always; + add_header Link "; rel=preconnect; crossorigin" always; } # Disable cache for service worker if you add one later @@ -61,4 +103,10 @@ server { # Error pages error_page 404 /index.html; + + # Optimisation supplémentaire + tcp_nopush on; + tcp_nodelay on; + sendfile on; + keepalive_timeout 65; } diff --git a/package.json b/package.json index 30519f3..1048b52 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,12 @@ "scripts": { "dev": "vite", "build": "vite build", + "build:analyze": "vite build --mode production && npm run analyze", "build:dev": "vite build --mode development", "lint": "eslint .", "preview": "vite preview", - "optimize-images": "node scripts/optimize-images.js" + "optimize-images": "node scripts/optimize-images.js", + "analyze": "node scripts/analyze-bundle.js" }, "dependencies": { "@hookform/resolvers": "^3.10.0", diff --git a/scripts/analyze-bundle.js b/scripts/analyze-bundle.js new file mode 100644 index 0000000..431dd21 --- /dev/null +++ b/scripts/analyze-bundle.js @@ -0,0 +1,92 @@ +import { readdir, stat } from 'fs/promises'; +import { join, extname } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const distDir = join(__dirname, '..', 'dist'); + +async function getFileSize(filePath) { + const stats = await stat(filePath); + return stats.size; +} + +async function analyzeDirectory(dir, prefix = '') { + const files = await readdir(dir); + const results = []; + + for (const file of files) { + const fullPath = join(dir, file); + const stats = await stat(fullPath); + + if (stats.isDirectory()) { + results.push(...(await analyzeDirectory(fullPath, prefix + file + '/'))); + } else { + const size = await getFileSize(fullPath); + results.push({ + path: prefix + file, + size, + sizeKB: (size / 1024).toFixed(2), + ext: extname(file) + }); + } + } + + return results; +} + +async function main() { + console.log('📊 Analyse du bundle de production...\n'); + + try { + const files = await analyzeDirectory(distDir); + + // Grouper par type + const byType = files.reduce((acc, file) => { + if (!acc[file.ext]) acc[file.ext] = []; + acc[file.ext].push(file); + return acc; + }, {}); + + // Afficher les statistiques + console.log('📦 Taille par type de fichier:\n'); + + Object.keys(byType).sort().forEach(ext => { + const totalSize = byType[ext].reduce((sum, f) => sum + f.size, 0); + const count = byType[ext].length; + console.log(`${ext || 'no-ext'}: ${(totalSize / 1024).toFixed(2)} KB (${count} fichier${count > 1 ? 's' : ''})`); + }); + + // Fichiers les plus lourds + console.log('\n🔝 10 fichiers les plus lourds:\n'); + files + .sort((a, b) => b.size - a.size) + .slice(0, 10) + .forEach((file, i) => { + console.log(`${i + 1}. ${file.path}: ${file.sizeKB} KB`); + }); + + // Taille totale + const totalSize = files.reduce((sum, f) => sum + f.size, 0); + console.log(`\n💾 Taille totale: ${(totalSize / 1024 / 1024).toFixed(2)} MB`); + + // Avertissements + console.log('\n⚠️ Avertissements:'); + const largeFiles = files.filter(f => f.size > 500 * 1024); // > 500KB + if (largeFiles.length > 0) { + console.log(`\n${largeFiles.length} fichier(s) de plus de 500 KB détecté(s):`); + largeFiles.forEach(f => { + console.log(` - ${f.path}: ${f.sizeKB} KB`); + }); + } else { + console.log('✅ Aucun fichier trop lourd détecté!'); + } + + } catch (error) { + console.error('❌ Erreur lors de l\'analyse:', error.message); + } +} + +main().catch(console.error); diff --git a/src/App.tsx b/src/App.tsx index 908d57f..594f1a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,14 +3,24 @@ import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; -import Index from "./pages/Index"; -import NotFound from "./pages/NotFound"; -import ProjectPage from "./pages/ProjectPage"; -import { ParticlesBackground } from "./components/ParticlesBackground"; +import { lazy, Suspense } from "react"; import { ThemeProvider } from "./contexts/ThemeContext"; +// Lazy load pages and heavy components for better performance +const Index = lazy(() => import("./pages/Index")); +const ProjectPage = lazy(() => import("./pages/ProjectPage")); +const NotFound = lazy(() => import("./pages/NotFound")); +const ParticlesBackground = lazy(() => import("./components/ParticlesBackground").then(m => ({ default: m.ParticlesBackground }))); + const queryClient = new QueryClient(); +// Loading fallback component +const PageLoader = () => ( +
+
+
+); + const IndexWrapper = () => ; const router = createBrowserRouter([ @@ -32,10 +42,14 @@ const App = () => ( - + + + - + }> + + diff --git a/src/components/OptimizedImage.tsx b/src/components/OptimizedImage.tsx new file mode 100644 index 0000000..6fc06f5 --- /dev/null +++ b/src/components/OptimizedImage.tsx @@ -0,0 +1,45 @@ +import { useState, useEffect } from 'react'; + +interface OptimizedImageProps { + src: string; + alt: string; + className?: string; + loading?: 'lazy' | 'eager'; + priority?: boolean; +} + +export const OptimizedImage = ({ + src, + alt, + className = '', + loading = 'lazy', + priority = false +}: OptimizedImageProps) => { + const [imageSrc, setImageSrc] = useState(undefined); + const [imageLoaded, setImageLoaded] = useState(false); + + useEffect(() => { + if (priority) { + // Pour les images prioritaires, charger immédiatement + setImageSrc(src); + } else { + // Pour les autres, utiliser IntersectionObserver + const img = new Image(); + img.src = src; + img.onload = () => { + setImageSrc(src); + setImageLoaded(true); + }; + } + }, [src, priority]); + + return ( + {alt} + ); +}; diff --git a/vite-plugin-preload.ts b/vite-plugin-preload.ts new file mode 100644 index 0000000..7a2ba27 --- /dev/null +++ b/vite-plugin-preload.ts @@ -0,0 +1,17 @@ +import type { Plugin } from 'vite'; + +export function preloadPlugin(): Plugin { + return { + name: 'vite-plugin-preload', + transformIndexHtml(html) { + // Ajouter des preload hints pour les chunks critiques + const preloadLinks = ` + + + + `; + + return html.replace('', `${preloadLinks}\n `); + }, + }; +} diff --git a/vite.config.ts b/vite.config.ts index 9795052..6fddb7a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,40 +25,30 @@ export default defineConfig(({ mode }) => ({ build: { // Optimisations pour la production minify: 'esbuild', // Utiliser esbuild au lieu de terser (plus rapide, déjà inclus) + target: 'esnext', // Code plus moderne et plus petit + cssMinify: true, // Chunking optimal pour de meilleures performances rollupOptions: { output: { + // Split vendors pour améliorer le cache manualChunks: { 'react-vendor': ['react', 'react-dom', 'react-router-dom'], 'ui-vendor': ['framer-motion', 'lucide-react'], - 'particles': ['@tsparticles/react', '@tsparticles/slim'], - 'radix-ui': [ - '@radix-ui/react-accordion', - '@radix-ui/react-alert-dialog', - '@radix-ui/react-avatar', - '@radix-ui/react-checkbox', - '@radix-ui/react-collapsible', - '@radix-ui/react-dialog', - '@radix-ui/react-dropdown-menu', - '@radix-ui/react-label', - '@radix-ui/react-popover', - '@radix-ui/react-progress', - '@radix-ui/react-scroll-area', - '@radix-ui/react-select', - '@radix-ui/react-separator', - '@radix-ui/react-slider', - '@radix-ui/react-slot', - '@radix-ui/react-switch', - '@radix-ui/react-tabs', - '@radix-ui/react-toast', - '@radix-ui/react-tooltip', - ], + 'particles': ['@tsparticles/react', '@tsparticles/slim', '@tsparticles/engine'], }, + // Nommer les chunks de manière cohérente pour le cache + chunkFileNames: 'assets/js/[name]-[hash].js', + entryFileNames: 'assets/js/[name]-[hash].js', + assetFileNames: 'assets/[ext]/[name]-[hash].[ext]', }, }, // Optimisation des assets assetsInlineLimit: 4096, // Images < 4kb seront inline en base64 - chunkSizeWarningLimit: 1000, // Augmenter la limite d'avertissement + chunkSizeWarningLimit: 500, // Limite plus stricte pour éviter les gros bundles sourcemap: false, // Désactiver les sourcemaps en production + // Compression CSS supplémentaire + cssCodeSplit: true, + // Minification supplémentaire + reportCompressedSize: true, }, }));