import CachedImage from '@app/components/Common/CachedImage'; import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; import Tooltip from '@app/components/Common/Tooltip'; import RegionSelector from '@app/components/RegionSelector'; import { encodeURIExtraParams } from '@app/hooks/useDiscover'; import useSettings from '@app/hooks/useSettings'; import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/20/solid'; import { CheckCircleIcon } from '@heroicons/react/24/solid'; import type { TmdbCompanySearchResponse, TmdbGenre, TmdbKeywordSearchResponse, } from '@server/api/themoviedb/interfaces'; import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; import type { Keyword, ProductionCompany, WatchProviderDetails, } from '@server/models/common'; import axios from 'axios'; import { orderBy } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import type { MultiValue, SingleValue } from 'react-select'; import AsyncSelect from 'react-select/async'; import useSWR from 'swr'; const messages = defineMessages({ searchKeywords: 'Search keywords…', searchGenres: 'Select genres…', searchStudios: 'Search studios…', starttyping: 'Starting typing to search.', nooptions: 'No results.', showmore: 'Show More', showless: 'Show Less', }); type SingleVal = { label: string; value: number; }; type BaseSelectorMultiProps = { defaultValue?: string; isMulti: true; onChange: (value: MultiValue | null) => void; }; type BaseSelectorSingleProps = { defaultValue?: string; isMulti?: false; onChange: (value: SingleValue | null) => void; }; export const CompanySelector = ({ defaultValue, isMulti, onChange, }: BaseSelectorSingleProps | BaseSelectorMultiProps) => { const intl = useIntl(); const [defaultDataValue, setDefaultDataValue] = useState< { label: string; value: number }[] | null >(null); useEffect(() => { const loadDefaultCompany = async (): Promise => { if (!defaultValue) { return; } const response = await axios.get( `/api/v1/studio/${defaultValue}` ); const studio = response.data; setDefaultDataValue([ { label: studio.name ?? '', value: studio.id ?? 0, }, ]); }; loadDefaultCompany(); }, [defaultValue]); const loadCompanyOptions = async (inputValue: string) => { if (inputValue === '') { return []; } const results = await axios.get( '/api/v1/search/company', { params: { query: encodeURIExtraParams(inputValue), }, } ); return results.data.results.map((result) => ({ label: result.name, value: result.id, })); }; return ( inputValue === '' ? intl.formatMessage(messages.starttyping) : intl.formatMessage(messages.nooptions) } loadOptions={loadCompanyOptions} placeholder={intl.formatMessage(messages.searchStudios)} onChange={(value) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange(value as any); }} /> ); }; type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & { type: 'movie' | 'tv'; }; export const GenreSelector = ({ isMulti, defaultValue, onChange, type, }: GenreSelectorProps) => { const intl = useIntl(); const [defaultDataValue, setDefaultDataValue] = useState< { label: string; value: number }[] | null >(null); useEffect(() => { const loadDefaultGenre = async (): Promise => { if (!defaultValue) { return; } const genres = defaultValue.split(','); const response = await axios.get(`/api/v1/genres/${type}`); const genreData = genres .filter((genre) => response.data.find((gd) => gd.id === Number(genre))) .map((g) => response.data.find((gd) => gd.id === Number(g))) .map((g) => ({ label: g?.name ?? '', value: g?.id ?? 0, })); setDefaultDataValue(genreData); }; loadDefaultGenre(); }, [defaultValue, type]); const loadGenreOptions = async (inputValue: string) => { const results = await axios.get( `/api/v1/discover/genreslider/${type}` ); return results.data .map((result) => ({ label: result.name, value: result.id, })) .filter(({ label }) => label.toLowerCase().includes(inputValue.toLowerCase()) ); }; return ( { // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange(value as any); }} /> ); }; export const KeywordSelector = ({ isMulti, defaultValue, onChange, }: BaseSelectorMultiProps | BaseSelectorSingleProps) => { const intl = useIntl(); const [defaultDataValue, setDefaultDataValue] = useState< { label: string; value: number }[] | null >(null); useEffect(() => { const loadDefaultKeywords = async (): Promise => { if (!defaultValue) { return; } const keywords = await Promise.all( defaultValue.split(',').map(async (keywordId) => { const keyword = await axios.get( `/api/v1/keyword/${keywordId}` ); return keyword.data; }) ); setDefaultDataValue( keywords.map((keyword) => ({ label: keyword.name, value: keyword.id, })) ); }; loadDefaultKeywords(); }, [defaultValue]); const loadKeywordOptions = async (inputValue: string) => { const results = await axios.get( '/api/v1/search/keyword', { params: { query: encodeURIExtraParams(inputValue), }, } ); return results.data.results.map((result) => ({ label: result.name, value: result.id, })); }; return ( inputValue === '' ? intl.formatMessage(messages.starttyping) : intl.formatMessage(messages.nooptions) } defaultValue={defaultDataValue} loadOptions={loadKeywordOptions} placeholder={intl.formatMessage(messages.searchKeywords)} onChange={(value) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange(value as any); }} /> ); }; type WatchProviderSelectorProps = { type: 'movie' | 'tv'; region?: string; activeProviders?: number[]; onChange: (region: string, value: number[]) => void; }; export const WatchProviderSelector = ({ type, onChange, region, activeProviders, }: WatchProviderSelectorProps) => { const intl = useIntl(); const { currentSettings } = useSettings(); const [showMore, setShowMore] = useState(false); const [watchRegion, setWatchRegion] = useState( region ? region : currentSettings.region ? currentSettings.region : 'US' ); const [activeProvider, setActiveProvider] = useState( activeProviders ?? [] ); const { data, isLoading } = useSWR( `/api/v1/watchproviders/${ type === 'movie' ? 'movies' : 'tv' }?watchRegion=${watchRegion}` ); useEffect(() => { onChange(watchRegion, activeProvider); // removed onChange as a dependency as we only need to call it when the value(s) change // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeProvider, watchRegion]); const orderedData = useMemo(() => { if (!data) { return []; } return orderBy(data, ['display_priority'], ['asc']); }, [data]); const toggleProvider = (id: number) => { if (activeProvider.includes(id)) { setActiveProvider(activeProvider.filter((p) => p !== id)); } else { setActiveProvider([...activeProvider, id]); } }; const initialProviders = orderedData.slice(0, 24); const otherProviders = orderedData.slice(24); return ( <> { if (value !== watchRegion) { setActiveProvider([]); } setWatchRegion(value); }} disableAll watchProviders /> {isLoading ? ( ) : (
{initialProviders.map((provider) => { const isActive = activeProvider.includes(provider.id); return (
toggleProvider(provider.id)} onKeyDown={(e) => { if (e.key === 'Enter') { toggleProvider(provider.id); } }} role="button" tabIndex={0} > {isActive && (
)}
); })}
{showMore && otherProviders.length > 0 && (
{otherProviders.map((provider) => { const isActive = activeProvider.includes(provider.id); return (
toggleProvider(provider.id)} onKeyDown={(e) => { if (e.key === 'Enter') { toggleProvider(provider.id); } }} role="button" tabIndex={0} > {isActive && (
)}
); })}
)} {otherProviders.length > 0 && ( )}
)} ); };