mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: genre sliders (experiment) (#1182)
This commit is contained in:
@@ -3780,6 +3780,70 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/MovieResult'
|
$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:
|
/request:
|
||||||
get:
|
get:
|
||||||
summary: Get all requests
|
summary: Get all requests
|
||||||
|
5
server/interfaces/api/discoverInterfaces.ts
Normal file
5
server/interfaces/api/discoverInterfaces.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface GenreSliderItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
backdrops: string[];
|
||||||
|
}
|
@@ -8,6 +8,9 @@ import { getSettings } from '../lib/settings';
|
|||||||
import { User } from '../entity/User';
|
import { User } from '../entity/User';
|
||||||
import { mapProductionCompany } from '../models/Movie';
|
import { mapProductionCompany } from '../models/Movie';
|
||||||
import { mapNetwork } from '../models/Tv';
|
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 createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => {
|
||||||
const settings = getSettings();
|
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;
|
export default discoverRoutes;
|
||||||
|
@@ -13,7 +13,7 @@ const CompanyCard: React.FC<CompanyCardProps> = ({ image, url, name }) => {
|
|||||||
return (
|
return (
|
||||||
<Link href={url}>
|
<Link href={url}>
|
||||||
<a
|
<a
|
||||||
className={`relative flex items-center justify-center h-32 w-64 sm:h-36 sm:w-72 p-8 shadow transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
|
className={`relative flex items-center justify-center h-32 w-64 sm:h-36 sm:w-72 p-8 shadow transition ease-in-out duration-300 cursor-pointer transform-gpu ring-1 ${
|
||||||
isHovered
|
isHovered
|
||||||
? 'bg-gray-700 scale-105 ring-gray-500'
|
? 'bg-gray-700 scale-105 ring-gray-500'
|
||||||
: 'bg-gray-800 scale-100 ring-gray-700'
|
: 'bg-gray-800 scale-100 ring-gray-700'
|
||||||
|
53
src/components/Discover/MovieGenreSlider/index.tsx
Normal file
53
src/components/Discover/MovieGenreSlider/index.tsx
Normal file
@@ -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({
|
||||||
|
moviegenres: 'Movie Genres',
|
||||||
|
});
|
||||||
|
|
||||||
|
const MovieGenreSlider: React.FC = () => {
|
||||||
|
const { locale } = useContext(LanguageContext);
|
||||||
|
const intl = useIntl();
|
||||||
|
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||||
|
`/api/v1/discover/genreslider/movie?language=${locale}`,
|
||||||
|
{
|
||||||
|
refreshInterval: 0,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.moviegenres)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="movie-genres"
|
||||||
|
isLoading={!data && !error}
|
||||||
|
isEmpty={false}
|
||||||
|
items={(data ?? []).map((genre, index) => (
|
||||||
|
<GenreCard
|
||||||
|
key={`genre-${genre.id}-${index}`}
|
||||||
|
name={genre.name}
|
||||||
|
image={`https://www.themoviedb.org/t/p/w1280_filter(duotone,${
|
||||||
|
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||||
|
})${genre.backdrops[4]}`}
|
||||||
|
url={`/discover/movies/genre/${genre.id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
placeholder={<GenreCard.Placeholder />}
|
||||||
|
emptyMessage=""
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(MovieGenreSlider);
|
53
src/components/Discover/TvGenreSlider/index.tsx
Normal file
53
src/components/Discover/TvGenreSlider/index.tsx
Normal file
@@ -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<GenreSliderItem[]>(
|
||||||
|
`/api/v1/discover/genreslider/tv?language=${locale}`,
|
||||||
|
{
|
||||||
|
refreshInterval: 0,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="slider-header">
|
||||||
|
<div className="slider-title">
|
||||||
|
<span>{intl.formatMessage(messages.tvgenres)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="tv-genres"
|
||||||
|
isLoading={!data && !error}
|
||||||
|
isEmpty={false}
|
||||||
|
items={(data ?? []).map((genre, index) => (
|
||||||
|
<GenreCard
|
||||||
|
key={`genre-tv-${genre.id}-${index}`}
|
||||||
|
name={genre.name}
|
||||||
|
image={`https://www.themoviedb.org/t/p/w1280_filter(duotone,${
|
||||||
|
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||||
|
})${genre.backdrops[4]}`}
|
||||||
|
url={`/discover/tv/genre/${genre.id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
placeholder={<GenreCard.Placeholder />}
|
||||||
|
emptyMessage=""
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(TvGenreSlider);
|
62
src/components/Discover/constants.ts
Normal file
62
src/components/Discover/constants.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
type AvailableColors =
|
||||||
|
| 'black'
|
||||||
|
| 'red'
|
||||||
|
| 'darkred'
|
||||||
|
| 'blue'
|
||||||
|
| 'lightblue'
|
||||||
|
| 'darkblue'
|
||||||
|
| 'orange'
|
||||||
|
| 'darkorange'
|
||||||
|
| 'green'
|
||||||
|
| 'lightgreen'
|
||||||
|
| 'purple'
|
||||||
|
| 'darkpurple'
|
||||||
|
| 'yellow'
|
||||||
|
| 'pink';
|
||||||
|
|
||||||
|
export const colorTones: Record<AvailableColors, [string, string]> = {
|
||||||
|
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<number, [string, string]> = {
|
||||||
|
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
|
||||||
|
};
|
@@ -11,6 +11,8 @@ import MediaSlider from '../MediaSlider';
|
|||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
import StudioSlider from './StudioSlider';
|
import StudioSlider from './StudioSlider';
|
||||||
import NetworkSlider from './NetworkSlider';
|
import NetworkSlider from './NetworkSlider';
|
||||||
|
import MovieGenreSlider from './MovieGenreSlider';
|
||||||
|
import TvGenreSlider from './TvGenreSlider';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
discover: 'Discover',
|
discover: 'Discover',
|
||||||
@@ -104,6 +106,7 @@ const Discover: React.FC = () => {
|
|||||||
url="/api/v1/discover/movies"
|
url="/api/v1/discover/movies"
|
||||||
linkUrl="/discover/movies"
|
linkUrl="/discover/movies"
|
||||||
/>
|
/>
|
||||||
|
<MovieGenreSlider />
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
sliderKey="upcoming"
|
sliderKey="upcoming"
|
||||||
title={intl.formatMessage(messages.upcoming)}
|
title={intl.formatMessage(messages.upcoming)}
|
||||||
@@ -117,6 +120,7 @@ const Discover: React.FC = () => {
|
|||||||
url="/api/v1/discover/tv"
|
url="/api/v1/discover/tv"
|
||||||
linkUrl="/discover/tv"
|
linkUrl="/discover/tv"
|
||||||
/>
|
/>
|
||||||
|
<TvGenreSlider />
|
||||||
<MediaSlider
|
<MediaSlider
|
||||||
sliderKey="upcoming-tv"
|
sliderKey="upcoming-tv"
|
||||||
title={intl.formatMessage(messages.upcomingtv)}
|
title={intl.formatMessage(messages.upcomingtv)}
|
||||||
|
58
src/components/GenreCard/index.tsx
Normal file
58
src/components/GenreCard/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { withProperties } from '../../utils/typeHelpers';
|
||||||
|
|
||||||
|
interface GenreCardProps {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenreCard: React.FC<GenreCardProps> = ({ image, url, name }) => {
|
||||||
|
const [isHovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={url}>
|
||||||
|
<a
|
||||||
|
className={`relative flex items-center justify-center h-32 w-56 sm:h-40 sm:w-72 p-8 shadow transition ease-in-out duration-300 cursor-pointer transform-gpu ring-1 ${
|
||||||
|
isHovered
|
||||||
|
? 'bg-gray-700 scale-105 ring-gray-500 bg-opacity-100'
|
||||||
|
: 'bg-gray-800 scale-100 ring-gray-700 bg-opacity-80'
|
||||||
|
} rounded-xl bg-cover bg-center overflow-hidden`}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("${image}")`,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setHovered(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute z-10 inset-0 w-full h-full transition duration-300 bg-gray-800 ${
|
||||||
|
isHovered ? 'bg-opacity-10' : 'bg-opacity-30'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="relative z-20 w-full text-2xl font-bold text-center text-white truncate whitespace-normal sm:text-3xl">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GenreCardPlaceholder: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative h-32 w-56 sm:h-40 sm:w-72 animate-pulse rounded-xl bg-gray-700`}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withProperties(GenreCard, { Placeholder: GenreCardPlaceholder });
|
@@ -20,8 +20,10 @@
|
|||||||
"components.Discover.DiscoverStudio.studioMovies": "{studio} Movies",
|
"components.Discover.DiscoverStudio.studioMovies": "{studio} Movies",
|
||||||
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series",
|
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series",
|
||||||
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series",
|
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series",
|
||||||
|
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
|
||||||
"components.Discover.NetworkSlider.networks": "Networks",
|
"components.Discover.NetworkSlider.networks": "Networks",
|
||||||
"components.Discover.StudioSlider.studios": "Studios",
|
"components.Discover.StudioSlider.studios": "Studios",
|
||||||
|
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
|
||||||
"components.Discover.discover": "Discover",
|
"components.Discover.discover": "Discover",
|
||||||
"components.Discover.discovermovies": "Popular Movies",
|
"components.Discover.discovermovies": "Popular Movies",
|
||||||
"components.Discover.discovertv": "Popular Series",
|
"components.Discover.discovertv": "Popular Series",
|
||||||
|
Reference in New Issue
Block a user