diff --git a/next/public/images/category/x.svg b/next/public/images/category/x.svg new file mode 100644 index 0000000..524c6b3 --- /dev/null +++ b/next/public/images/category/x.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/next/public/images/search/icon.svg b/next/public/images/search/icon.svg new file mode 100644 index 0000000..4f492ad --- /dev/null +++ b/next/public/images/search/icon.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/next/src/app/blog/(search-bar)/SearchBar.tsx b/next/src/app/blog/(search-bar)/SearchBar.tsx new file mode 100644 index 0000000..5ef6920 --- /dev/null +++ b/next/src/app/blog/(search-bar)/SearchBar.tsx @@ -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(null); + const [focusedIndex, setFocusedIndex] = useState(-1); + + const searchInputRef = useRef(null); + const debouncedSearchQuery = useDebounce(searchQuery, 500); + + const fetchPosts = async () => { + const posts = await db.getPostsBySearchTerm(debouncedSearchQuery); + setSearchResults(posts.data); + }; + + const handleBlur = (e: React.FocusEvent) => { + 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) => { + 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 ( +
+ + {Boolean(searchResults?.length && isActive) && ( +
    + {searchResults?.map((post, index) => ( +
  • + {post.attributes.title} +
  • + ))} +
+ )} +
+ ); +}; + +export default SearchBar; \ No newline at end of file diff --git a/next/src/app/blog/(search-bar)/styles.module.scss b/next/src/app/blog/(search-bar)/styles.module.scss new file mode 100644 index 0000000..75ac984 --- /dev/null +++ b/next/src/app/blog/(search-bar)/styles.module.scss @@ -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%); + } + } + } +} \ No newline at end of file diff --git a/next/src/app/blog/(search-bar)/useDebounce.tsx b/next/src/app/blog/(search-bar)/useDebounce.tsx new file mode 100644 index 0000000..b1c2493 --- /dev/null +++ b/next/src/app/blog/(search-bar)/useDebounce.tsx @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); // Only re-call effect if value or delay changes + + return debouncedValue; +}; diff --git a/next/src/app/blog/page.tsx b/next/src/app/blog/page.tsx index bb103b5..a75f8cf 100644 --- a/next/src/app/blog/page.tsx +++ b/next/src/app/blog/page.tsx @@ -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: { @@ -20,6 +21,7 @@ const Page: NextPage = async ({ searchParams: { category } }) => {

Blog

+
{posts.data.map((post) => )} diff --git a/next/src/components/categories/category-link/CategoryLink.tsx b/next/src/components/categories/category-link/CategoryLink.tsx index 18d09bd..f291f0e 100644 --- a/next/src/components/categories/category-link/CategoryLink.tsx +++ b/next/src/components/categories/category-link/CategoryLink.tsx @@ -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'; @@ -11,11 +12,22 @@ interface Props { const CategoryLink: FunctionComponent = ({ activeCategory, name }) => { const isActiveCategory = name === activeCategory; - const setActiveCategory = isActiveCategory ? styles.active : ''; + const setActiveClassName = isActiveCategory ? styles.active : ''; const setHref = isActiveCategory ? '/blog' : `/blog?category=${name}`; return ( - #{name} + + #{name} + {isActiveCategory && ( + {'Unselect + )} + ); }; diff --git a/next/src/components/categories/category-link/styles.module.scss b/next/src/components/categories/category-link/styles.module.scss index 8329331..57868ce 100644 --- a/next/src/components/categories/category-link/styles.module.scss +++ b/next/src/components/categories/category-link/styles.module.scss @@ -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; + } } } \ No newline at end of file diff --git a/next/src/components/categories/styles.module.scss b/next/src/components/categories/styles.module.scss index e150921..9675e5c 100644 --- a/next/src/components/categories/styles.module.scss +++ b/next/src/components/categories/styles.module.scss @@ -3,6 +3,7 @@ .categories { margin-bottom: 3rem; justify-content: flex-start; + align-items: center; display: flex; gap: .5rem; } \ No newline at end of file diff --git a/next/src/scripts/fetch.ts b/next/src/scripts/fetch.ts index 72113e1..b7f9032 100644 --- a/next/src/scripts/fetch.ts +++ b/next/src/scripts/fetch.ts @@ -66,6 +66,22 @@ export const db = { const queryString = qs.stringify(queryParams); return await fetchWrapper(`/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(`/posts?${queryString}`); + }, getPostSlugs: async () => { const queryParams = { fields: ['slug'] diff --git a/next/src/styles/about-and-blog-content.module.scss b/next/src/styles/about-and-blog-content.module.scss index e1a5c5c..d07bb7c 100644 --- a/next/src/styles/about-and-blog-content.module.scss +++ b/next/src/styles/about-and-blog-content.module.scss @@ -1,4 +1,4 @@ -$margin-top-value: 1rem; +$margin-top-value: 1.5rem; /** Typography, spacings and other styles for /about and /blog/[slug] diff --git a/next/src/styles/global.scss b/next/src/styles/global.scss index cc2ae57..3922e6d 100644 --- a/next/src/styles/global.scss +++ b/next/src/styles/global.scss @@ -4,6 +4,7 @@ font-family: 'League Spartan', sans-serif; color: $color-text; text-decoration: none; + box-sizing: border-box; } body {