Add blog detail page (/blog/[slug])
- app/blog/[slug]/page.tsx: article header (category/readTime, Playfair display title, excerpt, byline), full-bleed hero image, RichText body, "Passend dazu: ToDo-Karten" cross-promo, author bio card, "Weiterlesen" related-post card. Widths, fonts, and the Passend-dazu card's border/padding/spacing were corrected against the actual built Figma frame (page-blog-detail, node 4667:344, jCCZyh1DGwdjpv1wGge9To) via get_design_context after a few visually-wrong guesses. - RichText.tsx: new "quote" case renders Lexical's blockquote feature as the "Merke dir:" pull-quote — label+underline+divider+Caveat lines, using the real exported sparkle/underline assets, matching Figma's actual (background-less) structure instead of a guessed bordered card. - lib/payload.ts: getPostBySlug() for single-post fetches. - lib/format.ts: formatDate() — "03. Juli 2025"-style German dates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { RichText } from "../../components/RichText";
|
||||
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
|
||||
import { formatDate } from "../../lib/format";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await getPostBySlug(slug);
|
||||
if (!post) return { title: "Beitrag nicht gefunden" };
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
alternates: { canonical: `/blog/${post.slug}` },
|
||||
openGraph: {
|
||||
title: `${post.title} | einfach produktiv.`,
|
||||
description: post.excerpt,
|
||||
url: `/blog/${post.slug}`,
|
||||
type: "article",
|
||||
images: post.thumbnail ? [{ url: post.thumbnail }] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = await getPostBySlug(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
// "Weiterlesen" — any other post, most recent first. Not the current
|
||||
// one; falls back to nothing (section just doesn't render) rather than
|
||||
// showing a fake/duplicate card when this is the only post.
|
||||
const otherPosts = await getBlogPosts(4);
|
||||
const nextPost = otherPosts.find((p) => p.slug !== post.slug) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{post.category}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime} Min</span>
|
||||
</div>
|
||||
{/* text-display + Playfair, not text-h-feature + Lora — matches
|
||||
the actual Figma title exactly (Playfair Display SemiBold,
|
||||
44px), same "hero headline" treatment as Hero.tsx/
|
||||
TodoKartenHero.tsx/WeeklyImpulsesHero.tsx use, not the Lora
|
||||
section-heading style the legal pages use. text-display's
|
||||
clamp floors at exactly 2.75rem (44px) at the 768px Tablet
|
||||
breakpoint, matching the Figma value precisely. */}
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary leading-[1.12]"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
{post.title}
|
||||
</p>
|
||||
<p className="text-body text-text-muted">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
|
||||
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
Björn <span className="text-text-muted">• {formatDate(post.publishedAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{post.thumbnail && (
|
||||
// max-w-[70rem] (1120px) — exact Figma value (node 4667:379):
|
||||
// 1120px hero vs. 700px body = 1.6x, not full-bleed to the page
|
||||
// edge (an earlier version guessed that, which came out far too
|
||||
// wide) and not capped to the body column either.
|
||||
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
|
||||
<RichText content={post.content} />
|
||||
|
||||
{/* "Passend dazu" — static cross-promo, not CMS content (same
|
||||
reasoning as the legal pages' brand callouts): every post
|
||||
currently points at the same flagship product rather than
|
||||
needing a per-post "related product" field that nothing else
|
||||
uses yet. Matches the actual built Figma frame exactly (node
|
||||
4674:349, fetched via get_design_context) — 1px border-border,
|
||||
rounded-md, px-9/py-7 padding; an earlier version guessed a
|
||||
plain borderless row instead, which get_metadata's structural
|
||||
dump didn't reveal (frame-level stroke/padding/radius aren't
|
||||
visible there, only get_design_context shows those). */}
|
||||
<Link
|
||||
href="/todo-cards"
|
||||
className="group flex items-center gap-6 border border-border rounded-md px-9 py-7 hover:border-brand transition-colors"
|
||||
>
|
||||
<img alt="" src="/icon-todo-passend-dazu.png" className="w-16 h-[4.6875rem] shrink-0 object-contain" />
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
|
||||
<div className="flex items-end justify-between gap-4 w-full">
|
||||
{/* w-[19rem] (305px) — matches Figma's title-col exactly,
|
||||
so the description wraps at the same point instead of
|
||||
stretching out to fill the space before "Entdecken". */}
|
||||
<div className="flex flex-col gap-2 items-start w-[19rem] shrink-0">
|
||||
<p
|
||||
className="font-semibold text-[1.375rem] text-text-primary whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
ToDo-Karten
|
||||
</p>
|
||||
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">Bringe Struktur in deine Aufgaben und gewinne Zeit zurück.</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap">
|
||||
Entdecken
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="size-3.5 transition-transform duration-200 group-hover:translate-x-1"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
{/* Author bio — same hardcoded-brand-chrome reasoning as above;
|
||||
this is a single-author blog, there's no author field on the
|
||||
Posts collection to read from. */}
|
||||
<Reveal delay={0.2} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-14">
|
||||
<div className="flex items-center gap-4 bg-bg-muted rounded-md p-6">
|
||||
<div className="relative size-14 shrink-0 rounded-full overflow-hidden">
|
||||
<Image alt="Björn" src="/about-author.jpg" fill sizes="56px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="font-semibold text-body text-text-primary">Geschrieben von Björn.</p>
|
||||
<p className="text-body-sm text-text-muted">Führungskraft. Familienmensch.</p>
|
||||
<p className="text-body-sm text-text-muted">Gründer von einfach produktiv.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{nextPost && (
|
||||
// max-w-[58.75rem] (940px) — exact Figma value (node 4667:382),
|
||||
// deliberately wider than the 48rem body column (widened past
|
||||
// the 43.75rem/700px Figma value per user preference), matching
|
||||
// the original build's "Weiterlesen" card being wider than the
|
||||
// text column rather than symmetric to it.
|
||||
<Reveal delay={0.25} className="w-full max-w-[58.75rem] mx-auto px-[var(--layout-padding-x)] pb-16">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary mb-4"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Weiterlesen
|
||||
</p>
|
||||
<Link
|
||||
href={`/blog/${nextPost.slug}`}
|
||||
className="group flex flex-col sm:flex-row items-stretch border border-border rounded-md overflow-hidden transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full sm:w-64 aspect-video sm:aspect-auto shrink-0 bg-bg-muted">
|
||||
{nextPost.thumbnail && (
|
||||
<Image alt="" src={nextPost.thumbnail} fill sizes="(min-width: 640px) 16rem, 100vw" className="object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-2 p-6 min-w-0">
|
||||
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
|
||||
<span>{nextPost.category}</span>
|
||||
<span>•</span>
|
||||
<span>{nextPost.readTime} Min</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{nextPost.title}
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5 text-body-sm text-text-muted line-clamp-1">
|
||||
{nextPost.excerpt}
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="size-3.5 shrink-0 text-text-primary transition-transform duration-200 group-hover:translate-x-1"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</Reveal>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user