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>
@@ -79,3 +79,6 @@ supabase/.temp/
|
|||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
*.sublime-project
|
*.sublime-project
|
||||||
.history/
|
.history/
|
||||||
|
|
||||||
|
# Source images (originals before optimization)
|
||||||
|
source-images/
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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();
|
||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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={`rounded-full transition-all duration-300 ${
|
className="relative flex items-center justify-center transition-all duration-300"
|
||||||
index === currentPage
|
|
||||||
? 'bg-white'
|
|
||||||
: 'bg-zinc-600 hover:bg-zinc-400'
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
width: '5px',
|
width: '24px',
|
||||||
height: '5px',
|
height: '24px',
|
||||||
minWidth: '5px',
|
minWidth: '24px',
|
||||||
minHeight: '5px',
|
minHeight: '24px'
|
||||||
padding: '0'
|
|
||||||
}}
|
}}
|
||||||
aria-label={t('pages.home.experience.navigation.page', { page: index + 1 })}
|
aria-label={t('pages.home.experience.navigation.page', { page: index + 1 })}
|
||||||
/>
|
>
|
||||||
|
<span
|
||||||
|
className={`rounded-full transition-all duration-300 ${
|
||||||
|
index === currentPage
|
||||||
|
? 'bg-white'
|
||||||
|
: 'bg-zinc-600 hover:bg-zinc-400'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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">
|
||||||
<img
|
<picture>
|
||||||
src={imagePath}
|
<source srcSet={imagePathWebP} type="image/webp" />
|
||||||
alt={project.title}
|
<img
|
||||||
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700
|
src={imagePath}
|
||||||
group-hover:scale-110"
|
srcSet={srcSet}
|
||||||
loading="lazy"
|
sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"
|
||||||
style={{ transformOrigin: 'center center' }}
|
alt={project.title}
|
||||||
/>
|
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700
|
||||||
|
group-hover:scale-110"
|
||||||
|
loading="lazy"
|
||||||
|
width={1200}
|
||||||
|
height={675}
|
||||||
|
decoding="async"
|
||||||
|
style={{ transformOrigin: 'center center' }}
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content - with relative positioning to overlap the gradient */}
|
{/* Content - with relative positioning to overlap the gradient */}
|
||||||
|
|||||||
@@ -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ć',
|
||||||
|
|||||||