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:
Marco
2026-07-19 20:51:41 +00:00
parent 3cf45cfb16
commit 2b270cf299
7 changed files with 314 additions and 0 deletions
+212
View File
@@ -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 />
</>
);
}