b88a8edd35
- RichText.tsx: Lexical's auto-detected "autolink" nodes (a typed-out email/URL, as opposed to an editor-inserted link) fell through to Payload's unstyled default converter — only "link" was overridden. AGB's Vertragspartner email rendered as plain black text because of this. Both node types now get the same text-brand/hover:underline treatment. - Breadcrumb color revert: the breadcrumb Startseite links across the 4 legal pages/shop should stay their original hover:text-brand treatment — only the actual content/contact links needed the always-brand-colored style, not page-level breadcrumbs. - /blog was the one page missing both a breadcrumb and the text-h-feature heading size every other section page (/shop, legal pages) already uses — added to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018unaXmuzVA8ct1b6WoyP1U
282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
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";
|
|
|
|
// 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;
|
|
tag?: string;
|
|
};
|
|
|
|
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.
|
|
// Exported — the Impressum page renders some of its own headings outside
|
|
// this CMS-driven richText (the "Angaben zum Anbieter"/"Umsatzsteuer"/
|
|
// "Verantwortlich für den Inhalt" sections come straight from
|
|
// company-settings, not the richText field, see app/impressum/page.tsx)
|
|
// and needs the exact same id-assignment logic so its SectionTOC entries
|
|
// actually match the ids those headings render with.
|
|
export 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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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
|
|
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)" }}
|
|
>
|
|
{nodesToJSX({ nodes: node.children })}
|
|
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
|
|
</Tag>
|
|
);
|
|
},
|
|
list: ({ node, nodesToJSX }) => {
|
|
const ListTag = node.listType === "number" ? "ol" : "ul";
|
|
return (
|
|
<ListTag
|
|
className={
|
|
"flex flex-col gap-2 text-body text-text-body " +
|
|
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
|
|
}
|
|
>
|
|
{nodesToJSX({ nodes: node.children })}
|
|
</ListTag>
|
|
);
|
|
},
|
|
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 auto-detects a typed-out URL/email as its own "autolink" node,
|
|
// distinct from an editor-inserted "link" node — falls through to
|
|
// Payload's unstyled default converter without this, which is why the
|
|
// Impressum/AGB's typed-in-place mailto addresses rendered as plain
|
|
// black text instead of matching every editor-inserted link.
|
|
autolink: ({ 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({
|
|
content,
|
|
quoteLabel = "Merke dir:",
|
|
}: {
|
|
content: unknown;
|
|
/** 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?: { children?: unknown[] } })?.root;
|
|
if (!root?.children) return null;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 w-full">
|
|
<LexicalRichText
|
|
data={content as Parameters<typeof LexicalRichText>[0]["data"]}
|
|
converters={buildConverters(quoteLabel)}
|
|
disableContainer
|
|
/>
|
|
</div>
|
|
);
|
|
}
|