diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 968ec5fe9..8aba03437 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -73,7 +73,10 @@ const Discover: React.FC = () => { ); diff --git a/src/components/Layout/SearchInput/index.tsx b/src/components/Layout/SearchInput/index.tsx new file mode 100644 index 000000000..eb6bedc78 --- /dev/null +++ b/src/components/Layout/SearchInput/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import useSearchInput from '../../../hooks/useSearchInput'; + +const SearchInput: React.FC = () => { + const { searchValue, setSearchValue, setIsOpen } = useSearchInput(); + return ( +
+
+ +
+
+ + + +
+ setSearchValue(e.target.value)} + onFocus={() => setIsOpen(true)} + onBlur={() => setIsOpen(false)} + /> +
+
+
+ ); +}; + +export default SearchInput; diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 1df4cac48..ddcf941a4 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import Transition from '../../Transition'; +import Link from 'next/link'; interface SidebarProps { open?: boolean; @@ -62,26 +63,25 @@ const Sidebar: React.FC = ({ open, setClosed }) => { @@ -102,26 +102,25 @@ const Sidebar: React.FC = ({ open, setClosed }) => { Overseerr diff --git a/src/components/Layout/UserDropdown/index.tsx b/src/components/Layout/UserDropdown/index.tsx index de750f66a..cadd64204 100644 --- a/src/components/Layout/UserDropdown/index.tsx +++ b/src/components/Layout/UserDropdown/index.tsx @@ -1,11 +1,14 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import Transition from '../../Transition'; import { useUser } from '../../../hooks/useUser'; import axios from 'axios'; +import useClickOutside from '../../../hooks/useClickOutside'; const UserDropdown: React.FC = () => { + const dropdownRef = useRef(null); const { user, revalidate } = useUser(); const [isDropdownOpen, setDropdownOpen] = useState(false); + useClickOutside(dropdownRef, () => setDropdownOpen(false)); const logout = async () => { const response = await axios.get('/api/v1/auth/logout'); @@ -37,7 +40,10 @@ const UserDropdown: React.FC = () => { leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > -
+
{
- +
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index e9bdfdbf8..3437f7043 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,31 +1,141 @@ import React from 'react'; +import { useRouter } from 'next/router'; +import { + TvResult, + MovieResult, + PersonResult, +} from '../../../server/models/Search'; +import { useSWRInfinite } from 'swr'; +import useVerticalScroll from '../../hooks/useVerticalScroll'; +import TitleCard from '../TitleCard'; + +interface SearchResult { + page: number; + totalResults: number; + totalPages: number; + results: (MovieResult | TvResult | PersonResult)[]; +} const Search: React.FC = () => { + const router = useRouter(); + const { data, error, size, setSize } = useSWRInfinite( + (pageIndex: number, previousPageData: SearchResult | null) => { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + return null; + } + + return `/api/v1/search/?query=${router.query.query}&page=${ + pageIndex + 1 + }`; + }, + { + initialSize: 3, + } + ); + + const isLoadingInitialData = !data && !error; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === 'undefined'); + + useVerticalScroll(() => { + setSize(size + 1); + }, !isLoadingMore && !isLoadingInitialData); + + if (error) { + return
{error}
; + } + + const titles = data?.reduce( + (a, v) => [...a, ...v.results], + [] as (MovieResult | TvResult | PersonResult)[] + ); + return ( -
-
- -
-
- - - -
- + <> +
+
+

+ Search Results +

+
+
+ + + +
- -
+
+ {!isLoadingInitialData && titles?.length === 0 && ( +
+ No Results +
+ )} +
    + {titles?.map((title) => { + let titleCard: React.ReactNode; + + switch (title.mediaType) { + case 'movie': + titleCard = ( + + ); + break; + case 'tv': + titleCard = ( + + ); + break; + case 'person': + titleCard =
    {title.name}
    ; + break; + } + + return ( +
  • + {titleCard} +
  • + ); + })} + {(isLoadingInitialData || + (isLoadingMore && (titles?.length ?? 0) > 0)) && + [...Array(8)].map((_item, i) => ( +
  • + +
  • + ))} +
+ ); }; diff --git a/src/components/TitleCard/Placeholder.tsx b/src/components/TitleCard/Placeholder.tsx new file mode 100644 index 000000000..264f6a670 --- /dev/null +++ b/src/components/TitleCard/Placeholder.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +const Placeholder: React.FC = () => { + return ( +
+ ); +}; + +export default Placeholder; diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index e767ec64a..4066db658 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -1,5 +1,7 @@ import React, { useState } from 'react'; import Transition from '../Transition'; +import { withProperties } from '../../utils/typeHelpers'; +import Placeholder from './Placeholder'; interface TitleCardProps { image: string; @@ -93,4 +95,4 @@ const TitleCard: React.FC = ({ ); }; -export default TitleCard; +export default withProperties(TitleCard, { Placeholder }); diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts new file mode 100644 index 000000000..211349251 --- /dev/null +++ b/src/hooks/useClickOutside.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +/** + * useClickOutside + * + * Simple hook to add an event listener to the body and allow a callback to + * be triggered when clicking outside of the target ref + * + * @param ref Any HTML Element ref + * @param callback Callback triggered when clicking outside of ref element + */ +const useClickOutside = ( + ref: React.RefObject, + callback: (e: MouseEvent) => void +): void => { + useEffect(() => { + const handleBodyClick = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) { + callback(e); + } + }; + document.body.addEventListener('click', handleBodyClick); + + return () => { + document.body.removeEventListener('click', handleBodyClick); + }; + }, [ref, callback]); +}; + +export default useClickOutside; diff --git a/src/hooks/useDebouncedState.ts b/src/hooks/useDebouncedState.ts new file mode 100644 index 000000000..a73e3a06a --- /dev/null +++ b/src/hooks/useDebouncedState.ts @@ -0,0 +1,33 @@ +import { useState, useEffect, Dispatch, SetStateAction } from 'react'; + +/** + * A hook to help with debouncing state + * + * This hook basically acts the same as useState except it is also + * returning a deobuncedValue that can be used for things like + * debouncing input into a search field + * + * @param initialValue Initial state value + * @param debounceTime Debounce time in ms + */ +const useDebouncedState = ( + initialValue: S, + debounceTime = 300 +): [S, S, Dispatch>] => { + const [value, setValue] = useState(initialValue); + const [finalValue, setFinalValue] = useState(initialValue); + + useEffect(() => { + const timeout = setTimeout(() => { + setFinalValue(value); + }, debounceTime); + + return () => { + clearTimeout(timeout); + }; + }, [value, debounceTime]); + + return [value, finalValue, setValue]; +}; + +export default useDebouncedState; diff --git a/src/hooks/useSearchInput.ts b/src/hooks/useSearchInput.ts new file mode 100644 index 000000000..61df92e0a --- /dev/null +++ b/src/hooks/useSearchInput.ts @@ -0,0 +1,115 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import type { UrlObject } from 'url'; +import { useEffect, useState, Dispatch, SetStateAction } from 'react'; +import useDebouncedState from './useDebouncedState'; +import { useRouter } from 'next/router'; +import type { Nullable } from '../utils/typeHelpers'; + +type Url = string | UrlObject; + +interface SearchObject { + searchValue: string; + searchOpen: boolean; + setIsOpen: Dispatch>; + setSearchValue: Dispatch>; + clear: () => void; +} + +const useSearchInput = (): SearchObject => { + const router = useRouter(); + const [searchOpen, setIsOpen] = useState(false); + const [lastRoute, setLastRoute] = useState>(null); + const [searchValue, debouncedValue, setSearchValue] = useDebouncedState( + (router.query.query as string) ?? '' + ); + + /** + * This effect handles routing when the debounced search input + * value changes. + * + * If we are not already on the /search route, then we push + * in a new route. If we are, then we only replace the history. + */ + useEffect(() => { + if (debouncedValue !== '') { + if (router.pathname.startsWith('/search')) { + router.replace({ + pathname: router.pathname, + query: { ...router.query, query: debouncedValue }, + }); + } else { + setLastRoute(router.asPath); + router + .push({ + pathname: '/search', + query: { query: debouncedValue }, + }) + .then(() => window.scrollTo(0, 0)); + } + } + }, [debouncedValue]); + + /** + * This effect is handling behavior when the search input is closed. + * + * If we have a lastRoute, we will route back to it. If we don't + * (in the case of a deeplink) we take the user back to the index route + */ + useEffect(() => { + if ( + searchValue === '' && + router.pathname.startsWith('/search') && + !searchOpen + ) { + if (lastRoute) { + router.push(lastRoute).then(() => window.scrollTo(0, 0)); + } else { + router.replace('/').then(() => window.scrollTo(0, 0)); + } + } + }, [searchOpen]); + + /** + * This effect handles behavior for when the route is changed. + * + * If after a route change, the new debounced value is not the same + * as the query value then we will update the searchValue to either the + * new query or to an empty string (in the case of null). This makes sure + * that the value in the searchbox is whatever the user last entered regardless + * of routing to something like a detail page. + * + * If the new route is not /search and query is null, then we will close the + * search if it is open. + * + * In the final case, we want the search to always be open in the case the user + * is on /search + */ + useEffect(() => { + if (router.query.query !== debouncedValue) { + setSearchValue((router.query.query as string) ?? ''); + + if (!router.pathname.startsWith('/search') && !router.query.query) { + setIsOpen(false); + } + } + + if (router.pathname.startsWith('/search')) { + setIsOpen(true); + } + }, [router, setSearchValue]); + + const clear = () => { + setIsOpen(false); + setSearchValue(''); + }; + + return { + searchValue, + searchOpen, + setIsOpen, + setSearchValue, + clear, + }; +}; + +export default useSearchInput; diff --git a/src/pages/search.tsx b/src/pages/search.tsx new file mode 100644 index 000000000..05ed18a80 --- /dev/null +++ b/src/pages/search.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import Search from '../components/Search'; + +const SearchPage: React.FC = () => { + return ; +}; + +export default SearchPage; diff --git a/src/styles/globals.css b/src/styles/globals.css index 477388114..3dbbc1786 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -16,6 +16,6 @@ body { } .titleCard { - @apply relative bg-cover rounded-lg; + @apply relative bg-cover rounded-lg bg-cool-gray-800; padding-bottom: 150%; } diff --git a/src/utils/typeHelpers.ts b/src/utils/typeHelpers.ts index 2f47239c7..4d5cc1242 100644 --- a/src/utils/typeHelpers.ts +++ b/src/utils/typeHelpers.ts @@ -1,3 +1,17 @@ export type Undefinable = T | undefined; export type Nullable = T | null; export type Maybe = T | null | undefined; + +/** + * Helps type objects with an abitrary number of properties that are + * usually being defined at export. + * + * @param component Main object you want to apply properties to + * @param properties Object of properties you want to type on the main component + */ +export function withProperties(component: A, properties: B): A & B { + (Object.keys(properties) as (keyof B)[]).forEach((key) => { + Object.assign(component, { [key]: properties[key] }); + }); + return component as A & B; +}