Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 18s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
Build & Deploy / 🏗️ Build (push) Failing after 2m46s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
import { notFound, redirect } from 'next/navigation';
|
|
import JsonLd from '@/components/JsonLd';
|
|
import { SITE_URL } from '@/lib/schema';
|
|
import {
|
|
getPostBySlug,
|
|
getAdjacentPosts,
|
|
getReadingTime,
|
|
getHeadings,
|
|
} from '@/lib/blog';
|
|
import { Metadata } from 'next';
|
|
import Link from 'next/link';
|
|
import Image from 'next/image';
|
|
import PostNavigation from '@/components/blog/PostNavigation';
|
|
import PowerCTA from '@/components/blog/PowerCTA';
|
|
import TableOfContents from '@/components/blog/TableOfContents';
|
|
import { Heading } from '@/components/ui';
|
|
import { setRequestLocale } from 'next-intl/server';
|
|
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
|
|
|
const mdxComponents = {
|
|
Heading,
|
|
h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1
|
|
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,
|
|
h3: (props: any) => <Heading level={3} size="4" className="mt-12 mb-4 text-primary" {...props} />,
|
|
h4: (props: any) => <Heading level={4} size="5" className="mt-8 mb-4 uppercase tracking-widest text-neutral-500" {...props} />,
|
|
p: (props: any) => <p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-normal max-w-3xl" {...props} />,
|
|
ul: (props: any) => <ul className="list-none mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />,
|
|
ol: (props: any) => <ol className="list-decimal pl-6 mb-8 space-y-3 text-base md:text-lg text-text-secondary font-normal max-w-3xl" {...props} />,
|
|
li: (props: any) => (
|
|
<li className="relative pl-6 before:content-[''] before:absolute before:left-0 before:top-[0.6em] before:w-2 before:h-2 before:bg-primary/50 before:rounded-sm" {...props} />
|
|
),
|
|
a: (props: any) => <a className="text-text-secondary underline decoration-primary decoration-2 underline-offset-4 hover:text-primary transition-all font-bold" {...props} />,
|
|
strong: (props: any) => <strong className="font-bold text-neutral-900" {...props} />,
|
|
blockquote: (props: any) => (
|
|
<blockquote className="border-l-4 border-primary pl-6 py-3 my-10 italic bg-neutral-50 rounded-r-2xl text-neutral-700 font-medium max-w-3xl shadow-sm" {...props} />
|
|
),
|
|
hr: (props: any) => <hr className="my-16 border-t-2 border-neutral-100 max-w-3xl" {...props} />,
|
|
img: (props: any) => <img className="rounded-2xl shadow-2xl my-12 max-w-full h-auto border border-neutral-100" {...props} />,
|
|
};
|
|
|
|
interface BlogPostProps {
|
|
params: Promise<{
|
|
locale: string;
|
|
slug: string;
|
|
}>;
|
|
}
|
|
|
|
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
|
|
const { locale, slug } = await params;
|
|
const post = await getPostBySlug(slug, locale);
|
|
|
|
if (!post) return {};
|
|
|
|
const description = post.frontmatter.excerpt || '';
|
|
return {
|
|
title: post.frontmatter.title,
|
|
description: description,
|
|
alternates: {
|
|
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
|
},
|
|
openGraph: {
|
|
title: `${post.frontmatter.title} | E-TIB`,
|
|
description: description,
|
|
type: 'article',
|
|
publishedTime: post.frontmatter.date,
|
|
authors: ['E-TIB'],
|
|
url: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
|
},
|
|
twitter: {
|
|
card: 'summary_large_image',
|
|
title: `${post.frontmatter.title} | E-TIB`,
|
|
description: description,
|
|
},
|
|
};
|
|
}
|
|
|
|
export default async function BlogPost({ params }: BlogPostProps) {
|
|
const { locale, slug } = await params;
|
|
setRequestLocale(locale);
|
|
const post = await getPostBySlug(slug, locale);
|
|
|
|
if (!post) {
|
|
notFound();
|
|
}
|
|
|
|
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
|
|
const headings = getHeadings(post.content);
|
|
const readingTime = getReadingTime(post.content);
|
|
|
|
return (
|
|
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
|
<BlogEngagementTracker
|
|
title={post.frontmatter.title}
|
|
slug={slug}
|
|
category={post.frontmatter.category}
|
|
readingTime={readingTime}
|
|
/>
|
|
|
|
{/* Featured Image Header */}
|
|
{post.frontmatter.featuredImage ? (
|
|
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
|
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
|
|
<Image
|
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
|
alt={post.frontmatter.title}
|
|
fill
|
|
priority
|
|
quality={100}
|
|
className="object-cover"
|
|
sizes="100vw"
|
|
style={{
|
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/70 to-neutral-dark/30" />
|
|
|
|
{/* Title overlay on image */}
|
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
|
<div className="container mx-auto px-4">
|
|
<div className="max-w-4xl bg-neutral-dark/40 backdrop-blur-md p-6 rounded-2xl border border-white/10">
|
|
{post.frontmatter.category && (
|
|
<div className="overflow-hidden mb-6">
|
|
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
|
|
{post.frontmatter.category}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<Heading level={1} className="text-white mb-8 drop-shadow-md">
|
|
{post.frontmatter.title}
|
|
</Heading>
|
|
<div className="flex flex-wrap items-center gap-6 text-white text-sm md:text-base font-medium drop-shadow-sm">
|
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})}
|
|
</time>
|
|
<span className="w-1 h-1 bg-white/50 rounded-full" />
|
|
<span>{readingTime} min read</span>
|
|
{(new Date(post.frontmatter.date) > new Date() ||
|
|
post.frontmatter.public === false) && (
|
|
<>
|
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
|
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
|
Draft Preview
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<header className="pt-32 pb-16 bg-neutral-50 border-b border-neutral-100">
|
|
<div className="container mx-auto px-4 max-w-4xl">
|
|
{post.frontmatter.category && (
|
|
<div className="mb-6">
|
|
<span className="inline-block px-4 py-1.5 bg-primary/10 text-primary text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
|
|
{post.frontmatter.category}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<Heading level={1} className="mb-8">
|
|
{post.frontmatter.title}
|
|
</Heading>
|
|
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})}
|
|
</time>
|
|
<span className="w-1 h-1 bg-neutral-400 rounded-full" />
|
|
<span>{readingTime} min read</span>
|
|
{(new Date(post.frontmatter.date) > new Date() ||
|
|
post.frontmatter.public === false) && (
|
|
<>
|
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
|
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
|
Draft Preview
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
)}
|
|
|
|
{/* Main Content Area with Sticky Narrative Layout */}
|
|
<div className="container mx-auto px-4 py-16 md:py-24">
|
|
<div className="sticky-narrative-container">
|
|
{/* Left Column: Content */}
|
|
<div className="sticky-narrative-content">
|
|
{/* Excerpt/Lead paragraph if available */}
|
|
{post.frontmatter.excerpt && (
|
|
<div className="mb-16">
|
|
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
|
{post.frontmatter.excerpt}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main content */}
|
|
<div className="w-full [&_a]:text-primary [&_a:hover]:text-primary-dark [&_a]:underline [&_a]:decoration-primary [&_a]:underline-offset-4 [&_a]:transition-all [&_a]:font-bold [&_a_strong]:text-inherit">
|
|
<MDXRemote source={post.content} components={mdxComponents} />
|
|
</div>
|
|
|
|
{/* Power CTA */}
|
|
<div className="mt-24 animate-slight-fade-in-from-bottom">
|
|
<PowerCTA locale={locale} />
|
|
</div>
|
|
|
|
{/* Post Navigation */}
|
|
<div className="mt-16">
|
|
<PostNavigation
|
|
prev={prev}
|
|
next={next}
|
|
isPrevRandom={isPrevRandom}
|
|
isNextRandom={isNextRandom}
|
|
locale={locale}
|
|
/>
|
|
</div>
|
|
|
|
{/* Back to blog link */}
|
|
<div className="mt-16 pt-10 border-t border-neutral-100">
|
|
<Link
|
|
href={`/${locale}/blog`}
|
|
className="inline-flex items-center gap-3 text-text-secondary hover:text-primary font-bold text-sm uppercase tracking-widest transition-all group"
|
|
>
|
|
<svg
|
|
className="w-5 h-5 transition-transform group-hover:-translate-x-2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 19l-7-7 7-7"
|
|
/>
|
|
</svg>
|
|
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column: Sticky Sidebar - TOC */}
|
|
<aside className="sticky-narrative-sidebar hidden lg:block">
|
|
<div className="space-y-12 lg:sticky lg:top-32">
|
|
<TableOfContents headings={headings} locale={locale} />
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Structured Data */}
|
|
<JsonLd
|
|
id={`jsonld-${slug}`}
|
|
data={
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'BlogPosting',
|
|
headline: post.frontmatter.title,
|
|
datePublished: post.frontmatter.date,
|
|
dateModified: post.frontmatter.date,
|
|
image: post.frontmatter.featuredImage
|
|
? `${SITE_URL}${post.frontmatter.featuredImage}`
|
|
: undefined,
|
|
author: {
|
|
'@type': 'Organization',
|
|
name: 'E-TIB',
|
|
url: SITE_URL,
|
|
logo: `${SITE_URL}/logo-blue.svg`,
|
|
},
|
|
publisher: {
|
|
'@type': 'Organization',
|
|
name: 'E-TIB',
|
|
logo: {
|
|
'@type': 'ImageObject',
|
|
url: `${SITE_URL}/logo-blue.svg`,
|
|
},
|
|
},
|
|
description: post.frontmatter.excerpt,
|
|
mainEntityOfPage: {
|
|
'@type': 'WebPage',
|
|
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
|
|
},
|
|
articleSection: post.frontmatter.category,
|
|
wordCount: post.content.split(/\s+/).length,
|
|
timeRequired: `PT${readingTime}M`,
|
|
} as any
|
|
}
|
|
/>
|
|
<JsonLd
|
|
id={`breadcrumb-${slug}`}
|
|
data={
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'BreadcrumbList',
|
|
itemListElement: [
|
|
{
|
|
'@type': 'ListItem',
|
|
position: 1,
|
|
name: 'Blog',
|
|
item: `${SITE_URL}/${locale}/blog`,
|
|
},
|
|
{
|
|
'@type': 'ListItem',
|
|
position: 2,
|
|
name: post.frontmatter.title,
|
|
item: `${SITE_URL}/${locale}/blog/${slug}`,
|
|
},
|
|
],
|
|
} as any
|
|
}
|
|
/>
|
|
</article>
|
|
);
|
|
}
|