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
|
||||
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
|
||||
|
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 { 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;
|
||||
|
@@ -13,7 +13,7 @@ const CompanyCard: React.FC<CompanyCardProps> = ({ image, url, name }) => {
|
||||
return (
|
||||
<Link href={url}>
|
||||
<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
|
||||
? 'bg-gray-700 scale-105 ring-gray-500'
|
||||
: '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 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"
|
||||
/>
|
||||
<MovieGenreSlider />
|
||||
<MediaSlider
|
||||
sliderKey="upcoming"
|
||||
title={intl.formatMessage(messages.upcoming)}
|
||||
@@ -117,6 +120,7 @@ const Discover: React.FC = () => {
|
||||
url="/api/v1/discover/tv"
|
||||
linkUrl="/discover/tv"
|
||||
/>
|
||||
<TvGenreSlider />
|
||||
<MediaSlider
|
||||
sliderKey="upcoming-tv"
|
||||
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.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",
|
||||
|
Reference in New Issue
Block a user