src/components/blog/blog-client.tsx'use client'import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'import { Button } from '@/components/button'import { Heading } from '@/components/heading'import { Paragraph } from '@/components/paragraph'import { Image } from '@/components/image'import {SidebarStackProvider,useSidebarStack,AsyncSidebarRenderer,type ContentLoader,} from '@/components/sidebar-stack'import clsx from 'clsx'import Link from 'next/link'import { getSlug } from '@/tools/get-slug'import chevronDownIcon from '@iconify/icons-heroicons/chevron-down'// Helper function to decode HTML entitiesconst decodeHtmlEntities = (str: string): string => {return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")}interface BlogPost {slug: stringmetadata: {title?: stringdate?: stringcategories?: string[]featuredImage?: string}}// Content loader for blog posts - dynamically imports blog contentconst blogContentLoader: ContentLoader = async (componentId: string): Promise<ReactNode> => {if (!componentId) {console.error('No componentId provided to blog content loader')return <div className="text-red-500">Error: No post ID provided</div>}try {const postModule = await import(`../../app/(hero)/projects/${componentId}/content.tsx`)const Component = postModule.BlogContent || postModule.defaultreturn <Component />} catch (error) {console.error(`Failed to load blog post: ${componentId}`, error)return <div className="text-red-500">Error: Post not found</div>}}interface BlogClientInnerProps {allPosts: BlogPost[]perPage: numberloadMoreText: stringclassName?: string | undefined}// Inner component that uses the sidebar stack contextfunction BlogClientInner({allPosts,perPage,loadMoreText,className,}: BlogClientInnerProps) {const [displayedCount, setDisplayedCount] = useState(perPage)const containerRef = useRef<HTMLDivElement>(null)const itemRefs = useRef<(HTMLDivElement | null)[]>([])const [columns, setColumns] = useState(3)const [isMasonryEnabled, setIsMasonryEnabled] = useState(true)const [containerHeight, setContainerHeight] = useState(0)const [layoutKey, setLayoutKey] = useState(0)const { push, setContentLoader } = useSidebarStack()// Set the content loader on mountuseEffect(() => {setContentLoader(blogContentLoader)}, [setContentLoader])// Posts are already filtered and sorted on server, just slice for paginationconst currentPosts = allPosts.slice(0, displayedCount)const hasMore = displayedCount < allPosts.length// Calculate number of columns based on screen widthuseEffect(() => {const updateColumns = () => {const width = window.innerWidthlet newColumns = 3if (width < 768) {newColumns = 1} else if (width < 1024) {newColumns = 2} else {newColumns = 3}setColumns(newColumns)setIsMasonryEnabled(newColumns > 1)}updateColumns()// Debounced resize handlerlet resizeTimeout: NodeJS.Timeoutconst handleResize = () => {clearTimeout(resizeTimeout)resizeTimeout = setTimeout(() => {updateColumns()setLayoutKey((prev) => prev + 1)}, 150)}window.addEventListener('resize', handleResize)return () => {window.removeEventListener('resize', handleResize)clearTimeout(resizeTimeout)}}, [])// Layout calculation (masonry for multi-column, simple for single column)const calculateLayout = useCallback(() => {if (!containerRef.current) returnconst container = containerRef.currentconst containerWidth = container.offsetWidthif (!isMasonryEnabled) {itemRefs.current.forEach((item) => {if (!item) returnitem.style.position = 'relative'item.style.left = 'auto'item.style.top = 'auto'item.style.width = '100%'item.style.maxWidth = '100%'item.style.marginBottom = '32px'})setContainerHeight(0)return}const gap = 32const availableWidth = Math.max(containerWidth - gap * (columns - 1),columns * 200)const columnWidth = availableWidth / columnsconst columnHeights = new Array(columns).fill(0)itemRefs.current.forEach((item) => {if (!item) returnconst shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights))const x = shortestColumnIndex * (columnWidth + gap)const y = columnHeights[shortestColumnIndex]item.style.position = 'absolute'item.style.left = `${Math.min(x, containerWidth - columnWidth)}px`item.style.top = `${y}px`item.style.width = `${columnWidth}px`item.style.maxWidth = '100%'item.style.marginBottom = '0'columnHeights[shortestColumnIndex] += item.offsetHeight + gap})setContainerHeight(Math.max(...columnHeights))}, [columns, isMasonryEnabled])// Recalculate layout when posts change or images loaduseEffect(() => {const timer = setTimeout(() => {calculateLayout()}, 100)return () => clearTimeout(timer)}, [currentPosts, calculateLayout, columns, layoutKey, isMasonryEnabled])const showMore = () => {setDisplayedCount((prev) => prev + perPage)}const openSidebar = (slug: string, title: string) => {push({ title, componentId: slug })}// A category with no projects, or an empty _blog.json, should say so rather// than render a bare section with nothing under the heading.if (allPosts.length === 0) {return (<Paragraphcolor="text-contrast-light"margin="mb-0">No projects to show yet.</Paragraph>)}return (<><divref={containerRef}className={clsx('w-full',{'relative overflow-hidden': isMasonryEnabled,'space-y-8': !isMasonryEnabled,},className)}style={isMasonryEnabled ? { height: `${containerHeight}px` } : {}}>{currentPosts.map((post, index) =>post.metadata.title && (<divkey={post.slug}ref={(el) => {itemRefs.current[index] = elif (el && el.offsetHeight > 0 && isMasonryEnabled) {setTimeout(() => calculateLayout(), 50)}}}className={clsx('transition-all duration-300 ease-in-out cursor-pointer',{absolute: isMasonryEnabled,relative: !isMasonryEnabled,})}onClick={() =>openSidebar(post.slug, post.metadata.title || '')}><div className="bg-body-light rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">{post.metadata.featuredImage && (<div className="relative overflow-hidden w-full"><Linkhref={`/projects/${post.slug}`}scroll={true}prefetch={false}onClick={(e) => e.preventDefault()}><Imagesrc={post.metadata.featuredImage}alt={post.metadata.title || ''}size="large"className="w-full h-auto block"rounded="rounded-none"onLoad={() => {if (isMasonryEnabled) {setTimeout(() => calculateLayout(), 10)}}}/></Link></div>)}<div className="w-full flex flex-col text-left p-6 gap-4">{post.metadata.categories &&post.metadata.categories.length > 0 && (<div className="flex flex-wrap justify-start gap-2">{post.metadata.categories.map((category: string, idx: number) => (<Linkkey={idx}className="bg-body2 text-contrast text-xs px-3 py-[1px] rounded-full outline-0"href={`/projects/category/${getSlug(category)}`}onClick={(e) => e.preventDefault()}>{decodeHtmlEntities(category)}</Link>))}</div>)}<Linkhref={`/projects/${post.slug}`}scroll={true}prefetch={false}onClick={(e) => e.preventDefault()}><Headingas="h3"fontWeight="font-semibold"margin="mb-0"className="!text-xl normal-case!">{post.metadata.title}</Heading></Link></div></div></div>))}</div>{hasMore && (<div className="mt-10 flex justify-center"><ButtononClick={showMore}variant="outline"icon={chevronDownIcon}iconPlacement="after"className="cursor-pointer">{loadMoreText}</Button></div>)}<AsyncSidebarRenderer /></>)}// Main exported component with provider wrapperexport function BlogClient({allPosts = [],perPage = 9,loadMoreText = 'Load More Posts',className,}: {allPosts?: BlogPost[]perPage?: numberclassName?: string | undefinedloadMoreText?: string}) {return (<SidebarStackProvider><BlogClientInnerallPosts={allPosts}perPage={perPage}loadMoreText={loadMoreText}className={className}/></SidebarStackProvider>)}