blog editor
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
128
src/app/blog/_components/mdx-components.tsx
Normal file
128
src/app/blog/_components/mdx-components.tsx
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user