blog editor

This commit is contained in:
2026-04-24 11:58:19 +02:00
parent daab745c13
commit be6df0c8ad
16 changed files with 1448 additions and 559 deletions

View File

@@ -2,6 +2,8 @@ import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { TRPCError } from "@trpc/server";
import { servTrpc } from "~/app/_trpc/ServerClient";
import { Badge } from "~/components/ui/badge";
import { mdxComponents } from "../_components/mdx-components";
type Props = {
params: Promise<{ slug: string }>;
@@ -31,9 +33,16 @@ export default async function BlogPostPage({ params }: Props) {
})}
</time>
)}
{post.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{post.tags.map((tag) => (
<Badge key={tag} variant="outline">{tag}</Badge>
))}
</div>
)}
</header>
<article className="prose dark:prose-invert max-w-none">
<MDXRemote source={post.content} />
<MDXRemote source={post.content} components={mdxComponents} />
</article>
</main>
);

View File

@@ -0,0 +1,128 @@
import Link from "next/link";
import { Children, isValidElement, type ComponentPropsWithoutRef, type ReactNode } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
type CalloutVariant = "note" | "tip" | "warning";
const calloutStyles: Record<CalloutVariant, string> = {
note: "border-sky-500/40 bg-sky-500/10 text-sky-950 dark:text-sky-100",
tip: "border-emerald-500/40 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100",
warning: "border-amber-500/40 bg-amber-500/10 text-amber-950 dark:text-amber-100",
};
function Callout({
title,
variant = "note",
children,
}: {
title?: string;
variant?: CalloutVariant;
children: ReactNode;
}) {
return (
<aside className={cn("my-6 rounded-md border px-4 py-3", calloutStyles[variant])}>
{title && <p className="mb-2 font-semibold">{title}</p>}
<div className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{children}</div>
</aside>
);
}
function Lead({ children }: { children: ReactNode }) {
return <span className="text-muted-foreground my-6 block text-lg leading-8">{children}</span>;
}
function TagList({ tags }: { tags: string[] }) {
return (
<div className="my-4 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
);
}
function ButtonLink({
href,
children,
variant = "default",
}: {
href: string;
children: ReactNode;
variant?: ComponentPropsWithoutRef<typeof Button>["variant"];
}) {
const isExternal = /^https?:\/\//.test(href);
return (
<Button asChild variant={variant}>
{isExternal ? (
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
) : (
<Link href={href}>{children}</Link>
)}
</Button>
);
}
function Figure({
src,
alt,
caption,
}: {
src: string;
alt: string;
caption?: string;
}) {
return (
<figure className="my-8">
<img src={src} alt={alt} className="w-full rounded-md border object-cover" />
{caption && <figcaption className="text-muted-foreground mt-2 text-center text-sm">{caption}</figcaption>}
</figure>
);
}
function PullQuote({ children }: { children: ReactNode }) {
return (
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
{children}
</blockquote>
);
}
function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
const href = props.href ?? "";
const isExternal = /^https?:\/\//.test(href);
if (!isExternal) return <a {...props} />;
return <a {...props} target="_blank" rel="noreferrer" />;
}
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]);
function Paragraph({ children }: { children: ReactNode }) {
const containsBlockComponent = Children.toArray(children).some(
(child) => isValidElement(child) && blockComponents.has(child.type),
);
if (containsBlockComponent) return <>{children}</>;
return <p>{children}</p>;
}
export const mdxComponents = {
a: ExternalLink,
p: Paragraph,
Badge,
ButtonLink,
Callout,
Figure,
Lead,
PullQuote,
TagList,
};

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import { servTrpc } from "~/app/_trpc/ServerClient";
import { Badge } from "~/components/ui/badge";
export default async function BlogPage() {
const posts = await servTrpc.blog.list();
@@ -27,6 +28,13 @@ export default async function BlogPage() {
{post.description && (
<p className="text-muted-foreground mt-1">{post.description}</p>
)}
{post.tags && post.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{post.tags.map((tag) => (
<Badge key={tag} variant="outline">{tag}</Badge>
))}
</div>
)}
</Link>
</li>
))}