diff --git a/cypress/e2e/settings/discover-customization.cy.ts b/cypress/e2e/settings/discover-customization.cy.ts index 8c96b6e3..a0756ae2 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 9a6dc445..d31f4e23 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 faac1b43..8318bbfc 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 7d2a227d..344ce72b 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 1f5756cb..4234da68 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 ( - - ); -}; +
+ {children} +
+
+ {confirmText} +
+ + ); + } +); + +ConfirmButton.displayName = 'ConfirmButton'; export default ConfirmButton; diff --git a/src/components/Common/Tag/index.tsx b/src/components/Common/Tag/index.tsx index 9a24c149..dcb426d4 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 00000000..7c49b8cc --- /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 49ab90cd..3f70c980 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 (
-
- - {intl.formatMessage(messages.addcustomslider)} - +
{options.map((option) => (
)}
-
- {activeOption && values.title && values.data && ( + {activeOption && values.title && values.data && ( +
{ )} onNewTitles={updateResultCount} /> - )} -
+
+ )}
); }} diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx new file mode 100644 index 00000000..6e9bd8b7 --- /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 ? ( + + ) : ( + + )} + + + )} +
+ +
+ { + 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 b2c1a07c..b627ab6e 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 ? ( + <> +
+ + + + + + resetSliders()} + confirmText={intl.formatMessage( + globalMessages.areyousure + )} + > + + {intl.formatMessage(messages.resettodefault)} + + + + + + +
+
+
+ + + {intl.formatMessage(messages.createnewslider)} + +
+
+ { + const newSliders = await mutate(); + + if (newSliders) { + setSliders(newSliders); + } + }} + /> +
+
+ + ) : ( +
+ + + +
+ )} + + )} + {(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 00000000..bbb25afe --- /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 00000000..8d5065bb --- /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 b91c7a2a..2fd86f6f 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 79eff10e..00000000 --- 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 && ( -
- -
- )} - -
- { - 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 5af3d188..00000000 --- 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(); - }} - /> -
-
-
-
- - - - - - - - -
-
- - ); -}; - -export default DiscoverCustomization; diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index cf409988..62f26d49 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 f704b8e3..14185e87 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 d773f17e..90955422 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 7e0900e0..1757f65d 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 {