diff --git a/package.json b/package.json index 219b7443..1e474970 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "react-youtube": "^7.14.0", "setimmediate": "^1.0.5", "typescript": "4.6.3", + "use-debounce": "^8.0.2", "workbox-background-sync": "^5.1.3", "workbox-broadcast-update": "^5.1.3", "workbox-cacheable-response": "^5.1.3", diff --git a/src/components/nav/NavBar.tsx b/src/components/nav/NavBar.tsx index a015af92..c214283e 100644 --- a/src/components/nav/NavBar.tsx +++ b/src/components/nav/NavBar.tsx @@ -19,12 +19,13 @@ import { useTranslation } from "react-i18next"; import { FiMenu, FiChevronDown } from "react-icons/fi"; import { Link } from "react-router-dom"; import { useClient } from "../../modules/client"; -import { Searchbox } from "./Searchbox"; +import { SearchBox } from "./SearchBox"; import { LogoWithText } from "./LogoWithText"; interface MobileProps extends FlexProps { onOpen: () => void; } + export function NavBar({ onOpen, ...rest }: MobileProps) { const { t } = useTranslation(); const { isLoggedIn, logout, user } = useClient(); @@ -57,7 +58,7 @@ export function NavBar({ onOpen, ...rest }: MobileProps) { /> - + diff --git a/src/components/nav/Searchbox.tsx b/src/components/nav/SearchBox.tsx similarity index 93% rename from src/components/nav/Searchbox.tsx rename to src/components/nav/SearchBox.tsx index ce90c13a..7b4e784a 100644 --- a/src/components/nav/Searchbox.tsx +++ b/src/components/nav/SearchBox.tsx @@ -14,24 +14,24 @@ import { RiSearch2Line } from "react-icons/ri"; import { useNavigate } from "react-router"; import { useSearchParams } from "react-router-dom"; -export function Searchbox(props: BoxProps) { +export function SearchBox(props: BoxProps) { const { t } = useTranslation(); let [isFocused, setFocused] = useState(false); let navigate = useNavigate(); let [searchParams] = useSearchParams(); let [currentValue, setValue] = useState(""); - const input = useRef(); + const input = useRef(null); useEffect(() => { const q = searchParams.get("q"); - const prettyValue = q ? JSON.parse(q) : ""; + const prettyValue: string = q ? JSON.parse(q) : ""; setValue(prettyValue); - input.current.value = prettyValue; + input.current!.value = prettyValue; }, [searchParams]); useHotkeys("ctrl+l, cmd+l, cmd+alt+f", (e) => { e.preventDefault(); - input.current.focus(); + input.current!.focus(); }); const submitHandler = (e: FormEvent) => { diff --git a/src/components/search/CheckboxSearchList.tsx b/src/components/search/CheckboxSearchList.tsx new file mode 100644 index 00000000..eae6a5e6 --- /dev/null +++ b/src/components/search/CheckboxSearchList.tsx @@ -0,0 +1,116 @@ +import { + Checkbox, + CheckboxGroup, + IconButton, + Input, + InputGroup, + InputRightElement, + Tag, + VStack, +} from "@chakra-ui/react"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RiCloseFill } from "react-icons/ri"; + +interface CheckboxSearchListProps { + dataField: string; + placeholder?: string; + showSearch?: boolean; + aggregations: { + [k: string]: { buckets: Array<{ key: string; doc_count: number }> }; + }; + setQuery: (query: any) => void; + value: string[] | null; + tagLabel?: string; +} + +export const CheckboxSearchList = ({ + dataField, + placeholder, + showSearch = false, + aggregations, + setQuery, + value, + tagLabel, +}: CheckboxSearchListProps) => { + const { t } = useTranslation(); + const [filterValue, setFilterValue] = useState(""); + const [checkboxValues, setCheckboxValues] = useState>( + value!, + ); + + const getTermsQuery = useCallback( + (values: typeof checkboxValues) => { + if (!values?.length) return {}; + + return { query: { terms: { [dataField]: values } } }; + }, + [dataField], + ); + + useEffect(() => { + setQuery({ + value: checkboxValues, + query: getTermsQuery(checkboxValues), + }); + }, [checkboxValues, getTermsQuery, setQuery]); + + // Support resetting from SelectedFilters + useEffect(() => { + if (value === null) setCheckboxValues([]); + }, [value]); + + if (!aggregations?.[dataField]?.buckets?.length) { + return null; + } + + return ( + <> + {tagLabel && ( + + {tagLabel} + + )} + {showSearch && ( + + setFilterValue(e.target.value)} + placeholder={placeholder!} + /> + + {filterValue && ( + } + type="button" + title={t("Clear")} + onClick={() => setFilterValue("")} + > + )} + + + )} + setCheckboxValues(e)} + > + + {aggregations?.[dataField]?.buckets + ?.filter(({ key }) => + key.toLowerCase().includes(filterValue.toLowerCase()), + ) + .map(({ key }) => ( + + {key} + + ))} + + + + ); +}; diff --git a/src/components/search/GeneralSearchInput.tsx b/src/components/search/GeneralSearchInput.tsx new file mode 100644 index 00000000..d9fe15e8 --- /dev/null +++ b/src/components/search/GeneralSearchInput.tsx @@ -0,0 +1,84 @@ +import { + IconButton, + Input, + InputGroup, + InputRightElement, + Tag, +} from "@chakra-ui/react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RiSearch2Line } from "react-icons/ri"; +import { useDebounce } from "use-debounce"; + +interface GeneralInputProps { + debounceValue?: number; + placeholder?: string; + getQuery: (q: string) => object; + setQuery: (query: { value?: string; query?: any; opts?: any }) => void; + value: string | null; + tagLabel?: string; +} + +export const GeneralSearchInput = ({ + debounceValue = 1000, + placeholder, + getQuery, + setQuery, + value, + tagLabel, +}: GeneralInputProps) => { + const { t } = useTranslation(); + const [searchText, setSearchText] = useState(value!); + const [debouncedSearchText, { flush }] = useDebounce( + searchText, + debounceValue, + ); + + useEffect(() => { + setQuery({ + value: debouncedSearchText, + query: getQuery(debouncedSearchText), + }); + }, [debouncedSearchText, getQuery, setQuery]); + + // Support resetting from SelectedFilters + useEffect(() => { + if (value === null) setSearchText(""); + }, [value]); + + return ( + <> + {tagLabel && ( + + {tagLabel} + + )} +
{ + e.preventDefault(); + flush(); + }} + > + + setSearchText(e.target.value)} + placeholder={placeholder} + > + + } + type="submit" + title={t("Search")} + > + + +
+ + ); +}; diff --git a/src/components/search/RadioButtonSearchList.tsx b/src/components/search/RadioButtonSearchList.tsx new file mode 100644 index 00000000..9b68ad29 --- /dev/null +++ b/src/components/search/RadioButtonSearchList.tsx @@ -0,0 +1,111 @@ +import { + Radio, + RadioGroup, + Input, + VStack, + Tag, + InputRightElement, + InputGroup, + IconButton, +} from "@chakra-ui/react"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RiCloseFill } from "react-icons/ri"; + +interface RadioButtonSearchListProps { + dataField: string; + placeholder?: string; + showSearch?: boolean; + aggregations: { + [k: string]: { buckets: Array<{ key: string; doc_count: number }> }; + }; + setQuery: (query: any) => void; + value: string | null; + tagLabel?: string; +} + +export const RadioButtonSearchList = ({ + dataField, + placeholder, + showSearch = false, + aggregations, + setQuery, + value, + tagLabel, +}: RadioButtonSearchListProps) => { + const { t } = useTranslation(); + const [filterValue, setFilterValue] = useState(""); + const [radioValue, setRadioValue] = useState(value!); + + const getQuery = useCallback( + (value: string) => { + if (!value) return {}; + + return { query: { term: { [dataField]: value } } }; + }, + [dataField], + ); + + useEffect(() => { + setQuery({ + value: radioValue, + query: getQuery(radioValue), + }); + }, [radioValue, getQuery, setQuery]); + + // Support resetting from SelectedFilters + useEffect(() => { + if (value === null) setRadioValue(""); + }, [value]); + + if (!aggregations?.[dataField]?.buckets?.length) { + return null; + } + + return ( + <> + {tagLabel && ( + + {tagLabel} + + )} + {showSearch && ( + + setFilterValue(e.target.value)} + placeholder={placeholder!} + /> + + {filterValue && ( + } + type="button" + title={t("Clear")} + onClick={() => setFilterValue("")} + > + )} + + + )} + setRadioValue(value)}> + + {aggregations?.[dataField]?.buckets + ?.filter(({ key }) => + key.toLowerCase().includes(filterValue.toLowerCase()), + ) + .map(({ key }) => ( + + {key} + + ))} + + + + ); +}; diff --git a/src/components/search/ToggleButtonSearchInput.tsx b/src/components/search/ToggleButtonSearchInput.tsx new file mode 100644 index 00000000..5f7c0634 --- /dev/null +++ b/src/components/search/ToggleButtonSearchInput.tsx @@ -0,0 +1,70 @@ +import { Button, HStack, Tag } from "@chakra-ui/react"; +import { ReactElement, useCallback, useEffect, useState } from "react"; + +interface ToggleButtonSearchInputProps { + dataField: string; + buttons: Array<{ label: string; value: string; icon?: ReactElement }>; + setQuery: (query: { value?: string; query?: any; opts?: any }) => void; + value: string | null; + tagLabel?: string; +} + +export const ToggleButtonSearchInput = ({ + dataField, + setQuery, + value, + buttons, + tagLabel, +}: ToggleButtonSearchInputProps) => { + const [buttonValue, setButtonValue] = useState(value!); + + const getQuery = useCallback( + (value: string) => { + if (!value) return {}; + + return { query: { term: { [dataField]: value } } }; + }, + [dataField], + ); + + useEffect(() => { + setQuery({ + value: buttonValue, + query: getQuery(buttonValue), + }); + }, [buttonValue, getQuery, setQuery]); + + // Support resetting from SelectedFilters + useEffect(() => { + if (value === null) setButtonValue(""); + }, [value]); + + return ( + <> + {tagLabel && ( + + {tagLabel} + + )} + + {buttons.map(({ label, value, icon }) => { + return ( + + ); + })} + + + ); +}; diff --git a/src/pages/Search.css b/src/pages/Search.css index 77fadd8e..178faa61 100644 --- a/src/pages/Search.css +++ b/src/pages/Search.css @@ -1,21 +1,5 @@ -.m-search .input-fix input { - height: unset !important; -} - -.m-search label > span > span:nth-child(2) { - color: #555 !important; -} - -.m-search .m-filters li { - padding: 0 4px; - border-radius: 3px; -} - -.m-search .m-filters li[aria-checked="true"] { - background-color: var(--chakra-colors-gray-800); -} - .m-search .sort-select { + color: inherit; outline: transparent solid 2px; outline-offset: 2px; appearance: auto; @@ -44,3 +28,33 @@ border-color: rgb(99, 179, 237); box-shadow: rgb(99, 179, 237) 0 0 0 1px; } + +.m-search .custom-chakra-button > a { + border-radius: var(--chakra-radii-md); + font-weight: var(--chakra-fontWeights-semibold); + transition-property: var(--chakra-transition-property-common); + transition-duration: var(--chakra-transition-duration-normal); + height: var(--chakra-sizes-10); + min-width: var(--chakra-sizes-10); + font-size: var(--chakra-fontSizes-md); + -webkit-padding-start: var(--chakra-space-4); + padding-inline-start: var(--chakra-space-4); + -webkit-padding-end: var(--chakra-space-4); + padding-inline-end: var(--chakra-space-4); + background: var(--chakra-colors-whiteAlpha-200); +} + +.m-search .custom-chakra-button > a:hover { + background: var(--chakra-colors-n2-300); +} + +.m-search .custom-chakra-button > a:focus { + outline: none; + border-color: transparent; + background: var(--chakra-colors-whiteAlpha-200); +} + +.m-search .custom-chakra-button > a.active { + background: var(--chakra-colors-n2-100); + color: var(--chakra-colors-gray-800); +} diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index e2751835..1c8cbcd3 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -1,12 +1,8 @@ import { - DataSearch, - MultiList, ReactiveBase, ReactiveComponent, ReactiveList, SelectedFilters, - SingleList, - ToggleButton, } from "@appbaseio/reactivesearch"; import { Accordion, @@ -14,19 +10,24 @@ import { AccordionIcon, AccordionItem, AccordionPanel, - Box, Flex, Heading, - Input, Progress, - Tag, useBreakpointValue, VStack, } from "@chakra-ui/react"; -import { useEffect, useState } from "react"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BiMovie, BiMoviePlay } from "react-icons/bi"; import { useNavigate, useSearchParams } from "react-router-dom"; import { SongTable, SongTableCol } from "../components/data/SongTable"; import "./Search.css"; +import { GeneralSearchInput } from "../components/search/GeneralSearchInput"; +import { CheckboxSearchList } from "../components/search/CheckboxSearchList"; +import { RadioButtonSearchList } from "../components/search/RadioButtonSearchList"; +import { ToggleButtonSearchInput } from "../components/search/ToggleButtonSearchInput"; + +const debounceValue = 1000; const SearchResultSongTable = ({ data, @@ -50,6 +51,7 @@ const SearchResultSongTable = ({ size="xs" isIndeterminate visibility={loading ? "visible" : "hidden"} + mt={1} /> {data && ( { - if (searchParams.has("ch")) setChannelSelected(true); - }, [searchParams]); // searchParams --> isExact + const getGeneralQuery = useCallback((value: string) => { + if (!value) return {}; + + return { + query: { + multi_match: { + query: value, + fields: [ + "general", + "general.romaji", + "original_artist", + "original_artist.romaji", + ], + type: "phrase", + }, + }, + }; + }, []); + + const getSongQuery = useCallback((value: string) => { + if (!value) return {}; + + return { + query: { + multi_match: { + query: value, + fields: ["name.ngram", "name"], + type: "phrase", + }, + }, + }; + }, []); + + const getArtistQuery = useCallback((value: string) => { + if (!value) return {}; + + return { + query: { + multi_match: { + query: value, + fields: [ + "original_artist.ngram^2", + "original_artist^2", + "original_artist.romaji^0.5", + ], + type: "phrase", + }, + }, + }; + }, []); return ( - { - if (!q) return {}; - - return { - query: { - multi_match: { - query: q, - fields: [ - "general", - "general.romaji", - "original_artist", - "original_artist.romaji", - ], - type: "phrase", - }, - }, - }; - }} - dataField="" - queryFormat="and" - placeholder="Search for Music / Artist" - autosuggest={false} - enableDefaultSuggestions={false} - iconPosition="right" - onError={(e) => console.log(e)} filterLabel="Search" + customQuery={getGeneralQuery} + render={(props) => ( + + )} + onError={(e) => console.error(e)} /> - Advanced Filters + {t("Advanced Filters")} - ( + }, + { + label: t("Stream"), + value: "false", + icon: , + }, + ]} + {...props} + /> + )} + onError={(e) => console.error(e)} /> - - Song - - { - if (!q) return {}; - - return { - query: { - multi_match: { - query: q, - fields: ["name.ngram", "name"], - type: "phrase", - }, - }, - }; - }} - queryFormat="and" - placeholder="Song Name" - autosuggest={false} - enableDefaultSuggestions={false} - iconPosition="right" - onError={(e) => console.log(e)} - filterLabel="Song Name" + filterLabel={t("Song Name")} + customQuery={getSongQuery} + render={(props) => ( + + )} + onError={(e) => console.error(e)} /> - - Artist - - - { - if (!q) return {}; - - return { - query: { - multi_match: { - query: q, - fields: [ - "original_artist.ngram^2", - "original_artist^2", - "original_artist.romaji^0.5", - ], - type: "phrase", - }, - }, - }; - }} - queryFormat="and" - placeholder="Original Artist Name" - autosuggest={false} - enableDefaultSuggestions={false} - iconPosition="right" - onError={(e) => console.log(e)} - filterLabel="Original Artist" + filterLabel={t("Original Artist")} + customQuery={getArtistQuery} + render={(props) => ( + + )} + onError={(e) => console.error(e)} /> - { - setChannelSelected(e && e.length > 0); + defaultQuery={() => ({ + aggs: { + "channel.name": { + terms: { + field: "channel.name", + size: 12, + order: { _count: "desc" }, + }, + }, + }, + })} + render={(props) => { + setChannelSelected(props.value?.length > 0); + return ( + + ); }} - URLParams - size={12} - showCheckbox + onError={(e) => console.error(e)} /> {!channelSelected && ( - { - setSuborgVisible(!!e); + defaultQuery={() => ({ + aggs: { + org: { + terms: { + field: "org", + order: { _count: "desc" }, + }, + }, + }, + })} + render={(props) => { + setSuborgVisible(!!props.value); + return ( + + ); }} - URLParams + onError={(e) => console.error(e)} /> )} {suborgVisible && !channelSelected && ( - ({ + aggs: { + suborg: { + terms: { + field: "suborg", + order: { _count: "desc" }, + }, + }, + }, + })} + render={(props) => ( + + )} + onError={(e) => console.error(e)} /> )} @@ -284,7 +335,10 @@ export default function Search() { flexGrow={2} flexShrink={1} > - + @@ -319,99 +373,3 @@ export default function Search() { ); } - -// Garbage stuff: -/* { - console.log(args); - return { - query: { - bool: { - must: [ - { - multi_match: { - query: qFuzzy, - fields: [ - "general", - "general.romaji", - "original_artist", - "original_artist.romaji", - ], - }, - }, - ], - }, - }, - value: qFuzzy, - } - }} - render={({ setQuery, value }) => { - return ( - { - if (qFuzzy) { - setQuery({ - query: { - bool: { - must: [ - { - multi_match: { - query: qFuzzy, - fields: [ - "general", - "general.romaji", - "original_artist", - "original_artist.romaji", - ], - }, - }, - ], - }, - }, - value: qFuzzy, - }); - } else if (qExact) { - setQuery({ - query: { - bool: { - must: [ - { - multi_match: { - query: qExact, - fields: [ - "general.ngram", - "original_artist.ngram", - ], - }, - }, - , - ], - should: [ - { - multi_match: { - query: qExact, - fields: [ - "general", - "general.romaji", - "original_artist", - "original_artist.romaji", - ], - }, - }, - ], - }, - }, - value: qExact, - }); - } - }} - /> - ); - }} - > */ diff --git a/yarn.lock b/yarn.lock index 8ae3c8aa..ae51b00b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10763,6 +10763,11 @@ use-callback-ref@^1.2.3, use-callback-ref@^1.2.5: resolved "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.5.tgz" integrity sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg== +use-debounce@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-8.0.2.tgz#bd1b522c7b5b5d9dc249824fd2e4c3e4137b1ea9" + integrity sha512-4yCQ4FmlmYNpcHXJk1E19chO1X58fH4+QrwKpa5nkx3d7szHR3MjheRgECLvHivp3ClUqEom+SHOGB9zBz+qlw== + use-debouncy@^4.2.0: version "4.2.1" resolved "https://registry.npmjs.org/use-debouncy/-/use-debouncy-4.2.1.tgz"