2b270cf299
- 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>
195 lines
7.1 KiB
TypeScript
195 lines
7.1 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import type { TOCSection } from "./SectionTOC";
|
|
|
|
// Minimal Lexical JSON → JSX renderer for Payload's richText fields.
|
|
// Deliberately small and dependency-free (matches the project's existing
|
|
// style — see Posts.ts's own hand-rolled extractPlainText on the Payload
|
|
// side) rather than pulling in @payloadcms/richtext-lexical's full React
|
|
// renderer just to walk a legal page's headings/paragraphs/lists. Covers
|
|
// the node types real content actually uses; add more only when a page
|
|
// genuinely needs them.
|
|
|
|
type LexicalNode = {
|
|
type: string;
|
|
children?: LexicalNode[];
|
|
text?: string;
|
|
format?: number;
|
|
tag?: string;
|
|
listType?: "bullet" | "number";
|
|
fields?: { url?: string };
|
|
};
|
|
|
|
// Lexical's text format is a bitmask — see TextFormatType in the Lexical
|
|
// source (IS_BOLD = 1, IS_ITALIC = 2, IS_UNDERLINE = 8).
|
|
const BOLD = 1;
|
|
const ITALIC = 2;
|
|
const UNDERLINE = 8;
|
|
|
|
function plainText(node: LexicalNode): string {
|
|
if (node.type === "text") return node.text ?? "";
|
|
return (node.children ?? []).map(plainText).join("");
|
|
}
|
|
|
|
// Numbered legal-page headings ("1. Verantwortlicher") get a stable
|
|
// "section-1" id from the leading number — immune to copy edits changing
|
|
// the heading text later, unlike a text-derived slug. Anything else
|
|
// (headings with no leading number) falls back to a plain slugify.
|
|
function headingId(text: string): string {
|
|
const numbered = text.match(/^(\d+)\./);
|
|
if (numbered) return `section-${numbered[1]}`;
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9äöüß]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
// Walks the same tree the renderer does, collecting h2 headings for a
|
|
// SectionTOC sidebar — kept in this file (not duplicated) so the ids it
|
|
// produces can never drift from the ones the renderer actually assigns.
|
|
export function extractHeadings(content: unknown): TOCSection[] {
|
|
const root = (content as { root?: LexicalNode })?.root;
|
|
if (!root?.children) return [];
|
|
const headings: TOCSection[] = [];
|
|
const walk = (nodes: LexicalNode[]) => {
|
|
for (const node of nodes) {
|
|
if (node.type === "heading" && (node.tag ?? "h2") === "h2") {
|
|
const text = plainText(node);
|
|
headings.push({ id: headingId(text), title: text });
|
|
}
|
|
if (node.children) walk(node.children);
|
|
}
|
|
};
|
|
walk(root.children);
|
|
return headings;
|
|
}
|
|
|
|
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string): ReactNode {
|
|
if (!nodes) return null;
|
|
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`));
|
|
}
|
|
|
|
function renderNode(node: LexicalNode, key: string): ReactNode {
|
|
switch (node.type) {
|
|
case "linebreak":
|
|
return <br key={key} />;
|
|
case "text": {
|
|
let el: ReactNode = node.text;
|
|
const format = node.format ?? 0;
|
|
if (format & BOLD) el = <strong key={key}>{el}</strong>;
|
|
if (format & ITALIC) el = <em key={key}>{el}</em>;
|
|
if (format & UNDERLINE) el = <u key={key}>{el}</u>;
|
|
return <span key={key}>{el}</span>;
|
|
}
|
|
case "link":
|
|
return (
|
|
<a
|
|
key={key}
|
|
href={node.fields?.url ?? "#"}
|
|
className="text-brand hover:underline"
|
|
>
|
|
{renderChildren(node.children, key)}
|
|
</a>
|
|
);
|
|
case "heading": {
|
|
const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
|
const text = plainText(node);
|
|
return (
|
|
<Tag
|
|
key={key}
|
|
id={Tag === "h2" ? headingId(text) : undefined}
|
|
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
|
|
style={{ fontFamily: "var(--font-lora)" }}
|
|
>
|
|
{renderChildren(node.children, key)}
|
|
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
|
|
</Tag>
|
|
);
|
|
}
|
|
case "list": {
|
|
const ListTag = node.listType === "number" ? "ol" : "ul";
|
|
return (
|
|
<ListTag
|
|
key={key}
|
|
className={
|
|
"flex flex-col gap-2 text-body text-text-body " +
|
|
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
|
|
}
|
|
>
|
|
{renderChildren(node.children, key)}
|
|
</ListTag>
|
|
);
|
|
}
|
|
case "listitem":
|
|
return (
|
|
<li key={key}>{renderChildren(node.children, key)}</li>
|
|
);
|
|
case "paragraph":
|
|
return (
|
|
<p key={key} className="text-body text-text-body">
|
|
{renderChildren(node.children, key)}
|
|
</p>
|
|
);
|
|
// 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 (
|
|
<div key={key} className="relative flex items-start gap-6 w-full my-6">
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className="flex h-7 w-6 items-center justify-center shrink-0">
|
|
<img alt="" src="/icon-sparkle-merke-dir.png" className="w-full h-full object-contain" />
|
|
</span>
|
|
<span
|
|
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
|
|
style={{ fontFamily: "var(--font-caveat)" }}
|
|
>
|
|
Merke dir:
|
|
</span>
|
|
</div>
|
|
{/* Hand-drawn underline image, not a plain bar — exported
|
|
straight from the Figma node (label-underline). */}
|
|
<img
|
|
alt=""
|
|
src="/icon-merke-dir-underline.png"
|
|
className="absolute left-8 top-[2.1875rem] w-[8.5rem] h-[1.4375rem] object-cover pointer-events-none"
|
|
/>
|
|
<div className="w-px self-stretch bg-brand shrink-0" />
|
|
<div className="flex flex-col gap-3.5 flex-1">
|
|
{(node.children ?? []).map((child, i) => (
|
|
<p
|
|
key={`${key}-q-${i}`}
|
|
className="text-text-primary text-[1.75rem] leading-[1.1]"
|
|
style={{ fontFamily: "var(--font-caveat)" }}
|
|
>
|
|
{renderChildren(child.children, `${key}-q-${i}`)}
|
|
</p>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
default:
|
|
return renderChildren(node.children, key);
|
|
}
|
|
}
|
|
|
|
export function RichText({ content }: { content: unknown }) {
|
|
const root = (content as { root?: LexicalNode })?.root;
|
|
if (!root?.children) return null;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 w-full">
|
|
{renderChildren(root.children, "root")}
|
|
</div>
|
|
);
|
|
}
|