feat: discover overhaul (filters!) (#3232)
parent
b5157010c4
commit
dd00e48f59
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
Loading…
Reference in new issue