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