Files

309 lines
11 KiB
TypeScript

import { notFound } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { marked } from "marked";
import { apiFetch } from "@/lib/api";
import { BlogPost, PaginatedResult, TagCloudItem } from "@/lib/types";
import { ViewTracker } from "./view-tracker";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Eye, TrendingUp, ArrowLeft, Calendar } from "lucide-react";
import { Button } from "@/components/ui/button";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
try {
const { post } = await apiFetch<{ post: BlogPost }>(
`/blog-posts/public/${slug}`
);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: post.featuredImageUrl ? [post.featuredImageUrl] : [],
},
};
} catch {
return { title: "Post not found" };
}
}
async function getPost(slug: string): Promise<BlogPost | null> {
try {
const data = await apiFetch<{ success: boolean; post: BlogPost }>(
`/blog-posts/public/${slug}`
);
return data.post;
} catch {
return null;
}
}
async function getRelatedPosts(post: BlogPost): Promise<BlogPost[]> {
try {
// Get posts with shared category
const cat = post.categories[0];
if (cat) {
const result = await apiFetch<PaginatedResult<BlogPost>>(
`/blog-posts/public?category=${encodeURIComponent(cat)}&pageSize=4`
);
const filtered = result.items.filter((p) => p.id !== post.id);
if (filtered.length > 0) return filtered.slice(0, 4);
}
// Fallback: latest
const result = await apiFetch<PaginatedResult<BlogPost>>(
"/blog-posts/public?pageSize=4"
);
return result.items.filter((p) => p.id !== post.id).slice(0, 4);
} catch {
return [];
}
}
async function getPopularPosts(excludeId: string): Promise<BlogPost[]> {
try {
const result = await apiFetch<PaginatedResult<BlogPost>>(
"/blog-posts/public?sort=most_viewed&pageSize=6"
);
return result.items.filter((p) => p.id !== excludeId).slice(0, 5);
} catch {
return [];
}
}
async function getTopTags(): Promise<TagCloudItem[]> {
try {
const result = await apiFetch<PaginatedResult<BlogPost>>(
"/blog-posts/public?pageSize=100"
);
const counts: Record<string, number> = {};
for (const post of result.items) {
for (const tag of post.tags || []) {
const t = tag.trim();
if (t) counts[t] = (counts[t] || 0) + 1;
}
}
return Object.entries(counts)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20);
} catch {
return [];
}
}
export default async function BlogDetailPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return notFound();
const [relatedPosts, popularPosts, topTags] = await Promise.all([
getRelatedPosts(post),
getPopularPosts(post.id),
getTopTags(),
]);
const contentHtml =
post.contentFormat === "markdown"
? await marked(post.content)
: post.content;
const publishedDate = new Date(post.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="space-y-6">
<ViewTracker slug={slug} />
{/* ── Back button ────────────────────────────────────────────────────── */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
<Link href="/">
<ArrowLeft className="h-4 w-4 mr-1" /> Back to Blog
</Link>
</Button>
{/* ── Article ────────────────────────────────────────────────────────── */}
<article className="rounded-2xl border bg-card p-5 shadow-sm">
{/* Categories */}
{post.categories.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{post.categories.map((cat) => (
<Badge key={cat} variant="secondary" className="uppercase tracking-wide text-xs">
{cat}
</Badge>
))}
</div>
)}
<h1 className="text-3xl font-bold leading-tight">{post.title}</h1>
{post.excerpt && (
<p className="mt-3 text-lg text-muted-foreground">{post.excerpt}</p>
)}
{/* Meta */}
<div className="mt-3 flex flex-wrap gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> {publishedDate}
</span>
<span className="flex items-center gap-1">
<Eye className="h-3 w-3" /> {post.views.toLocaleString()} views
</span>
{post.author && (
<span>by {post.author.name || post.author.email}</span>
)}
</div>
{/* Featured image */}
{post.featuredImageUrl && (
<div className="mt-5 relative h-64 md:h-96 w-full overflow-hidden rounded-xl">
<Image
src={post.featuredImageUrl}
alt={post.featuredImageAlt || post.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 800px"
priority
/>
</div>
)}
{/* Tags */}
{post.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link key={tag} href={`/?tags=${encodeURIComponent(tag)}`}>
<Badge variant="outline" className="cursor-pointer hover:bg-muted">
#{tag}
</Badge>
</Link>
))}
</div>
)}
<Separator className="my-6" />
{/* Content */}
<div
className="prose prose-zinc dark:prose-invert max-w-none
prose-headings:font-bold prose-headings:tracking-tight
prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-muted prose-pre:border prose-pre:rounded-lg
prose-blockquote:border-l-4 prose-blockquote:border-muted-foreground/30
prose-img:rounded-xl prose-img:border"
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
</article>
{/* ── Related + Sidebar ──────────────────────────────────────────────── */}
<div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
{/* Related posts */}
<section className="rounded-2xl border bg-card p-5">
<h2 className="mb-4 text-xl font-bold">More from the blog</h2>
{relatedPosts.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{relatedPosts.map((related) => (
<article
key={related.id}
className="rounded-xl border bg-muted/30 p-4"
>
{related.categories.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{related.categories.slice(0, 2).map((cat) => (
<Badge
key={cat}
variant="secondary"
className="text-xs uppercase"
>
{cat}
</Badge>
))}
</div>
)}
<Link href={`/blog/${related.slug}`} className="group">
<h3 className="font-semibold leading-tight group-hover:text-primary transition-colors line-clamp-2">
{related.title}
</h3>
</Link>
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{related.excerpt}
</p>
<p className="mt-1 text-xs text-muted-foreground flex items-center gap-1">
<Eye className="h-3 w-3" /> {related.views.toLocaleString()}
</p>
</article>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No related posts yet.
</p>
)}
</section>
{/* Sidebar */}
<aside className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4" /> Popular Posts
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{popularPosts.map((p) => (
<Link
key={p.slug}
href={`/blog/${p.slug}`}
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm font-medium hover:bg-muted transition-colors"
>
<span className="line-clamp-1 mr-2">{p.title}</span>
<span className="text-muted-foreground shrink-0 flex items-center gap-1">
<Eye className="h-3 w-3" />
{p.views.toLocaleString()}
</span>
</Link>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Tags</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{topTags.map((tag) => (
<Link
key={tag.name}
href={`/?tags=${encodeURIComponent(tag.name)}`}
>
<Badge
variant="outline"
className="cursor-pointer hover:bg-muted transition-colors"
>
#{tag.name}
</Badge>
</Link>
))}
</div>
</CardContent>
</Card>
</aside>
</div>
</div>
);
}