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-dom": "19.2.4",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.24.1"
|
"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 { notFound } from "next/navigation";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import Link from "next/link";
|
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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function BlogDetailPage({
|
const markdownComponents: Components = {
|
||||||
params
|
h1: ({ children }) => (
|
||||||
}: {
|
<h1 className="text-3xl font-display font-bold text-foreground mt-10 mb-4 leading-tight">
|
||||||
params: Promise<{ slug: string }>
|
{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 { slug } = await params;
|
||||||
const blog = await prisma.blog.findUnique({
|
const blog = await prisma.blog.findUnique({
|
||||||
@@ -23,7 +175,7 @@ export default async function BlogDetailPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="min-h-screen pt-32 pb-20 bg-background overflow-hidden relative">
|
<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="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">
|
<div className="max-w-3xl mx-auto px-6">
|
||||||
@@ -31,19 +183,23 @@ export default async function BlogDetailPage({
|
|||||||
href="/blog"
|
href="/blog"
|
||||||
className="inline-flex items-center gap-2 text-sm text-foreground-muted hover:text-primary mb-8 transition-colors group"
|
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
|
Back to all posts
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="space-y-6 mb-12">
|
<div className="space-y-6 mb-12">
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<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" />
|
<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>
|
||||||
<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">
|
||||||
<Clock size={12} className="text-primary/60" />
|
<Clock size={12} className="text-primary/60" />6 min read
|
||||||
6 min read
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,9 +208,12 @@ export default async function BlogDetailPage({
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-6 border-y border-border/50">
|
<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) => (
|
{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}
|
{tag.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -65,25 +224,31 @@ export default async function BlogDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Section */}
|
{/* Markdown 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">
|
<div className="min-w-0">
|
||||||
{/* Using a simple div for content here. In a real app we'd use a markdown renderer like react-markdown */}
|
<ReactMarkdown
|
||||||
<div className="whitespace-pre-wrap">
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw]}
|
||||||
|
components={markdownComponents}
|
||||||
|
>
|
||||||
{blog.content}
|
{blog.content}
|
||||||
</div>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Author Footer */}
|
{/* Author Footer */}
|
||||||
<div className="mt-20 p-8 bg-surface/50 border border-border/50 rounded-3xl flex items-center gap-6">
|
<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">
|
<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" />
|
<TagIcon size={24} className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-foreground">Written by Fikri</p>
|
<p className="text-sm font-semibold text-foreground">
|
||||||
<p className="text-xs text-foreground-muted mt-1 leading-relaxed">
|
Written by Fikri
|
||||||
Fullstack engineer passionate about building beautiful, functional, and user-centric web applications.
|
</p>
|
||||||
</p>
|
<p className="text-xs text-foreground-muted mt-1 leading-relaxed">
|
||||||
</div>
|
Fullstack engineer passionate about building beautiful,
|
||||||
|
functional, and user-centric web applications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
Reference in New Issue
Block a user