384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
import { Suspense } from 'react'
|
|
import { Metadata } from 'next'
|
|
import { Header } from '@/components/header'
|
|
import { Footer } from '@/components/footer'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import {
|
|
Pagination,
|
|
PaginationContent,
|
|
PaginationItem,
|
|
PaginationLink,
|
|
PaginationNext,
|
|
PaginationPrevious,
|
|
} from '@/components/ui/pagination'
|
|
import { Search, BookOpen, Eye } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import Image from 'next/image'
|
|
import { ITopic } from '@/models/topic'
|
|
|
|
// Force dynamic rendering for real-time topic updates
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
// SEO metadata for the topics page
|
|
export async function generateMetadata(): Promise<Metadata> {
|
|
return {
|
|
title: 'Topic - SiliconPin | Web Development Insights & Tutorials',
|
|
description:
|
|
'Discover the latest insights, tutorials, and stories from the SiliconPin community. Learn about web development, cloud computing, and cutting-edge technology trends.',
|
|
keywords: [
|
|
'web development',
|
|
'cloud computing',
|
|
'programming tutorials',
|
|
'tech insights',
|
|
'software engineering',
|
|
'SiliconPin topic',
|
|
'development tutorials',
|
|
],
|
|
authors: [{ name: 'SiliconPin Team' }],
|
|
openGraph: {
|
|
title: 'SiliconPin Topic - Web Development Insights',
|
|
description:
|
|
'Discover the latest insights, tutorials, and stories from the SiliconPin community.',
|
|
type: 'website',
|
|
siteName: 'SiliconPin',
|
|
},
|
|
twitter: {
|
|
card: 'summary_large_image',
|
|
title: 'SiliconPin Topic - Web Development Insights',
|
|
description:
|
|
'Discover the latest insights, tutorials, and stories from the SiliconPin community.',
|
|
},
|
|
alternates: {
|
|
canonical: '/topics',
|
|
},
|
|
robots: {
|
|
index: true,
|
|
follow: true,
|
|
googleBot: {
|
|
index: true,
|
|
follow: true,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Server-side data fetching
|
|
async function parseSearchParams(
|
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
|
) {
|
|
const params = await searchParams
|
|
return {
|
|
page: params.page ? parseInt(params.page as string) : 1,
|
|
tag: params.tag ? (params.tag as string) : undefined,
|
|
search: params.q ? (params.q as string) : undefined,
|
|
}
|
|
}
|
|
|
|
async function getTopics(searchParams: Promise<{ [key: string]: string | string[] | undefined }>) {
|
|
const { page, tag, search } = await parseSearchParams(searchParams)
|
|
|
|
try {
|
|
// Build API URL with parameters
|
|
const baseUrl =
|
|
process.env.NODE_ENV === 'production'
|
|
? `${process.env.NEXTAUTH_URL || 'https://siliconpin.com'}`
|
|
: 'http://localhost:4024'
|
|
|
|
const searchQuery = new URLSearchParams({
|
|
page: page.toString(),
|
|
limit: '6',
|
|
...(tag && { tag }),
|
|
...(search && { q: search }),
|
|
})
|
|
|
|
const response = await fetch(`${baseUrl}/api/topics?${searchQuery}`, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
// Disable cache for dynamic data
|
|
cache: 'no-store',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch topics: ${response.status}`)
|
|
}
|
|
|
|
const result = await response.json()
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error?.message || 'Failed to fetch topics')
|
|
}
|
|
|
|
return {
|
|
topics: result.data || [],
|
|
pagination: result.pagination || {
|
|
total: 0,
|
|
page,
|
|
limit: 6,
|
|
totalPages: 0,
|
|
},
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching topics:', error)
|
|
|
|
// Fallback to empty state
|
|
return {
|
|
topics: [],
|
|
pagination: {
|
|
total: 0,
|
|
page,
|
|
limit: 6,
|
|
totalPages: 0,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get unique tags from topics for filtering
|
|
async function getTags(): Promise<Array<{ id: string; name: string }>> {
|
|
try {
|
|
const baseUrl =
|
|
process.env.NODE_ENV === 'production'
|
|
? `${process.env.NEXTAUTH_URL || 'https://siliconpin.com'}`
|
|
: 'http://localhost:4024'
|
|
|
|
const response = await fetch(`${baseUrl}/api/topics/tags`, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
cache: 'no-store',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch tags: ${response.status}`)
|
|
}
|
|
|
|
const result = await response.json()
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error?.message || 'Failed to fetch tags')
|
|
}
|
|
|
|
return result.data || []
|
|
} catch (error) {
|
|
console.error('Error fetching tags:', error)
|
|
|
|
// Fallback to empty array
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Topic grid skeleton loader
|
|
function TopicGridSkeleton() {
|
|
return (
|
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<Card key={i} className="overflow-hidden">
|
|
<Skeleton className="aspect-video w-full" />
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center gap-4 mb-3">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-4 w-16" />
|
|
<Skeleton className="h-4 w-20" />
|
|
</div>
|
|
<Skeleton className="h-6 w-full mb-3" />
|
|
<Skeleton className="h-4 w-full mb-2" />
|
|
<Skeleton className="h-4 w-3/4 mb-4" />
|
|
<div className="flex gap-2">
|
|
<Skeleton className="h-6 w-16" />
|
|
<Skeleton className="h-6 w-20" />
|
|
<Skeleton className="h-6 w-14" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default async function TopicsPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
|
}) {
|
|
const {
|
|
page: currentPage,
|
|
tag: selectedTag,
|
|
search: searchQuery,
|
|
} = await parseSearchParams(searchParams)
|
|
|
|
const { topics, pagination } = await getTopics(searchParams)
|
|
const tags = await getTags()
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
<section className="py-12 md:py-20">
|
|
<div className="container-custom">
|
|
<h1 className="section-heading mb-12 text-center">Topic Posts</h1>
|
|
|
|
{/* Search and filter section */}
|
|
<div className="mb-10 space-y-6">
|
|
<form action="/topics" method="GET" className="flex gap-2">
|
|
<Input
|
|
placeholder="Search posts..."
|
|
name="q"
|
|
defaultValue={searchQuery}
|
|
className="flex-1"
|
|
/>
|
|
<Button type="submit">
|
|
<Search className="mr-2 h-4 w-4" />
|
|
Search
|
|
</Button>
|
|
{searchQuery && (
|
|
<Button variant="ghost" asChild>
|
|
<Link href="/topics">Clear</Link>
|
|
</Button>
|
|
)}
|
|
</form>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{tags.map((tag) => (
|
|
<Link
|
|
key={tag.id}
|
|
href={`/blogs${selectedTag === tag.name ? '' : `?tag=${tag.name}`}${searchQuery ? `&q=${searchQuery}` : ''}`}
|
|
className="transition-colors"
|
|
>
|
|
<Badge
|
|
variant={selectedTag === tag.name ? 'default' : 'outline'}
|
|
className={
|
|
selectedTag === tag.name
|
|
? 'hover:bg-primary/90'
|
|
: 'hover:bg-primary/10 hover:text-primary'
|
|
}
|
|
>
|
|
{tag.name}
|
|
</Badge>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter info */}
|
|
{(selectedTag || searchQuery) && (
|
|
<div className="mb-6 rounded-md bg-muted p-3 text-sm">
|
|
<p>
|
|
{pagination.total} post(s) found
|
|
{selectedTag && ` with tag: ${selectedTag}`}
|
|
{searchQuery && ` containing: "${searchQuery}"`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{/* Topic posts grid */}
|
|
<Suspense fallback={<TopicGridSkeleton />}>
|
|
{topics.length > 0 ? (
|
|
<div className="mb-10 grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
|
{topics.map((topic) => (
|
|
<Link
|
|
key={topic.id}
|
|
href={`/topics/${topic.slug}`}
|
|
className="topic-card group block"
|
|
>
|
|
<div className="aspect-video overflow-hidden">
|
|
<Image
|
|
src={topic.coverImage}
|
|
alt={topic.title}
|
|
width={400}
|
|
height={225}
|
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
priority
|
|
/>
|
|
</div>
|
|
<div className="p-6">
|
|
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
|
|
<span>
|
|
{new Date(topic.publishedAt).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})}
|
|
</span>
|
|
{(topic.views || 0) > 0 && (
|
|
<span className="flex items-center gap-1">
|
|
<Eye className="h-3 w-3" />
|
|
{topic.views?.toLocaleString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h2 className="mb-2 text-xl font-bold transition-colors group-hover:text-primary">
|
|
{topic.title}
|
|
</h2>
|
|
<p className="mb-4 line-clamp-3 text-muted-foreground">{topic.excerpt}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{topic.tags.map((tag) => (
|
|
<Badge key={tag.id} variant="outline">
|
|
{tag.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="py-10 text-center">
|
|
<p className="text-xl text-muted-foreground">
|
|
No posts found matching your criteria.
|
|
</p>
|
|
<Button variant="outline" className="mt-4" asChild>
|
|
<Link href="/topics">Reset filters</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{topics.length > 0 && pagination.totalPages > 1 && (
|
|
<Pagination>
|
|
<PaginationContent>
|
|
{currentPage > 1 && (
|
|
<PaginationItem>
|
|
<PaginationPrevious
|
|
href={`/topics?page=${currentPage - 1}${selectedTag ? `&tag=${selectedTag}` : ''}${searchQuery ? `&q=${searchQuery}` : ''}`}
|
|
size="default"
|
|
/>
|
|
</PaginationItem>
|
|
)}
|
|
|
|
{Array.from({ length: Math.min(pagination.totalPages, 5) }, (_, i) => {
|
|
const page = i + 1
|
|
return (
|
|
<PaginationItem key={page}>
|
|
<PaginationLink
|
|
href={`/topics?page=${page}${selectedTag ? `&tag=${selectedTag}` : ''}${searchQuery ? `&q=${searchQuery}` : ''}`}
|
|
isActive={page === currentPage}
|
|
size="default"
|
|
>
|
|
{page}
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
)
|
|
})}
|
|
|
|
{currentPage < pagination.totalPages && (
|
|
<PaginationItem>
|
|
<PaginationNext
|
|
href={`/topics?page=${currentPage + 1}${selectedTag ? `&tag=${selectedTag}` : ''}${searchQuery ? `&q=${searchQuery}` : ''}`}
|
|
size="default"
|
|
/>
|
|
</PaginationItem>
|
|
)}
|
|
</PaginationContent>
|
|
</Pagination>
|
|
)}
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
<Footer />
|
|
</div>
|
|
)
|
|
}
|