feat: discover inline customization (#3220)

This commit is contained in:
Ryan Cohen
2023-01-06 21:03:09 +09:00
committed by GitHub
parent 0d8b390b67
commit 8bd10b5bf3
19 changed files with 1031 additions and 578 deletions

View File

@@ -1,6 +1,6 @@
import Button from '@app/components/Common/Button';
import useClickOutside from '@app/hooks/useClickOutside';
import { useRef, useState } from 'react';
import { forwardRef, useRef, useState } from 'react';
interface ConfirmButtonProps {
onClick: () => void;
@@ -9,50 +9,51 @@ interface ConfirmButtonProps {
children: React.ReactNode;
}
const ConfirmButton = ({
onClick,
children,
confirmText,
className,
}: ConfirmButtonProps) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
const ConfirmButton = forwardRef<HTMLButtonElement, ConfirmButtonProps>(
({ onClick, children, confirmText, className }, parentRef) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
ref={parentRef}
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
&nbsp;
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
{children}
</div>
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
};
<div
ref={ref}
className={`relative inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
>
{children}
</div>
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? 'translate-y-0 opacity-100'
: 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
}
);
ConfirmButton.displayName = 'ConfirmButton';
export default ConfirmButton;

View File

@@ -1,14 +1,22 @@
import { TagIcon } from '@heroicons/react/24/outline';
import React from 'react';
type TagProps = {
content: string;
children: React.ReactNode;
iconSvg?: JSX.Element;
};
const Tag = ({ content }: TagProps) => {
const Tag = ({ children, iconSvg }: TagProps) => {
return (
<div className="inline-flex cursor-pointer items-center rounded-full bg-gray-800 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-600 transition hover:bg-gray-700">
<TagIcon className="mr-1 h-4 w-4" />
<span>{content}</span>
{iconSvg ? (
React.cloneElement(iconSvg, {
className: 'mr-1 h-4 w-4',
})
) : (
<TagIcon className="mr-1 h-4 w-4" />
)}
<span>{children}</span>
</div>
);
};

View File

@@ -0,0 +1,28 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { BuildingOffice2Icon } from '@heroicons/react/24/outline';
import type { ProductionCompany, TvNetwork } from '@server/models/common';
import useSWR from 'swr';
type CompanyTagProps = {
type: 'studio' | 'network';
companyId: number;
};
const CompanyTag = ({ companyId, type }: CompanyTagProps) => {
const { data, error } = useSWR<TvNetwork | ProductionCompany>(
`/api/v1/${type}/${companyId}`
);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
return <Tag iconSvg={<BuildingOffice2Icon />}>{data?.name}</Tag>;
};
export default CompanyTag;

View File

@@ -5,14 +5,16 @@ import MediaSlider from '@app/components/MediaSlider';
import { encodeURIExtraParams } from '@app/hooks/useSearchInput';
import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { debounce } from 'lodash';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import AsyncSelect from 'react-select/async';
import { useToasts } from 'react-toast-notifications';
@@ -20,6 +22,7 @@ import * as Yup from 'yup';
const messages = defineMessages({
addSlider: 'Add Slider',
editSlider: 'Edit Slider',
slidernameplaceholder: 'Slider Name',
providetmdbkeywordid: 'Provide a TMDB Keyword ID',
providetmdbgenreid: 'Provide a TMDB Genre ID',
@@ -28,10 +31,12 @@ const messages = defineMessages({
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.',
editsuccess: 'Edited slider and saved discover customization settings.',
editfail: 'Failed to edit slider.',
needresults: 'You need to have at least 1 result.',
validationDatarequired: 'You must provide a data value.',
validationTitlerequired: 'You must provide a title.',
addcustomslider: 'Add Custom Slider',
addcustomslider: 'Create Custom Slider',
searchKeywords: 'Search keywords…',
searchGenres: 'Search genres…',
searchStudios: 'Search studios…',
@@ -41,6 +46,7 @@ const messages = defineMessages({
type CreateSliderProps = {
onCreate: () => void;
slider?: Partial<DiscoverSlider>;
};
type CreateOption = {
@@ -52,10 +58,96 @@ type CreateOption = {
dataPlaceholderText: string;
};
const CreateSlider = ({ onCreate }: CreateSliderProps) => {
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [resultCount, setResultCount] = useState(0);
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
if (slider) {
const loadDefaultKeywords = async (): Promise<void> => {
if (!slider.data) {
return;
}
const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
setDefaultDataValue(
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))
);
};
const loadDefaultGenre = async (): Promise<void> => {
if (!slider.data) {
return;
}
const response = await axios.get<TmdbGenre[]>(
`/api/v1/genres/${
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
}`
);
const genre = response.data.find(
(genre) => genre.id === Number(slider.data)
);
setDefaultDataValue([
{
label: genre?.name ?? '',
value: genre?.id ?? 0,
},
]);
};
const loadDefaultCompany = async (): Promise<void> => {
if (!slider.data) {
return;
}
const response = await axios.get<ProductionCompany>(
`/api/v1/studio/${slider.data}`
);
const studio = response.data;
setDefaultDataValue([
{
label: studio.name ?? '',
value: studio.id ?? 0,
},
]);
};
switch (slider.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
case DiscoverSliderType.TMDB_TV_KEYWORD:
loadDefaultKeywords();
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
case DiscoverSliderType.TMDB_TV_GENRE:
loadDefaultGenre();
break;
case DiscoverSliderType.TMDB_STUDIO:
loadDefaultCompany();
break;
}
}
}, [slider]);
const CreateSliderSchema = Yup.object().shape({
title: Yup.string().required(
@@ -73,7 +165,7 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
[setResultCount]
);
const loadKeywordOptions = debounce(async (inputValue: string) => {
const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
@@ -87,9 +179,13 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
label: result.name,
value: result.id,
}));
}, 100);
};
const loadCompanyOptions = async (inputValue: string) => {
if (inputValue === '') {
return [];
}
const loadCompanyOptions = debounce(async (inputValue: string) => {
const results = await axios.get<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
@@ -103,7 +199,7 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
label: result.name,
value: result.id,
}));
}, 100);
};
const loadMovieGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
@@ -184,32 +280,56 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
return (
<Formik
initialValues={{
sliderType: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: '',
data: '',
}}
initialValues={
slider
? {
sliderType: slider.type,
title: slider.title,
data: slider.data,
}
: {
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,
});
if (slider) {
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
} else {
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,
});
addToast(
intl.formatMessage(
slider ? messages.editsuccess : messages.addsuccess
),
{
appearance: 'success',
autoDismiss: true,
}
);
onCreate();
resetForm();
} catch (e) {
addToast(intl.formatMessage(messages.addfail), {
appearance: 'error',
autoDismiss: true,
});
addToast(
intl.formatMessage(slider ? messages.editfail : messages.addfail),
{
appearance: 'error',
autoDismiss: true,
}
);
}
}}
>
@@ -225,7 +345,7 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
case DiscoverSliderType.TMDB_TV_KEYWORD:
dataInput = (
<AsyncSelect
key="keyword-select"
key={`keyword-select-${defaultDataValue}`}
inputId="data"
isMulti
className="react-select-container"
@@ -235,6 +355,7 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
defaultValue={defaultDataValue}
loadOptions={loadKeywordOptions}
placeholder={intl.formatMessage(messages.searchKeywords)}
onChange={(value) => {
@@ -248,9 +369,10 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
case DiscoverSliderType.TMDB_MOVIE_GENRE:
dataInput = (
<AsyncSelect
key="movie-genre-select"
key={`movie-genre-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadMovieGenreOptions}
@@ -264,9 +386,10 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
case DiscoverSliderType.TMDB_TV_GENRE:
dataInput = (
<AsyncSelect
key="tv-genre-select"
key={`tv-genre-select-${defaultDataValue}}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadTvGenreOptions}
@@ -280,9 +403,10 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
case DiscoverSliderType.TMDB_STUDIO:
dataInput = (
<AsyncSelect
key="studio-select"
key={`studio-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadCompanyOptions}
@@ -306,10 +430,7 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
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>
<div className="flex flex-col space-y-2 text-gray-100">
<Field as="select" id="sliderType" name="sliderType">
{options.map((option) => (
<option value={option.type} key={`type-${option.type}`}>
@@ -350,14 +471,16 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
buttonSize="sm"
disabled={isSubmitting || !isValid}
>
{intl.formatMessage(messages.addSlider)}
{intl.formatMessage(
slider ? messages.editSlider : messages.addSlider
)}
</Button>
</div>
)}
</div>
<div className="relative px-4 pb-4">
{activeOption && values.title && values.data && (
{activeOption && values.title && values.data && (
<div className="relative py-4">
<MediaSlider
sliderKey={`preview-${values.title}`}
title={values.title}
@@ -371,8 +494,8 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
)}
onNewTitles={updateResultCount}
/>
)}
</div>
</div>
)}
</Form>
);
}}

View File

@@ -0,0 +1,301 @@
import Button from '@app/components/Common/Button';
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
import Tag from '@app/components/Common/Tag';
import Tooltip from '@app/components/Common/Tooltip';
import CompanyTag from '@app/components/CompanyTag';
import { sliderTitles } from '@app/components/Discover/constants';
import CreateSlider from '@app/components/Discover/CreateSlider';
import GenreTag from '@app/components/GenreTag';
import KeywordTag from '@app/components/KeywordTag';
import globalMessages from '@app/i18n/globalMessages';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import {
ArrowUturnLeftIcon,
Bars3Icon,
PencilIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
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 DiscoverSliderEditProps = {
slider: Partial<DiscoverSlider>;
onEnable: () => void;
onDelete: () => void;
onPositionUpdate: (
updatedItemId: number,
position: keyof typeof Position
) => void;
children: React.ReactNode;
};
const DiscoverSliderEdit = ({
slider,
children,
onEnable,
onDelete,
onPositionUpdate,
}: DiscoverSliderEditProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [isEditing, setIsEditing] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [hoverPosition, setHoverPosition] = useState<keyof typeof Position>(
Position.None
);
const { dragProps, isDragging } = useDrag({
getItems() {
return [{ id: (slider.id ?? -1).toString(), title: slider.title ?? '' }];
},
});
const deleteSlider = async () => {
try {
await axios.delete(`/api/v1/settings/discover/${slider.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);
}
},
});
const getSliderTitle = (slider: Partial<DiscoverSlider>): string => {
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);
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 'Unknown Slider';
}
};
return (
<div
key={`discover-slider-${slider.id}-editing`}
data-testid="discover-slider-edit-mode"
className={`relative mb-4 rounded-lg bg-gray-800 shadow-md ${
isDragging ? 'opacity-0' : 'opacity-100'
}`}
{...dragProps}
{...dropProps}
ref={ref}
>
{hoverPosition === Position.Above && (
<div
className={`absolute -top-3 left-0 w-full border-t-4 border-indigo-500`}
/>
)}
{hoverPosition === Position.Below && (
<div
className={`absolute -bottom-2 left-0 w-full border-t-4 border-indigo-500`}
/>
)}
<div className="flex w-full items-center space-x-2 rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-gray-400">
<Bars3Icon className="h-6 w-6" />
<div>{getSliderTitle(slider)}</div>
<div className="flex-1 pl-2">
{(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD ||
slider.type === DiscoverSliderType.TMDB_TV_KEYWORD) && (
<div className="flex space-x-2">
{slider.data?.split(',').map((keywordId) => (
<KeywordTag
key={`slider-keywords-${slider.id}-${keywordId}`}
keywordId={Number(keywordId)}
/>
))}
</div>
)}
{(slider.type === DiscoverSliderType.TMDB_NETWORK ||
slider.type === DiscoverSliderType.TMDB_STUDIO) && (
<CompanyTag
type={
slider.type === DiscoverSliderType.TMDB_STUDIO
? 'studio'
: 'network'
}
companyId={Number(slider.data)}
/>
)}
{(slider.type === DiscoverSliderType.TMDB_TV_GENRE ||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE) && (
<GenreTag
type={
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE
? 'movie'
: 'tv'
}
genreId={Number(slider.data)}
/>
)}
{slider.type === DiscoverSliderType.TMDB_SEARCH && (
<Tag iconSvg={<MagnifyingGlassIcon />}>{slider.data}</Tag>
)}
</div>
{!slider.isBuiltIn && (
<>
{!isEditing ? (
<Button
buttonType="warning"
buttonSize="sm"
onClick={() => {
setIsEditing(true);
}}
>
<PencilIcon />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</Button>
) : (
<Button
buttonType="default"
buttonSize="sm"
onClick={() => {
setIsEditing(false);
}}
>
<ArrowUturnLeftIcon />
<span>{intl.formatMessage(globalMessages.cancel)}</span>
</Button>
)}
<Button
data-testid="discover-slider-remove-button"
buttonType="danger"
buttonSize="sm"
onClick={() => {
deleteSlider();
}}
>
<XMarkIcon />
<span>{intl.formatMessage(messages.remove)}</span>
</Button>
</>
)}
<div className="pl-4">
<Tooltip content={intl.formatMessage(messages.enable)}>
<div>
<SlideCheckbox
onClick={() => {
onEnable();
}}
checked={slider.enabled}
/>
</div>
</Tooltip>
</div>
</div>
{isEditing ? (
<div className="p-4">
<CreateSlider
onCreate={() => {
onDelete();
setIsEditing(false);
}}
slider={slider}
/>
</div>
) : (
<div
className={`pointer-events-none p-4 ${
!slider.enabled ? 'opacity-50' : ''
}`}
>
{children}
</div>
)}
</div>
);
};
export default DiscoverSliderEdit;

View File

@@ -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>
);
})}
</>
);

View File

@@ -0,0 +1,28 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { RectangleStackIcon } from '@heroicons/react/24/outline';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import useSWR from 'swr';
type GenreTagProps = {
type: 'tv' | 'movie';
genreId: number;
};
const GenreTag = ({ genreId, type }: GenreTagProps) => {
const { data, error } = useSWR<TmdbGenre[]>(`/api/v1/genres/${type}`);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
const genre = data?.find((genre) => genre.id === genreId);
return <Tag iconSvg={<RectangleStackIcon />}>{genre?.name}</Tag>;
};
export default GenreTag;

View File

@@ -0,0 +1,24 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import type { Keyword } from '@server/models/common';
import useSWR from 'swr';
type KeywordTagProps = {
keywordId: number;
};
const KeywordTag = ({ keywordId }: KeywordTagProps) => {
const { data, error } = useSWR<Keyword>(`/api/v1/keyword/${keywordId}`);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
return <Tag>{data?.name}</Tag>;
};
export default KeywordTag;

View File

@@ -462,7 +462,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
key={`keyword-id-${keyword.id}`}
>
<a className="mb-2 mr-2 inline-flex last:mr-0">
<Tag content={keyword.name} />
<Tag>{keyword.name}</Tag>
</a>
</Link>
))}

View File

@@ -1,170 +0,0 @@
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 { Bars3Icon, XMarkIcon } from '@heroicons/react/24/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'
}`}
>
<Bars3Icon 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()}
>
<XMarkIcon />
<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

@@ -1,223 +0,0 @@
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 {
ArrowDownOnSquareIcon,
ArrowPathIcon,
} 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({
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()}>
<ArrowPathIcon />
<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"
>
<ArrowDownOnSquareIcon />
<span>{intl.formatMessage(globalMessages.save)}</span>
</Button>
</span>
</div>
</div>
</>
);
};
export default DiscoverCustomization;

View File

@@ -7,7 +7,6 @@ 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';
@@ -56,9 +55,6 @@ 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 = () => {
@@ -454,15 +450,6 @@ 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 />
</>
);
};

View File

@@ -503,7 +503,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
key={`keyword-id-${keyword.id}`}
>
<a className="mb-2 mr-2 inline-flex last:mr-0">
<Tag content={keyword.name} />
<Tag>{keyword.name}</Tag>
</a>
</Link>
))}