diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts index 3489061b..545f2565 100644 --- a/cypress/e2e/discover.cy.ts +++ b/cypress/e2e/discover.cy.ts @@ -36,7 +36,9 @@ describe('Discover', () => { }); it('loads upcoming movies', () => { - cy.intercept('/api/v1/discover/movies/upcoming*').as('getUpcomingMovies'); + cy.intercept('/api/v1/discover/movies?page=1&primaryReleaseDateGte*').as( + 'getUpcomingMovies' + ); cy.visit('/'); cy.wait('@getUpcomingMovies'); clickFirstTitleCardInSlider('Upcoming Movies'); @@ -50,7 +52,9 @@ describe('Discover', () => { }); it('loads upcoming series', () => { - cy.intercept('/api/v1/discover/tv/upcoming*').as('getUpcomingSeries'); + cy.intercept('/api/v1/discover/tv?page=1&firstAirDateGte=*').as( + 'getUpcomingSeries' + ); cy.visit('/'); cy.wait('@getUpcomingSeries'); clickFirstTitleCardInSlider('Upcoming Series'); diff --git a/overseerr-api.yml b/overseerr-api.yml index d31f4e23..58b9b41f 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4130,7 +4130,7 @@ paths: - in: query name: genre schema: - type: number + type: string example: 18 - in: query name: studio @@ -4142,6 +4142,41 @@ paths: schema: type: string example: 1,2 + - in: query + name: sortBy + schema: + type: string + example: popularity.desc + - in: query + name: primaryReleaseDateGte + schema: + type: string + example: 2022-01-01 + - in: query + name: primaryReleaseDateLte + schema: + type: string + example: 2023-01-01 + - in: query + name: withRuntimeGte + schema: + type: number + example: 60 + - in: query + name: withRuntimeLte + schema: + type: number + example: 120 + - in: query + name: voteAverageGte + schema: + type: number + example: 7 + - in: query + name: voteAverageLte + schema: + type: number + example: 10 responses: '200': description: Results @@ -4376,6 +4411,41 @@ paths: schema: type: string example: 1,2 + - in: query + name: sortBy + schema: + type: string + example: popularity.desc + - in: query + name: firstAirDateGte + schema: + type: string + example: 2022-01-01 + - in: query + name: firstAirDateLte + schema: + type: string + example: 2023-01-01 + - in: query + name: withRuntimeGte + schema: + type: number + example: 60 + - in: query + name: withRuntimeLte + schema: + type: number + example: 120 + - in: query + name: voteAverageGte + schema: + type: number + example: 7 + - in: query + name: voteAverageLte + schema: + type: number + example: 10 responses: '200': description: Results diff --git a/package.json b/package.json index 578dc131..f210da45 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "cronstrue": "2.21.0", "csurf": "1.11.0", "date-fns": "2.29.3", + "dayjs": "1.11.7", "email-templates": "9.0.0", "express": "4.18.2", "express-openapi-validator": "4.13.8", @@ -79,6 +80,7 @@ "react-popper-tooltip": "4.4.2", "react-select": "5.7.0", "react-spring": "9.6.1", + "react-tailwindcss-datepicker-sct": "1.3.4", "react-toast-notifications": "2.5.1", "react-truncate-markup": "5.1.2", "react-use-clipboard": "1.0.9", @@ -94,7 +96,8 @@ "winston-daily-rotate-file": "4.7.1", "xml2js": "0.4.23", "yamljs": "0.3.0", - "yup": "0.32.11" + "yup": "0.32.11", + "zod": "3.20.2" }, "devDependencies": { "@babel/cli": "7.20.7", diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index b6421edd..c6d0d29f 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -35,31 +35,39 @@ interface SingleSearchOptions extends SearchOptions { year?: number; } +export type SortOptions = + | 'popularity.asc' + | 'popularity.desc' + | 'release_date.asc' + | 'release_date.desc' + | 'revenue.asc' + | 'revenue.desc' + | 'primary_release_date.asc' + | 'primary_release_date.desc' + | 'original_title.asc' + | 'original_title.desc' + | 'vote_average.asc' + | 'vote_average.desc' + | 'vote_count.asc' + | 'vote_count.desc' + | 'first_air_date.asc' + | 'first_air_date.desc'; + interface DiscoverMovieOptions { page?: number; includeAdult?: boolean; language?: string; primaryReleaseDateGte?: string; primaryReleaseDateLte?: string; + withRuntimeGte?: string; + withRuntimeLte?: string; + voteAverageGte?: string; + voteAverageLte?: string; originalLanguage?: string; - genre?: number; - studio?: number; + genre?: string; + studio?: string; keywords?: string; - sortBy?: - | 'popularity.asc' - | 'popularity.desc' - | 'release_date.asc' - | 'release_date.desc' - | 'revenue.asc' - | 'revenue.desc' - | 'primary_release_date.asc' - | 'primary_release_date.desc' - | 'original_title.asc' - | 'original_title.desc' - | 'vote_average.asc' - | 'vote_average.desc' - | 'vote_count.asc' - | 'vote_count.desc'; + sortBy?: SortOptions; } interface DiscoverTvOptions { @@ -67,20 +75,16 @@ interface DiscoverTvOptions { language?: string; firstAirDateGte?: string; firstAirDateLte?: string; + withRuntimeGte?: string; + withRuntimeLte?: string; + voteAverageGte?: string; + voteAverageLte?: string; includeEmptyReleaseDate?: boolean; originalLanguage?: string; genre?: number; network?: number; keywords?: string; - sortBy?: - | 'popularity.asc' - | 'popularity.desc' - | 'vote_average.asc' - | 'vote_average.desc' - | 'vote_count.asc' - | 'vote_count.desc' - | 'first_air_date.asc' - | 'first_air_date.desc'; + sortBy?: SortOptions; } class TheMovieDb extends ExternalAPI { @@ -446,8 +450,22 @@ class TheMovieDb extends ExternalAPI { genre, studio, keywords, + withRuntimeGte, + withRuntimeLte, + voteAverageGte, + voteAverageLte, }: DiscoverMovieOptions = {}): Promise => { try { + const defaultFutureDate = new Date( + Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5) + ) + .toISOString() + .split('T')[0]; + + const defaultPastDate = new Date('1900-01-01') + .toISOString() + .split('T')[0]; + const data = await this.get('/discover/movie', { params: { sort_by: sortBy, @@ -455,12 +473,29 @@ class TheMovieDb extends ExternalAPI { include_adult: includeAdult, language, region: this.region, - with_original_language: originalLanguage ?? this.originalLanguage, - 'primary_release_date.gte': primaryReleaseDateGte, - 'primary_release_date.lte': primaryReleaseDateLte, + with_original_language: + originalLanguage && originalLanguage !== 'all' + ? originalLanguage + : originalLanguage === 'all' + ? undefined + : this.originalLanguage, + // Set our release date values, but check if one is set and not the other, + // so we can force a past date or a future date. TMDB Requires both values if one is set! + 'primary_release_date.gte': + !primaryReleaseDateGte && primaryReleaseDateLte + ? defaultPastDate + : primaryReleaseDateGte, + 'primary_release_date.lte': + !primaryReleaseDateLte && primaryReleaseDateGte + ? defaultFutureDate + : primaryReleaseDateLte, with_genres: genre, with_companies: studio, with_keywords: keywords, + 'with_runtime.gte': withRuntimeGte, + 'with_runtime.lte': withRuntimeLte, + 'vote_average.gte': voteAverageGte, + 'vote_average.lte': voteAverageLte, }, }); @@ -481,21 +516,52 @@ class TheMovieDb extends ExternalAPI { genre, network, keywords, + withRuntimeGte, + withRuntimeLte, + voteAverageGte, + voteAverageLte, }: DiscoverTvOptions = {}): Promise => { try { + const defaultFutureDate = new Date( + Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5) + ) + .toISOString() + .split('T')[0]; + + const defaultPastDate = new Date('1900-01-01') + .toISOString() + .split('T')[0]; + const data = await this.get('/discover/tv', { params: { sort_by: sortBy, page, language, region: this.region, - 'first_air_date.gte': firstAirDateGte, - 'first_air_date.lte': firstAirDateLte, - with_original_language: originalLanguage ?? this.originalLanguage, + // Set our release date values, but check if one is set and not the other, + // so we can force a past date or a future date. TMDB Requires both values if one is set! + 'first_air_date.gte': + !firstAirDateGte && firstAirDateLte + ? defaultPastDate + : firstAirDateGte, + 'first_air_date.lte': + !firstAirDateLte && firstAirDateGte + ? defaultFutureDate + : firstAirDateLte, + with_original_language: + originalLanguage && originalLanguage !== 'all' + ? originalLanguage + : originalLanguage === 'all' + ? undefined + : this.originalLanguage, include_null_first_air_dates: includeEmptyReleaseDate, with_genres: genre, with_networks: network, with_keywords: keywords, + 'with_runtime.gte': withRuntimeGte, + 'with_runtime.lte': withRuntimeLte, + 'vote_average.gte': voteAverageGte, + 'vote_average.lte': voteAverageLte, }, }); diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 428e4f7d..079f37e1 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,4 +1,5 @@ import PlexTvAPI from '@server/api/plextv'; +import type { SortOptions } from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; @@ -21,6 +22,7 @@ import { mapNetwork } from '@server/models/Tv'; import { isMovie, isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import { sortBy } from 'lodash'; +import { z } from 'zod'; export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -47,17 +49,50 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { const discoverRoutes = Router(); +const QueryFilterOptions = z.object({ + page: z.coerce.string().optional(), + sortBy: z.coerce.string().optional(), + primaryReleaseDateGte: z.coerce.string().optional(), + primaryReleaseDateLte: z.coerce.string().optional(), + firstAirDateGte: z.coerce.string().optional(), + firstAirDateLte: z.coerce.string().optional(), + studio: z.coerce.string().optional(), + genre: z.coerce.string().optional(), + keywords: z.coerce.string().optional(), + language: z.coerce.string().optional(), + withRuntimeGte: z.coerce.string().optional(), + withRuntimeLte: z.coerce.string().optional(), + voteAverageGte: z.coerce.string().optional(), + voteAverageLte: z.coerce.string().optional(), + network: z.coerce.string().optional(), +}); + +export type FilterOptions = z.infer; + discoverRoutes.get('/movies', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); - const keywords = req.query.keywords as string; try { + const query = QueryFilterOptions.parse(req.query); + const keywords = query.keywords; const data = await tmdb.getDiscoverMovies({ - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - genre: req.query.genre ? Number(req.query.genre) : undefined, - studio: req.query.studio ? Number(req.query.studio) : undefined, + page: Number(query.page), + sortBy: query.sortBy as SortOptions, + language: req.locale ?? query.language, + originalLanguage: query.language, + genre: query.genre, + studio: query.studio, + primaryReleaseDateLte: query.primaryReleaseDateLte + ? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0] + : undefined, + primaryReleaseDateGte: query.primaryReleaseDateGte + ? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0] + : undefined, keywords, + withRuntimeGte: query.withRuntimeGte, + withRuntimeLte: query.withRuntimeLte, + voteAverageGte: query.voteAverageGte, + voteAverageLte: query.voteAverageLte, }); const media = await Media.getRelatedMedia( @@ -178,7 +213,7 @@ discoverRoutes.get<{ genreId: string }>( const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: req.locale ?? (req.query.language as string), - genre: Number(req.params.genreId), + genre: req.params.genreId as string, }); const media = await Media.getRelatedMedia( @@ -225,7 +260,7 @@ discoverRoutes.get<{ studioId: string }>( const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: req.locale ?? (req.query.language as string), - studio: Number(req.params.studioId), + studio: req.params.studioId as string, }); const media = await Media.getRelatedMedia( @@ -309,15 +344,28 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => { discoverRoutes.get('/tv', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); - const keywords = req.query.keywords as string; try { + const query = QueryFilterOptions.parse(req.query); + const keywords = query.keywords; const data = await tmdb.getDiscoverTv({ - page: Number(req.query.page), - language: req.locale ?? (req.query.language as string), - genre: req.query.genre ? Number(req.query.genre) : undefined, - network: req.query.network ? Number(req.query.network) : undefined, + page: Number(query.page), + sortBy: query.sortBy as SortOptions, + language: req.locale ?? query.language, + genre: query.genre ? Number(query.genre) : undefined, + network: query.network ? Number(query.network) : undefined, + firstAirDateLte: query.firstAirDateLte + ? new Date(query.firstAirDateLte).toISOString().split('T')[0] + : undefined, + firstAirDateGte: query.firstAirDateGte + ? new Date(query.firstAirDateGte).toISOString().split('T')[0] + : undefined, + originalLanguage: query.language, keywords, + withRuntimeGte: query.withRuntimeGte, + withRuntimeLte: query.withRuntimeLte, + voteAverageGte: query.voteAverageGte, + voteAverageLte: query.voteAverageLte, }); const media = await Media.getRelatedMedia( @@ -672,7 +720,9 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( await Promise.all( genres.map(async (genre) => { - const genreData = await tmdb.getDiscoverMovies({ genre: genre.id }); + const genreData = await tmdb.getDiscoverMovies({ + genre: genre.id.toString(), + }); mappedGenres.push({ id: genre.id, diff --git a/src/components/Common/MultiRangeSlider/index.tsx b/src/components/Common/MultiRangeSlider/index.tsx new file mode 100644 index 00000000..c3da2b57 --- /dev/null +++ b/src/components/Common/MultiRangeSlider/index.tsx @@ -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 ( +
+ + = valueMax && valueMin !== min ? 'z-30' : 'z-10' + }`} + onChange={(e) => { + const value = Number(e.target.value); + + if (value <= valueMax) { + touched.current = true; + setValueMin(value); + } + }} + /> + + + { + const value = Number(e.target.value); + + if (value >= valueMin) { + touched.current = true; + setValueMax(value); + } + }} + /> + +
+ {subText && ( +
+ {subText} +
+ )} +
+ ); +}; + +export default MultiRangeSlider; diff --git a/src/components/Common/SlideOver/index.tsx b/src/components/Common/SlideOver/index.tsx index fd0ecb37..48c1f854 100644 --- a/src/components/Common/SlideOver/index.tsx +++ b/src/components/Common/SlideOver/index.tsx @@ -67,11 +67,11 @@ const SlideOver = ({ > {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
e.stopPropagation()} > -
+

@@ -95,8 +95,10 @@ const SlideOver = ({

)}
-
- {children} +
+
+ {children} +
diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx deleted file mode 100644 index b9ec8dea..00000000 --- a/src/components/Discover/DiscoverMovies.tsx +++ /dev/null @@ -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('/api/v1/discover/movies'); - - if (error) { - return ; - } - - const title = intl.formatMessage(messages.discovermovies); - - return ( - <> - -
-
{title}
-
- 0) - } - isReachingEnd={isReachingEnd} - onScrollBottom={fetchMore} - /> - - ); -}; - -export default DiscoverMovies; diff --git a/src/components/Discover/DiscoverMovies/index.tsx b/src/components/Discover/DiscoverMovies/index.tsx new file mode 100644 index 00000000..2cc51177 --- /dev/null +++ b/src/components/Discover/DiscoverMovies/index.tsx @@ -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 = { + 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( + '/api/v1/discover/movies', + preparedFilters + ); + const [showFilters, setShowFilters] = useState(false); + + if (error) { + return ; + } + + const title = intl.formatMessage(messages.discovermovies); + + return ( + <> + +
+
{title}
+
+
+ + + + +
+ setShowFilters(false)} + show={showFilters} + /> +
+ +
+
+
+ 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMovies; diff --git a/src/components/Discover/DiscoverTv.tsx b/src/components/Discover/DiscoverTv.tsx deleted file mode 100644 index 404b1aa5..00000000 --- a/src/components/Discover/DiscoverTv.tsx +++ /dev/null @@ -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('/api/v1/discover/tv'); - - if (error) { - return ; - } - - const title = intl.formatMessage(messages.discovertv); - - return ( - <> - -
-
{title}
-
- 0) - } - onScrollBottom={fetchMore} - /> - - ); -}; - -export default DiscoverTv; diff --git a/src/components/Discover/DiscoverTv/index.tsx b/src/components/Discover/DiscoverTv/index.tsx new file mode 100644 index 00000000..bd3a1701 --- /dev/null +++ b/src/components/Discover/DiscoverTv/index.tsx @@ -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 = { + 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('/api/v1/discover/tv', { + ...preparedFilters, + }); + + if (error) { + return ; + } + + const title = intl.formatMessage(messages.discovertv); + + return ( + <> + +
+
{title}
+
+
+ + + + +
+ setShowFilters(false)} + show={showFilters} + /> +
+ +
+
+
+ 0) + } + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverTv; diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx new file mode 100644 index 00000000..5a431f69 --- /dev/null +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -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 ( + onClose()} + > +
+
+
+ {intl.formatMessage( + type === 'movie' ? messages.releaseDate : messages.firstAirDate + )} +
+
+
+
{intl.formatMessage(messages.from)}
+ { + updateQueryParams( + dateGte, + value?.startDate ? (value.startDate as string) : undefined + ); + }} + inputName="fromdate" + useRange={false} + asSingle + containerClassName="datepicker-wrapper" + inputClassName="pr-1 sm:pr-4" + /> +
+
+
{intl.formatMessage(messages.to)}
+ { + updateQueryParams( + dateLte, + value?.startDate ? (value.startDate as string) : undefined + ); + }} + inputName="todate" + useRange={false} + asSingle + containerClassName="datepicker-wrapper" + inputClassName="pr-1 sm:pr-4" + /> +
+
+
+ {type === 'movie' && ( + <> + + {intl.formatMessage(messages.studio)} + + { + updateQueryParams('studio', value?.value.toString()); + }} + /> + + )} + + {intl.formatMessage(messages.genres)} + + { + updateQueryParams('genre', value?.map((v) => v.value).join(',')); + }} + /> + + {intl.formatMessage(messages.keywords)} + + { + updateQueryParams('keywords', value?.map((v) => v.value).join(',')); + }} + /> + + {intl.formatMessage(messages.originalLanguage)} + + { + updateQueryParams('language', value); + }} + /> + + {intl.formatMessage(messages.runtime)} + +
+ { + 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, + })} + /> +
+ + {intl.formatMessage(messages.tmdbuserscore)} + +
+ { + 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, + })} + /> +
+
+ +
+
+
+ ); +}; + +export default FilterSlideover; diff --git a/src/components/Discover/MovieGenreSlider/index.tsx b/src/components/Discover/MovieGenreSlider/index.tsx index b411a7d7..106d14a5 100644 --- a/src/components/Discover/MovieGenreSlider/index.tsx +++ b/src/components/Discover/MovieGenreSlider/index.tsx @@ -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={} diff --git a/src/components/Discover/TvGenreSlider/index.tsx b/src/components/Discover/TvGenreSlider/index.tsx index f8c74195..34dfd1c7 100644 --- a/src/components/Discover/TvGenreSlider/index.tsx +++ b/src/components/Discover/TvGenreSlider/index.tsx @@ -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={} diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 3cef94db..80faf5c5 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -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; + +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; +}; diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 920acc1b..64ddf80c 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -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 ; } @@ -240,8 +246,9 @@ const Discover = () => { ); break; @@ -266,8 +273,9 @@ const Discover = () => { ); 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 = () => { ); break; @@ -319,8 +328,9 @@ const Discover = () => { ); break; diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 2ef9f1f5..de2fd8cd 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -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: , - activeRegExp: /^\/(discover\/?(movies|tv)?)?$/, + activeRegExp: /^\/(discover\/?)?$/, + }, + { + href: '/discover/movies', + messagesKey: 'browsemovies', + svgIcon: , + activeRegExp: /^\/discover\/movies$/, + }, + { + href: '/discover/tv', + messagesKey: 'browsetv', + svgIcon: , + activeRegExp: /^\/discover\/tv$/, }, { href: '/requests', diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 2fd86f6f..1b339b7f 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -223,7 +223,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { movieAttributes.push( data.genres .map((g) => ( - + {g.name} )) @@ -458,7 +458,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
{data.keywords.map((keyword) => ( diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx new file mode 100644 index 00000000..87634d39 --- /dev/null +++ b/src/components/Selector/index.tsx @@ -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 | null) => void; +}; + +type BaseSelectorSingleProps = { + defaultValue?: string; + isMulti?: false; + onChange: (value: SingleValue | null) => void; +}; + +export const CompanySelector = ({ + defaultValue, + isMulti, + onChange, +}: BaseSelectorSingleProps | BaseSelectorMultiProps) => { + const intl = useIntl(); + const [defaultDataValue, setDefaultDataValue] = useState< + { label: string; value: number }[] | null + >(null); + + useEffect(() => { + const loadDefaultCompany = async (): Promise => { + if (!defaultValue) { + return; + } + + const response = await axios.get( + `/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( + '/api/v1/search/company', + { + params: { + query: encodeURIExtraParams(inputValue), + }, + } + ); + + return results.data.results.map((result) => ({ + label: result.name, + value: result.id, + })); + }; + + return ( + + inputValue === '' + ? intl.formatMessage(messages.starttyping) + : intl.formatMessage(messages.nooptions) + } + loadOptions={loadCompanyOptions} + placeholder={intl.formatMessage(messages.searchStudios)} + onChange={(value) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange(value as any); + }} + /> + ); +}; + +type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & { + type: 'movie' | 'tv'; +}; + +export const GenreSelector = ({ + isMulti, + defaultValue, + onChange, + type, +}: GenreSelectorProps) => { + const intl = useIntl(); + const [defaultDataValue, setDefaultDataValue] = useState< + { label: string; value: number }[] | null + >(null); + + useEffect(() => { + const loadDefaultGenre = async (): Promise => { + if (!defaultValue) { + return; + } + + const genres = defaultValue.split(','); + + const response = await axios.get(`/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( + `/api/v1/discover/genreslider/${type}` + ); + + return results.data.map((result) => ({ + label: result.name, + value: result.id, + })); + }; + + return ( + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange(value as any); + }} + /> + ); +}; + +export const KeywordSelector = ({ + isMulti, + defaultValue, + onChange, +}: BaseSelectorMultiProps | BaseSelectorSingleProps) => { + const intl = useIntl(); + const [defaultDataValue, setDefaultDataValue] = useState< + { label: string; value: number }[] | null + >(null); + + useEffect(() => { + const loadDefaultKeywords = async (): Promise => { + if (!defaultValue) { + return; + } + + const keywords = await Promise.all( + defaultValue.split(',').map(async (keywordId) => { + const keyword = await axios.get( + `/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( + '/api/v1/search/keyword', + { + params: { + query: encodeURIExtraParams(inputValue), + }, + } + ); + + return results.data.results.map((result) => ({ + label: result.name, + value: result.id, + })); + }; + + return ( + + inputValue === '' + ? intl.formatMessage(messages.starttyping) + : intl.formatMessage(messages.nooptions) + } + defaultValue={defaultDataValue} + loadOptions={loadKeywordOptions} + placeholder={intl.formatMessage(messages.searchKeywords)} + onChange={(value) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange(value as any); + }} + /> + ); +}; diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 14185e87..d3a60172 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -206,7 +206,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { seriesAttributes.push( data.genres .map((g) => ( - + {g.name} )) @@ -499,7 +499,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
{data.keywords.map((keyword) => ( diff --git a/src/hooks/useDiscover.ts b/src/hooks/useDiscover.ts index 96ea9cc7..163a1cc5 100644 --- a/src/hooks/useDiscover.ts +++ b/src/hooks/useDiscover.ts @@ -1,3 +1,4 @@ +import { encodeURIExtraParams } from '@app/hooks/useSearchInput'; import { MediaStatus } from '@server/constants/media'; import useSWRInfinite from 'swr/infinite'; import useSettings from './useSettings'; @@ -27,9 +28,13 @@ interface DiscoverResult { firstResultData?: BaseSearchResult & S; } -const useDiscover = >( +const useDiscover = < + T extends BaseMedia, + S = Record, + O = Record +>( endpoint: string, - options?: Record, + options?: O, { hideAvailable = true } = {} ): DiscoverResult => { const settings = useSettings(); @@ -47,7 +52,10 @@ const useDiscover = >( }; const finalQueryString = Object.keys(params) - .map((paramKey) => `${paramKey}=${params[paramKey]}`) + .map( + (paramKey) => + `${paramKey}=${encodeURIExtraParams(params[paramKey] as string)}` + ) .join('&'); return `${endpoint}?${finalQueryString}`; diff --git a/src/hooks/useUpdateQueryParams.ts b/src/hooks/useUpdateQueryParams.ts index e2ef801d..aadfaac2 100644 --- a/src/hooks/useUpdateQueryParams.ts +++ b/src/hooks/useUpdateQueryParams.ts @@ -132,3 +132,20 @@ export const useUpdateQueryParams = ( [filter, updateQueryParams] ); }; + +export const useBatchUpdateQueryParams = ( + filter: ParsedUrlQuery +): ((items: Record) => void) => { + const updateQueryParams = useQueryParams(); + + return useCallback( + (items: Record) => { + const query = { + ...filter, + ...items, + }; + updateQueryParams(query, 'replace'); + }, + [filter, updateQueryParams] + ); +}; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 90955422..44736d2d 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -30,17 +30,52 @@ "components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies", "components.Discover.DiscoverMovieKeyword.keywordMovies": "{keywordTitle} Movies", "components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies", + "components.Discover.DiscoverMovies.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}", + "components.Discover.DiscoverMovies.discovermovies": "Movies", + "components.Discover.DiscoverMovies.sortPopularityAsc": "Popularity Ascending", + "components.Discover.DiscoverMovies.sortPopularityDesc": "Popularity Descending", + "components.Discover.DiscoverMovies.sortReleaseDateAsc": "Release Date Ascending", + "components.Discover.DiscoverMovies.sortReleaseDateDesc": "Release Date Descending", + "components.Discover.DiscoverMovies.sortTitleAsc": "Title (A-Z) Ascending", + "components.Discover.DiscoverMovies.sortTitleDesc": "Title (Z-A) Descending", + "components.Discover.DiscoverMovies.sortTmdbRatingAsc": "TMDB Rating Ascending", + "components.Discover.DiscoverMovies.sortTmdbRatingDesc": "TMDB Rating Descending", "components.Discover.DiscoverNetwork.networkSeries": "{network} Series", "components.Discover.DiscoverSliderEdit.deletefail": "Failed to delete slider.", "components.Discover.DiscoverSliderEdit.deletesuccess": "Sucessfully deleted slider.", "components.Discover.DiscoverSliderEdit.enable": "Toggle Visibility", "components.Discover.DiscoverSliderEdit.remove": "Remove", "components.Discover.DiscoverStudio.studioMovies": "{studio} Movies", + "components.Discover.DiscoverTv.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}", + "components.Discover.DiscoverTv.discovertv": "Series", + "components.Discover.DiscoverTv.sortFirstAirDateAsc": "First Air Date Ascending", + "components.Discover.DiscoverTv.sortFirstAirDateDesc": "First Air Date Descending", + "components.Discover.DiscoverTv.sortPopularityAsc": "Popularity Ascending", + "components.Discover.DiscoverTv.sortPopularityDesc": "Popularity Descending", + "components.Discover.DiscoverTv.sortTitleAsc": "Title (A-Z) Ascending", + "components.Discover.DiscoverTv.sortTitleDesc": "Title (Z-A) Descending", + "components.Discover.DiscoverTv.sortTmdbRatingAsc": "TMDB Rating Ascending", + "components.Discover.DiscoverTv.sortTmdbRatingDesc": "TMDB Rating Descending", "components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series", "components.Discover.DiscoverTvKeyword.keywordSeries": "{keywordTitle} Series", "components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series", "components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Plex Watchlist", "components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist", + "components.Discover.FilterSlideover.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}", + "components.Discover.FilterSlideover.clearfilters": "Clear Active Filters", + "components.Discover.FilterSlideover.filters": "Filters", + "components.Discover.FilterSlideover.firstAirDate": "First Air Date", + "components.Discover.FilterSlideover.from": "From", + "components.Discover.FilterSlideover.genres": "Genres", + "components.Discover.FilterSlideover.keywords": "Keywords", + "components.Discover.FilterSlideover.originalLanguage": "Original Language", + "components.Discover.FilterSlideover.ratingText": "Ratings between {minValue} and {maxValue}", + "components.Discover.FilterSlideover.releaseDate": "Release Date", + "components.Discover.FilterSlideover.runtime": "Runtime", + "components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minute runtime", + "components.Discover.FilterSlideover.studio": "Studio", + "components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score", + "components.Discover.FilterSlideover.to": "To", "components.Discover.MovieGenreList.moviegenres": "Movie Genres", "components.Discover.MovieGenreSlider.moviegenres": "Movie Genres", "components.Discover.NetworkSlider.networks": "Networks", @@ -53,8 +88,6 @@ "components.Discover.createnewslider": "Create New Slider", "components.Discover.customizediscover": "Customize Discover", "components.Discover.discover": "Discover", - "components.Discover.discovermovies": "Popular Movies", - "components.Discover.discovertv": "Popular Series", "components.Discover.emptywatchlist": "Media added to your Plex Watchlist will appear here.", "components.Discover.moviegenres": "Movie Genres", "components.Discover.networks": "Networks", @@ -161,6 +194,8 @@ "components.LanguageSelector.originalLanguageDefault": "All Languages", "components.Layout.LanguagePicker.displaylanguage": "Display Language", "components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV", + "components.Layout.Sidebar.browsemovies": "Movies", + "components.Layout.Sidebar.browsetv": "Series", "components.Layout.Sidebar.dashboard": "Discover", "components.Layout.Sidebar.issues": "Issues", "components.Layout.Sidebar.requests": "Requests", @@ -472,6 +507,11 @@ "components.ResetPassword.validationpasswordrequired": "You must provide a password", "components.Search.search": "Search", "components.Search.searchresults": "Search Results", + "components.Selector.nooptions": "No results.", + "components.Selector.searchGenres": "Select genres…", + "components.Selector.searchKeywords": "Search keywords…", + "components.Selector.searchStudios": "Search studios…", + "components.Selector.starttyping": "Starting typing to search.", "components.Settings.Notifications.NotificationsGotify.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsGotify.gotifysettingsfailed": "Gotify notification settings failed to save.", "components.Settings.Notifications.NotificationsGotify.gotifysettingssaved": "Gotify notification settings saved successfully!", diff --git a/src/styles/globals.css b/src/styles/globals.css index 7e0900e0..c61d5dce 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -425,6 +425,34 @@ .react-select-container .react-select__placeholder { @apply text-sm text-gray-500; } + + .datepicker-wrapper > button { + @apply top-0; + } + + .datepicker-wrapper > div { + @apply fixed left-0 right-0 w-full px-4 md:w-auto; + } + + .datepicker-wrapper > div > div:nth-child(2) > div { + @apply !flex-col; + } + + .datepicker-wrapper > div > div:nth-child(2) > div > div > div { + @apply !w-full !min-w-full; + } + + .datepicker-wrapper > div > div:first-child { + @apply hidden; + } + + input[type='range']::-webkit-slider-thumb { + @apply rounded-full bg-indigo-500; + pointer-events: all; + width: 16px; + height: 16px; + -webkit-appearance: none; + } } @layer utilities { diff --git a/tailwind.config.js b/tailwind.config.js index 96b5faad..cd0bf1e4 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,7 +4,11 @@ const defaultTheme = require('tailwindcss/defaultTheme'); /** @type {import('tailwindcss').Config} */ module.exports = { mode: 'jit', - content: ['./src/pages/**/*.{ts,tsx}', './src/components/**/*.{ts,tsx}'], + content: [ + './node_modules/react-tailwindcss-datepicker/dist/index.esm.js', + './src/pages/**/*.{ts,tsx}', + './src/components/**/*.{ts,tsx}', + ], theme: { extend: { transitionProperty: { diff --git a/yarn.lock b/yarn.lock index 03338c4a..1da72e83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5822,7 +5822,7 @@ dateformat@^3.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -dayjs@^1.10.4: +dayjs@1.11.7, dayjs@^1.10.4: version "1.11.7" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2" integrity sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ== @@ -11304,6 +11304,11 @@ react-spring@9.6.1: "@react-spring/web" "~9.6.1" "@react-spring/zdog" "~9.6.1" +react-tailwindcss-datepicker-sct@1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/react-tailwindcss-datepicker-sct/-/react-tailwindcss-datepicker-sct-1.3.4.tgz#e7e91c390a40822abca62e7259cee8156616d7d0" + integrity sha512-QlLekGZDbmW2DPGS33c4gfIxkk4gcgu4sRzBIm4/mZxfHuo7J+GR6SBVNIb5Xh8aCLlGtgyLqD+o0UmOVFIc4w== + react-toast-notifications@2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/react-toast-notifications/-/react-toast-notifications-2.5.1.tgz#30216eedb5608ec69719a818b9a2e09283e90074" @@ -13579,3 +13584,8 @@ yup@0.32.11: nanoclone "^0.2.1" property-expr "^2.0.4" toposort "^2.0.2" + +zod@3.20.2: + version "3.20.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.20.2.tgz#068606642c8f51b3333981f91c0a8ab37dfc2807" + integrity sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ==