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
+519
View File
@@ -0,0 +1,519 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Calendar, ArrowLeft, Tag, Loader2, Folder } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import PageTransition from '../../../components/PageTransition';
import SEO from '../../../components/SEO';
import { trackBlogPostView } from '../../../services/gtm';
import { blog as blogDe } from '../../../i18n/locales/de/blog/';
import { blog as blogEn } from '../../../i18n/locales/en/blog';
import { blog as blogSr } from '../../../i18n/locales/sr/blog';
/* =======================
Typdefinitionen
========================== */
// Typ für einen Blog-Post, wie er in der Komponente genutzt wird (enthält slug)
interface BlogPostType {
slug: string;
title: string;
excerpt: string;
date: string;
coverImage?: string;
category?: string;
tags?: string[];
content: {
intro: {
title: string;
description: string;
};
background: {
title: string;
systems: Record<string, string>;
challenges: Record<string, string>;
};
tech: {
title: string;
components: {
title: string;
code: string;
};
};
api: {
title: string;
apparel_magic: {
title: string;
description: string;
};
};
conclusion: {
title: string;
requirements: Record<string, string>;
results: string;
};
};
}
// Typ für einen Blog-Post in der Konfiguration (ohne slug)
type BlogPostConfig = Omit<BlogPostType, 'slug'>;
// Typ für die Blog-Konfiguration (UI-Text, Kategorien, Posts)
interface BlogConfig {
meta?: {
title: string;
description: string;
header: unknown;
};
ui: {
loading: {
post: string;
};
notFound: {
title: string;
message: string;
};
backToBlog: string;
};
categories: Record<string, string>;
posts: Record<string, BlogPostConfig>;
}
/* =======================
BlogPost Component
========================== */
const BlogPost: React.FC = () => {
const { slug } = useParams();
const navigate = useNavigate();
const { i18n } = useTranslation();
const [post, setPost] = useState<BlogPostType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Memoized Funktion zur Ermittlung der aktuellen Blog-Konfiguration anhand der Sprache.
const getBlogConfig = useCallback((): BlogConfig => {
switch (i18n.language) {
case 'en':
return blogEn as unknown as BlogConfig;
case 'sr':
return blogSr as unknown as BlogConfig;
case 'de':
default:
return blogDe as unknown as BlogConfig;
}
}, [i18n.language]);
const blog = getBlogConfig();
useEffect(() => {
const loadPost = async () => {
setLoading(true);
try {
if (slug) {
const currentBlog = getBlogConfig();
const postData = currentBlog.posts[slug];
if (postData) {
// Wir fügen hier den slug hinzu, da er in der Konfiguration nicht enthalten ist.
setPost({
...postData,
slug,
});
// Track blog post view
trackBlogPostView(postData.title, postData.category);
} else {
// Statt eine Exception zu werfen, setzen wir den Fehler direkt.
setError(new Error('Post not found'));
}
}
} catch (err) {
console.error('Error loading blog post:', err);
setError(err instanceof Error ? err : new Error('Failed to load post'));
} finally {
setLoading(false);
}
};
loadPost();
}, [slug, getBlogConfig]);
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
const getFormattedDate = (date: string): string => {
const languageMap: Record<string, string> = {
de: 'de-DE',
en: 'en-US',
sr: 'sr-RS',
};
return new Date(date).toLocaleDateString(
languageMap[i18n.language] || 'de-DE',
{
year: 'numeric',
month: 'long',
day: 'numeric',
}
);
};
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">{blog.ui.loading.post}</p>
</motion.div>
</div>
);
}
if (error || !post) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center space-y-6 px-4 text-center"
>
<div className="space-y-2">
<h3 className="text-lg font-medium text-white">{blog.ui.notFound.title}</h3>
<p className="text-zinc-400 text-sm max-w-md">
{error?.message || blog.ui.notFound.message}
</p>
</div>
<Link
to="/blog"
className="flex items-center gap-2 text-zinc-400 hover:text-white transition-colors duration-300"
>
<ArrowLeft className="h-4 w-4" />
<span>{blog.ui.backToBlog}</span>
</Link>
</motion.div>
</div>
);
}
// Article Schema for SEO
const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
"headline": post.title,
"description": post.excerpt,
"datePublished": post.date,
"dateModified": post.date,
"author": {
"@type": "Person",
"name": "Damjan Savić",
"url": "https://damjan-savic.com"
},
"publisher": {
"@type": "Person",
"name": "Damjan Savić",
"logo": {
"@type": "ImageObject",
"url": "https://damjan-savic.com/logo.svg"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": `https://damjan-savic.com/blog/${post.slug}`
},
"articleSection": post.category ? blog.categories[post.category] : undefined,
"keywords": post.tags?.join(', ')
};
return (
<PageTransition>
<SEO title={`${post.title} - Blog`} description={post.excerpt} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
<main className="min-h-screen">
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-24">
<motion.div variants={containerVariants} initial="hidden" animate="visible">
{/* Back Navigation */}
<motion.div variants={itemVariants} className="mb-8">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-zinc-400 hover:text-white transition-colors duration-300"
>
<ArrowLeft className="h-4 w-4" />
<span>{blog.ui.backToBlog}</span>
</button>
</motion.div>
{/* Post Header */}
<div className="mb-12">
<motion.div
variants={itemVariants}
className="flex flex-wrap items-center gap-4 mb-6"
>
<div className="flex items-center gap-2 text-zinc-400">
<Calendar className="h-4 w-4" />
<time>{getFormattedDate(post.date)}</time>
</div>
{post.category && (
<div className="flex items-center gap-2 text-zinc-400">
<Folder className="h-4 w-4" />
<span>{blog.categories[post.category] || post.category}</span>
</div>
)}
</motion.div>
<motion.h1
variants={itemVariants}
className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-8"
>
{post.title}
</motion.h1>
</div>
{/* Cover Image */}
{post.coverImage && (
<motion.div variants={itemVariants} className="mb-12">
<div className="relative rounded-lg overflow-hidden bg-zinc-800">
<img
src={post.coverImage}
alt={post.title}
className="w-full aspect-video object-cover"
loading="lazy"
/>
</div>
</motion.div>
)}
{/* Content */}
<motion.div
variants={itemVariants}
className="prose prose-invert max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-zinc-300 prose-p:leading-relaxed
prose-a:text-white prose-a:no-underline hover:prose-a:text-zinc-300
prose-strong:text-white
prose-code:text-zinc-300 prose-code:bg-zinc-800/50
prose-pre:bg-zinc-800/50 prose-pre:border prose-pre:border-zinc-800
prose-img:rounded-lg
prose-blockquote:border-l-zinc-700 prose-blockquote:text-zinc-400"
>
{/* Intro Section */}
{post.content.intro && (
<section>
<h2>{post.content.intro.title}</h2>
<p>{post.content.intro.description}</p>
</section>
)}
{/* Video Embed (for posts with videoUrl) */}
{(post as unknown as { videoUrl?: string }).videoUrl && (
<section className="my-8">
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', borderRadius: '12px' }}>
<iframe
src={`${(post as unknown as { videoUrl: string }).videoUrl}/embed?b=0&title=1&a=1&loop=0&autoPlay=false&t=0&muted=0&wt=0`}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none' }}
allowFullScreen
loading="lazy"
/>
</div>
</section>
)}
{/* Background Section (optional) */}
{post.content.background && (
<section>
<h2>{post.content.background.title}</h2>
{post.content.background.systems && (
<div className="mb-6">
<h3>Systems</h3>
<ul>
{Object.values(post.content.background.systems).map((system, index) => (
<li key={index}>{system}</li>
))}
</ul>
</div>
)}
{post.content.background.challenges && (
<div>
<h3>Challenges</h3>
<ul>
{Object.values(post.content.background.challenges).map((challenge, index) => (
<li key={index}>{challenge}</li>
))}
</ul>
</div>
)}
</section>
)}
{/* Technical Architecture (optional) */}
{post.content.tech && (
<section>
<h2>{post.content.tech.title}</h2>
{post.content.tech.components && (
<>
<h3>{post.content.tech.components.title}</h3>
{post.content.tech.components.code && (
<pre>
<code>{post.content.tech.components.code}</code>
</pre>
)}
</>
)}
</section>
)}
{/* API Section (optional) */}
{post.content.api && (
<section>
<h2>{post.content.api.title}</h2>
{post.content.api.apparel_magic && (
<div className="mb-6">
<h3>{post.content.api.apparel_magic.title}</h3>
<p>{post.content.api.apparel_magic.description}</p>
</div>
)}
</section>
)}
{/* Workflow Section (for ad-creatives style posts) */}
{(post.content as unknown as { workflow?: { title: string; steps: Record<string, { title: string; description: string; points?: string[]; advantages?: string[]; steps?: string[] }> } }).workflow && (
<section>
<h2>{(post.content as unknown as { workflow: { title: string } }).workflow.title}</h2>
{Object.values((post.content as unknown as { workflow: { steps: Record<string, { title: string; description: string; points?: string[]; advantages?: string[]; steps?: string[] }> } }).workflow.steps).map((step, index) => (
<div key={index} className="mb-6">
<h3>{step.title}</h3>
<p>{step.description}</p>
{step.points && (
<ul>
{step.points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
)}
{step.advantages && (
<ul>
{step.advantages.map((adv, i) => (
<li key={i}>{adv}</li>
))}
</ul>
)}
{step.steps && (
<ul>
{step.steps.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
)}
</div>
))}
</section>
)}
{/* Advantages Section (for ad-creatives style posts) */}
{(post.content as unknown as { advantages?: { title: string; efficiency?: { title: string; points: string[] }; scalability?: { title: string; points: string[] }; consistency?: { title: string; points: string[] } } }).advantages && (
<section>
<h2>{(post.content as unknown as { advantages: { title: string } }).advantages.title}</h2>
{(post.content as unknown as { advantages: { efficiency?: { title: string; points: string[] } } }).advantages.efficiency && (
<div className="mb-4">
<h3>{(post.content as unknown as { advantages: { efficiency: { title: string } } }).advantages.efficiency.title}</h3>
<ul>
{(post.content as unknown as { advantages: { efficiency: { points: string[] } } }).advantages.efficiency.points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
{(post.content as unknown as { advantages: { scalability?: { title: string; points: string[] } } }).advantages.scalability && (
<div className="mb-4">
<h3>{(post.content as unknown as { advantages: { scalability: { title: string } } }).advantages.scalability.title}</h3>
<ul>
{(post.content as unknown as { advantages: { scalability: { points: string[] } } }).advantages.scalability.points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
{(post.content as unknown as { advantages: { consistency?: { title: string; points: string[] } } }).advantages.consistency && (
<div className="mb-4">
<h3>{(post.content as unknown as { advantages: { consistency: { title: string } } }).advantages.consistency.title}</h3>
<ul>
{(post.content as unknown as { advantages: { consistency: { points: string[] } } }).advantages.consistency.points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
)}
</section>
)}
{/* Conclusion */}
{post.content.conclusion && (
<section>
<h2>{post.content.conclusion.title}</h2>
{post.content.conclusion.requirements && (
<ul>
{Object.values(post.content.conclusion.requirements).map((req, index) => (
<li key={index}>{req}</li>
))}
</ul>
)}
{post.content.conclusion.results && (
<p>{post.content.conclusion.results}</p>
)}
{(post.content.conclusion as unknown as { description?: string }).description && (
<p>{(post.content.conclusion as unknown as { description: string }).description}</p>
)}
{(post.content.conclusion as unknown as { keyPoints?: string[] }).keyPoints && (
<ul>
{(post.content.conclusion as unknown as { keyPoints: string[] }).keyPoints.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
)}
</section>
)}
</motion.div>
{/* Tags */}
{post.tags && post.tags.length > 0 && (
<motion.div variants={itemVariants} className="mt-12 pt-6 border-t border-zinc-800">
<div className="flex items-center gap-4">
<Tag className="h-4 w-4 text-zinc-400" />
<div className="flex flex-wrap gap-2">
{post.tags.map((tag: string) => (
<span
key={tag}
className="px-3 py-1 bg-zinc-800/50 border border-zinc-800 rounded-full text-sm text-zinc-300"
>
{tag}
</span>
))}
</div>
</div>
</motion.div>
)}
</motion.div>
</article>
</main>
</PageTransition>
);
};
export default BlogPost;