mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: discover inline customization (#3220)
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
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';
|
||||
@@ -10,22 +15,98 @@ 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/useSearchInput';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
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 { data: discoverData, error: discoverError } = useSWR<DiscoverSlider[]>(
|
||||
'/api/v1/settings/discover'
|
||||
);
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!discoverData && !discoverError) {
|
||||
return <LoadingSpinner />;
|
||||
@@ -34,20 +115,97 @@ const Discover = () => {
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.discover)} />
|
||||
{discoverData?.map((slider) => {
|
||||
if (!slider.enabled) {
|
||||
return null;
|
||||
}
|
||||
{hasPermission(Permission.ADMIN) && (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="my-6 flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="default"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<ArrowUturnLeftIcon />
|
||||
<span>{intl.formatMessage(messages.stopediting)}</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Tooltip content={intl.formatMessage(messages.resetwarning)}>
|
||||
<ConfirmButton
|
||||
onClick={() => resetSliders()}
|
||||
confirmText={intl.formatMessage(
|
||||
globalMessages.areyousure
|
||||
)}
|
||||
>
|
||||
<ArrowPathIcon />
|
||||
<span>{intl.formatMessage(messages.resettodefault)}</span>
|
||||
</ConfirmButton>
|
||||
</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"
|
||||
>
|
||||
<ArrowDownOnSquareIcon />
|
||||
<span>{intl.formatMessage(globalMessages.save)}</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-6 rounded-lg bg-gray-800">
|
||||
<div className="flex items-center space-x-2 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>
|
||||
</>
|
||||
) : (
|
||||
<div className="my-6 flex justify-end">
|
||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="default"
|
||||
onClick={() => setIsEditing(true)}
|
||||
data-testid="discover-start-editing"
|
||||
>
|
||||
<PencilIcon />
|
||||
<span>{intl.formatMessage(messages.customizediscover)}</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(isEditing ? sliders : discoverData)?.map((slider, index) => {
|
||||
let sliderComponent: React.ReactNode;
|
||||
|
||||
switch (slider.type) {
|
||||
case DiscoverSliderType.RECENTLY_ADDED:
|
||||
return <RecentlyAddedSlider />;
|
||||
sliderComponent = <RecentlyAddedSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.RECENT_REQUESTS:
|
||||
return <RecentRequestsSlider />;
|
||||
sliderComponent = <RecentRequestsSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.PLEX_WATCHLIST:
|
||||
return <PlexWatchlistSlider />;
|
||||
sliderComponent = <PlexWatchlistSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.TRENDING:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey="trending"
|
||||
title={intl.formatMessage(sliderTitles.trending)}
|
||||
@@ -55,8 +213,9 @@ const Discover = () => {
|
||||
linkUrl="/discover/trending"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.POPULAR_MOVIES:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey="popular-movies"
|
||||
title={intl.formatMessage(sliderTitles.popularmovies)}
|
||||
@@ -64,10 +223,12 @@ const Discover = () => {
|
||||
linkUrl="/discover/movies"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.MOVIE_GENRES:
|
||||
return <MovieGenreSlider />;
|
||||
sliderComponent = <MovieGenreSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey="upcoming"
|
||||
title={intl.formatMessage(sliderTitles.upcoming)}
|
||||
@@ -75,10 +236,12 @@ const Discover = () => {
|
||||
url="/api/v1/discover/movies/upcoming"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.STUDIOS:
|
||||
return <StudioSlider />;
|
||||
sliderComponent = <StudioSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.POPULAR_TV:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey="popular-tv"
|
||||
title={intl.formatMessage(sliderTitles.populartv)}
|
||||
@@ -86,10 +249,12 @@ const Discover = () => {
|
||||
linkUrl="/discover/tv"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TV_GENRES:
|
||||
return <TvGenreSlider />;
|
||||
sliderComponent = <TvGenreSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.UPCOMING_TV:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey="upcoming-tv"
|
||||
title={intl.formatMessage(sliderTitles.upcomingtv)}
|
||||
@@ -97,10 +262,12 @@ const Discover = () => {
|
||||
linkUrl="/discover/tv/upcoming"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.NETWORKS:
|
||||
return <NetworkSlider />;
|
||||
sliderComponent = <NetworkSlider />;
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
@@ -113,8 +280,9 @@ const Discover = () => {
|
||||
linkUrl={`/discover/movies/keyword?keywords=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_KEYWORD:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
@@ -127,8 +295,9 @@ const Discover = () => {
|
||||
linkUrl={`/discover/tv/keyword?keywords=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_GENRE:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
@@ -136,8 +305,9 @@ const Discover = () => {
|
||||
linkUrl={`/discover/movies/genre/${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_GENRE:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
@@ -145,8 +315,9 @@ const Discover = () => {
|
||||
linkUrl={`/discover/tv/genre/${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_STUDIO:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
@@ -154,8 +325,9 @@ const Discover = () => {
|
||||
linkUrl={`/discover/movies/studio/${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_NETWORK:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
@@ -163,8 +335,9 @@ const Discover = () => {
|
||||
linkUrl={`/discover/tv/network/${slider.data}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_SEARCH:
|
||||
return (
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
@@ -173,7 +346,60 @@ const Discover = () => {
|
||||
linkUrl={`/search?query=${slider.data}`}
|
||||
/>
|
||||
);
|
||||
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) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
{sliderComponent}
|
||||
</DiscoverSliderEdit>
|
||||
);
|
||||
}
|
||||
|
||||
if (!slider.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`discover-slider-${slider.id}`} className="mt-6">
|
||||
{sliderComponent}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
Reference in New Issue
Block a user