mirror of
https://github.com/sct/overseerr.git
synced 2025-09-27 04:22:37 +02:00
feat: discover inline customization (#3220)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
<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;
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
28
src/components/CompanyTag/index.tsx
Normal file
28
src/components/CompanyTag/index.tsx
Normal 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;
|
@@ -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>
|
||||
);
|
||||
}}
|
301
src/components/Discover/DiscoverSliderEdit/index.tsx
Normal file
301
src/components/Discover/DiscoverSliderEdit/index.tsx
Normal 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;
|
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
28
src/components/GenreTag/index.tsx
Normal file
28
src/components/GenreTag/index.tsx
Normal 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;
|
24
src/components/KeywordTag/index.tsx
Normal file
24
src/components/KeywordTag/index.tsx
Normal 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;
|
@@ -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>
|
||||
))}
|
||||
|
@@ -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;
|
@@ -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;
|
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -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>
|
||||
))}
|
||||
|
Reference in New Issue
Block a user