You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
overseerr/src/components/Selector/index.tsx

465 lines
14 KiB

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<SingleVal> | null) => void;
};
type BaseSelectorSingleProps = {
defaultValue?: string;
isMulti?: false;
onChange: (value: SingleValue<SingleVal> | 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<void> => {
if (!defaultValue) {
return;
}
const response = await axios.get<ProductionCompany>(
`/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<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
return (
<AsyncSelect
key={`company-selector-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
isMulti={isMulti}
defaultValue={defaultDataValue}
defaultOptions
cacheOptions
isClearable
noOptionsMessage={({ inputValue }) =>
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<void> => {
if (!defaultValue) {
return;
}
const genres = defaultValue.split(',');
const response = await axios.get<TmdbGenre[]>(`/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<GenreSliderItem[]>(
`/api/v1/discover/genreslider/${type}`
);
return results.data
.map((result) => ({
label: result.name,
value: result.id,
}))
.filter(({ label }) =>
label.toLowerCase().includes(inputValue.toLowerCase())
);
};
return (
<AsyncSelect
key={`genre-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
defaultOptions
cacheOptions
isMulti={isMulti}
loadOptions={loadGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
// 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<void> => {
if (!defaultValue) {
return;
}
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
`/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<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
return (
<AsyncSelect
key={`keyword-select-${defaultDataValue}`}
inputId="data"
isMulti={isMulti}
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
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<number[]>(
activeProviders ?? []
);
const { data, isLoading } = useSWR<WatchProviderDetails[]>(
`/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 (
<>
<RegionSelector
value={watchRegion}
name="watchRegion"
onChange={(_name, value) => {
if (value !== watchRegion) {
setActiveProvider([]);
}
setWatchRegion(value);
}}
disableAll
watchProviders
/>
{isLoading ? (
<SmallLoadingSpinner />
) : (
<div className="grid">
<div className="provider-icons grid gap-2">
{initialProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id);
return (
<Tooltip
content={provider.name}
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
}`}
onClick={() => toggleProvider(provider.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
toggleProvider(provider.id);
}
}}
role="button"
tabIndex={0}
>
<CachedImage
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
layout="responsive"
width="100%"
height="100%"
className="rounded-lg"
/>
{isActive && (
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
<CheckCircleIcon className="h-6 w-6" />
</div>
)}
</div>
</Tooltip>
);
})}
</div>
{showMore && otherProviders.length > 0 && (
<div className="provider-icons relative top-2 grid gap-2">
{otherProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id);
return (
<Tooltip
content={provider.name}
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
}`}
onClick={() => toggleProvider(provider.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
toggleProvider(provider.id);
}
}}
role="button"
tabIndex={0}
>
<CachedImage
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
layout="responsive"
width="100%"
height="100%"
className="rounded-lg"
/>
{isActive && (
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
<CheckCircleIcon className="h-6 w-6" />
</div>
)}
</div>
</Tooltip>
);
})}
</div>
)}
{otherProviders.length > 0 && (
<button
className="relative top-4 flex items-center justify-center space-x-2 text-sm text-gray-400 transition hover:text-gray-200"
type="button"
onClick={() => setShowMore(!showMore)}
>
<div className="h-0.5 flex-1 bg-gray-600" />
{showMore ? (
<>
<ArrowUpIcon className="h-4 w-4" />
<span>{intl.formatMessage(messages.showless)}</span>
<ArrowUpIcon className="h-4 w-4" />
</>
) : (
<>
<ArrowDownIcon className="h-4 w-4" />
<span>{intl.formatMessage(messages.showmore)}</span>
<ArrowDownIcon className="h-4 w-4" />
</>
)}
<div className="h-0.5 flex-1 bg-gray-600" />
</button>
)}
</div>
)}
</>
);
};