You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
463 lines
17 KiB
463 lines
17 KiB
import Button from '@app/components/Common/Button';
|
|
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
|
import PageTitle from '@app/components/Common/PageTitle';
|
|
import Tooltip from '@app/components/Common/Tooltip';
|
|
import { sliderTitles } from '@app/components/Discover/constants';
|
|
import CreateSlider from '@app/components/Discover/CreateSlider';
|
|
import DiscoverSliderEdit from '@app/components/Discover/DiscoverSliderEdit';
|
|
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 { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
|
import { Permission, useUser } from '@app/hooks/useUser';
|
|
import globalMessages from '@app/i18n/globalMessages';
|
|
import { Transition } from '@headlessui/react';
|
|
import {
|
|
ArrowDownOnSquareIcon,
|
|
ArrowPathIcon,
|
|
ArrowUturnLeftIcon,
|
|
PencilIcon,
|
|
PlusIcon,
|
|
} from '@heroicons/react/24/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({
|
|
discover: 'Discover',
|
|
emptywatchlist:
|
|
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
|
|
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.',
|
|
customizediscover: 'Customize Discover',
|
|
stopediting: 'Stop Editing',
|
|
createnewslider: 'Create New Slider',
|
|
});
|
|
|
|
const Discover = () => {
|
|
const intl = useIntl();
|
|
const { hasPermission } = useUser();
|
|
const { addToast } = useToasts();
|
|
const {
|
|
data: discoverData,
|
|
error: discoverError,
|
|
mutate,
|
|
} = useSWR<DiscoverSlider[]>('/api/v1/settings/discover');
|
|
const [sliders, setSliders] = useState<Partial<DiscoverSlider>[]>([]);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
|
|
// 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 (discoverData && !isEditing) {
|
|
setSliders(discoverData);
|
|
}
|
|
}, [discoverData, isEditing]);
|
|
|
|
const hasChanged = () => !Object.is(discoverData, sliders);
|
|
|
|
const updateSliders = async () => {
|
|
try {
|
|
await axios.post('/api/v1/settings/discover', sliders);
|
|
|
|
addToast(intl.formatMessage(messages.updatesuccess), {
|
|
appearance: 'success',
|
|
autoDismiss: true,
|
|
});
|
|
setIsEditing(false);
|
|
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,
|
|
});
|
|
setIsEditing(false);
|
|
mutate();
|
|
} catch (e) {
|
|
addToast(intl.formatMessage(messages.resetfailed), {
|
|
appearance: 'error',
|
|
autoDismiss: true,
|
|
});
|
|
}
|
|
};
|
|
|
|
const now = new Date();
|
|
const offset = now.getTimezoneOffset();
|
|
const upcomingDate = new Date(now.getTime() - offset * 60 * 1000)
|
|
.toISOString()
|
|
.split('T')[0];
|
|
|
|
if (!discoverData && !discoverError) {
|
|
return <LoadingSpinner />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageTitle title={intl.formatMessage(messages.discover)} />
|
|
{hasPermission(Permission.ADMIN) && (
|
|
<>
|
|
{isEditing && (
|
|
<div className="my-6 rounded-lg bg-gray-800">
|
|
<div className="flex items-center space-x-2 rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-lg font-semibold text-gray-400">
|
|
<PlusIcon className="w-6" />
|
|
<span data-testid="create-slider-header">
|
|
{intl.formatMessage(messages.createnewslider)}
|
|
</span>
|
|
</div>
|
|
<div className="p-4">
|
|
<CreateSlider
|
|
onCreate={async () => {
|
|
const newSliders = await mutate();
|
|
|
|
if (newSliders) {
|
|
setSliders(newSliders);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Transition
|
|
show={!isEditing}
|
|
enter="transition-opacity duration-300"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave="transition-opacity duration-300"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
className="absolute-bottom-shift fixed right-6 z-50 flex items-center sm:bottom-8"
|
|
>
|
|
<button
|
|
onClick={() => setIsEditing(true)}
|
|
data-testid="discover-start-editing"
|
|
className="h-12 w-12 rounded-full border-2 border-gray-600 bg-gray-700 bg-opacity-90 p-3 text-gray-400 shadow transition-all hover:bg-opacity-100"
|
|
>
|
|
<PencilIcon className="h-full w-full" />
|
|
</button>
|
|
</Transition>
|
|
<Transition
|
|
show={isEditing}
|
|
enter="transition duration-300"
|
|
enterFrom="opacity-0 translate-y-6"
|
|
enterTo="opacity-100 translate-y-0"
|
|
leave="transition duration-300"
|
|
leaveFrom="opacity-100 translate-y-0"
|
|
leaveTo="opacity-0 translate-y-6"
|
|
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
|
|
>
|
|
<Button
|
|
buttonType="default"
|
|
onClick={() => setIsEditing(false)}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
<ArrowUturnLeftIcon />
|
|
<span>{intl.formatMessage(messages.stopediting)}</span>
|
|
</Button>
|
|
<Tooltip content={intl.formatMessage(messages.resetwarning)}>
|
|
<ConfirmButton
|
|
onClick={() => resetSliders()}
|
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
<ArrowPathIcon />
|
|
<span>{intl.formatMessage(messages.resettodefault)}</span>
|
|
</ConfirmButton>
|
|
</Tooltip>
|
|
<Button
|
|
buttonType="primary"
|
|
type="submit"
|
|
disabled={!hasChanged()}
|
|
onClick={() => updateSliders()}
|
|
data-testid="discover-customize-submit"
|
|
className="w-full sm:w-auto"
|
|
>
|
|
<ArrowDownOnSquareIcon />
|
|
<span>{intl.formatMessage(globalMessages.save)}</span>
|
|
</Button>
|
|
</Transition>
|
|
</>
|
|
)}
|
|
{(isEditing ? sliders : discoverData)?.map((slider, index) => {
|
|
let sliderComponent: React.ReactNode;
|
|
|
|
switch (slider.type) {
|
|
case DiscoverSliderType.RECENTLY_ADDED:
|
|
sliderComponent = <RecentlyAddedSlider />;
|
|
break;
|
|
case DiscoverSliderType.RECENT_REQUESTS:
|
|
sliderComponent = <RecentRequestsSlider />;
|
|
break;
|
|
case DiscoverSliderType.PLEX_WATCHLIST:
|
|
sliderComponent = <PlexWatchlistSlider />;
|
|
break;
|
|
case DiscoverSliderType.TRENDING:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey="trending"
|
|
title={intl.formatMessage(sliderTitles.trending)}
|
|
url="/api/v1/discover/trending"
|
|
linkUrl="/discover/trending"
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.POPULAR_MOVIES:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey="popular-movies"
|
|
title={intl.formatMessage(sliderTitles.popularmovies)}
|
|
url="/api/v1/discover/movies"
|
|
linkUrl="/discover/movies"
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.MOVIE_GENRES:
|
|
sliderComponent = <MovieGenreSlider />;
|
|
break;
|
|
case DiscoverSliderType.UPCOMING_MOVIES:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey="upcoming"
|
|
title={intl.formatMessage(sliderTitles.upcoming)}
|
|
linkUrl={`/discover/movies?primaryReleaseDateGte=${upcomingDate}`}
|
|
url="/api/v1/discover/movies"
|
|
extraParams={`primaryReleaseDateGte=${upcomingDate}`}
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.STUDIOS:
|
|
sliderComponent = <StudioSlider />;
|
|
break;
|
|
case DiscoverSliderType.POPULAR_TV:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey="popular-tv"
|
|
title={intl.formatMessage(sliderTitles.populartv)}
|
|
url="/api/v1/discover/tv"
|
|
linkUrl="/discover/tv"
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.TV_GENRES:
|
|
sliderComponent = <TvGenreSlider />;
|
|
break;
|
|
case DiscoverSliderType.UPCOMING_TV:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey="upcoming-tv"
|
|
title={intl.formatMessage(sliderTitles.upcomingtv)}
|
|
linkUrl={`/discover/tv?firstAirDateGte=${upcomingDate}`}
|
|
url="/api/v1/discover/tv"
|
|
extraParams={`firstAirDateGte=${upcomingDate}`}
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.NETWORKS:
|
|
sliderComponent = <NetworkSlider />;
|
|
break;
|
|
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey={`custom-slider-${slider.id}`}
|
|
title={slider.title ?? ''}
|
|
url="/api/v1/discover/movies"
|
|
extraParams={
|
|
slider.data
|
|
? `keywords=${encodeURIExtraParams(slider.data)}`
|
|
: ''
|
|
}
|
|
linkUrl={`/discover/movies?keywords=${slider.data}`}
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey={`custom-slider-${slider.id}`}
|
|
title={slider.title ?? ''}
|
|
url="/api/v1/discover/tv"
|
|
extraParams={
|
|
slider.data
|
|
? `keywords=${encodeURIExtraParams(slider.data)}`
|
|
: ''
|
|
}
|
|
linkUrl={`/discover/tv?keywords=${slider.data}`}
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey={`custom-slider-${slider.id}`}
|
|
title={slider.title ?? ''}
|
|
url={`/api/v1/discover/movies`}
|
|
extraParams={`genre=${slider.data}`}
|
|
linkUrl={`/discover/movies?genre=${slider.data}`}
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.TMDB_TV_GENRE:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey={`custom-slider-${slider.id}`}
|
|
title={slider.title ?? ''}
|
|
url={`/api/v1/discover/tv`}
|
|
extraParams={`genre=${slider.data}`}
|
|
linkUrl={`/discover/tv?genre=${slider.data}`}
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.TMDB_STUDIO:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey={`custom-slider-${slider.id}`}
|
|
title={slider.title ?? ''}
|
|
url={`/api/v1/discover/movies/studio/${slider.data}`}
|
|
linkUrl={`/discover/movies/studio/${slider.data}`}
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.TMDB_NETWORK:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey={`custom-slider-${slider.id}`}
|
|
title={slider.title ?? ''}
|
|
url={`/api/v1/discover/tv/network/${slider.data}`}
|
|
linkUrl={`/discover/tv/network/${slider.data}`}
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.TMDB_SEARCH:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey={`custom-slider-${slider.id}`}
|
|
title={slider.title ?? ''}
|
|
url="/api/v1/search"
|
|
extraParams={`query=${slider.data}`}
|
|
linkUrl={`/search?query=${slider.data}`}
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey={`custom-slider-${slider.id}`}
|
|
title={slider.title ?? ''}
|
|
url="/api/v1/discover/movies"
|
|
extraParams={`watchRegion=${
|
|
slider.data?.split(',')[0]
|
|
}&watchProviders=${slider.data?.split(',')[1]}`}
|
|
linkUrl={`/discover/movies?watchRegion=${
|
|
slider.data?.split(',')[0]
|
|
}&watchProviders=${slider.data?.split(',')[1]}`}
|
|
/>
|
|
);
|
|
break;
|
|
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
|
sliderComponent = (
|
|
<MediaSlider
|
|
sliderKey={`custom-slider-${slider.id}`}
|
|
title={slider.title ?? ''}
|
|
url="/api/v1/discover/tv"
|
|
extraParams={`watchRegion=${
|
|
slider.data?.split(',')[0]
|
|
}&watchProviders=${slider.data?.split(',')[1]}`}
|
|
linkUrl={`/discover/tv?watchRegion=${
|
|
slider.data?.split(',')[0]
|
|
}&watchProviders=${slider.data?.split(',')[1]}`}
|
|
/>
|
|
);
|
|
break;
|
|
}
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<DiscoverSliderEdit
|
|
key={`discover-slider-${slider.id}-edit`}
|
|
slider={slider}
|
|
onDelete={async () => {
|
|
const newSliders = await mutate();
|
|
|
|
if (newSliders) {
|
|
setSliders(newSliders);
|
|
}
|
|
}}
|
|
onEnable={() => {
|
|
const tempSliders = sliders.slice();
|
|
tempSliders[index].enabled = !tempSliders[index].enabled;
|
|
setSliders(tempSliders);
|
|
}}
|
|
onPositionUpdate={(updatedItemId, position, hasClickedArrows) => {
|
|
const originalPosition = sliders.findIndex(
|
|
(item) => item.id === updatedItemId
|
|
);
|
|
const originalItem = sliders[originalPosition];
|
|
|
|
const tempSliders = sliders.slice();
|
|
|
|
tempSliders.splice(originalPosition, 1);
|
|
hasClickedArrows
|
|
? tempSliders.splice(
|
|
position === 'Above' ? index - 1 : index + 1,
|
|
0,
|
|
originalItem
|
|
)
|
|
: tempSliders.splice(
|
|
position === 'Above' && index > originalPosition
|
|
? Math.max(index - 1, 0)
|
|
: index,
|
|
0,
|
|
originalItem
|
|
);
|
|
|
|
setSliders(tempSliders);
|
|
}}
|
|
disableUpButton={index === 0}
|
|
disableDownButton={index === sliders.length - 1}
|
|
>
|
|
{sliderComponent}
|
|
</DiscoverSliderEdit>
|
|
);
|
|
}
|
|
|
|
if (!slider.enabled) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div key={`discover-slider-${slider.id}`}>{sliderComponent}</div>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Discover;
|