mirror of
https://github.com/sct/overseerr.git
synced 2025-09-26 20:12:33 +02:00
feat: add discover customization (#3182)
This commit is contained in:
38
src/components/Common/SlideCheckbox/index.tsx
Normal file
38
src/components/Common/SlideCheckbox/index.tsx
Normal 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;
|
68
src/components/Discover/DiscoverMovieKeyword/index.tsx
Normal file
68
src/components/Discover/DiscoverMovieKeyword/index.tsx
Normal 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;
|
68
src/components/Discover/DiscoverTvKeyword/index.tsx
Normal file
68
src/components/Discover/DiscoverTvKeyword/index.tsx
Normal 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;
|
79
src/components/Discover/PlexWatchlistSlider/index.tsx
Normal file
79
src/components/Discover/PlexWatchlistSlider/index.tsx
Normal 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;
|
49
src/components/Discover/RecentRequestsSlider/index.tsx
Normal file
49
src/components/Discover/RecentRequestsSlider/index.tsx
Normal 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;
|
53
src/components/Discover/RecentlyAddedSlider/index.tsx
Normal file
53
src/components/Discover/RecentlyAddedSlider/index.tsx
Normal 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;
|
@@ -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',
|
||||
});
|
||||
|
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
@@ -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;
|
@@ -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;
|
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
Reference in New Issue
Block a user