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 defineMessages from '@app/utils/defineMessages'; 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 { orderBy } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import type { MultiValue, SingleValue } from 'react-select'; import AsyncSelect from 'react-select/async'; import useSWR from 'swr'; const messages = defineMessages('components.Selector', { searchKeywords: 'Search keywords…', searchGenres: 'Select genres…', searchStudios: 'Search studios…', starttyping: 'Starting typing to search.', nooptions: 'No results.', showmore: 'Show More', showless: 'Show Less', searchStatus: 'Select status...', returningSeries: 'Returning Series', planned: 'Planned', inProduction: 'In Production', ended: 'Ended', canceled: 'Canceled', pilot: 'Pilot', }); 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 res = await fetch(`/api/v1/studio/${defaultValue}`); if (!res.ok) throw new Error(); const studio: ProductionCompany = await res.json(); setDefaultDataValue([ { label: studio.name ?? '', value: studio.id ?? 0, }, ]); }; loadDefaultCompany(); }, [defaultValue]); const loadCompanyOptions = async (inputValue: string) => { if (inputValue === '') { return []; } const res = await fetch( `/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}` ); if (!res.ok) { throw new Error('Network response was not ok'); } const results: TmdbCompanySearchResponse = await res.json(); return results.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 res = await fetch(`/api/v1/genres/${type}`); if (!res.ok) { throw new Error('Network response was not ok'); } const response: TmdbGenre[] = await res.json(); const genreData = genres .filter((genre) => response.find((gd) => gd.id === Number(genre))) .map((g) => response.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 res = await fetch(`/api/v1/discover/genreslider/${type}`); if (!res.ok) throw new Error(); const results: GenreSliderItem[] = await res.json(); return results .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 StatusSelector = ({ isMulti, defaultValue, onChange, }: BaseSelectorMultiProps | BaseSelectorSingleProps) => { const intl = useIntl(); const [defaultDataValue, setDefaultDataValue] = useState< { label: string; value: number }[] | null >(null); const options = useMemo( () => [ { name: intl.formatMessage(messages.returningSeries), id: 0 }, { name: intl.formatMessage(messages.planned), id: 1 }, { name: intl.formatMessage(messages.inProduction), id: 2 }, { name: intl.formatMessage(messages.ended), id: 3 }, { name: intl.formatMessage(messages.canceled), id: 4 }, { name: intl.formatMessage(messages.pilot), id: 5 }, ], [intl] ); useEffect(() => { const loadDefaultStatus = async (): Promise => { if (!defaultValue) { return; } const statuses = defaultValue.split('|'); const statusData = options .filter((opt) => statuses.find((s) => Number(s) === opt.id)) .map((o) => ({ label: o.name, value: o.id, })); setDefaultDataValue(statusData); }; loadDefaultStatus(); }, [defaultValue, options]); const loadStatusOptions = async () => { return options .map((result) => ({ label: result.name, value: result.id, })) .filter(({ label }) => label.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 res = await fetch(`/api/v1/keyword/${keywordId}`); if (!res.ok) { throw new Error('Network response was not ok'); } const keyword: Keyword = await res.json(); return keyword; }) ); setDefaultDataValue( keywords.map((keyword) => ({ label: keyword.name, value: keyword.id, })) ); }; loadDefaultKeywords(); }, [defaultValue]); const loadKeywordOptions = async (inputValue: string) => { const res = await fetch( `/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}` ); if (!res.ok) { throw new Error('Network response was not ok'); } const results: TmdbKeywordSearchResponse = await res.json(); return results.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.discoverRegion ? currentSettings.discoverRegion : '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 && ( )}
)} ); };