Migrate from Vite to Next.js 15 with SSR
- Replace Vite + React Router with Next.js 15 App Router - Implement i18n with next-intl (URL-based: /de, /en, /sr) - Add SSR/SSG for all pages (48 static pages generated) - Setup Supabase SSR client for auth - Migrate all pages: Home, About, Portfolio, Blog, Contact, Login, Dashboard, Imprint, Privacy, Terms - Add Docker support with standalone output - Replace i18next with next-intl JSON translations - Use next/image for optimized images Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'pages.about.seo' });
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AboutPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('pages.about');
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8">
|
||||
<Link href={`/${locale}`} className="text-zinc-400 hover:text-white transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<span className="text-zinc-600 mx-2">/</span>
|
||||
<span className="text-white">About</span>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="mb-16">
|
||||
<p className="text-zinc-400 text-sm tracking-wider mb-2">
|
||||
{t('hero.aboutMe')}
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-6">
|
||||
{t('hero.title')}
|
||||
</h1>
|
||||
<p className="text-zinc-300 text-lg leading-relaxed">
|
||||
{t('hero.description')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Skills Section */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-2xl font-bold mb-8">Core Expertise</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-zinc-800/50 rounded-lg p-6 border border-zinc-700/50">
|
||||
<h3 className="text-lg font-semibold mb-2">AI & Automation</h3>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
GPT-4, Claude API, Voice AI, n8n, Process Automation
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-zinc-800/50 rounded-lg p-6 border border-zinc-700/50">
|
||||
<h3 className="text-lg font-semibold mb-2">Fullstack Development</h3>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
Python, TypeScript, React, Next.js, Node.js
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-zinc-800/50 rounded-lg p-6 border border-zinc-700/50">
|
||||
<h3 className="text-lg font-semibold mb-2">System Integration</h3>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
ERP Systems, APIs, Database Design, DevOps
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact CTA */}
|
||||
<section className="text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Interested in working together?</h2>
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
className="inline-block px-8 py-4 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Get in Touch
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { locales } from '@/i18n/config';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
};
|
||||
|
||||
// Blog post content
|
||||
const blogPosts: Record<string, Record<string, {
|
||||
title: string;
|
||||
date: string;
|
||||
excerpt: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
coverImage: string;
|
||||
content: string;
|
||||
}>> = {
|
||||
de: {
|
||||
'erp-integration-breuninger': {
|
||||
title: 'ApparelMagic und TradeByte: Analyse komplexer Integrationen',
|
||||
date: '2024-02-09',
|
||||
excerpt: 'Detaillierte Analyse einer E-Commerce-Integration zwischen Apparel Magic und Breuninger via TradeByte',
|
||||
category: 'System Integration',
|
||||
tags: ['ERP', 'Apparel Magic', 'TradeByte', 'API Integration', 'Python', 'MariaDB'],
|
||||
coverImage: '/images/posts/erp-integration-breuninger/cover.jpg',
|
||||
content: `
|
||||
## Apparel Magic meets Breuninger: Eine technische Analyse der TradeByte-Integration
|
||||
|
||||
Die Integration von E-Commerce-Systemen mit etablierten Marktplaetzen stellt Unternehmen vor besondere Herausforderungen. In diesem Artikel teile ich meine Erfahrungen aus einem komplexen Integrationsprojekt zwischen Apparel Magic als ERP-System und der Breuninger-Plattform ueber TradeByte.
|
||||
|
||||
### Projekthintergrund
|
||||
|
||||
Das Projekt umfasste:
|
||||
- **Apparel Magic** als ERP-System fuer Produktdaten und Bestandsmanagement
|
||||
- **TradeByte** als Middleware fuer die Breuninger-Plattform
|
||||
|
||||
### Herausforderungen
|
||||
|
||||
Die wichtigsten Herausforderungen waren:
|
||||
1. Bidirektionale Synchronisation von Bestellungen
|
||||
2. Automatisierte Kundenanlage
|
||||
3. Verwaltung von Lieferscheinen
|
||||
4. Bestandsmanagement in Echtzeit
|
||||
|
||||
### Technische Architektur
|
||||
|
||||
Die Loesung basiert auf einer Python-basierten Middleware mit MariaDB als zentralem Datenspeicher. Die API-Kommunikation erfolgt sowohl ueber REST als auch XML-basierte Schnittstellen.
|
||||
|
||||
### Fazit
|
||||
|
||||
Die entwickelte Loesung verarbeitet erfolgreich Bestellungen und ermoeglicht eine nahtlose Integration zwischen den Systemen.
|
||||
`,
|
||||
},
|
||||
},
|
||||
en: {
|
||||
'erp-integration-breuninger': {
|
||||
title: 'ApparelMagic and TradeByte: Complex Integration Analysis',
|
||||
date: '2024-02-09',
|
||||
excerpt: 'Detailed analysis of an e-commerce integration between Apparel Magic and Breuninger via TradeByte',
|
||||
category: 'System Integration',
|
||||
tags: ['ERP', 'Apparel Magic', 'TradeByte', 'API Integration', 'Python', 'MariaDB'],
|
||||
coverImage: '/images/posts/erp-integration-breuninger/cover.jpg',
|
||||
content: `
|
||||
## Apparel Magic meets Breuninger: A Technical Analysis of TradeByte Integration
|
||||
|
||||
Integrating e-commerce systems with established marketplaces poses unique challenges for businesses. In this article, I share my experiences from a complex integration project between Apparel Magic as an ERP system and the Breuninger platform via TradeByte.
|
||||
|
||||
### Project Background
|
||||
|
||||
The project included:
|
||||
- **Apparel Magic** as ERP system for product data and inventory management
|
||||
- **TradeByte** as middleware for the Breuninger platform
|
||||
|
||||
### Challenges
|
||||
|
||||
The main challenges were:
|
||||
1. Bidirectional synchronization of orders
|
||||
2. Automated customer creation
|
||||
3. Management of delivery notes
|
||||
4. Real-time inventory management
|
||||
|
||||
### Technical Architecture
|
||||
|
||||
The solution is based on Python middleware with MariaDB as central data store. API communication uses both REST and XML-based interfaces.
|
||||
|
||||
### Conclusion
|
||||
|
||||
The developed solution successfully processes orders and enables seamless integration between systems.
|
||||
`,
|
||||
},
|
||||
},
|
||||
sr: {
|
||||
'erp-integration-breuninger': {
|
||||
title: 'ApparelMagic i TradeByte: Analiza kompleksnih integracija',
|
||||
date: '2024-02-09',
|
||||
excerpt: 'Detaljna analiza e-commerce integracije izmedu Apparel Magic i Breuninger putem TradeByte',
|
||||
category: 'Sistemska Integracija',
|
||||
tags: ['ERP', 'Apparel Magic', 'TradeByte', 'API Integracija', 'Python', 'MariaDB'],
|
||||
coverImage: '/images/posts/erp-integration-breuninger/cover.jpg',
|
||||
content: `
|
||||
## Apparel Magic i Breuninger: Tehnicka analiza TradeByte integracije
|
||||
|
||||
Integracija e-commerce sistema sa uspostavljenim trzistima predstavlja posebne izazove za preduzeca. U ovom clanku delim svoja iskustva iz kompleksnog projekta integracije izmedu Apparel Magic kao ERP sistema i Breuninger platforme putem TradeByte.
|
||||
|
||||
### Pozadina projekta
|
||||
|
||||
Projekat je obuhvatao:
|
||||
- **Apparel Magic** kao ERP sistem za podatke o proizvodima i upravljanje zalihama
|
||||
- **TradeByte** kao middleware za Breuninger platformu
|
||||
|
||||
### Izazovi
|
||||
|
||||
Glavni izazovi bili su:
|
||||
1. Bidirekciona sinhronizacija narudzbi
|
||||
2. Automatizovano kreiranje kupaca
|
||||
3. Upravljanje otpremnicama
|
||||
4. Upravljanje zalihama u realnom vremenu
|
||||
|
||||
### Tehnicka arhitektura
|
||||
|
||||
Resenje se zasniva na Python middleware-u sa MariaDB kao centralnim skladistem podataka.
|
||||
|
||||
### Zakljucak
|
||||
|
||||
Razvieno resenje uspesno obraduje narudzbe i omogucava nesmetanu integraciju izmedu sistema.
|
||||
`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = Object.keys(blogPosts.de);
|
||||
|
||||
return locales.flatMap((locale) =>
|
||||
slugs.map((slug) => ({
|
||||
locale,
|
||||
slug,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const post = blogPosts[locale]?.[slug] || blogPosts.de[slug];
|
||||
|
||||
if (!post) {
|
||||
return {
|
||||
title: 'Post Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
type: 'article',
|
||||
publishedTime: post.date,
|
||||
tags: post.tags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: Props) {
|
||||
const { locale, slug } = await params;
|
||||
|
||||
setRequestLocale(locale);
|
||||
|
||||
const post = blogPosts[locale]?.[slug] || blogPosts.de[slug];
|
||||
const t = await getTranslations('blog');
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const getFormattedDate = (date: string) => {
|
||||
const languageMap: Record<string, string> = {
|
||||
de: 'de-DE',
|
||||
en: 'en-US',
|
||||
sr: 'sr-RS',
|
||||
};
|
||||
|
||||
return new Date(date).toLocaleDateString(languageMap[locale] || 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-16 sm:py-20 lg:py-24">
|
||||
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="inline-flex items-center gap-2 text-zinc-400 hover:text-white transition-colors mb-8"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t('ui.backToBlog')}
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-12">
|
||||
<div className="flex items-center gap-4 mb-6 text-zinc-500">
|
||||
<time dateTime={post.date}>{getFormattedDate(post.date)}</time>
|
||||
<span className="px-3 py-1 bg-zinc-800 rounded-full text-sm">
|
||||
{post.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-zinc-400">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2 mt-6">
|
||||
{post.tags.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"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Cover Image */}
|
||||
<div className="aspect-[16/9] w-full mb-12 rounded-xl overflow-hidden bg-zinc-800 relative">
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 896px"
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-invert prose-zinc max-w-none">
|
||||
{post.content.split('\n').map((paragraph, index) => {
|
||||
if (paragraph.startsWith('## ')) {
|
||||
return <h2 key={index} className="text-2xl font-bold text-white mt-8 mb-4">{paragraph.replace('## ', '')}</h2>;
|
||||
}
|
||||
if (paragraph.startsWith('### ')) {
|
||||
return <h3 key={index} className="text-xl font-semibold text-white mt-6 mb-3">{paragraph.replace('### ', '')}</h3>;
|
||||
}
|
||||
if (paragraph.startsWith('- **')) {
|
||||
return <li key={index} className="text-zinc-300 ml-4">{paragraph.replace('- ', '')}</li>;
|
||||
}
|
||||
if (paragraph.startsWith('1. ') || paragraph.startsWith('2. ') || paragraph.startsWith('3. ') || paragraph.startsWith('4. ')) {
|
||||
return <li key={index} className="text-zinc-300 ml-4 list-decimal">{paragraph.replace(/^\d+\. /, '')}</li>;
|
||||
}
|
||||
if (paragraph.trim()) {
|
||||
return <p key={index} className="text-zinc-300 mb-4">{paragraph}</p>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, Loader2, Calendar, Tag, ExternalLink } from 'lucide-react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
excerpt: string;
|
||||
coverImage: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// Blog posts data - in a real app, this would come from a CMS or MDX files
|
||||
const blogPostsData: Record<string, BlogPost[]> = {
|
||||
de: [
|
||||
{
|
||||
slug: 'erp-integration-breuninger',
|
||||
title: 'ApparelMagic und TradeByte: Analyse komplexer Integrationen',
|
||||
date: '2024-02-09',
|
||||
excerpt: 'Detaillierte Analyse einer E-Commerce-Integration zwischen Apparel Magic und Breuninger via TradeByte',
|
||||
category: 'System Integration',
|
||||
tags: ['ERP', 'Apparel Magic', 'TradeByte', 'API Integration', 'Python', 'MariaDB'],
|
||||
coverImage: '/images/posts/erp-integration-breuninger/cover.jpg',
|
||||
},
|
||||
],
|
||||
en: [
|
||||
{
|
||||
slug: 'erp-integration-breuninger',
|
||||
title: 'ApparelMagic and TradeByte: Complex Integration Analysis',
|
||||
date: '2024-02-09',
|
||||
excerpt: 'Detailed analysis of an e-commerce integration between Apparel Magic and Breuninger via TradeByte',
|
||||
category: 'System Integration',
|
||||
tags: ['ERP', 'Apparel Magic', 'TradeByte', 'API Integration', 'Python', 'MariaDB'],
|
||||
coverImage: '/images/posts/erp-integration-breuninger/cover.jpg',
|
||||
},
|
||||
],
|
||||
sr: [
|
||||
{
|
||||
slug: 'erp-integration-breuninger',
|
||||
title: 'ApparelMagic i TradeByte: Analiza kompleksnih integracija',
|
||||
date: '2024-02-09',
|
||||
excerpt: 'Detaljna analiza e-commerce integracije izmedu Apparel Magic i Breuninger putem TradeByte',
|
||||
category: 'Sistemska Integracija',
|
||||
tags: ['ERP', 'Apparel Magic', 'TradeByte', 'API Integracija', 'Python', 'MariaDB'],
|
||||
coverImage: '/images/posts/erp-integration-breuninger/cover.jpg',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function BlogPostCard({ post, locale }: { post: BlogPost; locale: string }) {
|
||||
|
||||
const getFormattedDate = (date: string) => {
|
||||
const languageMap: Record<string, string> = {
|
||||
de: 'de-DE',
|
||||
en: 'en-US',
|
||||
sr: 'sr-RS',
|
||||
};
|
||||
|
||||
return new Date(date).toLocaleDateString(languageMap[locale] || 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getImagePath = (coverImage: string) => {
|
||||
if (coverImage.startsWith('http')) {
|
||||
return coverImage;
|
||||
}
|
||||
return coverImage.replace('/blog/', '/images/posts/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${locale}/blog/${post.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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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={getImagePath(post.coverImage)}
|
||||
alt={post.title}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-x-0 -bottom-20 top-0 bg-gradient-to-t from-zinc-900 via-zinc-900/70 to-transparent opacity-95" />
|
||||
</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">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{getFormattedDate(post.date)}</span>
|
||||
</div>
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Tag className="h-4 w-4" />
|
||||
<span>{post.tags.length} Tags</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-white mb-3
|
||||
group-hover:text-zinc-100 transition-colors duration-300">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="text-zinc-400 text-sm line-clamp-2 mb-4
|
||||
group-hover:text-zinc-300 transition-colors duration-300">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags?.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>
|
||||
))}
|
||||
{post.tags && post.tags.length > 3 && (
|
||||
<span className="text-sm text-zinc-600">
|
||||
+{post.tags.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>
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlogPage() {
|
||||
const t = useTranslations('blog');
|
||||
const locale = useLocale();
|
||||
const [posts, setPosts] = useState<BlogPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate loading
|
||||
const loadPosts = async () => {
|
||||
setLoading(true);
|
||||
// Get posts for current locale
|
||||
const currentPosts = blogPostsData[locale] || blogPostsData.de;
|
||||
setPosts(currentPosts);
|
||||
setLoading(false);
|
||||
};
|
||||
loadPosts();
|
||||
}, [locale]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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('ui.loading.posts')}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
|
||||
{/* Header Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-12 sm:mb-16 lg:mb-20"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="flex items-center gap-4 mb-8"
|
||||
>
|
||||
<ArrowRight className="h-5 w-5 text-zinc-500" />
|
||||
<h2 className="text-white text-sm tracking-wider">BLOG</h2>
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-6"
|
||||
>
|
||||
{t('meta.header.title')}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="text-lg sm:text-xl text-zinc-400 max-w-2xl"
|
||||
>
|
||||
{t('meta.header.subtitle')}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Blog Posts Grid */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8"
|
||||
>
|
||||
{posts.map((post, index) => (
|
||||
<motion.div
|
||||
key={post.slug}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 * (index + 4) }}
|
||||
>
|
||||
<BlogPostCard post={post} locale={locale} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Empty State */}
|
||||
{posts.length === 0 && !loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="text-center py-12"
|
||||
>
|
||||
<p className="text-zinc-400">{t('ui.errors.posts')}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { Mail, Phone, MapPin } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
export default function ContactPage() {
|
||||
const t = useTranslations('pages.contact');
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
message: '',
|
||||
});
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStatus('loading');
|
||||
|
||||
// Simulate form submission
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setStatus('success');
|
||||
setFormData({ name: '', email: '', message: '' });
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8">
|
||||
<Link href={`/${locale}`} className="text-zinc-400 hover:text-white transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<span className="text-zinc-600 mx-2">/</span>
|
||||
<span className="text-white">{t('breadcrumb')}</span>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="mb-16 text-center">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4">{t('hero.title')}</h1>
|
||||
<p className="text-zinc-400 text-lg max-w-2xl mx-auto">
|
||||
{t('hero.subtitle')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Contact Info */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-6">{t('contactInfo.title')}</h2>
|
||||
<p className="text-zinc-400 mb-8">{t('contactInfo.description')}</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-zinc-800 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<MapPin size={20} className="text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm">{t('contactInfo.address.label')}</p>
|
||||
<p className="text-white">{t('contactInfo.address.value')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-zinc-800 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Phone size={20} className="text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm">{t('contactInfo.phone.label')}</p>
|
||||
<a href="tel:+491756950979" className="text-white hover:text-blue-400 transition-colors">
|
||||
{t('contactInfo.phone.value')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-zinc-800 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Mail size={20} className="text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm">{t('contactInfo.email.label')}</p>
|
||||
<a href="mailto:info@damjan-savic.com" className="text-white hover:text-blue-400 transition-colors">
|
||||
{t('contactInfo.email.value')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Form */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold mb-6">{t('contactForm.title')}</h2>
|
||||
<p className="text-zinc-400 mb-8">{t('contactForm.description')}</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-zinc-300 mb-2">
|
||||
{t('contactForm.name.label')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t('contactForm.name.placeholder')}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-zinc-300 mb-2">
|
||||
{t('contactForm.email.label')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder={t('contactForm.email.placeholder')}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-zinc-300 mb-2">
|
||||
{t('contactForm.message.label')}
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
placeholder={t('contactForm.message.placeholder')}
|
||||
required
|
||||
rows={5}
|
||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'loading'}
|
||||
className="w-full px-6 py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{status === 'loading' ? 'Sending...' : t('contactForm.submit')}
|
||||
</button>
|
||||
|
||||
{status === 'success' && (
|
||||
<p className="text-green-400 text-center">{t('contactForm.successMessage')}</p>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<p className="text-red-400 text-center">{t('contactForm.errorMessage')}</p>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 DashboardPage() {
|
||||
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,276 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
de: 'Impressum',
|
||||
en: 'Imprint',
|
||||
sr: 'Impresum',
|
||||
};
|
||||
|
||||
const descriptions: Record<string, string> = {
|
||||
de: 'Impressum fuer die Portfolio-Website von Damjan Savic',
|
||||
en: 'Legal notice for Damjan Savic portfolio website',
|
||||
sr: 'Pravno obavestenje za portfolio veb sajt Damjana Savica',
|
||||
};
|
||||
|
||||
return {
|
||||
title: titles[locale] || titles.de,
|
||||
description: descriptions[locale] || descriptions.de,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ImprintPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const content: Record<string, {
|
||||
title: string;
|
||||
contact: { title: string; content: string[] };
|
||||
liability: { title: string; sections: { title: string; content: string }[] };
|
||||
copyright: { title: string; content: string };
|
||||
images: { title: string; content: string[] };
|
||||
privacy: { title: string; content: string; link: string };
|
||||
technical: { title: string; design: string; tech: string };
|
||||
copyrightNotice: string;
|
||||
}> = {
|
||||
de: {
|
||||
title: 'Impressum',
|
||||
contact: {
|
||||
title: 'Kontakt',
|
||||
content: [
|
||||
'Damjan Savic',
|
||||
'Fullstack Developer & Digital Solutions Consultant',
|
||||
'E-Mail: info@damjan-savic.com',
|
||||
'Website: https://damjan-savic.com',
|
||||
],
|
||||
},
|
||||
liability: {
|
||||
title: 'Haftungsausschluss',
|
||||
sections: [
|
||||
{
|
||||
title: 'Haftung fuer Inhalte',
|
||||
content: 'Die Inhalte meiner Seiten wurden mit groesster Sorgfalt erstellt. Fuer die Richtigkeit, Vollstaendigkeit und Aktualitaet der Inhalte kann ich jedoch keine Gewaehr uebernehmen.',
|
||||
},
|
||||
{
|
||||
title: 'Haftung fuer Links',
|
||||
content: 'Meine Website enthaelt Links zu externen Websites Dritter, auf deren Inhalte ich keinen Einfluss habe. Deshalb kann ich fuer diese fremden Inhalte auch keine Gewaehr uebernehmen. Fuer die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.',
|
||||
},
|
||||
],
|
||||
},
|
||||
copyright: {
|
||||
title: 'Urheberrecht',
|
||||
content: 'Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfaeltigung, Bearbeitung, Verbreitung und jede Art der Verwertung ausserhalb der Grenzen des Urheberrechtes beduerfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.',
|
||||
},
|
||||
images: {
|
||||
title: 'Bildnachweise',
|
||||
content: [
|
||||
'Eigene Aufnahmen und Erstellungen',
|
||||
'Icons: Heroicons, Tabler Icons (MIT Lizenz)',
|
||||
'Weitere Bildquellen sind direkt bei den jeweiligen Bildern angegeben',
|
||||
],
|
||||
},
|
||||
privacy: {
|
||||
title: 'Datenschutz',
|
||||
content: 'Die Nutzung unserer Webseite ist in der Regel ohne Angabe personenbezogener Daten moeglich. Weitere Informationen zum Datenschutz finden Sie in unserer',
|
||||
link: 'Datenschutzerklaerung',
|
||||
},
|
||||
technical: {
|
||||
title: 'Technische Umsetzung',
|
||||
design: 'Design & Entwicklung: Damjan Savic',
|
||||
tech: 'Technologien: Next.js, TypeScript, Tailwind CSS',
|
||||
},
|
||||
copyrightNotice: 'Alle Rechte vorbehalten.',
|
||||
},
|
||||
en: {
|
||||
title: 'Legal Notice',
|
||||
contact: {
|
||||
title: 'Contact',
|
||||
content: [
|
||||
'Damjan Savic',
|
||||
'Fullstack Developer & Digital Solutions Consultant',
|
||||
'Email: info@damjan-savic.com',
|
||||
'Website: https://damjan-savic.com',
|
||||
],
|
||||
},
|
||||
liability: {
|
||||
title: 'Disclaimer',
|
||||
sections: [
|
||||
{
|
||||
title: 'Liability for Content',
|
||||
content: 'The contents of my pages were created with the utmost care. However, I cannot guarantee the accuracy, completeness and timeliness of the content.',
|
||||
},
|
||||
{
|
||||
title: 'Liability for Links',
|
||||
content: 'My website contains links to external websites of third parties, over whose content I have no influence. Therefore, I cannot assume any liability for this external content. The respective provider or operator of the pages is always responsible for the content of the linked pages.',
|
||||
},
|
||||
],
|
||||
},
|
||||
copyright: {
|
||||
title: 'Copyright',
|
||||
content: 'The content and works created by the site operator on these pages are subject to German copyright law. Reproduction, editing, distribution and any kind of exploitation outside the limits of copyright require the written consent of the respective author or creator.',
|
||||
},
|
||||
images: {
|
||||
title: 'Image Credits',
|
||||
content: [
|
||||
'Own photographs and creations',
|
||||
'Icons: Heroicons, Tabler Icons (MIT License)',
|
||||
'Other image sources are indicated directly at the respective images',
|
||||
],
|
||||
},
|
||||
privacy: {
|
||||
title: 'Privacy',
|
||||
content: 'The use of our website is generally possible without providing personal data. For more information on data protection, please see our',
|
||||
link: 'Privacy Policy',
|
||||
},
|
||||
technical: {
|
||||
title: 'Technical Implementation',
|
||||
design: 'Design & Development: Damjan Savic',
|
||||
tech: 'Technologies: Next.js, TypeScript, Tailwind CSS',
|
||||
},
|
||||
copyrightNotice: 'All rights reserved.',
|
||||
},
|
||||
sr: {
|
||||
title: 'Pravno obavestenje',
|
||||
contact: {
|
||||
title: 'Kontakt',
|
||||
content: [
|
||||
'Damjan Savic',
|
||||
'Fullstack Developer & Digital Solutions Consultant',
|
||||
'Email: info@damjan-savic.com',
|
||||
'Website: https://damjan-savic.com',
|
||||
],
|
||||
},
|
||||
liability: {
|
||||
title: 'Odricanje od odgovornosti',
|
||||
sections: [
|
||||
{
|
||||
title: 'Odgovornost za sadrzaj',
|
||||
content: 'Sadrzaj mojih stranica je kreiran sa najvecom paznjom. Medjutim, ne mogu garantovati tacnost, potpunost i aktuelnost sadrzaja.',
|
||||
},
|
||||
{
|
||||
title: 'Odgovornost za linkove',
|
||||
content: 'Moja web stranica sadrzi linkove ka eksternim web stranicama trecih lica, na ciji sadrzaj nemam uticaja. Stoga ne mogu preuzeti nikakvu odgovornost za ovaj eksterni sadrzaj.',
|
||||
},
|
||||
],
|
||||
},
|
||||
copyright: {
|
||||
title: 'Autorska prava',
|
||||
content: 'Sadrzaj i dela koja je kreirao vlasnik sajta na ovim stranicama podlezu nemackom zakonu o autorskim pravima.',
|
||||
},
|
||||
images: {
|
||||
title: 'Krediti za slike',
|
||||
content: [
|
||||
'Sopstvene fotografije i kreacije',
|
||||
'Ikone: Heroicons, Tabler Icons (MIT Licenca)',
|
||||
'Drugi izvori slika su naznaceni direktno kod odgovarajucih slika',
|
||||
],
|
||||
},
|
||||
privacy: {
|
||||
title: 'Privatnost',
|
||||
content: 'Koriscenje nase web stranice je generalno moguce bez navodjenja licnih podataka. Za vise informacija o zastiti podataka, pogledajte nasu',
|
||||
link: 'Politiku privatnosti',
|
||||
},
|
||||
technical: {
|
||||
title: 'Tehnicka implementacija',
|
||||
design: 'Dizajn i razvoj: Damjan Savic',
|
||||
tech: 'Tehnologije: Next.js, TypeScript, Tailwind CSS',
|
||||
},
|
||||
copyrightNotice: 'Sva prava zadrzana.',
|
||||
},
|
||||
};
|
||||
|
||||
const c = content[locale] || content.de;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-16 sm:py-20 lg:py-24">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<span className="mx-2 text-zinc-600">/</span>
|
||||
<span className="text-white">{c.title}</span>
|
||||
</nav>
|
||||
|
||||
<h1 className="text-4xl font-bold text-white mb-8">{c.title}</h1>
|
||||
|
||||
<div className="prose prose-invert max-w-none space-y-8">
|
||||
{/* Contact */}
|
||||
<section className="bg-zinc-900/50 p-6 rounded-xl ring-1 ring-zinc-800">
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">{c.contact.title}</h2>
|
||||
<div className="text-zinc-300 space-y-1">
|
||||
{c.contact.content.map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Liability */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">{c.liability.title}</h2>
|
||||
{c.liability.sections.map((section, index) => (
|
||||
<div key={index} className="mb-4">
|
||||
<h3 className="text-xl font-medium text-white mb-2">{section.title}</h3>
|
||||
<p className="text-zinc-300">{section.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Copyright */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">{c.copyright.title}</h2>
|
||||
<p className="text-zinc-300">{c.copyright.content}</p>
|
||||
</section>
|
||||
|
||||
{/* Images */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">{c.images.title}</h2>
|
||||
<ul className="list-disc list-inside text-zinc-300 space-y-1">
|
||||
{c.images.content.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Privacy */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">{c.privacy.title}</h2>
|
||||
<p className="text-zinc-300">
|
||||
{c.privacy.content}{' '}
|
||||
<Link href={`/${locale}/privacy`} className="text-orange-500 hover:text-orange-400 underline">
|
||||
{c.privacy.link}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Technical */}
|
||||
<section className="bg-zinc-900/50 p-6 rounded-xl ring-1 ring-zinc-800">
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">{c.technical.title}</h2>
|
||||
<p className="text-zinc-300">{c.technical.design}</p>
|
||||
<p className="text-zinc-300">{c.technical.tech}</p>
|
||||
</section>
|
||||
|
||||
{/* Copyright Notice */}
|
||||
<section>
|
||||
<p className="text-zinc-400">
|
||||
© {new Date().getFullYear()} Damjan Savic. {c.copyrightNotice}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages, setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { locales, type Locale } from '@/i18n/config';
|
||||
import type { Metadata } from 'next';
|
||||
import '../globals.css';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter',
|
||||
});
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
|
||||
const titles: Record<Locale, string> = {
|
||||
de: 'Damjan Savic | AI & Automation Specialist aus Koeln',
|
||||
en: 'Damjan Savic | AI & Automation Specialist from Cologne',
|
||||
sr: 'Damjan Savic | AI & Automation Specialist iz Kelna',
|
||||
};
|
||||
|
||||
const descriptions: Record<Locale, string> = {
|
||||
de: 'Entwicklung von KI-Agenten und Automatisierungsloesungen. Von Voice-AI-Plattformen bis zu autonomen Web-Agenten.',
|
||||
en: 'Building AI agents and automation solutions. From voice AI platforms to autonomous web agents.',
|
||||
sr: 'Razvoj AI agenata i resenja za automatizaciju. Od voice AI platformi do autonomnih web agenata.',
|
||||
};
|
||||
|
||||
return {
|
||||
title: {
|
||||
template: '%s | Damjan Savic',
|
||||
default: titles[locale as Locale] || titles.de,
|
||||
},
|
||||
description: descriptions[locale as Locale] || descriptions.de,
|
||||
alternates: {
|
||||
canonical: `/${locale}`,
|
||||
languages: {
|
||||
de: '/de',
|
||||
en: '/en',
|
||||
sr: '/sr',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: locale === 'de' ? 'de_DE' : locale === 'sr' ? 'sr_RS' : 'en_US',
|
||||
siteName: 'Damjan Savic',
|
||||
images: [
|
||||
{
|
||||
url: '/images/og-image.jpg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Damjan Savic - AI & Automation Specialist',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
creator: '@damjansavic',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: Props) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!locales.includes(locale as Locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale} className={inter.variable}>
|
||||
<body className="bg-zinc-900 text-zinc-50 antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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 LoginPage() {
|
||||
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">
|
||||
<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,270 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Linkedin, Github, Mail } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export default async function HomePage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<HeroSection />
|
||||
<ExperienceSection />
|
||||
<SkillsSection />
|
||||
<AboutSection />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroSection() {
|
||||
const t = useTranslations('pages.home.hero');
|
||||
|
||||
return (
|
||||
<section id="home" className="relative min-h-screen text-white overflow-hidden">
|
||||
{/* Concentric Circles Background */}
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center z-[2]">
|
||||
{[1, 2, 3].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute rounded-full border border-zinc-800/30"
|
||||
style={{
|
||||
width: `${index * 30}%`,
|
||||
height: `${index * 30}%`,
|
||||
opacity: 0.1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Social Links Bar */}
|
||||
<div className="absolute top-20 left-0 right-0 px-4 sm:px-8 z-40">
|
||||
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
||||
<div className="flex gap-4">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/damjan-savi%C4%87-720288127/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<Linkedin className="transform transition-transform group-hover:scale-110" size={20} />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/damjan1996"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github className="transform transition-transform group-hover:scale-110" size={20} />
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="mailto:info@damjan-savic.com"
|
||||
className="text-sm text-zinc-400 hover:text-white transition-colors uppercase tracking-wider"
|
||||
>
|
||||
{t('cta')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col items-center justify-start min-h-screen px-4 pt-32 relative z-30">
|
||||
{/* Profile Image */}
|
||||
<div className="w-56 h-56 sm:w-72 sm:h-72 mb-8 rounded-full overflow-hidden border-2 border-zinc-800">
|
||||
<Image
|
||||
src="/portraits/portrait-288.webp"
|
||||
alt={t('name')}
|
||||
width={288}
|
||||
height={288}
|
||||
className="w-full h-full object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title and Name */}
|
||||
<p className="text-zinc-400 text-sm sm:text-base tracking-wider mb-2">
|
||||
{t('title')}
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-3 flex items-center">
|
||||
{t('name')}<span className="animate-pulse ml-1">|</span>
|
||||
</h1>
|
||||
<p className="text-zinc-300 text-sm sm:text-base max-w-xl text-center mb-2 px-4">
|
||||
{t('description')}
|
||||
</p>
|
||||
<p className="text-zinc-400 text-xs sm:text-sm mb-8">
|
||||
{t('currentRole')}
|
||||
</p>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-8 text-white text-base sm:text-lg z-20">
|
||||
{['experience', 'skills', 'projects', 'about'].map((section) => (
|
||||
<Link
|
||||
key={section}
|
||||
href={`#${section}`}
|
||||
className="relative w-full sm:w-auto px-8 py-3 rounded-full uppercase text-center hover:bg-zinc-700/50 transition-colors"
|
||||
>
|
||||
{t(`navigation.${section}`)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Button Bottom */}
|
||||
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-40">
|
||||
<a
|
||||
href="mailto:info@damjan-savic.com"
|
||||
className="w-12 h-12 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
||||
aria-label="Contact"
|
||||
>
|
||||
<Mail size={20} className="text-white" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ExperienceSection() {
|
||||
const t = useTranslations('pages.home.experience');
|
||||
|
||||
const positions = [
|
||||
{
|
||||
role: 'Process Automation Specialist',
|
||||
company: 'Everlast Consulting GmbH',
|
||||
period: '12/2024 - PRESENT',
|
||||
highlights: [
|
||||
'Development of AI agents with n8n and Zapier',
|
||||
'Building web scraping solutions',
|
||||
'Migration from Power Automate to n8n',
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'Consultant Digital Solutions',
|
||||
company: 'Ritter Digital GmbH',
|
||||
period: '08/2023 - 11/2024',
|
||||
highlights: [
|
||||
'Backend services on dedicated servers',
|
||||
'AI integration via Power Automate',
|
||||
'RFID/IoT solutions with Zebra hardware',
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'ERP Integration Specialist',
|
||||
company: 'Joyce & Girls',
|
||||
period: '01/2023 - 08/2023',
|
||||
highlights: [
|
||||
'ApparelMagic & TradeByte Integration',
|
||||
'MariaDB Middleware Development',
|
||||
'Shopify Optimization',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="experience" className="py-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold mb-12 text-center">{t('title')}</h2>
|
||||
<div className="space-y-8">
|
||||
{positions.map((position, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-zinc-800/50 rounded-lg p-6 border border-zinc-700/50"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-white">{position.role}</h3>
|
||||
<p className="text-zinc-400">{position.company}</p>
|
||||
</div>
|
||||
<span className="text-zinc-500 text-sm mt-2 sm:mt-0">{position.period}</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{position.highlights.map((highlight, i) => (
|
||||
<li key={i} className="text-zinc-300 flex items-start">
|
||||
<span className="text-blue-500 mr-2">-</span>
|
||||
{highlight}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsSection() {
|
||||
const t = useTranslations('pages.home.skills');
|
||||
|
||||
const skills = [
|
||||
{ name: 'AI & LLMs', level: 90, description: 'GPT-4, Claude API, Vapi Voice AI' },
|
||||
{ name: 'Automation', level: 85, description: 'n8n, Zapier, Power Automate' },
|
||||
{ name: 'Python', level: 90, description: 'Backend, APIs, Automation' },
|
||||
{ name: 'TypeScript', level: 85, description: 'React, Next.js, Node.js' },
|
||||
{ name: 'Database', level: 85, description: 'PostgreSQL, Supabase, MariaDB' },
|
||||
{ name: 'DevOps', level: 75, description: 'Docker, Vercel, Dedicated Servers' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="skills" className="py-20 px-4 bg-zinc-800/30">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold mb-12 text-center">{t('title')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{skills.map((skill, index) => (
|
||||
<div key={index} className="bg-zinc-900/50 rounded-lg p-6 border border-zinc-700/50">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="text-lg font-semibold text-white">{skill.name}</h3>
|
||||
<span className="text-zinc-400 text-sm">{skill.level}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-700 rounded-full h-2 mb-3">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${skill.level}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-zinc-400 text-sm">{skill.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutSection() {
|
||||
const t = useTranslations('pages.home.about');
|
||||
|
||||
return (
|
||||
<section id="about" className="py-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold mb-8 text-center">{t('title')}</h2>
|
||||
<div className="bg-zinc-800/50 rounded-lg p-8 border border-zinc-700/50">
|
||||
<p className="text-zinc-300 leading-relaxed whitespace-pre-line">
|
||||
{t('content')}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/about"
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-center"
|
||||
>
|
||||
{t('buttons.learnMore')}
|
||||
</Link>
|
||||
<a
|
||||
href="/damjan_savic_cv_en.pdf"
|
||||
download
|
||||
className="px-6 py-3 bg-zinc-700 hover:bg-zinc-600 text-white rounded-lg transition-colors text-center"
|
||||
>
|
||||
{t('buttons.downloadCV')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
};
|
||||
|
||||
const projects: Record<string, {
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
technologies: string[];
|
||||
content: string;
|
||||
}> = {
|
||||
'ai-data-reader': {
|
||||
title: 'AI Data Reader',
|
||||
description: 'KI-gestuetzte PDF-Datenextraktion mit Claude AI und JTL-Integration',
|
||||
category: 'Data Processing',
|
||||
technologies: ['Python', 'FastAPI', 'Claude AI', 'PyPDF', 'Pydantic', 'JTL-Wawi'],
|
||||
content: `Ein KI-gestuetztes System zur automatisierten Extraktion von Produktdaten aus PDF-Dokumenten mit direkter Integration in JTL-Warenwirtschaftssysteme.
|
||||
|
||||
## Die Herausforderung
|
||||
|
||||
Die manuelle Erfassung von Produktdaten aus PDF-Dokumenten war zeitaufwendig und fehleranfaellig:
|
||||
- Grosse Mengen an PDF-Dokumenten mit Produktinformationen
|
||||
- Zeitintensive manuelle Dateneingabe
|
||||
- Inkonsistente Datenqualitaet
|
||||
|
||||
## Die Loesung
|
||||
|
||||
Durch den Einsatz von Claude AI und modernen Python-Technologien wurde eine vollautomatische Loesung geschaffen:
|
||||
- Automatisierte PDF-Verarbeitung
|
||||
- KI-gestuetzte Datenextraktion
|
||||
- Intelligente Datenvalidierung
|
||||
- Nahtlose JTL-Integration
|
||||
|
||||
## Ergebnisse
|
||||
|
||||
- 90% Zeitersparnis bei der Produktdatenpflege
|
||||
- 99% Genauigkeit bei der Datenextraktion
|
||||
- Eliminierung manueller Dateneingaben`,
|
||||
},
|
||||
'smart-warehouse': {
|
||||
title: 'Smart Warehouse',
|
||||
description: 'RFID-basierte Lagerverwaltung mit IoT-Integration',
|
||||
category: 'Integration',
|
||||
technologies: ['Python', 'RFID', 'Zebra Hardware', 'PostgreSQL', 'FastAPI'],
|
||||
content: `Ein intelligentes Lagerverwaltungssystem mit RFID-Technologie und IoT-Integration fuer Echtzeit-Bestandsverfolgung.
|
||||
|
||||
## Features
|
||||
|
||||
- Echtzeit-Bestandsverfolgung mit RFID
|
||||
- Integration mit Zebra-Hardware
|
||||
- Automatische Bestandsaktualisierung
|
||||
- Dashboard fuer Lageranalyse`,
|
||||
},
|
||||
'website-mit-ki': {
|
||||
title: 'KI-Portfolio Website',
|
||||
description: 'Diese Portfolio-Website mit Next.js und modernen Web-Technologien',
|
||||
category: 'Web Development',
|
||||
technologies: ['Next.js', 'TypeScript', 'Tailwind CSS', 'Vercel', 'React'],
|
||||
content: `Eine moderne Portfolio-Website entwickelt mit Next.js 15 und Server-Side Rendering fuer optimale Performance und SEO.
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
- Next.js 15 mit App Router
|
||||
- TypeScript fuer Typsicherheit
|
||||
- Tailwind CSS fuer Styling
|
||||
- Server-Side Rendering fuer SEO
|
||||
- Internationalisierung (DE/EN/SR)`,
|
||||
},
|
||||
'power-platform-governance': {
|
||||
title: 'Power Platform Governance',
|
||||
description: 'Enterprise Governance-Loesung fuer Microsoft Power Platform',
|
||||
category: 'Enterprise Software',
|
||||
technologies: ['Power Automate', 'SharePoint', 'Azure', 'TypeScript', 'Power Apps'],
|
||||
content: `Eine umfassende Governance-Loesung fuer Microsoft Power Platform in Unternehmensumgebungen.
|
||||
|
||||
## Features
|
||||
|
||||
- Automatisierte Compliance-Pruefungen
|
||||
- Benutzer- und Ressourcenverwaltung
|
||||
- Audit-Trails und Reporting
|
||||
- Integration mit Azure AD`,
|
||||
},
|
||||
};
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = Object.keys(projects);
|
||||
const locales = ['de', 'en', 'sr'];
|
||||
|
||||
return locales.flatMap((locale) =>
|
||||
slugs.map((slug) => ({ locale, slug }))
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const project = projects[slug];
|
||||
|
||||
if (!project) {
|
||||
return { title: 'Project Not Found' };
|
||||
}
|
||||
|
||||
return {
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectPage({ params }: Props) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const project = projects[slug];
|
||||
|
||||
if (!project) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8">
|
||||
<Link href={`/${locale}`} className="text-zinc-400 hover:text-white transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<span className="text-zinc-600 mx-2">/</span>
|
||||
<Link href={`/${locale}/portfolio`} className="text-zinc-400 hover:text-white transition-colors">
|
||||
Portfolio
|
||||
</Link>
|
||||
<span className="text-zinc-600 mx-2">/</span>
|
||||
<span className="text-white">{project.title}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-12">
|
||||
<span className="text-blue-400 text-sm font-medium">{project.category}</span>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mt-2 mb-4">{project.title}</h1>
|
||||
<p className="text-zinc-400 text-lg">{project.description}</p>
|
||||
|
||||
{/* Technologies */}
|
||||
<div className="flex flex-wrap gap-2 mt-6">
|
||||
{project.technologies.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="text-sm bg-zinc-800 text-zinc-300 px-3 py-1 rounded-full"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<article className="prose prose-invert prose-zinc max-w-none">
|
||||
{project.content.split('\n\n').map((paragraph, index) => {
|
||||
if (paragraph.startsWith('## ')) {
|
||||
return (
|
||||
<h2 key={index} className="text-2xl font-bold mt-8 mb-4">
|
||||
{paragraph.replace('## ', '')}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
if (paragraph.startsWith('- ')) {
|
||||
const items = paragraph.split('\n').filter((item) => item.startsWith('- '));
|
||||
return (
|
||||
<ul key={index} className="list-disc list-inside space-y-2 text-zinc-300">
|
||||
{items.map((item, i) => (
|
||||
<li key={i}>{item.replace('- ', '')}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p key={index} className="text-zinc-300 leading-relaxed">
|
||||
{paragraph}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</article>
|
||||
|
||||
{/* Back Link */}
|
||||
<div className="mt-12 pt-8 border-t border-zinc-800">
|
||||
<Link
|
||||
href={`/${locale}/portfolio`}
|
||||
className="text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
← Back to Portfolio
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'portfolio.seo' });
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
};
|
||||
}
|
||||
|
||||
const projects = [
|
||||
{
|
||||
slug: 'ai-data-reader',
|
||||
title: 'AI Data Reader',
|
||||
description: 'KI-gestuetzte PDF-Datenextraktion mit Claude AI und JTL-Integration',
|
||||
category: 'Data Processing',
|
||||
technologies: ['Python', 'FastAPI', 'Claude AI', 'JTL-Wawi'],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
slug: 'smart-warehouse',
|
||||
title: 'Smart Warehouse',
|
||||
description: 'RFID-basierte Lagerverwaltung mit IoT-Integration',
|
||||
category: 'Integration',
|
||||
technologies: ['Python', 'RFID', 'Zebra Hardware', 'PostgreSQL'],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
slug: 'website-mit-ki',
|
||||
title: 'KI-Portfolio Website',
|
||||
description: 'Diese Portfolio-Website mit Next.js und modernen Web-Technologien',
|
||||
category: 'Web Development',
|
||||
technologies: ['Next.js', 'TypeScript', 'Tailwind CSS', 'Vercel'],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
slug: 'power-platform-governance',
|
||||
title: 'Power Platform Governance',
|
||||
description: 'Enterprise Governance-Loesung fuer Microsoft Power Platform',
|
||||
category: 'Enterprise Software',
|
||||
technologies: ['Power Automate', 'SharePoint', 'Azure', 'TypeScript'],
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function PortfolioPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('portfolio');
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-20 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8">
|
||||
<Link href={`/${locale}`} className="text-zinc-400 hover:text-white transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<span className="text-zinc-600 mx-2">/</span>
|
||||
<span className="text-white">Portfolio</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<section className="mb-16">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4">{t('sections.title')}</h1>
|
||||
<p className="text-zinc-400 text-lg max-w-2xl">
|
||||
{t('sections.subtitle')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Projects Grid */}
|
||||
<section>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{projects.map((project) => (
|
||||
<Link
|
||||
key={project.slug}
|
||||
href={`/${locale}/portfolio/${project.slug}`}
|
||||
className="group bg-zinc-800/50 rounded-lg overflow-hidden border border-zinc-700/50 hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
<div className="aspect-video bg-zinc-700/50 flex items-center justify-center">
|
||||
<span className="text-zinc-500 text-sm">{project.category}</span>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h2 className="text-xl font-semibold text-white group-hover:text-blue-400 transition-colors">
|
||||
{project.title}
|
||||
</h2>
|
||||
{project.featured && (
|
||||
<span className="text-xs bg-blue-600/20 text-blue-400 px-2 py-1 rounded">
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-zinc-400 mb-4">{project.description}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.technologies.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="text-xs bg-zinc-700/50 text-zinc-300 px-2 py-1 rounded"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'pages.privacy.seo' });
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PrivacyPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('pages.privacy');
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8">
|
||||
<Link href={`/${locale}`} className="text-zinc-400 hover:text-white transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<span className="text-zinc-600 mx-2">/</span>
|
||||
<span className="text-white">{t('content.title')}</span>
|
||||
</nav>
|
||||
|
||||
<article className="prose prose-invert prose-zinc max-w-none">
|
||||
<h1>{t('content.title')}</h1>
|
||||
<p className="text-zinc-400">{t('content.lastUpdated')}</p>
|
||||
|
||||
<h2>1. Data Collection</h2>
|
||||
<p>
|
||||
We collect personal information that you voluntarily provide to us when you contact us through our website.
|
||||
This may include your name, email address, and message content.
|
||||
</p>
|
||||
|
||||
<h2>2. Use of Data</h2>
|
||||
<p>
|
||||
We use the information we collect to respond to your inquiries and provide our services.
|
||||
We do not sell or share your personal information with third parties.
|
||||
</p>
|
||||
|
||||
<h2>3. Cookies</h2>
|
||||
<p>
|
||||
Our website uses cookies to improve your browsing experience and analyze website traffic.
|
||||
You can control cookie preferences through your browser settings.
|
||||
</p>
|
||||
|
||||
<h2>4. Your Rights</h2>
|
||||
<p>
|
||||
You have the right to access, correct, or delete your personal data.
|
||||
Contact us at privacy@damjan-savic.com for any privacy-related requests.
|
||||
</p>
|
||||
|
||||
<h2>5. Contact</h2>
|
||||
<p>
|
||||
For questions about this privacy policy, please contact us at:
|
||||
<br />
|
||||
Email: privacy@damjan-savic.com
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'pages.terms.seo' });
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TermsPage({ params }: Props) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('pages.terms');
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-8">
|
||||
<Link href={`/${locale}`} className="text-zinc-400 hover:text-white transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<span className="text-zinc-600 mx-2">/</span>
|
||||
<span className="text-white">{t('content.title')}</span>
|
||||
</nav>
|
||||
|
||||
<article className="prose prose-invert prose-zinc max-w-none">
|
||||
<h1>{t('content.title')}</h1>
|
||||
<p className="text-zinc-400">{t('content.lastUpdated')}</p>
|
||||
|
||||
<h2>1. Agreement to Terms</h2>
|
||||
<p>
|
||||
By accessing our website, you agree to be bound by these Terms and Conditions
|
||||
and to comply with all applicable laws and regulations.
|
||||
</p>
|
||||
|
||||
<h2>2. Intellectual Property</h2>
|
||||
<p>
|
||||
All content on this website, including text, graphics, logos, images, and software,
|
||||
is the property of Damjan Savic and is protected by intellectual property laws.
|
||||
</p>
|
||||
|
||||
<h2>3. User Responsibilities</h2>
|
||||
<p>
|
||||
When using our website, you agree to provide accurate information,
|
||||
use the website legally and ethically, and not attempt unauthorized access.
|
||||
</p>
|
||||
|
||||
<h2>4. Limitation of Liability</h2>
|
||||
<p>
|
||||
We are not liable for any indirect, incidental, special, or consequential damages
|
||||
arising from your use of or inability to use the website.
|
||||
</p>
|
||||
|
||||
<h2>5. Governing Law</h2>
|
||||
<p>
|
||||
These terms are governed by and construed in accordance with the laws of Germany.
|
||||
</p>
|
||||
|
||||
<h2>6. Contact</h2>
|
||||
<p>
|
||||
For questions about these terms, please contact us at:
|
||||
<br />
|
||||
Email: legal@damjan-savic.com
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--font-inter: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* Colors */
|
||||
--background: 24 24 27;
|
||||
--foreground: 250 250 250;
|
||||
--card: 39 39 42;
|
||||
--card-foreground: 250 250 250;
|
||||
--primary: 59 130 246;
|
||||
--primary-foreground: 255 255 255;
|
||||
--secondary: 63 63 70;
|
||||
--secondary-foreground: 250 250 250;
|
||||
--muted: 63 63 70;
|
||||
--muted-foreground: 161 161 170;
|
||||
--accent: 63 63 70;
|
||||
--accent-foreground: 250 250 250;
|
||||
--destructive: 239 68 68;
|
||||
--destructive-foreground: 250 250 250;
|
||||
--border: 63 63 70;
|
||||
--input: 63 63 70;
|
||||
--ring: 59 130 246;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-inter);
|
||||
background-color: rgb(var(--background));
|
||||
color: rgb(var(--foreground));
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgb(var(--background));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--secondary));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: rgb(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid rgb(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
a,
|
||||
button {
|
||||
transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, rgb(var(--primary)) 0%, #8b5cf6 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgb(var(--card) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgb(var(--border) / 0.5);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: '%s | Damjan Savic',
|
||||
default: 'Damjan Savic | AI & Automation Specialist',
|
||||
},
|
||||
description: 'AI & Automation Specialist - Building AI agents and automation solutions.',
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://damjan-savic.com'),
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body className="bg-zinc-900 text-zinc-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center px-4">
|
||||
<h1 className="text-6xl font-bold mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold mb-4">Page Not Found</h2>
|
||||
<p className="text-zinc-400 mb-8">
|
||||
The page you are looking for does not exist or has been moved.
|
||||
</p>
|
||||
<Link
|
||||
href="/de"
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors inline-block"
|
||||
>
|
||||
Go to Homepage
|
||||
</Link>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const locales = ['de', 'en', 'sr'] as const;
|
||||
export const defaultLocale = 'de' as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const localeNames: Record<Locale, string> = {
|
||||
de: 'Deutsch',
|
||||
en: 'English',
|
||||
sr: 'Srpski',
|
||||
};
|
||||
|
||||
export const localeFlags: Record<Locale, string> = {
|
||||
de: '🇩🇪',
|
||||
en: '🇬🇧',
|
||||
sr: '🇷🇸',
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user