'use client'
import { type BlogView } from 'app/blog/blog-view'
import BlogFilters from 'components/Blog/BlogFilters'
import BlogGridItem from 'components/Blog/BlogGridItem'
import BlogListItem from 'components/Blog/BlogListItem'
import { useInfiniteScrollWithFetch } from 'hooks/useInfiniteScroll'
import { Suspense, useCallback, useState } from 'react'
import type PostTypes from 'types/post'
import SectionContainerWithCn from '../../components/Layouts/SectionContainerWithCn'
import SectionContainer from '@/components/Layouts/SectionContainer'
const POSTS_PER_PAGE = 25
const SKELETON_COUNT = 6
// Featured + 2 secondary posts shown by the layout hero on the index. Excluded
// from the list to avoid duplication — keep in sync with app/blog/layout.tsx.
const HERO_POST_COUNT = 3
function BlogListItemSkeleton() {
return (
)
}
function BlogGridItemSkeleton() {
return (
)
}
interface BlogClientProps {
initialBlogs: any[]
totalPosts: number
initialView: BlogView
}
export default function BlogClient({ initialBlogs, totalPosts, initialView }: BlogClientProps) {
const [view, setView] = useState(initialView)
const [isFiltering, setIsFiltering] = useState(false)
const [filterParams, setFilterParams] = useState<{ category?: string; search?: string }>({})
const [filteredPosts, setFilteredPosts] = useState(null)
const [filteredTotal, setFilteredTotal] = useState(null)
const isList = view === 'list'
const currentPosts = filteredPosts ?? initialBlogs
const currentTotal = filteredTotal ?? totalPosts
const fetchMorePosts = useCallback(
async (offset: number, limit: number) => {
// The hero posts stay in the loaded array (just rendered in the layout
// hero, sliced off at render), so `offset` already counts them — no skip.
const params = new URLSearchParams({
offset: offset.toString(),
limit: limit.toString(),
})
if (filterParams.search) {
params.set('q', filterParams.search)
}
const response = await fetch(`/api-v2/blog-posts?${params}`)
const data = await response.json()
if (!data.success) {
throw new Error(data.error || 'Failed to fetch posts')
}
return data.posts
},
[filterParams]
)
const {
items: blogs,
hasMore,
loadMoreRef,
} = useInfiniteScrollWithFetch({
initialItems: currentPosts,
totalItems: currentTotal,
pageSize: POSTS_PER_PAGE,
fetchMore: fetchMorePosts,
rootMargin: '1000px',
})
const handleFilterChange = useCallback(async (category?: string, search?: string) => {
if ((!category || category === 'all') && !search) {
setFilterParams({})
setFilteredPosts(null)
setFilteredTotal(null)
setIsFiltering(false)
return
}
setIsFiltering(true)
setFilterParams({ category, search })
try {
const params = new URLSearchParams({
offset: '0',
limit: POSTS_PER_PAGE.toString(),
})
if (category && category !== 'all') {
params.set('category', category)
}
if (search) {
params.set('q', search)
}
const response = await fetch(`/api-v2/blog-posts?${params}`)
const data = await response.json()
if (data.success) {
setFilteredPosts(data.posts)
setFilteredTotal(data.total)
}
} catch (error) {
console.error('Failed to fetch filtered posts:', error)
} finally {
setIsFiltering(false)
}
}, [])
// The hero posts (the most recent HERO_POST_COUNT) are rendered by the blog
// layout's hero, so drop them from the list to avoid duplication. Filtered
// results are a separate query with no hero, so show all.
const visibleBlogs = filteredPosts !== null ? blogs : blogs.slice(HERO_POST_COUNT)
return (
{/* Filters row */}
{/* Blog posts */}
{isFiltering ? (
isList ? (
{Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
))}
) : (
{Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
))}
)
) : visibleBlogs?.length ? (
isList ? (
{visibleBlogs.map((blog: PostTypes, idx: number) => (
-
))}
) : (
{visibleBlogs.map((blog: PostTypes, idx: number) => (
-
))}
)
) : (
)}
{hasMore && !isFiltering && (
)}
)
}