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,119 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Terminal, Globe2, Code2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const Hero = () => {
|
||||
const t = useTranslations('pages.about.hero');
|
||||
|
||||
const getLanguages = () => {
|
||||
try {
|
||||
const languages = t.raw('languages');
|
||||
return typeof languages === 'object' ? Object.entries(languages) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToSkills = () => {
|
||||
const skillsSection = document.getElementById('skills');
|
||||
if (skillsSection) {
|
||||
skillsSection.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative min-h-screen text-white overflow-hidden">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 relative z-10">
|
||||
{/* Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-white text-3xl font-bold tracking-wider mb-4"
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h1>
|
||||
|
||||
{/* Description */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="max-w-3xl text-center mb-12"
|
||||
>
|
||||
<p className="text-zinc-400 text-lg mb-8">
|
||||
{t('description')}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Expertise Areas */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12"
|
||||
>
|
||||
<div className="flex flex-col items-center p-6 border border-zinc-800 rounded-lg bg-zinc-900/50">
|
||||
<Terminal className="h-8 w-8 mb-4 text-white" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('expertise.processAutomation.title')}
|
||||
</h3>
|
||||
<p className="text-zinc-400 text-center text-sm">
|
||||
{t('expertise.processAutomation.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-6 border border-zinc-800 rounded-lg bg-zinc-900/50">
|
||||
<Globe2 className="h-8 w-8 mb-4 text-white" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('expertise.systemIntegration.title')}
|
||||
</h3>
|
||||
<p className="text-zinc-400 text-center text-sm">
|
||||
{t('expertise.systemIntegration.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-6 border border-zinc-800 rounded-lg bg-zinc-900/50">
|
||||
<Code2 className="h-8 w-8 mb-4 text-white" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('expertise.customDevelopment.title')}
|
||||
</h3>
|
||||
<p className="text-zinc-400 text-center text-sm">
|
||||
{t('expertise.customDevelopment.description')}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Languages */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4"
|
||||
>
|
||||
{getLanguages().map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col items-center p-3 border border-zinc-800 rounded-lg bg-zinc-900/50 hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<span className="text-white font-medium">{value as string}</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* About Me Link */}
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
onClick={scrollToSkills}
|
||||
className="mt-8 px-6 py-3 bg-white text-zinc-900 rounded-lg font-medium hover:bg-zinc-100 transition-colors"
|
||||
>
|
||||
{t('aboutMe')}
|
||||
</motion.button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Briefcase, GraduationCap, Globe } from 'lucide-react';
|
||||
|
||||
interface WorkPosition {
|
||||
title: string;
|
||||
period: string;
|
||||
company: string;
|
||||
location: string;
|
||||
highlights: string[];
|
||||
}
|
||||
|
||||
interface LanguageItem {
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
const Journey = async () => {
|
||||
const t = await getTranslations('pages.about.journey');
|
||||
|
||||
let workPositions: WorkPosition[] = [];
|
||||
let languageItems: [string, LanguageItem][] = [];
|
||||
|
||||
try {
|
||||
const positions = t.raw('work.positions') as WorkPosition[];
|
||||
if (Array.isArray(positions)) {
|
||||
workPositions = positions;
|
||||
}
|
||||
} catch {
|
||||
workPositions = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const items = t.raw('languages.items') as Record<string, LanguageItem>;
|
||||
if (typeof items === 'object') {
|
||||
languageItems = Object.entries(items);
|
||||
}
|
||||
} catch {
|
||||
languageItems = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-24 relative">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* Education Column */}
|
||||
<div className="space-y-6">
|
||||
<div className="p-6 bg-zinc-800/30 border border-zinc-800 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<GraduationCap className="h-5 w-5 text-zinc-400" />
|
||||
<h3 className="text-lg font-medium text-white">
|
||||
{t('education.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-white font-medium">
|
||||
{t('education.degrees.masters.degree')}
|
||||
</h4>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
{t('education.degrees.masters.university')}
|
||||
</p>
|
||||
<p className="text-zinc-500 text-sm">
|
||||
{t('education.degrees.masters.period')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-medium">
|
||||
{t('education.degrees.bachelors.degree')}
|
||||
</h4>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
{t('education.degrees.bachelors.university')}
|
||||
</p>
|
||||
<p className="text-zinc-500 text-sm">
|
||||
{t('education.degrees.bachelors.period')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Languages */}
|
||||
<div className="p-6 bg-zinc-800/30 border border-zinc-800 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Globe className="h-5 w-5 text-zinc-400" />
|
||||
<h3 className="text-lg font-medium text-white">
|
||||
{t('languages.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{languageItems.map(([key, item]) => (
|
||||
<div key={key}>
|
||||
<div className="text-zinc-300">{item.name}</div>
|
||||
<div className="text-zinc-500 text-sm">{item.level}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Work History */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{workPositions.map((job, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-6 bg-zinc-800/30 border border-zinc-800 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-white">{job.title}</h3>
|
||||
<span className="text-zinc-500 text-sm">{job.period}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Briefcase className="h-4 w-4 text-zinc-400" />
|
||||
<p className="text-zinc-300">{job.company}</p>
|
||||
<span className="text-zinc-500">• {job.location}</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{job.highlights.map((highlight, idx) => (
|
||||
<li key={idx} className="text-zinc-400 text-sm flex items-start gap-2">
|
||||
<span className="text-zinc-500">•</span>
|
||||
{highlight}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Journey;
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Database, Server, Store, Code2, Box } from 'lucide-react';
|
||||
|
||||
interface SkillItem {
|
||||
name: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface SkillGroup {
|
||||
category: string;
|
||||
items: SkillItem[];
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
'Python': <Code2 className="w-8 h-8" />,
|
||||
'Server': <Server className="w-8 h-8" />,
|
||||
'Google Ads': <Store className="w-8 h-8" />,
|
||||
'Meta Ads': <Store className="w-8 h-8" />,
|
||||
'Shopware': <Store className="w-8 h-8" />,
|
||||
'Shopify': <Store className="w-8 h-8" />,
|
||||
'WooCommerce': <Store className="w-8 h-8" />,
|
||||
'JTL WAWI': <Box className="w-8 h-8" />,
|
||||
'AirFlow': <Database className="w-8 h-8" />
|
||||
};
|
||||
|
||||
const Skills = () => {
|
||||
const t = useTranslations('pages.about.skills');
|
||||
const [selectedSkill, setSelectedSkill] = useState<string | null>(null);
|
||||
const [allSkills, setAllSkills] = useState<SkillItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const skillGroups = t.raw('skillGroups') as SkillGroup[];
|
||||
if (Array.isArray(skillGroups)) {
|
||||
const skills = skillGroups.flatMap(group => group.items);
|
||||
setAllSkills(skills);
|
||||
}
|
||||
} catch {
|
||||
setAllSkills([]);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
if (allSkills.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Skills Progress Bars */}
|
||||
<div className="space-y-6">
|
||||
{allSkills.map((skill) => (
|
||||
<motion.div
|
||||
key={skill.name}
|
||||
className="space-y-2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onHoverStart={() => setSelectedSkill(skill.name)}
|
||||
onHoverEnd={() => setSelectedSkill(null)}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{skill.name}
|
||||
</span>
|
||||
{selectedSkill === skill.name && (
|
||||
<span className="text-zinc-400 text-xs">
|
||||
{skill.level}% Proficiency
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-white text-sm">
|
||||
{skill.level}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2 bg-zinc-800 rounded-full overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-valuenow={skill.level}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<motion.div
|
||||
className={`h-full rounded-full ${
|
||||
selectedSkill === skill.name ? 'bg-zinc-500' : 'bg-zinc-600'
|
||||
}`}
|
||||
initial={{ width: 0 }}
|
||||
whileInView={{ width: `${skill.level}%` }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Skills Icons Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
{allSkills.map((skill) => (
|
||||
<motion.div
|
||||
key={skill.name}
|
||||
className={`p-6 rounded-xl bg-zinc-800/30 backdrop-blur-sm flex flex-col items-center justify-center gap-3 cursor-pointer transition-all duration-300 hover:bg-zinc-700/50 ${
|
||||
selectedSkill === skill.name ? 'ring-2 ring-zinc-500' : ''
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
onClick={() => setSelectedSkill(skill.name === selectedSkill ? null : skill.name)}
|
||||
role="button"
|
||||
aria-pressed={selectedSkill === skill.name}
|
||||
>
|
||||
<div className={selectedSkill === skill.name ? 'text-zinc-500' : 'text-white'}>
|
||||
{iconMap[skill.name] || <Box className="w-8 h-8" />}
|
||||
</div>
|
||||
<span className="text-white text-sm text-center">
|
||||
{skill.name}
|
||||
</span>
|
||||
{selectedSkill === skill.name && (
|
||||
<span className="text-zinc-400 text-xs text-center mt-1">
|
||||
{skill.level}%
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Skills;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface WorkflowStep {
|
||||
number: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const Workflow = async () => {
|
||||
const t = await getTranslations('pages.about.workflow');
|
||||
|
||||
let workflowSteps: WorkflowStep[] = [];
|
||||
try {
|
||||
const steps = t.raw('steps') as WorkflowStep[];
|
||||
if (Array.isArray(steps)) {
|
||||
workflowSteps = steps;
|
||||
}
|
||||
} catch {
|
||||
workflowSteps = [];
|
||||
}
|
||||
|
||||
if (workflowSteps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalSteps = workflowSteps.length;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto" role="list">
|
||||
{workflowSteps.map((step) => (
|
||||
<div
|
||||
key={step.number}
|
||||
className="group relative"
|
||||
role="listitem"
|
||||
aria-label={`Step ${step.number} of ${totalSteps}: ${step.title}`}
|
||||
>
|
||||
<div className="flex items-center py-6 border-b border-zinc-800 group-hover:border-zinc-700 transition-colors duration-300">
|
||||
<div className="w-12 sm:w-20 text-sm text-zinc-600 font-light">
|
||||
{step.number}
|
||||
</div>
|
||||
<h3 className="text-sm sm:text-base text-zinc-400 group-hover:text-white transition-colors duration-300">
|
||||
{step.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Line Indicator */}
|
||||
<div
|
||||
className="absolute left-0 bottom-0 w-0 h-px bg-white group-hover:w-full transition-all duration-500 ease-out"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Workflow;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as Hero } from './Hero';
|
||||
export { default as Skills } from './Skills';
|
||||
export { default as Workflow } from './Workflow';
|
||||
export { default as Journey } from './Journey';
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, BarChart2, Users, Eye, Clock, TrendingUp, TrendingDown, LogOut } from 'lucide-react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
interface MetricCard {
|
||||
title: string;
|
||||
value: string;
|
||||
subtitle: string;
|
||||
trend?: 'up' | 'down';
|
||||
trendValue?: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DashboardContent() {
|
||||
const t = useTranslations('dashboard');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
router.push(`/${locale}/login`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(user);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [locale, router]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
router.push(`/${locale}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 text-zinc-400 animate-spin" />
|
||||
<p className="text-zinc-400 text-sm">{t('status.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-zinc-400">{t('status.notAuthenticated')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics: MetricCard[] = [
|
||||
{
|
||||
title: t('metrics.pageViews.title'),
|
||||
value: '12,543',
|
||||
subtitle: '45 ' + t('metrics.pageViews.perMinute'),
|
||||
trend: 'up',
|
||||
trendValue: '+12.5%',
|
||||
icon: <Eye className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: t('metrics.uniqueVisitors.title'),
|
||||
value: '3,721',
|
||||
subtitle: '127 ' + t('metrics.uniqueVisitors.currentlyActive'),
|
||||
trend: 'up',
|
||||
trendValue: '+8.2%',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: t('metrics.conversions.title'),
|
||||
value: '156',
|
||||
subtitle: '4.2% ' + t('metrics.conversions.rate'),
|
||||
trend: 'down',
|
||||
trendValue: '-2.1%',
|
||||
icon: <BarChart2 className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: t('metrics.timeOnSite.title'),
|
||||
value: '4:32',
|
||||
subtitle: '32% ' + t('metrics.timeOnSite.bounceRate'),
|
||||
trend: 'up',
|
||||
trendValue: '+15.3%',
|
||||
icon: <Clock className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
const topPages = [
|
||||
{ path: '/', views: 4521, change: '+12%' },
|
||||
{ path: '/portfolio', views: 2341, change: '+8%' },
|
||||
{ path: '/about', views: 1823, change: '+5%' },
|
||||
{ path: '/blog', views: 1456, change: '-2%' },
|
||||
{ path: '/contact', views: 892, change: '+3%' },
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-16 sm:py-20 lg:py-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-12">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">{t('header.title')}</h1>
|
||||
<p className="text-zinc-400 mt-2">
|
||||
127 {t('header.activeUsers')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-zinc-300 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
{metrics.map((metric, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-zinc-900/50 backdrop-blur-sm rounded-xl p-6 ring-1 ring-zinc-800"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-2 bg-zinc-800 rounded-lg text-zinc-400">
|
||||
{metric.icon}
|
||||
</div>
|
||||
{metric.trend && (
|
||||
<div className={`flex items-center gap-1 text-sm ${
|
||||
metric.trend === 'up' ? 'text-green-500' : 'text-red-500'
|
||||
}`}>
|
||||
{metric.trend === 'up' ? (
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
)}
|
||||
{metric.trendValue}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm text-zinc-400 mb-1">{metric.title}</h3>
|
||||
<p className="text-2xl font-bold text-white">{metric.value}</p>
|
||||
<p className="text-sm text-zinc-500 mt-1">{metric.subtitle}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Top Pages */}
|
||||
<div className="bg-zinc-900/50 backdrop-blur-sm rounded-xl ring-1 ring-zinc-800">
|
||||
<div className="p-6 border-b border-zinc-800">
|
||||
<h2 className="text-xl font-semibold text-white">{t('topPages.title')}</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-zinc-500 border-b border-zinc-800">
|
||||
<th className="px-6 py-4 font-medium">{t('topPages.columns.path')}</th>
|
||||
<th className="px-6 py-4 font-medium">{t('topPages.columns.views')}</th>
|
||||
<th className="px-6 py-4 font-medium">{t('topPages.columns.change')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topPages.map((page, index) => (
|
||||
<tr key={index} className="border-b border-zinc-800/50 last:border-0">
|
||||
<td className="px-6 py-4 text-white font-mono text-sm">{page.path}</td>
|
||||
<td className="px-6 py-4 text-zinc-300">{page.views.toLocaleString()}</td>
|
||||
<td className={`px-6 py-4 ${
|
||||
page.change.startsWith('+') ? 'text-green-500' : 'text-red-500'
|
||||
}`}>
|
||||
{page.change}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Lock, Loader2 } from 'lucide-react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
export default function LoginForm() {
|
||||
const t = useTranslations('login');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
router.push(`/${locale}/dashboard`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('form.errors.default'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center py-24 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Lock className="mx-auto h-12 w-12 text-orange-500" />
|
||||
<h1 className="mt-6 text-3xl font-bold text-white">{t('header.title')}</h1>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 p-4 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-white/80 mb-2">
|
||||
{t('form.email.label')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('form.email.placeholder')}
|
||||
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-white/80 mb-2">
|
||||
{t('form.password.label')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('form.password.placeholder')}
|
||||
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-orange-500 hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t('form.submit.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('form.submit.default')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-sm text-zinc-500">
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<svg className="h-4 w-4 text-green-500" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('security.secureConnection')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as LoginForm } from './LoginForm';
|
||||
export { default as DashboardContent } from './DashboardContent';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ContactForm } from './ContactForm';
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Phone, Mail, ArrowRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
export function ContactInfo() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('footer.sections.contact');
|
||||
|
||||
return (
|
||||
<div className="pb-6 px-6 pt-4 bg-zinc-800/30 border border-zinc-800 rounded-lg mx-4 mt-2">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-white tracking-wider uppercase">
|
||||
{t('title')}
|
||||
</h3>
|
||||
|
||||
<a
|
||||
href="tel:+491756950979"
|
||||
className="flex items-center space-x-3 text-sm text-zinc-400 hover:text-white transition-colors duration-200 group"
|
||||
>
|
||||
<Phone className="h-4 w-4 text-zinc-400 group-hover:text-white transition-colors duration-200" />
|
||||
<span>+49 175 695 0979</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="mailto:info@damjan-savic.com"
|
||||
className="flex items-center space-x-3 text-sm text-zinc-400 hover:text-white transition-colors duration-200 group"
|
||||
>
|
||||
<Mail className="h-4 w-4 text-zinc-400 group-hover:text-white transition-colors duration-200" />
|
||||
<span>{t('email')}</span>
|
||||
</a>
|
||||
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
className="flex items-center justify-between mt-6 px-4 py-2 bg-zinc-800/50
|
||||
hover:bg-zinc-800 rounded-full text-sm text-zinc-400 hover:text-white
|
||||
transition-all duration-200 group border border-zinc-800 hover:border-zinc-700"
|
||||
>
|
||||
<span className="tracking-wide">Kontakt aufnehmen</span>
|
||||
<ArrowRight className="h-4 w-4 transform group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Link from 'next/link';
|
||||
import { getLocale, getTranslations } from 'next-intl/server';
|
||||
import { Mail, Linkedin, Github, MapPin } from 'lucide-react';
|
||||
|
||||
const Footer = async () => {
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations('footer');
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="relative bg-zinc-800/50 backdrop-blur-sm border-t border-zinc-700/30 pt-8 pb-4 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col space-y-8">
|
||||
{/* Contact Section */}
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-3">
|
||||
{t('sections.contact.title')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href={`mailto:${t('sections.contact.email')}`}
|
||||
className="group flex items-center gap-2 text-zinc-400 hover:text-white transition-colors text-sm w-fit"
|
||||
>
|
||||
<Mail className="h-4 w-4 text-zinc-500 group-hover:text-white transition-colors flex-shrink-0" />
|
||||
<span>{t('sections.contact.email')}</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-2 text-zinc-400 text-sm w-fit">
|
||||
<MapPin className="h-4 w-4 text-zinc-500 flex-shrink-0" />
|
||||
<span>{t('sections.contact.location')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Section */}
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-3 text-left">
|
||||
{t('sections.navigation.title')}
|
||||
</h3>
|
||||
<nav className="flex flex-col space-y-2">
|
||||
<Link
|
||||
href={`/${locale}/portfolio`}
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit"
|
||||
>
|
||||
{t('sections.navigation.links.portfolio')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit"
|
||||
>
|
||||
{t('sections.navigation.links.blog')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/about`}
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit"
|
||||
>
|
||||
{t('sections.navigation.links.about')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
className="text-zinc-400 hover:text-white transition-colors text-sm w-fit"
|
||||
>
|
||||
{t('sections.navigation.links.contact')}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Social Media Section */}
|
||||
<div>
|
||||
<h3 className="text-white text-base font-semibold mb-3 text-left">
|
||||
{t('sections.social.title')}
|
||||
</h3>
|
||||
<div className="flex space-x-4">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/damjan-savić-720288127/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<Linkedin className="transform transition-transform group-hover:scale-110" size={20} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/damjan1996"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github className="transform transition-transform group-hover:scale-110" size={20} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal Section */}
|
||||
<div className="border-t border-zinc-700/30 pt-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<p className="text-zinc-400 text-xs text-left">
|
||||
© {currentYear} Damjan Savić. {t('legal.copyright')}
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<Link
|
||||
href={`/${locale}/privacy`}
|
||||
className="text-zinc-400 hover:text-white text-xs transition-colors"
|
||||
>
|
||||
{t('legal.links.privacy')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/imprint`}
|
||||
className="text-zinc-400 hover:text-white text-xs transition-colors"
|
||||
>
|
||||
{t('legal.links.imprint')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/terms`}
|
||||
className="text-zinc-400 hover:text-white text-xs transition-colors"
|
||||
>
|
||||
{t('legal.links.terms')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,112 @@
|
||||
const GlobalBackground = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Background layer */}
|
||||
<div
|
||||
className="fixed inset-0 bg-zinc-900"
|
||||
style={{ zIndex: -2 }}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
background: 'linear-gradient(135deg, #1e1e1e 0%, #2a2a2a 50%, #1e1e1e 100%)',
|
||||
opacity: 0.9
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Animated lines */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{ zIndex: -1 }}
|
||||
>
|
||||
<svg
|
||||
className="w-full h-full"
|
||||
preserveAspectRatio="none"
|
||||
style={{ opacity: 0.3 }}
|
||||
>
|
||||
<line
|
||||
x1="0%"
|
||||
y1="20%"
|
||||
x2="100%"
|
||||
y2="20%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.2"
|
||||
>
|
||||
<animate
|
||||
attributeName="y1"
|
||||
values="20%;80%;20%"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y2"
|
||||
values="20%;80%;20%"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
|
||||
<line
|
||||
x1="0%"
|
||||
y1="50%"
|
||||
x2="100%"
|
||||
y2="50%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.15"
|
||||
>
|
||||
<animate
|
||||
attributeName="x1"
|
||||
values="0%;100%;0%"
|
||||
dur="15s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
|
||||
<line
|
||||
x1="20%"
|
||||
y1="0%"
|
||||
x2="20%"
|
||||
y2="100%"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
opacity="0.1"
|
||||
>
|
||||
<animate
|
||||
attributeName="x1"
|
||||
values="20%;80%;20%"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="x2"
|
||||
values="20%;80%;20%"
|
||||
dur="12s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Grid overlay */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '50px 50px',
|
||||
opacity: 0.5
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalBackground;
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import {
|
||||
Menu,
|
||||
X,
|
||||
Home,
|
||||
User,
|
||||
Briefcase,
|
||||
BookOpen,
|
||||
Phone,
|
||||
Cog,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { NavLink } from './NavLink';
|
||||
import LanguageSwitcher from './LanguageSwitcher';
|
||||
import { ContactInfo } from './ContactInfo';
|
||||
|
||||
const Header = () => {
|
||||
const t = useTranslations('navigation');
|
||||
const locale = useLocale();
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// Close menu on route change
|
||||
useEffect(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Scroll handler
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 0);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Prevent body scroll when menu is open
|
||||
useEffect(() => {
|
||||
if (isMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isMenuOpen]);
|
||||
|
||||
const navigationLinks = [
|
||||
{
|
||||
path: `/${locale}`,
|
||||
label: t('main.home'),
|
||||
icon: <Home className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: `/${locale}/about`,
|
||||
label: t('main.about'),
|
||||
icon: <User className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: `/${locale}/leistungen`,
|
||||
label: t('main.services'),
|
||||
icon: <Cog className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: `/${locale}/portfolio`,
|
||||
label: t('main.portfolio'),
|
||||
icon: <Briefcase className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: `/${locale}/blog`,
|
||||
label: t('main.blog'),
|
||||
icon: <BookOpen className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: `/${locale}/contact`,
|
||||
label: t('main.contact'),
|
||||
icon: <Phone className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
className={`
|
||||
fixed w-full z-40 transition-all duration-300
|
||||
bg-zinc-800/50 backdrop-blur-sm border-b border-zinc-700/30
|
||||
${scrolled
|
||||
? 'bg-zinc-800/70 backdrop-blur-md shadow-lg shadow-zinc-900/30'
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<Link href={`/${locale}`} className="flex items-center space-x-2 group shrink-0">
|
||||
<Image
|
||||
src="/header-logo.svg"
|
||||
alt="Damjan Savić Logo"
|
||||
className="h-8 w-auto transition-transform duration-200 hover:scale-105"
|
||||
width={120}
|
||||
height={32}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{navigationLinks.map(({ path, label, icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
href={path}
|
||||
icon={icon}
|
||||
label={label}
|
||||
className="text-sm"
|
||||
/>
|
||||
))}
|
||||
<div className="ml-4 text-zinc-400 pl-2 border-l border-zinc-700">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden relative z-50 p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors active:scale-95"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label={isMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/50 md:hidden z-30 transition-opacity duration-200 ${
|
||||
isMenuOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<div
|
||||
id="mobile-menu"
|
||||
className={`fixed right-0 top-0 h-full w-80 bg-zinc-900/95 backdrop-blur-md shadow-xl md:hidden z-50 border-l border-zinc-800 transition-transform duration-300 ease-out ${
|
||||
isMenuOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Sidebar Header */}
|
||||
<div className="flex items-center justify-between px-4 h-16 border-b border-zinc-800">
|
||||
<span className="text-white font-semibold">Menu</span>
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800/50 transition-colors active:scale-95"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Sidebar Content */}
|
||||
<div className="h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Contact Info */}
|
||||
<ContactInfo />
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="py-4 px-4">
|
||||
{navigationLinks.map(({ path, label, icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
href={path}
|
||||
icon={icon}
|
||||
label={label}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className="flex items-center w-full mb-1"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<div className="px-4 py-3 border-t border-zinc-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-zinc-400">
|
||||
Sprache ändern
|
||||
</span>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
const LanguageSwitcher = () => {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'de', name: 'Deutsch' },
|
||||
{ code: 'sr', name: 'Srpski' },
|
||||
];
|
||||
|
||||
const currentLanguage = languages.find(lang => lang.code === locale)?.name || 'Language';
|
||||
|
||||
const handleLanguageChange = (newLocale: string) => {
|
||||
// Replace the locale in the pathname
|
||||
const pathWithoutLocale = pathname.replace(/^\/(de|en|sr)/, '');
|
||||
const newPath = `/${newLocale}${pathWithoutLocale || ''}`;
|
||||
router.push(newPath);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={dropdownRef}>
|
||||
<button
|
||||
className="flex items-center space-x-2 text-zinc-400 hover:text-white
|
||||
px-4 py-2 rounded-full transition-all duration-200
|
||||
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800
|
||||
hover:scale-105 active:scale-95"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Select language"
|
||||
>
|
||||
<Globe className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-sm">{currentLanguage}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`absolute ${isMobile ? 'bottom-full mb-2' : 'top-full mt-2'} right-0 w-48 rounded-lg overflow-hidden
|
||||
border border-zinc-800 bg-zinc-900/95 backdrop-blur-sm shadow-lg
|
||||
transition-all duration-200 origin-top
|
||||
${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'}`}
|
||||
role="listbox"
|
||||
aria-label="Languages"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="py-1">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
|
||||
hover:bg-zinc-800/50
|
||||
${locale === lang.code
|
||||
? 'bg-zinc-800 text-white'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
role="option"
|
||||
aria-selected={locale === lang.code}
|
||||
>
|
||||
{lang.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface NavLinkProps {
|
||||
href: string;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NavLink({ href, icon, label, onClick, className }: NavLinkProps) {
|
||||
const pathname = usePathname();
|
||||
// Check if path matches (accounting for locale prefix)
|
||||
const pathWithoutLocale = pathname.replace(/^\/(de|en|sr)/, '');
|
||||
const hrefWithoutLocale = href.replace(/^\/(de|en|sr)/, '');
|
||||
const isActive = pathWithoutLocale === hrefWithoutLocale ||
|
||||
(hrefWithoutLocale === '' && pathWithoutLocale === '');
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
relative flex items-center gap-2 rounded-full py-2 px-4
|
||||
transition-all duration-200 ease-in-out
|
||||
${isActive ? 'text-white bg-zinc-700/60' : 'text-zinc-400 hover:text-white hover:bg-zinc-800/30'}
|
||||
${className || ''}
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-sm tracking-wide">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Header } from './Header';
|
||||
export { default as Footer } from './Footer';
|
||||
export { default as GlobalBackground } from './GlobalBackground';
|
||||
export { default as LanguageSwitcher } from './LanguageSwitcher';
|
||||
export { NavLink } from './NavLink';
|
||||
export { ContactInfo } from './ContactInfo';
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ExternalLink, Calendar, Tag } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
interface Project {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
excerpt?: string;
|
||||
technologies: string[];
|
||||
category?: string;
|
||||
date?: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
interface PortfolioCardProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const PortfolioCard = ({ project }: PortfolioCardProps) => {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('portfolio.ui');
|
||||
const displayTags = project.technologies;
|
||||
|
||||
return (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
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">
|
||||
{/* Project Info */}
|
||||
<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>
|
||||
)}
|
||||
{displayTags && displayTags.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Tag className="h-4 w-4" />
|
||||
<span>{displayTags.length} {t('technologies')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title & Description */}
|
||||
<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 || project.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayTags.slice(0, 3).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
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>
|
||||
))}
|
||||
{displayTags.length > 3 && (
|
||||
<span className="text-sm text-zinc-600">
|
||||
+{displayTags.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>
|
||||
|
||||
{/* Subtle Hover Border */}
|
||||
<div className="absolute inset-0 rounded-xl pointer-events-none
|
||||
ring-1 ring-zinc-600/0 group-hover:ring-zinc-600/30
|
||||
transition-all duration-500" />
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default PortfolioCard;
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import PortfolioCard from './PortfolioCard';
|
||||
|
||||
interface Project {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
excerpt?: string;
|
||||
technologies: string[];
|
||||
category?: string;
|
||||
date?: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
interface PortfolioGridProps {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
const PortfolioGrid = ({ projects }: PortfolioGridProps) => {
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-8"
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<motion.div
|
||||
key={project.slug}
|
||||
variants={itemVariants}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="group"
|
||||
>
|
||||
<PortfolioCard project={project} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PortfolioGrid;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as PortfolioCard } from './PortfolioCard';
|
||||
export { default as PortfolioGrid } from './PortfolioGrid';
|
||||
@@ -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';
|
||||
@@ -0,0 +1,758 @@
|
||||
import { type Locale } from '@/i18n/config';
|
||||
|
||||
interface JsonLdProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export function PersonJsonLd({ locale }: JsonLdProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
'@id': `${baseUrl}/#person`,
|
||||
name: 'Damjan Savić',
|
||||
givenName: 'Damjan',
|
||||
familyName: 'Savić',
|
||||
jobTitle: locale === 'de'
|
||||
? 'AI & Automation Specialist | Fullstack Entwickler'
|
||||
: locale === 'sr'
|
||||
? 'AI & Automation Specialist | Fullstack Developer'
|
||||
: 'AI & Automation Specialist | Fullstack Developer',
|
||||
description: locale === 'de'
|
||||
? 'Entwicklung von KI-Agenten und Automatisierungslösungen. Von Voice-AI-Plattformen bis zu autonomen Web-Agenten.'
|
||||
: locale === 'sr'
|
||||
? 'Razvoj AI agenata i rešenja za automatizaciju. Od voice AI platformi do autonomnih web agenata.'
|
||||
: 'Remote AI developer building AI agents and automation solutions for clients in USA, UK and Europe. From voice AI platforms to autonomous web agents.',
|
||||
url: baseUrl,
|
||||
image: `${baseUrl}/images/og-image.jpg`,
|
||||
email: 'info@damjan-savic.com',
|
||||
telephone: '+491756950979',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: locale === 'sr' ? 'Keln' : locale === 'en' ? 'Cologne' : 'Köln',
|
||||
addressRegion: 'NRW',
|
||||
addressCountry: 'DE',
|
||||
},
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/in/damjansavic/',
|
||||
'https://github.com/damjansavic',
|
||||
],
|
||||
knowsAbout: [
|
||||
'Artificial Intelligence',
|
||||
'Machine Learning',
|
||||
'Process Automation',
|
||||
'Voice AI',
|
||||
'Web Development',
|
||||
'Python',
|
||||
'TypeScript',
|
||||
'React',
|
||||
'Next.js',
|
||||
'n8n',
|
||||
'SaaS Development',
|
||||
],
|
||||
alumniOf: [
|
||||
{
|
||||
'@type': 'EducationalOrganization',
|
||||
name: 'FOM Hochschule für Ökonomie & Management',
|
||||
},
|
||||
],
|
||||
hasCredential: [
|
||||
{
|
||||
'@type': 'EducationalOccupationalCredential',
|
||||
name: 'M.A. Software Development',
|
||||
credentialCategory: 'degree',
|
||||
},
|
||||
{
|
||||
'@type': 'EducationalOccupationalCredential',
|
||||
name: 'B.Sc. Wirtschaftsinformatik',
|
||||
credentialCategory: 'degree',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfessionalServiceJsonLd({ locale }: JsonLdProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const serviceNames = {
|
||||
de: [
|
||||
'KI-Entwicklung',
|
||||
'Prozessautomatisierung',
|
||||
'Voice AI Entwicklung',
|
||||
'SaaS Entwicklung',
|
||||
'Fullstack Webentwicklung',
|
||||
'n8n Automatisierung',
|
||||
],
|
||||
en: [
|
||||
'AI Development',
|
||||
'Process Automation',
|
||||
'Voice AI Development',
|
||||
'SaaS Development',
|
||||
'Fullstack Web Development',
|
||||
'n8n Automation',
|
||||
],
|
||||
sr: [
|
||||
'AI Razvoj',
|
||||
'Automatizacija procesa',
|
||||
'Voice AI Razvoj',
|
||||
'SaaS Razvoj',
|
||||
'Fullstack Web Razvoj',
|
||||
'n8n Automatizacija',
|
||||
],
|
||||
};
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfessionalService',
|
||||
'@id': `${baseUrl}/#business`,
|
||||
name: 'Damjan Savić - AI & Automation Specialist',
|
||||
description: locale === 'de'
|
||||
? 'Freelance AI & Automation Specialist aus Köln. Spezialisiert auf KI-Agenten, Voice AI, Prozessautomatisierung und SaaS-Entwicklung.'
|
||||
: locale === 'sr'
|
||||
? 'Freelance AI & Automation Specialist iz Kelna. Specijalizovan za AI agente, Voice AI, automatizaciju procesa i SaaS razvoj.'
|
||||
: 'Remote AI & Automation Specialist based in Germany, serving clients in USA, UK and Europe. Specialized in AI agents, Voice AI, n8n process automation and custom SaaS development.',
|
||||
url: baseUrl,
|
||||
logo: `${baseUrl}/images/og-image.jpg`,
|
||||
image: `${baseUrl}/images/og-image.jpg`,
|
||||
email: 'info@damjan-savic.com',
|
||||
telephone: '+491756950979',
|
||||
priceRange: '€€€',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: 'Rotdornallee',
|
||||
addressLocality: locale === 'sr' ? 'Keln' : locale === 'en' ? 'Cologne' : 'Köln',
|
||||
addressRegion: 'NRW',
|
||||
postalCode: '50769',
|
||||
addressCountry: 'DE',
|
||||
},
|
||||
geo: {
|
||||
'@type': 'GeoCoordinates',
|
||||
latitude: 50.9375,
|
||||
longitude: 6.9603,
|
||||
},
|
||||
areaServed: [
|
||||
// Germany
|
||||
{ '@type': 'City', name: 'Cologne' },
|
||||
{ '@type': 'City', name: 'Düsseldorf' },
|
||||
{ '@type': 'City', name: 'Frankfurt' },
|
||||
{ '@type': 'City', name: 'Munich' },
|
||||
{ '@type': 'City', name: 'Berlin' },
|
||||
{ '@type': 'City', name: 'Hamburg' },
|
||||
{ '@type': 'Country', name: 'Germany' },
|
||||
// USA
|
||||
{ '@type': 'City', name: 'New York' },
|
||||
{ '@type': 'City', name: 'Los Angeles' },
|
||||
{ '@type': 'City', name: 'San Francisco' },
|
||||
{ '@type': 'City', name: 'Miami' },
|
||||
{ '@type': 'City', name: 'Chicago' },
|
||||
{ '@type': 'Country', name: 'United States' },
|
||||
// UK
|
||||
{ '@type': 'City', name: 'London' },
|
||||
{ '@type': 'Country', name: 'United Kingdom' },
|
||||
// Europe
|
||||
{ '@type': 'Country', name: 'Austria' },
|
||||
{ '@type': 'Country', name: 'Switzerland' },
|
||||
],
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: locale === 'de' ? 'Dienstleistungen' : locale === 'sr' ? 'Usluge' : 'Services',
|
||||
itemListElement: serviceNames[locale].map((service, index) => ({
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: service,
|
||||
},
|
||||
position: index + 1,
|
||||
})),
|
||||
},
|
||||
founder: {
|
||||
'@id': `${baseUrl}/#person`,
|
||||
},
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/in/damjansavic/',
|
||||
'https://github.com/damjansavic',
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreadcrumbJsonLd({
|
||||
items,
|
||||
locale,
|
||||
}: {
|
||||
items: { name: string; url: string }[];
|
||||
locale: Locale;
|
||||
}) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
inLanguage: locale === 'de' ? 'de-DE' : locale === 'sr' ? 'sr-RS' : 'en-US',
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: item.url.startsWith('http') ? item.url : `${baseUrl}${item.url}`,
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebPageJsonLd({
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
locale,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
locale: Locale;
|
||||
}) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: title,
|
||||
description: description,
|
||||
url: url.startsWith('http') ? url : `${baseUrl}${url}`,
|
||||
inLanguage: locale === 'de' ? 'de-DE' : locale === 'sr' ? 'sr-RS' : 'en-US',
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'Damjan Savić',
|
||||
url: baseUrl,
|
||||
},
|
||||
author: {
|
||||
'@id': `${baseUrl}/#person`,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export function FAQJsonLd({ items, locale }: { items: FAQItem[]; locale?: Locale }) {
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
inLanguage: locale ? (locale === 'de' ? 'de-DE' : locale === 'sr' ? 'sr-RS' : 'en-US') : 'de-DE',
|
||||
mainEntity: items.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServiceJsonLdProps {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
locale: Locale;
|
||||
areaServed?: string[];
|
||||
}
|
||||
|
||||
export function ServiceJsonLd({
|
||||
name,
|
||||
description,
|
||||
url,
|
||||
locale,
|
||||
areaServed = ['Köln', 'Düsseldorf', 'Frankfurt', 'Deutschland'],
|
||||
}: ServiceJsonLdProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: name,
|
||||
description: description,
|
||||
url: url.startsWith('http') ? url : `${baseUrl}${url}`,
|
||||
inLanguage: locale === 'de' ? 'de-DE' : locale === 'sr' ? 'sr-RS' : 'en-US',
|
||||
availableLanguage: ['de-DE', 'en-US', 'sr-RS'],
|
||||
provider: {
|
||||
'@id': `${baseUrl}/#business`,
|
||||
},
|
||||
areaServed: areaServed.map((area) => ({
|
||||
'@type': 'City',
|
||||
name: area,
|
||||
})),
|
||||
serviceType: name,
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Organization Schema
|
||||
export function OrganizationJsonLd({ locale }: JsonLdProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
'@id': `${baseUrl}/#organization`,
|
||||
name: 'Damjan Savić - AI & Automation Specialist',
|
||||
alternateName: 'Damjan Savić',
|
||||
url: baseUrl,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${baseUrl}/images/og-image.jpg`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
image: `${baseUrl}/images/og-image.jpg`,
|
||||
description: locale === 'de'
|
||||
? 'Freelance KI-Entwickler und Automatisierungsspezialist aus Köln. Spezialisiert auf KI-Agenten, Voice AI, Prozessautomatisierung mit n8n und SaaS-Entwicklung.'
|
||||
: locale === 'sr'
|
||||
? 'Freelance AI developer i specijalista za automatizaciju iz Kelna. Specijalizovan za AI agente, Voice AI, automatizaciju procesa sa n8n i SaaS razvoj.'
|
||||
: 'Remote AI developer and automation specialist based in Germany. Working with clients in USA, UK & Europe. Specialized in AI agents, Voice AI, n8n automation and SaaS development.',
|
||||
email: 'info@damjan-savic.com',
|
||||
telephone: '+491756950979',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: 'Rotdornallee',
|
||||
addressLocality: locale === 'sr' ? 'Keln' : locale === 'en' ? 'Cologne' : 'Köln',
|
||||
addressRegion: 'NRW',
|
||||
postalCode: '50769',
|
||||
addressCountry: 'DE',
|
||||
},
|
||||
geo: {
|
||||
'@type': 'GeoCoordinates',
|
||||
latitude: 50.9375,
|
||||
longitude: 6.9603,
|
||||
},
|
||||
areaServed: [
|
||||
// DACH Region
|
||||
{ '@type': 'Country', name: 'Germany' },
|
||||
{ '@type': 'Country', name: 'Austria' },
|
||||
{ '@type': 'Country', name: 'Switzerland' },
|
||||
// International (Remote)
|
||||
{ '@type': 'Country', name: 'United States' },
|
||||
{ '@type': 'Country', name: 'United Kingdom' },
|
||||
{ '@type': 'Country', name: 'Serbia' },
|
||||
// Major International Cities
|
||||
{ '@type': 'City', name: 'New York' },
|
||||
{ '@type': 'City', name: 'London' },
|
||||
{ '@type': 'City', name: 'San Francisco' },
|
||||
{ '@type': 'City', name: 'Los Angeles' },
|
||||
],
|
||||
founder: {
|
||||
'@id': `${baseUrl}/#person`,
|
||||
},
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/in/damjansavic/',
|
||||
'https://github.com/damjansavic',
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'customer service',
|
||||
email: 'info@damjan-savic.com',
|
||||
telephone: '+491756950979',
|
||||
availableLanguage: ['German', 'English', 'Serbian'],
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Article Schema for Blog Posts
|
||||
interface ArticleJsonLdProps {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
imageUrl: string;
|
||||
datePublished: string;
|
||||
dateModified?: string;
|
||||
authorName?: string;
|
||||
tags?: string[];
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export function ArticleJsonLd({
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
imageUrl,
|
||||
datePublished,
|
||||
dateModified,
|
||||
authorName = 'Damjan Savić',
|
||||
tags = [],
|
||||
locale,
|
||||
}: ArticleJsonLdProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: title,
|
||||
description: description,
|
||||
image: imageUrl.startsWith('http') ? imageUrl : `${baseUrl}${imageUrl}`,
|
||||
url: url.startsWith('http') ? url : `${baseUrl}${url}`,
|
||||
datePublished: datePublished,
|
||||
dateModified: dateModified || datePublished,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
'@id': `${baseUrl}/#person`,
|
||||
name: authorName,
|
||||
url: baseUrl,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
'@id': `${baseUrl}/#organization`,
|
||||
name: 'Damjan Savić',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${baseUrl}/images/og-image.jpg`,
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url.startsWith('http') ? url : `${baseUrl}${url}`,
|
||||
},
|
||||
inLanguage: locale === 'de' ? 'de-DE' : locale === 'sr' ? 'sr-RS' : 'en-US',
|
||||
keywords: tags.join(', '),
|
||||
articleSection: 'Technology',
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// HowTo Schema for Tutorials
|
||||
interface HowToStep {
|
||||
name: string;
|
||||
text: string;
|
||||
url?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
interface HowToJsonLdProps {
|
||||
name: string;
|
||||
description: string;
|
||||
steps: HowToStep[];
|
||||
totalTime?: string; // ISO 8601 duration format, e.g., "PT30M" for 30 minutes
|
||||
estimatedCost?: { currency: string; value: string };
|
||||
tools?: string[];
|
||||
supplies?: string[];
|
||||
image?: string;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export function HowToJsonLd({
|
||||
name,
|
||||
description,
|
||||
steps,
|
||||
totalTime,
|
||||
estimatedCost,
|
||||
tools,
|
||||
supplies,
|
||||
image,
|
||||
locale,
|
||||
}: HowToJsonLdProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: name,
|
||||
description: description,
|
||||
step: steps.map((step, index) => ({
|
||||
'@type': 'HowToStep',
|
||||
position: index + 1,
|
||||
name: step.name,
|
||||
text: step.text,
|
||||
...(step.url && { url: step.url.startsWith('http') ? step.url : `${baseUrl}${step.url}` }),
|
||||
...(step.image && { image: step.image.startsWith('http') ? step.image : `${baseUrl}${step.image}` }),
|
||||
})),
|
||||
inLanguage: locale === 'de' ? 'de-DE' : locale === 'sr' ? 'sr-RS' : 'en-US',
|
||||
};
|
||||
|
||||
if (totalTime) {
|
||||
schema.totalTime = totalTime;
|
||||
}
|
||||
|
||||
if (estimatedCost) {
|
||||
schema.estimatedCost = {
|
||||
'@type': 'MonetaryAmount',
|
||||
currency: estimatedCost.currency,
|
||||
value: estimatedCost.value,
|
||||
};
|
||||
}
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
schema.tool = tools.map((tool) => ({
|
||||
'@type': 'HowToTool',
|
||||
name: tool,
|
||||
}));
|
||||
}
|
||||
|
||||
if (supplies && supplies.length > 0) {
|
||||
schema.supply = supplies.map((supply) => ({
|
||||
'@type': 'HowToSupply',
|
||||
name: supply,
|
||||
}));
|
||||
}
|
||||
|
||||
if (image) {
|
||||
schema.image = image.startsWith('http') ? image : `${baseUrl}${image}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ProfilePage Schema for About Page
|
||||
interface ProfilePageJsonLdProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export function ProfilePageJsonLd({ locale }: ProfilePageJsonLdProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfilePage',
|
||||
dateCreated: '2024-01-01',
|
||||
dateModified: new Date().toISOString().split('T')[0],
|
||||
mainEntity: {
|
||||
'@id': `${baseUrl}/#person`,
|
||||
},
|
||||
name: locale === 'de'
|
||||
? 'Über Damjan Savić - KI-Entwickler & Automatisierungsspezialist'
|
||||
: locale === 'sr'
|
||||
? 'O Damjanu Saviću - AI Developer & Specijalista za automatizaciju'
|
||||
: 'About Damjan Savić - AI Developer & Automation Specialist',
|
||||
description: locale === 'de'
|
||||
? 'Erfahren Sie mehr über Damjan Savić, Freelance KI-Entwickler und Automatisierungsspezialist aus Köln. Expertise in KI-Agenten, Voice AI, n8n und SaaS-Entwicklung.'
|
||||
: locale === 'sr'
|
||||
? 'Saznajte više o Damjanu Saviću, freelance AI developeru i specijalisti za automatizaciju iz Kelna. Ekspertiza u AI agentima, Voice AI, n8n i SaaS razvoju.'
|
||||
: 'Learn more about Damjan Savić, freelance AI developer and automation specialist from Cologne. Expertise in AI agents, Voice AI, n8n and SaaS development.',
|
||||
url: `${baseUrl}/${locale}/about`,
|
||||
inLanguage: locale === 'de' ? 'de-DE' : locale === 'sr' ? 'sr-RS' : 'en-US',
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// VideoObject Schema for embedded videos
|
||||
interface VideoJsonLdProps {
|
||||
name: string;
|
||||
description: string;
|
||||
thumbnailUrl: string;
|
||||
uploadDate: string;
|
||||
duration?: string; // ISO 8601 format, e.g., "PT1M30S"
|
||||
contentUrl?: string;
|
||||
embedUrl?: string;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export function VideoJsonLd({
|
||||
name,
|
||||
description,
|
||||
thumbnailUrl,
|
||||
uploadDate,
|
||||
duration,
|
||||
contentUrl,
|
||||
embedUrl,
|
||||
locale,
|
||||
}: VideoJsonLdProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'VideoObject',
|
||||
name: name,
|
||||
description: description,
|
||||
thumbnailUrl: thumbnailUrl.startsWith('http') ? thumbnailUrl : `${baseUrl}${thumbnailUrl}`,
|
||||
uploadDate: uploadDate,
|
||||
inLanguage: locale === 'de' ? 'de-DE' : locale === 'sr' ? 'sr-RS' : 'en-US',
|
||||
author: {
|
||||
'@id': `${baseUrl}/#person`,
|
||||
},
|
||||
};
|
||||
|
||||
if (duration) {
|
||||
schema.duration = duration;
|
||||
}
|
||||
|
||||
if (contentUrl) {
|
||||
schema.contentUrl = contentUrl;
|
||||
}
|
||||
|
||||
if (embedUrl) {
|
||||
schema.embedUrl = embedUrl;
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// SoftwareApplication Schema for tools/projects
|
||||
interface SoftwareAppJsonLdProps {
|
||||
name: string;
|
||||
description: string;
|
||||
applicationCategory: string;
|
||||
operatingSystem?: string;
|
||||
offers?: {
|
||||
price: string;
|
||||
priceCurrency: string;
|
||||
};
|
||||
aggregateRating?: {
|
||||
ratingValue: string;
|
||||
ratingCount: string;
|
||||
};
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export function SoftwareAppJsonLd({
|
||||
name,
|
||||
description,
|
||||
applicationCategory,
|
||||
operatingSystem = 'Web',
|
||||
offers,
|
||||
aggregateRating,
|
||||
locale,
|
||||
}: SoftwareAppJsonLdProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: name,
|
||||
description: description,
|
||||
applicationCategory: applicationCategory,
|
||||
operatingSystem: operatingSystem,
|
||||
author: {
|
||||
'@id': `${baseUrl}/#person`,
|
||||
},
|
||||
inLanguage: locale === 'de' ? 'de-DE' : locale === 'sr' ? 'sr-RS' : 'en-US',
|
||||
};
|
||||
|
||||
if (offers) {
|
||||
schema.offers = {
|
||||
'@type': 'Offer',
|
||||
price: offers.price,
|
||||
priceCurrency: offers.priceCurrency,
|
||||
};
|
||||
}
|
||||
|
||||
if (aggregateRating) {
|
||||
schema.aggregateRating = {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: aggregateRating.ratingValue,
|
||||
ratingCount: aggregateRating.ratingCount,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// WebSite Schema for Search Box
|
||||
export function WebSiteJsonLd({ locale }: JsonLdProps) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com';
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
'@id': `${baseUrl}/#website`,
|
||||
name: 'Damjan Savić',
|
||||
alternateName: locale === 'de'
|
||||
? 'Damjan Savić - KI Entwickler Köln'
|
||||
: locale === 'sr'
|
||||
? 'Damjan Savić - AI Developer Keln'
|
||||
: 'Damjan Savić - AI Developer Cologne',
|
||||
url: baseUrl,
|
||||
description: locale === 'de'
|
||||
? 'Portfolio und Blog von Damjan Savić - KI-Entwickler und Automatisierungsspezialist aus Köln'
|
||||
: locale === 'sr'
|
||||
? 'Portfolio i blog Damjana Savića - AI developer i specijalista za automatizaciju iz Kelna'
|
||||
: 'Portfolio and blog of Damjan Savić - AI Developer and Automation Specialist from Cologne',
|
||||
publisher: {
|
||||
'@id': `${baseUrl}/#organization`,
|
||||
},
|
||||
inLanguage: [
|
||||
{ '@type': 'Language', name: 'German', alternateName: 'de' },
|
||||
{ '@type': 'Language', name: 'English', alternateName: 'en' },
|
||||
{ '@type': 'Language', name: 'Serbian', alternateName: 'sr' },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
PersonJsonLd,
|
||||
ProfessionalServiceJsonLd,
|
||||
BreadcrumbJsonLd,
|
||||
WebPageJsonLd,
|
||||
FAQJsonLd,
|
||||
ServiceJsonLd,
|
||||
OrganizationJsonLd,
|
||||
ArticleJsonLd,
|
||||
WebSiteJsonLd,
|
||||
HowToJsonLd,
|
||||
ProfilePageJsonLd,
|
||||
VideoJsonLd,
|
||||
SoftwareAppJsonLd,
|
||||
} from './JsonLd';
|
||||
Reference in New Issue
Block a user