From 2b270cf299507c73f0a62ed0a428704c42b1c2c2 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 19 Jul 2026 20:51:41 +0000 Subject: [PATCH] Add blog detail page (/blog/[slug]) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/blog/[slug]/page.tsx | 212 ++++++++++++++++++++++++++++ app/components/RichText.tsx | 48 +++++++ app/lib/format.ts | 13 ++ app/lib/payload.ts | 41 ++++++ public/icon-merke-dir-underline.png | Bin 0 -> 14308 bytes public/icon-sparkle-merke-dir.png | Bin 0 -> 1033 bytes public/icon-todo-passend-dazu.png | Bin 0 -> 5864 bytes 7 files changed, 314 insertions(+) create mode 100644 app/blog/[slug]/page.tsx create mode 100644 public/icon-merke-dir-underline.png create mode 100644 public/icon-sparkle-merke-dir.png create mode 100644 public/icon-todo-passend-dazu.png diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..f4e00e0 --- /dev/null +++ b/app/blog/[slug]/page.tsx @@ -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 { + 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 ( + <> +
+ +
+ {post.category} + + {post.readTime} Min +
+ {/* 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. */} +

+ {post.title} +

+

{post.excerpt}

+
+
+ Björn +
+

+ Björn • {formatDate(post.publishedAt)} +

+
+
+ + {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. + +
+ +
+
+ )} + + + + + {/* "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). */} + + +
+

Passend dazu:

+
+ {/* 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". */} +
+

+ ToDo-Karten +

+

Bringe Struktur in deine Aufgaben und gewinne Zeit zurück.

+
+ + Entdecken + + +
+
+ +
+ + {/* 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. */} + +
+
+ Björn +
+
+

Geschrieben von Björn.

+

Führungskraft. Familienmensch.

+

Gründer von einfach produktiv.

+
+
+
+ + {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. + +

+ Weiterlesen +

+ +
+ {nextPost.thumbnail && ( + + )} +
+
+
+ {nextPost.category} + + {nextPost.readTime} Min +
+

+ {nextPost.title} +

+

+ {nextPost.excerpt} + +

+
+ +
+ )} +
+