mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
debounced searching
Co-authored-by: William Oldham <wegg7250@gmail.com>
This commit is contained in:
parent
e75fcd3002
commit
f1ffa98a2b
8 changed files with 749 additions and 1609 deletions
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
20
src/hooks/useDebounce.ts
Normal 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;
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue