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",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"postcss": "^8.4.6",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
"tailwindcss": "^3.0.20",
|
||||
"typescript": "^4.5.5"
|
||||
}
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||
import { Icon, Icons } from "components/Icon";
|
||||
import React, {
|
||||
useRef,
|
||||
Ref,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
MouseEventHandler,
|
||||
KeyboardEvent,
|
||||
SyntheticEvent,
|
||||
useEffect,
|
||||
useState,
|
||||
|
@ -17,9 +12,9 @@ import { Backdrop, useBackdrop } from "components/layout/Backdrop";
|
|||
export interface DropdownButtonProps extends ButtonControlProps {
|
||||
icon: Icons;
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setOpen: (open: boolean) => void;
|
||||
selectedItem: string;
|
||||
setSelectedItem: Dispatch<SetStateAction<string>>;
|
||||
setSelectedItem: (value: string) => void;
|
||||
options: Array<OptionItem>;
|
||||
}
|
||||
|
||||
|
@ -38,7 +33,7 @@ export interface OptionItem {
|
|||
function Option({ option, onClick, tabIndex }: OptionProps) {
|
||||
return (
|
||||
<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}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
|
@ -73,6 +68,7 @@ export const DropdownButton = React.forwardRef<
|
|||
return () => {
|
||||
if (id) clearTimeout(id);
|
||||
};
|
||||
/* eslint-disable-next-line */
|
||||
}, [props.open]);
|
||||
|
||||
const selectedItem: OptionItem = props.options.find(
|
||||
|
@ -81,6 +77,7 @@ export const DropdownButton = React.forwardRef<
|
|||
|
||||
useEffect(() => {
|
||||
setBackdrop(props.open);
|
||||
/* eslint-disable-next-line */
|
||||
}, [props.open]);
|
||||
|
||||
const onOptionClick = (e: SyntheticEvent, option: OptionItem) => {
|
||||
|
@ -90,7 +87,7 @@ export const DropdownButton = React.forwardRef<
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full sm:w-auto min-w-[140px]">
|
||||
<div className="w-full min-w-[140px] sm:w-auto">
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative w-full sm:w-auto"
|
||||
|
@ -98,7 +95,7 @@ export const DropdownButton = React.forwardRef<
|
|||
>
|
||||
<ButtonControl
|
||||
{...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} />
|
||||
<span className="flex-1">{selectedItem.name}</span>
|
||||
|
@ -108,22 +105,20 @@ export const DropdownButton = React.forwardRef<
|
|||
/>
|
||||
</ButtonControl>
|
||||
<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
|
||||
? "opacity-100 max-h-60 block"
|
||||
: "opacity-0 max-h-0 invisible"
|
||||
? "block max-h-60 opacity-100"
|
||||
: "invisible max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{props.options
|
||||
.filter((opt) => opt.id != delayedSelectedId)
|
||||
.filter((opt) => opt.id !== delayedSelectedId)
|
||||
.map((opt) => (
|
||||
<Option
|
||||
option={opt}
|
||||
key={opt.id}
|
||||
onClick={(e) => onOptionClick(e, opt)}
|
||||
tabIndex={
|
||||
props.open ? 0 : undefined
|
||||
} /*onKeyPress={active ? handleOptionKeyPress(opt, i) : undefined}*/
|
||||
tabIndex={props.open ? 0 : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,43 +1,38 @@
|
|||
import { DropdownButton } from "./Buttons/DropdownButton";
|
||||
import { Icons } from "./Icon";
|
||||
import {
|
||||
TextInputControl,
|
||||
TextInputControlPropsNoLabel,
|
||||
} from "./TextInputs/TextInputControl";
|
||||
import { TextInputControl } 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;
|
||||
onClick?: () => void;
|
||||
placeholder?: string;
|
||||
onChange: (value: MWQuery) => void;
|
||||
value: MWQuery;
|
||||
}
|
||||
|
||||
export function SearchBarInput(props: SearchBarProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [dropdownSelected, setDropdownSelected] = useState("movie");
|
||||
|
||||
const dropdownRef = useRef<any>();
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (dropdownRef.current?.contains(e.target as Node)) {
|
||||
// inside click
|
||||
return;
|
||||
}
|
||||
// outside click
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
function setSearch(value: string) {
|
||||
props.onChange({
|
||||
...props.value,
|
||||
searchQuery: value,
|
||||
});
|
||||
}
|
||||
function setType(type: string) {
|
||||
props.onChange({
|
||||
...props.value,
|
||||
type: type as MWMediaType,
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
onChange={props.onChange}
|
||||
value={props.value}
|
||||
className="placeholder-denim-700 w-full bg-transparent flex-1 focus:outline-none text-white"
|
||||
onChange={setSearch}
|
||||
value={props.value.searchQuery}
|
||||
className="placeholder-denim-700 w-full flex-1 bg-transparent text-white focus:outline-none"
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
|
||||
|
@ -45,27 +40,26 @@ export function SearchBarInput(props: SearchBarProps) {
|
|||
icon={Icons.SEARCH}
|
||||
open={dropdownOpen}
|
||||
setOpen={setDropdownOpen}
|
||||
selectedItem={dropdownSelected}
|
||||
setSelectedItem={setDropdownSelected}
|
||||
selectedItem={props.value.type}
|
||||
setSelectedItem={setType}
|
||||
options={[
|
||||
{
|
||||
id: "movie",
|
||||
id: MWMediaType.MOVIE,
|
||||
name: "Movie",
|
||||
icon: Icons.FILM,
|
||||
},
|
||||
{
|
||||
id: "series",
|
||||
id: MWMediaType.SERIES,
|
||||
name: "Series",
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
},
|
||||
{
|
||||
id: "anime",
|
||||
id: MWMediaType.ANIME,
|
||||
name: "Anime",
|
||||
icon: Icons.DRAGON,
|
||||
},
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
ref={dropdownRef}
|
||||
>
|
||||
{props.buttonText || "Search"}
|
||||
</DropdownButton>
|
||||
|
|
|
@ -46,17 +46,19 @@ export function Backdrop(props: BackdropProps) {
|
|||
|
||||
useEffect(() => {
|
||||
setVisible(!!props.active);
|
||||
}, [props.active]);
|
||||
/* eslint-disable-next-line */
|
||||
}, [props.active, setVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) animationEvent();
|
||||
/* eslint-disable-next-line */
|
||||
}, [isVisible]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<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" : ""
|
||||
}`}
|
||||
{...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 {
|
||||
MOVIE = "movie",
|
||||
SERIES = "series",
|
||||
ANIME = "anime",
|
||||
}
|
||||
|
||||
export interface MWPortableMedia {
|
||||
|
|
|
@ -1,29 +1,38 @@
|
|||
import { WatchedMediaCard } from "components/media/WatchedMediaCard";
|
||||
import { SearchBarInput } from "components/SearchBar";
|
||||
import { MWMedia, MWMediaType, SearchProviders } from "scrapers";
|
||||
import { useState } from "react";
|
||||
import { MWMedia, MWMediaType, MWQuery, SearchProviders } from "scrapers";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThinContainer } from "components/layout/ThinContainer";
|
||||
import { SectionHeading } from "components/layout/SectionHeading";
|
||||
import { Icons } from "components/Icon";
|
||||
import { Loading } from "components/layout/Loading";
|
||||
import { Tagline } from "components/Text/Tagline";
|
||||
import { Title } from "components/Text/Title";
|
||||
import { useDebounce } from "hooks/useDebounce";
|
||||
|
||||
export function SearchView() {
|
||||
const [results, setResults] = useState<MWMedia[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [search, setSearch] = useState<MWQuery>({
|
||||
searchQuery: "",
|
||||
type: MWMediaType.MOVIE,
|
||||
});
|
||||
|
||||
async function runSearch() {
|
||||
const results = await SearchProviders({
|
||||
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);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThinContainer>
|
||||
<div className="mt-36 text-center space-y-16">
|
||||
<div className="mt-36 space-y-16 text-center">
|
||||
<div className="space-y-4">
|
||||
<Tagline>Because watching legally is boring</Tagline>
|
||||
<Title>What movie do you want to watch?</Title>
|
||||
|
@ -31,16 +40,17 @@ export function SearchView() {
|
|||
<SearchBarInput
|
||||
onChange={setSearch}
|
||||
value={search}
|
||||
onClick={runSearch}
|
||||
placeholder="What movie do you want to watch?"
|
||||
/>
|
||||
</div>
|
||||
<SectionHeading title="Yoink" icon={Icons.SEARCH}>
|
||||
{results.map((v) => (
|
||||
<WatchedMediaCard media={v} />
|
||||
))}
|
||||
</SectionHeading>
|
||||
<Loading />
|
||||
{results.length > 0 ? (
|
||||
<SectionHeading title="Search results" icon={Icons.SEARCH}>
|
||||
{results.map((v) => (
|
||||
<WatchedMediaCard media={v} />
|
||||
))}
|
||||
</SectionHeading>
|
||||
) : null}
|
||||
{search.searchQuery !== "" && results.length == 0 ? <Loading /> : null}
|
||||
</ThinContainer>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue