From e85c010bf2815eb33a5465cf0ccabf20c198e13d Mon Sep 17 00:00:00 2001 From: Qstick Date: Sat, 22 Apr 2023 13:52:31 -0500 Subject: [PATCH] New: Mass Editor is now part of movie list Co-Authored-By: Mark McDowall --- .../ImportMovie/Import/ImportMovieFooter.css | 22 ++ frontend/src/App/SelectContext.tsx | 11 +- frontend/src/Components/Alert.js | 8 +- .../src/Components/Form/FormInputGroup.js | 6 +- .../QualityProfileSelectInputConnector.js | 13 +- .../Form/RootFolderSelectInputConnector.js | 5 +- frontend/src/Components/Label.js | 1 + frontend/src/Components/Link/SpinnerButton.js | 1 + .../src/Components/Page/PageContentFooter.css | 8 - .../Toolbar/PageToolbarOverflowMenuItem.css | 3 + .../PageToolbarOverflowMenuItem.css.d.ts | 7 + .../Toolbar/PageToolbarOverflowMenuItem.tsx | 41 ++++ .../Page/Toolbar/PageToolbarSection.js | 24 +- frontend/src/Components/SpinnerIcon.js | 1 + frontend/src/Components/Tooltip/Popover.css | 1 + frontend/src/Components/Tooltip/Popover.js | 8 +- frontend/src/Components/Tooltip/Tooltip.css | 1 + frontend/src/Helpers/Props/TooltipPosition.ts | 8 + frontend/src/Movie/Index/MovieIndex.css | 1 + frontend/src/Movie/Index/MovieIndex.tsx | 76 ++++-- .../Index/MovieIndexRefreshMovieButton.tsx | 72 ++++++ .../Movie/Index/MovieIndexSearchButton.tsx | 68 ++++++ .../Index/Select/Delete/DeleteMovieModal.tsx | 24 ++ .../Select/Delete/DeleteMovieModalContent.css | 13 ++ .../Delete/DeleteMovieModalContent.css.d.ts | 9 + .../Select/Delete/DeleteMovieModalContent.tsx | 155 +++++++++++++ .../Index/Select/Edit/EditMoviesModal.tsx | 26 +++ .../Select/Edit/EditMoviesModalContent.css | 16 ++ .../Edit/EditMoviesModalContent.css.d.ts | 8 + .../Select/Edit/EditMoviesModalContent.tsx | 187 +++++++++++++++ .../Select/MovieIndexSelectAllButton.tsx | 13 +- .../Select/MovieIndexSelectAllMenuItem.tsx | 41 ++++ .../Index/Select/MovieIndexSelectFooter.css | 72 ++++++ .../Select/MovieIndexSelectFooter.css.d.ts | 11 + .../Index/Select/MovieIndexSelectFooter.tsx | 216 ++++++++++++++++++ .../Select/MovieIndexSelectModeButton.tsx | 37 +++ .../Select/MovieIndexSelectModeMenuItem.tsx | 38 +++ .../Select/Organize/OrganizeMoviesModal.tsx | 21 ++ .../Organize/OrganizeMoviesModalContent.css | 8 + .../OrganizeMoviesModalContent.css.d.ts | 8 + .../Organize/OrganizeMoviesModalContent.tsx | 82 +++++++ .../src/Movie/Index/Select/Tags/TagsModal.tsx | 22 ++ .../Index/Select/Tags/TagsModalContent.css | 12 + .../Select/Tags/TagsModalContent.css.d.ts | 9 + .../Index/Select/Tags/TagsModalContent.tsx | 172 ++++++++++++++ .../src/Movie/Index/Table/MovieIndexRow.tsx | 13 +- .../src/Movie/Index/Table/MovieStatusCell.css | 1 + .../src/Movie/Index/Table/MovieStatusCell.tsx | 44 +++- frontend/src/Movie/Movie.ts | 1 + frontend/src/Store/Actions/movieActions.js | 105 ++++++++- .../src/Store/Actions/movieIndexActions.js | 85 ------- src/NzbDrone.Core/Localization/Core/en.json | 5 +- 52 files changed, 1660 insertions(+), 180 deletions(-) create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css.d.ts create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx create mode 100644 frontend/src/Helpers/Props/TooltipPosition.ts create mode 100644 frontend/src/Movie/Index/MovieIndexRefreshMovieButton.tsx create mode 100644 frontend/src/Movie/Index/MovieIndexSearchButton.tsx create mode 100644 frontend/src/Movie/Index/Select/Delete/DeleteMovieModal.tsx create mode 100644 frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.css create mode 100644 frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.css.d.ts create mode 100644 frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.tsx create mode 100644 frontend/src/Movie/Index/Select/Edit/EditMoviesModal.tsx create mode 100644 frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.css create mode 100644 frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.css.d.ts create mode 100644 frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.tsx create mode 100644 frontend/src/Movie/Index/Select/MovieIndexSelectAllMenuItem.tsx create mode 100644 frontend/src/Movie/Index/Select/MovieIndexSelectFooter.css create mode 100644 frontend/src/Movie/Index/Select/MovieIndexSelectFooter.css.d.ts create mode 100644 frontend/src/Movie/Index/Select/MovieIndexSelectFooter.tsx create mode 100644 frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.tsx create mode 100644 frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.tsx create mode 100644 frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModal.tsx create mode 100644 frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.css create mode 100644 frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.css.d.ts create mode 100644 frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.tsx create mode 100644 frontend/src/Movie/Index/Select/Tags/TagsModal.tsx create mode 100644 frontend/src/Movie/Index/Select/Tags/TagsModalContent.css create mode 100644 frontend/src/Movie/Index/Select/Tags/TagsModalContent.css.d.ts create mode 100644 frontend/src/Movie/Index/Select/Tags/TagsModalContent.tsx diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css index 9bfb5a493..415155274 100644 --- a/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovieFooter.css @@ -1,6 +1,14 @@ .inputContainer { margin-right: 20px; min-width: 150px; + + div { + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } } .label { @@ -35,3 +43,17 @@ .importError { margin-left: 10px; } + +@media only screen and (max-width: $breakpointSmall) { + .inputContainer { + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } + + .importButtonContainer { + margin-top: 10px; + } +} diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx index 05ee42791..63c2d3cd3 100644 --- a/frontend/src/App/SelectContext.tsx +++ b/frontend/src/App/SelectContext.tsx @@ -57,7 +57,6 @@ const initialState = { interface SelectProviderOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any children: any; - isSelectMode: boolean; items: Array; } @@ -97,7 +96,7 @@ function selectReducer(state: SelectState, action: SelectAction): SelectState { }; } case SelectActionType.ToggleSelected: { - var result = { + const result = { items, ...toggleSelected( state, @@ -129,7 +128,7 @@ function selectReducer(state: SelectState, action: SelectAction): SelectState { export function SelectProvider( props: SelectProviderOptions ) { - const { isSelectMode, items } = props; + const { items } = props; const selectedState = getSelectedState(items, {}); const [state, dispatch] = React.useReducer(selectReducer, { @@ -142,12 +141,6 @@ export function SelectProvider( const value: [SelectState, Dispatch] = [state, dispatch]; - useEffect(() => { - if (!isSelectMode) { - dispatch({ type: SelectActionType.Reset }); - } - }, [isSelectMode]); - useEffect(() => { dispatch({ type: SelectActionType.UpdateItems, items }); }, [items]); diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js index 10f124c78..418cbf5e6 100644 --- a/frontend/src/Components/Alert.js +++ b/frontend/src/Components/Alert.js @@ -4,7 +4,9 @@ import React from 'react'; import { kinds } from 'Helpers/Props'; import styles from './Alert.css'; -function Alert({ className, kind, children, ...otherProps }) { +function Alert(props) { + const { className, kind, children, ...otherProps } = props; + return (
includeNoChange, + (state, { includeNoChangeDisabled }) => includeNoChangeDisabled, (state, { includeMixed }) => includeMixed, - (qualityProfiles, includeNoChange, includeMixed) => { + (qualityProfiles, includeNoChange, includeNoChangeDisabled = true, includeMixed) => { const values = _.map(qualityProfiles.items, (qualityProfile) => { return { key: qualityProfile.id, @@ -24,7 +25,7 @@ function createMapStateToProps() { values.unshift({ key: 'noChange', value: 'No Change', - disabled: true + disabled: includeNoChangeDisabled }); } @@ -55,8 +56,8 @@ class QualityProfileSelectInputConnector extends Component { values } = this.props; - if (!value || !values.some((v) => v.key === value) ) { - const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key))); + if (!value || !values.some((option) => option.key === value || parseInt(option.key) === value)) { + const firstValue = values.find((option) => !isNaN(parseInt(option.key))); if (firstValue) { this.onChange({ name, value: firstValue.key }); @@ -76,7 +77,7 @@ class QualityProfileSelectInputConnector extends Component { render() { return ( - diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js index e2b7fde26..71afc22c2 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputConnector.js +++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js @@ -13,7 +13,8 @@ function createMapStateToProps() { (state, { value }) => value, (state, { includeMissingValue }) => includeMissingValue, (state, { includeNoChange }) => includeNoChange, - (rootFolders, value, includeMissingValue, includeNoChange) => { + (state, { includeNoChangeDisabled }) => includeNoChangeDisabled, + (rootFolders, value, includeMissingValue, includeNoChange, includeNoChangeDisabled = true) => { const values = rootFolders.items.map((rootFolder) => { return { key: rootFolder.path, @@ -27,7 +28,7 @@ function createMapStateToProps() { values.unshift({ key: 'noChange', value: 'No Change', - isDisabled: true, + isDisabled: includeNoChangeDisabled, isMissing: false }); } diff --git a/frontend/src/Components/Label.js b/frontend/src/Components/Label.js index 26fdf2074..4b565f840 100644 --- a/frontend/src/Components/Label.js +++ b/frontend/src/Components/Label.js @@ -33,6 +33,7 @@ function Label(props) { Label.propTypes = { className: PropTypes.string.isRequired, + title: PropTypes.string, kind: PropTypes.oneOf(kinds.all).isRequired, size: PropTypes.oneOf(sizes.all).isRequired, outline: PropTypes.bool.isRequired, diff --git a/frontend/src/Components/Link/SpinnerButton.js b/frontend/src/Components/Link/SpinnerButton.js index a55455172..09f11499b 100644 --- a/frontend/src/Components/Link/SpinnerButton.js +++ b/frontend/src/Components/Link/SpinnerButton.js @@ -42,6 +42,7 @@ function SpinnerButton(props) { } SpinnerButton.propTypes = { + ...Button.Props, className: PropTypes.string.isRequired, isSpinning: PropTypes.bool.isRequired, isDisabled: PropTypes.bool, diff --git a/frontend/src/Components/Page/PageContentFooter.css b/frontend/src/Components/Page/PageContentFooter.css index 4709af871..61c63064a 100644 --- a/frontend/src/Components/Page/PageContentFooter.css +++ b/frontend/src/Components/Page/PageContentFooter.css @@ -8,14 +8,6 @@ @media only screen and (max-width: $breakpointSmall) { .contentFooter { display: block; - - div { - margin-top: 10px; - - &:first-child { - margin-top: 0; - } - } } } diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css new file mode 100644 index 000000000..b3cae8163 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css @@ -0,0 +1,3 @@ +.icon { + margin-right: 8px; +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css.d.ts b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css.d.ts new file mode 100644 index 000000000..2c598cbee --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'icon': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx new file mode 100644 index 000000000..c97eb2a91 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx @@ -0,0 +1,41 @@ +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import React from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import styles from './PageToolbarOverflowMenuItem.css'; + +interface PageToolbarOverflowMenuItemProps { + iconName: IconDefinition; + spinningName?: IconDefinition; + isDisabled?: boolean; + isSpinning?: boolean; + showIndicator?: boolean; + label: string; + text?: string; + onPress: () => void; +} + +function PageToolbarOverflowMenuItem(props: PageToolbarOverflowMenuItemProps) { + const { + iconName, + spinningName, + label, + isDisabled, + isSpinning = false, + ...otherProps + } = props; + + return ( + + + {label} + + ); +} + +export default PageToolbarOverflowMenuItem; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js index def28d2d4..2d50bab8b 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js @@ -4,13 +4,12 @@ import React, { Component } from 'react'; import Measure from 'Components/Measure'; import Menu from 'Components/Menu/Menu'; import MenuContent from 'Components/Menu/MenuContent'; -import MenuItem from 'Components/Menu/MenuItem'; import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; -import SpinnerIcon from 'Components/SpinnerIcon'; import { forEach } from 'Helpers/elementChildren'; import { align, icons } from 'Helpers/Props'; import dimensions from 'Styles/Variables/dimensions'; import translate from 'Utilities/String/translate'; +import PageToolbarOverflowMenuItem from './PageToolbarOverflowMenuItem'; import styles from './PageToolbarSection.css'; const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth); @@ -169,28 +168,15 @@ class PageToolbarSection extends Component { { overflowItems.map((item) => { const { - iconName, - spinningName, label, - isDisabled, - isSpinning, - ...otherProps + overflowComponent: OverflowComponent = PageToolbarOverflowMenuItem } = item; return ( - - - {label} - + {...item} + /> ); }) } diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js index d21674d9e..5ae03ee66 100644 --- a/frontend/src/Components/SpinnerIcon.js +++ b/frontend/src/Components/SpinnerIcon.js @@ -21,6 +21,7 @@ function SpinnerIcon(props) { } SpinnerIcon.propTypes = { + className: PropTypes.string, name: PropTypes.object.isRequired, spinningName: PropTypes.object.isRequired, isSpinning: PropTypes.bool.isRequired diff --git a/frontend/src/Components/Tooltip/Popover.css b/frontend/src/Components/Tooltip/Popover.css index d392bab46..7d9e9da5d 100644 --- a/frontend/src/Components/Tooltip/Popover.css +++ b/frontend/src/Components/Tooltip/Popover.css @@ -8,6 +8,7 @@ .body { overflow: auto; padding: 10px; + background-color: var(--popoverBodyBackgroundColor); } .tooltipBody { diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js index 9ce73cf08..1fe92fcbf 100644 --- a/frontend/src/Components/Tooltip/Popover.js +++ b/frontend/src/Components/Tooltip/Popover.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; +import { tooltipPositions } from 'Helpers/Props'; import Tooltip from './Tooltip'; import styles from './Popover.css'; @@ -30,8 +31,13 @@ function Popover(props) { } Popover.propTypes = { + className: PropTypes.string, + bodyClassName: PropTypes.string, + anchor: PropTypes.node.isRequired, title: PropTypes.string.isRequired, - body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired + body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + position: PropTypes.oneOf(tooltipPositions.all), + canFlip: PropTypes.bool }; export default Popover; diff --git a/frontend/src/Components/Tooltip/Tooltip.css b/frontend/src/Components/Tooltip/Tooltip.css index 3921a4de2..8ab533d07 100644 --- a/frontend/src/Components/Tooltip/Tooltip.css +++ b/frontend/src/Components/Tooltip/Tooltip.css @@ -14,6 +14,7 @@ &.inverse { background-color: var(--themeDarkColor); box-shadow: 0 5px 10px var(--popoverShadowInverseColor); + color: var(--white); } } diff --git a/frontend/src/Helpers/Props/TooltipPosition.ts b/frontend/src/Helpers/Props/TooltipPosition.ts new file mode 100644 index 000000000..7a9351ac6 --- /dev/null +++ b/frontend/src/Helpers/Props/TooltipPosition.ts @@ -0,0 +1,8 @@ +enum TooltipPosition { + Top = 'top', + Right = 'right', + Bottom = 'bottom', + Left = 'left', +} + +export default TooltipPosition; diff --git a/frontend/src/Movie/Index/MovieIndex.css b/frontend/src/Movie/Index/MovieIndex.css index 9adeaea43..94d428958 100644 --- a/frontend/src/Movie/Index/MovieIndex.css +++ b/frontend/src/Movie/Index/MovieIndex.css @@ -13,6 +13,7 @@ .contentBody { composes: contentBody from '~Components/Page/PageContentBody.css'; + position: relative; display: flex; flex-direction: column; } diff --git a/frontend/src/Movie/Index/MovieIndex.tsx b/frontend/src/Movie/Index/MovieIndex.tsx index 0b49c6699..6a584741a 100644 --- a/frontend/src/Movie/Index/MovieIndex.tsx +++ b/frontend/src/Movie/Index/MovieIndex.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { SelectProvider } from 'App/SelectContext'; -import { REFRESH_MOVIE, RSS_SYNC } from 'Commands/commandNames'; +import { RSS_SYNC } from 'Commands/commandNames'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; @@ -14,6 +14,7 @@ import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptions import withScrollPosition from 'Components/withScrollPosition'; import { align, icons } from 'Helpers/Props'; import SortDirection from 'Helpers/Props/SortDirection'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import NoMovie from 'Movie/NoMovie'; import { executeCommand } from 'Store/Actions/commandActions'; import { @@ -31,11 +32,17 @@ import MovieIndexFilterMenu from './Menus/MovieIndexFilterMenu'; import MovieIndexSortMenu from './Menus/MovieIndexSortMenu'; import MovieIndexViewMenu from './Menus/MovieIndexViewMenu'; import MovieIndexFooter from './MovieIndexFooter'; +import MovieIndexRefreshMovieButton from './MovieIndexRefreshMovieButton'; +import MovieIndexSearchButton from './MovieIndexSearchButton'; import MovieIndexOverviews from './Overview/MovieIndexOverviews'; import MovieIndexOverviewOptionsModal from './Overview/Options/MovieIndexOverviewOptionsModal'; import MovieIndexPosters from './Posters/MovieIndexPosters'; import MovieIndexPosterOptionsModal from './Posters/Options/MovieIndexPosterOptionsModal'; import MovieIndexSelectAllButton from './Select/MovieIndexSelectAllButton'; +import MovieIndexSelectAllMenuItem from './Select/MovieIndexSelectAllMenuItem'; +import MovieIndexSelectFooter from './Select/MovieIndexSelectFooter'; +import MovieIndexSelectModeButton from './Select/MovieIndexSelectModeButton'; +import MovieIndexSelectModeMenuItem from './Select/MovieIndexSelectModeMenuItem'; import MovieIndexTable from './Table/MovieIndexTable'; import MovieIndexTableOptions from './Table/MovieIndexTableOptions'; import styles from './MovieIndex.css'; @@ -72,9 +79,6 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => { view, } = useSelector(createMovieClientSideCollectionItemsSelector('movieIndex')); - const isRefreshingMovie = useSelector( - createCommandExecutingSelector(REFRESH_MOVIE) - ); const isRssSyncExecuting = useSelector( createCommandExecutingSelector(RSS_SYNC) ); @@ -82,17 +86,11 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => { const dispatch = useDispatch(); const scrollerRef = useRef(); const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + const [isInteractiveImportModalOpen, setIsInteractiveImportModalOpen] = + useState(false); const [jumpToCharacter, setJumpToCharacter] = useState(null); const [isSelectMode, setIsSelectMode] = useState(false); - const onRefreshMoviePress = useCallback(() => { - dispatch( - executeCommand({ - name: REFRESH_MOVIE, - }) - ); - }, [dispatch]); - const onSelectModePress = useCallback(() => { setIsSelectMode(!isSelectMode); }, [isSelectMode, setIsSelectMode]); @@ -145,6 +143,14 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => { setIsOptionsModalOpen(false); }, [setIsOptionsModalOpen]); + const onInteractiveImportPress = useCallback(() => { + setIsInteractiveImportModalOpen(true); + }, [setIsInteractiveImportModalOpen]); + + const onInteractiveImportModalClose = useCallback(() => { + setIsInteractiveImportModalOpen(false); + }, [setIsInteractiveImportModalOpen]); + const onJumpBarItemPress = useCallback( (character) => { setJumpToCharacter(character); @@ -202,17 +208,13 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => { const hasNoMovie = !totalItems; return ( - + - { + + + + + + - {isSelectMode ? : null} + { /> ) : null}
+ + {isSelectMode ? : null} + + + {view === 'posters' ? ( { + return getSelectedIds(selectedState); + }, [selectedState]); + + const moviesToRefresh = + isSelectMode && selectedMovieIds.length > 0 + ? selectedMovieIds + : items.map((m) => m.id); + + const refreshIndexLabel = + selectedFilterKey === 'all' + ? translate('UpdateAll') + : translate('UpdateFiltered'); + + const refreshSelectLabel = + selectedMovieIds.length > 0 + ? translate('UpdateSelected') + : translate('UpdateAll'); + + const onPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_MOVIE, + movieIds: moviesToRefresh, + }) + ); + }, [dispatch, moviesToRefresh]); + + return ( + + ); +} + +export default MovieIndexRefreshMovieButton; diff --git a/frontend/src/Movie/Index/MovieIndexSearchButton.tsx b/frontend/src/Movie/Index/MovieIndexSearchButton.tsx new file mode 100644 index 000000000..1a35ef669 --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndexSearchButton.tsx @@ -0,0 +1,68 @@ +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useSelect } from 'App/SelectContext'; +import { MOVIE_SEARCH } from 'Commands/commandNames'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createMovieClientSideCollectionItemsSelector from 'Store/Selectors/createMovieClientSideCollectionItemsSelector'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; + +interface MovieIndexSearchButtonProps { + isSelectMode: boolean; + selectedFilterKey: string; +} + +function MovieIndexSearchButton(props: MovieIndexSearchButtonProps) { + const isSearching = useSelector(createCommandExecutingSelector(MOVIE_SEARCH)); + const { items, totalItems } = useSelector( + createMovieClientSideCollectionItemsSelector('movieIndex') + ); + + const dispatch = useDispatch(); + const { isSelectMode, selectedFilterKey } = props; + const [selectState] = useSelect(); + const { selectedState } = selectState; + + const selectedMovieIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const moviesToSearch = + isSelectMode && selectedMovieIds.length > 0 + ? selectedMovieIds + : items.map((m) => m.id); + + const searchIndexLabel = + selectedFilterKey === 'all' + ? translate('SearchAll') + : translate('SearchFiltered'); + + const searchSelectLabel = + selectedMovieIds.length > 0 + ? translate('SearchSelected') + : translate('SearchAll'); + + const onPress = useCallback(() => { + dispatch( + executeCommand({ + name: MOVIE_SEARCH, + movieIds: moviesToSearch, + }) + ); + }, [dispatch, moviesToSearch]); + + return ( + + ); +} + +export default MovieIndexSearchButton; diff --git a/frontend/src/Movie/Index/Select/Delete/DeleteMovieModal.tsx b/frontend/src/Movie/Index/Select/Delete/DeleteMovieModal.tsx new file mode 100644 index 000000000..91dd16191 --- /dev/null +++ b/frontend/src/Movie/Index/Select/Delete/DeleteMovieModal.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DeleteMovieModalContent from './DeleteMovieModalContent'; + +interface DeleteMovieModalProps { + isOpen: boolean; + movieIds: number[]; + onModalClose(): void; +} + +function DeleteMovieModal(props: DeleteMovieModalProps) { + const { isOpen, movieIds, onModalClose } = props; + + return ( + + + + ); +} + +export default DeleteMovieModal; diff --git a/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.css b/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.css new file mode 100644 index 000000000..02a0514be --- /dev/null +++ b/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.css @@ -0,0 +1,13 @@ +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.pathContainer { + margin-left: 5px; +} + +.path { + margin-left: 5px; + color: var(--dangerColor); +} diff --git a/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.css.d.ts b/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.css.d.ts new file mode 100644 index 000000000..bcc2e2492 --- /dev/null +++ b/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; + 'path': string; + 'pathContainer': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.tsx b/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.tsx new file mode 100644 index 000000000..cfaa1bc17 --- /dev/null +++ b/frontend/src/Movie/Index/Select/Delete/DeleteMovieModalContent.tsx @@ -0,0 +1,155 @@ +import { orderBy } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { bulkDeleteMovie, setDeleteOption } from 'Store/Actions/movieActions'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import translate from 'Utilities/String/translate'; +import styles from './DeleteMovieModalContent.css'; + +interface DeleteMovieModalContentProps { + movieIds: number[]; + onModalClose(): void; +} + +const selectDeleteOptions = createSelector( + (state) => state.movie.deleteOptions, + (deleteOptions) => deleteOptions +); + +function DeleteMovieModalContent(props: DeleteMovieModalContentProps) { + const { movieIds, onModalClose } = props; + + const { addImportListExclusion } = useSelector(selectDeleteOptions); + const allMovies = useSelector(createAllMoviesSelector()); + const dispatch = useDispatch(); + + const [deleteFiles, setDeleteFiles] = useState(false); + + const movies = useMemo(() => { + const movies = movieIds.map((id) => { + return allMovies.find((s) => s.id === id); + }); + + return orderBy(movies, ['sortTitle']); + }, [movieIds, allMovies]); + + const onDeleteFilesChange = useCallback( + ({ value }) => { + setDeleteFiles(value); + }, + [setDeleteFiles] + ); + + const onDeleteOptionChange = useCallback( + ({ name, value }) => { + dispatch( + setDeleteOption({ + [name]: value, + }) + ); + }, + [dispatch] + ); + + const onDeleteMoviesConfirmed = useCallback(() => { + setDeleteFiles(false); + + dispatch( + bulkDeleteMovie({ + movieIds, + deleteFiles, + addImportListExclusion, + }) + ); + + onModalClose(); + }, [ + movieIds, + deleteFiles, + addImportListExclusion, + setDeleteFiles, + dispatch, + onModalClose, + ]); + + return ( + + {translate('DeleteSelectedMovie')} + + +
+ + {translate('AddListExclusion')} + + + + + + {`Delete Movie Folder${ + movies.length > 1 ? 's' : '' + }`} + + 1 ? 's' : '' + } and all contents`} + kind={kinds.DANGER} + onChange={onDeleteFilesChange} + /> + +
+ +
+ {`Are you sure you want to delete ${movies.length} selected movie(s)${ + deleteFiles ? ' and all contents' : '' + }?`} +
+ +
    + {movies.map((s) => { + return ( +
  • + {s.title} + + {deleteFiles && ( + + -{s.path} + + )} +
  • + ); + })} +
+
+ + + + + + +
+ ); +} + +export default DeleteMovieModalContent; diff --git a/frontend/src/Movie/Index/Select/Edit/EditMoviesModal.tsx b/frontend/src/Movie/Index/Select/Edit/EditMoviesModal.tsx new file mode 100644 index 000000000..9c0331f36 --- /dev/null +++ b/frontend/src/Movie/Index/Select/Edit/EditMoviesModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditMoviesModalContent from './EditMoviesModalContent'; + +interface EditMoviesModalProps { + isOpen: boolean; + movieIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function EditMoviesModal(props: EditMoviesModalProps) { + const { isOpen, movieIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default EditMoviesModal; diff --git a/frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.css b/frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.css.d.ts b/frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.tsx b/frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.tsx new file mode 100644 index 000000000..98bfbb5fc --- /dev/null +++ b/frontend/src/Movie/Index/Select/Edit/EditMoviesModalContent.tsx @@ -0,0 +1,187 @@ +import React, { useCallback, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import MoveMovieModal from 'Movie/MoveMovie/MoveMovieModal'; +import translate from 'Utilities/String/translate'; +import styles from './EditMoviesModalContent.css'; + +interface SavePayload { + monitored?: boolean; + qualityProfileId?: number; + rootFolderPath?: string; + moveFiles?: boolean; +} + +interface EditMoviesModalContentProps { + movieIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const monitoredOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'monitored', value: 'Monitored' }, + { key: 'unmonitored', value: 'Unmonitored' }, +]; + +function EditMoviesModalContent(props: EditMoviesModalContentProps) { + const { movieIds, onSavePress, onModalClose } = props; + + const [monitored, setMonitored] = useState(NO_CHANGE); + const [qualityProfileId, setQualityProfileId] = useState( + NO_CHANGE + ); + const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE); + const [isConfirmMoveModalOpen, setIsConfirmMoveModalOpen] = useState(false); + + const save = useCallback( + (moveFiles) => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (monitored !== NO_CHANGE) { + hasChanges = true; + payload.monitored = monitored === 'monitored'; + } + + if (qualityProfileId !== NO_CHANGE) { + hasChanges = true; + payload.qualityProfileId = qualityProfileId as number; + } + + if (rootFolderPath !== NO_CHANGE) { + hasChanges = true; + payload.rootFolderPath = rootFolderPath; + payload.moveFiles = moveFiles; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, + [monitored, qualityProfileId, rootFolderPath, onSavePress, onModalClose] + ); + + const onInputChange = useCallback( + ({ name, value }) => { + switch (name) { + case 'monitored': + setMonitored(value); + break; + case 'qualityProfileId': + setQualityProfileId(value); + break; + case 'rootFolderPath': + setRootFolderPath(value); + break; + default: + console.warn('EditMoviesModalContent Unknown Input'); + } + }, + [setMonitored] + ); + + const onSavePressWrapper = useCallback(() => { + if (rootFolderPath === NO_CHANGE) { + save(false); + } else { + setIsConfirmMoveModalOpen(true); + } + }, [rootFolderPath, save]); + + const onDoNotMoveMoviePress = useCallback(() => { + setIsConfirmMoveModalOpen(false); + save(false); + }, [setIsConfirmMoveModalOpen, save]); + + const onMoveMoviePress = useCallback(() => { + setIsConfirmMoveModalOpen(false); + save(true); + }, [setIsConfirmMoveModalOpen, save]); + + const selectedCount = movieIds.length; + + return ( + + {translate('EditSelectedMovies')} + + + + {translate('Monitored')} + + + + + + {translate('Quality Profile')} + + + + + + {translate('Root Folder')} + + + + + + +
+ {translate('MoviesSelectedInterp', selectedCount.toString())} +
+ +
+ + + +
+
+ + +
+ ); +} + +export default EditMoviesModalContent; diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.tsx index 50f74adc7..7860814b3 100644 --- a/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.tsx +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectAllButton.tsx @@ -3,7 +3,14 @@ import { SelectActionType, useSelect } from 'App/SelectContext'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import { icons } from 'Helpers/Props'; -function MovieIndexSelectAllButton() { +interface MovieIndexSelectAllButtonProps { + label: string; + isSelectMode: boolean; + overflowComponent: React.FunctionComponent; +} + +function MovieIndexSelectAllButton(props: MovieIndexSelectAllButtonProps) { + const { isSelectMode } = props; const [selectState, selectDispatch] = useSelect(); const { allSelected, allUnselected } = selectState; @@ -23,13 +30,13 @@ function MovieIndexSelectAllButton() { }); }, [allSelected, selectDispatch]); - return ( + return isSelectMode ? ( - ); + ) : null; } export default MovieIndexSelectAllButton; diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectAllMenuItem.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectAllMenuItem.tsx new file mode 100644 index 000000000..64b03bdae --- /dev/null +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectAllMenuItem.tsx @@ -0,0 +1,41 @@ +import React, { useCallback } from 'react'; +import { SelectActionType, useSelect } from 'App/SelectContext'; +import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; +import { icons } from 'Helpers/Props'; + +interface MovieIndexSelectAllMenuItemProps { + label: string; + isSelectMode: boolean; +} + +function MovieIndexSelectAllMenuItem(props: MovieIndexSelectAllMenuItemProps) { + const { isSelectMode } = props; + const [selectState, selectDispatch] = useSelect(); + const { allSelected, allUnselected } = selectState; + + let iconName = icons.SQUARE_MINUS; + + if (allSelected) { + iconName = icons.CHECK_SQUARE; + } else if (allUnselected) { + iconName = icons.SQUARE; + } + + const onPressWrapper = useCallback(() => { + selectDispatch({ + type: allSelected + ? SelectActionType.UnselectAll + : SelectActionType.SelectAll, + }); + }, [allSelected, selectDispatch]); + + return isSelectMode ? ( + + ) : null; +} + +export default MovieIndexSelectAllMenuItem; diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.css b/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.css new file mode 100644 index 000000000..d385923ef --- /dev/null +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.css @@ -0,0 +1,72 @@ +.footer { + composes: contentFooter from '~Components/Page/PageContentFooter.css'; + + align-items: center; +} + +.buttons { + display: flex; +} + +.actionButtons, +.deleteButtons { + display: flex; + gap: 10px; +} + +.deleteButtons { + margin-left: 50px; +} + +.selected { + display: flex; + justify-content: flex-end; + flex-grow: 1; + font-weight: bold; +} + +@media only screen and (max-width: $breakpointMedium) { + .buttons { + justify-content: center; + width: 100%; + } + + .selected { + justify-content: center; + margin-bottom: 20px; + width: 100%; + order: -1; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .footer { + display: flex; + flex-direction: column; + } + + .buttons { + flex-direction: column; + margin-top: 20px; + gap: 20px; + } + + .actionButtons { + flex-wrap: wrap; + } + + .actionButtons, + .deleteButtons { + display: flex; + justify-content: center; + } + + .deleteButtons { + margin-left: 0; + } + + .selected { + justify-content: center; + order: -1; + } +} diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.css.d.ts b/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.css.d.ts new file mode 100644 index 000000000..7f02229e3 --- /dev/null +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actionButtons': string; + 'buttons': string; + 'deleteButtons': string; + 'footer': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.tsx new file mode 100644 index 000000000..8de28027f --- /dev/null +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectFooter.tsx @@ -0,0 +1,216 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import { SelectActionType, useSelect } from 'App/SelectContext'; +import { RENAME_MOVIE } from 'Commands/commandNames'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import { kinds } from 'Helpers/Props'; +import { saveMovieEditor } from 'Store/Actions/movieActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import DeleteMovieModal from './Delete/DeleteMovieModal'; +import EditMoviesModal from './Edit/EditMoviesModal'; +import OrganizeMoviesModal from './Organize/OrganizeMoviesModal'; +import TagsModal from './Tags/TagsModal'; +import styles from './MovieIndexSelectFooter.css'; + +const movieEditorSelector = createSelector( + (state) => state.movies, + (movies) => { + const { isSaving, isDeleting, deleteError } = movies; + + return { + isSaving, + isDeleting, + deleteError, + }; + } +); + +function MovieIndexSelectFooter() { + const { isSaving, isDeleting, deleteError } = + useSelector(movieEditorSelector); + + const isOrganizingMovies = useSelector( + createCommandExecutingSelector(RENAME_MOVIE) + ); + + const dispatch = useDispatch(); + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false); + const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isSavingMovies, setIsSavingMovies] = useState(false); + const [isSavingTags, setIsSavingTags] = useState(false); + + const [selectState, selectDispatch] = useSelect(); + const { selectedState } = selectState; + + const movieIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = movieIds.length ? movieIds.length : 0; + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onSavePress = useCallback( + (payload) => { + setIsSavingMovies(true); + setIsEditModalOpen(false); + + dispatch( + saveMovieEditor({ + ...payload, + movieIds, + }) + ); + }, + [movieIds, dispatch] + ); + + const onOrganizePress = useCallback(() => { + setIsOrganizeModalOpen(true); + }, [setIsOrganizeModalOpen]); + + const onOrganizeModalClose = useCallback(() => { + setIsOrganizeModalOpen(false); + }, [setIsOrganizeModalOpen]); + + const onTagsPress = useCallback(() => { + setIsTagsModalOpen(true); + }, [setIsTagsModalOpen]); + + const onTagsModalClose = useCallback(() => { + setIsTagsModalOpen(false); + }, [setIsTagsModalOpen]); + + const onApplyTagsPress = useCallback( + (tags, applyTags) => { + setIsSavingTags(true); + setIsTagsModalOpen(false); + + dispatch( + saveMovieEditor({ + movieIds, + tags, + applyTags, + }) + ); + }, + [movieIds, dispatch] + ); + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, []); + + useEffect(() => { + if (!isSaving) { + setIsSavingMovies(false); + setIsSavingTags(false); + } + }, [isSaving]); + + useEffect(() => { + if (!isDeleting && !deleteError) { + selectDispatch({ type: SelectActionType.UnselectAll }); + } + }, [isDeleting, deleteError, selectDispatch]); + + useEffect(() => { + dispatch(fetchRootFolders()); + }, [dispatch]); + + const anySelected = selectedCount > 0; + + return ( + +
+
+ + {translate('Edit')} + + + + {translate('Rename Files')} + + + + {translate('SetTags')} + +
+ +
+ + {translate('Delete')} + +
+
+ +
+ {translate('MoviesSelectedInterp', selectedCount.toString())} +
+ + + + + + + + +
+ ); +} + +export default MovieIndexSelectFooter; diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.tsx new file mode 100644 index 000000000..9eff931cc --- /dev/null +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectModeButton.tsx @@ -0,0 +1,37 @@ +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import React, { useCallback } from 'react'; +import { SelectActionType, useSelect } from 'App/SelectContext'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; + +interface MovieIndexSelectModeButtonProps { + label: string; + iconName: IconDefinition; + isSelectMode: boolean; + overflowComponent: React.FunctionComponent; + onPress: () => void; +} + +function MovieIndexSelectModeButton(props: MovieIndexSelectModeButtonProps) { + const { label, iconName, isSelectMode, onPress } = props; + const [, selectDispatch] = useSelect(); + + const onPressWrapper = useCallback(() => { + if (isSelectMode) { + selectDispatch({ + type: SelectActionType.Reset, + }); + } + + onPress(); + }, [isSelectMode, onPress, selectDispatch]); + + return ( + + ); +} + +export default MovieIndexSelectModeButton; diff --git a/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.tsx b/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.tsx new file mode 100644 index 000000000..7113e12c2 --- /dev/null +++ b/frontend/src/Movie/Index/Select/MovieIndexSelectModeMenuItem.tsx @@ -0,0 +1,38 @@ +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import React, { useCallback } from 'react'; +import { SelectActionType, useSelect } from 'App/SelectContext'; +import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; + +interface MovieIndexSelectModeMenuItemProps { + label: string; + iconName: IconDefinition; + isSelectMode: boolean; + onPress: () => void; +} + +function MovieIndexSelectModeMenuItem( + props: MovieIndexSelectModeMenuItemProps +) { + const { label, iconName, isSelectMode, onPress } = props; + const [, selectDispatch] = useSelect(); + + const onPressWrapper = useCallback(() => { + if (isSelectMode) { + selectDispatch({ + type: SelectActionType.Reset, + }); + } + + onPress(); + }, [isSelectMode, onPress, selectDispatch]); + + return ( + + ); +} + +export default MovieIndexSelectModeMenuItem; diff --git a/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModal.tsx b/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModal.tsx new file mode 100644 index 000000000..8851b296c --- /dev/null +++ b/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import OrganizeMoviesModalContent from './OrganizeMoviesModalContent'; + +interface OrganizeMoviesModalProps { + isOpen: boolean; + movieIds: number[]; + onModalClose: () => void; +} + +function OrganizeMoviesModal(props: OrganizeMoviesModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default OrganizeMoviesModal; diff --git a/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.css b/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.css new file mode 100644 index 000000000..0b896f4ef --- /dev/null +++ b/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.css @@ -0,0 +1,8 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} diff --git a/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.css.d.ts b/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.css.d.ts new file mode 100644 index 000000000..ae2303476 --- /dev/null +++ b/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; + 'renameIcon': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.tsx b/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.tsx new file mode 100644 index 000000000..c1dde17a1 --- /dev/null +++ b/frontend/src/Movie/Index/Select/Organize/OrganizeMoviesModalContent.tsx @@ -0,0 +1,82 @@ +import { orderBy } from 'lodash'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RENAME_MOVIE } from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { icons, kinds } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import translate from 'Utilities/String/translate'; +import styles from './OrganizeMoviesModalContent.css'; + +interface OrganizeMoviesModalContentProps { + movieIds: number[]; + onModalClose: () => void; +} + +function OrganizeMoviesModalContent(props: OrganizeMoviesModalContentProps) { + const { movieIds, onModalClose } = props; + + const allMovies = useSelector(createAllMoviesSelector()); + const dispatch = useDispatch(); + + const movieTitles = useMemo(() => { + const movies = movieIds.map((id) => { + return allMovies.find((s) => s.id === id); + }); + + const sorted = orderBy(movies, ['sortTitle']); + + return sorted.map((s) => s.title); + }, [movieIds, allMovies]); + + const onOrganizePress = useCallback(() => { + dispatch( + executeCommand({ + name: RENAME_MOVIE, + movieIds, + }) + ); + + onModalClose(); + }, [movieIds, onModalClose, dispatch]); + + return ( + + {translate('OrganizeSelectedMovies')} + + + + {translate('PreviewRenameHelpText')} + + + +
+ {translate('OrganizeConfirm', movieTitles.length)} +
+ +
    + {movieTitles.map((title) => { + return
  • {title}
  • ; + })} +
+
+ + + + + + +
+ ); +} + +export default OrganizeMoviesModalContent; diff --git a/frontend/src/Movie/Index/Select/Tags/TagsModal.tsx b/frontend/src/Movie/Index/Select/Tags/TagsModal.tsx new file mode 100644 index 000000000..5ddcd515b --- /dev/null +++ b/frontend/src/Movie/Index/Select/Tags/TagsModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContent from './TagsModalContent'; + +interface TagsModalProps { + isOpen: boolean; + movieIds: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModal(props: TagsModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Movie/Index/Select/Tags/TagsModalContent.css b/frontend/src/Movie/Index/Select/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Movie/Index/Select/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Movie/Index/Select/Tags/TagsModalContent.css.d.ts b/frontend/src/Movie/Index/Select/Tags/TagsModalContent.css.d.ts new file mode 100644 index 000000000..9b4321dcc --- /dev/null +++ b/frontend/src/Movie/Index/Select/Tags/TagsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; + 'renameIcon': string; + 'result': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Movie/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Movie/Index/Select/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..85933c973 --- /dev/null +++ b/frontend/src/Movie/Index/Select/Tags/TagsModalContent.tsx @@ -0,0 +1,172 @@ +import { concat, uniq } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Label from 'Components/Label'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import translate from 'Utilities/String/translate'; +import styles from './TagsModalContent.css'; + +interface TagsModalContentProps { + movieIds: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModalContent(props: TagsModalContentProps) { + const { movieIds, onModalClose, onApplyTagsPress } = props; + + const allMovies = useSelector(createAllMoviesSelector()); + const tagList = useSelector(createTagsSelector()); + + const [tags, setTags] = useState([]); + const [applyTags, setApplyTags] = useState('add'); + + const movieTags = useMemo(() => { + const movies = movieIds.map((id) => { + return allMovies.find((s) => s.id === id); + }); + + return uniq(concat(...movies.map((s) => s.tags))); + }, [movieIds, allMovies]); + + const onTagsChange = useCallback( + ({ value }) => { + setTags(value); + }, + [setTags] + ); + + const onApplyTagsChange = useCallback( + ({ value }) => { + setApplyTags(value); + }, + [setApplyTags] + ); + + const onApplyPress = useCallback(() => { + onApplyTagsPress(tags, applyTags); + }, [tags, applyTags, onApplyTagsPress]); + + const applyTagsOptions = [ + { key: 'add', value: translate('Add') }, + { key: 'remove', value: translate('Remove') }, + { key: 'replace', value: translate('Replace') }, + ]; + + return ( + + {translate('Tags')} + + +
+ + {translate('Tags')} + + + + + + {translate('ApplyTags')} + + + + + + {translate('Result')} + +
+ {movieTags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + const removeTag = + (applyTags === 'remove' && tags.indexOf(id) > -1) || + (applyTags === 'replace' && tags.indexOf(id) === -1); + + return ( + + ); + })} + + {(applyTags === 'add' || applyTags === 'replace') && + tags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + if (movieTags.indexOf(id) > -1) { + return null; + } + + return ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.tsx b/frontend/src/Movie/Index/Table/MovieIndexRow.tsx index 774e081a7..a7e4ad5d5 100644 --- a/frontend/src/Movie/Index/Table/MovieIndexRow.tsx +++ b/frontend/src/Movie/Index/Table/MovieIndexRow.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { SelectActionType, useSelect } from 'App/SelectContext'; import { MOVIE_SEARCH, REFRESH_MOVIE } from 'Commands/commandNames'; import Icon from 'Components/Icon'; import ImdbRating from 'Components/ImdbRating'; @@ -13,7 +14,7 @@ import Column from 'Components/Table/Column'; import TagListConnector from 'Components/TagListConnector'; import TmdbRating from 'Components/TmdbRating'; import Tooltip from 'Components/Tooltip/Tooltip'; -import { icons } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; @@ -28,7 +29,6 @@ import translate from 'Utilities/String/translate'; import MovieStatusCell from './MovieStatusCell'; import selectTableOptions from './selectTableOptions'; import styles from './MovieIndexRow.css'; -import { SelectActionType, useSelect } from 'App/SelectContext'; interface MovieIndexRowProps { movieId: number; @@ -62,16 +62,16 @@ function MovieIndexRow(props: MovieIndexRowProps) { minimumAvailability, path, sizeOnDisk, - genres, + genres = [], queueStatus, queueState, ratings, certification, - tags, + tags = [], tmdbId, imdbId, youTubeTrailerId, - kinds, + isSaving = false, movieRuntimeFormat, } = movie; @@ -150,8 +150,11 @@ function MovieIndexRow(props: MovieIndexRowProps) { ); diff --git a/frontend/src/Movie/Index/Table/MovieStatusCell.css b/frontend/src/Movie/Index/Table/MovieStatusCell.css index fbcd5eee9..304d06c34 100644 --- a/frontend/src/Movie/Index/Table/MovieStatusCell.css +++ b/frontend/src/Movie/Index/Table/MovieStatusCell.css @@ -6,4 +6,5 @@ .statusIcon { width: 20px !important; + text-align: center; } diff --git a/frontend/src/Movie/Index/Table/MovieStatusCell.tsx b/frontend/src/Movie/Index/Table/MovieStatusCell.tsx index c155336b6..457fd42f4 100644 --- a/frontend/src/Movie/Index/Table/MovieStatusCell.tsx +++ b/frontend/src/Movie/Index/Table/MovieStatusCell.tsx @@ -1,40 +1,64 @@ -import React, { Component } from 'react'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; import Icon from 'Components/Icon'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; import { icons } from 'Helpers/Props'; import { getMovieStatusDetails } from 'Movie/MovieStatus'; +import { toggleMovieMonitored } from 'Store/Actions/movieActions'; import translate from 'Utilities/String/translate'; import styles from './MovieStatusCell.css'; interface MovieStatusCellProps { className: string; + movieId: number; monitored: boolean; status: string; + isSelectMode: boolean; + isSaving: boolean; component?: React.ElementType; } function MovieStatusCell(props: MovieStatusCellProps) { const { className, + movieId, monitored, status, + isSelectMode, + isSaving, component: Component = VirtualTableRowCell, ...otherProps } = props; const statusDetails = getMovieStatusDetails(status); + const dispatch = useDispatch(); + + const onMonitoredPress = useCallback(() => { + dispatch(toggleMovieMonitored({ movieId, monitored: !monitored })); + }, [movieId, monitored, dispatch]); + return ( - + {isSelectMode ? ( + + ) : ( + + )} { }); export const toggleMovieMonitored = createThunk(TOGGLE_MOVIE_MONITORED); +export const saveMovieEditor = createThunk(SAVE_MOVIE_EDITOR); +export const bulkDeleteMovie = createThunk(BULK_DELETE_MOVIE); export const setMovieValue = createAction(SET_MOVIE_VALUE, (payload) => { return { @@ -299,6 +315,8 @@ export const setMovieValue = createAction(SET_MOVIE_VALUE, (payload) => { }; }); +export const setDeleteOption = createAction(SET_DELETE_OPTION); + // // Helpers @@ -359,8 +377,79 @@ export const actionHandlers = handleThunks({ isSaving: false })); }); - } + }, + + [SAVE_MOVIE_EDITOR]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/movie/editor', + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(batchActions([ + ...data.map((movie) => { + return updateItem({ + id: movie.id, + section: 'movies', + ...movie + }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }, + + [BULK_DELETE_MOVIE]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isDeleting: true + })); + const promise = createAjaxRequest({ + url: '/movie/editor', + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done(() => { + // SignaR will take care of removing the movie from the collection + + dispatch(set({ + section, + isDeleting: false, + deleteError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + } }); // @@ -368,6 +457,14 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ - [SET_MOVIE_VALUE]: createSetSettingValueReducer(section) + [SET_MOVIE_VALUE]: createSetSettingValueReducer(section), + [SET_DELETE_OPTION]: (state, { payload }) => { + return { + ...state, + deleteOptions: { + ...payload + } + }; + } }, defaultState, section); diff --git a/frontend/src/Store/Actions/movieIndexActions.js b/frontend/src/Store/Actions/movieIndexActions.js index ec243aa8c..bd9c62b0e 100644 --- a/frontend/src/Store/Actions/movieIndexActions.js +++ b/frontend/src/Store/Actions/movieIndexActions.js @@ -1,11 +1,7 @@ import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; -import { createThunk, handleThunks } from 'Store/thunks'; import sortByName from 'Utilities/Array/sortByName'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; import translate from 'Utilities/String/translate'; -import { set, updateItem } from './baseActions'; import createHandleActions from './Creators/createHandleActions'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; @@ -496,8 +492,6 @@ export const SET_MOVIE_VIEW = 'movieIndex/setMovieView'; export const SET_MOVIE_TABLE_OPTION = 'movieIndex/setMovieTableOption'; export const SET_MOVIE_POSTER_OPTION = 'movieIndex/setMoviePosterOption'; export const SET_MOVIE_OVERVIEW_OPTION = 'movieIndex/setMovieOverviewOption'; -export const SAVE_MOVIE_EDITOR = 'movieIndex/saveMovieEditor'; -export const BULK_DELETE_MOVIE = 'movieIndex/bulkDeleteMovie'; // // Action Creators @@ -508,85 +502,6 @@ export const setMovieView = createAction(SET_MOVIE_VIEW); export const setMovieTableOption = createAction(SET_MOVIE_TABLE_OPTION); export const setMoviePosterOption = createAction(SET_MOVIE_POSTER_OPTION); export const setMovieOverviewOption = createAction(SET_MOVIE_OVERVIEW_OPTION); -export const saveMovieEditor = createThunk(SAVE_MOVIE_EDITOR); -export const bulkDeleteMovie = createThunk(BULK_DELETE_MOVIE); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - [SAVE_MOVIE_EDITOR]: function(getState, payload, dispatch) { - dispatch(set({ - section, - isSaving: true - })); - - const promise = createAjaxRequest({ - url: '/movie/editor', - method: 'PUT', - data: JSON.stringify(payload), - dataType: 'json' - }).request; - - promise.done((data) => { - dispatch(batchActions([ - ...data.map((movie) => { - return updateItem({ - id: movie.id, - section: 'movies', - ...movie - }); - }), - - set({ - section, - isSaving: false, - saveError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isSaving: false, - saveError: xhr - })); - }); - }, - - [BULK_DELETE_MOVIE]: function(getState, payload, dispatch) { - dispatch(set({ - section, - isDeleting: true - })); - - const promise = createAjaxRequest({ - url: '/movie/editor', - method: 'DELETE', - data: JSON.stringify(payload), - dataType: 'json' - }).request; - - promise.done(() => { - // SignaR will take care of removing the movie from the collection - - dispatch(set({ - section, - isDeleting: false, - deleteError: null - })); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isDeleting: false, - deleteError: xhr - })); - }); - } -}); // // Reducers diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 8cd92fdac..e530428ac 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -286,10 +286,12 @@ "EditListExclusion": "Edit List Exclusion", "EditMovie": "Edit Movie", "EditMovieFile": "Edit Movie File", + "EditMovies": "Edit Movies", "EditPerson": "Edit Person", "EditQualityProfile": "Edit Quality Profile", "EditRemotePathMapping": "Edit Remote Path Mapping", "EditRestriction": "Edit Restriction", + "EditSelectedMovies": "Edit Selected Movies", "Enable": "Enable", "EnableAutoHelpText": "If enabled, Movies will be automatically added to Radarr from this list", "EnableAutomaticAdd": "Enable Automatic Add", @@ -997,6 +999,7 @@ "StartTypingOrSelectAPathBelow": "Start typing or select a path below", "StartupDirectory": "Startup directory", "Status": "Status", + "StopSelecting": "Stop Selecting", "Studio": "Studio", "Style": "Style", "SubfolderWillBeCreatedAutomaticallyInterp": "'{0}' subfolder will be created automatically", @@ -1081,7 +1084,6 @@ "UnableToLoadLanguages": "Unable to load languages", "UnableToLoadListExclusions": "Unable to load List Exclusions", "UnableToLoadListOptions": "Unable to load list options", - "StopSelecting": "Stop Selecting", "UnableToLoadLists": "Unable to load Lists", "UnableToLoadManualImportItems": "Unable to load manual import items", "UnableToLoadMediaManagementSettings": "Unable to load Media Management settings", @@ -1116,6 +1118,7 @@ "UpdateCheckStartupNotWritableMessage": "Cannot install update because startup folder '{0}' is not writable by the user '{1}'.", "UpdateCheckStartupTranslocationMessage": "Cannot install update because startup folder '{0}' is in an App Translocation folder.", "UpdateCheckUINotWritableMessage": "Cannot install update because UI folder '{0}' is not writable by the user '{1}'.", + "UpdateFiltered": "Update Filtered", "UpdateMechanismHelpText": "Use Radarr's built-in updater or a script", "Updates": "Updates", "UpdateScriptPathHelpText": "Path to a custom script that takes an extracted update package and handle the remainder of the update process",