From 1c4515a1ae6097f3948aaa0d0ed210831581fd98 Mon Sep 17 00:00:00 2001 From: sct Date: Tue, 16 Mar 2021 01:06:44 +0900 Subject: [PATCH] feat: genre sliders (experiment) (#1182) --- overseerr-api.yml | 64 ++++++++++++++ server/interfaces/api/discoverInterfaces.ts | 5 ++ server/routes/discover.ts | 85 +++++++++++++++++++ src/components/CompanyCard/index.tsx | 2 +- .../Discover/MovieGenreSlider/index.tsx | 53 ++++++++++++ .../Discover/TvGenreSlider/index.tsx | 53 ++++++++++++ src/components/Discover/constants.ts | 62 ++++++++++++++ src/components/Discover/index.tsx | 4 + src/components/GenreCard/index.tsx | 58 +++++++++++++ src/i18n/locale/en.json | 2 + 10 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 server/interfaces/api/discoverInterfaces.ts create mode 100644 src/components/Discover/MovieGenreSlider/index.tsx create mode 100644 src/components/Discover/TvGenreSlider/index.tsx create mode 100644 src/components/Discover/constants.ts create mode 100644 src/components/GenreCard/index.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index b75b9b21..dd11cd9e 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3780,6 +3780,70 @@ paths: type: array items: $ref: '#/components/schemas/MovieResult' + /discover/genreslider/movie: + get: + summary: Get genre slider data for movies + description: Returns a list of genres with backdrops attached + tags: + - search + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name + /discover/genreslider/tv: + get: + summary: Get genre slider data for TV series + description: Returns a list of genres with backdrops attached + tags: + - search + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name /request: get: summary: Get all requests diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts new file mode 100644 index 00000000..db90e55d --- /dev/null +++ b/server/interfaces/api/discoverInterfaces.ts @@ -0,0 +1,5 @@ +export interface GenreSliderItem { + id: number; + name: string; + backdrops: string[]; +} diff --git a/server/routes/discover.ts b/server/routes/discover.ts index c46048ae..3e690c8e 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -8,6 +8,9 @@ import { getSettings } from '../lib/settings'; import { User } from '../entity/User'; import { mapProductionCompany } from '../models/Movie'; import { mapNetwork } from '../models/Tv'; +import logger from '../logger'; +import { sortBy } from 'lodash'; +import { GenreSliderItem } from '../interfaces/api/discoverInterfaces'; const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -482,4 +485,86 @@ discoverRoutes.get<{ keywordId: string }>( } ); +discoverRoutes.get<{ language: string }, GenreSliderItem[]>( + '/genreslider/movie', + async (req, res, next) => { + const tmdb = new TheMovieDb(); + + try { + const mappedGenres: GenreSliderItem[] = []; + + const genres = await tmdb.getMovieGenres({ + language: req.query.language as string, + }); + + await Promise.all( + genres.map(async (genre) => { + const genreData = await tmdb.getDiscoverMovies({ genre: genre.id }); + + mappedGenres.push({ + id: genre.id, + name: genre.name, + backdrops: genreData.results + .filter((title) => !!title.backdrop_path) + .map((title) => title.backdrop_path) as string[], + }); + }) + ); + + const sortedData = sortBy(mappedGenres, 'name'); + + return res.status(200).json(sortedData); + } catch (e) { + logger.error('Something went wrong retrieving the movie genre slider', { + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve movie genre slider.', + }); + } + } +); + +discoverRoutes.get<{ language: string }, GenreSliderItem[]>( + '/genreslider/tv', + async (req, res, next) => { + const tmdb = new TheMovieDb(); + + try { + const mappedGenres: GenreSliderItem[] = []; + + const genres = await tmdb.getTvGenres({ + language: req.query.language as string, + }); + + await Promise.all( + genres.map(async (genre) => { + const genreData = await tmdb.getDiscoverTv({ genre: genre.id }); + + mappedGenres.push({ + id: genre.id, + name: genre.name, + backdrops: genreData.results + .filter((title) => !!title.backdrop_path) + .map((title) => title.backdrop_path) as string[], + }); + }) + ); + + const sortedData = sortBy(mappedGenres, 'name'); + + return res.status(200).json(sortedData); + } catch (e) { + logger.error('Something went wrong retrieving the tv genre slider', { + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve tv genre slider.', + }); + } + } +); + export default discoverRoutes; diff --git a/src/components/CompanyCard/index.tsx b/src/components/CompanyCard/index.tsx index 3e4c5938..100fd901 100644 --- a/src/components/CompanyCard/index.tsx +++ b/src/components/CompanyCard/index.tsx @@ -13,7 +13,7 @@ const CompanyCard: React.FC = ({ image, url, name }) => { return ( { + const { locale } = useContext(LanguageContext); + const intl = useIntl(); + const { data, error } = useSWR( + `/api/v1/discover/genreslider/movie?language=${locale}`, + { + refreshInterval: 0, + revalidateOnFocus: false, + } + ); + + return ( + <> +
+
+ {intl.formatMessage(messages.moviegenres)} +
+
+ ( + + ))} + placeholder={} + emptyMessage="" + /> + + ); +}; + +export default React.memo(MovieGenreSlider); diff --git a/src/components/Discover/TvGenreSlider/index.tsx b/src/components/Discover/TvGenreSlider/index.tsx new file mode 100644 index 00000000..9fd8fdac --- /dev/null +++ b/src/components/Discover/TvGenreSlider/index.tsx @@ -0,0 +1,53 @@ +import React, { useContext } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import useSWR from 'swr'; +import GenreCard from '../../GenreCard'; +import Slider from '../../Slider'; +import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; +import { LanguageContext } from '../../../context/LanguageContext'; +import { genreColorMap } from '../constants'; + +const messages = defineMessages({ + tvgenres: 'Series Genres', +}); + +const TvGenreSlider: React.FC = () => { + const { locale } = useContext(LanguageContext); + const intl = useIntl(); + const { data, error } = useSWR( + `/api/v1/discover/genreslider/tv?language=${locale}`, + { + refreshInterval: 0, + revalidateOnFocus: false, + } + ); + + return ( + <> +
+
+ {intl.formatMessage(messages.tvgenres)} +
+
+ ( + + ))} + placeholder={} + emptyMessage="" + /> + + ); +}; + +export default React.memo(TvGenreSlider); diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts new file mode 100644 index 00000000..90500bf5 --- /dev/null +++ b/src/components/Discover/constants.ts @@ -0,0 +1,62 @@ +type AvailableColors = + | 'black' + | 'red' + | 'darkred' + | 'blue' + | 'lightblue' + | 'darkblue' + | 'orange' + | 'darkorange' + | 'green' + | 'lightgreen' + | 'purple' + | 'darkpurple' + | 'yellow' + | 'pink'; + +export const colorTones: Record = { + red: ['991B1B', 'FCA5A5'], + darkred: ['1F2937', 'F87171'], + blue: ['032541', '01b4e4'], + lightblue: ['1F2937', '60A5FA'], + darkblue: ['1F2937', '2864d2'], + orange: ['92400E', 'FCD34D'], + lightgreen: ['065F46', '6EE7B7'], + green: ['087d29', '21cb51'], + purple: ['5B21B6', 'C4B5FD'], + yellow: ['777e0d', 'e4ed55'], + darkorange: ['552c01', 'd47c1d'], + black: ['1F2937', 'D1D5DB'], + pink: ['9D174D', 'F9A8D4'], + darkpurple: ['480c8b', 'a96bef'], +}; + +export const genreColorMap: Record = { + 0: colorTones.black, + 28: colorTones.red, + 12: colorTones.blue, + 16: colorTones.orange, + 35: colorTones.lightgreen, + 80: colorTones.darkblue, + 99: colorTones.green, + 18: colorTones.purple, + 10751: colorTones.yellow, + 14: colorTones.darkorange, + 36: colorTones.green, + 27: colorTones.black, + 10402: colorTones.blue, + 9648: colorTones.purple, + 10749: colorTones.pink, + 878: colorTones.lightblue, + 10770: colorTones.red, + 53: colorTones.darkpurple, + 10752: colorTones.darkred, + 37: colorTones.orange, + 10759: colorTones.blue, // Action & Adventure + 10762: colorTones.blue, // Kids + 10764: colorTones.red, // Reality + 10765: colorTones.lightblue, // Sci-Fi & Fantasy + 10766: colorTones.darkpurple, // Soap + 10767: colorTones.lightgreen, // Talk + 10768: colorTones.darkred, // War & Politics +}; diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index f522a2a7..790305e8 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -11,6 +11,8 @@ import MediaSlider from '../MediaSlider'; import PageTitle from '../Common/PageTitle'; import StudioSlider from './StudioSlider'; import NetworkSlider from './NetworkSlider'; +import MovieGenreSlider from './MovieGenreSlider'; +import TvGenreSlider from './TvGenreSlider'; const messages = defineMessages({ discover: 'Discover', @@ -104,6 +106,7 @@ const Discover: React.FC = () => { url="/api/v1/discover/movies" linkUrl="/discover/movies" /> + { url="/api/v1/discover/tv" linkUrl="/discover/tv" /> + = ({ image, url, name }) => { + const [isHovered, setHovered] = useState(false); + + return ( + +
{ + setHovered(true); + }} + onMouseLeave={() => setHovered(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setHovered(true); + } + }} + role="link" + tabIndex={0} + > +
+
+ {name} +
+
+ + ); +}; + +const GenreCardPlaceholder: React.FC = () => { + return ( +
+ ); +}; + +export default withProperties(GenreCard, { Placeholder: GenreCardPlaceholder }); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 5295635d..7d1e75ba 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -20,8 +20,10 @@ "components.Discover.DiscoverStudio.studioMovies": "{studio} Movies", "components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series", "components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series", + "components.Discover.MovieGenreSlider.moviegenres": "Movie Genres", "components.Discover.NetworkSlider.networks": "Networks", "components.Discover.StudioSlider.studios": "Studios", + "components.Discover.TvGenreSlider.tvgenres": "Series Genres", "components.Discover.discover": "Discover", "components.Discover.discovermovies": "Popular Movies", "components.Discover.discovertv": "Popular Series",