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
+328
View File
@@ -0,0 +1,328 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { motion } from 'framer-motion';
import { MapPin, Phone, Mail, Send, Loader2, CheckCircle2 } from 'lucide-react';
import Image from 'next/image';
interface FormData {
name: string;
email: string;
message: string;
}
export function ContactForm() {
const t = useTranslations('pages.contact');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
message: ''
});
const [errors, setErrors] = useState<Partial<FormData>>({});
const validateForm = () => {
const newErrors: Partial<FormData> = {};
if (!formData.name.trim()) {
newErrors.name = t('contactForm.errors.nameRequired');
}
if (!formData.email.trim()) {
newErrors.email = t('contactForm.errors.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = t('contactForm.errors.emailInvalid');
}
if (!formData.message.trim()) {
newErrors.message = t('contactForm.errors.messageRequired');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (errors[name as keyof FormData]) {
setErrors(prev => ({ ...prev, [name]: undefined }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsSubmitting(true);
setSubmitStatus('idle');
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
setSubmitStatus('success');
setFormData({ name: '', email: '', message: '' });
} catch {
setSubmitStatus('error');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen">
{/* Hero Section */}
<div className="py-24 text-center">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-4xl md:text-5xl font-bold text-white mb-4"
>
{t('hero.title')}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-lg text-zinc-400 max-w-2xl mx-auto px-4"
>
{t('hero.subtitle')}
</motion.p>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-24">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
{/* Contact Info */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="space-y-8"
>
{/* Headshot */}
<div className="flex items-center gap-6">
<div className="relative w-24 h-24 rounded-full overflow-hidden border-2 border-zinc-700 flex-shrink-0">
<Image
src="/images/headshot.webp"
alt="Damjan Savić"
fill
sizes="96px"
className="object-cover"
/>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-1">
{t('contactInfo.title')}
</h2>
<p className="text-zinc-400">
{t('contactInfo.description')}
</p>
</div>
</div>
<div className="space-y-6">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="flex items-start gap-4"
>
<div className="p-3 bg-zinc-800/50 rounded-lg">
<MapPin className="h-6 w-6 text-zinc-400" />
</div>
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
{t('contactInfo.address.label')}
</h3>
<p className="text-zinc-400">
{t('contactInfo.address.value')}
</p>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="flex items-start gap-4"
>
<div className="p-3 bg-zinc-800/50 rounded-lg">
<Phone className="h-6 w-6 text-zinc-400" />
</div>
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
{t('contactInfo.phone.label')}
</h3>
<a
href="tel:+491756950979"
className="text-zinc-400 hover:text-white transition-colors"
>
{t('contactInfo.phone.value')}
</a>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="flex items-start gap-4"
>
<div className="p-3 bg-zinc-800/50 rounded-lg">
<Mail className="h-6 w-6 text-zinc-400" />
</div>
<div>
<h3 className="text-sm font-medium text-zinc-300 mb-1">
{t('contactInfo.email.label')}
</h3>
<a
href="mailto:info@damjan-savic.com"
className="text-zinc-400 hover:text-white transition-colors"
>
{t('contactInfo.email.value')}
</a>
</div>
</motion.div>
</div>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="text-sm text-zinc-500"
>
{t('contactInfo.availabilityNote')}
</motion.p>
</motion.div>
{/* Contact Form */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<div className="bg-zinc-800/30 border border-zinc-800 rounded-xl p-6 md:p-8">
<div className="flex items-center gap-2 mb-2">
<Mail className="h-5 w-5 text-zinc-400" />
<h2 className="text-xl font-semibold text-white">
{t('contactForm.title')}
</h2>
</div>
<p className="text-zinc-400 mb-6">
{t('contactForm.description')}
</p>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="block text-sm text-zinc-300">
{t('contactForm.name.label')}
</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
placeholder={t('contactForm.name.placeholder')}
disabled={isSubmitting}
className="w-full h-12 px-4 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-600 disabled:opacity-50"
/>
{errors.name && (
<p className="text-sm text-red-400">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="email" className="block text-sm text-zinc-300">
{t('contactForm.email.label')}
</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder={t('contactForm.email.placeholder')}
disabled={isSubmitting}
className="w-full h-12 px-4 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-600 disabled:opacity-50"
/>
{errors.email && (
<p className="text-sm text-red-400">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="message" className="block text-sm text-zinc-300">
{t('contactForm.message.label')}
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder={t('contactForm.message.placeholder')}
rows={6}
disabled={isSubmitting}
className="w-full px-4 py-3 bg-zinc-900/50 border border-zinc-800 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-600 disabled:opacity-50 resize-none"
/>
{errors.message && (
<p className="text-sm text-red-400">{errors.message}</p>
)}
</div>
</div>
{submitStatus === 'success' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 p-3 bg-green-900/20 border border-green-900/50 rounded-lg"
>
<CheckCircle2 className="h-4 w-4 text-green-500" />
<p className="text-green-400 text-sm">
{t('contactForm.successMessage')}
</p>
</motion.div>
)}
{submitStatus === 'error' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-red-900/20 border border-red-900/50 rounded-lg"
>
<p className="text-red-400 text-sm">
{t('contactForm.errorMessage')}
</p>
</motion.div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full h-12 bg-zinc-100 hover:bg-white text-zinc-900 font-medium rounded-lg transition-colors duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<Send className="h-5 w-5" />
{t('contactForm.submit')}
</>
)}
</button>
</form>
</div>
</motion.div>
</div>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { ContactForm } from './ContactForm';