mirror of
https://github.com/Dokploy/website
synced 2025-06-26 18:16:01 +00:00
feat: enhance blog post page with markdown rendering and related posts
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"postsTaggedWith": "标签为",
|
||||
"foundPosts": "{count, plural, =0 {未找到文章} other {找到 # 篇文章}}",
|
||||
"tagTitle": "标签为 {tag} 的文章",
|
||||
"tagDescription": "浏览所有标签为 {tag} 的博客文章"
|
||||
"tagDescription": "浏览所有标签为 {tag} 的博客文章",
|
||||
"relatedPosts": "相关文章"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user