Migrate from Vite to Next.js 15 with SSR
- Replace Vite + React Router with Next.js 15 App Router - Implement i18n with next-intl (URL-based: /de, /en, /sr) - Add SSR/SSG for all pages (48 static pages generated) - Setup Supabase SSR client for auth - Migrate all pages: Home, About, Portfolio, Blog, Contact, Login, Dashboard, Imprint, Privacy, Terms - Add Docker support with standalone output - Replace i18next with next-intl JSON translations - Use next/image for optimized images Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MapPin, Briefcase, GraduationCap, Code, Award, Download } from 'lucide-react';
|
||||
|
||||
const About = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// CV-Datei basierend auf der aktuellen Sprache
|
||||
const getCvUrl = () => {
|
||||
const lang = i18n.language;
|
||||
if (lang === 'de') return '/damjan_savic_cv_de.pdf';
|
||||
// Für alle anderen Sprachen (en, sr, etc.) englische Version verwenden
|
||||
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 className="py-24 relative">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center"
|
||||
>
|
||||
{/* Image Section - Simplified */}
|
||||
<motion.div
|
||||
className="relative"
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
{/* Main Image Container */}
|
||||
<div className="relative rounded-3xl overflow-hidden aspect-square bg-zinc-800/30 backdrop-blur-sm">
|
||||
<img
|
||||
src="/portrait.jpg"
|
||||
alt={t('pages.home.about.image.alt')}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = 'data:image/svg+xml,%3Csvg width="400" height="400" xmlns="http://www.w3.org/2000/svg"%3E%3Crect width="400" height="400" fill="%2327272a"/%3E%3Ctext x="50%25" y="50%25" font-family="system-ui" font-size="24" fill="%2371717a" text-anchor="middle" dominant-baseline="middle"%3EDamjan Savić%3C/text%3E%3C/svg%3E';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="space-y-6">
|
||||
{/* Title */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-2">
|
||||
{t('pages.home.about.title')}
|
||||
</h2>
|
||||
<div className="h-1 w-20 bg-gradient-to-r from-zinc-600 to-zinc-800 rounded-full"></div>
|
||||
</motion.div>
|
||||
|
||||
{/* Highlight Pills */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="flex flex-wrap gap-3"
|
||||
>
|
||||
{highlights.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 + index * 0.1 }}
|
||||
whileHover={{ scale: 1.05, y: -2 }}
|
||||
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>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Main Content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<p className="text-zinc-300 leading-relaxed text-lg">
|
||||
{t('pages.home.about.content')}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Call to Action Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="flex flex-wrap gap-4 pt-4"
|
||||
>
|
||||
<motion.a
|
||||
href="/about"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
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('pages.home.about.buttons.learnMore')}
|
||||
</motion.a>
|
||||
<motion.a
|
||||
href={getCvUrl()}
|
||||
download
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
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('pages.home.about.buttons.downloadCV')}
|
||||
</motion.a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const Experience = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null);
|
||||
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Entfernen des 'translation.' Prefixes, da dies nicht in der Struktur vorhanden ist
|
||||
const experiences = t('pages.home.experience.positions', {
|
||||
returnObjects: true,
|
||||
defaultValue: []
|
||||
});
|
||||
|
||||
const itemsPerPage = window.innerWidth >= 768 ? 2 : 1;
|
||||
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 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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: [0.25, 0.46, 0.45, 0.94]
|
||||
}}
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'opacity, transform'
|
||||
}}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-white text-center mb-12">
|
||||
{t('pages.home.experience.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"
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'opacity, transform'
|
||||
}}
|
||||
>
|
||||
{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"
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'opacity, transform'
|
||||
}}
|
||||
>
|
||||
<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={t('pages.home.experience.navigation.prev')}
|
||||
>
|
||||
‹
|
||||
</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',
|
||||
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: '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={t('pages.home.experience.navigation.next')}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Experience;
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from "framer-motion";
|
||||
import { Code2, ShoppingBag, TrendingUp } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../../components/Card";
|
||||
import { Badge } from "../../../components/Badge";
|
||||
|
||||
// Definiere den Typ für einen Expertise-Bereich
|
||||
interface ExpertiseArea {
|
||||
title: string;
|
||||
description: string;
|
||||
skills: string[];
|
||||
}
|
||||
|
||||
// Typisierung für iconMap: Wir erwarten ein React-Komponenten-Typ, der optional eine className erhält
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
'ERP Integration': ShoppingBag,
|
||||
'Entwicklung': Code2,
|
||||
'E-Commerce': ShoppingBag,
|
||||
'Digitales Marketing': TrendingUp,
|
||||
// English mappings
|
||||
'Development': Code2,
|
||||
'Digital Marketing': TrendingUp
|
||||
};
|
||||
|
||||
const Expertise = () => {
|
||||
const { t } = useTranslation('home');
|
||||
|
||||
// Wir casten den Rückgabewert explizit zu ExpertiseArea[]
|
||||
const expertiseAreas = t('expertise.areas', { returnObjects: true }) as ExpertiseArea[];
|
||||
|
||||
return (
|
||||
<section className="py-12 sm:py-24 bg-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<motion.h2
|
||||
className="text-3xl sm:text-4xl font-bold text-foreground text-center mb-12"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{t('expertise.title')}
|
||||
</motion.h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 sm:gap-8">
|
||||
{expertiseAreas.map((area: ExpertiseArea, index: number) => {
|
||||
const Icon = iconMap[area.title] || ShoppingBag;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Card className="h-full bg-card hover:bg-accent transition-all duration-300 ease-in-out transform hover:-translate-y-2">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-primary rounded-full flex items-center justify-center mb-4">
|
||||
<Icon className="w-6 h-6 text-primary-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-xl font-semibold text-foreground">
|
||||
{area.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-muted-foreground mb-4">
|
||||
{area.description}
|
||||
</CardDescription>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{area.skills.map((skill: string, skillIndex: number) => (
|
||||
<Badge
|
||||
key={skillIndex}
|
||||
variant="secondary"
|
||||
className="bg-secondary text-secondary-foreground hover:bg-primary hover:text-primary-foreground transition-colors duration-200"
|
||||
>
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Expertise;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Linkedin, Github } from 'lucide-react';
|
||||
|
||||
const Hero = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="home" className="relative min-h-screen text-white overflow-hidden">
|
||||
{/* Concentric Circles Background */}
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center" style={{ zIndex: 2 }}>
|
||||
{[1, 2, 3].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute rounded-full border border-zinc-800/30"
|
||||
style={{
|
||||
width: `${index * 30}%`,
|
||||
height: `${index * 30}%`,
|
||||
opacity: 0.1,
|
||||
transform: 'translateZ(0)'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Social Links Bar */}
|
||||
<div className="absolute top-20 left-0 right-0 px-4 sm:px-8" style={{ zIndex: 40 }}>
|
||||
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
||||
<div className="flex gap-4">
|
||||
<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={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>
|
||||
<a
|
||||
href="mailto:info@damjan-savic.com"
|
||||
className="text-sm text-zinc-400 hover:text-white transition-colors uppercase tracking-wider"
|
||||
>
|
||||
{t('pages.home.hero.cta')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col items-center justify-start min-h-screen px-4 pt-32 relative" style={{ zIndex: 30 }}>
|
||||
{/* Profile Image */}
|
||||
<div
|
||||
className="w-56 h-56 sm:w-72 sm:h-72 mb-8 rounded-full overflow-hidden border-2 border-zinc-800"
|
||||
style={{ transform: 'translateZ(0)' }}
|
||||
>
|
||||
<picture>
|
||||
<source
|
||||
srcSet="/portrait-224.webp 224w, /portrait-448.webp 448w, /portrait-288.webp 288w, /portrait-576.webp 576w"
|
||||
sizes="(max-width: 640px) 224px, 288px"
|
||||
type="image/webp"
|
||||
/>
|
||||
<source
|
||||
srcSet="/portrait-224.jpg 224w, /portrait-288.jpg 288w"
|
||||
sizes="(max-width: 640px) 224px, 288px"
|
||||
type="image/jpeg"
|
||||
/>
|
||||
<img
|
||||
src="/portrait-288.jpg"
|
||||
alt={t('pages.home.hero.image.alt')}
|
||||
className="w-full h-full object-cover"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
width={288}
|
||||
height={288}
|
||||
decoding="sync"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
{/* Title and Name */}
|
||||
<p className="text-zinc-400 text-sm sm:text-base tracking-wider mb-2">
|
||||
{t('pages.home.hero.title')}
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-3 flex items-center">
|
||||
{t('pages.home.hero.name')}<span className="animate-pulse ml-1">|</span>
|
||||
</h1>
|
||||
<p className="text-zinc-300 text-sm sm:text-base max-w-xl text-center mb-2 px-4">
|
||||
{t('pages.home.hero.description')}
|
||||
</p>
|
||||
<p className="text-zinc-400 text-xs sm:text-sm mb-8">
|
||||
{t('pages.home.hero.currentRole')}
|
||||
</p>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-8 text-white text-base sm:text-lg z-20">
|
||||
<button
|
||||
onClick={() => scrollToSection('experience')}
|
||||
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
>
|
||||
<span className="relative z-10">{t('pages.home.hero.navigation.experience')}</span>
|
||||
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('skills')}
|
||||
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
>
|
||||
<span className="relative z-10">{t('pages.home.hero.navigation.skills')}</span>
|
||||
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('projects')}
|
||||
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
>
|
||||
<span className="relative z-10">{t('pages.home.hero.navigation.projects')}</span>
|
||||
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('about')}
|
||||
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase cursor-pointer overflow-hidden group"
|
||||
>
|
||||
<span className="relative z-10">{t('pages.home.hero.navigation.about')}</span>
|
||||
<div className="absolute inset-0 bg-zinc-700/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-full" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Button Bottom */}
|
||||
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2" style={{ zIndex: 40 }}>
|
||||
<a
|
||||
href="mailto:info@damjan-savic.com"
|
||||
className="w-12 h-12 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
||||
aria-label={t('pages.home.hero.social.getInTouch')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M0 3v18h24v-18h-24zm21.518 2l-9.518 7.713-9.518-7.713h19.036zm-19.518 14v-11.817l10 8.104 10-8.104v11.817h-20z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import PortfolioCard from '../../portfolio/components/PortfolioCard';
|
||||
import { getAllProjects } from '../../portfolio/utils';
|
||||
import type { Project } from '../../portfolio/utils';
|
||||
|
||||
// Erweiterter Typ: Wir gehen davon aus, dass jedes Project über einen 'slug' verfügt.
|
||||
// Falls doch ein 'id'-Feld existieren sollte, wird es beibehalten.
|
||||
interface ProjectWithId extends Project {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const Projects = () => {
|
||||
const { t } = useTranslation();
|
||||
const [projects, setProjects] = useState<ProjectWithId[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const projectsData = await getAllProjects();
|
||||
// Ergänze das fehlende 'id'-Feld (als Fallback verwenden wir hier den 'slug')
|
||||
const projectsWithId: ProjectWithId[] = projectsData.map((project) => ({
|
||||
...project,
|
||||
id: project.slug,
|
||||
}));
|
||||
// Nur die ersten 3 Projekte für die Homepage
|
||||
setProjects(projectsWithId.slice(0, 3));
|
||||
} catch (error) {
|
||||
console.error(t('pages.home.projects.error.loading'), error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProjects();
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-center items-center min-h-[300px] gap-3">
|
||||
<Loader2 className="h-8 w-8 text-zinc-400 animate-spin" />
|
||||
<span className="text-zinc-400">
|
||||
{t('pages.home.projects.loading')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-zinc-100">
|
||||
{t('pages.home.projects.title')}
|
||||
</h2>
|
||||
<Link
|
||||
to="/portfolio"
|
||||
className="text-zinc-400 hover:text-white transition-colors duration-300"
|
||||
aria-label={t('pages.home.projects.viewAll')}
|
||||
>
|
||||
{t('pages.home.projects.viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{projects.map((project, index) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<PortfolioCard project={project} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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: { [key: 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 } = useTranslation();
|
||||
const [selectedSkill, setSelectedSkill] = useState<string | null>(null);
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Type Assertion: Wir gehen davon aus, dass die Übersetzungsobjekte dem Skill-Interface entsprechen.
|
||||
const translatedSkills = t('pages.home.skills.skills', {
|
||||
returnObjects: true,
|
||||
defaultValue: []
|
||||
}) as Skill[];
|
||||
|
||||
if (Array.isArray(translatedSkills)) {
|
||||
setSkills(translatedSkills);
|
||||
} else {
|
||||
console.error('Skills translation is not an array');
|
||||
setSkills([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading skills:', error);
|
||||
setSkills([]);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
if (skills.length === 0) {
|
||||
return <div>Loading skills...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section 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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-white mb-12">
|
||||
{t('pages.home.skills.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) => (
|
||||
<motion.div
|
||||
key={skill.name}
|
||||
className="space-y-2"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: skills.indexOf(skill) * 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}
|
||||
aria-label={t('pages.home.skills.aria.skillLevel')}
|
||||
>
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-zinc-600 to-zinc-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{
|
||||
width: `${skill.level}%`
|
||||
}}
|
||||
transition={{
|
||||
width: { duration: 1, delay: skills.indexOf(skill) * 0.1 }
|
||||
}}
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'width'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Skills Icons Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
{skills.map((skill) => (
|
||||
<motion.div
|
||||
key={skill.name}
|
||||
className="relative"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: skills.indexOf(skill) * 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"
|
||||
aria-pressed={selectedSkill === skill.name}
|
||||
aria-label={t('pages.home.skills.aria.selectSkill')}
|
||||
style={{
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'transform',
|
||||
position: 'relative',
|
||||
zIndex: selectedSkill === skill.name ? 20 : 1
|
||||
}}
|
||||
>
|
||||
<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]}
|
||||
</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"></div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Skills;
|
||||
@@ -0,0 +1,115 @@
|
||||
import SEO from '../../components/SEO';
|
||||
import Hero from './components/Hero';
|
||||
import Experience from './components/Experience';
|
||||
import Skills from './components/Skills';
|
||||
import Projects from './components/Projects';
|
||||
import About from './components/About';
|
||||
import { FAQSection } from '../../components/FAQSection';
|
||||
import { LocalizedWebsiteSchema, LocalizedPersonSchema } from '../../components/schemas/LocalizedSchemas';
|
||||
import { LocalBusinessSchema } from '../../components/schemas/LocalBusinessSchema';
|
||||
import { ServiceSchema } from '../../components/schemas/ServiceSchema';
|
||||
|
||||
const HomePage = () => {
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfilePage',
|
||||
'@id': 'https://damjan-savic.com/#profilepage',
|
||||
name: 'Damjan Savić - AI & Automation Specialist',
|
||||
description:
|
||||
'Damjan Savić ist AI & Automation Specialist. Spezialisiert auf KI-Agenten, Voice AI, Prozessautomatisierung mit n8n, und Fullstack Development.',
|
||||
mainEntity: {
|
||||
'@type': 'Person',
|
||||
name: 'Damjan Savić',
|
||||
jobTitle: ['AI & Automation Specialist', 'Process Automation Specialist', 'Fullstack Developer'],
|
||||
description:
|
||||
'Damjan Savić entwickelt KI-Agenten und Automatisierungslösungen. Von Voice-AI-Plattformen bis zu autonomen Web-Agenten.',
|
||||
knowsLanguage: [
|
||||
'English',
|
||||
'German',
|
||||
'Serbian',
|
||||
'French',
|
||||
'Spanish',
|
||||
'Russian'
|
||||
],
|
||||
hasOccupation: {
|
||||
'@type': 'Occupation',
|
||||
name: 'AI & Automation Specialist',
|
||||
skills: [
|
||||
'AI Agents Development',
|
||||
'Voice AI (Vapi)',
|
||||
'Process Automation (n8n, Zapier)',
|
||||
'Python Development',
|
||||
'TypeScript & React',
|
||||
'Next.js',
|
||||
'GPT-4 & Claude API Integration',
|
||||
'Web Scraping',
|
||||
'Supabase & PostgreSQL',
|
||||
'WebSocket & Real-time Applications'
|
||||
]
|
||||
},
|
||||
worksFor: {
|
||||
'@type': 'Organization',
|
||||
name: 'Everlast Consulting GmbH',
|
||||
description: 'Process Automation & AI Solutions',
|
||||
location: 'Remote'
|
||||
},
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Bergisch Gladbach',
|
||||
addressRegion: 'Nordrhein-Westfalen',
|
||||
addressCountry: 'Deutschland'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const metaDescription =
|
||||
'Damjan Savić - AI & Automation Specialist. Entwicklung von KI-Agenten und Automatisierungslösungen mit n8n, Vapi Voice AI, ' +
|
||||
'GPT-4 und Claude API. Von Voice-AI-Plattformen bis zu autonomen Web-Agenten. TypeScript, Python, Next.js.';
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEO
|
||||
title="Damjan Savić - AI & Automation Specialist | KI-Agenten, Voice AI & Prozessautomatisierung"
|
||||
description={metaDescription}
|
||||
schema={schema}
|
||||
/>
|
||||
<LocalizedWebsiteSchema />
|
||||
<LocalizedPersonSchema />
|
||||
<LocalBusinessSchema />
|
||||
<ServiceSchema />
|
||||
<main className="min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<div id="home">
|
||||
<Hero />
|
||||
</div>
|
||||
|
||||
{/* Experience Section */}
|
||||
<div id="experience">
|
||||
<Experience />
|
||||
</div>
|
||||
|
||||
{/* Skills Section */}
|
||||
<div id="skills">
|
||||
<Skills />
|
||||
</div>
|
||||
|
||||
{/* Projects Section */}
|
||||
<div id="projects">
|
||||
<Projects />
|
||||
</div>
|
||||
|
||||
{/* About Section */}
|
||||
<div id="about">
|
||||
<About />
|
||||
</div>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div id="faq" className="py-16">
|
||||
<FAQSection />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
Reference in New Issue
Block a user