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

#24 #29

Merged
merged 7 commits into from
Feb 25, 2024
Merged

#24 #29

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
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
Loading