Skip to content

Commit

Permalink
Merge pull request #29 from krckyboy/#24
Browse files Browse the repository at this point in the history
  • Loading branch information
krckyboy authored Feb 25, 2024
2 parents 9f2e94a + 27c16ad commit bec6215
Show file tree
Hide file tree
Showing 12 changed files with 267 additions and 5 deletions.
4 changes: 4 additions & 0 deletions next/public/images/category/x.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions next/public/images/search/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
124 changes: 124 additions & 0 deletions next/src/app/blog/(search-bar)/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'use client';
import React, { FunctionComponent, useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useDebounce } from '@/app/blog/(search-bar)/useDebounce';
import type { PostsFetchResponse } from '@/components/blog-post-item/types';
import { db } from '@/scripts/fetch';
import styles from './styles.module.scss';

const SearchBar: FunctionComponent = () => {
const router = useRouter();

const [isActive, setFormActive] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<PostsFetchResponse['data'] | null>(null);
const [focusedIndex, setFocusedIndex] = useState(-1);

const searchInputRef = useRef<HTMLInputElement>(null);
const debouncedSearchQuery = useDebounce(searchQuery, 500);

const fetchPosts = async () => {
const posts = await db.getPostsBySearchTerm(debouncedSearchQuery);
setSearchResults(posts.data);
};

const handleBlur = (e: React.FocusEvent<HTMLDivElement>) => {
const { currentTarget, relatedTarget } = e;

if (currentTarget.contains(relatedTarget)) {
return;
}

setFormActive(false);
};

const handleEscKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
searchInputRef.current?.blur();
setSearchQuery('');
}
};

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setFormActive(true);
};

// Keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();

if (!searchResults) {
return;
}

setFocusedIndex((prevIndex) => Math.min(prevIndex + 1, searchResults?.length - 1 ?? 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();

setFocusedIndex((prevIndex) => Math.max(prevIndex - 1, 0));
} else if (e.key === 'Enter' && focusedIndex !== -1) {
const postToGoTo = searchResults?.find((post, index) => focusedIndex === index);

if (!postToGoTo) {
return;
}

router.push(`/blog/${postToGoTo.attributes.slug}`);
}
};

useEffect(() => {
document.addEventListener('keydown', handleEscKey);

return () => {
document.removeEventListener('keydown', handleEscKey);
};
}, []);

useEffect(() => {
if (!debouncedSearchQuery || debouncedSearchQuery.length < 2) {
setSearchResults([]);
return;
}

fetchPosts();
}, [debouncedSearchQuery]);

return (
<section className={styles.container} onBlur={handleBlur} onKeyDown={handleKeyDown}>
<label className={styles.label}>
<Image src={'/images/search/icon.svg'}
priority
alt={'Search bar'}
width={16}
height={16}
className={styles.image}
/>
<input className={styles.input}
type="text"
placeholder={'Search blog posts'}
onClick={() => setFormActive(true)}
onFocus={() => setFormActive(true)}
ref={searchInputRef}
value={searchQuery}
onChange={handleChange}
/>
</label>
{Boolean(searchResults?.length && isActive) && (
<ul className={styles.results}>
{searchResults?.map((post, index) => (
<li key={post.id} className={index === focusedIndex ? styles.focused : ''}>
<Link href={`/blog/${post.attributes.slug}`}>{post.attributes.title}</Link>
</li>
))}
</ul>
)}
</section>
);
};

export default SearchBar;
64 changes: 64 additions & 0 deletions next/src/app/blog/(search-bar)/styles.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
@import '@/styles/vars';

.container {
display: flex;
width: 100%;
background-color: $color-background;
margin-bottom: 2rem;
position: relative;

.label {
height: 36px;
border-radius: .35rem;
border: 1px solid darken($color-text, 40%);
width: 100%;
display: flex;
align-items: center;
padding-inline: .5rem;

.input {
background-color: inherit;
outline: none;
border: none;
height: 100%;
width: 100%;
padding-left: .3rem;
color: darken($color-text, 40%);
font-weight: 300;

&::placeholder {
color: darken($color-text, 60%);
}
}
}

.results {
overflow: hidden;
display: flex;
flex-direction: column;
background-color: $color-background;
border-radius: .35rem;
border: 1px solid darken($color-text, 40%);
width: 100%;
position: absolute;
translate: 0 100%;
bottom: -1rem;
left: 0;
padding-block: .5rem;

li {
width: 100%;

a {
display: flex;
width: 100%;
padding-inline: 1rem;
padding-block: .8rem;
}

&.focused, &:hover {
background-color: lighten($color-background, 10%);
}
}
}
}
17 changes: 17 additions & 0 deletions next/src/app/blog/(search-bar)/useDebounce.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';

export const useDebounce = <T, >(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => {
clearTimeout(handler);
};
}, [value, delay]); // Only re-call effect if value or delay changes

return debouncedValue;
};
2 changes: 2 additions & 0 deletions next/src/app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import BlogPostItem from '@/components/blog-post-item/BlogPostItem';
import React from 'react';
import { db } from '@/scripts/fetch';
import Categories from '@/components/categories/Categories';
import SearchBar from '@/app/blog/(search-bar)/SearchBar';

interface Props {
searchParams: {
Expand All @@ -20,6 +21,7 @@ const Page: NextPage<Props> = async ({ searchParams: { category } }) => {
<main>
<section className={`${gStyles.section} ${gStyles.paddingInline}`}>
<h1 className={gStyles.pageHeading}>Blog</h1>
<SearchBar />
<Categories categories={categories.data} activeCategory={category} />
<div className={`${gStyles.blogs} ${styles.blogs}`}>
{posts.data.map((post) => <BlogPostItem post={post} key={post.id} />)}
Expand Down
16 changes: 14 additions & 2 deletions next/src/components/categories/category-link/CategoryLink.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from 'next/link';
import Image from 'next/image';
import React, { FunctionComponent } from 'react';
import styles from './styles.module.scss';
import { Category } from '@/components/categories/types';
Expand All @@ -11,11 +12,22 @@ interface Props {
const CategoryLink: FunctionComponent<Props> = ({ activeCategory, name }) => {
const isActiveCategory = name === activeCategory;

const setActiveCategory = isActiveCategory ? styles.active : '';
const setActiveClassName = isActiveCategory ? styles.active : '';
const setHref = isActiveCategory ? '/blog' : `/blog?category=${name}`;

return (
<Link className={`${styles.link} ${setActiveCategory}`} href={setHref}>#{name}</Link>
<Link className={`${styles.link} ${setActiveClassName}`} href={setHref}>
<span className={styles.text}>#{name}</span>
{isActiveCategory && (
<Image src={'/images/category/x.svg'}
priority
alt={'Unselect tag'}
width={12}
height={12}
className={styles.cross}
/>
)}
</Link>
);
};

Expand Down
19 changes: 17 additions & 2 deletions next/src/components/categories/category-link/styles.module.scss
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
@import '@/styles/vars';

.link {
color: darken($color-text, 40%);
display: flex;
align-items: center;

.text {
color: darken($color-text, 40%);
}

.cross {
margin-left: .2rem;
}

&.active {
color: $color-accent;
border-radius: .35rem;
padding: .5rem;
background-color: darken($color-accent, 10%);

.text {
color: $color-text;
}
}
}
1 change: 1 addition & 0 deletions next/src/components/categories/styles.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
.categories {
margin-bottom: 3rem;
justify-content: flex-start;
align-items: center;
display: flex;
gap: .5rem;
}
16 changes: 16 additions & 0 deletions next/src/scripts/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ export const db = {
const queryString = qs.stringify(queryParams);
return await fetchWrapper<PostsFetchResponse>(`/posts?${queryString}`);
},
getPostsBySearchTerm: async (searchValue: string) => {
const queryParams = {
filters: {
title: {
$containsi: searchValue
}
},
pagination: {
pageSize: 10,
page: 1
}
};

const queryString = qs.stringify(queryParams);
return await fetchWrapper<PostsFetchResponse>(`/posts?${queryString}`);
},
getPostSlugs: async () => {
const queryParams = {
fields: ['slug']
Expand Down
2 changes: 1 addition & 1 deletion next/src/styles/about-and-blog-content.module.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
$margin-top-value: 1rem;
$margin-top-value: 1.5rem;

/**
Typography, spacings and other styles for /about and /blog/[slug]
Expand Down
1 change: 1 addition & 0 deletions next/src/styles/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
font-family: 'League Spartan', sans-serif;
color: $color-text;
text-decoration: none;
box-sizing: border-box;
}

body {
Expand Down

0 comments on commit bec6215

Please sign in to comment.