Performance: PageSpeed Optimierungen implementiert

- LCP-Bild (Portrait) mit fetchpriority="high" und Preload optimiert
- Logo SVG mit expliziten width/height Attributen versehen (CLS)
- Touch-Targets in Experience Navigation von 5px auf 24px vergrößert
- Kontrast für Firmenname/Zeitraum verbessert (zinc-300/zinc-400)
- PWA Service Worker mit script-defer für nicht-blockierendes Laden
- Responsive Bilder: srcset mit 400w/800w/1200w + WebP-Format
- Neues Build-Script für Bildoptimierung (scripts/optimize-images.js)
- Original-Bilder in source-images/ verschoben (gitignored)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-05 21:14:29 +01:00
co-authored by Claude Opus 4.5
parent bda8e34355
commit 588d6e1f17
84 changed files with 166 additions and 23 deletions
+3
View File
@@ -79,3 +79,6 @@ supabase/.temp/
*.sublime-workspace *.sublime-workspace
*.sublime-project *.sublime-project
.history/ .history/
# Source images (originals before optimization)
source-images/
+1
View File
@@ -125,6 +125,7 @@
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<!-- Preload wichtiger Assets --> <!-- Preload wichtiger Assets -->
<link rel="preload" href="/portrait.jpg" as="image" fetchpriority="high" />
<link rel="preload" href="/header-logo.svg" as="image" /> <link rel="preload" href="/header-logo.svg" as="image" />
<link rel="preload" href="/favicon.svg" as="image" /> <link rel="preload" href="/favicon.svg" as="image" />
</head> </head>
+1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "npm run generate-sitemap && vite build", "build": "npm run generate-sitemap && vite build",
"build:images": "node scripts/optimize-images.js",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+115
View File
@@ -0,0 +1,115 @@
import sharp from 'sharp';
import { readdir, mkdir } from 'fs/promises';
import { join, dirname, basename, extname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SIZES = [400, 800, 1200];
const INPUT_DIR = join(__dirname, '../source-images/projects');
const OUTPUT_DIR = join(__dirname, '../public/images/projects');
async function processImage(inputPath, projectSlug) {
const filename = basename(inputPath, extname(inputPath));
const ext = extname(inputPath).toLowerCase();
// Skip non-image files
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
return;
}
const outputDir = join(OUTPUT_DIR, projectSlug);
// Create output directory if it doesn't exist
await mkdir(outputDir, { recursive: true });
console.log(`Processing: ${projectSlug}/${filename}${ext}`);
// Create optimized original size (cover.jpg)
const originalOutputPath = join(outputDir, `${filename}.jpg`);
try {
await sharp(inputPath)
.jpeg({ quality: 85, progressive: true })
.toFile(originalOutputPath);
console.log(` Created: ${filename}.jpg (optimized original)`);
} catch (error) {
console.error(` Error creating optimized original:`, error.message);
}
// Create different sizes
for (const width of SIZES) {
const outputFilename = `${filename}-${width}w.jpg`;
const outputPath = join(outputDir, outputFilename);
try {
await sharp(inputPath)
.resize(width, null, {
withoutEnlargement: true,
fit: 'inside'
})
.jpeg({ quality: 80, progressive: true })
.toFile(outputPath);
console.log(` Created: ${outputFilename}`);
} catch (error) {
console.error(` Error creating ${outputFilename}:`, error.message);
}
}
// Create WebP version of original size
const webpFilename = `${filename}.webp`;
const webpPath = join(outputDir, webpFilename);
try {
await sharp(inputPath)
.webp({ quality: 80 })
.toFile(webpPath);
console.log(` Created: ${webpFilename}`);
} catch (error) {
console.error(` Error creating WebP:`, error.message);
}
}
async function processDirectory(dirPath) {
if (!existsSync(dirPath)) {
console.error(`Error: Source directory not found: ${dirPath}`);
console.log('\nPlease ensure original images are placed in:');
console.log(' source-images/projects/<project-slug>/cover.jpg');
process.exit(1);
}
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
// Check for cover.jpg in this project directory
const coverPath = join(fullPath, 'cover.jpg');
if (existsSync(coverPath)) {
await processImage(coverPath, entry.name);
} else {
console.log(`Skipping ${entry.name}: no cover.jpg found`);
}
}
}
}
async function main() {
console.log('Starting image optimization...');
console.log(`Source: ${INPUT_DIR}`);
console.log(`Output: ${OUTPUT_DIR}\n`);
try {
await processDirectory(INPUT_DIR);
console.log('\nImage optimization complete!');
} catch (error) {
console.error('Error during optimization:', error);
process.exit(1);
}
}
main();
+2
View File
@@ -156,6 +156,8 @@ const Layout = ({ children }: LayoutProps) => {
src="/header-logo.svg" src="/header-logo.svg"
alt="Damjan Savić Logo" alt="Damjan Savić Logo"
className="h-8 w-auto" className="h-8 w-auto"
width={120}
height={32}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
transition={{ type: "spring", stiffness: 400, damping: 10 }} transition={{ type: "spring", stiffness: 400, damping: 10 }}
/> />
+16 -9
View File
@@ -117,10 +117,10 @@ const Experience = () => {
<h3 className="text-lg md:text-xl font-semibold text-white mb-1"> <h3 className="text-lg md:text-xl font-semibold text-white mb-1">
{exp.role} {exp.role}
</h3> </h3>
<h4 className="text-base md:text-lg text-zinc-400 mb-2"> <h4 className="text-base md:text-lg text-zinc-300 mb-2">
{exp.company} {exp.company}
</h4> </h4>
<p className="text-sm text-zinc-500 mb-6"> <p className="text-sm text-zinc-400 mb-6">
{exp.period} {exp.period}
</p> </p>
<ul className="space-y-3 md:space-y-4"> <ul className="space-y-3 md:space-y-4">
@@ -158,25 +158,32 @@ const Experience = () => {
> >
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
{[...Array(totalPages)].map((_, index) => ( {[...Array(totalPages)].map((_, index) => (
<button <button
key={index} key={index}
onClick={() => setCurrentPage(index)} onClick={() => setCurrentPage(index)}
className="relative flex items-center justify-center transition-all duration-300"
style={{
width: '24px',
height: '24px',
minWidth: '24px',
minHeight: '24px'
}}
aria-label={t('pages.home.experience.navigation.page', { page: index + 1 })}
>
<span
className={`rounded-full transition-all duration-300 ${ className={`rounded-full transition-all duration-300 ${
index === currentPage index === currentPage
? 'bg-white' ? 'bg-white'
: 'bg-zinc-600 hover:bg-zinc-400' : 'bg-zinc-600 hover:bg-zinc-400'
}`} }`}
style={{ style={{
width: '5px', width: '8px',
height: '5px', height: '8px'
minWidth: '5px',
minHeight: '5px',
padding: '0'
}} }}
aria-label={t('pages.home.experience.navigation.page', { page: index + 1 })}
/> />
</button>
))} ))}
</div> </div>
<button <button
+3
View File
@@ -76,6 +76,9 @@ const Hero = () => {
alt={t('pages.home.hero.image.alt')} alt={t('pages.home.hero.image.alt')}
className="w-full h-full object-cover" className="w-full h-full object-cover"
loading="eager" loading="eager"
fetchPriority="high"
width={288}
height={288}
style={{ transform: 'scale(1.01)' }} style={{ transform: 'scale(1.01)' }}
/> />
</motion.div> </motion.div>
@@ -30,6 +30,8 @@ const PortfolioCard: React.FC<PortfolioCardProps> = ({ project }) => {
}; };
const imagePath = `/images/projects/${project.slug}/cover.jpg`; const imagePath = `/images/projects/${project.slug}/cover.jpg`;
const imagePathWebP = `/images/projects/${project.slug}/cover.webp`;
const srcSet = `/images/projects/${project.slug}/cover-400w.jpg 400w, /images/projects/${project.slug}/cover-800w.jpg 800w, /images/projects/${project.slug}/cover-1200w.jpg 1200w`;
// Verwende technologies anstelle von tags wenn verfügbar // Verwende technologies anstelle von tags wenn verfügbar
const displayTags = project.technologies || project.tags; const displayTags = project.technologies || project.tags;
@@ -60,14 +62,22 @@ const PortfolioCard: React.FC<PortfolioCardProps> = ({ project }) => {
> >
{/* Image Container */} {/* Image Container */}
<div className="aspect-[16/9] w-full relative overflow-hidden bg-zinc-900"> <div className="aspect-[16/9] w-full relative overflow-hidden bg-zinc-900">
<picture>
<source srcSet={imagePathWebP} type="image/webp" />
<img <img
src={imagePath} src={imagePath}
srcSet={srcSet}
sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"
alt={project.title} alt={project.title}
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700 className="absolute inset-0 w-full h-full object-cover transition-transform duration-700
group-hover:scale-110" group-hover:scale-110"
loading="lazy" loading="lazy"
width={1200}
height={675}
decoding="async"
style={{ transformOrigin: 'center center' }} style={{ transformOrigin: 'center center' }}
/> />
</picture>
</div> </div>
{/* Content - with relative positioning to overlap the gradient */} {/* Content - with relative positioning to overlap the gradient */}
+1
View File
@@ -28,6 +28,7 @@ export default defineConfig({
VitePWA({ VitePWA({
strategies: 'generateSW', strategies: 'generateSW',
registerType: 'autoUpdate', registerType: 'autoUpdate',
injectRegister: 'script-defer',
manifest: { manifest: {
name: 'Damjan Savić - JTL Integration Expert', name: 'Damjan Savić - JTL Integration Expert',
short_name: 'Damjan Savić', short_name: 'Damjan Savić',