New: Mass Editor is now part of movie list

Co-Authored-By: Mark McDowall <markus101@users.noreply.github.com>
pull/8414/head
Qstick 1 year ago
parent ee5fed8522
commit e85c010bf2

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

@ -57,7 +57,6 @@ const initialState = {
interface SelectProviderOptions<T extends ModelBase> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: any;
isSelectMode: boolean;
items: Array<T>;
}
@ -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<T extends ModelBase>(
props: SelectProviderOptions<T>
) {
const { isSelectMode, items } = props;
const { items } = props;
const selectedState = getSelectedState(items, {});
const [state, dispatch] = React.useReducer(selectReducer, {
@ -142,12 +141,6 @@ export function SelectProvider<T extends ModelBase>(
const value: [SelectState, Dispatch] = [state, dispatch];
useEffect(() => {
if (!isSelectMode) {
dispatch({ type: SelectActionType.Reset });
}
}, [isSelectMode]);
useEffect(() => {
dispatch({ type: SelectActionType.UpdateItems, items });
}, [items]);

@ -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 (
<div
className={classNames(
@ -19,8 +21,8 @@ function Alert({ className, kind, children, ...otherProps }) {
}
Alert.propTypes = {
className: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all).isRequired,
className: PropTypes.string,
kind: PropTypes.oneOf(kinds.all),
children: PropTypes.node.isRequired
};

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import Link from 'Components/Link/Link';
import { inputTypes } from 'Helpers/Props';
import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AutoCompleteInput from './AutoCompleteInput';
import AvailabilitySelectInput from './AvailabilitySelectInput';
@ -264,12 +264,16 @@ FormInputGroup.propTypes = {
value: PropTypes.any,
values: PropTypes.arrayOf(PropTypes.any),
type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
unit: PropTypes.string,
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
helpText: PropTypes.string,
helpTexts: PropTypes.arrayOf(PropTypes.string),
helpTextWarning: PropTypes.string,
helpLink: PropTypes.string,
includeNoChange: PropTypes.bool,
includeNoChangeDisabled: PropTypes.bool,
selectedValueOptions: PropTypes.object,
pending: PropTypes.bool,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object),

@ -5,14 +5,15 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByName from 'Utilities/Array/sortByName';
import SelectInput from './SelectInput';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.qualityProfiles', sortByName),
(state, { includeNoChange }) => 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 (
<SelectInput
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>

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

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

@ -42,6 +42,7 @@ function SpinnerButton(props) {
}
SpinnerButton.propTypes = {
...Button.Props,
className: PropTypes.string.isRequired,
isSpinning: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool,

@ -8,14 +8,6 @@
@media only screen and (max-width: $breakpointSmall) {
.contentFooter {
display: block;
div {
margin-top: 10px;
&:first-child {
margin-top: 0;
}
}
}
}

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

@ -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 (
<MenuItem key={label} isDisabled={isDisabled || isSpinning} {...otherProps}>
<SpinnerIcon
className={styles.icon}
name={iconName}
spinningName={spinningName}
isSpinning={isSpinning}
/>
{label}
</MenuItem>
);
}
export default PageToolbarOverflowMenuItem;

@ -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 (
<MenuItem
<OverflowComponent
key={label}
isDisabled={isDisabled || isSpinning}
{...otherProps}
>
<SpinnerIcon
className={styles.overflowMenuItemIcon}
name={iconName}
spinningName={spinningName}
isSpinning={isSpinning}
/>
{label}
</MenuItem>
{...item}
/>
);
})
}

@ -21,6 +21,7 @@ function SpinnerIcon(props) {
}
SpinnerIcon.propTypes = {
className: PropTypes.string,
name: PropTypes.object.isRequired,
spinningName: PropTypes.object.isRequired,
isSpinning: PropTypes.bool.isRequired

@ -8,6 +8,7 @@
.body {
overflow: auto;
padding: 10px;
background-color: var(--popoverBodyBackgroundColor);
}
.tooltipBody {

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

@ -14,6 +14,7 @@
&.inverse {
background-color: var(--themeDarkColor);
box-shadow: 0 5px 10px var(--popoverShadowInverseColor);
color: var(--white);
}
}

@ -0,0 +1,8 @@
enum TooltipPosition {
Top = 'top',
Right = 'right',
Bottom = 'bottom',
Left = 'left',
}
export default TooltipPosition;

@ -13,6 +13,7 @@
.contentBody {
composes: contentBody from '~Components/Page/PageContentBody.css';
position: relative;
display: flex;
flex-direction: column;
}

@ -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<HTMLDivElement>();
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [isInteractiveImportModalOpen, setIsInteractiveImportModalOpen] =
useState(false);
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(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 (
<SelectProvider isSelectMode={isSelectMode} items={items}>
<SelectProvider items={items}>
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('UpdateAll')}
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingMovie}
isDisabled={hasNoMovie}
onPress={onRefreshMoviePress}
<MovieIndexRefreshMovieButton
isSelectMode={isSelectMode}
selectedFilterKey={selectedFilterKey}
/>
<PageToolbarButton
@ -225,17 +227,37 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
<PageToolbarSeparator />
<MovieIndexSearchButton
isSelectMode={isSelectMode}
selectedFilterKey={selectedFilterKey}
/>
<PageToolbarButton
label={translate('ManualImport')}
iconName={icons.INTERACTIVE}
isDisabled={hasNoMovie}
onPress={onInteractiveImportPress}
/>
<PageToolbarSeparator />
<MovieIndexSelectModeButton
label={
isSelectMode
? translate('StopSelecting')
: translate('SelectMovie')
: translate('EditMovies')
}
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
iconName={isSelectMode ? icons.SERIES_ENDED : icons.EDIT}
isSelectMode={isSelectMode}
overflowComponent={MovieIndexSelectModeMenuItem}
onPress={onSelectModePress}
/>
{isSelectMode ? <MovieIndexSelectAllButton /> : null}
<MovieIndexSelectAllButton
label="SelectAll"
isSelectMode={isSelectMode}
overflowComponent={MovieIndexSelectAllMenuItem}
/>
</PageToolbarSection>
<PageToolbarSection
@ -326,6 +348,14 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
/>
) : null}
</div>
{isSelectMode ? <MovieIndexSelectFooter /> : null}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
onModalClose={onInteractiveImportModalClose}
/>
{view === 'posters' ? (
<MovieIndexPosterOptionsModal
isOpen={isOptionsModalOpen}

@ -0,0 +1,72 @@
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelect } from 'App/SelectContext';
import { REFRESH_MOVIE } 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 MovieIndexRefreshMovieButtonProps {
isSelectMode: boolean;
selectedFilterKey: string;
}
function MovieIndexRefreshMovieButton(
props: MovieIndexRefreshMovieButtonProps
) {
const isRefreshing = useSelector(
createCommandExecutingSelector(REFRESH_MOVIE)
);
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 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 (
<PageToolbarButton
label={isSelectMode ? refreshSelectLabel : refreshIndexLabel}
isSpinning={isRefreshing}
isDisabled={!totalItems}
iconName={icons.REFRESH}
onPress={onPress}
/>
);
}
export default MovieIndexRefreshMovieButton;

@ -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 (
<PageToolbarButton
label={isSelectMode ? searchSelectLabel : searchIndexLabel}
isSpinning={isSearching}
isDisabled={!totalItems}
iconName={icons.SEARCH}
onPress={onPress}
/>
);
}
export default MovieIndexSearchButton;

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<DeleteMovieModalContent
movieIds={movieIds}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default DeleteMovieModal;

@ -0,0 +1,13 @@
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.pathContainer {
margin-left: 5px;
}
.path {
margin-left: 5px;
color: var(--dangerColor);
}

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

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('DeleteSelectedMovie')}</ModalHeader>
<ModalBody>
<div>
<FormGroup>
<FormLabel>{translate('AddListExclusion')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText={translate('AddImportExclusionHelpText')}
onChange={onDeleteOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{`Delete Movie Folder${
movies.length > 1 ? 's' : ''
}`}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={`Delete Movie Folder${
movies.length > 1 ? 's' : ''
} and all contents`}
kind={kinds.DANGER}
onChange={onDeleteFilesChange}
/>
</FormGroup>
</div>
<div className={styles.message}>
{`Are you sure you want to delete ${movies.length} selected movie(s)${
deleteFiles ? ' and all contents' : ''
}?`}
</div>
<ul>
{movies.map((s) => {
return (
<li key={s.title}>
<span>{s.title}</span>
{deleteFiles && (
<span className={styles.pathContainer}>
-<span className={styles.path}>{s.path}</span>
</span>
)}
</li>
);
})}
</ul>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.DANGER} onPress={onDeleteMoviesConfirmed}>
{translate('Delete')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default DeleteMovieModalContent;

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<EditMoviesModalContent
movieIds={movieIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default EditMoviesModal;

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

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

@ -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<string | number>(
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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedMovies')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('Monitored')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="monitored"
value={monitored}
values={monitoredOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Quality Profile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
includeNoChange={true}
includeNoChangeDisabled={false}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Root Folder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
value={rootFolderPath}
includeNoChange={true}
includeNoChangeDisabled={false}
selectedValueOptions={{ includeFreeSpace: false }}
helpText={
'Moving movies to the same root folder can be used to rename movie folders to match updated title or naming format'
}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('MoviesSelectedInterp', selectedCount.toString())}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={onSavePressWrapper}>
{translate('Apply Changes')}
</Button>
</div>
</ModalFooter>
<MoveMovieModal
isOpen={isConfirmMoveModalOpen}
destinationRootFolder={rootFolderPath}
onSavePress={onDoNotMoveMoviePress}
onMoveMoviePress={onMoveMoviePress}
/>
</ModalContent>
);
}
export default EditMoviesModalContent;

@ -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 ? (
<PageToolbarButton
label={allSelected ? 'Unselect All' : 'Select All'}
iconName={icon}
onPress={onPress}
/>
);
) : null;
}
export default MovieIndexSelectAllButton;

@ -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 ? (
<PageToolbarOverflowMenuItem
label={allSelected ? 'Unselect All' : 'Select All'}
iconName={iconName}
onPress={onPressWrapper}
/>
) : null;
}
export default MovieIndexSelectAllMenuItem;

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

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

@ -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 (
<PageContentFooter className={styles.footer}>
<div className={styles.buttons}>
<div className={styles.actionButtons}>
<SpinnerButton
isSpinning={isSaving && isSavingMovies}
isDisabled={!anySelected || isOrganizingMovies}
onPress={onEditPress}
>
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
kind={kinds.WARNING}
isSpinning={isOrganizingMovies}
isDisabled={!anySelected || isOrganizingMovies}
onPress={onOrganizePress}
>
{translate('Rename Files')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingTags}
isDisabled={!anySelected || isOrganizingMovies}
onPress={onTagsPress}
>
{translate('SetTags')}
</SpinnerButton>
</div>
<div className={styles.deleteButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected || isDeleting}
onPress={onDeletePress}
>
{translate('Delete')}
</SpinnerButton>
</div>
</div>
<div className={styles.selected}>
{translate('MoviesSelectedInterp', selectedCount.toString())}
</div>
<EditMoviesModal
isOpen={isEditModalOpen}
movieIds={movieIds}
onSavePress={onSavePress}
onModalClose={onEditModalClose}
/>
<TagsModal
isOpen={isTagsModalOpen}
movieIds={movieIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<OrganizeMoviesModal
isOpen={isOrganizeModalOpen}
movieIds={movieIds}
onModalClose={onOrganizeModalClose}
/>
<DeleteMovieModal
isOpen={isDeleteModalOpen}
movieIds={movieIds}
onModalClose={onDeleteModalClose}
/>
</PageContentFooter>
);
}
export default MovieIndexSelectFooter;

@ -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 (
<PageToolbarButton
label={label}
iconName={iconName}
onPress={onPressWrapper}
/>
);
}
export default MovieIndexSelectModeButton;

@ -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 (
<PageToolbarOverflowMenuItem
label={label}
iconName={iconName}
onPress={onPressWrapper}
/>
);
}
export default MovieIndexSelectModeMenuItem;

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<OrganizeMoviesModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default OrganizeMoviesModal;

@ -0,0 +1,8 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}

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

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('OrganizeSelectedMovies')}</ModalHeader>
<ModalBody>
<Alert>
{translate('PreviewRenameHelpText')}
<Icon className={styles.renameIcon} name={icons.ORGANIZE} />
</Alert>
<div className={styles.message}>
{translate('OrganizeConfirm', movieTitles.length)}
</div>
<ul>
{movieTitles.map((title) => {
return <li key={title}>{title}</li>;
})}
</ul>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.DANGER} onPress={onOrganizePress}>
{translate('Organize')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default OrganizeMoviesModalContent;

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<TagsModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default TagsModal;

@ -0,0 +1,12 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.result {
padding-top: 4px;
}

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

@ -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<number[]>([]);
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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('Tags')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
value={tags}
onChange={onTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ApplyTags')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="applyTags"
value={applyTags}
values={applyTagsOptions}
helpTexts={[
translate('ApplyTagsHelpTexts1'),
translate('ApplyTagsHelpTexts2'),
translate('ApplyTagsHelpTexts3'),
translate('ApplyTagsHelpTexts4'),
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Result')}</FormLabel>
<div className={styles.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 (
<Label
key={tag.id}
title={
removeTag
? translate('RemovingTag')
: translate('ExistingTag')
}
kind={removeTag ? kinds.INVERSE : kinds.INFO}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
{(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 (
<Label
key={tag.id}
title={translate('AddingTag')}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
</div>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
{translate('Apply')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default TagsModalContent;

@ -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) {
<MovieStatusCell
key={name}
className={styles[name]}
movieId={movieId}
monitored={monitored}
status={status}
isSelectMode={isSelectMode}
isSaving={isSaving}
component={VirtualTableRowCell}
/>
);

@ -6,4 +6,5 @@
.statusIcon {
width: 20px !important;
text-align: center;
}

@ -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 (
<Component className={className} {...otherProps}>
<Icon
className={styles.statusIcon}
name={monitored ? icons.MONITORED : icons.UNMONITORED}
title={
monitored
? translate('MovieIsMonitored')
: translate('MovieIsUnmonitored')
}
/>
{isSelectMode ? (
<MonitorToggleButton
className={styles.statusIcon}
monitored={monitored}
isSaving={isSaving}
onPress={onMonitoredPress}
/>
) : (
<Icon
className={styles.statusIcon}
name={monitored ? icons.MONITORED : icons.UNMONITORED}
title={
monitored
? translate('MovieIsMonitored')
: translate('MovieIsUnmonitored')
}
/>
)}
<Icon
className={styles.statusIcon}

@ -38,6 +38,7 @@ interface Movie extends ModelBase {
certification: string;
tags: number[];
images: Image;
isSaving?: boolean;
}
export default Movie;

@ -1,5 +1,6 @@
import _ from 'lodash';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
// import { batchActions } from 'redux-batched-actions';
@ -7,7 +8,7 @@ import createAjaxRequest from 'Utilities/createAjaxRequest';
import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import { updateItem } from './baseActions';
import { set, updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
@ -245,12 +246,21 @@ export const defaultState = {
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
sortKey: 'sortTitle',
sortDirection: sortDirections.ASCENDING,
pendingChanges: {}
pendingChanges: {},
deleteOptions: {
addImportListExclusion: false
}
};
export const persistState = [
'movies.deleteOptions'
];
//
// Actions Types
@ -258,6 +268,10 @@ export const FETCH_MOVIES = 'movies/fetchMovies';
export const SET_MOVIE_VALUE = 'movies/setMovieValue';
export const SAVE_MOVIE = 'movies/saveMovie';
export const DELETE_MOVIE = 'movies/deleteMovie';
export const SAVE_MOVIE_EDITOR = 'movies/saveMovieEditor';
export const BULK_DELETE_MOVIE = 'movies/bulkDeleteMovie';
export const SET_DELETE_OPTION = 'movies/setDeleteOption';
export const TOGGLE_MOVIE_MONITORED = 'movies/toggleMovieMonitored';
@ -291,6 +305,8 @@ export const deleteMovie = createThunk(DELETE_MOVIE, (payload) => {
});
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);

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

@ -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",

Loading…
Cancel
Save