feat: Implement markdown rendering for blog posts with custom styling and syntax highlighting using react-markdown.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Moh Dzulfikri Maulana
2026-03-11 00:11:32 +07:00
parent 2144847feb
commit a5a526808b
3 changed files with 1063 additions and 27 deletions

View File

@@ -38,7 +38,10 @@
"react-dom": "19.2.4",
"react-hook-form": "^7.54.2",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"

868
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,166 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { format } from "date-fns";
import Link from "next/link";
import { ChevronLeft, Calendar, Clock, Tag as TagIcon, Share2 } from "lucide-react";
import {
ChevronLeft,
Calendar,
Clock,
Tag as TagIcon,
Share2,
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import type { Components } from "react-markdown";
export const dynamic = "force-dynamic";
export default async function BlogDetailPage({
params
}: {
params: Promise<{ slug: string }>
const markdownComponents: Components = {
h1: ({ children }) => (
<h1 className="text-3xl font-display font-bold text-foreground mt-10 mb-4 leading-tight">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-display font-bold text-foreground mt-8 mb-3 leading-tight">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-display font-semibold text-foreground mt-6 mb-2">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-display font-semibold text-foreground mt-5 mb-2">
{children}
</h4>
),
p: ({ children }) => (
<p className="text-foreground/85 leading-relaxed mb-5 font-serif text-[1.05rem]">
{children}
</p>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-4 decoration-primary/40 hover:decoration-primary transition-colors"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">{children}</strong>
),
em: ({ children }) => (
<em className="italic text-foreground/80">{children}</em>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/40 pl-5 py-1 my-6 bg-primary/5 rounded-r-xl text-foreground/70 italic">
{children}
</blockquote>
),
ul: ({ children }) => (
<ul className="list-disc list-outside ml-6 mb-5 space-y-1.5 text-foreground/85 font-serif text-[1.05rem]">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-outside ml-6 mb-5 space-y-1.5 text-foreground/85 font-serif text-[1.05rem]">
{children}
</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
hr: () => <hr className="border-border/50 my-10" />,
img: ({ src, alt }) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt ?? ""}
className="rounded-2xl border border-border/50 w-full my-6 object-cover"
/>
),
table: ({ children }) => (
<div className="overflow-x-auto my-6 rounded-2xl border border-border/50">
<table className="w-full text-sm text-foreground/85">{children}</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-surface-elevated/60 text-foreground font-semibold">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-border/30">{children}</tbody>
),
tr: ({ children }) => (
<tr className="hover:bg-surface-elevated/30 transition-colors">
{children}
</tr>
),
th: ({ children }) => (
<th className="text-left px-4 py-3 text-xs uppercase tracking-wider">
{children}
</th>
),
td: ({ children }) => <td className="px-4 py-3">{children}</td>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
code({ className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
if (isInline) {
return (
<code
className="px-1.5 py-0.5 rounded-md bg-primary/10 text-primary font-mono text-[0.88em] border border-primary/10"
{...props}
>
{children}
</code>
);
}
return (
<div className="my-6 rounded-2xl overflow-hidden border border-border/50 shadow-lg">
<div className="flex items-center justify-between px-4 py-2 bg-surface-elevated/80 border-b border-border/50">
<span className="text-xs font-mono text-foreground-muted uppercase tracking-widest">
{match[1]}
</span>
<div className="flex gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500/60" />
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500/60" />
<span className="w-2.5 h-2.5 rounded-full bg-green-500/60" />
</div>
</div>
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
borderRadius: 0,
background: "transparent",
padding: "1.25rem 1.5rem",
fontSize: "0.875rem",
lineHeight: "1.7",
}}
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
</div>
);
},
};
export default async function BlogDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const blog = await prisma.blog.findUnique({
@@ -23,7 +175,7 @@ export default async function BlogDetailPage({
return (
<article className="min-h-screen pt-32 pb-20 bg-background overflow-hidden relative">
{/* Decorative Background Elements */}
{/* Decorative Background Elements */}
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/3 rounded-full blur-[120px] -z-10" />
<div className="max-w-3xl mx-auto px-6">
@@ -31,19 +183,23 @@ export default async function BlogDetailPage({
href="/blog"
className="inline-flex items-center gap-2 text-sm text-foreground-muted hover:text-primary mb-8 transition-colors group"
>
<ChevronLeft size={16} className="group-hover:-translate-x-1 transition-transform" />
<ChevronLeft
size={16}
className="group-hover:-translate-x-1 transition-transform"
/>
Back to all posts
</Link>
<div className="space-y-6 mb-12">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50">
<div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50">
<Calendar size={12} className="text-primary/60" />
{blog.publishedAt ? format(new Date(blog.publishedAt), "MMMM dd, yyyy") : "Published"}
{blog.publishedAt
? format(new Date(blog.publishedAt), "MMMM dd, yyyy")
: "Published"}
</div>
<div className="flex items-center gap-1.5 text-xs text-foreground-muted bg-surface-elevated/50 px-3 py-1.5 rounded-full border border-border/50">
<Clock size={12} className="text-primary/60" />
6 min read
<Clock size={12} className="text-primary/60" />6 min read
</div>
</div>
@@ -52,9 +208,12 @@ export default async function BlogDetailPage({
</h1>
<div className="flex items-center justify-between py-6 border-y border-border/50">
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2">
{blog.tags.map((tag) => (
<span key={tag.id} className="text-[10px] font-bold uppercase tracking-widest text-primary bg-primary/5 px-2.5 py-1 rounded-md border border-primary/10">
<span
key={tag.id}
className="text-[10px] font-bold uppercase tracking-widest text-primary bg-primary/5 px-2.5 py-1 rounded-md border border-primary/10"
>
{tag.name}
</span>
))}
@@ -65,25 +224,31 @@ export default async function BlogDetailPage({
</div>
</div>
{/* Content Section */}
<div className="prose prose-slate dark:prose-invert max-w-none prose-pre:bg-surface prose-pre:border prose-pre:border-border/50 prose-pre:rounded-2xl prose-code:text-primary prose-a:text-primary hover:prose-a:underline font-serif text-lg leading-relaxed text-foreground/90">
{/* Using a simple div for content here. In a real app we'd use a markdown renderer like react-markdown */}
<div className="whitespace-pre-wrap">
{/* Markdown Content Section */}
<div className="min-w-0">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={markdownComponents}
>
{blog.content}
</div>
</ReactMarkdown>
</div>
{/* Author Footer */}
<div className="mt-20 p-8 bg-surface/50 border border-border/50 rounded-3xl flex items-center gap-6">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0 border border-primary/20">
<TagIcon size={24} className="text-primary" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">Written by Fikri</p>
<p className="text-xs text-foreground-muted mt-1 leading-relaxed">
Fullstack engineer passionate about building beautiful, functional, and user-centric web applications.
</p>
</div>
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0 border border-primary/20">
<TagIcon size={24} className="text-primary" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">
Written by Fikri
</p>
<p className="text-xs text-foreground-muted mt-1 leading-relaxed">
Fullstack engineer passionate about building beautiful,
functional, and user-centric web applications.
</p>
</div>
</div>
</div>
</article>