feat: enhance blog post page with markdown rendering and related posts

This commit is contained in:
Mauricio Siu
2025-02-27 23:42:24 -06:00
parent 2e09e76e50
commit 8868d7f586
8 changed files with 330 additions and 52 deletions

View File

@@ -5,6 +5,11 @@ import { getTranslations } from "next-intl/server";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
type Props = {
params: { locale: string; slug: string };
@@ -52,6 +57,10 @@ export default async function BlogPostPage({ params }: Props) {
const { locale, slug } = params;
const t = await getTranslations({ locale, namespace: "blog" });
const post = await getPost(slug);
const allPosts = await getPosts();
// Get related posts (excluding current post)
const relatedPosts = allPosts.filter((p) => p.id !== post?.id).slice(0, 3); // Show only 3 related posts
if (!post) {
notFound();
@@ -63,6 +72,50 @@ export default async function BlogPostPage({ params }: Props) {
day: "numeric",
});
const components: Partial<Components> = {
h1: ({ node, ...props }) => (
<h1 className="text-3xl font-bold mt-8 mb-4" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="text-2xl font-bold mt-6 mb-3" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="text-xl font-bold mt-4 mb-2" {...props} />
),
p: ({ node, ...props }) => (
<p className="text-base leading-relaxed mb-4" {...props} />
),
a: ({ node, href, ...props }) => (
<a
href={href}
className="text-primary hover:text-primary/80 transition-colors"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul className="list-disc list-inside space-y-2 mb-4" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal list-inside space-y-2 mb-4" {...props} />
),
li: ({ node, ...props }) => (
<li className="text-base leading-relaxed" {...props} />
),
blockquote: ({ node, ...props }) => (
<blockquote
className="border-l-4 border-primary pl-4 py-2 my-4 bg-muted/50"
{...props}
/>
),
img: ({ node, src, alt }) => (
<div className="relative w-full h-64 my-6 rounded-lg overflow-hidden">
<Image src={src || ""} alt={alt || ""} fill className="object-cover" />
</div>
),
};
return (
<article className="container mx-auto px-4 py-12 max-w-4xl">
<Link
@@ -84,59 +137,115 @@ export default async function BlogPostPage({ params }: Props) {
{t("backToBlog")}
</Link>
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center mb-6">
{post.primary_author?.profile_image && (
<div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
<div className="bg-card rounded-lg p-8 shadow-lg border border-border">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center mb-6">
{post.primary_author?.profile_image && (
<div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
className="object-cover"
/>
</div>
)}
<div>
<p className="font-medium">
{post.primary_author?.name || "Unknown Author"}
</p>
<p className="text-sm text-muted-foreground">
{formattedDate} {post.reading_time} min read
</p>
</div>
</div>
{post.feature_image && (
<div className="relative h-96 w-full rounded-lg overflow-hidden mb-8">
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
src={post.feature_image}
alt={post.title}
fill
className="object-cover"
priority
/>
</div>
)}
<div>
<p className="font-medium">
{post.primary_author?.name || "Unknown Author"}
</p>
<p className="text-sm text-muted-foreground">
{formattedDate} {post.reading_time} min read
</p>
</div>
</header>
<div className="prose prose-lg max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={components}
>
{post.html}
</ReactMarkdown>
</div>
{post.feature_image && (
<div className="relative h-96 w-full rounded-lg overflow-hidden">
<Image
src={post.feature_image}
alt={post.title}
fill
className="object-cover"
priority
/>
{post.tags && post.tags.length > 0 && (
<div className="mt-12 pt-6 border-t border-border">
<h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag.id}
href={`/blog/tag/${tag.slug}`}
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
>
{tag.name}
</Link>
))}
</div>
</div>
)}
</header>
</div>
<div
className="prose prose-lg max-w-none prose-headings:text-foreground prose-a:text-primary hover:prose-a:text-primary/80"
dangerouslySetInnerHTML={{ __html: post.html }}
/>
{relatedPosts.length > 0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">{t("relatedPosts")}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{relatedPosts.map((relatedPost) => {
const relatedPostDate = new Date(
relatedPost.published_at,
).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
{post.tags && post.tags.length > 0 && (
<div className="mt-12 pt-6 border-t border-border">
<h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag.id}
href={`/blog/tag/${tag.slug}`}
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
>
{tag.name}
</Link>
))}
return (
<Link
key={relatedPost.id}
href={`/blog/${relatedPost.slug}`}
className="group"
>
<div className="bg-card rounded-lg overflow-hidden h-full shadow-lg transition-all duration-300 hover:shadow-xl border border-border">
{relatedPost.feature_image && (
<div className="relative h-48 w-full">
<Image
src={relatedPost.feature_image}
alt={relatedPost.title}
fill
className="object-cover"
/>
</div>
)}
<div className="p-6">
<h3 className="text-lg font-semibold mb-2 group-hover:text-primary transition-colors line-clamp-2">
{relatedPost.title}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{relatedPostDate} {relatedPost.reading_time} min read
</p>
<p className="text-muted-foreground line-clamp-2">
{relatedPost.excerpt}
</p>
</div>
</div>
</Link>
);
})}
</div>
</div>
)}

View File

@@ -80,11 +80,10 @@ export interface Post {
export async function getPosts(options = {}): Promise<Post[]> {
try {
const result = (await api.posts.browse({
include: "authors",
limit: "all",
include: ["tags", "authors"],
})) as Post[];
console.log(result);
console.log("Posts data from Ghost API:", JSON.stringify(result, null, 2));
return result;
} catch (error) {
console.error("Error fetching posts:", error);
@@ -94,7 +93,10 @@ export async function getPosts(options = {}): Promise<Post[]> {
export async function getPost(slug: string): Promise<Post | null> {
try {
const result = (await api.posts.read({ slug })) as Post;
const result = (await api.posts.read({
slug,
include: ["authors"],
})) as Post;
return result;
} catch (error) {
console.error("Error fetching post:", error);

View File

@@ -206,6 +206,7 @@
"postsTaggedWith": "Posts tagged with",
"foundPosts": "{count, plural, =0 {No posts found} one {# post found} other {# posts found}}",
"tagTitle": "Posts tagged with {tag}",
"tagDescription": "Browse all blog posts tagged with {tag}"
"tagDescription": "Browse all blog posts tagged with {tag}",
"relatedPosts": "Related Posts"
}
}

View File

@@ -205,6 +205,7 @@
"postsTaggedWith": "Articles tagués avec",
"foundPosts": "{count, plural, =0 {Aucun article trouvé} one {# article trouvé} other {# articles trouvés}}",
"tagTitle": "Articles tagués avec {tag}",
"tagDescription": "Parcourir tous les articles de blog tagués avec {tag}"
"tagDescription": "Parcourir tous les articles de blog tagués avec {tag}",
"relatedPosts": "Articles similaires"
}
}

View File

@@ -210,6 +210,7 @@
"postsTaggedWith": "标签为",
"foundPosts": "{count, plural, =0 {未找到文章} other {找到 # 篇文章}}",
"tagTitle": "标签为 {tag} 的文章",
"tagDescription": "浏览所有标签为 {tag} 的博客文章"
"tagDescription": "浏览所有标签为 {tag} 的博客文章",
"relatedPosts": "相关文章"
}
}

View File

@@ -14,6 +14,8 @@ const nextConfig = {
domains: [
"static.ghost.org",
"testing-ghost-8423be-31-220-108-27.traefik.me",
"images.unsplash.com",
"www.gravatar.com",
],
},
};