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:
2026-01-17 01:00:33 +01:00
co-authored by Claude Opus 4.5
parent a66f51b9a2
commit b1ec7b4d61
281 changed files with 8024 additions and 8901 deletions
+134
View File
@@ -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;
+158
View File
@@ -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;
+182
View File
@@ -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;
+115
View File
@@ -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;