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
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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
868
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user