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 • {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. */}
+
+
+
+
+
+
+
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
+
);
+ // Lexical's default blockquote feature — used sitewide as a "Merke
+ // dir:" pull-quote callout, per page-blog-detail's actual built Figma
+ // frame (node 4676:341, file jCCZyh1DGwdjpv1wGge9To) — NOT a bordered/
+ // background card (an earlier version of this guessed one; the real
+ // design has no background or padding at all, just a plain 3-column
+ // row: label+underline, a full-height divider rule, then the quote
+ // lines). Icon is the actual exported sparkle asset from that node
+ // (icon-sparkle-merke-dir.png), not a hand-drawn approximation. The
+ // "Merke dir:" label itself is generic/hardcoded here rather than
+ // content-authored, since a blog post's own body text drives which
+ // lines get quoted, not the label framing them — legal pages never
+ // use blockquotes, so this styling is effectively blog-only in
+ // practice despite living in the shared renderer.
+ case "quote":
+ return (
+
+
+
+
+
+
+ Merke dir:
+
+
+ {/* Hand-drawn underline image, not a plain bar — exported
+ straight from the Figma node (label-underline). */}
+
+
+