Skip to content

Commit 58cecac

Browse files
committed
feat(posts, tools): integrate MDX support for post content and add code block component
1 parent 53ace97 commit 58cecac

File tree

5 files changed

+130
-26
lines changed

5 files changed

+130
-26
lines changed

src/app/(public)/posts/[slug]/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from "next";
22

33
import { notFound } from "next/navigation";
4+
import { bundleMDX } from "mdx-bundler";
45
import { PostDetail } from "@/components/posts";
56
import { getPostBySlug } from "@/actions/posts";
67
import { PROJECT_NAME } from "@/config/constants";
@@ -46,7 +47,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
4647

4748
export default async function PostDetailPage({ params }: Props) {
4849
const { slug } = await params;
50+
4951
const post = await getPostBySlug(slug);
5052
if (!post) return notFound();
51-
return <PostDetail post={post} />;
53+
54+
const { code } = await bundleMDX({ source: post.content });
55+
return <PostDetail post={{ ...post, content: code }} />;
5256
}

src/components/posts/post-detail.tsx

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,20 @@
22

33
import type { Post } from "@/types";
44

5+
import { formatDistanceToNow } from "date-fns";
56
import { Image } from "@heroui/react";
6-
import dynamic from "next/dynamic";
7-
8-
const MarkdownPreview = dynamic(
9-
() => import("@uiw/react-markdown-preview").then((mod) => mod.default),
10-
{ ssr: false },
11-
);
7+
import { MDXContent } from "@/components/tools";
128

139
type Props = { post: Post };
1410

1511
export default function PostDetail({ post }: Props) {
1612
return (
17-
<article className="max-w-3xl mx-auto py-12 px-4 space-y-8">
18-
<header className="space-y-4">
19-
<h1 className="text-4xl font-bold leading-tight">{post.name}</h1>
20-
<div className="flex items-center gap-3 text-sm text-gray-500">
13+
<article className="max-w-3xl mx-auto py-4 px-4 space-y-8">
14+
<header className="flex flex-col gap-4">
15+
<h1 className="text-4xl font-bold leading-tight text-neutral-950 dark:text-white text-center">
16+
{post.name}
17+
</h1>
18+
<div className="flex justify-center items-center gap-3 text-sm text-gray-500">
2119
{post.author.avatar_url && (
2220
<Image
2321
isBlurred
@@ -26,32 +24,35 @@ export default function PostDetail({ post }: Props) {
2624
width={18}
2725
height={18}
2826
radius="full"
27+
className="border-2 border-neutral-200 dark:border-neutral-800"
2928
/>
3029
)}
31-
<span>
32-
By <strong>{post.author.full_name || post.author.username}</strong>
30+
<span className="hover:underline">
31+
{post.author.full_name || post.author.username}
3332
</span>
3433
<span></span>
3534
<time dateTime={post.created_at}>
36-
{new Date(post.created_at).toLocaleDateString()}
35+
{formatDistanceToNow(new Date(post.created_at), {
36+
addSuffix: true,
37+
})}
3738
</time>
3839
</div>
39-
4040
{post.cover_url && (
41-
<Image
42-
src={post.cover_url}
43-
alt={post.name}
44-
width={400}
45-
height={200}
46-
radius="lg"
47-
/>
41+
<figure className="h-60 border-2 border-neutral-200 dark:border-neutral-800 overflow-hidden rounded-xl">
42+
<Image
43+
src={post.cover_url}
44+
alt={post.name}
45+
radius="none"
46+
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
47+
className="object-cover w-full h-full"
48+
/>
49+
</figure>
4850
)}
4951
</header>
5052

51-
<MarkdownPreview
52-
source={post.content}
53-
className="prose max-w-none dark:prose-invert"
54-
/>
53+
<div className="prose dark:prose-invert break-words mx-auto sm:pb-56">
54+
<MDXContent code={post.content} />
55+
</div>
5556
</article>
5657
);
5758
}

src/components/tools/code-block.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { Button } from "@heroui/react";
5+
import { Check, Copy } from "lucide-react";
6+
7+
type ReactNodeWithProps = {
8+
props: {
9+
children?: React.ReactNode;
10+
[key: string]: unknown;
11+
};
12+
};
13+
14+
export default function CodeBlock({
15+
children,
16+
language,
17+
}: {
18+
children: React.ReactNode;
19+
language?: string;
20+
}) {
21+
const [copied, setCopied] = useState<boolean>(false);
22+
23+
const extractText = (node: React.ReactNode): string => {
24+
if (typeof node === "string" || typeof node === "number") {
25+
return node.toString();
26+
}
27+
if (Array.isArray(node)) {
28+
return node.map(extractText).join("");
29+
}
30+
if (typeof node === "object" && node !== null && "props" in node) {
31+
const nodeWithProps = node as ReactNodeWithProps;
32+
return extractText(nodeWithProps.props.children);
33+
}
34+
return "";
35+
};
36+
37+
const code = extractText(children);
38+
39+
const handleCopy = async () => {
40+
await navigator.clipboard.writeText(code);
41+
setCopied(true);
42+
setTimeout(() => setCopied(false), 2000);
43+
};
44+
45+
return (
46+
<div className="relative group">
47+
{language && (
48+
<Button
49+
isIconOnly
50+
size="sm"
51+
onPress={handleCopy}
52+
className="absolute bg-neutral-200 dark:bg-neutral-800 top-2 right-2 text-red-neutral-950 dark:text-white transition px-0.5 py-0.5"
53+
>
54+
{copied ? <Check size={10} /> : <Copy size={10} />}
55+
</Button>
56+
)}
57+
<pre>{children}</pre>
58+
</div>
59+
);
60+
}

src/components/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as CodeBlock } from "./code-block";
2+
export { default as MDXContent } from "./mdx-content";

src/components/tools/mdx-content.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"use client";
2+
3+
import type { MDXComponents } from "mdx/types";
4+
5+
import { useMemo } from "react";
6+
import Link from "next/link";
7+
import { getMDXComponent } from "mdx-bundler/client";
8+
import { CodeBlock } from "@/components/tools";
9+
10+
const mdxComponents: MDXComponents = {
11+
a: ({ href = "", children, ...props }) =>
12+
href.startsWith("/") || href.startsWith("#") ? (
13+
<Link href={href} {...props}>
14+
{children}
15+
</Link>
16+
) : (
17+
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
18+
{children}
19+
</a>
20+
),
21+
pre: ({ children }) => {
22+
const className = children.props?.className || "";
23+
const language = className.replace("language-", "");
24+
return <CodeBlock language={language}>{children}</CodeBlock>;
25+
},
26+
};
27+
28+
export default function MDXContent({
29+
code,
30+
components = mdxComponents,
31+
}: {
32+
code: string;
33+
components?: MDXComponents;
34+
}) {
35+
const Component = useMemo(() => getMDXComponent(code), [code]);
36+
return <Component components={components} />;
37+
}

0 commit comments

Comments
 (0)