From ebf0b5a8d593703e4b6c7bbab1ee25c07a0bc518 Mon Sep 17 00:00:00 2001 From: ladunjexa <110838700+ladunjexa@users.noreply.github.com> Date: Mon, 6 May 2024 16:29:10 +0300 Subject: [PATCH] feat(blog): add search and pagination functionality --- app/(blog)/blog/page.tsx | 19 ++++++-- package.json | 1 + src/components/atoms/Pagination.tsx | 47 +++++++++++++++++++ src/components/common/LocalSearch.tsx | 65 +++++++++++++++++++++++++++ src/shared/types.d.ts | 23 ++++++++++ src/utils/posts.js | 35 +++++++++++++++ src/utils/utils.ts | 51 ++++++++++++++++++++- tsconfig.json | 19 +++++--- 8 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 src/components/atoms/Pagination.tsx create mode 100644 src/components/common/LocalSearch.tsx diff --git a/app/(blog)/blog/page.tsx b/app/(blog)/blog/page.tsx index ea473e80..08e80c93 100644 --- a/app/(blog)/blog/page.tsx +++ b/app/(blog)/blog/page.tsx @@ -1,16 +1,23 @@ + import type { Metadata } from 'next'; import Image from 'next/image'; import Link from 'next/link'; +import Pagination from '~/components/atoms/Pagination'; +import LocalSearch from '~/components/common/LocalSearch'; -import { findLatestPosts } from '~/utils/posts'; +import { findLatestPosts, getPosts } from '~/utils/posts'; export const metadata: Metadata = { title: 'Blog', }; -export default async function Home({}) { - const posts = await findLatestPosts(); +export default async function Home({ searchParams }: { searchParams: { [key: string]: string | undefined } }) { + const result = await getPosts({ + searchQuery: searchParams.q, + page: searchParams.page ? +searchParams.page : 1, + }); + return (
@@ -18,8 +25,9 @@ export default async function Home({}) { Blog
+
- {posts.map(({ slug, title, image }: { slug: string, title: string, image: string }) => ( + {result.posts.map(({ slug, title, image }: { slug: string; title: string; image: string }) => (
{title} @@ -28,6 +36,9 @@ export default async function Home({}) {
))}
+
+ +
); } diff --git a/package.json b/package.json index 4d736a9e..bbb21396 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "markdown-it": "^14.0.0", "next": "^14.1.0", "next-themes": "^0.2.1", + "query-string": "^9.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "sharp": "^0.33.2", diff --git a/src/components/atoms/Pagination.tsx b/src/components/atoms/Pagination.tsx new file mode 100644 index 00000000..452a6738 --- /dev/null +++ b/src/components/atoms/Pagination.tsx @@ -0,0 +1,47 @@ + +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { formUrlQuery } from '~/utils/utils'; +import type { PaginationProps } from '~/shared/types'; +import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; + +const Pagination: React.FC = ({ pageNumber, isNext }) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const handleNavigation = (direction: 'prev' | 'next') => { + const nextPageNumber = direction === 'next' ? pageNumber + 1 : pageNumber - 1; + + const newUrl = formUrlQuery({ + params: searchParams.toString(), + key: 'page', + value: nextPageNumber.toString(), + }); + + router.push(newUrl); + }; + + return ( +
+ + {pageNumber} + +
+ ); +}; + +export default Pagination; diff --git a/src/components/common/LocalSearch.tsx b/src/components/common/LocalSearch.tsx new file mode 100644 index 00000000..9da60461 --- /dev/null +++ b/src/components/common/LocalSearch.tsx @@ -0,0 +1,65 @@ + +'use client'; + +import React, { useState, useEffect } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import type { LocalSearchProps } from '~/shared/types'; +import { formUrlQuery, removeKeysFromUrlQuery } from '~/utils/utils'; + +const LocalSearch: React.FC = ({ route, placeholder, otherClasses, label }) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const query = searchParams.get('q'); + + const [search, setSearch] = useState(query || ''); + + useEffect(() => { + const delayDebounceFn = setTimeout(() => { + let newUrl = null; + if (search) { + // Form new url query with search params + newUrl = formUrlQuery({ + params: searchParams.toString(), + key: 'q', + value: search, + }); + } else { + // Remove search params from url + newUrl = removeKeysFromUrlQuery({ + params: searchParams.toString(), + keysToRemove: ['q'], + }); + } + + if (newUrl) { + router.push(newUrl, { scroll: false }); + } + }, 300); + + return () => clearTimeout(delayDebounceFn); + }, [route, router, pathname, searchParams, query, search]); + + return ( +
+ {label && ( + + )} + setSearch(e.target.value)} + placeholder={placeholder} + type="text" + className="mb-2 w-full rounded-md border border-gray-400 py-2 pl-2 pr-4 shadow-md dark:text-gray-300 sm:mb-0" + /> +
+ ); +}; + +export default LocalSearch; diff --git a/src/shared/types.d.ts b/src/shared/types.d.ts index bc60f46f..321c2ce1 100644 --- a/src/shared/types.d.ts +++ b/src/shared/types.d.ts @@ -232,7 +232,19 @@ type WindowSize = { height: number; }; +type PaginationProps = { + pageNumber: number; + isNext: boolean; +}; + // WIDGETS +type LocalSearchProps = { + route: string; + placeholder: string; + otherClasses?: string; + label?: string; +}; + type HeroProps = { title?: string | ReactElement; subtitle?: string | ReactElement; @@ -359,3 +371,14 @@ type HeaderProps = { showRssFeed?: boolean; position?: 'center' | 'right' | 'left'; }; + +type UrlQueryParams = { + params: string; + key: string; + value: string | null; +}; + +type RemoveUrlQueryParams = { + params: string; + keysToRemove: string[]; +}; diff --git a/src/utils/posts.js b/src/utils/posts.js index e47495e4..a9725566 100644 --- a/src/utils/posts.js +++ b/src/utils/posts.js @@ -28,6 +28,41 @@ export const fetchPosts = async () => { return await _posts; }; +/** */ +const POSTS_PER_PAGE = 4; + +export const getPosts = async (params) => { + const posts = await fetchPosts(); + + const { searchQuery, page = 1, pageSize = POSTS_PER_PAGE } = params; + + // Calculate the number of posts to skip based on the page number and page size + const skipAmount = (page - 1) * pageSize; + + let query = []; + + query = [ + { + title: { $regex: new RegExp(searchQuery, 'i') }, + content: { $regex: new RegExp(searchQuery, 'i') }, + }, + ]; + + const filteredPosts = posts.filter((post) => { + return query.every((q) => { + return Object.keys(q).every((field) => { + return new RegExp(q[field].$regex).test(post[field]); + }); + }); + }); + + const data = filteredPosts.slice(skipAmount, skipAmount + pageSize); + + const isNext = filteredPosts.length > skipAmount + pageSize; + + return { posts: data, isNext }; +}; + /** */ export const findLatestPosts = async ({ count } = {}) => { const _count = count || 4; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index e5e0b523..c67d4277 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,4 +1,12 @@ -// Function to format a number in thousands (K) or millions (M) format depending on its value +import qs from 'query-string'; +import type { RemoveUrlQueryParams, UrlQueryParams } from '~/shared/types'; + +/** + * Function to format a number in thousands (K) or millions (M) format depending on its value + * @param {number} number - number to format + * @param {number} digits - number of digits after the decimal point + * @returns {string} formatted number + */ export const getSuffixNumber = (number: number, digits: number = 1): string => { const lookup = [ { value: 1, symbol: '' }, @@ -17,3 +25,44 @@ export const getSuffixNumber = (number: number, digits: number = 1): string => { .find((item) => number >= item.value); return lookupItem ? (number / lookupItem.value).toFixed(digits).replace(rx, '$1') + lookupItem.symbol : '0'; }; + +/** + * Constructs a URL query string by adding/updating a key-value pairs based on the provided parameters. + * @param {string} params - current URL query string + * @param {string} key - key to add/update + * @param {string} value - value associated with the key to add/update + * @returns {string} - updated URL query string + */ +export const formUrlQuery = ({ params, key, value }: UrlQueryParams): string => { + const currentUrl = qs.parse(params); + + currentUrl[key] = value; + + return qs.stringifyUrl( + { + url: window.location.pathname, + query: currentUrl, + }, + { skipNull: true }, + ); +}; + +/** + * Constructs a URL query string by removing the specified keys from the provided parameters. + * @param {string} params - current URL query string + * @param {string[]} keysToRemove - keys to remove from the URL query string + * @returns {string} - updated URL query string + */ +export const removeKeysFromUrlQuery = ({ params, keysToRemove }: RemoveUrlQueryParams): string => { + const currentUrl = qs.parse(params); + + keysToRemove.forEach((key) => delete currentUrl[key]); + + return qs.stringifyUrl( + { + url: window.location.pathname, + query: currentUrl, + }, + { skipNull: true }, + ); +}; diff --git a/tsconfig.json b/tsconfig.json index c2cbce36..3bfbeefc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,14 +16,21 @@ "incremental": true, "baseUrl": ".", "paths": { - "~/*": ["src/*"] + "~/*": ["src/*"], }, "plugins": [ { - "name": "next" - } - ] + "name": "next", + }, + ], }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/(blog)/blog/page.tsx", "app/(blog)/[slug]/page.jsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "app/(blog)/blog/page.tsx", + "app/(blog)/[slug]/page.jsx", + ], + "exclude": ["node_modules"], }