initial commit
This commit is contained in:
289
app/topics/[slug]/page.tsx
Normal file
289
app/topics/[slug]/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { ArrowLeft, Eye, Clock } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { ITopic } from '@/models/topic'
|
||||
import { Metadata } from 'next'
|
||||
import { Header } from '@/components/header'
|
||||
import { Footer } from '@/components/footer'
|
||||
import { TopicViewTracker } from '@/components/topics/TopicClientComponents'
|
||||
import { TopicEditButton } from '@/components/topics/TopicEditButton'
|
||||
import { SimpleShareButtons } from '@/components/topics/SimpleShareButtons'
|
||||
|
||||
// Force dynamic rendering for real-time updates
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Server-side data fetching
|
||||
async function getTopicPost(slug: string): Promise<ITopic | null> {
|
||||
try {
|
||||
const baseUrl =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? `${process.env.NEXTAUTH_URL || 'https://siliconpin.com'}`
|
||||
: 'http://localhost:4023'
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/topics/${encodeURIComponent(slug)}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
throw new Error(`Failed to fetch topic post: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error?.code === 'POST_NOT_FOUND') {
|
||||
return null
|
||||
}
|
||||
throw new Error(result.error?.message || 'Failed to fetch topic post')
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching topic post:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getRelatedPosts(slug: string, limit: number = 2): Promise<ITopic[]> {
|
||||
try {
|
||||
const baseUrl =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? `${process.env.NEXTAUTH_URL || 'https://siliconpin.com'}`
|
||||
: 'http://localhost:4023'
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/topics/${encodeURIComponent(slug)}/related?limit=${limit}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch related posts: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error?.message || 'Failed to fetch related posts')
|
||||
}
|
||||
|
||||
return result.data || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching related posts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Generate metadata for the page
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const post = await getTopicPost(slug)
|
||||
|
||||
if (!post) {
|
||||
return {
|
||||
title: 'Topic Not Found - SiliconPin',
|
||||
description: 'The requested topic could not be found.',
|
||||
}
|
||||
}
|
||||
|
||||
const keywords = post.tags.map((tag) => tag.name).join(', ')
|
||||
|
||||
return {
|
||||
title: `${post.title} - SiliconPin Topics`,
|
||||
description: post.excerpt,
|
||||
keywords: keywords,
|
||||
authors: [{ name: post.author }],
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
type: 'article',
|
||||
publishedTime: new Date(post.publishedAt).toISOString(),
|
||||
authors: [post.author],
|
||||
images: post.coverImage ? [post.coverImage] : undefined,
|
||||
tags: post.tags.map((tag) => tag.name),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
images: post.coverImage ? [post.coverImage] : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function TopicPostPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const post = await getTopicPost(slug)
|
||||
|
||||
if (!post) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const relatedPosts = await getRelatedPosts(slug)
|
||||
|
||||
const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
{/* View tracking only - no visual component */}
|
||||
<TopicViewTracker topicSlug={post.slug} isDraft={post.isDraft || false} />
|
||||
|
||||
<article className="pt-20 pb-12">
|
||||
<div className="container-custom max-w-4xl">
|
||||
{/* Back to topics and Edit button */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="pl-0 transition-all duration-200 hover:pl-2">
|
||||
<Link href="/topics" className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to all topics
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<TopicEditButton topicId={post.id} authorId={post.authorId} />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-10">
|
||||
<h1 className="mb-6 text-4xl font-bold md:text-5xl">{post.title}</h1>
|
||||
|
||||
<div className="mb-8 flex flex-wrap items-center gap-x-6 gap-y-2 text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
||||
{post.author.charAt(0)}
|
||||
</div>
|
||||
<span>{post.author}</span>
|
||||
</div>
|
||||
|
||||
<time dateTime={new Date(post.publishedAt).toISOString()}>{publishedDate}</time>
|
||||
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
{post.readingTime?.text || `${post.readingTime?.minutes || 1} min read`}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{(post.views || 0) > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>{post.views?.toLocaleString()} views</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<Link key={tag.id || tag.name} href={`/topics?tag=${tag.name}`}>
|
||||
<Badge variant="outline" className="hover:bg-secondary">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Cover Image */}
|
||||
<div className="mb-10 overflow-hidden rounded-xl">
|
||||
<Image
|
||||
width={848}
|
||||
height={560}
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
className="h-auto w-full object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<div className="mb-4" dangerouslySetInnerHTML={{ __html: post.content }} />
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mt-12 border-t pt-8">
|
||||
<h3 className="mb-4 text-lg font-semibold">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<Link key={tag.id || tag.name} href={`/topics?tag=${tag.name}`}>
|
||||
<Badge variant="outline" className="hover:bg-secondary">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Share Buttons */}
|
||||
<SimpleShareButtons title={post.title} url={`/topics/${post.slug}`} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Related posts */}
|
||||
{relatedPosts.length > 0 && (
|
||||
<section className="bg-muted/30 py-12">
|
||||
<div className="container-custom max-w-4xl">
|
||||
<h2 className="mb-8 text-2xl font-bold">Related Posts</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
{relatedPosts.map((relatedPost) => (
|
||||
<Link
|
||||
key={relatedPost.id}
|
||||
href={`/topics/${relatedPost.slug}`}
|
||||
className="topic-card group block"
|
||||
>
|
||||
<div className="aspect-video overflow-hidden">
|
||||
<Image
|
||||
width={406}
|
||||
height={228}
|
||||
src={relatedPost.coverImage}
|
||||
alt={relatedPost.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h3 className="mb-2 text-xl font-bold transition-colors group-hover:text-primary">
|
||||
{relatedPost.title}
|
||||
</h3>
|
||||
<p className="mb-4 line-clamp-2 text-muted-foreground">{relatedPost.excerpt}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relatedPost.tags.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag.id || tag.name} variant="outline">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user