Migrate from Vite to Next.js 15 with SSR

- Replace Vite + React Router with Next.js 15 App Router
- Implement i18n with next-intl (URL-based: /de, /en, /sr)
- Add SSR/SSG for all pages (48 static pages generated)
- Setup Supabase SSR client for auth
- Migrate all pages: Home, About, Portfolio, Blog, Contact, Login, Dashboard, Imprint, Privacy, Terms
- Add Docker support with standalone output
- Replace i18next with next-intl JSON translations
- Use next/image for optimized images

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 01:00:33 +01:00
co-authored by Claude Opus 4.5
parent a66f51b9a2
commit b1ec7b4d61
281 changed files with 8024 additions and 8901 deletions
+270
View File
@@ -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>
);
}