migrate to Next.js
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-20 09:46:17 +03:30
parent dacbd3a328
commit 42f2087b7c
86 changed files with 2831 additions and 2679 deletions

View 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>
);
}