mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 20:28:40 -05:00
feat: discover overhaul (filters!) (#3232)
This commit is contained in:
113
src/components/Common/MultiRangeSlider/index.tsx
Normal file
113
src/components/Common/MultiRangeSlider/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import useDebouncedState from '@app/hooks/useDebouncedState';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type MultiRangeSliderProps = {
|
||||
min: number;
|
||||
max: number;
|
||||
defaultMinValue?: number;
|
||||
defaultMaxValue?: number;
|
||||
subText?: string;
|
||||
onUpdateMin: (min: number) => void;
|
||||
onUpdateMax: (max: number) => void;
|
||||
};
|
||||
|
||||
const MultiRangeSlider = ({
|
||||
min,
|
||||
max,
|
||||
defaultMinValue,
|
||||
defaultMaxValue,
|
||||
subText,
|
||||
onUpdateMin,
|
||||
onUpdateMax,
|
||||
}: MultiRangeSliderProps) => {
|
||||
const touched = useRef(false);
|
||||
const [valueMin, finalValueMin, setValueMin] = useDebouncedState(
|
||||
defaultMinValue ?? min
|
||||
);
|
||||
const [valueMax, finalValueMax, setValueMax] = useDebouncedState(
|
||||
defaultMaxValue ?? max
|
||||
);
|
||||
|
||||
const minThumb = ((valueMin - min) / (max - min)) * 100;
|
||||
const maxThumb = ((valueMax - min) / (max - min)) * 100;
|
||||
|
||||
useEffect(() => {
|
||||
if (touched.current) {
|
||||
onUpdateMin(finalValueMin);
|
||||
}
|
||||
}, [finalValueMin, onUpdateMin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (touched.current) {
|
||||
onUpdateMax(finalValueMax);
|
||||
}
|
||||
}, [finalValueMax, onUpdateMax]);
|
||||
|
||||
useEffect(() => {
|
||||
touched.current = false;
|
||||
setValueMax(defaultMaxValue ?? max);
|
||||
setValueMin(defaultMinValue ?? min);
|
||||
}, [defaultMinValue, defaultMaxValue, setValueMax, setValueMin, min, max]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${subText ? 'h-8' : 'h-4'} w-full`}>
|
||||
<Tooltip
|
||||
content={valueMin.toString()}
|
||||
tooltipConfig={{
|
||||
placement: 'top',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={valueMin}
|
||||
className={`pointer-events-none absolute h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 ${
|
||||
valueMin >= valueMax && valueMin !== min ? 'z-30' : 'z-10'
|
||||
}`}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
|
||||
if (value <= valueMax) {
|
||||
touched.current = true;
|
||||
setValueMin(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={valueMax}>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={valueMax}
|
||||
step="1"
|
||||
className={`pointer-events-none absolute top-0 left-0 right-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
|
||||
if (value >= valueMin) {
|
||||
touched.current = true;
|
||||
setValueMax(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-30 ml-1 mr-1 h-2 bg-indigo-500"
|
||||
style={{
|
||||
left: `${minThumb}%`,
|
||||
right: `${100 - maxThumb}%`,
|
||||
}}
|
||||
/>
|
||||
{subText && (
|
||||
<div className="relative top-4 z-30 flex w-full justify-center text-sm text-gray-400">
|
||||
<span>{subText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiRangeSlider;
|
||||
@@ -67,11 +67,11 @@ const SlideOver = ({
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="slideover h-full w-screen max-w-md p-2 sm:p-4"
|
||||
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
|
||||
ref={slideoverRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="hide-scrollbar flex h-full flex-col overflow-y-scroll rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
|
||||
<div className="flex h-full flex-col rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
|
||||
<header className="space-y-1 border-b border-gray-700 py-4 px-4">
|
||||
<div className="flex items-center justify-between space-x-3">
|
||||
<h2 className="text-overseerr text-2xl font-bold leading-7">
|
||||
@@ -95,8 +95,10 @@ const SlideOver = ({
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="relative flex-1 px-4 py-6 text-white">
|
||||
{children}
|
||||
<div className="hide-scrollbar flex flex-1 flex-col overflow-y-auto">
|
||||
<div className="flex-1 px-4 py-6 text-white">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import Error from '@app/pages/_error';
|
||||
import type { MovieResult } from '@server/models/Search';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovermovies: 'Popular Movies',
|
||||
});
|
||||
|
||||
const DiscoverMovies = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult>('/api/v1/discover/movies');
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovermovies);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovies;
|
||||
147
src/components/Discover/DiscoverMovies/index.tsx
Normal file
147
src/components/Discover/DiscoverMovies/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||
import {
|
||||
countActiveFilters,
|
||||
prepareFilterValues,
|
||||
} from '@app/components/Discover/constants';
|
||||
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||
import Error from '@app/pages/_error';
|
||||
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
||||
import type { MovieResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovermovies: 'Movies',
|
||||
activefilters:
|
||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||
sortPopularityAsc: 'Popularity Ascending',
|
||||
sortPopularityDesc: 'Popularity Descending',
|
||||
sortReleaseDateAsc: 'Release Date Ascending',
|
||||
sortReleaseDateDesc: 'Release Date Descending',
|
||||
sortTmdbRatingAsc: 'TMDB Rating Ascending',
|
||||
sortTmdbRatingDesc: 'TMDB Rating Descending',
|
||||
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||
sortTitleDesc: 'Title (Z-A) Descending',
|
||||
});
|
||||
|
||||
const SortOptions: Record<string, TMDBSortOptions> = {
|
||||
PopularityAsc: 'popularity.asc',
|
||||
PopularityDesc: 'popularity.desc',
|
||||
ReleaseDateAsc: 'release_date.asc',
|
||||
ReleaseDateDesc: 'release_date.desc',
|
||||
TmdbRatingAsc: 'vote_average.asc',
|
||||
TmdbRatingDesc: 'vote_average.desc',
|
||||
TitleAsc: 'original_title.asc',
|
||||
TitleDesc: 'original_title.desc',
|
||||
} as const;
|
||||
|
||||
const DiscoverMovies = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
|
||||
const preparedFilters = prepareFilterValues(router.query);
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult, unknown, FilterOptions>(
|
||||
'/api/v1/discover/movies',
|
||||
preparedFilters
|
||||
);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovermovies);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||
<Header>{title}</Header>
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
<BarsArrowDownIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sortBy"
|
||||
name="sortBy"
|
||||
className="rounded-r-only"
|
||||
value={preparedFilters.sortBy}
|
||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||
>
|
||||
<option value={SortOptions.PopularityDesc}>
|
||||
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.PopularityAsc}>
|
||||
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateDesc}>
|
||||
{intl.formatMessage(messages.sortReleaseDateDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateAsc}>
|
||||
{intl.formatMessage(messages.sortReleaseDateAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingDesc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingAsc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleAsc}>
|
||||
{intl.formatMessage(messages.sortTitleAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleDesc}>
|
||||
{intl.formatMessage(messages.sortTitleDesc)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<FilterSlideover
|
||||
type="movie"
|
||||
currentFilters={preparedFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
show={showFilters}
|
||||
/>
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||
<Button onClick={() => setShowFilters(true)} className="w-full">
|
||||
<FunnelIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.activefilters, {
|
||||
count: countActiveFilters(preparedFilters),
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovies;
|
||||
@@ -1,51 +0,0 @@
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import Error from '@app/pages/_error';
|
||||
import type { TvResult } from '@server/models/Search';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovertv: 'Popular Series',
|
||||
});
|
||||
|
||||
const DiscoverTv = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<TvResult>('/api/v1/discover/tv');
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovertv);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isReachingEnd={isReachingEnd}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTv;
|
||||
145
src/components/Discover/DiscoverTv/index.tsx
Normal file
145
src/components/Discover/DiscoverTv/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Header from '@app/components/Common/Header';
|
||||
import ListView from '@app/components/Common/ListView';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||
import {
|
||||
countActiveFilters,
|
||||
prepareFilterValues,
|
||||
} from '@app/components/Discover/constants';
|
||||
import FilterSlideover from '@app/components/Discover/FilterSlideover';
|
||||
import useDiscover from '@app/hooks/useDiscover';
|
||||
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
|
||||
import Error from '@app/pages/_error';
|
||||
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
|
||||
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
|
||||
import type { TvResult } from '@server/models/Search';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovertv: 'Series',
|
||||
activefilters:
|
||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||
sortPopularityAsc: 'Popularity Ascending',
|
||||
sortPopularityDesc: 'Popularity Descending',
|
||||
sortFirstAirDateAsc: 'First Air Date Ascending',
|
||||
sortFirstAirDateDesc: 'First Air Date Descending',
|
||||
sortTmdbRatingAsc: 'TMDB Rating Ascending',
|
||||
sortTmdbRatingDesc: 'TMDB Rating Descending',
|
||||
sortTitleAsc: 'Title (A-Z) Ascending',
|
||||
sortTitleDesc: 'Title (Z-A) Descending',
|
||||
});
|
||||
|
||||
const SortOptions: Record<string, TMDBSortOptions> = {
|
||||
PopularityAsc: 'popularity.asc',
|
||||
PopularityDesc: 'popularity.desc',
|
||||
FirstAirDateAsc: 'first_air_date.asc',
|
||||
FirstAirDateDesc: 'first_air_date.desc',
|
||||
TmdbRatingAsc: 'vote_average.asc',
|
||||
TmdbRatingDesc: 'vote_average.desc',
|
||||
TitleAsc: 'original_title.asc',
|
||||
TitleDesc: 'original_title.desc',
|
||||
} as const;
|
||||
|
||||
const DiscoverTv = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const preparedFilters = prepareFilterValues(router.query);
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<TvResult, never, FilterOptions>('/api/v1/discover/tv', {
|
||||
...preparedFilters,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.discovertv);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||
<Header>{title}</Header>
|
||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
|
||||
<BarsArrowDownIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sortBy"
|
||||
name="sortBy"
|
||||
className="rounded-r-only"
|
||||
value={preparedFilters.sortBy}
|
||||
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
|
||||
>
|
||||
<option value={SortOptions.PopularityDesc}>
|
||||
{intl.formatMessage(messages.sortPopularityDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.PopularityAsc}>
|
||||
{intl.formatMessage(messages.sortPopularityAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateDesc}>
|
||||
{intl.formatMessage(messages.sortFirstAirDateDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.ReleaseDateAsc}>
|
||||
{intl.formatMessage(messages.sortFirstAirDateAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingDesc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingDesc)}
|
||||
</option>
|
||||
<option value={SortOptions.TmdbRatingAsc}>
|
||||
{intl.formatMessage(messages.sortTmdbRatingAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleAsc}>
|
||||
{intl.formatMessage(messages.sortTitleAsc)}
|
||||
</option>
|
||||
<option value={SortOptions.TitleDesc}>
|
||||
{intl.formatMessage(messages.sortTitleDesc)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<FilterSlideover
|
||||
type="tv"
|
||||
currentFilters={preparedFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
show={showFilters}
|
||||
/>
|
||||
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
|
||||
<Button onClick={() => setShowFilters(true)} className="w-full">
|
||||
<FunnelIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.activefilters, {
|
||||
count: countActiveFilters(preparedFilters),
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isReachingEnd={isReachingEnd}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTv;
|
||||
271
src/components/Discover/FilterSlideover/index.tsx
Normal file
271
src/components/Discover/FilterSlideover/index.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
|
||||
import SlideOver from '@app/components/Common/SlideOver';
|
||||
import type { FilterOptions } from '@app/components/Discover/constants';
|
||||
import { countActiveFilters } from '@app/components/Discover/constants';
|
||||
import LanguageSelector from '@app/components/LanguageSelector';
|
||||
import {
|
||||
CompanySelector,
|
||||
GenreSelector,
|
||||
KeywordSelector,
|
||||
} from '@app/components/Selector';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import {
|
||||
useBatchUpdateQueryParams,
|
||||
useUpdateQueryParams,
|
||||
} from '@app/hooks/useUpdateQueryParams';
|
||||
import { XCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Datepicker from 'react-tailwindcss-datepicker-sct';
|
||||
|
||||
const messages = defineMessages({
|
||||
filters: 'Filters',
|
||||
activefilters:
|
||||
'{count, plural, one {# Active Filter} other {# Active Filters}}',
|
||||
releaseDate: 'Release Date',
|
||||
firstAirDate: 'First Air Date',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
studio: 'Studio',
|
||||
genres: 'Genres',
|
||||
keywords: 'Keywords',
|
||||
originalLanguage: 'Original Language',
|
||||
runtimeText: '{minValue}-{maxValue} minute runtime',
|
||||
ratingText: 'Ratings between {minValue} and {maxValue}',
|
||||
clearfilters: 'Clear Active Filters',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
runtime: 'Runtime',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
type: 'movie' | 'tv';
|
||||
currentFilters: FilterOptions;
|
||||
};
|
||||
|
||||
const FilterSlideover = ({
|
||||
show,
|
||||
onClose,
|
||||
type,
|
||||
currentFilters,
|
||||
}: FilterSlideoverProps) => {
|
||||
const intl = useIntl();
|
||||
const { currentSettings } = useSettings();
|
||||
const updateQueryParams = useUpdateQueryParams({});
|
||||
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
|
||||
|
||||
const dateGte =
|
||||
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
|
||||
const dateLte =
|
||||
type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte';
|
||||
|
||||
return (
|
||||
<SlideOver
|
||||
show={show}
|
||||
title={intl.formatMessage(messages.filters)}
|
||||
subText={intl.formatMessage(messages.activefilters, {
|
||||
count: countActiveFilters(currentFilters),
|
||||
})}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 text-lg font-semibold">
|
||||
{intl.formatMessage(
|
||||
type === 'movie' ? messages.releaseDate : messages.firstAirDate
|
||||
)}
|
||||
</div>
|
||||
<div className="relative z-40 flex space-x-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
|
||||
<Datepicker
|
||||
primaryColor="indigo"
|
||||
value={{
|
||||
startDate: currentFilters[dateGte] ?? null,
|
||||
endDate: currentFilters[dateGte] ?? null,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
updateQueryParams(
|
||||
dateGte,
|
||||
value?.startDate ? (value.startDate as string) : undefined
|
||||
);
|
||||
}}
|
||||
inputName="fromdate"
|
||||
useRange={false}
|
||||
asSingle
|
||||
containerClassName="datepicker-wrapper"
|
||||
inputClassName="pr-1 sm:pr-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
|
||||
<Datepicker
|
||||
primaryColor="indigo"
|
||||
value={{
|
||||
startDate: currentFilters[dateLte] ?? null,
|
||||
endDate: currentFilters[dateLte] ?? null,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
updateQueryParams(
|
||||
dateLte,
|
||||
value?.startDate ? (value.startDate as string) : undefined
|
||||
);
|
||||
}}
|
||||
inputName="todate"
|
||||
useRange={false}
|
||||
asSingle
|
||||
containerClassName="datepicker-wrapper"
|
||||
inputClassName="pr-1 sm:pr-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{type === 'movie' && (
|
||||
<>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.studio)}
|
||||
</span>
|
||||
<CompanySelector
|
||||
defaultValue={currentFilters.studio}
|
||||
onChange={(value) => {
|
||||
updateQueryParams('studio', value?.value.toString());
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.genres)}
|
||||
</span>
|
||||
<GenreSelector
|
||||
type={type}
|
||||
defaultValue={currentFilters.genre}
|
||||
isMulti
|
||||
onChange={(value) => {
|
||||
updateQueryParams('genre', value?.map((v) => v.value).join(','));
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</span>
|
||||
<KeywordSelector
|
||||
defaultValue={currentFilters.keywords}
|
||||
isMulti
|
||||
onChange={(value) => {
|
||||
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.originalLanguage)}
|
||||
</span>
|
||||
<LanguageSelector
|
||||
value={currentFilters.language}
|
||||
serverValue={currentSettings.originalLanguage}
|
||||
isUserSettings
|
||||
setFieldValue={(_key, value) => {
|
||||
updateQueryParams('language', value);
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.runtime)}
|
||||
</span>
|
||||
<div className="relative z-0">
|
||||
<MultiRangeSlider
|
||||
min={0}
|
||||
max={400}
|
||||
onUpdateMin={(min) => {
|
||||
updateQueryParams(
|
||||
'withRuntimeGte',
|
||||
min !== 0 && Number(currentFilters.withRuntimeLte) !== 400
|
||||
? min.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
onUpdateMax={(max) => {
|
||||
updateQueryParams(
|
||||
'withRuntimeLte',
|
||||
max !== 400 && Number(currentFilters.withRuntimeGte) !== 0
|
||||
? max.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
defaultMaxValue={
|
||||
currentFilters.withRuntimeLte
|
||||
? Number(currentFilters.withRuntimeLte)
|
||||
: undefined
|
||||
}
|
||||
defaultMinValue={
|
||||
currentFilters.withRuntimeGte
|
||||
? Number(currentFilters.withRuntimeGte)
|
||||
: undefined
|
||||
}
|
||||
subText={intl.formatMessage(messages.runtimeText, {
|
||||
minValue: currentFilters.withRuntimeGte ?? 0,
|
||||
maxValue: currentFilters.withRuntimeLte ?? 400,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.tmdbuserscore)}
|
||||
</span>
|
||||
<div className="relative z-0">
|
||||
<MultiRangeSlider
|
||||
min={1}
|
||||
max={10}
|
||||
defaultMaxValue={
|
||||
currentFilters.voteAverageLte
|
||||
? Number(currentFilters.voteAverageLte)
|
||||
: undefined
|
||||
}
|
||||
defaultMinValue={
|
||||
currentFilters.voteAverageGte
|
||||
? Number(currentFilters.voteAverageGte)
|
||||
: undefined
|
||||
}
|
||||
onUpdateMin={(min) => {
|
||||
updateQueryParams(
|
||||
'voteAverageGte',
|
||||
min !== 1 && Number(currentFilters.voteAverageLte) !== 10
|
||||
? min.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
onUpdateMax={(max) => {
|
||||
updateQueryParams(
|
||||
'voteAverageLte',
|
||||
max !== 10 && Number(currentFilters.voteAverageGte) !== 1
|
||||
? max.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
subText={intl.formatMessage(messages.ratingText, {
|
||||
minValue: currentFilters.voteAverageGte ?? 1,
|
||||
maxValue: currentFilters.voteAverageLte ?? 10,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={Object.keys(currentFilters).length === 0}
|
||||
onClick={() => {
|
||||
const copyCurrent = Object.assign({}, currentFilters);
|
||||
(
|
||||
Object.keys(copyCurrent) as (keyof typeof currentFilters)[]
|
||||
).forEach((k) => {
|
||||
copyCurrent[k] = undefined;
|
||||
});
|
||||
batchUpdateQueryParams(copyCurrent);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<XCircleIcon />
|
||||
<span>{intl.formatMessage(messages.clearfilters)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterSlideover;
|
||||
@@ -43,7 +43,7 @@ const MovieGenreSlider = () => {
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/movies/genre/${genre.id}`}
|
||||
url={`/discover/movies?genre=${genre.id}`}
|
||||
/>
|
||||
))}
|
||||
placeholder={<GenreCard.Placeholder />}
|
||||
|
||||
@@ -43,7 +43,7 @@ const TvGenreSlider = () => {
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/tv/genre/${genre.id}`}
|
||||
url={`/discover/tv?genre=${genre.id}`}
|
||||
/>
|
||||
))}
|
||||
placeholder={<GenreCard.Placeholder />}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ParsedUrlQuery } from 'querystring';
|
||||
import { defineMessages } from 'react-intl';
|
||||
import { z } from 'zod';
|
||||
|
||||
type AvailableColors =
|
||||
| 'black'
|
||||
@@ -85,3 +87,104 @@ export const sliderTitles = defineMessages({
|
||||
tmdbstudio: 'TMDB Studio',
|
||||
tmdbsearch: 'TMDB Search',
|
||||
});
|
||||
|
||||
export const QueryFilterOptions = z.object({
|
||||
sortBy: z.string().optional(),
|
||||
primaryReleaseDateGte: z.string().optional(),
|
||||
primaryReleaseDateLte: z.string().optional(),
|
||||
firstAirDateGte: z.string().optional(),
|
||||
firstAirDateLte: z.string().optional(),
|
||||
studio: z.string().optional(),
|
||||
genre: z.string().optional(),
|
||||
keywords: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
withRuntimeGte: z.string().optional(),
|
||||
withRuntimeLte: z.string().optional(),
|
||||
voteAverageGte: z.string().optional(),
|
||||
voteAverageLte: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
|
||||
export const prepareFilterValues = (
|
||||
inputValues: ParsedUrlQuery
|
||||
): FilterOptions => {
|
||||
const filterValues: FilterOptions = {};
|
||||
|
||||
const values = QueryFilterOptions.parse(inputValues);
|
||||
|
||||
if (values.sortBy) {
|
||||
filterValues.sortBy = values.sortBy;
|
||||
}
|
||||
|
||||
if (values.primaryReleaseDateGte) {
|
||||
filterValues.primaryReleaseDateGte = values.primaryReleaseDateGte;
|
||||
}
|
||||
|
||||
if (values.primaryReleaseDateLte) {
|
||||
filterValues.primaryReleaseDateLte = values.primaryReleaseDateLte;
|
||||
}
|
||||
|
||||
if (values.firstAirDateGte) {
|
||||
filterValues.firstAirDateGte = values.firstAirDateGte;
|
||||
}
|
||||
|
||||
if (values.firstAirDateLte) {
|
||||
filterValues.firstAirDateLte = values.firstAirDateLte;
|
||||
}
|
||||
|
||||
if (values.studio) {
|
||||
filterValues.studio = values.studio;
|
||||
}
|
||||
|
||||
if (values.genre) {
|
||||
filterValues.genre = values.genre;
|
||||
}
|
||||
|
||||
if (values.keywords) {
|
||||
filterValues.keywords = values.keywords;
|
||||
}
|
||||
|
||||
if (values.language) {
|
||||
filterValues.language = values.language;
|
||||
}
|
||||
|
||||
if (values.withRuntimeGte) {
|
||||
filterValues.withRuntimeGte = values.withRuntimeGte;
|
||||
}
|
||||
|
||||
if (values.withRuntimeLte) {
|
||||
filterValues.withRuntimeLte = values.withRuntimeLte;
|
||||
}
|
||||
|
||||
if (values.voteAverageGte) {
|
||||
filterValues.voteAverageGte = values.voteAverageGte;
|
||||
}
|
||||
|
||||
if (values.voteAverageLte) {
|
||||
filterValues.voteAverageLte = values.voteAverageLte;
|
||||
}
|
||||
|
||||
return filterValues;
|
||||
};
|
||||
|
||||
export const countActiveFilters = (filterValues: FilterOptions): number => {
|
||||
let totalCount = 0;
|
||||
const clonedFilters = Object.assign({}, filterValues);
|
||||
|
||||
if (clonedFilters.voteAverageGte || filterValues.voteAverageLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.voteAverageGte;
|
||||
delete clonedFilters.voteAverageLte;
|
||||
}
|
||||
|
||||
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.withRuntimeGte;
|
||||
delete clonedFilters.withRuntimeLte;
|
||||
}
|
||||
|
||||
totalCount += Object.keys(clonedFilters).length;
|
||||
|
||||
return totalCount;
|
||||
};
|
||||
|
||||
@@ -109,6 +109,12 @@ const Discover = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const offset = now.getTimezoneOffset();
|
||||
const upcomingDate = new Date(now.getTime() - offset * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
if (!discoverData && !discoverError) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -240,8 +246,9 @@ const Discover = () => {
|
||||
<MediaSlider
|
||||
sliderKey="upcoming"
|
||||
title={intl.formatMessage(sliderTitles.upcoming)}
|
||||
linkUrl="/discover/movies/upcoming"
|
||||
url="/api/v1/discover/movies/upcoming"
|
||||
linkUrl={`/discover/movies?primaryReleaseDateGte=${upcomingDate}`}
|
||||
url="/api/v1/discover/movies"
|
||||
extraParams={`primaryReleaseDateGte=${upcomingDate}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@@ -266,8 +273,9 @@ const Discover = () => {
|
||||
<MediaSlider
|
||||
sliderKey="upcoming-tv"
|
||||
title={intl.formatMessage(sliderTitles.upcomingtv)}
|
||||
url="/api/v1/discover/tv/upcoming"
|
||||
linkUrl="/discover/tv/upcoming"
|
||||
linkUrl={`/discover/tv?firstAirDateGte=${upcomingDate}`}
|
||||
url="/api/v1/discover/tv"
|
||||
extraParams={`firstAirDateGte=${upcomingDate}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@@ -285,7 +293,7 @@ const Discover = () => {
|
||||
? `keywords=${encodeURIExtraParams(slider.data)}`
|
||||
: ''
|
||||
}
|
||||
linkUrl={`/discover/movies/keyword?keywords=${slider.data}`}
|
||||
linkUrl={`/discover/movies?keywords=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@@ -300,7 +308,7 @@ const Discover = () => {
|
||||
? `keywords=${encodeURIExtraParams(slider.data)}`
|
||||
: ''
|
||||
}
|
||||
linkUrl={`/discover/tv/keyword?keywords=${slider.data}`}
|
||||
linkUrl={`/discover/tv?keywords=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@@ -309,8 +317,9 @@ const Discover = () => {
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url={`/api/v1/discover/movies/genre/${slider.data}`}
|
||||
linkUrl={`/discover/movies/genre/${slider.data}`}
|
||||
url={`/api/v1/discover/movies`}
|
||||
extraParams={`genre=${slider.data}`}
|
||||
linkUrl={`/discover/movies?genre=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@@ -319,8 +328,9 @@ const Discover = () => {
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url={`/api/v1/discover/tv/genre/${slider.data}`}
|
||||
linkUrl={`/discover/tv/genre/${slider.data}`}
|
||||
url={`/api/v1/discover/tv`}
|
||||
extraParams={`genre=${slider.data}`}
|
||||
linkUrl={`/discover/tv?genre=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FilmIcon,
|
||||
SparklesIcon,
|
||||
TvIcon,
|
||||
UsersIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
@@ -17,6 +19,8 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
dashboard: 'Discover',
|
||||
browsemovies: 'Movies',
|
||||
browsetv: 'Series',
|
||||
requests: 'Requests',
|
||||
issues: 'Issues',
|
||||
users: 'Users',
|
||||
@@ -44,7 +48,19 @@ const SidebarLinks: SidebarLinkProps[] = [
|
||||
href: '/',
|
||||
messagesKey: 'dashboard',
|
||||
svgIcon: <SparklesIcon className="mr-3 h-6 w-6" />,
|
||||
activeRegExp: /^\/(discover\/?(movies|tv)?)?$/,
|
||||
activeRegExp: /^\/(discover\/?)?$/,
|
||||
},
|
||||
{
|
||||
href: '/discover/movies',
|
||||
messagesKey: 'browsemovies',
|
||||
svgIcon: <FilmIcon className="mr-3 h-6 w-6" />,
|
||||
activeRegExp: /^\/discover\/movies$/,
|
||||
},
|
||||
{
|
||||
href: '/discover/tv',
|
||||
messagesKey: 'browsetv',
|
||||
svgIcon: <TvIcon className="mr-3 h-6 w-6" />,
|
||||
activeRegExp: /^\/discover\/tv$/,
|
||||
},
|
||||
{
|
||||
href: '/requests',
|
||||
|
||||
@@ -223,7 +223,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
movieAttributes.push(
|
||||
data.genres
|
||||
.map((g) => (
|
||||
<Link href={`/discover/movies/genre/${g.id}`} key={`genre-${g.id}`}>
|
||||
<Link href={`/discover/movies?genre=${g.id}`} key={`genre-${g.id}`}>
|
||||
<a className="hover:underline">{g.name}</a>
|
||||
</Link>
|
||||
))
|
||||
@@ -458,7 +458,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
<div className="mt-6">
|
||||
{data.keywords.map((keyword) => (
|
||||
<Link
|
||||
href={`/discover/movies/keyword?keywords=${keyword.id}`}
|
||||
href={`/discover/movies?keywords=${keyword.id}`}
|
||||
key={`keyword-id-${keyword.id}`}
|
||||
>
|
||||
<a className="mb-2 mr-2 inline-flex last:mr-0">
|
||||
|
||||
261
src/components/Selector/index.tsx
Normal file
261
src/components/Selector/index.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { encodeURIExtraParams } from '@app/hooks/useSearchInput';
|
||||
import type {
|
||||
TmdbCompanySearchResponse,
|
||||
TmdbGenre,
|
||||
TmdbKeywordSearchResponse,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type { Keyword, ProductionCompany } from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import type { MultiValue, SingleValue } from 'react-select';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchKeywords: 'Search keywords…',
|
||||
searchGenres: 'Select genres…',
|
||||
searchStudios: 'Search studios…',
|
||||
starttyping: 'Starting typing to search.',
|
||||
nooptions: 'No results.',
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const results = await axios.get<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/${type}`
|
||||
);
|
||||
|
||||
return results.data.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
};
|
||||
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -206,7 +206,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
seriesAttributes.push(
|
||||
data.genres
|
||||
.map((g) => (
|
||||
<Link href={`/discover/tv/genre/${g.id}`} key={`genre-${g.id}`}>
|
||||
<Link href={`/discover/tv?genre=${g.id}`} key={`genre-${g.id}`}>
|
||||
<a className="hover:underline">{g.name}</a>
|
||||
</Link>
|
||||
))
|
||||
@@ -499,7 +499,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
||||
<div className="mt-6">
|
||||
{data.keywords.map((keyword) => (
|
||||
<Link
|
||||
href={`/discover/tv/keyword?keywords=${keyword.id}`}
|
||||
href={`/discover/tv?keywords=${keyword.id}`}
|
||||
key={`keyword-id-${keyword.id}`}
|
||||
>
|
||||
<a className="mb-2 mr-2 inline-flex last:mr-0">
|
||||
|
||||
Reference in New Issue
Block a user