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
+46
View File
@@ -0,0 +1,46 @@
'use client';
import { Phone, Mail, ArrowRight } from 'lucide-react';
import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl';
export function ContactInfo() {
const locale = useLocale();
const t = useTranslations('footer.sections.contact');
return (
<div className="pb-6 px-6 pt-4 bg-zinc-800/30 border border-zinc-800 rounded-lg mx-4 mt-2">
<div className="space-y-4">
<h3 className="text-sm font-medium text-white tracking-wider uppercase">
{t('title')}
</h3>
<a
href="tel:+491756950979"
className="flex items-center space-x-3 text-sm text-zinc-400 hover:text-white transition-colors duration-200 group"
>
<Phone className="h-4 w-4 text-zinc-400 group-hover:text-white transition-colors duration-200" />
<span>+49 175 695 0979</span>
</a>
<a
href="mailto:info@damjan-savic.com"
className="flex items-center space-x-3 text-sm text-zinc-400 hover:text-white transition-colors duration-200 group"
>
<Mail className="h-4 w-4 text-zinc-400 group-hover:text-white transition-colors duration-200" />
<span>{t('email')}</span>
</a>
<Link
href={`/${locale}/contact`}
className="flex items-center justify-between mt-6 px-4 py-2 bg-zinc-800/50
hover:bg-zinc-800 rounded-full text-sm text-zinc-400 hover:text-white
transition-all duration-200 group border border-zinc-800 hover:border-zinc-700"
>
<span className="tracking-wide">Kontakt aufnehmen</span>
<ArrowRight className="h-4 w-4 transform group-hover:translate-x-1 transition-transform duration-200" />
</Link>
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import Link from 'next/link';
import { getLocale, getTranslations } from 'next-intl/server';
import { Mail, Linkedin, Github, MapPin } from 'lucide-react';
const Footer = async () => {
const locale = await getLocale();
const t = await getTranslations('footer');
const currentYear = new Date().getFullYear();
return (
<footer className="relative bg-zinc-800/50 backdrop-blur-sm border-t border-zinc-700/30 pt-8 pb-4 px-4">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col space-y-8">
{/* Contact Section */}
<div>
<h3 className="text-white text-base font-semibold mb-3">
{t('sections.contact.title')}
</h3>
<div className="space-y-2">
<a
href={`mailto:${t('sections.contact.email')}`}
className="group flex items-center gap-2 text-zinc-400 hover:text-white transition-colors text-sm w-fit"
>
<Mail className="h-4 w-4 text-zinc-500 group-hover:text-white transition-colors flex-shrink-0" />
<span>{t('sections.contact.email')}</span>
</a>
<div className="flex items-center gap-2 text-zinc-400 text-sm w-fit">
<MapPin className="h-4 w-4 text-zinc-500 flex-shrink-0" />
<span>{t('sections.contact.location')}</span>
</div>
</div>
</div>
{/* Navigation Section */}
<div>
<h3 className="text-white text-base font-semibold mb-3 text-left">
{t('sections.navigation.title')}
</h3>
<nav className="flex flex-col space-y-2">
<Link
href={`/${locale}/portfolio`}
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit"
>
{t('sections.navigation.links.portfolio')}
</Link>
<Link
href={`/${locale}/blog`}
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit"
>
{t('sections.navigation.links.blog')}
</Link>
<Link
href={`/${locale}/about`}
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit"
>
{t('sections.navigation.links.about')}
</Link>
<Link
href={`/${locale}/contact`}
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit"
>
{t('sections.navigation.links.contact')}
</Link>
</nav>
</div>
{/* Social Media Section */}
<div>
<h3 className="text-white text-base font-semibold mb-3 text-left">
{t('sections.social.title')}
</h3>
<div className="flex space-x-4">
<a
href="https://www.linkedin.com/in/damjan-savić-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={20} />
</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={20} />
</a>
</div>
</div>
{/* Legal Section */}
<div className="border-t border-zinc-700/30 pt-4">
<div className="flex flex-col space-y-4">
<p className="text-zinc-400 text-xs text-left">
&copy; {currentYear} Damjan Savić. {t('legal.copyright')}
</p>
<div className="flex space-x-4">
<Link
href={`/${locale}/privacy`}
className="text-zinc-400 hover:text-white text-xs transition-colors"
>
{t('legal.links.privacy')}
</Link>
<Link
href={`/${locale}/imprint`}
className="text-zinc-400 hover:text-white text-xs transition-colors"
>
{t('legal.links.imprint')}
</Link>
<Link
href={`/${locale}/terms`}
className="text-zinc-400 hover:text-white text-xs transition-colors"
>
{t('legal.links.terms')}
</Link>
</div>
</div>
</div>
</div>
</div>
</footer>
);
};
export default Footer;
+112
View File
@@ -0,0 +1,112 @@
const GlobalBackground = () => {
return (
<>
{/* Background layer */}
<div
className="fixed inset-0 bg-zinc-900"
style={{ zIndex: -2 }}
/>
{/* Gradient overlay */}
<div
className="fixed inset-0"
style={{
zIndex: -1,
background: 'linear-gradient(135deg, #1e1e1e 0%, #2a2a2a 50%, #1e1e1e 100%)',
opacity: 0.9
}}
/>
{/* Animated lines */}
<div
className="fixed inset-0 pointer-events-none"
style={{ zIndex: -1 }}
>
<svg
className="w-full h-full"
preserveAspectRatio="none"
style={{ opacity: 0.3 }}
>
<line
x1="0%"
y1="20%"
x2="100%"
y2="20%"
stroke="white"
strokeWidth="1"
opacity="0.2"
>
<animate
attributeName="y1"
values="20%;80%;20%"
dur="10s"
repeatCount="indefinite"
/>
<animate
attributeName="y2"
values="20%;80%;20%"
dur="10s"
repeatCount="indefinite"
/>
</line>
<line
x1="0%"
y1="50%"
x2="100%"
y2="50%"
stroke="white"
strokeWidth="1"
opacity="0.15"
>
<animate
attributeName="x1"
values="0%;100%;0%"
dur="15s"
repeatCount="indefinite"
/>
</line>
<line
x1="20%"
y1="0%"
x2="20%"
y2="100%"
stroke="white"
strokeWidth="1"
opacity="0.1"
>
<animate
attributeName="x1"
values="20%;80%;20%"
dur="12s"
repeatCount="indefinite"
/>
<animate
attributeName="x2"
values="20%;80%;20%"
dur="12s"
repeatCount="indefinite"
/>
</line>
</svg>
</div>
{/* Grid overlay */}
<div
className="fixed inset-0 pointer-events-none"
style={{
zIndex: -1,
backgroundImage: `
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px)
`,
backgroundSize: '50px 50px',
opacity: 0.5
}}
/>
</>
);
};
export default GlobalBackground;
+210
View File
@@ -0,0 +1,210 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import {
Menu,
X,
Home,
User,
Briefcase,
BookOpen,
Phone,
Cog,
} from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { NavLink } from './NavLink';
import LanguageSwitcher from './LanguageSwitcher';
import { ContactInfo } from './ContactInfo';
const Header = () => {
const t = useTranslations('navigation');
const locale = useLocale();
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Close menu on route change
useEffect(() => {
setIsMenuOpen(false);
}, [pathname]);
// Scroll handler
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 0);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Prevent body scroll when menu is open
useEffect(() => {
if (isMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isMenuOpen]);
const navigationLinks = [
{
path: `/${locale}`,
label: t('main.home'),
icon: <Home className="h-5 w-5" />,
},
{
path: `/${locale}/about`,
label: t('main.about'),
icon: <User className="h-5 w-5" />,
},
{
path: `/${locale}/leistungen`,
label: t('main.services'),
icon: <Cog className="h-5 w-5" />,
},
{
path: `/${locale}/portfolio`,
label: t('main.portfolio'),
icon: <Briefcase className="h-5 w-5" />,
},
{
path: `/${locale}/blog`,
label: t('main.blog'),
icon: <BookOpen className="h-5 w-5" />,
},
{
path: `/${locale}/contact`,
label: t('main.contact'),
icon: <Phone className="h-5 w-5" />,
},
];
return (
<>
{/* Navigation */}
<nav
className={`
fixed w-full z-40 transition-all duration-300
bg-zinc-800/50 backdrop-blur-sm border-b border-zinc-700/30
${scrolled
? 'bg-zinc-800/70 backdrop-blur-md shadow-lg shadow-zinc-900/30'
: ''
}
`}
role="navigation"
aria-label="Main navigation"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link href={`/${locale}`} className="flex items-center space-x-2 group shrink-0">
<Image
src="/header-logo.svg"
alt="Damjan Savić Logo"
className="h-8 w-auto transition-transform duration-200 hover:scale-105"
width={120}
height={32}
priority
/>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-2">
{navigationLinks.map(({ path, label, icon }) => (
<NavLink
key={path}
href={path}
icon={icon}
label={label}
className="text-sm"
/>
))}
<div className="ml-4 text-zinc-400 pl-2 border-l border-zinc-700">
<LanguageSwitcher />
</div>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden relative z-50 p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors active:scale-95"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-expanded={isMenuOpen}
aria-controls="mobile-menu"
aria-label={isMenuOpen ? 'Close menu' : 'Open menu'}
>
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
</div>
</nav>
{/* Mobile Menu Backdrop */}
<div
className={`fixed inset-0 bg-black/50 md:hidden z-30 transition-opacity duration-200 ${
isMenuOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={() => setIsMenuOpen(false)}
/>
{/* Mobile Sidebar */}
<div
id="mobile-menu"
className={`fixed right-0 top-0 h-full w-80 bg-zinc-900/95 backdrop-blur-md shadow-xl md:hidden z-50 border-l border-zinc-800 transition-transform duration-300 ease-out ${
isMenuOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Sidebar Header */}
<div className="flex items-center justify-between px-4 h-16 border-b border-zinc-800">
<span className="text-white font-semibold">Menu</span>
<button
onClick={() => setIsMenuOpen(false)}
aria-label="Close sidebar"
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors active:scale-95"
>
<X className="h-6 w-6" />
</button>
</div>
{/* Scrollable Sidebar Content */}
<div className="h-[calc(100vh-4rem)] overflow-y-auto">
<div className="flex flex-col h-full">
{/* Contact Info */}
<ContactInfo />
{/* Navigation Links */}
<div className="py-4 px-4">
{navigationLinks.map(({ path, label, icon }) => (
<NavLink
key={path}
href={path}
icon={icon}
label={label}
onClick={() => setIsMenuOpen(false)}
className="flex items-center w-full mb-1"
/>
))}
</div>
{/* Language Switcher */}
<div className="px-4 py-3 border-t border-zinc-800">
<div className="flex items-center justify-between">
<span className="text-sm text-zinc-400">
Sprache ändern
</span>
<LanguageSwitcher />
</div>
</div>
</div>
</div>
</div>
</>
);
};
export default Header;
+107
View File
@@ -0,0 +1,107 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useLocale } from 'next-intl';
import { Globe } from 'lucide-react';
const LanguageSwitcher = () => {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const languages = [
{ code: 'en', name: 'English' },
{ code: 'de', name: 'Deutsch' },
{ code: 'sr', name: 'Srpski' },
];
const currentLanguage = languages.find(lang => lang.code === locale)?.name || 'Language';
const handleLanguageChange = (newLocale: string) => {
// Replace the locale in the pathname
const pathWithoutLocale = pathname.replace(/^\/(de|en|sr)/, '');
const newPath = `/${newLocale}${pathWithoutLocale || ''}`;
router.push(newPath);
setIsOpen(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
};
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
document.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('resize', checkMobile);
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div className="relative inline-block" ref={dropdownRef}>
<button
className="flex items-center space-x-2 text-zinc-400 hover:text-white
px-4 py-2 rounded-full transition-all duration-200
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800
hover:scale-105 active:scale-95"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label="Select language"
>
<Globe className="h-5 w-5" aria-hidden="true" />
<span className="text-sm">{currentLanguage}</span>
</button>
<div
className={`absolute ${isMobile ? 'bottom-full mb-2' : 'top-full mt-2'} right-0 w-48 rounded-lg overflow-hidden
border border-zinc-800 bg-zinc-900/95 backdrop-blur-sm shadow-lg
transition-all duration-200 origin-top
${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'}`}
role="listbox"
aria-label="Languages"
onKeyDown={handleKeyDown}
>
<div className="py-1">
{languages.map((lang) => (
<button
key={lang.code}
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
hover:bg-zinc-800/50
${locale === lang.code
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-white'
}`}
onClick={() => handleLanguageChange(lang.code)}
role="option"
aria-selected={locale === lang.code}
>
{lang.name}
</button>
))}
</div>
</div>
</div>
);
};
export default LanguageSwitcher;
+38
View File
@@ -0,0 +1,38 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
interface NavLinkProps {
href: string;
icon: ReactNode;
label: string;
onClick?: () => void;
className?: string;
}
export function NavLink({ href, icon, label, onClick, className }: NavLinkProps) {
const pathname = usePathname();
// Check if path matches (accounting for locale prefix)
const pathWithoutLocale = pathname.replace(/^\/(de|en|sr)/, '');
const hrefWithoutLocale = href.replace(/^\/(de|en|sr)/, '');
const isActive = pathWithoutLocale === hrefWithoutLocale ||
(hrefWithoutLocale === '' && pathWithoutLocale === '');
return (
<Link
href={href}
onClick={onClick}
className={`
relative flex items-center gap-2 rounded-full py-2 px-4
transition-all duration-200 ease-in-out
${isActive ? 'text-white bg-zinc-700/60' : 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'}
${className || ''}
`}
>
{icon}
<span className="text-sm tracking-wide">{label}</span>
</Link>
);
}
+6
View File
@@ -0,0 +1,6 @@
export { default as Header } from './Header';
export { default as Footer } from './Footer';
export { default as GlobalBackground } from './GlobalBackground';
export { default as LanguageSwitcher } from './LanguageSwitcher';
export { NavLink } from './NavLink';
export { ContactInfo } from './ContactInfo';