feat: discover inline customization (#3220)

pull/3221/head
Ryan Cohen 2 years ago committed by GitHub
parent 0d8b390b67
commit 8bd10b5bf3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,15 +5,20 @@ describe('Discover Customization', () => {
}); });
it('show the discover customization settings', () => { it('show the discover customization settings', () => {
cy.visit('/settings'); cy.visit('/');
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-customization]') cy.get('[data-testid=create-slider-header')
.should('contain', 'Discover Customization') .should('contain', 'Create New Slider')
.scrollIntoView(); .scrollIntoView();
// There should be some built in options // There should be some built in options
cy.get('[data-testid=discover-option]').should('contain', 'Recently Added'); cy.get('[data-testid=discover-slider-edit-mode]').should(
cy.get('[data-testid=discover-option]').should( 'contain',
'Recently Added'
);
cy.get('[data-testid=discover-slider-edit-mode]').should(
'contain', 'contain',
'Recent Requests' 'Recent Requests'
); );
@ -21,19 +26,21 @@ describe('Discover Customization', () => {
it('can drag to re-order elements and save to persist the changes', () => { it('can drag to re-order elements and save to persist the changes', () => {
let dataTransfer = new DataTransfer(); let dataTransfer = new DataTransfer();
cy.visit('/settings'); cy.visit('/');
cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.first() .first()
.trigger('dragstart', { dataTransfer }); .trigger('dragstart', { dataTransfer });
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1) .eq(1)
.trigger('drop', { dataTransfer }); .trigger('drop', { dataTransfer });
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1) .eq(1)
.trigger('dragend', { dataTransfer }); .trigger('dragend', { dataTransfer });
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1) .eq(1)
.should('contain', 'Recently Added'); .should('contain', 'Recently Added');
@ -42,23 +49,25 @@ describe('Discover Customization', () => {
cy.reload(); cy.reload();
cy.get('[data-testid=discover-start-editing]').click();
dataTransfer = new DataTransfer(); dataTransfer = new DataTransfer();
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1) .eq(1)
.should('contain', 'Recently Added'); .should('contain', 'Recently Added');
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.first() .first()
.trigger('dragstart', { dataTransfer }); .trigger('dragstart', { dataTransfer });
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1) .eq(1)
.trigger('drop', { dataTransfer }); .trigger('drop', { dataTransfer });
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1) .eq(1)
.trigger('dragend', { dataTransfer }); .trigger('dragend', { dataTransfer });
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1) .eq(1)
.should('contain', 'Recent Requests'); .should('contain', 'Recent Requests');
@ -67,10 +76,12 @@ describe('Discover Customization', () => {
}); });
it('can create a new discover option and remove it', () => { it('can create a new discover option and remove it', () => {
cy.visit('/settings'); cy.visit('/');
cy.intercept('/api/v1/settings/discover/*').as('discoverSlider'); cy.intercept('/api/v1/settings/discover/*').as('discoverSlider');
cy.intercept('/api/v1/search/keyword*').as('searchKeyword'); cy.intercept('/api/v1/search/keyword*').as('searchKeyword');
cy.get('[data-testid=discover-start-editing]').click();
const sliderTitle = 'Custom Keyword Slider'; const sliderTitle = 'Custom Keyword Slider';
cy.get('#sliderType').select('TMDB Movie Keyword'); cy.get('#sliderType').select('TMDB Movie Keyword');
@ -98,14 +109,16 @@ describe('Discover Customization', () => {
cy.wait('@getDiscoverSliders'); cy.wait('@getDiscoverSliders');
cy.wait(1000); cy.wait(1000);
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.first() .first()
.should('contain', sliderTitle); .should('contain', sliderTitle);
// Make sure its still there even if we reload // Make sure its still there even if we reload
cy.reload(); cy.reload();
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-start-editing]').click();
cy.get('[data-testid=discover-slider-edit-mode]')
.first() .first()
.should('contain', sliderTitle); .should('contain', sliderTitle);
@ -114,10 +127,10 @@ describe('Discover Customization', () => {
cy.get('.slider-header').should('not.contain', sliderTitle); cy.get('.slider-header').should('not.contain', sliderTitle);
cy.visit('/settings'); cy.get('[data-testid=discover-start-editing]').click();
// Enable it, and check again // Enable it, and check again
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.first() .first()
.find('[role="checkbox"]') .find('[role="checkbox"]')
.click(); .click();
@ -131,20 +144,19 @@ describe('Discover Customization', () => {
.next('[data-testid=media-slider]') .next('[data-testid=media-slider]')
.find('[data-testid=title-card]'); .find('[data-testid=title-card]');
cy.visit('/settings'); cy.get('[data-testid=discover-start-editing]').click();
// let's delete it and confirm its deleted. // let's delete it and confirm its deleted.
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.first() .first()
.find('button') .find('[data-testid=discover-slider-remove-button]')
.should('contain', 'Remove')
.click(); .click();
cy.wait('@discoverSlider'); cy.wait('@discoverSlider');
cy.wait('@getDiscoverSliders'); cy.wait('@getDiscoverSliders');
cy.wait(1000); cy.wait(1000);
cy.get('[data-testid=discover-option]') cy.get('[data-testid=discover-slider-edit-mode]')
.first() .first()
.should('not.contain', sliderTitle); .should('not.contain', sliderTitle);
}); });

@ -26,6 +26,8 @@ tags:
description: Endpoints related to retrieving movies and their details. description: Endpoints related to retrieving movies and their details.
- name: tv - name: tv
description: Endpoints related to retrieving TV series and their details. description: Endpoints related to retrieving TV series and their details.
- name: keyword
description: Endpoints related to getting keywords and their details.
- name: person - name: person
description: Endpoints related to retrieving person details. description: Endpoints related to retrieving person details.
- name: media - name: media
@ -3121,6 +3123,35 @@ paths:
items: items:
$ref: '#/components/schemas/DiscoverSlider' $ref: '#/components/schemas/DiscoverSlider'
/settings/discover/{sliderId}: /settings/discover/{sliderId}:
put:
summary: Update a single slider
description: |
Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
example: 'Slider Title'
type:
type: number
example: 1
data:
type: string
example: '1'
responses:
'200':
description: Returns newly added discovery slider
content:
application/json:
schema:
$ref: '#/components/schemas/DiscoverSlider'
delete: delete:
summary: Delete slider by ID summary: Delete slider by ID
description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission. description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission.
@ -6143,6 +6174,27 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Issue' $ref: '#/components/schemas/Issue'
/keyword/{keywordId}:
get:
summary: Get keyword
description: |
Returns a single keyword in JSON format.
tags:
- keyword
parameters:
- in: path
name: keywordId
required: true
schema:
type: number
example: 1
responses:
'200':
description: Keyword returned
content:
application/json:
schema:
$ref: '#/components/schemas/Keyword'
security: security:
- cookieAuth: [] - cookieAuth: []
- apiKey: [] - apiKey: []

@ -278,6 +278,27 @@ router.get('/backdrops', async (req, res, next) => {
} }
}); });
router.get('/keyword/:keywordId', async (req, res, next) => {
const tmdb = createTmdbWithRegionLanguage();
try {
const result = await tmdb.getKeywordDetails({
keywordId: Number(req.params.keywordId),
});
return res.status(200).json(result);
} catch (e) {
logger.debug('Something went wrong retrieving keyword data', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve keyword data.',
});
}
});
router.get('/', (_req, res) => { router.get('/', (_req, res) => {
return res.status(200).json({ return res.status(200).json({
api: 'Overseerr API', api: 'Overseerr API',

@ -77,6 +77,37 @@ discoverSettingRoutes.get('/reset', async (_req, res) => {
return res.status(204).send(); return res.status(204).send();
}); });
discoverSettingRoutes.put('/:sliderId', async (req, res, next) => {
const sliderRepository = getRepository(DiscoverSlider);
const slider = req.body as DiscoverSlider;
try {
const existingSlider = await sliderRepository.findOneOrFail({
where: {
id: Number(req.params.sliderId),
},
});
// Only allow changes to the following when the slider is not built in
if (!existingSlider.isBuiltIn) {
existingSlider.title = slider.title;
existingSlider.data = slider.data;
existingSlider.type = slider.type;
}
await sliderRepository.save(existingSlider);
return res.status(200).json(existingSlider);
} catch (e) {
logger.error('Something went wrong updating a slider.', {
label: 'API',
errorMessage: e.message,
});
next({ status: 404, message: 'Slider not found or cannot be updated.' });
}
});
discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => { discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => {
const sliderRepository = getRepository(DiscoverSlider); const sliderRepository = getRepository(DiscoverSlider);

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

@ -1,14 +1,22 @@
import { TagIcon } from '@heroicons/react/24/outline'; import { TagIcon } from '@heroicons/react/24/outline';
import React from 'react';
type TagProps = { type TagProps = {
content: string; children: React.ReactNode;
iconSvg?: JSX.Element;
}; };
const Tag = ({ content }: TagProps) => { const Tag = ({ children, iconSvg }: TagProps) => {
return ( 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"> <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">
{iconSvg ? (
React.cloneElement(iconSvg, {
className: 'mr-1 h-4 w-4',
})
) : (
<TagIcon className="mr-1 h-4 w-4" /> <TagIcon className="mr-1 h-4 w-4" />
<span>{content}</span> )}
<span>{children}</span>
</div> </div>
); );
}; };

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

@ -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 LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants'; 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 MovieGenreSlider from '@app/components/Discover/MovieGenreSlider';
import NetworkSlider from '@app/components/Discover/NetworkSlider'; import NetworkSlider from '@app/components/Discover/NetworkSlider';
import PlexWatchlistSlider from '@app/components/Discover/PlexWatchlistSlider'; 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 TvGenreSlider from '@app/components/Discover/TvGenreSlider';
import MediaSlider from '@app/components/MediaSlider'; import MediaSlider from '@app/components/MediaSlider';
import { encodeURIExtraParams } from '@app/hooks/useSearchInput'; 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 { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider'; import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages({
discover: 'Discover', discover: 'Discover',
emptywatchlist: emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.', '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 Discover = () => {
const intl = useIntl(); const intl = useIntl();
const { data: discoverData, error: discoverError } = useSWR<DiscoverSlider[]>( const { hasPermission } = useUser();
'/api/v1/settings/discover' 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) { if (!discoverData && !discoverError) {
return <LoadingSpinner />; return <LoadingSpinner />;
@ -34,20 +115,97 @@ const Discover = () => {
return ( return (
<> <>
<PageTitle title={intl.formatMessage(messages.discover)} /> <PageTitle title={intl.formatMessage(messages.discover)} />
{discoverData?.map((slider) => { {hasPermission(Permission.ADMIN) && (
if (!slider.enabled) { <>
return null; {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) { switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED: case DiscoverSliderType.RECENTLY_ADDED:
return <RecentlyAddedSlider />; sliderComponent = <RecentlyAddedSlider />;
break;
case DiscoverSliderType.RECENT_REQUESTS: case DiscoverSliderType.RECENT_REQUESTS:
return <RecentRequestsSlider />; sliderComponent = <RecentRequestsSlider />;
break;
case DiscoverSliderType.PLEX_WATCHLIST: case DiscoverSliderType.PLEX_WATCHLIST:
return <PlexWatchlistSlider />; sliderComponent = <PlexWatchlistSlider />;
break;
case DiscoverSliderType.TRENDING: case DiscoverSliderType.TRENDING:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey="trending" sliderKey="trending"
title={intl.formatMessage(sliderTitles.trending)} title={intl.formatMessage(sliderTitles.trending)}
@ -55,8 +213,9 @@ const Discover = () => {
linkUrl="/discover/trending" linkUrl="/discover/trending"
/> />
); );
break;
case DiscoverSliderType.POPULAR_MOVIES: case DiscoverSliderType.POPULAR_MOVIES:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey="popular-movies" sliderKey="popular-movies"
title={intl.formatMessage(sliderTitles.popularmovies)} title={intl.formatMessage(sliderTitles.popularmovies)}
@ -64,10 +223,12 @@ const Discover = () => {
linkUrl="/discover/movies" linkUrl="/discover/movies"
/> />
); );
break;
case DiscoverSliderType.MOVIE_GENRES: case DiscoverSliderType.MOVIE_GENRES:
return <MovieGenreSlider />; sliderComponent = <MovieGenreSlider />;
break;
case DiscoverSliderType.UPCOMING_MOVIES: case DiscoverSliderType.UPCOMING_MOVIES:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey="upcoming" sliderKey="upcoming"
title={intl.formatMessage(sliderTitles.upcoming)} title={intl.formatMessage(sliderTitles.upcoming)}
@ -75,10 +236,12 @@ const Discover = () => {
url="/api/v1/discover/movies/upcoming" url="/api/v1/discover/movies/upcoming"
/> />
); );
break;
case DiscoverSliderType.STUDIOS: case DiscoverSliderType.STUDIOS:
return <StudioSlider />; sliderComponent = <StudioSlider />;
break;
case DiscoverSliderType.POPULAR_TV: case DiscoverSliderType.POPULAR_TV:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey="popular-tv" sliderKey="popular-tv"
title={intl.formatMessage(sliderTitles.populartv)} title={intl.formatMessage(sliderTitles.populartv)}
@ -86,10 +249,12 @@ const Discover = () => {
linkUrl="/discover/tv" linkUrl="/discover/tv"
/> />
); );
break;
case DiscoverSliderType.TV_GENRES: case DiscoverSliderType.TV_GENRES:
return <TvGenreSlider />; sliderComponent = <TvGenreSlider />;
break;
case DiscoverSliderType.UPCOMING_TV: case DiscoverSliderType.UPCOMING_TV:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey="upcoming-tv" sliderKey="upcoming-tv"
title={intl.formatMessage(sliderTitles.upcomingtv)} title={intl.formatMessage(sliderTitles.upcomingtv)}
@ -97,10 +262,12 @@ const Discover = () => {
linkUrl="/discover/tv/upcoming" linkUrl="/discover/tv/upcoming"
/> />
); );
break;
case DiscoverSliderType.NETWORKS: case DiscoverSliderType.NETWORKS:
return <NetworkSlider />; sliderComponent = <NetworkSlider />;
break;
case DiscoverSliderType.TMDB_MOVIE_KEYWORD: case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey={`custom-slider-${slider.id}`} sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''} title={slider.title ?? ''}
@ -113,8 +280,9 @@ const Discover = () => {
linkUrl={`/discover/movies/keyword?keywords=${slider.data}`} linkUrl={`/discover/movies/keyword?keywords=${slider.data}`}
/> />
); );
break;
case DiscoverSliderType.TMDB_TV_KEYWORD: case DiscoverSliderType.TMDB_TV_KEYWORD:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey={`custom-slider-${slider.id}`} sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''} title={slider.title ?? ''}
@ -127,8 +295,9 @@ const Discover = () => {
linkUrl={`/discover/tv/keyword?keywords=${slider.data}`} linkUrl={`/discover/tv/keyword?keywords=${slider.data}`}
/> />
); );
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE: case DiscoverSliderType.TMDB_MOVIE_GENRE:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey={`custom-slider-${slider.id}`} sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''} title={slider.title ?? ''}
@ -136,8 +305,9 @@ const Discover = () => {
linkUrl={`/discover/movies/genre/${slider.data}`} linkUrl={`/discover/movies/genre/${slider.data}`}
/> />
); );
break;
case DiscoverSliderType.TMDB_TV_GENRE: case DiscoverSliderType.TMDB_TV_GENRE:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey={`custom-slider-${slider.id}`} sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''} title={slider.title ?? ''}
@ -145,8 +315,9 @@ const Discover = () => {
linkUrl={`/discover/tv/genre/${slider.data}`} linkUrl={`/discover/tv/genre/${slider.data}`}
/> />
); );
break;
case DiscoverSliderType.TMDB_STUDIO: case DiscoverSliderType.TMDB_STUDIO:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey={`custom-slider-${slider.id}`} sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''} title={slider.title ?? ''}
@ -154,8 +325,9 @@ const Discover = () => {
linkUrl={`/discover/movies/studio/${slider.data}`} linkUrl={`/discover/movies/studio/${slider.data}`}
/> />
); );
break;
case DiscoverSliderType.TMDB_NETWORK: case DiscoverSliderType.TMDB_NETWORK:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey={`custom-slider-${slider.id}`} sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''} title={slider.title ?? ''}
@ -163,8 +335,9 @@ const Discover = () => {
linkUrl={`/discover/tv/network/${slider.data}`} linkUrl={`/discover/tv/network/${slider.data}`}
/> />
); );
break;
case DiscoverSliderType.TMDB_SEARCH: case DiscoverSliderType.TMDB_SEARCH:
return ( sliderComponent = (
<MediaSlider <MediaSlider
sliderKey={`custom-slider-${slider.id}`} sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''} title={slider.title ?? ''}
@ -173,7 +346,60 @@ const Discover = () => {
linkUrl={`/search?query=${slider.data}`} 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>
);
})} })}
</> </>
); );

@ -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;

@ -462,7 +462,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
key={`keyword-id-${keyword.id}`} key={`keyword-id-${keyword.id}`}
> >
<a className="mb-2 mr-2 inline-flex last:mr-0"> <a className="mb-2 mr-2 inline-flex last:mr-0">
<Tag content={keyword.name} /> <Tag>{keyword.name}</Tag>
</a> </a>
</Link> </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 RegionSelector from '@app/components/RegionSelector';
import CopyButton from '@app/components/Settings/CopyButton'; import CopyButton from '@app/components/Settings/CopyButton';
import SettingsBadge from '@app/components/Settings/SettingsBadge'; import SettingsBadge from '@app/components/Settings/SettingsBadge';
import DiscoverCustomization from '@app/components/Settings/SettingsMain/DiscoverCustomization';
import type { AvailableLocale } from '@app/context/LanguageContext'; import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext'; import { availableLanguages } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale'; import useLocale from '@app/hooks/useLocale';
@ -56,9 +55,6 @@ const messages = defineMessages({
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests', partialRequestsEnabled: 'Allow Partial Series Requests',
locale: 'Display Language', locale: 'Display Language',
discovercustomization: 'Discover Customization',
discovercustomizationDescription:
'Add or remove sliders on the Discover page.',
}); });
const SettingsMain = () => { const SettingsMain = () => {
@ -454,15 +450,6 @@ const SettingsMain = () => {
}} }}
</Formik> </Formik>
</div> </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}`} key={`keyword-id-${keyword.id}`}
> >
<a className="mb-2 mr-2 inline-flex last:mr-0"> <a className="mb-2 mr-2 inline-flex last:mr-0">
<Tag content={keyword.name} /> <Tag>{keyword.name}</Tag>
</a> </a>
</Link> </Link>
))} ))}

@ -6,10 +6,35 @@
"components.CollectionDetails.overview": "Overview", "components.CollectionDetails.overview": "Overview",
"components.CollectionDetails.requestcollection": "Request Collection", "components.CollectionDetails.requestcollection": "Request Collection",
"components.CollectionDetails.requestcollection4k": "Request Collection in 4K", "components.CollectionDetails.requestcollection4k": "Request Collection in 4K",
"components.Discover.CreateSlider.addSlider": "Add Slider",
"components.Discover.CreateSlider.addcustomslider": "Create Custom Slider",
"components.Discover.CreateSlider.addfail": "Failed to create new slider.",
"components.Discover.CreateSlider.addsuccess": "Created new slider and saved discover customization settings.",
"components.Discover.CreateSlider.editSlider": "Edit Slider",
"components.Discover.CreateSlider.editfail": "Failed to edit slider.",
"components.Discover.CreateSlider.editsuccess": "Edited slider and saved discover customization settings.",
"components.Discover.CreateSlider.needresults": "You need to have at least 1 result.",
"components.Discover.CreateSlider.nooptions": "No results.",
"components.Discover.CreateSlider.providetmdbgenreid": "Provide a TMDB Genre ID",
"components.Discover.CreateSlider.providetmdbkeywordid": "Provide a TMDB Keyword ID",
"components.Discover.CreateSlider.providetmdbnetwork": "Provide TMDB Network ID",
"components.Discover.CreateSlider.providetmdbsearch": "Provide a search query",
"components.Discover.CreateSlider.providetmdbstudio": "Provide TMDB Studio ID",
"components.Discover.CreateSlider.searchGenres": "Search genres…",
"components.Discover.CreateSlider.searchKeywords": "Search keywords…",
"components.Discover.CreateSlider.searchStudios": "Search studios…",
"components.Discover.CreateSlider.slidernameplaceholder": "Slider Name",
"components.Discover.CreateSlider.starttyping": "Starting typing to search.",
"components.Discover.CreateSlider.validationDatarequired": "You must provide a data value.",
"components.Discover.CreateSlider.validationTitlerequired": "You must provide a title.",
"components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies", "components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies",
"components.Discover.DiscoverMovieKeyword.keywordMovies": "{keywordTitle} Movies", "components.Discover.DiscoverMovieKeyword.keywordMovies": "{keywordTitle} Movies",
"components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies", "components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies",
"components.Discover.DiscoverNetwork.networkSeries": "{network} Series", "components.Discover.DiscoverNetwork.networkSeries": "{network} Series",
"components.Discover.DiscoverSliderEdit.deletefail": "Failed to delete slider.",
"components.Discover.DiscoverSliderEdit.deletesuccess": "Sucessfully deleted slider.",
"components.Discover.DiscoverSliderEdit.enable": "Toggle Visibility",
"components.Discover.DiscoverSliderEdit.remove": "Remove",
"components.Discover.DiscoverStudio.studioMovies": "{studio} Movies", "components.Discover.DiscoverStudio.studioMovies": "{studio} Movies",
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series", "components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series",
"components.Discover.DiscoverTvKeyword.keywordSeries": "{keywordTitle} Series", "components.Discover.DiscoverTvKeyword.keywordSeries": "{keywordTitle} Series",
@ -25,6 +50,8 @@
"components.Discover.StudioSlider.studios": "Studios", "components.Discover.StudioSlider.studios": "Studios",
"components.Discover.TvGenreList.seriesgenres": "Series Genres", "components.Discover.TvGenreList.seriesgenres": "Series Genres",
"components.Discover.TvGenreSlider.tvgenres": "Series Genres", "components.Discover.TvGenreSlider.tvgenres": "Series Genres",
"components.Discover.createnewslider": "Create New Slider",
"components.Discover.customizediscover": "Customize Discover",
"components.Discover.discover": "Discover", "components.Discover.discover": "Discover",
"components.Discover.discovermovies": "Popular Movies", "components.Discover.discovermovies": "Popular Movies",
"components.Discover.discovertv": "Popular Series", "components.Discover.discovertv": "Popular Series",
@ -36,6 +63,11 @@
"components.Discover.populartv": "Popular Series", "components.Discover.populartv": "Popular Series",
"components.Discover.recentlyAdded": "Recently Added", "components.Discover.recentlyAdded": "Recently Added",
"components.Discover.recentrequests": "Recent Requests", "components.Discover.recentrequests": "Recent Requests",
"components.Discover.resetfailed": "Something went wrong resetting the discover customization settings.",
"components.Discover.resetsuccess": "Sucessfully reset discover customization settings.",
"components.Discover.resettodefault": "Reset to Default",
"components.Discover.resetwarning": "Reset all sliders to default. This will also delete any custom sliders!",
"components.Discover.stopediting": "Stop Editing",
"components.Discover.studios": "Studios", "components.Discover.studios": "Studios",
"components.Discover.tmdbmoviegenre": "TMDB Movie Genre", "components.Discover.tmdbmoviegenre": "TMDB Movie Genre",
"components.Discover.tmdbmoviekeyword": "TMDB Movie Keyword", "components.Discover.tmdbmoviekeyword": "TMDB Movie Keyword",
@ -49,6 +81,8 @@
"components.Discover.upcoming": "Upcoming Movies", "components.Discover.upcoming": "Upcoming Movies",
"components.Discover.upcomingmovies": "Upcoming Movies", "components.Discover.upcomingmovies": "Upcoming Movies",
"components.Discover.upcomingtv": "Upcoming Series", "components.Discover.upcomingtv": "Upcoming Series",
"components.Discover.updatefailed": "Something went wrong updating the discover customization settings.",
"components.Discover.updatesuccess": "Updated discover customization settings.",
"components.DownloadBlock.estimatedtime": "Estimated {time}", "components.DownloadBlock.estimatedtime": "Estimated {time}",
"components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}", "components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}",
"components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?", "components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?",
@ -704,34 +738,6 @@
"components.Settings.SettingsLogs.showall": "Show All Logs", "components.Settings.SettingsLogs.showall": "Show All Logs",
"components.Settings.SettingsLogs.time": "Timestamp", "components.Settings.SettingsLogs.time": "Timestamp",
"components.Settings.SettingsLogs.viewdetails": "View Details", "components.Settings.SettingsLogs.viewdetails": "View Details",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.addSlider": "Add Slider",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.addcustomslider": "Add Custom Slider",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.addfail": "Failed to create new slider.",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.addsuccess": "Created new slider and saved discover customization settings.",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.needresults": "You need to have at least 1 result to create a slider.",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.nooptions": "No results.",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.providetmdbgenreid": "Provide a TMDB Genre ID",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.providetmdbkeywordid": "Provide a TMDB Keyword ID",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.providetmdbnetwork": "Provide TMDB Network ID",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.providetmdbsearch": "Provide a search query",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.providetmdbstudio": "Provide TMDB Studio ID",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.searchGenres": "Search genres…",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.searchKeywords": "Search keywords…",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.searchStudios": "Search studios…",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.slidernameplaceholder": "Slider Name",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.starttyping": "Starting typing to search.",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.validationDatarequired": "You must provide a data value.",
"components.Settings.SettingsMain.DiscoverCustomization.CreateSlider.validationTitlerequired": "You must provide a title.",
"components.Settings.SettingsMain.DiscoverCustomization.DiscoverOption.deletefail": "Failed to delete slider.",
"components.Settings.SettingsMain.DiscoverCustomization.DiscoverOption.deletesuccess": "Sucessfully deleted slider.",
"components.Settings.SettingsMain.DiscoverCustomization.DiscoverOption.enable": "Toggle Visibility",
"components.Settings.SettingsMain.DiscoverCustomization.DiscoverOption.remove": "Remove",
"components.Settings.SettingsMain.DiscoverCustomization.resetfailed": "Something went wrong resetting the discover customization settings.",
"components.Settings.SettingsMain.DiscoverCustomization.resetsuccess": "Sucessfully reset discover customization settings.",
"components.Settings.SettingsMain.DiscoverCustomization.resettodefault": "Reset to Default",
"components.Settings.SettingsMain.DiscoverCustomization.resetwarning": "Reset all sliders to default. This will also delete any custom sliders!",
"components.Settings.SettingsMain.DiscoverCustomization.updatefailed": "Something went wrong updating the discover customization settings.",
"components.Settings.SettingsMain.DiscoverCustomization.updatesuccess": "Updated discover customization settings.",
"components.Settings.SettingsMain.apikey": "API Key", "components.Settings.SettingsMain.apikey": "API Key",
"components.Settings.SettingsMain.applicationTitle": "Application Title", "components.Settings.SettingsMain.applicationTitle": "Application Title",
"components.Settings.SettingsMain.applicationurl": "Application URL", "components.Settings.SettingsMain.applicationurl": "Application URL",
@ -740,8 +746,6 @@
"components.Settings.SettingsMain.csrfProtection": "Enable CSRF Protection", "components.Settings.SettingsMain.csrfProtection": "Enable CSRF Protection",
"components.Settings.SettingsMain.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!", "components.Settings.SettingsMain.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
"components.Settings.SettingsMain.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)", "components.Settings.SettingsMain.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
"components.Settings.SettingsMain.discovercustomization": "Discover Customization",
"components.Settings.SettingsMain.discovercustomizationDescription": "Add or remove sliders on the Discover page.",
"components.Settings.SettingsMain.general": "General", "components.Settings.SettingsMain.general": "General",
"components.Settings.SettingsMain.generalsettings": "General Settings", "components.Settings.SettingsMain.generalsettings": "General Settings",
"components.Settings.SettingsMain.generalsettingsDescription": "Configure global and default settings for Overseerr.", "components.Settings.SettingsMain.generalsettingsDescription": "Configure global and default settings for Overseerr.",

@ -68,7 +68,7 @@
} }
.slider-header { .slider-header {
@apply relative mt-6 mb-4 flex; @apply relative mb-4 flex;
} }
.slider-title { .slider-title {

Loading…
Cancel
Save