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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user