Files
Portfolio/src/pages/blog/components/BlogPost.tsx
T

399 lines
16 KiB
TypeScript

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 */}
<section>
<h2>{post.content.intro.title}</h2>
<p>{post.content.intro.description}</p>
</section>
{/* Background Section */}
<section>
<h2>{post.content.background.title}</h2>
<div className="mb-6">
<h3>Systems</h3>
<ul>
{Object.values(post.content.background.systems).map((system, index) => (
<li key={index}>{system}</li>
))}
</ul>
</div>
<div>
<h3>Challenges</h3>
<ul>
{Object.values(post.content.background.challenges).map((challenge, index) => (
<li key={index}>{challenge}</li>
))}
</ul>
</div>
</section>
{/* Technical Architecture */}
<section>
<h2>{post.content.tech.title}</h2>
<h3>{post.content.tech.components.title}</h3>
<pre>
<code>{post.content.tech.components.code}</code>
</pre>
</section>
{/* API Section */}
<section>
<h2>{post.content.api.title}</h2>
<div className="mb-6">
<h3>{post.content.api.apparel_magic.title}</h3>
<p>{post.content.api.apparel_magic.description}</p>
</div>
</section>
{/* Conclusion */}
<section>
<h2>{post.content.conclusion.title}</h2>
<ul>
{Object.values(post.content.conclusion.requirements).map((req, index) => (
<li key={index}>{req}</li>
))}
</ul>
<p>{post.content.conclusion.results}</p>
</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;