Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(blog): add search and pagination functionality #46

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions app/(blog)/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@

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 (
<section className="mx-auto max-w-3xl px-6 py-12 sm:px-6 sm:py-16 lg:py-20">
<header>
<h1 className="leading-tighter font-heading mb-8 text-center text-4xl font-bold tracking-tighter md:mb-16 md:text-5xl">
Blog
</h1>
</header>
<LocalSearch route="/blog" placeholder="Search for blog post..." />
<div className="grid grid-cols-1 gap-6 p-4 md:p-0 lg:grid-cols-2">
{posts.map(({ slug, title, image }: { slug: string, title: string, image: string }) => (
{result.posts.map(({ slug, title, image }: { slug: string; title: string; image: string }) => (
<div key={slug} className="flex flex-col overflow-hidden rounded-xl border border-gray-200 shadow-lg">
<Link href={`/${slug}`}>
<Image width={650} height={340} alt={title} src={`${image}`} />
Expand All @@ -28,6 +36,9 @@ export default async function Home({}) {
</div>
))}
</div>
<div className="mt-6 flex items-center justify-center">
<Pagination pageNumber={searchParams.page ? +searchParams.page : 1} isNext={result.isNext} />
</div>
</section>
);
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"markdown-it": "^14.1.0",
"next": "^14.2.6",
"next-themes": "^0.3.0",
"query-string": "^9.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sharp": "^0.33.5",
Expand Down
47 changes: 47 additions & 0 deletions src/components/atoms/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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<PaginationProps> = ({ 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 (
<div className="flex justify-between w-full">
<button
className={pageNumber == 1 ? 'invisible' : `btn btn-ghost px-4 py-2 text-sm font-semibold`}
onClick={() => handleNavigation('prev')}
disabled={pageNumber === 1}
>
<IconChevronLeft className="h-6 w-6" />
</button>
<span className="px-4 py-2 text-sm font-semibold">{pageNumber}</span>
<button
className={!isNext ? 'invisible' : 'btn btn-ghost px-4 py-2 text-sm font-semibold'}
onClick={() => handleNavigation('next')}
disabled={!isNext}
>
<IconChevronRight className="h-6 w-6" />
</button>
</div>
);
};

export default Pagination;
65 changes: 65 additions & 0 deletions src/components/common/LocalSearch.tsx
Original file line number Diff line number Diff line change
@@ -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<LocalSearchProps> = ({ route, placeholder, otherClasses, label }) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

const query = searchParams.get('q');

const [search, setSearch] = useState<string>(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 (
<div className={`mx-0 mb-1 sm:mb-4 ${otherClasses}`}>
{label && (
<label htmlFor="search-input" className="pb-1 text-xs uppercase tracking-wider">
{label}
</label>
)}
<input
id="search-input"
name="search-input"
autoComplete="given-search"
value={search}
onChange={(e) => 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"
/>
</div>
);
};

export default LocalSearch;
23 changes: 23 additions & 0 deletions src/shared/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,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;
Expand Down Expand Up @@ -360,3 +372,14 @@ type HeaderProps = {
showRssFeed?: boolean;
position?: 'center' | 'right' | 'left';
};

type UrlQueryParams = {
params: string;
key: string;
value: string | null;
};

type RemoveUrlQueryParams = {
params: string;
keysToRemove: string[];
};
35 changes: 35 additions & 0 deletions src/utils/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 50 additions & 1 deletion src/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -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: '' },
Expand All @@ -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 },
);
};
19 changes: 13 additions & 6 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
}