diff --git a/components/helpers/applyFilter.ts b/components/helpers/applyFilter.ts index a7f3f217d5eb..c2772cce63e1 100644 --- a/components/helpers/applyFilter.ts +++ b/components/helpers/applyFilter.ts @@ -137,6 +137,9 @@ export const onFilterApply = ( if (query && Object.keys(query).length >= 1) { Object.keys(query).forEach((property) => { + if (property === 'page') { + return; + } const res = result.filter((e) => { if (!query[property] || e[property] === query[property]) { return e[property]; diff --git a/components/navigation/BlogPagination.tsx b/components/navigation/BlogPagination.tsx new file mode 100644 index 000000000000..6479e105faf5 --- /dev/null +++ b/components/navigation/BlogPagination.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useState } from 'react'; + +import { ButtonIconPosition } from '@/types/components/buttons/ButtonPropsType'; + +import Button from '../buttons/Button'; +import IconArrowLeft from '../icons/ArrowLeft'; +import IconArrowRight from '../icons/ArrowRight'; + +/** + * Props for the BlogPagination component + * @property {number} blogsPerPage - Number of blogs to display per page + * @property {number} totalBlogs - Total number of blogs + * @property {function} paginate - Callback function to handle page changes + * @property {number} currentPage - Current active page number + */ +interface BlogPaginationProps { + // eslint-disable-next-line prettier/prettier + + blogsPerPage: number; + totalBlogs: number; + paginate: (pageNumber: number) => void; + currentPage: number; +} + +/** + * A pagination component for blog posts that displays page numbers and navigation buttons + * @param {BlogPaginationProps} props - The props for the component + * @returns {JSX.Element} A navigation element with pagination controls + */ +export default function BlogPagination({ + blogsPerPage, + totalBlogs, + paginate, + currentPage, +}: BlogPaginationProps) { + const totalPages: number = Math.ceil(totalBlogs / blogsPerPage); + const pagesToShow: number = 6; + const [pageNumbers, setPageNumbers] = useState<(number | string)[]>([]); + + const calculatePageNumbers = () => { + const numbers: (number | string)[] = []; + + if (totalPages < 1) return []; + if (totalPages <= pagesToShow) { + for (let i = 1; i <= totalPages; i++) { + numbers.push(i); + } + } else if (currentPage <= 2) { + for (let i = 1; i <= 3; i++) { + numbers.push(i); + } + numbers.push('...'); + numbers.push(totalPages - 2); + numbers.push(totalPages - 1); + numbers.push(totalPages); + } else if (currentPage >= totalPages - 1) { + numbers.push(1); + numbers.push(2); + numbers.push(3); + numbers.push('...'); + for (let i = totalPages - 2; i <= totalPages; i++) { + numbers.push(i); + } + } else { + numbers.push(1); + numbers.push('...'); + numbers.push(currentPage - 1); + numbers.push(currentPage); + numbers.push(currentPage + 1); + numbers.push('...'); + numbers.push(totalPages); + } + + return numbers; + }; + + useEffect(() => { + setPageNumbers(calculatePageNumbers()); + }, [currentPage, totalBlogs]); + + return ( + <nav + aria-label="Blog pagination" + className="mt-8 flex items-center justify-center gap-2 p-4" + > + {/* Previous button */} + <Button + className={`${currentPage === 1 ? 'cursor-not-allowed opacity-50' : ''} size-[120px] rounded-l-md px-4 py-2`} + aria-label="Previous page" + bgClassName="bg-white" + textClassName="text-[#212525] font-inter text-[14px] font-normal" + text="Previous" + disabled={currentPage === 1} + onClick={() => paginate(currentPage - 1)} + icon={<IconArrowLeft className="inline-block size-4" />} + iconPosition={ButtonIconPosition.LEFT} + /> + {/* Page numbers */} + <div className="flex w-[35vw] justify-center gap-3"> + {pageNumbers.map((number, index) => ( + <button + key={index} + className={`size-[40px] ${number === currentPage ? 'rounded border bg-[#6200EE] text-white' : 'text-[#6B6B6B]'}`} + aria-label={`${typeof number === 'number' ? `Go to page ${number}` : 'More pages'}`} + aria-current={number === currentPage ? 'page' : undefined} + onClick={() => typeof number === 'number' && paginate(number)} + disabled={number === '...'} + > + {number} + </button> + ))} + </div> + {/* Next button */} + <Button + className={`${currentPage === totalPages && 'cursor-not-allowed opacity-50'} h-[35px] w-[120px] rounded-l-md px-4 py-2`} + bgClassName="bg-white" + textClassName="text-[#212525] font-inter text-[14px] font-normal" + text="Next" + disabled={currentPage === totalPages} + onClick={() => paginate(currentPage + 1)} + icon={<IconArrowRight className="inline-block size-4" />} + iconPosition={ButtonIconPosition.RIGHT} + /> + </nav> + ); +} diff --git a/pages/blog/index.tsx b/pages/blog/index.tsx index 37958cec4308..9b8d556436d0 100644 --- a/pages/blog/index.tsx +++ b/pages/blog/index.tsx @@ -1,9 +1,10 @@ import { useRouter } from 'next/router'; -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; import Empty from '@/components/illustrations/Empty'; import GenericLayout from '@/components/layout/GenericLayout'; import Loader from '@/components/Loader'; +import BlogPagination from '@/components/navigation/BlogPagination'; import BlogPostItem from '@/components/navigation/BlogPostItem'; import Filter from '@/components/navigation/Filter'; import Heading from '@/components/typography/Heading'; @@ -32,106 +33,184 @@ export default function BlogIndexPage() { return i2Date.getTime() - i1Date.getTime(); }) - : [] + : [], ); const [isClient, setIsClient] = useState(false); - const onFilter = (data: IBlogPost[]) => setPosts(data); + const onFilter = (data: IBlogPost[]) => { + setPosts(data); + }; const toFilter = [ { - name: 'type' + name: 'type', }, { name: 'authors', - unique: 'name' + unique: 'name', }, { - name: 'tags' - } + name: 'tags', + }, ]; const clearFilters = () => { - router.push(`${router.pathname}`, undefined, { - shallow: true - }); + const { page } = router.query; + + router.push( + { + pathname: router.pathname, + query: { ...(page && { page }) }, + }, + undefined, + { + shallow: true, + }, + ); }; - const showClearFilters = Object.keys(router.query).length > 0; + const showClearFilters = Object.keys(router.query).length > 1; const description = 'Find the latest and greatest stories from our community'; const image = '/img/social/blog.webp'; + const blogsPerPage = 9; + + const currentPage = parseInt(router.query.page as string, 10) || 1; + + const currentPosts = useMemo(() => { + const indexOfLastPost = currentPage * blogsPerPage; + const indexOfFirstPost = indexOfLastPost - blogsPerPage; + + return posts.slice(indexOfFirstPost, indexOfLastPost); + }, [currentPage, posts]); + + const paginate = (pageNumber: number) => { + const { query } = router; + const newQuery = { + ...query, + page: pageNumber, + }; + + router.push( + { + pathname: router.pathname, + query: newQuery, + }, + undefined, + { + shallow: true, + }, + ); + }; + + useEffect(() => { + if (router.isReady && !router.query.page) { + router.replace( + { pathname: router.pathname, query: { page: '1' } }, + undefined, + { shallow: true }, + ); + } + }, [router.isReady]); useEffect(() => { - setIsClient(true); - }, []); + if (router.isReady) { + setIsClient(true); + } + }, [router.isReady]); return ( - <GenericLayout title='Blog' description={description} image={image} wide> - <div className='relative px-4 pb-20 pt-8 sm:px-6 lg:px-8 lg:pb-28 lg:pt-12' id='main-content'> - <div className='absolute inset-0'> - <div className='h-1/3 bg-white sm:h-2/3'></div> + <GenericLayout title="Blog" description={description} image={image} wide> + <div + className="relative px-4 pb-20 pt-8 sm:px-6 lg:px-8 lg:pb-28 lg:pt-12" + id="main-content" + > + <div className="absolute inset-0"> + <div className="h-1/3 bg-white sm:h-2/3"></div> </div> - <div className='relative mx-auto max-w-7xl'> - <div className='text-center'> + <div className="relative mx-auto max-w-7xl"> + <div className="text-center"> <Heading level={HeadingLevel.h1} typeStyle={HeadingTypeStyle.lg}> Welcome to our blog! </Heading> - <Paragraph className='mx-auto mt-3 max-w-2xl sm:mt-4'> + <Paragraph className="mx-auto mt-3 max-w-2xl sm:mt-4"> Find the latest and greatest stories from our community </Paragraph> - <Paragraph typeStyle={ParagraphTypeStyle.md} className='mx-auto mt-4 max-w-2xl'> + <Paragraph + typeStyle={ParagraphTypeStyle.md} + className="mx-auto mt-4 max-w-2xl" + > Want to publish a blog post? We love community stories.{' '} - <TextLink href='https://github.com/asyncapi/website/issues/new?template=blog.md' target='_blank'> + <TextLink + href="https://github.com/asyncapi/website/issues/new?template=blog.md" + target="_blank" + > Submit yours! </TextLink> </Paragraph> - <Paragraph typeStyle={ParagraphTypeStyle.md} className='mx-auto mt-1 max-w-2xl'> + <Paragraph + typeStyle={ParagraphTypeStyle.md} + className="mx-auto mt-1 max-w-2xl" + > We have an <img - className='ml-1 text-primary-500 hover:text-primary-300' + className="ml-1 text-primary-500 hover:text-primary-300" style={{ display: 'inline' }} - src='/img/logos/rss.svg' - alt='RSS feed' - height='18px' - width='18px' + src="/img/logos/rss.svg" + alt="RSS feed" + height="18px" + width="18px" /> - <TextLink href='/rss.xml'> RSS Feed</TextLink>, too! + <TextLink href="/rss.xml"> RSS Feed</TextLink>, too! </Paragraph> </div> - <div className='mx:64 mt-12 md:flex md:justify-center lg:justify-start'> + <div className="mx:64 mt-12 md:flex md:justify-center lg:justify-start"> <Filter data={navItems || []} onFilter={onFilter} - className='md: mx-px mt-1 w-full md:mt-0 md:w-1/5 md:text-sm' + className="md: mx-px mt-1 w-full md:mt-0 md:w-1/5 md:text-sm" checks={toFilter} /> {showClearFilters && ( <button - type='button' - className='bg-none text-md mt-1 rounded-md border border-gray-200 px-4 py-2 font-semibold tracking-heading text-gray-800 shadow-none transition-all duration-500 ease-in-out hover:text-gray-700 md:mt-0 md:py-0' + type="button" + className="bg-none text-md mt-1 rounded-md border border-gray-200 px-4 py-2 font-semibold tracking-heading text-gray-800 shadow-none transition-all duration-500 ease-in-out hover:text-gray-700 md:mt-0 md:py-0" onClick={clearFilters} > - <span className='inline-block'>Clear filters</span> + <span className="inline-block">Clear filters</span> </button> )} </div> <div> - {Object.keys(posts).length === 0 && ( - <div className='mt-16 flex flex-col items-center justify-center'> + {(Object.keys(posts).length === 0 || + Object.keys(currentPosts).length === 0) && ( + <div className="mt-16 flex flex-col items-center justify-center"> <Empty /> - <p className='mx-auto mt-3 max-w-2xl text-xl leading-7 text-gray-500'>No post matches your filter</p> + <p className="mx-auto mt-3 max-w-2xl text-xl leading-7 text-gray-500"> + No post matches your filter + </p> </div> )} {Object.keys(posts).length > 0 && isClient && ( - <ul className='mx-auto mt-12 grid max-w-lg gap-5 lg:max-w-none lg:grid-cols-3'> - {posts.map((post, index) => ( + <ul className="mx-auto mt-12 grid max-w-lg gap-5 lg:max-w-none lg:grid-cols-3"> + {currentPosts.map((post, index) => ( <BlogPostItem key={index} post={post} /> ))} </ul> )} {Object.keys(posts).length > 0 && !isClient && ( - <div className='h-screen w-full'> - <Loader loaderText='Loading Blogs' className='mx-auto my-60' pulsating /> + <div className="h-screen w-full"> + <Loader + loaderText="Loading Blogs" + className="mx-auto my-60" + pulsating + /> </div> )} + {/* Pagination component */} + <BlogPagination + blogsPerPage={blogsPerPage} + totalBlogs={posts.length} + paginate={paginate} + currentPage={currentPage} + /> </div> </div> </div>