1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2024-11-23 11:13:34 +01:00

New: Mass Editor is now part of series list

This commit is contained in:
Mark McDowall 2023-01-15 10:47:40 -08:00 committed by Mark McDowall
parent 815a16d5cf
commit a731d24e23
42 changed files with 1455 additions and 63 deletions

View File

@ -1,6 +1,14 @@
.inputContainer { .inputContainer {
margin-right: 20px; margin-right: 20px;
min-width: 150px; min-width: 150px;
div {
margin-top: 10px;
&:first-child {
margin-top: 0;
}
}
} }
.label { .label {
@ -35,3 +43,17 @@
.importError { .importError {
margin-left: 10px; margin-left: 10px;
} }
@media only screen and (max-width: $breakpointSmall) {
.inputContainer {
margin-top: 10px;
&:first-child {
margin-top: 0;
}
}
.importButtonContainer {
margin-top: 10px;
}
}

View File

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

View File

@ -4,7 +4,9 @@ import React from 'react';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import styles from './Alert.css'; import styles from './Alert.css';
function Alert({ className, kind, children, ...otherProps }) { function Alert(props) {
const { className, kind, children, ...otherProps } = props;
return ( return (
<div <div
className={classNames( className={classNames(
@ -19,8 +21,8 @@ function Alert({ className, kind, children, ...otherProps }) {
} }
Alert.propTypes = { Alert.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string,
kind: PropTypes.oneOf(kinds.all).isRequired, kind: PropTypes.oneOf(kinds.all),
children: PropTypes.node.isRequired children: PropTypes.node.isRequired
}; };

View File

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

View File

@ -5,14 +5,15 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByName from 'Utilities/Array/sortByName'; import sortByName from 'Utilities/Array/sortByName';
import SelectInput from './SelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
createSortedSectionSelector('settings.qualityProfiles', sortByName), createSortedSectionSelector('settings.qualityProfiles', sortByName),
(state, { includeNoChange }) => includeNoChange, (state, { includeNoChange }) => includeNoChange,
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(state, { includeMixed }) => includeMixed, (state, { includeMixed }) => includeMixed,
(qualityProfiles, includeNoChange, includeMixed) => { (qualityProfiles, includeNoChange, includeNoChangeDisabled = true, includeMixed) => {
const values = _.map(qualityProfiles.items, (qualityProfile) => { const values = _.map(qualityProfiles.items, (qualityProfile) => {
return { return {
key: qualityProfile.id, key: qualityProfile.id,
@ -24,7 +25,7 @@ function createMapStateToProps() {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: 'No Change', value: 'No Change',
disabled: true disabled: includeNoChangeDisabled
}); });
} }
@ -55,8 +56,8 @@ class QualityProfileSelectInputConnector extends Component {
values values
} = this.props; } = this.props;
if (!value || !_.some(values, (option) => parseInt(option.key) === value)) { if (!value || !values.some((option) => option.key === value || parseInt(option.key) === value)) {
const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key))); const firstValue = values.find((option) => !isNaN(parseInt(option.key)));
if (firstValue) { if (firstValue) {
this.onChange({ name, value: firstValue.key }); this.onChange({ name, value: firstValue.key });
@ -76,7 +77,7 @@ class QualityProfileSelectInputConnector extends Component {
render() { render() {
return ( return (
<SelectInput <EnhancedSelectInput
{...this.props} {...this.props}
onChange={this.onChange} onChange={this.onChange}
/> />

View File

@ -13,7 +13,8 @@ function createMapStateToProps() {
(state, { value }) => value, (state, { value }) => value,
(state, { includeMissingValue }) => includeMissingValue, (state, { includeMissingValue }) => includeMissingValue,
(state, { includeNoChange }) => includeNoChange, (state, { includeNoChange }) => includeNoChange,
(rootFolders, value, includeMissingValue, includeNoChange) => { (state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(rootFolders, value, includeMissingValue, includeNoChange, includeNoChangeDisabled = true) => {
const values = rootFolders.items.map((rootFolder) => { const values = rootFolders.items.map((rootFolder) => {
return { return {
key: rootFolder.path, key: rootFolder.path,
@ -27,7 +28,7 @@ function createMapStateToProps() {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: 'No Change', value: 'No Change',
isDisabled: true, isDisabled: includeNoChangeDisabled,
isMissing: false isMissing: false
}); });
} }

View File

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import * as seriesTypes from 'Utilities/Series/seriesTypes'; import * as seriesTypes from 'Utilities/Series/seriesTypes';
import SelectInput from './SelectInput'; import EnhancedSelectInput from './EnhancedSelectInput';
const seriesTypeOptions = [ const seriesTypeOptions = [
{ key: seriesTypes.STANDARD, value: 'Standard' }, { key: seriesTypes.STANDARD, value: 'Standard' },
@ -14,6 +14,7 @@ function SeriesTypeSelectInput(props) {
const { const {
includeNoChange, includeNoChange,
includeNoChangeDisabled = true,
includeMixed includeMixed
} = props; } = props;
@ -21,7 +22,7 @@ function SeriesTypeSelectInput(props) {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: 'No Change', value: 'No Change',
disabled: true disabled: includeNoChangeDisabled
}); });
} }
@ -34,7 +35,7 @@ function SeriesTypeSelectInput(props) {
} }
return ( return (
<SelectInput <EnhancedSelectInput
{...props} {...props}
values={values} values={values}
/> />
@ -43,6 +44,7 @@ function SeriesTypeSelectInput(props) {
SeriesTypeSelectInput.propTypes = { SeriesTypeSelectInput.propTypes = {
includeNoChange: PropTypes.bool.isRequired, includeNoChange: PropTypes.bool.isRequired,
includeNoChangeDisabled: PropTypes.bool,
includeMixed: PropTypes.bool.isRequired includeMixed: PropTypes.bool.isRequired
}; };

View File

@ -31,6 +31,7 @@ function Label(props) {
Label.propTypes = { Label.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
title: PropTypes.string,
kind: PropTypes.oneOf(kinds.all).isRequired, kind: PropTypes.oneOf(kinds.all).isRequired,
size: PropTypes.oneOf(sizes.all).isRequired, size: PropTypes.oneOf(sizes.all).isRequired,
outline: PropTypes.bool.isRequired, outline: PropTypes.bool.isRequired,

View File

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

View File

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

View File

@ -0,0 +1,3 @@
.icon {
margin-right: 8px;
}

View File

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

View File

@ -4,12 +4,11 @@ import React, { Component } from 'react';
import Measure from 'Components/Measure'; import Measure from 'Components/Measure';
import Menu from 'Components/Menu/Menu'; import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent'; import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
import SpinnerIcon from 'Components/SpinnerIcon';
import { forEach } from 'Helpers/elementChildren'; import { forEach } from 'Helpers/elementChildren';
import { align, icons } from 'Helpers/Props'; import { align, icons } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions'; import dimensions from 'Styles/Variables/dimensions';
import PageToolbarOverflowMenuItem from './PageToolbarOverflowMenuItem';
import styles from './PageToolbarSection.css'; import styles from './PageToolbarSection.css';
const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth); const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
@ -168,28 +167,15 @@ class PageToolbarSection extends Component {
{ {
overflowItems.map((item) => { overflowItems.map((item) => {
const { const {
iconName,
spinningName,
label, label,
isDisabled, overflowComponent: OverflowComponent = PageToolbarOverflowMenuItem
isSpinning,
...otherProps
} = item; } = item;
return ( return (
<MenuItem <OverflowComponent
key={label} key={label}
isDisabled={isDisabled || isSpinning} {...item}
{...otherProps}
>
<SpinnerIcon
className={styles.overflowMenuItemIcon}
name={iconName}
spinningName={spinningName}
isSpinning={isSpinning}
/> />
{label}
</MenuItem>
); );
}) })
} }

View File

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

View File

@ -0,0 +1,24 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DeleteSeriesModalContent from './DeleteSeriesModalContent';
interface DeleteSeriesModalProps {
isOpen: boolean;
seriesIds: number[];
onModalClose(): void;
}
function DeleteSeriesModal(props: DeleteSeriesModalProps) {
const { isOpen, seriesIds, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<DeleteSeriesModalContent
seriesIds={seriesIds}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default DeleteSeriesModal;

View File

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

View File

@ -0,0 +1,154 @@
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 { bulkDeleteSeries, setDeleteOption } from 'Store/Actions/seriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import styles from './DeleteSeriesModalContent.css';
interface DeleteSeriesModalContentProps {
seriesIds: number[];
onModalClose(): void;
}
const selectDeleteOptions = createSelector(
(state) => state.series.deleteOptions,
(deleteOptions) => deleteOptions
);
function DeleteSeriesModalContent(props: DeleteSeriesModalContentProps) {
const { seriesIds, onModalClose } = props;
const { addImportListExclusion } = useSelector(selectDeleteOptions);
const allSeries = useSelector(createAllSeriesSelector());
const dispatch = useDispatch();
const [deleteFiles, setDeleteFiles] = useState(false);
const series = useMemo(() => {
const series = seriesIds.map((id) => {
return allSeries.find((s) => s.id === id);
});
return orderBy(series, ['sortTitle']);
}, [seriesIds, allSeries]);
const onDeleteFilesChange = useCallback(
({ value }) => {
setDeleteFiles(value);
},
[setDeleteFiles]
);
const onDeleteOptionChange = useCallback(
({ name, value }) => {
dispatch(
setDeleteOption({
[name]: value,
})
);
},
[dispatch]
);
const onDeleteSeriesConfirmed = useCallback(() => {
setDeleteFiles(false);
dispatch(
bulkDeleteSeries({
seriesIds,
deleteFiles,
addImportListExclusion,
})
);
onModalClose();
}, [
seriesIds,
deleteFiles,
addImportListExclusion,
setDeleteFiles,
dispatch,
onModalClose,
]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Delete Selected Series</ModalHeader>
<ModalBody>
<div>
<FormGroup>
<FormLabel>Add List Exclusion</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText="Prevent series from being added to Sonarr by lists"
onChange={onDeleteOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{`Delete Series Folder${
series.length > 1 ? 's' : ''
}`}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={`Delete Series Folder${
series.length > 1 ? 's' : ''
} and all contents`}
kind={kinds.DANGER}
onChange={onDeleteFilesChange}
/>
</FormGroup>
</div>
<div className={styles.message}>
{`Are you sure you want to delete ${series.length} selected series${
deleteFiles ? ' and all contents' : ''
}?`}
</div>
<ul>
{series.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}>Cancel</Button>
<Button kind={kinds.DANGER} onPress={onDeleteSeriesConfirmed}>
Delete
</Button>
</ModalFooter>
</ModalContent>
);
}
export default DeleteSeriesModalContent;

View File

@ -0,0 +1,26 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import EditSeriesModalContent from './EditSeriesModalContent';
interface EditSeriesModalProps {
isOpen: boolean;
seriesIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function EditSeriesModal(props: EditSeriesModalProps) {
const { isOpen, seriesIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<EditSeriesModalContent
seriesIds={seriesIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default EditSeriesModal;

View File

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

View File

@ -0,0 +1,246 @@
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 MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal';
import translate from 'Utilities/String/translate';
import styles from './EditSeriesModalContent.css';
interface SavePayload {
monitored?: boolean;
qualityProfileId?: number;
seriesType?: string;
seasonFolder?: boolean;
rootFolderPath?: string;
moveFiles?: boolean;
}
interface EditSeriesModalContentProps {
seriesIds: 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' },
];
const seasonFolderOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'yes', value: 'Yes' },
{ key: 'no', value: 'No' },
];
function EditSeriesModalContent(props: EditSeriesModalContentProps) {
const { seriesIds, onSavePress, onModalClose } = props;
const [monitored, setMonitored] = useState(NO_CHANGE);
const [qualityProfileId, setQualityProfileId] = useState<string | number>(
NO_CHANGE
);
const [seriesType, setSeriesType] = useState(NO_CHANGE);
const [seasonFolder, setSeasonFolder] = 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 (seriesType !== NO_CHANGE) {
hasChanges = true;
payload.seriesType = seriesType;
}
if (seasonFolder !== NO_CHANGE) {
hasChanges = true;
payload.seasonFolder = seasonFolder === 'yes';
}
if (rootFolderPath !== NO_CHANGE) {
hasChanges = true;
payload.rootFolderPath = rootFolderPath;
payload.moveFiles = moveFiles;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
},
[
monitored,
qualityProfileId,
seriesType,
seasonFolder,
rootFolderPath,
onSavePress,
onModalClose,
]
);
const onInputChange = useCallback(
({ name, value }) => {
switch (name) {
case 'monitored':
setMonitored(value);
break;
case 'qualityProfileId':
setQualityProfileId(value);
break;
case 'seriesType':
setSeriesType(value);
break;
case 'seasonFolder':
setSeasonFolder(value);
break;
case 'rootFolderPath':
setRootFolderPath(value);
break;
default:
console.warn('EditSeriesModalContent Unknown Input');
}
},
[setMonitored]
);
const onSavePressWrapper = useCallback(() => {
if (rootFolderPath === NO_CHANGE) {
save(false);
} else {
setIsConfirmMoveModalOpen(true);
}
}, [rootFolderPath, save]);
const onDoNotMoveSeriesPress = useCallback(() => {
setIsConfirmMoveModalOpen(false);
save(false);
}, [setIsConfirmMoveModalOpen, save]);
const onMoveSeriesPress = useCallback(() => {
setIsConfirmMoveModalOpen(false);
save(true);
}, [setIsConfirmMoveModalOpen, save]);
const selectedCount = seriesIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('Edit Selected Series')}</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('Series Type')}</FormLabel>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
value={seriesType}
includeNoChange={true}
includeNoChangeDisabled={false}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Season Folder')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="seasonFolder"
value={seasonFolder}
values={seasonFolderOptions}
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={translate(
'Moving series to the same root folder can be used to rename series folders to match updated title or naming format'
)}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('{count} series selected', { count: selectedCount })}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={onSavePressWrapper}>
{translate('Apply Changes')}
</Button>
</div>
</ModalFooter>
<MoveSeriesModal
isOpen={isConfirmMoveModalOpen}
destinationRootFolder={rootFolderPath}
onSavePress={onDoNotMoveSeriesPress}
onMoveSeriesPress={onMoveSeriesPress}
/>
</ModalContent>
);
}
export default EditSeriesModalContent;

View File

@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import OrganizeSeriesModalContent from './OrganizeSeriesModalContent';
interface OrganizeSeriesModalProps {
isOpen: boolean;
seriesIds: number[];
onModalClose: () => void;
}
function OrganizeSeriesModal(props: OrganizeSeriesModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<OrganizeSeriesModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default OrganizeSeriesModal;

View File

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

View File

@ -0,0 +1,83 @@
import { orderBy } from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RENAME_SERIES } 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 createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import styles from './OrganizeSeriesModalContent.css';
interface OrganizeSeriesModalContentProps {
seriesIds: number[];
onModalClose: () => void;
}
function OrganizeSeriesModalContent(props: OrganizeSeriesModalContentProps) {
const { seriesIds, onModalClose } = props;
const allSeries = useSelector(createAllSeriesSelector());
const dispatch = useDispatch();
const seriesTitles = useMemo(() => {
const series = seriesIds.map((id) => {
return allSeries.find((s) => s.id === id);
});
const sorted = orderBy(series, ['sortTitle']);
return sorted.map((s) => s.title);
}, [seriesIds, allSeries]);
const onOrganizePress = useCallback(() => {
dispatch(
executeCommand({
name: RENAME_SERIES,
seriesIds,
})
);
onModalClose();
}, [seriesIds, onModalClose, dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Organize Selected Series</ModalHeader>
<ModalBody>
<Alert>
Tip: To preview a rename, select "Cancel", then select any series
title and use the
<Icon className={styles.renameIcon} name={icons.ORGANIZE} />
</Alert>
<div className={styles.message}>
Are you sure you want to organize all files in the{' '}
{seriesTitles.length} selected series?
</div>
<ul>
{seriesTitles.map((title) => {
return <li key={title}>{title}</li>;
})}
</ul>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button kind={kinds.DANGER} onPress={onOrganizePress}>
Organize
</Button>
</ModalFooter>
</ModalContent>
);
}
export default OrganizeSeriesModalContent;

View File

@ -3,7 +3,14 @@ import { SelectActionType, useSelect } from 'App/SelectContext';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
function SeriesIndexSelectAllButton() { interface SeriesIndexSelectAllButtonProps {
label: string;
isSelectMode: boolean;
overflowComponent: React.FunctionComponent;
}
function SeriesIndexSelectAllButton(props: SeriesIndexSelectAllButtonProps) {
const { isSelectMode } = props;
const [selectState, selectDispatch] = useSelect(); const [selectState, selectDispatch] = useSelect();
const { allSelected, allUnselected } = selectState; const { allSelected, allUnselected } = selectState;
@ -23,13 +30,13 @@ function SeriesIndexSelectAllButton() {
}); });
}, [allSelected, selectDispatch]); }, [allSelected, selectDispatch]);
return ( return isSelectMode ? (
<PageToolbarButton <PageToolbarButton
label={allSelected ? 'Unselect All' : 'Select All'} label={allSelected ? 'Unselect All' : 'Select All'}
iconName={icon} iconName={icon}
onPress={onPress} onPress={onPress}
/> />
); ) : null;
} }
export default SeriesIndexSelectAllButton; export default SeriesIndexSelectAllButton;

View File

@ -0,0 +1,43 @@
import React, { useCallback } from 'react';
import { SelectActionType, useSelect } from 'App/SelectContext';
import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem';
import { icons } from 'Helpers/Props';
interface SeriesIndexSelectAllMenuItemProps {
label: string;
isSelectMode: boolean;
}
function SeriesIndexSelectAllMenuItem(
props: SeriesIndexSelectAllMenuItemProps
) {
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 SeriesIndexSelectAllMenuItem;

View File

@ -0,0 +1,68 @@
.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,
.deleteButtons {
display: flex;
justify-content: center;
}
.deleteButtons {
margin-left: 0;
}
.selected {
justify-content: center;
order: -1;
}
}

View File

@ -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_SERIES } from 'Commands/commandNames';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import { kinds } from 'Helpers/Props';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { saveSeriesEditor } from 'Store/Actions/seriesActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import DeleteSeriesModal from './Delete/DeleteSeriesModal';
import EditSeriesModal from './Edit/EditSeriesModal';
import OrganizeSeriesModal from './Organize/OrganizeSeriesModal';
import TagsModal from './Tags/TagsModal';
import styles from './SeriesIndexSelectFooter.css';
const seriesEditorSelector = createSelector(
(state) => state.series,
(series) => {
const { isSaving, isDeleting, deleteError } = series;
return {
isSaving,
isDeleting,
deleteError
}
}
);
function SeriesIndexSelectFooter() {
const { isSaving, isDeleting, deleteError } =
useSelector(seriesEditorSelector);
const isOrganizingSeries = useSelector(
createCommandExecutingSelector(RENAME_SERIES)
);
const dispatch = useDispatch();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSavingSeries, setIsSavingSeries] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [selectState, selectDispatch] = useSelect();
const { selectedState } = selectState;
const seriesIds = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = seriesIds.length;
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onSavePress = useCallback(
(payload) => {
setIsSavingSeries(true);
setIsEditModalOpen(false);
dispatch(
saveSeriesEditor({
...payload,
seriesIds,
})
);
},
[seriesIds, 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(
saveSeriesEditor({
seriesIds,
tags,
applyTags,
})
);
},
[seriesIds, dispatch]
);
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, []);
useEffect(() => {
if (!isSaving) {
setIsSavingSeries(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 && isSavingSeries}
isDisabled={!anySelected || isOrganizingSeries}
onPress={onEditPress}
>
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
kind={kinds.WARNING}
isSpinning={isOrganizingSeries}
isDisabled={!anySelected || isOrganizingSeries}
onPress={onOrganizePress}
>
{translate('Rename Files')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingTags}
isDisabled={!anySelected || isOrganizingSeries}
onPress={onTagsPress}
>
{translate('Set Tags')}
</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('{count} series selected', { count: selectedCount })}
</div>
<EditSeriesModal
isOpen={isEditModalOpen}
seriesIds={seriesIds}
onSavePress={onSavePress}
onModalClose={onEditModalClose}
/>
<TagsModal
isOpen={isTagsModalOpen}
seriesIds={seriesIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<OrganizeSeriesModal
isOpen={isOrganizeModalOpen}
seriesIds={seriesIds}
onModalClose={onOrganizeModalClose}
/>
<DeleteSeriesModal
isOpen={isDeleteModalOpen}
seriesIds={seriesIds}
onModalClose={onDeleteModalClose}
/>
</PageContentFooter>
);
}
export default SeriesIndexSelectFooter;

View File

@ -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 SeriesIndexSelectModeButtonProps {
label: string;
iconName: IconDefinition;
isSelectMode: boolean;
overflowComponent: React.FunctionComponent;
onPress: () => void;
}
function SeriesIndexSelectModeButton(props: SeriesIndexSelectModeButtonProps) {
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 SeriesIndexSelectModeButton;

View File

@ -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 SeriesIndexSelectModeMenuItemProps {
label: string;
iconName: IconDefinition;
isSelectMode: boolean;
onPress: () => void;
}
function SeriesIndexSelectModeMenuItem(
props: SeriesIndexSelectModeMenuItemProps
) {
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 SeriesIndexSelectModeMenuItem;

View File

@ -0,0 +1,22 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import TagsModalContent from './TagsModalContent';
interface TagsModalProps {
isOpen: boolean;
seriesIds: 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;

View File

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

View File

@ -0,0 +1,167 @@
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 createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import styles from './TagsModalContent.css';
interface TagsModalContentProps {
seriesIds: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModalContent(props: TagsModalContentProps) {
const { seriesIds, onModalClose, onApplyTagsPress } = props;
const allSeries = useSelector(createAllSeriesSelector());
const tagList = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const seriesTags = useMemo(() => {
const series = seriesIds.map((id) => {
return allSeries.find((s) => s.id === id);
});
return uniq(concat(...series.map((s) => s.tags)));
}, [seriesIds, allSeries]);
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: 'Add' },
{ key: 'remove', value: 'Remove' },
{ key: 'replace', value: 'Replace' },
];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Tags</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
value={tags}
onChange={onTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Apply Tags</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="applyTags"
value={applyTags}
values={applyTagsOptions}
helpTexts={[
'How to apply tags to the selected series',
'Add: Add the tags the existing list of tags',
'Remove: Remove the entered tags',
'Replace: Replace the tags with the entered tags (enter no tags to clear all tags)',
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Result</FormLabel>
<div className={styles.result}>
{seriesTags.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 ? 'Removing tag' : 'Existing tag'}
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 (seriesTags.indexOf(id) > -1) {
return null;
}
return (
<Label
key={tag.id}
title={'Adding tag'}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
</div>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
Apply
</Button>
</ModalFooter>
</ModalContent>
);
}
export default TagsModalContent;

View File

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

View File

@ -34,6 +34,10 @@ import SeriesIndexOverviews from './Overview/SeriesIndexOverviews';
import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal'; import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal';
import SeriesIndexPosters from './Posters/SeriesIndexPosters'; import SeriesIndexPosters from './Posters/SeriesIndexPosters';
import SeriesIndexSelectAllButton from './Select/SeriesIndexSelectAllButton'; import SeriesIndexSelectAllButton from './Select/SeriesIndexSelectAllButton';
import SeriesIndexSelectAllMenuItem from './Select/SeriesIndexSelectAllMenuItem';
import SeriesIndexSelectFooter from './Select/SeriesIndexSelectFooter';
import SeriesIndexSelectModeButton from './Select/SeriesIndexSelectModeButton';
import SeriesIndexSelectModeMenuItem from './Select/SeriesIndexSelectModeMenuItem';
import SeriesIndexFooter from './SeriesIndexFooter'; import SeriesIndexFooter from './SeriesIndexFooter';
import SeriesIndexTable from './Table/SeriesIndexTable'; import SeriesIndexTable from './Table/SeriesIndexTable';
import SeriesIndexTableOptions from './Table/SeriesIndexTableOptions'; import SeriesIndexTableOptions from './Table/SeriesIndexTableOptions';
@ -201,7 +205,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
const hasNoSeries = !totalItems; const hasNoSeries = !totalItems;
return ( return (
<SelectProvider isSelectMode={isSelectMode} items={items}> <SelectProvider items={items}>
<PageContent> <PageContent>
<PageToolbar> <PageToolbar>
<PageToolbarSection> <PageToolbarSection>
@ -224,13 +228,19 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
<PageToolbarSeparator /> <PageToolbarSeparator />
<PageToolbarButton <SeriesIndexSelectModeButton
label={isSelectMode ? 'Stop Selecting' : 'Select Series'} label={isSelectMode ? 'Stop Selecting' : 'Select Series'}
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK} iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
isSelectMode={isSelectMode}
overflowComponent={SeriesIndexSelectModeMenuItem}
onPress={onSelectModePress} onPress={onSelectModePress}
/> />
{isSelectMode ? <SeriesIndexSelectAllButton /> : null} <SeriesIndexSelectAllButton
label="SelectAll"
isSelectMode={isSelectMode}
overflowComponent={SeriesIndexSelectAllMenuItem}
/>
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection <PageToolbarSection
@ -310,7 +320,6 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
<NoSeries totalItems={totalItems} /> <NoSeries totalItems={totalItems} />
) : null} ) : null}
</PageContentBody> </PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? ( {isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar <PageJumpBar
items={jumpBarItems} items={jumpBarItems}
@ -318,6 +327,9 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
/> />
) : null} ) : null}
</div> </div>
{isSelectMode ? <SeriesIndexSelectFooter /> : null}
{view === 'posters' ? ( {view === 'posters' ? (
<SeriesIndexPosterOptionsModal <SeriesIndexPosterOptionsModal
isOpen={isOptionsModalOpen} isOpen={isOptionsModalOpen}

View File

@ -90,6 +90,12 @@
flex: 0 0 100px; flex: 0 0 100px;
} }
.seasonFolder {
composes: cell;
width: 150px;
}
.episodeProgress, .episodeProgress,
.latestSeason { .latestSeason {
composes: cell; composes: cell;

View File

@ -59,6 +59,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
previousAiring, previousAiring,
added, added,
statistics = {}, statistics = {},
seasonFolder,
images, images,
seriesType, seriesType,
network, network,
@ -129,7 +130,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
setIsDeleteSeriesModalOpen(false); setIsDeleteSeriesModalOpen(false);
}, [setIsDeleteSeriesModalOpen]); }, [setIsDeleteSeriesModalOpen]);
const onUseSceneNumberingChange = useCallback(() => { const checkInputCallback = useCallback(() => {
// Mock handler to satisfy `onChange` being required for `CheckInput`. // Mock handler to satisfy `onChange` being required for `CheckInput`.
}, []); }, []);
@ -280,6 +281,19 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
); );
} }
if (name === 'seasonFolder') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<CheckInput
name="seasonFolder"
value={seasonFolder}
isDisabled={true}
onChange={checkInputCallback}
/>
</VirtualTableRowCell>
);
}
if (name === 'episodeProgress') { if (name === 'episodeProgress') {
const progress = episodeCount const progress = episodeCount
? (episodeFileCount / episodeCount) * 100 ? (episodeFileCount / episodeCount) * 100
@ -413,7 +427,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
name="useSceneNumbering" name="useSceneNumbering"
value={useSceneNumbering} value={useSceneNumbering}
isDisabled={true} isDisabled={true}
onChange={onUseSceneNumberingChange} onChange={checkInputCallback}
/> />
</VirtualTableRowCell> </VirtualTableRowCell>
); );

View File

@ -54,6 +54,12 @@
flex: 0 0 100px; flex: 0 0 100px;
} }
.seasonFolder {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 150px;
}
.episodeProgress, .episodeProgress,
.latestSeason { .latestSeason {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';

View File

@ -381,6 +381,8 @@ export const defaultState = {
error: null, error: null,
isSaving: false, isSaving: false,
saveError: null, saveError: null,
isDeleting: false,
deleteError: null,
items: [], items: [],
sortKey: 'sortTitle', sortKey: 'sortTitle',
sortDirection: sortDirections.ASCENDING, sortDirection: sortDirections.ASCENDING,
@ -405,6 +407,8 @@ export const DELETE_SERIES = 'series/deleteSeries';
export const TOGGLE_SERIES_MONITORED = 'series/toggleSeriesMonitored'; export const TOGGLE_SERIES_MONITORED = 'series/toggleSeriesMonitored';
export const TOGGLE_SEASON_MONITORED = 'series/toggleSeasonMonitored'; export const TOGGLE_SEASON_MONITORED = 'series/toggleSeasonMonitored';
export const UPDATE_SERIES_MONITOR = 'series/updateSeriesMonitor'; export const UPDATE_SERIES_MONITOR = 'series/updateSeriesMonitor';
export const SAVE_SERIES_EDITOR = 'series/saveSeriesEditor';
export const BULK_DELETE_SERIES = 'series/bulkDeleteSeries';
export const SET_DELETE_OPTION = 'series/setDeleteOption'; export const SET_DELETE_OPTION = 'series/setDeleteOption';
@ -441,6 +445,8 @@ export const deleteSeries = createThunk(DELETE_SERIES, (payload) => {
export const toggleSeriesMonitored = createThunk(TOGGLE_SERIES_MONITORED); export const toggleSeriesMonitored = createThunk(TOGGLE_SERIES_MONITORED);
export const toggleSeasonMonitored = createThunk(TOGGLE_SEASON_MONITORED); export const toggleSeasonMonitored = createThunk(TOGGLE_SEASON_MONITORED);
export const updateSeriesMonitor = createThunk(UPDATE_SERIES_MONITOR); export const updateSeriesMonitor = createThunk(UPDATE_SERIES_MONITOR);
export const saveSeriesEditor = createThunk(SAVE_SERIES_EDITOR);
export const bulkDeleteSeries = createThunk(BULK_DELETE_SERIES);
export const setSeriesValue = createAction(SET_SERIES_VALUE, (payload) => { export const setSeriesValue = createAction(SET_SERIES_VALUE, (payload) => {
return { return {
@ -659,6 +665,87 @@ export const actionHandlers = handleThunks({
saveError: xhr saveError: xhr
})); }));
}); });
},
[SAVE_SERIES_EDITOR]: function(getState, payload, dispatch) {
dispatch(set({
section,
isSaving: true
}));
const promise = createAjaxRequest({
url: '/series/editor',
method: 'PUT',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done((data) => {
dispatch(batchActions([
...data.map((series) => {
const {
alternateTitles,
images,
rootFolderPath,
statistics,
...propsToUpdate
} = series;
return updateItem({
id: series.id,
section: 'series',
...propsToUpdate
});
}),
set({
section,
isSaving: false,
saveError: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isSaving: false,
saveError: xhr
}));
});
},
[BULK_DELETE_SERIES]: function(getState, payload, dispatch) {
dispatch(set({
section,
isDeleting: true
}));
const promise = createAjaxRequest({
url: '/series/editor',
method: 'DELETE',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done(() => {
// SignaR will take care of removing the series from the collection
dispatch(set({
section,
isDeleting: false,
deleteError: null
}));
});
promise.fail((xhr) => {
dispatch(set({
section,
isDeleting: false,
deleteError: xhr
}));
});
} }
}); });

View File

@ -134,10 +134,19 @@ export const actionHandlers = handleThunks({
promise.done((data) => { promise.done((data) => {
dispatch(batchActions([ dispatch(batchActions([
...data.map((series) => { ...data.map((series) => {
const {
alternateTitles,
images,
rootFolderPath,
statistics,
...propsToUpdate
} = series;
return updateItem({ return updateItem({
id: series.id, id: series.id,
section: 'series', section: 'series',
...series ...propsToUpdate
}); });
}), }),

View File

@ -113,6 +113,12 @@ export const defaultState = {
isSortable: true, isSortable: true,
isVisible: true isVisible: true
}, },
{
name: 'seasonFolder',
label: 'Season Folder',
isSortable: true,
isVisible: false
},
{ {
name: 'episodeProgress', name: 'episodeProgress',
label: 'Episodes', label: 'Episodes',

View File

@ -40,7 +40,7 @@ module.exports = {
themeDarkColor: '#494949', themeDarkColor: '#494949',
themeLightColor: '#595959', themeLightColor: '#595959',
pageBackground: '#202020', pageBackground: '#202020',
pageFooterBackgroud: 'rgba(0, 0, 0, .25)', pageFooterBackground: 'rgba(0, 0, 0, .25)',
torrentColor: '#00853d', torrentColor: '#00853d',
usenetColor: '#17b1d9', usenetColor: '#17b1d9',

View File

@ -42,7 +42,7 @@ module.exports = {
themeDarkColor: '#3a3f51', themeDarkColor: '#3a3f51',
themeLightColor: '#4f566f', themeLightColor: '#4f566f',
pageBackground: '#f5f7fa', pageBackground: '#f5f7fa',
pageFooterBackgroud: '#f1f1f1', pageFooterBackground: '#f1f1f1',
torrentColor: '#00853d', torrentColor: '#00853d',
usenetColor: '#17b1d9', usenetColor: '#17b1d9',