debounced searching

Co-authored-by: William Oldham <wegg7250@gmail.com>
This commit is contained in:
Jelle van Snik 2022-02-13 19:29:25 +01:00
parent e75fcd3002
commit f1ffa98a2b
8 changed files with 749 additions and 1609 deletions

View File

@ -49,6 +49,8 @@
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"postcss": "^8.4.6", "postcss": "^8.4.6",
"prettier": "^2.5.1",
"prettier-plugin-tailwindcss": "^0.1.7",
"tailwindcss": "^3.0.20", "tailwindcss": "^3.0.20",
"typescript": "^4.5.5" "typescript": "^4.5.5"
} }

View File

@ -1,12 +1,7 @@
import { ButtonControlProps, ButtonControl } from "./ButtonControl"; import { ButtonControlProps, ButtonControl } from "./ButtonControl";
import { Icon, Icons } from "components/Icon"; import { Icon, Icons } from "components/Icon";
import React, { import React, {
useRef,
Ref,
Dispatch,
SetStateAction,
MouseEventHandler, MouseEventHandler,
KeyboardEvent,
SyntheticEvent, SyntheticEvent,
useEffect, useEffect,
useState, useState,
@ -17,9 +12,9 @@ import { Backdrop, useBackdrop } from "components/layout/Backdrop";
export interface DropdownButtonProps extends ButtonControlProps { export interface DropdownButtonProps extends ButtonControlProps {
icon: Icons; icon: Icons;
open: boolean; open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>; setOpen: (open: boolean) => void;
selectedItem: string; selectedItem: string;
setSelectedItem: Dispatch<SetStateAction<string>>; setSelectedItem: (value: string) => void;
options: Array<OptionItem>; options: Array<OptionItem>;
} }
@ -38,7 +33,7 @@ export interface OptionItem {
function Option({ option, onClick, tabIndex }: OptionProps) { function Option({ option, onClick, tabIndex }: OptionProps) {
return ( return (
<div <div
className="text-denim-700 h-10 px-4 py-2 text-left cursor-pointer flex items-center space-x-2 hover:text-white transition-colors" className="text-denim-700 flex h-10 cursor-pointer items-center space-x-2 px-4 py-2 text-left transition-colors hover:text-white"
onClick={onClick} onClick={onClick}
tabIndex={tabIndex} tabIndex={tabIndex}
> >
@ -73,6 +68,7 @@ export const DropdownButton = React.forwardRef<
return () => { return () => {
if (id) clearTimeout(id); if (id) clearTimeout(id);
}; };
/* eslint-disable-next-line */
}, [props.open]); }, [props.open]);
const selectedItem: OptionItem = props.options.find( const selectedItem: OptionItem = props.options.find(
@ -81,6 +77,7 @@ export const DropdownButton = React.forwardRef<
useEffect(() => { useEffect(() => {
setBackdrop(props.open); setBackdrop(props.open);
/* eslint-disable-next-line */
}, [props.open]); }, [props.open]);
const onOptionClick = (e: SyntheticEvent, option: OptionItem) => { const onOptionClick = (e: SyntheticEvent, option: OptionItem) => {
@ -90,7 +87,7 @@ export const DropdownButton = React.forwardRef<
}; };
return ( return (
<div className="w-full sm:w-auto min-w-[140px]"> <div className="w-full min-w-[140px] sm:w-auto">
<div <div
ref={ref} ref={ref}
className="relative w-full sm:w-auto" className="relative w-full sm:w-auto"
@ -98,7 +95,7 @@ export const DropdownButton = React.forwardRef<
> >
<ButtonControl <ButtonControl
{...props} {...props}
className="flex items-center justify-center sm:justify-left px-4 py-2 space-x-2 bg-bink-200 relative z-20 hover:bg-bink-300 text-white h-10 rounded-[20px] w-full" className="sm:justify-left bg-bink-200 hover:bg-bink-300 relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] px-4 py-2 text-white"
> >
<Icon icon={selectedItem.icon} /> <Icon icon={selectedItem.icon} />
<span className="flex-1">{selectedItem.name}</span> <span className="flex-1">{selectedItem.name}</span>
@ -108,22 +105,20 @@ export const DropdownButton = React.forwardRef<
/> />
</ButtonControl> </ButtonControl>
<div <div
className={`absolute pt-[40px] top-0 duration-200 transition-all w-full rounded-[20px] z-10 bg-denim-300 ${ className={`bg-denim-300 absolute top-0 z-10 w-full rounded-[20px] pt-[40px] transition-all duration-200 ${
props.open props.open
? "opacity-100 max-h-60 block" ? "block max-h-60 opacity-100"
: "opacity-0 max-h-0 invisible" : "invisible max-h-0 opacity-0"
}`} }`}
> >
{props.options {props.options
.filter((opt) => opt.id != delayedSelectedId) .filter((opt) => opt.id !== delayedSelectedId)
.map((opt) => ( .map((opt) => (
<Option <Option
option={opt} option={opt}
key={opt.id} key={opt.id}
onClick={(e) => onOptionClick(e, opt)} onClick={(e) => onOptionClick(e, opt)}
tabIndex={ tabIndex={props.open ? 0 : undefined}
props.open ? 0 : undefined
} /*onKeyPress={active ? handleOptionKeyPress(opt, i) : undefined}*/
/> />
))} ))}
</div> </div>

View File

@ -1,43 +1,38 @@
import { DropdownButton } from "./Buttons/DropdownButton"; import { DropdownButton } from "./Buttons/DropdownButton";
import { Icons } from "./Icon"; import { Icons } from "./Icon";
import { import { TextInputControl } from "./TextInputs/TextInputControl";
TextInputControl,
TextInputControlPropsNoLabel,
} from "./TextInputs/TextInputControl";
import { useState, useRef, useEffect } from "react"; import { useState } from "react";
import { MWMediaType, MWQuery } from "scrapers";
export interface SearchBarProps extends TextInputControlPropsNoLabel { export interface SearchBarProps {
buttonText?: string; buttonText?: string;
onClick?: () => void;
placeholder?: string; placeholder?: string;
onChange: (value: MWQuery) => void;
value: MWQuery;
} }
export function SearchBarInput(props: SearchBarProps) { export function SearchBarInput(props: SearchBarProps) {
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [dropdownSelected, setDropdownSelected] = useState("movie"); function setSearch(value: string) {
props.onChange({
const dropdownRef = useRef<any>(); ...props.value,
searchQuery: value,
const handleClick = (e: MouseEvent) => { });
if (dropdownRef.current?.contains(e.target as Node)) { }
// inside click function setType(type: string) {
return; props.onChange({
...props.value,
type: type as MWMediaType,
});
} }
// outside click
closeDropdown();
};
const closeDropdown = () => {
setDropdownOpen(false);
};
return ( return (
<div className="flex flex-col sm:flex-row items-center gap-4 px-4 py-4 sm:pl-8 sm:pr-2 sm:py-2 bg-denim-300 rounded-[28px] hover:bg-denim-400 focus-within:bg-denim-400 transition-colors"> <div className="bg-denim-300 hover:bg-denim-400 focus-within:bg-denim-400 flex flex-col items-center gap-4 rounded-[28px] px-4 py-4 transition-colors sm:flex-row sm:py-2 sm:pl-8 sm:pr-2">
<TextInputControl <TextInputControl
onChange={props.onChange} onChange={setSearch}
value={props.value} value={props.value.searchQuery}
className="placeholder-denim-700 w-full bg-transparent flex-1 focus:outline-none text-white" className="placeholder-denim-700 w-full flex-1 bg-transparent text-white focus:outline-none"
placeholder={props.placeholder} placeholder={props.placeholder}
/> />
@ -45,27 +40,26 @@ export function SearchBarInput(props: SearchBarProps) {
icon={Icons.SEARCH} icon={Icons.SEARCH}
open={dropdownOpen} open={dropdownOpen}
setOpen={setDropdownOpen} setOpen={setDropdownOpen}
selectedItem={dropdownSelected} selectedItem={props.value.type}
setSelectedItem={setDropdownSelected} setSelectedItem={setType}
options={[ options={[
{ {
id: "movie", id: MWMediaType.MOVIE,
name: "Movie", name: "Movie",
icon: Icons.FILM, icon: Icons.FILM,
}, },
{ {
id: "series", id: MWMediaType.SERIES,
name: "Series", name: "Series",
icon: Icons.CLAPPER_BOARD, icon: Icons.CLAPPER_BOARD,
}, },
{ {
id: "anime", id: MWMediaType.ANIME,
name: "Anime", name: "Anime",
icon: Icons.DRAGON, icon: Icons.DRAGON,
}, },
]} ]}
onClick={() => setDropdownOpen((old) => !old)} onClick={() => setDropdownOpen((old) => !old)}
ref={dropdownRef}
> >
{props.buttonText || "Search"} {props.buttonText || "Search"}
</DropdownButton> </DropdownButton>

View File

@ -46,17 +46,19 @@ export function Backdrop(props: BackdropProps) {
useEffect(() => { useEffect(() => {
setVisible(!!props.active); setVisible(!!props.active);
}, [props.active]); /* eslint-disable-next-line */
}, [props.active, setVisible]);
useEffect(() => { useEffect(() => {
if (!isVisible) animationEvent(); if (!isVisible) animationEvent();
/* eslint-disable-next-line */
}, [isVisible]); }, [isVisible]);
if (!isVisible) return null; if (!isVisible) return null;
return ( return (
<div <div
className={`fixed h-screen z-[999] top-0 left-0 right-0 bg-black bg-opacity-50 transition-opacity opacity-100 ${ className={`fixed top-0 left-0 right-0 z-[999] h-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
!isVisible ? "opacity-0" : "" !isVisible ? "opacity-0" : ""
}`} }`}
{...fadeProps} {...fadeProps}

20
src/hooks/useDebounce.ts Normal file
View File

@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(
() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
},
[value, delay]
);
return debouncedValue;
}

View File

@ -1,6 +1,7 @@
export enum MWMediaType { export enum MWMediaType {
MOVIE = "movie", MOVIE = "movie",
SERIES = "series", SERIES = "series",
ANIME = "anime",
} }
export interface MWPortableMedia { export interface MWPortableMedia {

View File

@ -1,29 +1,38 @@
import { WatchedMediaCard } from "components/media/WatchedMediaCard"; import { WatchedMediaCard } from "components/media/WatchedMediaCard";
import { SearchBarInput } from "components/SearchBar"; import { SearchBarInput } from "components/SearchBar";
import { MWMedia, MWMediaType, SearchProviders } from "scrapers"; import { MWMedia, MWMediaType, MWQuery, SearchProviders } from "scrapers";
import { useState } from "react"; import { useEffect, useState } from "react";
import { ThinContainer } from "components/layout/ThinContainer"; import { ThinContainer } from "components/layout/ThinContainer";
import { SectionHeading } from "components/layout/SectionHeading"; import { SectionHeading } from "components/layout/SectionHeading";
import { Icons } from "components/Icon"; import { Icons } from "components/Icon";
import { Loading } from "components/layout/Loading"; import { Loading } from "components/layout/Loading";
import { Tagline } from "components/Text/Tagline"; import { Tagline } from "components/Text/Tagline";
import { Title } from "components/Text/Title"; import { Title } from "components/Text/Title";
import { useDebounce } from "hooks/useDebounce";
export function SearchView() { export function SearchView() {
const [results, setResults] = useState<MWMedia[]>([]); const [results, setResults] = useState<MWMedia[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState<MWQuery>({
searchQuery: "",
async function runSearch() {
const results = await SearchProviders({
type: MWMediaType.MOVIE, type: MWMediaType.MOVIE,
searchQuery: search,
}); });
const debouncedSearch = useDebounce<MWQuery>(search, 2000);
useEffect(() => {
if (debouncedSearch.searchQuery !== "") runSearch(debouncedSearch);
}, [debouncedSearch]);
useEffect(() => {
setResults([]);
}, [search]);
async function runSearch(query: MWQuery) {
const results = await SearchProviders(query);
setResults(results); setResults(results);
} }
return ( return (
<ThinContainer> <ThinContainer>
<div className="mt-36 text-center space-y-16"> <div className="mt-36 space-y-16 text-center">
<div className="space-y-4"> <div className="space-y-4">
<Tagline>Because watching legally is boring</Tagline> <Tagline>Because watching legally is boring</Tagline>
<Title>What movie do you want to watch?</Title> <Title>What movie do you want to watch?</Title>
@ -31,16 +40,17 @@ export function SearchView() {
<SearchBarInput <SearchBarInput
onChange={setSearch} onChange={setSearch}
value={search} value={search}
onClick={runSearch}
placeholder="What movie do you want to watch?" placeholder="What movie do you want to watch?"
/> />
</div> </div>
<SectionHeading title="Yoink" icon={Icons.SEARCH}> {results.length > 0 ? (
<SectionHeading title="Search results" icon={Icons.SEARCH}>
{results.map((v) => ( {results.map((v) => (
<WatchedMediaCard media={v} /> <WatchedMediaCard media={v} />
))} ))}
</SectionHeading> </SectionHeading>
<Loading /> ) : null}
{search.searchQuery !== "" && results.length == 0 ? <Loading /> : null}
</ThinContainer> </ThinContainer>
); );
} }

2198
yarn.lock

File diff suppressed because it is too large Load Diff