feat: add discover customization (#3182)

This commit is contained in:
Ryan Cohen
2023-01-03 16:04:28 +09:00
committed by GitHub
parent f14d9407d8
commit cd3574851a
34 changed files with 3389 additions and 195 deletions

View File

@@ -0,0 +1,38 @@
type SlideCheckboxProps = {
onClick: () => void;
checked?: boolean;
};
const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
return (
<span
role="checkbox"
tabIndex={0}
aria-checked={false}
onClick={() => {
onClick();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
onClick();
}
}}
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none`}
>
<span
aria-hidden="true"
className={`${
checked ? 'bg-indigo-500' : 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
checked ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
);
};
export default SlideCheckbox;

View File

@@ -0,0 +1,68 @@
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 { encodeURIExtraParams } from '@app/hooks/useSearchInput';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
keywordMovies: '{keywordTitle} Movies',
});
const DiscoverMovieKeyword = () => {
const router = useRouter();
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
firstResultData,
} = useDiscover<MovieResult, { keywords: TmdbKeyword[] }>(
`/api/v1/discover/movies`,
{
keywords: encodeURIExtraParams(router.query.keywords as string),
}
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.keywordMovies, {
keywordTitle: firstResultData?.keywords
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
.join(', '),
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovieKeyword;

View File

@@ -0,0 +1,68 @@
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 { encodeURIExtraParams } from '@app/hooks/useSearchInput';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
keywordSeries: '{keywordTitle} Series',
});
const DiscoverTvKeyword = () => {
const router = useRouter();
const intl = useIntl();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
firstResultData,
} = useDiscover<TvResult, { keywords: TmdbKeyword[] }>(
`/api/v1/discover/tv`,
{
keywords: encodeURIExtraParams(router.query.keywords as string),
}
);
if (error) {
return <Error statusCode={500} />;
}
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.keywordSeries, {
keywordTitle: firstResultData?.keywords
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
.join(', '),
});
return (
<>
<PageTitle title={title} />
<div className="mt-1 mb-5">
<Header>{title}</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTvKeyword;

View File

@@ -0,0 +1,79 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { UserType, useUser } from '@app/hooks/useUser';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
plexwatchlist: 'Your Plex Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
});
const PlexWatchlistSlider = () => {
const intl = useIntl();
const { user } = useUser();
const { data: watchlistItems, error: watchlistError } = useSWR<{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
revalidateOnMount: true,
});
if (
user?.userType !== UserType.PLEX ||
(watchlistItems &&
watchlistItems.results.length === 0 &&
!user?.settings?.watchlistSyncMovies &&
!user?.settings?.watchlistSyncTv) ||
watchlistError
) {
return null;
}
return (
<>
<div className="slider-header">
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="watchlist"
isLoading={!watchlistItems}
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
})}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
/>
))}
/>
</>
);
};
export default PlexWatchlistSlider;

View File

@@ -0,0 +1,49 @@
import { sliderTitles } from '@app/components/Discover/constants';
import RequestCard from '@app/components/RequestCard';
import Slider from '@app/components/Slider';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
const RecentRequestsSlider = () => {
const intl = useIntl();
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{
revalidateOnMount: true,
}
);
if (requests && requests.results.length === 0 && !requestError) {
return null;
}
return (
<>
<div className="slider-header">
<Link href="/requests?filter=all">
<a className="slider-title">
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="requests"
isLoading={!requests}
items={(requests?.results ?? []).map((request) => (
<RequestCard
key={`request-slider-item-${request.id}`}
request={request}
/>
))}
placeholder={<RequestCard.Placeholder />}
/>
</>
);
};
export default RecentRequestsSlider;

View File

@@ -0,0 +1,53 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
recentlyAdded: 'Recently Added',
});
const RecentlyAddedSlider = () => {
const intl = useIntl();
const { hasPermission } = useUser();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
);
if (
(media && !media.results.length && !mediaError) ||
!hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
type: 'or',
})
) {
return null;
}
return (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={(media?.results ?? []).map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tvdbId={item.tvdbId}
type={item.mediaType}
/>
))}
/>
</>
);
};
export default RecentlyAddedSlider;

View File

@@ -1,3 +1,5 @@
import { defineMessages } from 'react-intl';
type AvailableColors =
| 'black'
| 'red'
@@ -61,3 +63,25 @@ export const genreColorMap: Record<number, [string, string]> = {
10767: colorTones.lightgreen, // Talk
10768: colorTones.darkred, // War & Politics
};
export const sliderTitles = defineMessages({
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
moviegenres: 'Movie Genres',
tvgenres: 'Series Genres',
studios: 'Studios',
networks: 'Networks',
tmdbmoviekeyword: 'TMDB Movie Keyword',
tmdbtvkeyword: 'TMDB Series Keyword',
tmdbmoviegenre: 'TMDB Movie Genre',
tmdbtvgenre: 'TMDB Series Genre',
tmdbnetwork: 'TMDB Network',
tmdbstudio: 'TMDB Studio',
tmdbsearch: 'TMDB Search',
});

View File

@@ -1,189 +1,180 @@
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import { sliderTitles } from '@app/components/Discover/constants';
import MovieGenreSlider from '@app/components/Discover/MovieGenreSlider';
import NetworkSlider from '@app/components/Discover/NetworkSlider';
import PlexWatchlistSlider from '@app/components/Discover/PlexWatchlistSlider';
import RecentlyAddedSlider from '@app/components/Discover/RecentlyAddedSlider';
import RecentRequestsSlider from '@app/components/Discover/RecentRequestsSlider';
import StudioSlider from '@app/components/Discover/StudioSlider';
import TvGenreSlider from '@app/components/Discover/TvGenreSlider';
import MediaSlider from '@app/components/MediaSlider';
import RequestCard from '@app/components/RequestCard';
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
import { encodeURIExtraParams } from '@app/hooks/useSearchInput';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
discover: 'Discover',
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
});
const Discover = () => {
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
const { data: discoverData, error: discoverError } = useSWR<DiscoverSlider[]>(
'/api/v1/settings/discover'
);
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{
revalidateOnMount: true,
}
);
const { data: watchlistItems, error: watchlistError } = useSWR<{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
revalidateOnMount: true,
});
if (!discoverData && !discoverError) {
return <LoadingSpinner />;
}
return (
<>
<PageTitle title={intl.formatMessage(messages.discover)} />
{(!media || !!media.results.length) &&
!mediaError &&
hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
type: 'or',
}) && (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={(media?.results ?? []).map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tvdbId={item.tvdbId}
type={item.mediaType}
/>
))}
/>
</>
)}
{(!requests || !!requests.results.length) && !requestError && (
<>
<div className="slider-header">
<Link href="/requests?filter=all">
<a className="slider-title">
<span>{intl.formatMessage(messages.recentrequests)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="requests"
isLoading={!requests}
items={(requests?.results ?? []).map((request) => (
<RequestCard
key={`request-slider-item-${request.id}`}
request={request}
{discoverData?.map((slider) => {
if (!slider.enabled) {
return null;
}
switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED:
return <RecentlyAddedSlider />;
case DiscoverSliderType.RECENT_REQUESTS:
return <RecentRequestsSlider />;
case DiscoverSliderType.PLEX_WATCHLIST:
return <PlexWatchlistSlider />;
case DiscoverSliderType.TRENDING:
return (
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(sliderTitles.trending)}
url="/api/v1/discover/trending"
linkUrl="/discover/trending"
/>
))}
placeholder={<RequestCard.Placeholder />}
/>
</>
)}
{user?.userType === UserType.PLEX &&
(!watchlistItems ||
!!watchlistItems.results.length ||
user.settings?.watchlistSyncMovies ||
user.settings?.watchlistSyncTv) &&
!watchlistError && (
<>
<div className="slider-header">
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="watchlist"
isLoading={!watchlistItems}
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
})}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
/>
))}
/>
</>
)}
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(messages.trending)}
url="/api/v1/discover/trending"
linkUrl="/discover/trending"
/>
<MediaSlider
sliderKey="popular-movies"
title={intl.formatMessage(messages.popularmovies)}
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
<MovieGenreSlider />
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(messages.upcoming)}
linkUrl="/discover/movies/upcoming"
url="/api/v1/discover/movies/upcoming"
/>
<StudioSlider />
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(messages.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
<TvGenreSlider />
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(messages.upcomingtv)}
url="/api/v1/discover/tv/upcoming"
linkUrl="/discover/tv/upcoming"
/>
<NetworkSlider />
);
case DiscoverSliderType.POPULAR_MOVIES:
return (
<MediaSlider
sliderKey="popular-movies"
title={intl.formatMessage(sliderTitles.popularmovies)}
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
);
case DiscoverSliderType.MOVIE_GENRES:
return <MovieGenreSlider />;
case DiscoverSliderType.UPCOMING_MOVIES:
return (
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(sliderTitles.upcoming)}
linkUrl="/discover/movies/upcoming"
url="/api/v1/discover/movies/upcoming"
/>
);
case DiscoverSliderType.STUDIOS:
return <StudioSlider />;
case DiscoverSliderType.POPULAR_TV:
return (
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(sliderTitles.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
);
case DiscoverSliderType.TV_GENRES:
return <TvGenreSlider />;
case DiscoverSliderType.UPCOMING_TV:
return (
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(sliderTitles.upcomingtv)}
url="/api/v1/discover/tv/upcoming"
linkUrl="/discover/tv/upcoming"
/>
);
case DiscoverSliderType.NETWORKS:
return <NetworkSlider />;
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
return (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/movies"
extraParams={
slider.data
? `keywords=${encodeURIExtraParams(slider.data)}`
: ''
}
linkUrl={`/discover/movies/keyword?keywords=${slider.data}`}
/>
);
case DiscoverSliderType.TMDB_TV_KEYWORD:
return (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/tv"
extraParams={
slider.data
? `keywords=${encodeURIExtraParams(slider.data)}`
: ''
}
linkUrl={`/discover/tv/keyword?keywords=${slider.data}`}
/>
);
case DiscoverSliderType.TMDB_MOVIE_GENRE:
return (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/movies/genre/${slider.data}`}
linkUrl={`/discover/movies/genre/${slider.data}`}
/>
);
case DiscoverSliderType.TMDB_TV_GENRE:
return (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/tv/genre/${slider.data}`}
linkUrl={`/discover/tv/genre/${slider.data}`}
/>
);
case DiscoverSliderType.TMDB_STUDIO:
return (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/movies/studio/${slider.data}`}
linkUrl={`/discover/movies/studio/${slider.data}`}
/>
);
case DiscoverSliderType.TMDB_NETWORK:
return (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/tv/network/${slider.data}`}
linkUrl={`/discover/tv/network/${slider.data}`}
/>
);
case DiscoverSliderType.TMDB_SEARCH:
return (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/search"
extraParams={`query=${slider.data}`}
linkUrl={`/search?query=${slider.data}`}
/>
);
}
})}
</>
);
};

View File

@@ -27,14 +27,18 @@ interface MediaSliderProps {
linkUrl?: string;
sliderKey: string;
hideWhenEmpty?: boolean;
extraParams?: string;
onNewTitles?: (titleCount: number) => void;
}
const MediaSlider = ({
title,
url,
linkUrl,
extraParams,
sliderKey,
hideWhenEmpty = false,
onNewTitles,
}: MediaSliderProps) => {
const settings = useSettings();
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
@@ -43,7 +47,9 @@ const MediaSlider = ({
return null;
}
return `${url}?page=${pageIndex + 1}`;
return `${url}?page=${pageIndex + 1}${
extraParams ? `&${extraParams}` : ''
}`;
},
{
initialSize: 2,
@@ -72,7 +78,13 @@ const MediaSlider = ({
) {
setSize(size + 1);
}
}, [titles, setSize, size, data]);
if (onNewTitles) {
// We aren't reporting all titles. We just want to know if there are any titles
// at all for our purposes.
onNewTitles(titles.length);
}
}, [titles, setSize, size, data, onNewTitles]);
if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) {
return null;

View File

@@ -0,0 +1,383 @@
import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider';
import { encodeURIExtraParams } from '@app/hooks/useSearchInput';
import type {
TmdbCompanySearchResponse,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { debounce } from 'lodash';
import { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import AsyncSelect from 'react-select/async';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
const messages = defineMessages({
addSlider: 'Add Slider',
slidernameplaceholder: 'Slider Name',
providetmdbkeywordid: 'Provide a TMDB Keyword ID',
providetmdbgenreid: 'Provide a TMDB Genre ID',
providetmdbsearch: 'Provide a search query',
providetmdbstudio: 'Provide TMDB Studio ID',
providetmdbnetwork: 'Provide TMDB Network ID',
addsuccess: 'Created new slider and saved discover customization settings.',
addfail: 'Failed to create new slider.',
needresults: 'You need to have at least 1 result to create a slider.',
validationDatarequired: 'You must provide a data value.',
validationTitlerequired: 'You must provide a title.',
addcustomslider: 'Add Custom Slider',
searchKeywords: 'Search keywords…',
seachGenres: 'Search genres…',
searchStudios: 'Search studios…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
});
type CreateSliderProps = {
onCreate: () => void;
};
type CreateOption = {
type: DiscoverSliderType;
title: string;
dataUrl: string;
params?: string;
titlePlaceholderText: string;
dataPlaceholderText: string;
};
const CreateSlider = ({ onCreate }: CreateSliderProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [resultCount, setResultCount] = useState(0);
const CreateSliderSchema = Yup.object().shape({
title: Yup.string().required(
intl.formatMessage(messages.validationTitlerequired)
),
data: Yup.string().required(
intl.formatMessage(messages.validationDatarequired)
),
});
const updateResultCount = useCallback(
(count: number) => {
setResultCount(count);
},
[setResultCount]
);
const loadKeywordOptions = debounce(async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
}, 100);
const loadCompanyOptions = debounce(async (inputValue: string) => {
const results = await axios.get<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
}, 100);
const loadMovieGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/movie'
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadTvGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/tv'
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const options: CreateOption[] = [
{
type: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: intl.formatMessage(sliderTitles.tmdbmoviekeyword),
dataUrl: '/api/v1/discover/movies',
params: 'keywords=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
},
{
type: DiscoverSliderType.TMDB_TV_KEYWORD,
title: intl.formatMessage(sliderTitles.tmdbtvkeyword),
dataUrl: '/api/v1/discover/tv',
params: 'keywords=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
},
{
type: DiscoverSliderType.TMDB_MOVIE_GENRE,
title: intl.formatMessage(sliderTitles.tmdbmoviegenre),
dataUrl: '/api/v1/discover/movies/genre/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
},
{
type: DiscoverSliderType.TMDB_TV_GENRE,
title: intl.formatMessage(sliderTitles.tmdbtvgenre),
dataUrl: '/api/v1/discover/tv/genre/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
},
{
type: DiscoverSliderType.TMDB_STUDIO,
title: intl.formatMessage(sliderTitles.tmdbstudio),
dataUrl: '/api/v1/discover/movies/studio/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbstudio),
},
{
type: DiscoverSliderType.TMDB_NETWORK,
title: intl.formatMessage(sliderTitles.tmdbnetwork),
dataUrl: '/api/v1/discover/tv/network/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbnetwork),
},
{
type: DiscoverSliderType.TMDB_SEARCH,
title: intl.formatMessage(sliderTitles.tmdbsearch),
dataUrl: '/api/v1/search',
params: 'query=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
},
];
return (
<Formik
initialValues={{
sliderType: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: '',
data: '',
}}
validationSchema={CreateSliderSchema}
enableReinitialize
onSubmit={async (values, { resetForm }) => {
try {
await axios.post('/api/v1/settings/discover/add', {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
addToast(intl.formatMessage(messages.addsuccess), {
appearance: 'success',
autoDismiss: true,
});
onCreate();
resetForm();
} catch (e) {
addToast(intl.formatMessage(messages.addfail), {
appearance: 'error',
autoDismiss: true,
});
}
}}
>
{({ values, isValid, isSubmitting, errors, touched, setFieldValue }) => {
const activeOption = options.find(
(option) => option.type === Number(values.sliderType)
);
let dataInput: React.ReactNode;
switch (activeOption?.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
case DiscoverSliderType.TMDB_TV_KEYWORD:
dataInput = (
<AsyncSelect
key="keyword-select"
inputId="data"
isMulti
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
inputValue === ''
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
loadOptions={loadKeywordOptions}
placeholder={intl.formatMessage(messages.searchKeywords)}
onChange={(value) => {
const keywords = value.map((item) => item.value).join(',');
setFieldValue('data', keywords);
}}
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
dataInput = (
<AsyncSelect
key="movie-genre-select"
className="react-select-container"
classNamePrefix="react-select"
defaultOptions
cacheOptions
loadOptions={loadMovieGenreOptions}
placeholder={intl.formatMessage(messages.seachGenres)}
onChange={(value) => {
setFieldValue('data', value?.value);
}}
/>
);
break;
case DiscoverSliderType.TMDB_TV_GENRE:
dataInput = (
<AsyncSelect
key="tv-genre-select"
className="react-select-container"
classNamePrefix="react-select"
defaultOptions
cacheOptions
loadOptions={loadTvGenreOptions}
placeholder={intl.formatMessage(messages.seachGenres)}
onChange={(value) => {
setFieldValue('data', value?.value);
}}
/>
);
break;
case DiscoverSliderType.TMDB_STUDIO:
dataInput = (
<AsyncSelect
key="studio-select"
className="react-select-container"
classNamePrefix="react-select"
defaultOptions
cacheOptions
loadOptions={loadCompanyOptions}
placeholder={intl.formatMessage(messages.searchStudios)}
onChange={(value) => {
setFieldValue('data', value?.value);
}}
/>
);
break;
default:
dataInput = (
<Field
type="text"
name="data"
id="data"
placeholder={activeOption?.dataPlaceholderText}
/>
);
}
return (
<Form data-testid="create-discover-option-form">
<div className="flex flex-col space-y-2 rounded border-2 border-dashed border-gray-700 bg-gray-800 px-2 py-2 text-gray-100">
<span className="text-overseerr text-xl font-semibold">
{intl.formatMessage(messages.addcustomslider)}
</span>
<Field as="select" id="sliderType" name="sliderType">
{options.map((option) => (
<option value={option.type} key={`type-${option.type}`}>
{option.title}
</option>
))}
</Field>
<Field
type="text"
name="title"
id="title"
placeholder={activeOption?.titlePlaceholderText}
/>
{errors.title &&
touched.title &&
typeof errors.title === 'string' && (
<div className="error">{errors.title}</div>
)}
{dataInput}
{errors.data &&
touched.data &&
typeof errors.data === 'string' && (
<div className="error">{errors.data}</div>
)}
<div className="flex-1"></div>
{resultCount === 0 ? (
<Tooltip content={intl.formatMessage(messages.needresults)}>
<div>
<Button buttonType="primary" buttonSize="sm" disabled>
{intl.formatMessage(messages.addSlider)}
</Button>
</div>
</Tooltip>
) : (
<div>
<Button
buttonType="primary"
buttonSize="sm"
disabled={isSubmitting || !isValid}
>
{intl.formatMessage(messages.addSlider)}
</Button>
</div>
)}
</div>
<div className="relative px-4 pb-4">
{activeOption && values.title && values.data && (
<MediaSlider
sliderKey={`preview-${values.title}`}
title={values.title}
url={activeOption?.dataUrl.replace(
'$value',
encodeURIExtraParams(values.data)
)}
extraParams={activeOption.params?.replace(
'$value',
encodeURIExtraParams(values.data)
)}
onNewTitles={updateResultCount}
/>
)}
</div>
</Form>
);
}}
</Formik>
);
};
export default CreateSlider;

View File

@@ -0,0 +1,170 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
import Tooltip from '@app/components/Common/Tooltip';
import { MenuIcon, XIcon } from '@heroicons/react/solid';
import axios from 'axios';
import { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-aria';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
deletesuccess: 'Sucessfully deleted slider.',
deletefail: 'Failed to delete slider.',
remove: 'Remove',
enable: 'Toggle Visibility',
});
const Position = {
None: 'None',
Above: 'Above',
Below: 'Below',
} as const;
type DiscoverOptionProps = {
id: number;
title: string;
subtitle?: string;
data?: string;
enabled?: boolean;
isBuiltIn?: boolean;
onEnable: () => void;
onDelete: () => void;
onPositionUpdate: (
updatedItemId: number,
position: keyof typeof Position
) => void;
};
const DiscoverOption = ({
id,
title,
enabled,
onPositionUpdate,
onEnable,
subtitle,
data,
isBuiltIn,
onDelete,
}: DiscoverOptionProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const ref = useRef<HTMLDivElement>(null);
const [hoverPosition, setHoverPosition] = useState<keyof typeof Position>(
Position.None
);
const { dragProps, isDragging } = useDrag({
getItems() {
return [{ id: id.toString(), title }];
},
});
const deleteSlider = async () => {
try {
await axios.delete(`/api/v1/settings/discover/${id}`);
addToast(intl.formatMessage(messages.deletesuccess), {
appearance: 'success',
autoDismiss: true,
});
onDelete();
} catch (e) {
addToast(intl.formatMessage(messages.deletefail), {
appearance: 'error',
autoDismiss: true,
});
}
};
const { dropProps } = useDrop({
ref,
onDropMove: (e) => {
if (ref.current) {
const middlePoint = ref.current.offsetHeight / 2;
if (e.y < middlePoint) {
setHoverPosition(Position.Above);
} else {
setHoverPosition(Position.Below);
}
}
},
onDropExit: () => {
setHoverPosition(Position.None);
},
onDrop: async (e) => {
const items = await Promise.all(
e.items
.filter((item) => item.kind === 'text' && item.types.has('id'))
.map(async (item) => {
if (item.kind === 'text') {
return item.getText('id');
}
})
);
if (items?.[0]) {
const dropped = Number(items[0]);
onPositionUpdate(dropped, hoverPosition);
}
},
});
return (
<div
className="relative w-full"
{...dragProps}
{...dropProps}
ref={ref}
data-testid="discover-option"
>
{hoverPosition === Position.Above && (
<div
className={`absolute -top-1 left-0 w-full border-t-2 border-indigo-500`}
/>
)}
{hoverPosition === Position.Below && (
<div
className={`absolute -bottom-1 left-0 w-full border-t-2 border-indigo-500`}
/>
)}
<div
role="button"
tabIndex={0}
className={`relative flex h-12 items-center space-x-2 rounded border border-gray-700 bg-gray-800 px-2 py-2 text-gray-100 ${
isDragging ? 'opacity-0' : 'opacity-100'
}`}
>
<MenuIcon className="h-6 w-6" />
<span className="flex-1">{title}</span>
{subtitle && <Badge>{subtitle}</Badge>}
{data && <Badge badgeType="warning">{data}</Badge>}
{!isBuiltIn && (
<div className="px-2">
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => deleteSlider()}
>
<XIcon />
<span>{intl.formatMessage(messages.remove)}</span>
</Button>
</div>
)}
<Tooltip content={intl.formatMessage(messages.enable)}>
<div>
<SlideCheckbox
onClick={() => {
onEnable();
}}
checked={enabled}
/>
</div>
</Tooltip>
</div>
</div>
);
};
export default DiscoverOption;

View File

@@ -0,0 +1,220 @@
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import CreateSlider from '@app/components/Settings/SettingsMain/DiscoverCustomization/CreateSlider';
import DiscoverOption from '@app/components/Settings/SettingsMain/DiscoverCustomization/DiscoverOption';
import globalMessages from '@app/i18n/globalMessages';
import { RefreshIcon, SaveIcon } from '@heroicons/react/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages({
resettodefault: 'Reset to Default',
resetwarning:
'Reset all sliders to default. This will also delete any custom sliders!',
updatesuccess: 'Updated discover customization settings.',
updatefailed:
'Something went wrong updating the discover customization settings.',
resetsuccess: 'Sucessfully reset discover customization settings.',
resetfailed:
'Something went wrong resetting the discover customization settings.',
});
const DiscoverCustomization = () => {
const intl = useIntl();
const { addToast } = useToasts();
const { data, error, mutate } = useSWR<DiscoverSlider[]>(
'/api/v1/settings/discover'
);
const [sliders, setSliders] = useState<Partial<DiscoverSlider>[]>([]);
// We need to sync the state here so that we can modify the changes locally without commiting
// anything to the server until the user decides to save the changes
useEffect(() => {
if (data) {
setSliders(data);
}
}, [data]);
const updateSliders = async () => {
try {
await axios.post('/api/v1/settings/discover', sliders);
addToast(intl.formatMessage(messages.updatesuccess), {
appearance: 'success',
autoDismiss: true,
});
mutate();
} catch (e) {
addToast(intl.formatMessage(messages.updatefailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const resetSliders = async () => {
try {
await axios.get('/api/v1/settings/discover/reset');
addToast(intl.formatMessage(messages.resetsuccess), {
appearance: 'success',
autoDismiss: true,
});
mutate();
} catch (e) {
addToast(intl.formatMessage(messages.resetfailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const hasChanged = () => !Object.is(data, sliders);
const getSliderTitle = (slider: Partial<DiscoverSlider>): string => {
if (slider.title) {
return slider.title;
}
switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED:
return intl.formatMessage(sliderTitles.recentlyAdded);
case DiscoverSliderType.RECENT_REQUESTS:
return intl.formatMessage(sliderTitles.recentrequests);
case DiscoverSliderType.PLEX_WATCHLIST:
return intl.formatMessage(sliderTitles.plexwatchlist);
case DiscoverSliderType.TRENDING:
return intl.formatMessage(sliderTitles.trending);
case DiscoverSliderType.POPULAR_MOVIES:
return intl.formatMessage(sliderTitles.popularmovies);
case DiscoverSliderType.MOVIE_GENRES:
return intl.formatMessage(sliderTitles.moviegenres);
case DiscoverSliderType.UPCOMING_MOVIES:
return intl.formatMessage(sliderTitles.upcoming);
case DiscoverSliderType.STUDIOS:
return intl.formatMessage(sliderTitles.studios);
case DiscoverSliderType.POPULAR_TV:
return intl.formatMessage(sliderTitles.populartv);
case DiscoverSliderType.TV_GENRES:
return intl.formatMessage(sliderTitles.tvgenres);
case DiscoverSliderType.UPCOMING_TV:
return intl.formatMessage(sliderTitles.upcomingtv);
case DiscoverSliderType.NETWORKS:
return intl.formatMessage(sliderTitles.networks);
default:
return 'Unknown Slider';
}
};
const getSliderSubtitle = (
slider: Partial<DiscoverSlider>
): string | undefined => {
switch (slider.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
return intl.formatMessage(sliderTitles.tmdbmoviekeyword);
case DiscoverSliderType.TMDB_TV_KEYWORD:
return intl.formatMessage(sliderTitles.tmdbtvkeyword);
case DiscoverSliderType.TMDB_MOVIE_GENRE:
return intl.formatMessage(sliderTitles.tmdbmoviegenre);
case DiscoverSliderType.TMDB_TV_GENRE:
return intl.formatMessage(sliderTitles.tmdbtvgenre);
case DiscoverSliderType.TMDB_STUDIO:
return intl.formatMessage(sliderTitles.tmdbstudio);
case DiscoverSliderType.TMDB_NETWORK:
return intl.formatMessage(sliderTitles.tmdbnetwork);
case DiscoverSliderType.TMDB_SEARCH:
return intl.formatMessage(sliderTitles.tmdbsearch);
default:
return undefined;
}
};
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<>
<div className="section">
<div className="flex flex-col space-y-2 rounded border border-gray-700 p-2">
{sliders.map((slider, index) => (
<DiscoverOption
id={slider.id ?? -1}
key={slider.id ?? `no-id-${index}`}
title={getSliderTitle(slider)}
subtitle={getSliderSubtitle(slider)}
data={slider.data}
enabled={slider.enabled}
isBuiltIn={slider.isBuiltIn}
onDelete={() => {
mutate();
}}
onEnable={() => {
const tempSliders = sliders.slice();
tempSliders[index].enabled = !tempSliders[index].enabled;
setSliders(tempSliders);
}}
onPositionUpdate={(updatedItemId, position) => {
const originalPosition = sliders.findIndex(
(item) => item.id === updatedItemId
);
const originalItem = sliders[originalPosition];
const tempSliders = sliders.slice();
tempSliders.splice(originalPosition, 1);
tempSliders.splice(
position === 'Above' && index > originalPosition
? Math.max(index - 1, 0)
: index,
0,
originalItem
);
setSliders(tempSliders);
}}
/>
))}
<CreateSlider
onCreate={() => {
mutate();
}}
/>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Tooltip content={intl.formatMessage(messages.resetwarning)}>
<Button buttonType="default" onClick={() => resetSliders()}>
<RefreshIcon />
<span>{intl.formatMessage(messages.resettodefault)}</span>
</Button>
</Tooltip>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={!hasChanged()}
onClick={() => updateSliders()}
data-testid="discover-customize-submit"
>
<SaveIcon />
<span>{intl.formatMessage(globalMessages.save)}</span>
</Button>
</span>
</div>
</div>
</>
);
};
export default DiscoverCustomization;

View File

@@ -7,6 +7,7 @@ import LanguageSelector from '@app/components/LanguageSelector';
import RegionSelector from '@app/components/RegionSelector';
import CopyButton from '@app/components/Settings/CopyButton';
import SettingsBadge from '@app/components/Settings/SettingsBadge';
import DiscoverCustomization from '@app/components/Settings/SettingsMain/DiscoverCustomization';
import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale';
@@ -55,6 +56,9 @@ const messages = defineMessages({
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests',
locale: 'Display Language',
discovercustomization: 'Discover Customization',
discovercustomizationDescription:
'Add or remove sliders on the Discover page.',
});
const SettingsMain = () => {
@@ -185,7 +189,7 @@ const SettingsMain = () => {
setFieldValue,
}) => {
return (
<Form className="section">
<Form className="section" data-testid="settings-main-form">
{userHasPermission(Permission.ADMIN) && (
<div className="form-row">
<label htmlFor="apiKey" className="text-label">
@@ -450,6 +454,15 @@ const SettingsMain = () => {
}}
</Formik>
</div>
<div className="mb-6">
<h3 className="heading" data-testid="discover-customization">
{intl.formatMessage(messages.discovercustomization)}
</h3>
<p className="description">
{intl.formatMessage(messages.discovercustomizationDescription)}
</p>
</div>
<DiscoverCustomization />
</>
);
};