Custom post content blocks (images/gallery/video/quote) + backend-driven SEO settings
RichText.tsx switched to Payload's official React renderer + custom JSXConverters (same call signature, LiveRichText/LivePostContent untouched) — needed to render the new Lexical Blocks the Payload repo's Posts.content just gained. Converters follow the existing CMS-image convention (relative + aspect-[...] + fill + object-cover); the video block resolves YouTube/Vimeo links to an iframe embed. New getSeoSettings() fetcher (same pattern as getKleinunternehmer()), app/layout.tsx now generateMetadata() reading it with the same fallback values it used to hardcode. Per-post SEO overrides (seoTitle/ seoDescription/seoImage) wired into the blog detail page's metadata, falling back to title/excerpt/thumbnail when empty. Also fixed while auditing every page's metadata: missing descriptions on 3 konto pages, a static title on the dynamic order-detail route, and missing OG images on /shop and /blog.
This commit is contained in:
+179
-110
@@ -1,31 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import { RichText as LexicalRichText, type JSXConvertersFunction } from "@payloadcms/richtext-lexical/react";
|
||||
import type { TOCSection } from "./SectionTOC";
|
||||
import { QuoteLabel } from "./QuoteLabel";
|
||||
|
||||
// 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.
|
||||
// Switched 2026-07-24 from a small hand-rolled Lexical JSON->JSX walker to
|
||||
// Payload's own official React renderer + custom JSXConverters — needed
|
||||
// once Posts.content gained custom Lexical Blocks (Bild/Bildergalerie/
|
||||
// Video/Zitat, see payload/src/collections/Posts.ts), which the old
|
||||
// hand-rolled switch had no case for at all. extractHeadings()/headingId()
|
||||
// below are kept as an independent, minimal walk over the raw JSON (same
|
||||
// as before) — they only ever need to find h2 headings for SectionTOC and
|
||||
// never touch Blocks, no reason to route that through the new renderer too.
|
||||
|
||||
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("");
|
||||
@@ -70,114 +63,185 @@ export function extractHeadings(content: unknown): TOCSection[] {
|
||||
return headings;
|
||||
}
|
||||
|
||||
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string, quoteLabel: string): ReactNode {
|
||||
if (!nodes) return null;
|
||||
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`, quoteLabel));
|
||||
// Payload upload relations resolve to the full media doc when fetched at
|
||||
// sufficient depth (every richText-consuming fetch in app/lib/payload.ts
|
||||
// already uses depth >= 2), or fall back to a bare id if not — only
|
||||
// render when actually populated.
|
||||
type MediaRef = { url?: string | null } | number | null | undefined;
|
||||
function mediaUrl(ref: MediaRef): string | null {
|
||||
if (ref && typeof ref === "object" && typeof ref.url === "string") return ref.url;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderNode(node: LexicalNode, key: string, quoteLabel: 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, quoteLabel)}
|
||||
</a>
|
||||
);
|
||||
case "heading": {
|
||||
const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
const text = plainText(node);
|
||||
// Naive YouTube/Vimeo URL -> embed URL. Not exhaustive (no playlist/short-
|
||||
// link edge cases) — good enough for a "paste a link" editor field; a
|
||||
// URL that doesn't match either pattern just doesn't render rather than
|
||||
// guessing wrong.
|
||||
function toEmbedUrl(url: string): string | null {
|
||||
const youtube = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{6,})/);
|
||||
if (youtube) return `https://www.youtube.com/embed/${youtube[1]}`;
|
||||
const vimeo = url.match(/vimeo\.com\/(\d+)/);
|
||||
if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
type ImageBlockFields = { image: MediaRef; caption?: string | null };
|
||||
type ImageGalleryBlockFields = { images: { image: MediaRef; caption?: string | null }[] };
|
||||
type VideoEmbedBlockFields = { url: string; caption?: string | null };
|
||||
type QuoteBlockFields = { text: string; label?: string | null };
|
||||
|
||||
function BlockCaption({ caption }: { caption?: string | null }) {
|
||||
if (!caption) return null;
|
||||
return <p className="text-body-sm text-text-muted text-center">{caption}</p>;
|
||||
}
|
||||
|
||||
// Same visual treatment as the QuoteBlock converter below (and the native
|
||||
// blockquote case it replaces going forward) — see that converter's own
|
||||
// comment for why both still exist.
|
||||
function Quote({ label, children }: { label?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex items-start gap-6 w-full my-6">
|
||||
{/* Label/icon/underline are optional — if empty, only the divider +
|
||||
quote text render. The quote itself is never optional, just this
|
||||
framing around it. */}
|
||||
{label && <QuoteLabel label={label} />}
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
<p
|
||||
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A factory, not a module-level constant — needs to close over each
|
||||
// call's own `quoteLabel` (the native "quote" converter reads it). Server
|
||||
// Components can render multiple posts concurrently in the same process,
|
||||
// so a shared module-level variable set right before rendering would be
|
||||
// a real race condition, not just a style choice.
|
||||
function buildConverters(quoteLabel: string): JSXConvertersFunction {
|
||||
return ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
paragraph: ({ node, nodesToJSX }) => (
|
||||
<p className="text-body text-text-body">{nodesToJSX({ nodes: node.children })}</p>
|
||||
),
|
||||
heading: ({ node, nodesToJSX }) => {
|
||||
const Tag = node.tag as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
const text = plainText(node as unknown as LexicalNode);
|
||||
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, quoteLabel)}
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case "list": {
|
||||
},
|
||||
list: ({ node, nodesToJSX }) => {
|
||||
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, quoteLabel)}
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</ListTag>
|
||||
);
|
||||
}
|
||||
case "listitem":
|
||||
return (
|
||||
<li key={key}>{renderChildren(node.children, key, quoteLabel)}</li>
|
||||
);
|
||||
case "paragraph":
|
||||
return (
|
||||
<p key={key} className="text-body text-text-body">
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
</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">
|
||||
{/* Label/icon/underline are optional (Posts.quoteLabel) — if
|
||||
empty, only the divider + quote text render. The blockquote
|
||||
itself is never optional, just this framing around it. */}
|
||||
{quoteLabel && <QuoteLabel label={quoteLabel} />}
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
{/* Lexical's real QuoteNode holds flat text/linebreak children
|
||||
directly, NOT nested paragraphs — pressing Enter inside a
|
||||
blockquote in the editor exits it into a new paragraph
|
||||
rather than adding a line within it (confirmed by reading
|
||||
@lexical/rich-text's QuoteNode.insertNewAfter). An earlier
|
||||
version of this case assumed nested-paragraph children,
|
||||
which only happened to work for this session's own
|
||||
hand-authored seed JSON — any blockquote actually typed in
|
||||
the CMS (Shift+Enter for a soft line break) rendered blank,
|
||||
since child.children was undefined on a plain text node. */}
|
||||
<p
|
||||
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{renderChildren(node.children, key, quoteLabel)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return renderChildren(node.children, key, quoteLabel);
|
||||
}
|
||||
},
|
||||
listitem: ({ node, nodesToJSX }) => <li>{nodesToJSX({ nodes: node.children })}</li>,
|
||||
link: ({ node, nodesToJSX }) => (
|
||||
<a href={node.fields?.url ?? "#"} className="text-brand hover:underline">
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</a>
|
||||
),
|
||||
// Lexical's native blockquote feature — used by every post written
|
||||
// before Blocks existed. Kept working exactly as before (own comment on
|
||||
// Posts.ts's `content` field editor config on why this stays enabled
|
||||
// alongside the new QuoteBlock) rather than migrating old content.
|
||||
quote: ({ node, nodesToJSX }) => (
|
||||
<Quote label={quoteLabel}>{nodesToJSX({ nodes: node.children })}</Quote>
|
||||
),
|
||||
blocks: {
|
||||
image: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ImageBlockFields;
|
||||
const url = mediaUrl(fields.image);
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-[3/2] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image alt="" src={url} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-cover" />
|
||||
</div>
|
||||
<BlockCaption caption={fields.caption} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
imageGallery: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as ImageGalleryBlockFields;
|
||||
const images = (fields.images ?? []).filter((row) => mediaUrl(row.image));
|
||||
if (images.length === 0) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 w-full">
|
||||
{images.map((row, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<div className="relative aspect-[4/3] rounded-md overflow-hidden bg-bg-muted">
|
||||
<Image
|
||||
alt=""
|
||||
src={mediaUrl(row.image)!}
|
||||
fill
|
||||
sizes="(min-width: 768px) 24rem, 50vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<BlockCaption caption={row.caption} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
videoEmbed: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as VideoEmbedBlockFields;
|
||||
const embedUrl = toEmbedUrl(fields.url);
|
||||
if (!embedUrl) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full aspect-video rounded-md overflow-hidden bg-bg-muted">
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={fields.caption ?? "Video"}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<BlockCaption caption={fields.caption} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Per-quote label, unlike Posts.quoteLabel above (one label shared by
|
||||
// every native blockquote in the post) — new quotes going forward use
|
||||
// this instead of the native blockquote feature.
|
||||
quote: ({ node }: { node: { fields: unknown } }) => {
|
||||
const fields = node.fields as QuoteBlockFields;
|
||||
const lines = fields.text.split("\n");
|
||||
return (
|
||||
<Quote label={fields.label ?? undefined}>
|
||||
{lines.map((line, i) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</Quote>
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
@@ -185,18 +249,23 @@ export function RichText({
|
||||
quoteLabel = "Merke dir:",
|
||||
}: {
|
||||
content: unknown;
|
||||
/** Label for any blockquote's callout (see the "quote" case above) —
|
||||
* defaults to "Merke dir:" for callers that don't pass one (legal pages
|
||||
* never use blockquotes, so this only actually matters for blog posts).
|
||||
* Pass "" to hide the label/icon/underline for every blockquote here. */
|
||||
/** Label for any native blockquote's callout — defaults to "Merke dir:"
|
||||
* for callers that don't pass one (legal pages never use blockquotes,
|
||||
* so this only actually matters for blog posts). Pass "" to hide the
|
||||
* label/icon/underline for every native blockquote here. New content
|
||||
* should use the Zitat block instead, which carries its own label. */
|
||||
quoteLabel?: string;
|
||||
}) {
|
||||
const root = (content as { root?: LexicalNode })?.root;
|
||||
const root = (content as { root?: { children?: unknown[] } })?.root;
|
||||
if (!root?.children) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{renderChildren(root.children, "root", quoteLabel)}
|
||||
<LexicalRichText
|
||||
data={content as Parameters<typeof LexicalRichText>[0]["data"]}
|
||||
converters={buildConverters(quoteLabel)}
|
||||
disableContainer
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user