feat: discover inline customization (#3220)
parent
0d8b390b67
commit
8bd10b5bf3
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
|
Loading…
Reference in new issue