diff --git a/cypress/e2e/settings/discover-customization.cy.ts b/cypress/e2e/settings/discover-customization.cy.ts
index 8c96b6e3c..a0756ae21 100644
--- a/cypress/e2e/settings/discover-customization.cy.ts
+++ b/cypress/e2e/settings/discover-customization.cy.ts
@@ -5,15 +5,20 @@ describe('Discover Customization', () => {
});
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]')
- .should('contain', 'Discover Customization')
+ cy.get('[data-testid=create-slider-header')
+ .should('contain', 'Create New Slider')
.scrollIntoView();
// There should be some built in options
- cy.get('[data-testid=discover-option]').should('contain', 'Recently Added');
- cy.get('[data-testid=discover-option]').should(
+ cy.get('[data-testid=discover-slider-edit-mode]').should(
+ 'contain',
+ 'Recently Added'
+ );
+ cy.get('[data-testid=discover-slider-edit-mode]').should(
'contain',
'Recent Requests'
);
@@ -21,19 +26,21 @@ describe('Discover Customization', () => {
it('can drag to re-order elements and save to persist the changes', () => {
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()
.trigger('dragstart', { dataTransfer });
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('drop', { dataTransfer });
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('dragend', { dataTransfer });
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recently Added');
@@ -42,23 +49,25 @@ describe('Discover Customization', () => {
cy.reload();
+ cy.get('[data-testid=discover-start-editing]').click();
+
dataTransfer = new DataTransfer();
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recently Added');
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.trigger('dragstart', { dataTransfer });
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('drop', { dataTransfer });
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.trigger('dragend', { dataTransfer });
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.eq(1)
.should('contain', 'Recent Requests');
@@ -67,10 +76,12 @@ describe('Discover Customization', () => {
});
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/search/keyword*').as('searchKeyword');
+ cy.get('[data-testid=discover-start-editing]').click();
+
const sliderTitle = 'Custom Keyword Slider';
cy.get('#sliderType').select('TMDB Movie Keyword');
@@ -98,14 +109,16 @@ describe('Discover Customization', () => {
cy.wait('@getDiscoverSliders');
cy.wait(1000);
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('contain', sliderTitle);
// Make sure its still there even if we 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()
.should('contain', sliderTitle);
@@ -114,10 +127,10 @@ describe('Discover Customization', () => {
cy.get('.slider-header').should('not.contain', sliderTitle);
- cy.visit('/settings');
+ cy.get('[data-testid=discover-start-editing]').click();
// Enable it, and check again
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.find('[role="checkbox"]')
.click();
@@ -131,20 +144,19 @@ describe('Discover Customization', () => {
.next('[data-testid=media-slider]')
.find('[data-testid=title-card]');
- cy.visit('/settings');
+ cy.get('[data-testid=discover-start-editing]').click();
// let's delete it and confirm its deleted.
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.first()
- .find('button')
- .should('contain', 'Remove')
+ .find('[data-testid=discover-slider-remove-button]')
.click();
cy.wait('@discoverSlider');
cy.wait('@getDiscoverSliders');
cy.wait(1000);
- cy.get('[data-testid=discover-option]')
+ cy.get('[data-testid=discover-slider-edit-mode]')
.first()
.should('not.contain', sliderTitle);
});
diff --git a/overseerr-api.yml b/overseerr-api.yml
index 9a6dc4457..d31f4e239 100644
--- a/overseerr-api.yml
+++ b/overseerr-api.yml
@@ -26,6 +26,8 @@ tags:
description: Endpoints related to retrieving movies and their details.
- name: tv
description: Endpoints related to retrieving TV series and their details.
+ - name: keyword
+ description: Endpoints related to getting keywords and their details.
- name: person
description: Endpoints related to retrieving person details.
- name: media
@@ -3121,6 +3123,35 @@ paths:
items:
$ref: '#/components/schemas/DiscoverSlider'
/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:
summary: Delete slider by ID
description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission.
@@ -6143,6 +6174,27 @@ paths:
application/json:
schema:
$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:
- cookieAuth: []
- apiKey: []
diff --git a/server/routes/index.ts b/server/routes/index.ts
index faac1b439..8318bbfc0 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -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) => {
return res.status(200).json({
api: 'Overseerr API',
diff --git a/server/routes/settings/discover.ts b/server/routes/settings/discover.ts
index 7d2a227da..344ce72be 100644
--- a/server/routes/settings/discover.ts
+++ b/server/routes/settings/discover.ts
@@ -77,6 +77,37 @@ discoverSettingRoutes.get('/reset', async (_req, res) => {
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) => {
const sliderRepository = getRepository(DiscoverSlider);
diff --git a/src/components/Common/ConfirmButton/index.tsx b/src/components/Common/ConfirmButton/index.tsx
index 1f5756cb9..4234da68a 100644
--- a/src/components/Common/ConfirmButton/index.tsx
+++ b/src/components/Common/ConfirmButton/index.tsx
@@ -1,6 +1,6 @@
import Button from '@app/components/Common/Button';
import useClickOutside from '@app/hooks/useClickOutside';
-import { useRef, useState } from 'react';
+import { forwardRef, useRef, useState } from 'react';
interface ConfirmButtonProps {
onClick: () => void;
@@ -9,50 +9,51 @@ interface ConfirmButtonProps {
children: React.ReactNode;
}
-const ConfirmButton = ({
- onClick,
- children,
- confirmText,
- className,
-}: ConfirmButtonProps) => {
- const ref = useRef(null);
- useClickOutside(ref, () => setIsClicked(false));
- const [isClicked, setIsClicked] = useState(false);
- return (
-
+ );
+ }
+);
+
+ConfirmButton.displayName = 'ConfirmButton';
export default ConfirmButton;
diff --git a/src/components/Common/Tag/index.tsx b/src/components/Common/Tag/index.tsx
index 9a24c149a..dcb426d40 100644
--- a/src/components/Common/Tag/index.tsx
+++ b/src/components/Common/Tag/index.tsx
@@ -1,14 +1,22 @@
import { TagIcon } from '@heroicons/react/24/outline';
+import React from 'react';
type TagProps = {
- content: string;
+ children: React.ReactNode;
+ iconSvg?: JSX.Element;
};
-const Tag = ({ content }: TagProps) => {
+const Tag = ({ children, iconSvg }: TagProps) => {
return (
-
- {content}
+ {iconSvg ? (
+ React.cloneElement(iconSvg, {
+ className: 'mr-1 h-4 w-4',
+ })
+ ) : (
+
+ )}
+ {children}
);
};
diff --git a/src/components/CompanyTag/index.tsx b/src/components/CompanyTag/index.tsx
new file mode 100644
index 000000000..7c49b8cca
--- /dev/null
+++ b/src/components/CompanyTag/index.tsx
@@ -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(
+ `/api/v1/${type}/${companyId}`
+ );
+
+ if (!data && !error) {
+ return (
+
+
+
+ );
+ }
+
+ return }>{data?.name};
+};
+
+export default CompanyTag;
diff --git a/src/components/Settings/SettingsMain/DiscoverCustomization/CreateSlider/index.tsx b/src/components/Discover/CreateSlider/index.tsx
similarity index 70%
rename from src/components/Settings/SettingsMain/DiscoverCustomization/CreateSlider/index.tsx
rename to src/components/Discover/CreateSlider/index.tsx
index 49ab90cd8..3f70c980e 100644
--- a/src/components/Settings/SettingsMain/DiscoverCustomization/CreateSlider/index.tsx
+++ b/src/components/Discover/CreateSlider/index.tsx
@@ -5,14 +5,16 @@ import MediaSlider from '@app/components/MediaSlider';
import { encodeURIExtraParams } from '@app/hooks/useSearchInput';
import type {
TmdbCompanySearchResponse,
+ TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover';
+import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
+import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
-import { debounce } from 'lodash';
-import { useCallback, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import AsyncSelect from 'react-select/async';
import { useToasts } from 'react-toast-notifications';
@@ -20,6 +22,7 @@ import * as Yup from 'yup';
const messages = defineMessages({
addSlider: 'Add Slider',
+ editSlider: 'Edit Slider',
slidernameplaceholder: 'Slider Name',
providetmdbkeywordid: 'Provide a TMDB Keyword ID',
providetmdbgenreid: 'Provide a TMDB Genre ID',
@@ -28,10 +31,12 @@ const messages = defineMessages({
providetmdbnetwork: 'Provide TMDB Network ID',
addsuccess: 'Created new slider and saved discover customization settings.',
addfail: 'Failed to create new slider.',
- needresults: 'You need to have at least 1 result to create a slider.',
+ editsuccess: 'Edited slider and saved discover customization settings.',
+ editfail: 'Failed to edit slider.',
+ needresults: 'You need to have at least 1 result.',
validationDatarequired: 'You must provide a data value.',
validationTitlerequired: 'You must provide a title.',
- addcustomslider: 'Add Custom Slider',
+ addcustomslider: 'Create Custom Slider',
searchKeywords: 'Search keywords…',
searchGenres: 'Search genres…',
searchStudios: 'Search studios…',
@@ -41,6 +46,7 @@ const messages = defineMessages({
type CreateSliderProps = {
onCreate: () => void;
+ slider?: Partial;
};
type CreateOption = {
@@ -52,10 +58,96 @@ type CreateOption = {
dataPlaceholderText: string;
};
-const CreateSlider = ({ onCreate }: CreateSliderProps) => {
+const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [resultCount, setResultCount] = useState(0);
+ const [defaultDataValue, setDefaultDataValue] = useState<
+ { label: string; value: number }[] | null
+ >(null);
+
+ useEffect(() => {
+ if (slider) {
+ const loadDefaultKeywords = async (): Promise => {
+ if (!slider.data) {
+ return;
+ }
+
+ const keywords = await Promise.all(
+ slider.data.split(',').map(async (keywordId) => {
+ const keyword = await axios.get(
+ `/api/v1/keyword/${keywordId}`
+ );
+
+ return keyword.data;
+ })
+ );
+
+ setDefaultDataValue(
+ keywords.map((keyword) => ({
+ label: keyword.name,
+ value: keyword.id,
+ }))
+ );
+ };
+
+ const loadDefaultGenre = async (): Promise => {
+ if (!slider.data) {
+ return;
+ }
+
+ const response = await axios.get(
+ `/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 => {
+ if (!slider.data) {
+ return;
+ }
+
+ const response = await axios.get(
+ `/api/v1/studio/${slider.data}`
+ );
+
+ const studio = response.data;
+
+ setDefaultDataValue([
+ {
+ label: studio.name ?? '',
+ value: studio.id ?? 0,
+ },
+ ]);
+ };
+
+ switch (slider.type) {
+ case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
+ case DiscoverSliderType.TMDB_TV_KEYWORD:
+ loadDefaultKeywords();
+ break;
+ case DiscoverSliderType.TMDB_MOVIE_GENRE:
+ case DiscoverSliderType.TMDB_TV_GENRE:
+ loadDefaultGenre();
+ break;
+ case DiscoverSliderType.TMDB_STUDIO:
+ loadDefaultCompany();
+ break;
+ }
+ }
+ }, [slider]);
const CreateSliderSchema = Yup.object().shape({
title: Yup.string().required(
@@ -73,7 +165,7 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
[setResultCount]
);
- const loadKeywordOptions = debounce(async (inputValue: string) => {
+ const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get(
'/api/v1/search/keyword',
{
@@ -87,9 +179,13 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
label: result.name,
value: result.id,
}));
- }, 100);
+ };
+
+ const loadCompanyOptions = async (inputValue: string) => {
+ if (inputValue === '') {
+ return [];
+ }
- const loadCompanyOptions = debounce(async (inputValue: string) => {
const results = await axios.get(
'/api/v1/search/company',
{
@@ -103,7 +199,7 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
label: result.name,
value: result.id,
}));
- }, 100);
+ };
const loadMovieGenreOptions = async () => {
const results = await axios.get(
@@ -184,32 +280,56 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
return (
{
try {
- await axios.post('/api/v1/settings/discover/add', {
- type: Number(values.sliderType),
- title: values.title,
- data: values.data,
- });
-
- addToast(intl.formatMessage(messages.addsuccess), {
- appearance: 'success',
- autoDismiss: true,
- });
+ if (slider) {
+ await axios.put(`/api/v1/settings/discover/${slider.id}`, {
+ type: Number(values.sliderType),
+ title: values.title,
+ data: values.data,
+ });
+ } else {
+ await axios.post('/api/v1/settings/discover/add', {
+ type: Number(values.sliderType),
+ title: values.title,
+ data: values.data,
+ });
+ }
+
+ addToast(
+ intl.formatMessage(
+ slider ? messages.editsuccess : messages.addsuccess
+ ),
+ {
+ appearance: 'success',
+ autoDismiss: true,
+ }
+ );
onCreate();
resetForm();
} catch (e) {
- addToast(intl.formatMessage(messages.addfail), {
- appearance: 'error',
- autoDismiss: true,
- });
+ addToast(
+ intl.formatMessage(slider ? messages.editfail : messages.addfail),
+ {
+ appearance: 'error',
+ autoDismiss: true,
+ }
+ );
}
}}
>
@@ -225,7 +345,7 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
case DiscoverSliderType.TMDB_TV_KEYWORD:
dataInput = (
{
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
+ defaultValue={defaultDataValue}
loadOptions={loadKeywordOptions}
placeholder={intl.formatMessage(messages.searchKeywords)}
onChange={(value) => {
@@ -248,9 +369,10 @@ const CreateSlider = ({ onCreate }: CreateSliderProps) => {
case DiscoverSliderType.TMDB_MOVIE_GENRE:
dataInput = (
{
case DiscoverSliderType.TMDB_TV_GENRE:
dataInput = (
{
case DiscoverSliderType.TMDB_STUDIO:
dataInput = (
{
return (
);
}}
diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx
new file mode 100644
index 000000000..6e9bd8b76
--- /dev/null
+++ b/src/components/Discover/DiscoverSliderEdit/index.tsx
@@ -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;
+ 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(null);
+ const [hoverPosition, setHoverPosition] = useState(
+ 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): 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 (
+
+ {hoverPosition === Position.Above && (
+
+ )}
+ {hoverPosition === Position.Below && (
+
+ )}
+
+
+
{getSliderTitle(slider)}
+
+ {(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD ||
+ slider.type === DiscoverSliderType.TMDB_TV_KEYWORD) && (
+
+ {slider.data?.split(',').map((keywordId) => (
+
+ ))}
+
+ )}
+ {(slider.type === DiscoverSliderType.TMDB_NETWORK ||
+ slider.type === DiscoverSliderType.TMDB_STUDIO) && (
+
+ )}
+ {(slider.type === DiscoverSliderType.TMDB_TV_GENRE ||
+ slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE) && (
+
+ )}
+ {slider.type === DiscoverSliderType.TMDB_SEARCH && (
+
}>{slider.data}
+ )}
+
+ {!slider.isBuiltIn && (
+ <>
+ {!isEditing ? (
+
{
+ setIsEditing(true);
+ }}
+ >
+
+ {intl.formatMessage(globalMessages.edit)}
+
+ ) : (
+
{
+ setIsEditing(false);
+ }}
+ >
+
+ {intl.formatMessage(globalMessages.cancel)}
+
+ )}
+
{
+ deleteSlider();
+ }}
+ >
+
+ {intl.formatMessage(messages.remove)}
+
+ >
+ )}
+
+
+
+ {
+ onEnable();
+ }}
+ checked={slider.enabled}
+ />
+
+
+
+
+ {isEditing ? (
+
+ {
+ onDelete();
+ setIsEditing(false);
+ }}
+ slider={slider}
+ />
+
+ ) : (
+
+ {children}
+
+ )}
+
+ );
+};
+
+export default DiscoverSliderEdit;
diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx
index b2c1a07c2..b627ab6ed 100644
--- a/src/components/Discover/index.tsx
+++ b/src/components/Discover/index.tsx
@@ -1,6 +1,11 @@
+import Button from '@app/components/Common/Button';
+import ConfirmButton from '@app/components/Common/ConfirmButton';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
+import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
+import CreateSlider from '@app/components/Discover/CreateSlider';
+import DiscoverSliderEdit from '@app/components/Discover/DiscoverSliderEdit';
import MovieGenreSlider from '@app/components/Discover/MovieGenreSlider';
import NetworkSlider from '@app/components/Discover/NetworkSlider';
import PlexWatchlistSlider from '@app/components/Discover/PlexWatchlistSlider';
@@ -10,22 +15,98 @@ import StudioSlider from '@app/components/Discover/StudioSlider';
import TvGenreSlider from '@app/components/Discover/TvGenreSlider';
import MediaSlider from '@app/components/MediaSlider';
import { encodeURIExtraParams } from '@app/hooks/useSearchInput';
+import { Permission, useUser } from '@app/hooks/useUser';
+import globalMessages from '@app/i18n/globalMessages';
+import {
+ ArrowDownOnSquareIcon,
+ ArrowPathIcon,
+ ArrowUturnLeftIcon,
+ PencilIcon,
+ PlusIcon,
+} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
+import axios from 'axios';
+import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
+import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages({
discover: 'Discover',
emptywatchlist:
'Media added to your Plex Watchlist will appear here.',
+ resettodefault: 'Reset to Default',
+ resetwarning:
+ 'Reset all sliders to default. This will also delete any custom sliders!',
+ updatesuccess: 'Updated discover customization settings.',
+ updatefailed:
+ 'Something went wrong updating the discover customization settings.',
+ resetsuccess: 'Sucessfully reset discover customization settings.',
+ resetfailed:
+ 'Something went wrong resetting the discover customization settings.',
+ customizediscover: 'Customize Discover',
+ stopediting: 'Stop Editing',
+ createnewslider: 'Create New Slider',
});
const Discover = () => {
const intl = useIntl();
- const { data: discoverData, error: discoverError } = useSWR(
- '/api/v1/settings/discover'
- );
+ const { hasPermission } = useUser();
+ const { addToast } = useToasts();
+ const {
+ data: discoverData,
+ error: discoverError,
+ mutate,
+ } = useSWR('/api/v1/settings/discover');
+ const [sliders, setSliders] = useState[]>([]);
+ const [isEditing, setIsEditing] = useState(false);
+
+ // We need to sync the state here so that we can modify the changes locally without commiting
+ // anything to the server until the user decides to save the changes
+ useEffect(() => {
+ if (discoverData && !isEditing) {
+ setSliders(discoverData);
+ }
+ }, [discoverData, isEditing]);
+
+ const hasChanged = () => !Object.is(discoverData, sliders);
+
+ const updateSliders = async () => {
+ try {
+ await axios.post('/api/v1/settings/discover', sliders);
+
+ addToast(intl.formatMessage(messages.updatesuccess), {
+ appearance: 'success',
+ autoDismiss: true,
+ });
+ setIsEditing(false);
+ mutate();
+ } catch (e) {
+ addToast(intl.formatMessage(messages.updatefailed), {
+ appearance: 'error',
+ autoDismiss: true,
+ });
+ }
+ };
+
+ const resetSliders = async () => {
+ try {
+ await axios.get('/api/v1/settings/discover/reset');
+
+ addToast(intl.formatMessage(messages.resetsuccess), {
+ appearance: 'success',
+ autoDismiss: true,
+ });
+ setIsEditing(false);
+ mutate();
+ } catch (e) {
+ addToast(intl.formatMessage(messages.resetfailed), {
+ appearance: 'error',
+ autoDismiss: true,
+ });
+ }
+ };
if (!discoverData && !discoverError) {
return ;
@@ -34,20 +115,97 @@ const Discover = () => {
return (
<>
- {discoverData?.map((slider) => {
- if (!slider.enabled) {
- return null;
- }
+ {hasPermission(Permission.ADMIN) && (
+ <>
+ {isEditing ? (
+ <>
+
+
+ setIsEditing(false)}
+ >
+
+ {intl.formatMessage(messages.stopediting)}
+
+
+
+
+ resetSliders()}
+ confirmText={intl.formatMessage(
+ globalMessages.areyousure
+ )}
+ >
+
+ {intl.formatMessage(messages.resettodefault)}
+
+
+
+
+ updateSliders()}
+ data-testid="discover-customize-submit"
+ >
+
+ {intl.formatMessage(globalMessages.save)}
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.createnewslider)}
+
+
+
+ {
+ const newSliders = await mutate();
+
+ if (newSliders) {
+ setSliders(newSliders);
+ }
+ }}
+ />
+
+
+ >
+ ) : (
+
+
+ setIsEditing(true)}
+ data-testid="discover-start-editing"
+ >
+
+ {intl.formatMessage(messages.customizediscover)}
+
+
+
+ )}
+ >
+ )}
+ {(isEditing ? sliders : discoverData)?.map((slider, index) => {
+ let sliderComponent: React.ReactNode;
switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED:
- return ;
+ sliderComponent = ;
+ break;
case DiscoverSliderType.RECENT_REQUESTS:
- return ;
+ sliderComponent = ;
+ break;
case DiscoverSliderType.PLEX_WATCHLIST:
- return ;
+ sliderComponent = ;
+ break;
case DiscoverSliderType.TRENDING:
- return (
+ sliderComponent = (
{
linkUrl="/discover/trending"
/>
);
+ break;
case DiscoverSliderType.POPULAR_MOVIES:
- return (
+ sliderComponent = (
{
linkUrl="/discover/movies"
/>
);
+ break;
case DiscoverSliderType.MOVIE_GENRES:
- return ;
+ sliderComponent = ;
+ break;
case DiscoverSliderType.UPCOMING_MOVIES:
- return (
+ sliderComponent = (
{
url="/api/v1/discover/movies/upcoming"
/>
);
+ break;
case DiscoverSliderType.STUDIOS:
- return ;
+ sliderComponent = ;
+ break;
case DiscoverSliderType.POPULAR_TV:
- return (
+ sliderComponent = (
{
linkUrl="/discover/tv"
/>
);
+ break;
case DiscoverSliderType.TV_GENRES:
- return ;
+ sliderComponent = ;
+ break;
case DiscoverSliderType.UPCOMING_TV:
- return (
+ sliderComponent = (
{
linkUrl="/discover/tv/upcoming"
/>
);
+ break;
case DiscoverSliderType.NETWORKS:
- return ;
+ sliderComponent = ;
+ break;
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
- return (
+ sliderComponent = (
{
linkUrl={`/discover/movies/keyword?keywords=${slider.data}`}
/>
);
+ break;
case DiscoverSliderType.TMDB_TV_KEYWORD:
- return (
+ sliderComponent = (
{
linkUrl={`/discover/tv/keyword?keywords=${slider.data}`}
/>
);
+ break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
- return (
+ sliderComponent = (
{
linkUrl={`/discover/movies/genre/${slider.data}`}
/>
);
+ break;
case DiscoverSliderType.TMDB_TV_GENRE:
- return (
+ sliderComponent = (
{
linkUrl={`/discover/tv/genre/${slider.data}`}
/>
);
+ break;
case DiscoverSliderType.TMDB_STUDIO:
- return (
+ sliderComponent = (
{
linkUrl={`/discover/movies/studio/${slider.data}`}
/>
);
+ break;
case DiscoverSliderType.TMDB_NETWORK:
- return (
+ sliderComponent = (
{
linkUrl={`/discover/tv/network/${slider.data}`}
/>
);
+ break;
case DiscoverSliderType.TMDB_SEARCH:
- return (
+ sliderComponent = (
{
linkUrl={`/search?query=${slider.data}`}
/>
);
+ break;
}
+
+ if (isEditing) {
+ return (
+ {
+ 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}
+
+ );
+ }
+
+ if (!slider.enabled) {
+ return null;
+ }
+
+ return (
+
+ {sliderComponent}
+
+ );
})}
>
);
diff --git a/src/components/GenreTag/index.tsx b/src/components/GenreTag/index.tsx
new file mode 100644
index 000000000..bbb25afe0
--- /dev/null
+++ b/src/components/GenreTag/index.tsx
@@ -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(`/api/v1/genres/${type}`);
+
+ if (!data && !error) {
+ return (
+
+
+
+ );
+ }
+
+ const genre = data?.find((genre) => genre.id === genreId);
+
+ return }>{genre?.name};
+};
+
+export default GenreTag;
diff --git a/src/components/KeywordTag/index.tsx b/src/components/KeywordTag/index.tsx
new file mode 100644
index 000000000..8d5065bbd
--- /dev/null
+++ b/src/components/KeywordTag/index.tsx
@@ -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(`/api/v1/keyword/${keywordId}`);
+
+ if (!data && !error) {
+ return (
+
+
+
+ );
+ }
+
+ return {data?.name};
+};
+
+export default KeywordTag;
diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx
index b91c7a2a6..2fd86f6f6 100644
--- a/src/components/MovieDetails/index.tsx
+++ b/src/components/MovieDetails/index.tsx
@@ -462,7 +462,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
key={`keyword-id-${keyword.id}`}
>
-
+ {keyword.name}
))}
diff --git a/src/components/Settings/SettingsMain/DiscoverCustomization/DiscoverOption/index.tsx b/src/components/Settings/SettingsMain/DiscoverCustomization/DiscoverOption/index.tsx
deleted file mode 100644
index 79eff10e9..000000000
--- a/src/components/Settings/SettingsMain/DiscoverCustomization/DiscoverOption/index.tsx
+++ /dev/null
@@ -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(null);
- const [hoverPosition, setHoverPosition] = useState(
- 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 (
-
- {hoverPosition === Position.Above && (
-
- )}
- {hoverPosition === Position.Below && (
-
- )}
-
-
-
-
{title}
- {subtitle &&
{subtitle}}
- {data &&
{data}}
- {!isBuiltIn && (
-
- deleteSlider()}
- >
-
- {intl.formatMessage(messages.remove)}
-
-
- )}
-
-
- {
- onEnable();
- }}
- checked={enabled}
- />
-
-
-
-
- );
-};
-
-export default DiscoverOption;
diff --git a/src/components/Settings/SettingsMain/DiscoverCustomization/index.tsx b/src/components/Settings/SettingsMain/DiscoverCustomization/index.tsx
deleted file mode 100644
index 5af3d1884..000000000
--- a/src/components/Settings/SettingsMain/DiscoverCustomization/index.tsx
+++ /dev/null
@@ -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(
- '/api/v1/settings/discover'
- );
- const [sliders, setSliders] = useState[]>([]);
-
- // 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): 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
- ): 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 ;
- }
-
- return (
- <>
-
-
- {sliders.map((slider, index) => (
- {
- 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);
- }}
- />
- ))}
- {
- mutate();
- }}
- />
-
-
-
-
-
-
- resetSliders()}>
-
- {intl.formatMessage(messages.resettodefault)}
-
-
-
-
- updateSliders()}
- data-testid="discover-customize-submit"
- >
-
- {intl.formatMessage(globalMessages.save)}
-
-
-
-
- >
- );
-};
-
-export default DiscoverCustomization;
diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx
index cf4099887..62f26d49a 100644
--- a/src/components/Settings/SettingsMain/index.tsx
+++ b/src/components/Settings/SettingsMain/index.tsx
@@ -7,7 +7,6 @@ import LanguageSelector from '@app/components/LanguageSelector';
import RegionSelector from '@app/components/RegionSelector';
import CopyButton from '@app/components/Settings/CopyButton';
import SettingsBadge from '@app/components/Settings/SettingsBadge';
-import DiscoverCustomization from '@app/components/Settings/SettingsMain/DiscoverCustomization';
import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale';
@@ -56,9 +55,6 @@ const messages = defineMessages({
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests',
locale: 'Display Language',
- discovercustomization: 'Discover Customization',
- discovercustomizationDescription:
- 'Add or remove sliders on the Discover page.',
});
const SettingsMain = () => {
@@ -454,15 +450,6 @@ const SettingsMain = () => {
}}
-
-
- {intl.formatMessage(messages.discovercustomization)}
-
-
- {intl.formatMessage(messages.discovercustomizationDescription)}
-
-
-
>
);
};
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx
index f704b8e39..14185e879 100644
--- a/src/components/TvDetails/index.tsx
+++ b/src/components/TvDetails/index.tsx
@@ -503,7 +503,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
key={`keyword-id-${keyword.id}`}
>
-
+ {keyword.name}
))}
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json
index d773f17e9..909554224 100644
--- a/src/i18n/locale/en.json
+++ b/src/i18n/locale/en.json
@@ -6,10 +6,35 @@
"components.CollectionDetails.overview": "Overview",
"components.CollectionDetails.requestcollection": "Request Collection",
"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.DiscoverMovieKeyword.keywordMovies": "{keywordTitle} Movies",
"components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies",
"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.DiscoverTvGenre.genreSeries": "{genre} Series",
"components.Discover.DiscoverTvKeyword.keywordSeries": "{keywordTitle} Series",
@@ -25,6 +50,8 @@
"components.Discover.StudioSlider.studios": "Studios",
"components.Discover.TvGenreList.seriesgenres": "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.discovermovies": "Popular Movies",
"components.Discover.discovertv": "Popular Series",
@@ -36,6 +63,11 @@
"components.Discover.populartv": "Popular Series",
"components.Discover.recentlyAdded": "Recently Added",
"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.tmdbmoviegenre": "TMDB Movie Genre",
"components.Discover.tmdbmoviekeyword": "TMDB Movie Keyword",
@@ -49,6 +81,8 @@
"components.Discover.upcoming": "Upcoming Movies",
"components.Discover.upcomingmovies": "Upcoming Movies",
"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.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}",
"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.time": "Timestamp",
"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.applicationTitle": "Application Title",
"components.Settings.SettingsMain.applicationurl": "Application URL",
@@ -740,8 +746,6 @@
"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.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.generalsettings": "General Settings",
"components.Settings.SettingsMain.generalsettingsDescription": "Configure global and default settings for Overseerr.",
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 7e0900e0c..1757f65db 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -68,7 +68,7 @@
}
.slider-header {
- @apply relative mt-6 mb-4 flex;
+ @apply relative mb-4 flex;
}
.slider-title {