feat: Add professional portfolio images and Hero redesign

- Add 15 optimized WebP images for portfolio (21-53KB each)
- Redesign Hero section with full-width background image
- Add SSR support for Hero component
- Update About section with new portrait image
- Update Contact page with professional headshot
- Add images to Services/Leistungen page
- Add image optimization script (sharp)
- Update translations for gallery section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 23:33:28 +01:00
co-authored by Claude Opus 4.5
parent b1ec7b4d61
commit 87c7ebc5e3
75 changed files with 10271 additions and 1314 deletions
+94
View File
@@ -0,0 +1,94 @@
import { getTranslations, getLocale } from 'next-intl/server';
import { MapPin, Briefcase, Code, Award, GraduationCap, Download } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
const About = async () => {
const t = await getTranslations('pages.home.about');
const locale = await getLocale();
const getCvUrl = () => {
if (locale === 'de') return '/damjan_savic_cv_de.pdf';
return '/damjan_savic_cv_en.pdf';
};
const highlights = [
{ icon: <MapPin className="w-4 h-4" />, label: 'Köln, Deutschland' },
{ icon: <Briefcase className="w-4 h-4" />, label: '5+ Jahre Erfahrung' },
{ icon: <Code className="w-4 h-4" />, label: 'Full-Stack Developer' },
{ icon: <Award className="w-4 h-4" />, label: 'ERP Spezialist' }
];
return (
<section id="about" className="py-24 relative">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
{/* Content Section */}
<div className="space-y-6 order-2 lg:order-1">
{/* Title */}
<div>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-2">
{t('title')}
</h2>
<div className="h-1 w-20 bg-gradient-to-r from-zinc-600 to-zinc-800 rounded-full" />
</div>
{/* Highlight Pills */}
<div className="flex flex-wrap gap-3">
{highlights.map((item, index) => (
<div
key={index}
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-800/30 backdrop-blur-sm rounded-full border border-zinc-700/50 hover:border-zinc-600 transition-colors"
>
<span className="text-zinc-400">{item.icon}</span>
<span className="text-zinc-300 text-sm">{item.label}</span>
</div>
))}
</div>
{/* Main Content */}
<div className="space-y-4">
<p className="text-zinc-300 leading-relaxed text-lg">
{t('content')}
</p>
</div>
{/* Call to Action Buttons */}
<div className="flex flex-wrap gap-4 pt-4">
<Link
href={`/${locale}/about`}
className="px-6 py-3 bg-zinc-800/50 backdrop-blur-sm hover:bg-zinc-700/50 text-white rounded-xl font-medium transition-colors duration-300 flex items-center gap-2"
>
<GraduationCap className="w-4 h-4" />
{t('buttons.learnMore')}
</Link>
<a
href={getCvUrl()}
download
className="px-6 py-3 bg-transparent hover:bg-zinc-800/30 text-zinc-300 border border-zinc-700/50 rounded-xl font-medium transition-colors duration-300 flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('buttons.downloadCV')}
</a>
</div>
</div>
{/* Image Section */}
<div className="relative order-1 lg:order-2">
<div className="relative rounded-3xl overflow-hidden aspect-square bg-zinc-800/30 backdrop-blur-sm">
<Image
src="/images/gallery/frontal-smile.webp"
alt="Damjan Savić"
fill
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover object-bottom"
/>
</div>
</div>
</div>
</div>
</section>
);
};
export default About;
+178
View File
@@ -0,0 +1,178 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { motion, AnimatePresence } from 'framer-motion';
interface ExperiencePosition {
role: string;
company: string;
period: string;
highlights: string[];
}
const Experience = () => {
const t = useTranslations('pages.home.experience');
const [currentPage, setCurrentPage] = useState(0);
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const [itemsPerPage, setItemsPerPage] = useState(2);
const containerRef = useRef<HTMLDivElement>(null);
const experiences = t.raw('positions') as ExperiencePosition[];
useEffect(() => {
const updateItemsPerPage = () => {
setItemsPerPage(window.innerWidth >= 768 ? 2 : 1);
};
updateItemsPerPage();
window.addEventListener('resize', updateItemsPerPage);
return () => window.removeEventListener('resize', updateItemsPerPage);
}, []);
const totalPages = Math.ceil((Array.isArray(experiences) ? experiences.length : 0) / itemsPerPage);
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStart(e.touches[0].clientX);
};
const handleTouchMove = (e: React.TouchEvent) => {
setTouchEnd(e.touches[0].clientX);
};
const handleTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > 50;
const isRightSwipe = distance < -50;
if (isLeftSwipe && currentPage < totalPages - 1) {
setCurrentPage(prev => prev + 1);
}
if (isRightSwipe && currentPage > 0) {
setCurrentPage(prev => prev - 1);
}
setTouchStart(null);
setTouchEnd(null);
};
const getCurrentPageItems = () => {
if (!Array.isArray(experiences)) return [];
const startIndex = currentPage * itemsPerPage;
return experiences.slice(startIndex, startIndex + itemsPerPage);
};
return (
<section id="experience" className="py-12 md:py-24 overflow-hidden">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<h2 className="text-3xl font-bold text-white text-center mb-12">
{t('title')}
</h2>
<div
ref={containerRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className="relative"
>
<AnimatePresence mode="wait">
<motion.div
key={currentPage}
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -100, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.43, 0.13, 0.23, 0.96] }}
className="grid grid-cols-1 md:grid-cols-2 gap-6"
>
{getCurrentPageItems().map((exp, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: index * 0.15, ease: [0.25, 0.46, 0.45, 0.94] }}
whileHover={{ scale: 1.02, y: -5, transition: { duration: 0.3 } }}
className="p-6 md:p-8 rounded-3xl bg-zinc-800/40 hover:bg-zinc-800/60 transition-all duration-300 hover:shadow-xl hover:shadow-zinc-900/50"
>
<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-300 mb-2">
{exp.company}
</h4>
<p className="text-sm text-zinc-400 mb-6">
{exp.period}
</p>
<ul className="space-y-3 md:space-y-4">
{exp.highlights?.map((highlight: string, hIndex: number) => (
<motion.li
key={hIndex}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: 0.3 + (hIndex * 0.1), ease: 'easeOut' }}
className="text-zinc-400 text-sm leading-relaxed flex items-start"
>
<span className="text-white/40 mr-2 mt-0.5"></span>
<span>{highlight}</span>
</motion.li>
))}
</ul>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation Dots */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-8">
<button
onClick={() => currentPage > 0 && setCurrentPage(prev => prev - 1)}
className="text-zinc-400 hover:text-white transition-all duration-300 p-2 text-2xl font-light disabled:opacity-30"
disabled={currentPage === 0}
aria-label="Previous page"
>
</button>
<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' }}
aria-label={`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>
<button
onClick={() => currentPage < totalPages - 1 && setCurrentPage(prev => prev + 1)}
className="text-zinc-400 hover:text-white transition-all duration-300 p-2 text-2xl font-light disabled:opacity-30"
disabled={currentPage === totalPages - 1}
aria-label="Next page"
>
</button>
</div>
)}
</motion.div>
</div>
</section>
);
};
export default Experience;
+114
View File
@@ -0,0 +1,114 @@
import { getTranslations } from 'next-intl/server';
import { Linkedin, Github, ChevronDown } from 'lucide-react';
import Image from 'next/image';
const Hero = async () => {
const t = await getTranslations('pages.home.hero');
return (
<section id="home" className="relative h-screen max-h-screen text-white overflow-hidden">
{/* Hero Image - Full Width */}
<div className="absolute inset-0">
<Image
src="/hero-portrait.jpg"
alt="Damjan Savić"
fill
sizes="100vw"
className="object-cover object-top md:object-left"
priority
/>
</div>
{/* Desktop: Gradient on left side */}
<div className="hidden md:block absolute inset-y-0 left-0 w-1/2 bg-gradient-to-r from-zinc-950 via-zinc-950 via-60% to-transparent z-10" />
{/* Mobile: Gradient at bottom for text */}
<div className="md:hidden absolute inset-x-0 bottom-0 h-[60%] bg-gradient-to-t from-zinc-950 via-zinc-950/80 via-40% to-transparent z-10 pointer-events-none" />
{/* Social Links - Top */}
<div className="absolute top-8 left-6 sm:left-12 lg:left-20 flex gap-4 z-20">
<a
href="https://www.linkedin.com/in/damjan-savi%C4%87-720288127/"
target="_blank"
rel="noopener noreferrer"
className="group text-zinc-400 hover:text-white transition-colors"
aria-label="LinkedIn"
>
<Linkedin className="transform transition-transform group-hover:scale-110" size={22} />
</a>
<a
href="https://github.com/damjan1996"
target="_blank"
rel="noopener noreferrer"
className="group text-zinc-400 hover:text-white transition-colors"
aria-label="GitHub"
>
<Github className="transform transition-transform group-hover:scale-110" size={22} />
</a>
</div>
{/* Main Content - Bottom on mobile, left on desktop */}
<div className="relative z-20 h-full flex flex-col justify-end md:justify-center items-center md:items-start px-6 sm:px-12 lg:px-20 pb-24 md:pb-0">
<div className="max-w-3xl space-y-4 md:space-y-6 text-center md:text-left">
<p className="text-zinc-400 text-sm sm:text-base tracking-[0.3em] uppercase">
{t('title')}
</p>
<h1 className="text-4xl sm:text-5xl lg:text-7xl font-bold tracking-tight">
{t('name')}
<span className="animate-pulse text-white/80">|</span>
</h1>
<p className="text-zinc-300 text-base sm:text-lg lg:text-xl max-w-lg leading-relaxed">
{t('description')}
</p>
{/* CTA Buttons */}
<div className="flex flex-wrap justify-center md:justify-start gap-3 sm:gap-4 pt-2 sm:pt-4">
<a
href="mailto:info@damjan-savic.com"
className="px-6 sm:px-8 py-2.5 sm:py-3 bg-white text-zinc-900 font-medium rounded-full hover:bg-zinc-200 transition-colors text-sm sm:text-base"
>
{t('cta')}
</a>
<a
href="#projects"
className="px-6 sm:px-8 py-2.5 sm:py-3 border border-zinc-600 text-white rounded-full hover:bg-zinc-800 transition-colors text-sm sm:text-base"
>
{t('navigation.projects')}
</a>
</div>
{/* Navigation Links - Hidden on mobile */}
<nav className="hidden sm:flex gap-8 pt-4 text-sm uppercase tracking-wider">
<a
href="#experience"
className="text-zinc-400 hover:text-white transition-colors"
>
{t('navigation.experience')}
</a>
<a
href="#skills"
className="text-zinc-400 hover:text-white transition-colors"
>
{t('navigation.skills')}
</a>
<a
href="#about"
className="text-zinc-400 hover:text-white transition-colors"
>
{t('navigation.about')}
</a>
</nav>
</div>
</div>
{/* Scroll Indicator - Hidden on mobile */}
<div className="hidden sm:block absolute bottom-8 left-1/2 -translate-x-1/2 z-20 animate-bounce">
<ChevronDown className="text-zinc-400" size={28} />
</div>
</section>
);
};
export default Hero;
+139
View File
@@ -0,0 +1,139 @@
import { getTranslations, getLocale } from 'next-intl/server';
import Link from 'next/link';
import Image from 'next/image';
import { Calendar, Tag, ExternalLink } from 'lucide-react';
// Project data embedded directly for SSG
const projectsData = [
{
slug: 'ai-data-reader',
title: 'AI Document Reader',
excerpt: 'KI-gestützte Dokumentenanalyse mit OLLAMA und Python',
date: '2024-01',
technologies: ['Python', 'OLLAMA', 'FastAPI', 'React'],
category: 'AI Development',
},
{
slug: 'smart-warehouse',
title: 'Smart Warehouse RFID',
excerpt: 'RFID-basiertes Lagerverwaltungssystem mit IoT-Integration',
date: '2024-02',
technologies: ['Python', 'RFID', 'IoT', 'PostgreSQL'],
category: 'IoT',
},
{
slug: 'website-mit-ki',
title: 'Portfolio mit KI',
excerpt: 'Moderne Portfolio-Website mit KI-Integration',
date: '2024-03',
technologies: ['Next.js', 'TypeScript', 'Tailwind', 'Supabase'],
category: 'Full-Stack',
},
];
const Projects = async () => {
const t = await getTranslations('pages.home.projects');
const locale = await getLocale();
return (
<section id="projects" className="py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center mb-12">
<h2 className="text-3xl font-bold text-zinc-100">
{t('title')}
</h2>
<Link
href={`/${locale}/portfolio`}
className="text-zinc-400 hover:text-white transition-colors duration-300"
>
{t('viewAll')}
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projectsData.map((project) => (
<Link
key={project.slug}
href={`/${locale}/portfolio/${project.slug}`}
className="group relative block bg-zinc-900/50 backdrop-blur-sm
rounded-xl shadow-lg hover:shadow-2xl
ring-1 ring-zinc-800 hover:ring-zinc-700
transition-all duration-500 focus:outline-none focus:ring-2
focus:ring-zinc-600 focus:ring-offset-2 focus:ring-offset-zinc-950
transform hover:-translate-y-1"
>
<div className="relative overflow-hidden rounded-xl">
{/* Image Container */}
<div className="aspect-[16/9] w-full relative overflow-hidden bg-zinc-900">
<Image
src={`/images/projects/${project.slug}/cover.jpg`}
alt={project.title}
fill
sizes="(max-width: 640px) 350px, (max-width: 768px) 400px, (max-width: 1024px) 550px, 400px"
className="object-cover transition-transform duration-700 group-hover:scale-110"
/>
</div>
{/* Content */}
<div className="relative -mt-2 p-6 bg-zinc-900 z-10">
<div className="flex items-center gap-4 mb-4 text-zinc-500">
{project.date && (
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4" />
<span>{project.date}</span>
</div>
)}
{project.technologies.length > 0 && (
<div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4" />
<span>{project.technologies.length} Technologies</span>
</div>
)}
</div>
<h3 className="text-xl font-semibold text-white mb-3 group-hover:text-zinc-100 transition-colors duration-300">
{project.title}
</h3>
<p className="text-zinc-400 text-sm line-clamp-2 mb-4 group-hover:text-zinc-300 transition-colors duration-300">
{project.excerpt}
</p>
{/* Tags */}
<div className="flex flex-wrap gap-2">
{project.technologies.slice(0, 3).map((tag, tagIndex) => (
<span
key={tagIndex}
className="px-3 py-1 bg-zinc-800/50 text-sm text-zinc-400 rounded-full
ring-1 ring-zinc-700/50 group-hover:ring-zinc-600/50
group-hover:bg-zinc-800/70 group-hover:text-zinc-300
transition-all duration-300"
>
{tag}
</span>
))}
{project.technologies.length > 3 && (
<span className="text-sm text-zinc-600">
+{project.technologies.length - 3} more
</span>
)}
</div>
{/* Link Icon */}
<div className="absolute top-6 right-6 p-2.5 rounded-full
bg-zinc-800/60 backdrop-blur-sm ring-1 ring-zinc-700/50
opacity-0 group-hover:opacity-100
transform translate-y-2 group-hover:translate-y-0
transition-all duration-300">
<ExternalLink className="h-4 w-4 text-zinc-400" />
</div>
</div>
</div>
</Link>
))}
</div>
</div>
</section>
);
};
export default Projects;
+157
View File
@@ -0,0 +1,157 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { motion, AnimatePresence } from 'framer-motion';
import { Database, Server, Code2, Bot, Workflow, FileCode2 } from 'lucide-react';
interface Skill {
name: string;
level: number;
description: string;
}
const iconMap: Record<string, React.ReactNode> = {
'AI & LLMs': <Bot className="w-8 h-8" />,
'Automation': <Workflow className="w-8 h-8" />,
'Automatizacija': <Workflow className="w-8 h-8" />,
'Python': <Code2 className="w-8 h-8" />,
'TypeScript': <FileCode2 className="w-8 h-8" />,
'Datenbank': <Database className="w-8 h-8" />,
'Database': <Database className="w-8 h-8" />,
'Baze podataka': <Database className="w-8 h-8" />,
'DevOps': <Server className="w-8 h-8" />
};
const Skills = () => {
const t = useTranslations('pages.home.skills');
const [selectedSkill, setSelectedSkill] = useState<string | null>(null);
const [skills, setSkills] = useState<Skill[]>([]);
useEffect(() => {
try {
const translatedSkills = t.raw('skills') as Skill[];
if (Array.isArray(translatedSkills)) {
setSkills(translatedSkills);
}
} catch (error) {
console.error('Error loading skills:', error);
setSkills([]);
}
}, [t]);
if (skills.length === 0) {
return <div className="py-24 text-center text-zinc-400">Loading skills...</div>;
}
return (
<section id="skills" className="py-24 relative">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<h2 className="text-3xl font-bold text-white mb-12">
{t('title')}
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Skills Progress Bars */}
<div className="space-y-6">
{skills.map((skill, idx) => (
<motion.div
key={skill.name}
className="space-y-2"
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: idx * 0.1 }}
>
<div className="flex justify-between items-center">
<span className="text-white text-sm font-medium">{skill.name}</span>
<span className="text-white text-sm">{skill.level}%</span>
</div>
<div
className="h-2 bg-zinc-800/50 rounded-full overflow-hidden"
role="progressbar"
aria-valuenow={skill.level}
aria-valuemin={0}
aria-valuemax={100}
>
<motion.div
className="h-full rounded-full bg-gradient-to-r from-zinc-600 to-zinc-500"
initial={{ width: 0 }}
whileInView={{ width: `${skill.level}%` }}
viewport={{ once: true }}
transition={{ duration: 1, delay: idx * 0.1 }}
/>
</div>
</motion.div>
))}
</div>
{/* Skills Icons Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{skills.map((skill, idx) => (
<motion.div
key={skill.name}
className="relative"
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: idx * 0.1 }}
>
<motion.div
className={`relative p-6 rounded-xl bg-zinc-800/30 backdrop-blur-sm flex flex-col items-center justify-center gap-3 cursor-pointer transition-colors duration-300 hover:bg-zinc-700/50 ${
selectedSkill === skill.name ? 'ring-2 ring-zinc-500 z-20' : ''
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.98 }}
onHoverStart={() => setSelectedSkill(skill.name)}
onHoverEnd={() => setSelectedSkill(null)}
role="button"
>
<motion.div
className={selectedSkill === skill.name ? 'text-zinc-500' : 'text-white'}
animate={{
rotate: selectedSkill === skill.name ? [0, -10, 10, 0] : 0,
scale: selectedSkill === skill.name ? 1.1 : 1
}}
transition={{ duration: 0.4 }}
>
{iconMap[skill.name] || <Code2 className="w-8 h-8" />}
</motion.div>
<span className="text-white text-sm text-center">{skill.name}</span>
<AnimatePresence>
{selectedSkill === skill.name && (
<motion.div
className="absolute left-1/2 top-0 -translate-x-1/2 bg-zinc-800/80 backdrop-blur-sm rounded-lg px-4 py-2 text-zinc-300 text-xs text-center shadow-xl pointer-events-none"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
transition={{ duration: 0.15 }}
style={{
whiteSpace: 'nowrap',
transform: 'translate(-50%, calc(-100% - 12px))',
zIndex: 999
}}
>
{skill.description}
<div className="absolute left-1/2 bottom-0 translate-y-1/2 -translate-x-1/2 w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-zinc-800" />
</motion.div>
)}
</AnimatePresence>
</motion.div>
</motion.div>
))}
</div>
</div>
</motion.div>
</div>
</section>
);
};
export default Skills;
+5
View File
@@ -0,0 +1,5 @@
export { default as Hero } from './Hero';
export { default as Experience } from './Experience';
export { default as Skills } from './Skills';
export { default as Projects } from './Projects';
export { default as About } from './About';