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-project
|
||||
.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>
|
||||
|
||||
<!-- 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="/favicon.svg" as="image" />
|
||||
</head>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "npm run generate-sitemap && vite build",
|
||||
"build:images": "node scripts/optimize-images.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"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"
|
||||
alt="Damjan Savić Logo"
|
||||
className="h-8 w-auto"
|
||||
width={120}
|
||||
height={32}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
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">
|
||||
{exp.role}
|
||||
</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}
|
||||
</h4>
|
||||
<p className="text-sm text-zinc-500 mb-6">
|
||||
<p className="text-sm text-zinc-400 mb-6">
|
||||
{exp.period}
|
||||
</p>
|
||||
<ul className="space-y-3 md:space-y-4">
|
||||
@@ -158,25 +158,32 @@ const Experience = () => {
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(totalPages)].map((_, index) => (
|
||||
<button
|
||||
key={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 ${
|
||||
index === currentPage
|
||||
? 'bg-white'
|
||||
: 'bg-zinc-600 hover:bg-zinc-400'
|
||||
}`}
|
||||
style={{
|
||||
width: '5px',
|
||||
height: '5px',
|
||||
minWidth: '5px',
|
||||
minHeight: '5px',
|
||||
padding: '0'
|
||||
width: '8px',
|
||||
height: '8px'
|
||||
}}
|
||||
aria-label={t('pages.home.experience.navigation.page', { page: index + 1 })}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -76,6 +76,9 @@ const Hero = () => {
|
||||
alt={t('pages.home.hero.image.alt')}
|
||||
className="w-full h-full object-cover"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
width={288}
|
||||
height={288}
|
||||
style={{ transform: 'scale(1.01)' }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -30,6 +30,8 @@ const PortfolioCard: React.FC<PortfolioCardProps> = ({ project }) => {
|
||||
};
|
||||
|
||||
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
|
||||
const displayTags = project.technologies || project.tags;
|
||||
@@ -60,14 +62,22 @@ const PortfolioCard: React.FC<PortfolioCardProps> = ({ project }) => {
|
||||
>
|
||||
{/* Image Container */}
|
||||
<div className="aspect-[16/9] w-full relative overflow-hidden bg-zinc-900">
|
||||
<picture>
|
||||
<source srcSet={imagePathWebP} type="image/webp" />
|
||||
<img
|
||||
src={imagePath}
|
||||
srcSet={srcSet}
|
||||
sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"
|
||||
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>
|
||||
|
||||
{/* Content - with relative positioning to overlap the gradient */}
|
||||
|
||||
@@ -28,6 +28,7 @@ export default defineConfig({
|
||||
VitePWA({
|
||||
strategies: 'generateSW',
|
||||
registerType: 'autoUpdate',
|
||||
injectRegister: 'script-defer',
|
||||
manifest: {
|
||||
name: 'Damjan Savić - JTL Integration Expert',
|
||||
short_name: 'Damjan Savić',
|
||||
|
||||