refactor(all): migrate from React to Next.js
This commit is contained in:
156
src/app/blog/[slug]/page.tsx
Normal file
156
src/app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PublicApiError, getPublicPost } from "@/lib/public-api";
|
||||
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import { formatJalali } from "@/lib/utils";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
function cleanText(value?: string | null) {
|
||||
if (!value) return "";
|
||||
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
async function loadPost(slug: string) {
|
||||
try {
|
||||
return await getPublicPost(slug);
|
||||
} catch (error) {
|
||||
if (error instanceof PublicApiError && error.status === 404) {
|
||||
notFound();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await loadPost(slug);
|
||||
const description = cleanText(post.excerpt || post.content).slice(0, 160);
|
||||
const image = toAbsoluteUrl(
|
||||
post.absolute_featured_image_url || post.featured_image,
|
||||
apiBaseUrl,
|
||||
) ?? `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description,
|
||||
alternates: { canonical: `/blog/${post.slug}` },
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description,
|
||||
url: `${siteUrl}/blog/${post.slug}`,
|
||||
siteName: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
type: "article",
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
publishedTime: post.published_at || post.created_at,
|
||||
modifiedTime: post.updated_at,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description,
|
||||
images: [image],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = await loadPost(slug);
|
||||
const description = cleanText(post.excerpt || post.content).slice(0, 160);
|
||||
const image = toAbsoluteUrl(
|
||||
post.absolute_featured_image_url || post.featured_image,
|
||||
apiBaseUrl,
|
||||
) ?? `${siteUrl}/favicon.ico`;
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: post.title,
|
||||
description,
|
||||
image: [image],
|
||||
datePublished: post.published_at || post.created_at,
|
||||
dateModified: post.updated_at,
|
||||
url: `${siteUrl}/blog/${post.slug}`,
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: [post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username,
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: `${siteUrl}/favicon.ico`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/blog">بازگشت به وبلاگ</Link>
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatJalali(post.published_at || post.created_at, false)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{image && (
|
||||
<div className="w-full aspect-video overflow-hidden rounded-t-lg bg-muted">
|
||||
<img
|
||||
src={image}
|
||||
alt={post.title}
|
||||
className="h-full w-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.category?.name ? <Badge variant="secondary">{post.category.name}</Badge> : null}
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag.id} variant="outline">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<CardTitle className="text-3xl leading-relaxed">{post.title}</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
نویسنده: {[post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{post.excerpt ? (
|
||||
<p className="rounded-lg border bg-muted/30 p-4 text-sm leading-7 text-muted-foreground">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
<Markdown content={post.content} justify size="base" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user