From a731d24e23b83484da1376d331b2ce998e216690 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 15 Jan 2023 10:47:40 -0800 Subject: [PATCH] New: Mass Editor is now part of series list --- .../Import/ImportSeriesFooter.css | 22 ++ frontend/src/App/SelectContext.tsx | 11 +- frontend/src/Components/Alert.js | 8 +- .../src/Components/Form/FormInputGroup.js | 6 +- .../QualityProfileSelectInputConnector.js | 13 +- .../Form/RootFolderSelectInputConnector.js | 5 +- .../Components/Form/SeriesTypeSelectInput.js | 8 +- frontend/src/Components/Label.js | 1 + frontend/src/Components/Link/SpinnerButton.js | 1 + .../src/Components/Page/PageContentFooter.css | 8 - .../Toolbar/PageToolbarOverflowMenuItem.css | 3 + .../Toolbar/PageToolbarOverflowMenuItem.tsx | 41 +++ .../Page/Toolbar/PageToolbarSection.js | 24 +- frontend/src/Components/SpinnerIcon.js | 1 + .../Index/Select/Delete/DeleteSeriesModal.tsx | 24 ++ .../Delete/DeleteSeriesModalContent.css | 13 + .../Delete/DeleteSeriesModalContent.tsx | 154 +++++++++++ .../Index/Select/Edit/EditSeriesModal.tsx | 26 ++ .../Select/Edit/EditSeriesModalContent.css | 16 ++ .../Select/Edit/EditSeriesModalContent.tsx | 246 ++++++++++++++++++ .../Select/Organize/OrganizeSeriesModal.tsx | 21 ++ .../Organize/OrganizeSeriesModalContent.css | 8 + .../Organize/OrganizeSeriesModalContent.tsx | 83 ++++++ .../Select/SeriesIndexSelectAllButton.tsx | 13 +- .../Select/SeriesIndexSelectAllMenuItem.tsx | 43 +++ .../Index/Select/SeriesIndexSelectFooter.css | 68 +++++ .../Index/Select/SeriesIndexSelectFooter.tsx | 216 +++++++++++++++ .../Select/SeriesIndexSelectModeButton.tsx | 37 +++ .../Select/SeriesIndexSelectModeMenuItem.tsx | 38 +++ .../Series/Index/Select/Tags/TagsModal.tsx | 22 ++ .../Index/Select/Tags/TagsModalContent.css | 12 + .../Index/Select/Tags/TagsModalContent.tsx | 167 ++++++++++++ frontend/src/Series/Index/SeriesIndex.css | 1 + frontend/src/Series/Index/SeriesIndex.tsx | 20 +- .../src/Series/Index/Table/SeriesIndexRow.css | 6 + .../src/Series/Index/Table/SeriesIndexRow.tsx | 18 +- .../Index/Table/SeriesIndexTableHeader.css | 6 + frontend/src/Store/Actions/seriesActions.js | 87 +++++++ .../src/Store/Actions/seriesEditorActions.js | 11 +- .../src/Store/Actions/seriesIndexActions.js | 6 + frontend/src/Styles/Themes/dark.js | 2 +- frontend/src/Styles/Themes/light.js | 2 +- 42 files changed, 1455 insertions(+), 63 deletions(-) create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css create mode 100644 frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx create mode 100644 frontend/src/Series/Index/Select/Delete/DeleteSeriesModal.tsx create mode 100644 frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.css create mode 100644 frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.tsx create mode 100644 frontend/src/Series/Index/Select/Edit/EditSeriesModal.tsx create mode 100644 frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.css create mode 100644 frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.tsx create mode 100644 frontend/src/Series/Index/Select/Organize/OrganizeSeriesModal.tsx create mode 100644 frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.css create mode 100644 frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.tsx create mode 100644 frontend/src/Series/Index/Select/SeriesIndexSelectAllMenuItem.tsx create mode 100644 frontend/src/Series/Index/Select/SeriesIndexSelectFooter.css create mode 100644 frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx create mode 100644 frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx create mode 100644 frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx create mode 100644 frontend/src/Series/Index/Select/Tags/TagsModal.tsx create mode 100644 frontend/src/Series/Index/Select/Tags/TagsModalContent.css create mode 100644 frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css index 9bfb5a493..415155274 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css @@ -1,6 +1,14 @@ .inputContainer { margin-right: 20px; min-width: 150px; + + div { + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } } .label { @@ -35,3 +43,17 @@ .importError { margin-left: 10px; } + +@media only screen and (max-width: $breakpointSmall) { + .inputContainer { + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } + + .importButtonContainer { + margin-top: 10px; + } +} diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx index 05ee42791..63c2d3cd3 100644 --- a/frontend/src/App/SelectContext.tsx +++ b/frontend/src/App/SelectContext.tsx @@ -57,7 +57,6 @@ const initialState = { interface SelectProviderOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any children: any; - isSelectMode: boolean; items: Array; } @@ -97,7 +96,7 @@ function selectReducer(state: SelectState, action: SelectAction): SelectState { }; } case SelectActionType.ToggleSelected: { - var result = { + const result = { items, ...toggleSelected( state, @@ -129,7 +128,7 @@ function selectReducer(state: SelectState, action: SelectAction): SelectState { export function SelectProvider( props: SelectProviderOptions ) { - const { isSelectMode, items } = props; + const { items } = props; const selectedState = getSelectedState(items, {}); const [state, dispatch] = React.useReducer(selectReducer, { @@ -142,12 +141,6 @@ export function SelectProvider( const value: [SelectState, Dispatch] = [state, dispatch]; - useEffect(() => { - if (!isSelectMode) { - dispatch({ type: SelectActionType.Reset }); - } - }, [isSelectMode]); - useEffect(() => { dispatch({ type: SelectActionType.UpdateItems, items }); }, [items]); diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js index 10f124c78..418cbf5e6 100644 --- a/frontend/src/Components/Alert.js +++ b/frontend/src/Components/Alert.js @@ -4,7 +4,9 @@ import React from 'react'; import { kinds } from 'Helpers/Props'; import styles from './Alert.css'; -function Alert({ className, kind, children, ...otherProps }) { +function Alert(props) { + const { className, kind, children, ...otherProps } = props; + return (
includeNoChange, + (state, { includeNoChangeDisabled }) => includeNoChangeDisabled, (state, { includeMixed }) => includeMixed, - (qualityProfiles, includeNoChange, includeMixed) => { + (qualityProfiles, includeNoChange, includeNoChangeDisabled = true, includeMixed) => { const values = _.map(qualityProfiles.items, (qualityProfile) => { return { key: qualityProfile.id, @@ -24,7 +25,7 @@ function createMapStateToProps() { values.unshift({ key: 'noChange', value: 'No Change', - disabled: true + disabled: includeNoChangeDisabled }); } @@ -55,8 +56,8 @@ class QualityProfileSelectInputConnector extends Component { values } = this.props; - if (!value || !_.some(values, (option) => parseInt(option.key) === value)) { - const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key))); + if (!value || !values.some((option) => option.key === value || parseInt(option.key) === value)) { + const firstValue = values.find((option) => !isNaN(parseInt(option.key))); if (firstValue) { this.onChange({ name, value: firstValue.key }); @@ -76,7 +77,7 @@ class QualityProfileSelectInputConnector extends Component { render() { return ( - diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js index f61fed78a..11a00841c 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputConnector.js +++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js @@ -13,7 +13,8 @@ function createMapStateToProps() { (state, { value }) => value, (state, { includeMissingValue }) => includeMissingValue, (state, { includeNoChange }) => includeNoChange, - (rootFolders, value, includeMissingValue, includeNoChange) => { + (state, { includeNoChangeDisabled }) => includeNoChangeDisabled, + (rootFolders, value, includeMissingValue, includeNoChange, includeNoChangeDisabled = true) => { const values = rootFolders.items.map((rootFolder) => { return { key: rootFolder.path, @@ -27,7 +28,7 @@ function createMapStateToProps() { values.unshift({ key: 'noChange', value: 'No Change', - isDisabled: true, + isDisabled: includeNoChangeDisabled, isMissing: false }); } diff --git a/frontend/src/Components/Form/SeriesTypeSelectInput.js b/frontend/src/Components/Form/SeriesTypeSelectInput.js index 5c9ad0cac..202523e42 100644 --- a/frontend/src/Components/Form/SeriesTypeSelectInput.js +++ b/frontend/src/Components/Form/SeriesTypeSelectInput.js @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import * as seriesTypes from 'Utilities/Series/seriesTypes'; -import SelectInput from './SelectInput'; +import EnhancedSelectInput from './EnhancedSelectInput'; const seriesTypeOptions = [ { key: seriesTypes.STANDARD, value: 'Standard' }, @@ -14,6 +14,7 @@ function SeriesTypeSelectInput(props) { const { includeNoChange, + includeNoChangeDisabled = true, includeMixed } = props; @@ -21,7 +22,7 @@ function SeriesTypeSelectInput(props) { values.unshift({ key: 'noChange', value: 'No Change', - disabled: true + disabled: includeNoChangeDisabled }); } @@ -34,7 +35,7 @@ function SeriesTypeSelectInput(props) { } return ( - @@ -43,6 +44,7 @@ function SeriesTypeSelectInput(props) { SeriesTypeSelectInput.propTypes = { includeNoChange: PropTypes.bool.isRequired, + includeNoChangeDisabled: PropTypes.bool, includeMixed: PropTypes.bool.isRequired }; diff --git a/frontend/src/Components/Label.js b/frontend/src/Components/Label.js index 6f662ec7d..844da8165 100644 --- a/frontend/src/Components/Label.js +++ b/frontend/src/Components/Label.js @@ -31,6 +31,7 @@ function Label(props) { Label.propTypes = { className: PropTypes.string.isRequired, + title: PropTypes.string, kind: PropTypes.oneOf(kinds.all).isRequired, size: PropTypes.oneOf(sizes.all).isRequired, outline: PropTypes.bool.isRequired, diff --git a/frontend/src/Components/Link/SpinnerButton.js b/frontend/src/Components/Link/SpinnerButton.js index a55455172..09f11499b 100644 --- a/frontend/src/Components/Link/SpinnerButton.js +++ b/frontend/src/Components/Link/SpinnerButton.js @@ -42,6 +42,7 @@ function SpinnerButton(props) { } SpinnerButton.propTypes = { + ...Button.Props, className: PropTypes.string.isRequired, isSpinning: PropTypes.bool.isRequired, isDisabled: PropTypes.bool, diff --git a/frontend/src/Components/Page/PageContentFooter.css b/frontend/src/Components/Page/PageContentFooter.css index 4709af871..61c63064a 100644 --- a/frontend/src/Components/Page/PageContentFooter.css +++ b/frontend/src/Components/Page/PageContentFooter.css @@ -8,14 +8,6 @@ @media only screen and (max-width: $breakpointSmall) { .contentFooter { display: block; - - div { - margin-top: 10px; - - &:first-child { - margin-top: 0; - } - } } } diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css new file mode 100644 index 000000000..b3cae8163 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css @@ -0,0 +1,3 @@ +.icon { + margin-right: 8px; +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx new file mode 100644 index 000000000..c97eb2a91 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx @@ -0,0 +1,41 @@ +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import React from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import styles from './PageToolbarOverflowMenuItem.css'; + +interface PageToolbarOverflowMenuItemProps { + iconName: IconDefinition; + spinningName?: IconDefinition; + isDisabled?: boolean; + isSpinning?: boolean; + showIndicator?: boolean; + label: string; + text?: string; + onPress: () => void; +} + +function PageToolbarOverflowMenuItem(props: PageToolbarOverflowMenuItemProps) { + const { + iconName, + spinningName, + label, + isDisabled, + isSpinning = false, + ...otherProps + } = props; + + return ( + + + {label} + + ); +} + +export default PageToolbarOverflowMenuItem; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js index d64d11435..2d4aca718 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js @@ -4,12 +4,11 @@ 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 PageToolbarOverflowMenuItem from './PageToolbarOverflowMenuItem'; import styles from './PageToolbarSection.css'; const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth); @@ -168,28 +167,15 @@ class PageToolbarSection extends Component { { overflowItems.map((item) => { const { - iconName, - spinningName, label, - isDisabled, - isSpinning, - ...otherProps + overflowComponent: OverflowComponent = PageToolbarOverflowMenuItem } = item; return ( - - - {label} - + {...item} + /> ); }) } diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js index d21674d9e..5ae03ee66 100644 --- a/frontend/src/Components/SpinnerIcon.js +++ b/frontend/src/Components/SpinnerIcon.js @@ -21,6 +21,7 @@ function SpinnerIcon(props) { } SpinnerIcon.propTypes = { + className: PropTypes.string, name: PropTypes.object.isRequired, spinningName: PropTypes.object.isRequired, isSpinning: PropTypes.bool.isRequired diff --git a/frontend/src/Series/Index/Select/Delete/DeleteSeriesModal.tsx b/frontend/src/Series/Index/Select/Delete/DeleteSeriesModal.tsx new file mode 100644 index 000000000..146dc111e --- /dev/null +++ b/frontend/src/Series/Index/Select/Delete/DeleteSeriesModal.tsx @@ -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 ( + + + + ); +} + +export default DeleteSeriesModal; diff --git a/frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.css b/frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.css new file mode 100644 index 000000000..02a0514be --- /dev/null +++ b/frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.css @@ -0,0 +1,13 @@ +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.pathContainer { + margin-left: 5px; +} + +.path { + margin-left: 5px; + color: var(--dangerColor); +} diff --git a/frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.tsx b/frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.tsx new file mode 100644 index 000000000..5fc392d21 --- /dev/null +++ b/frontend/src/Series/Index/Select/Delete/DeleteSeriesModalContent.tsx @@ -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 ( + + Delete Selected Series + + +
+ + Add List Exclusion + + + + + + {`Delete Series Folder${ + series.length > 1 ? 's' : '' + }`} + + 1 ? 's' : '' + } and all contents`} + kind={kinds.DANGER} + onChange={onDeleteFilesChange} + /> + +
+ +
+ {`Are you sure you want to delete ${series.length} selected series${ + deleteFiles ? ' and all contents' : '' + }?`} +
+ +
    + {series.map((s) => { + return ( +
  • + {s.title} + + {deleteFiles && ( + + -{s.path} + + )} +
  • + ); + })} +
+
+ + + + + + +
+ ); +} + +export default DeleteSeriesModalContent; diff --git a/frontend/src/Series/Index/Select/Edit/EditSeriesModal.tsx b/frontend/src/Series/Index/Select/Edit/EditSeriesModal.tsx new file mode 100644 index 000000000..80807f2e1 --- /dev/null +++ b/frontend/src/Series/Index/Select/Edit/EditSeriesModal.tsx @@ -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 ( + + + + ); +} + +export default EditSeriesModal; diff --git a/frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.css b/frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.tsx b/frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.tsx new file mode 100644 index 000000000..677790d77 --- /dev/null +++ b/frontend/src/Series/Index/Select/Edit/EditSeriesModalContent.tsx @@ -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( + 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 ( + + {translate('Edit Selected Series')} + + + + {translate('Monitored')} + + + + + + {translate('Quality Profile')} + + + + + + {translate('Series Type')} + + + + + + {translate('Season Folder')} + + + + + + {translate('Root Folder')} + + + + + + +
+ {translate('{count} series selected', { count: selectedCount })} +
+ +
+ + + +
+
+ + +
+ ); +} + +export default EditSeriesModalContent; diff --git a/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModal.tsx b/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModal.tsx new file mode 100644 index 000000000..087037002 --- /dev/null +++ b/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModal.tsx @@ -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 ( + + + + ); +} + +export default OrganizeSeriesModal; diff --git a/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.css b/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.css new file mode 100644 index 000000000..0b896f4ef --- /dev/null +++ b/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.css @@ -0,0 +1,8 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} diff --git a/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.tsx b/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.tsx new file mode 100644 index 000000000..f706f2032 --- /dev/null +++ b/frontend/src/Series/Index/Select/Organize/OrganizeSeriesModalContent.tsx @@ -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 ( + + Organize Selected Series + + + + Tip: To preview a rename, select "Cancel", then select any series + title and use the + + + +
+ Are you sure you want to organize all files in the{' '} + {seriesTitles.length} selected series? +
+ +
    + {seriesTitles.map((title) => { + return
  • {title}
  • ; + })} +
+
+ + + + + + +
+ ); +} + +export default OrganizeSeriesModalContent; diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx index 5183fd4f6..6b5741d41 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectAllButton.tsx @@ -3,7 +3,14 @@ import { SelectActionType, useSelect } from 'App/SelectContext'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; 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 { allSelected, allUnselected } = selectState; @@ -23,13 +30,13 @@ function SeriesIndexSelectAllButton() { }); }, [allSelected, selectDispatch]); - return ( + return isSelectMode ? ( - ); + ) : null; } export default SeriesIndexSelectAllButton; diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectAllMenuItem.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectAllMenuItem.tsx new file mode 100644 index 000000000..bc7094949 --- /dev/null +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectAllMenuItem.tsx @@ -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 ? ( + + ) : null; +} + +export default SeriesIndexSelectAllMenuItem; diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.css b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.css new file mode 100644 index 000000000..b226a06a0 --- /dev/null +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.css @@ -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; + } +} diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx new file mode 100644 index 000000000..f98909973 --- /dev/null +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx @@ -0,0 +1,216 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import { SelectActionType, useSelect } from 'App/SelectContext'; +import { RENAME_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 ( + +
+
+ + {translate('Edit')} + + + + {translate('Rename Files')} + + + + {translate('Set Tags')} + +
+ +
+ + {translate('Delete')} + +
+
+ +
+ {translate('{count} series selected', { count: selectedCount })} +
+ + + + + + + + +
+ ); +} + +export default SeriesIndexSelectFooter; diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx new file mode 100644 index 000000000..2f52c6ba0 --- /dev/null +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectModeButton.tsx @@ -0,0 +1,37 @@ +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import React, { useCallback } from 'react'; +import { SelectActionType, useSelect } from 'App/SelectContext'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; + +interface 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 ( + + ); +} + +export default SeriesIndexSelectModeButton; diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx new file mode 100644 index 000000000..8b74e541f --- /dev/null +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectModeMenuItem.tsx @@ -0,0 +1,38 @@ +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import React, { useCallback } from 'react'; +import { SelectActionType, useSelect } from 'App/SelectContext'; +import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; + +interface 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 ( + + ); +} + +export default SeriesIndexSelectModeMenuItem; diff --git a/frontend/src/Series/Index/Select/Tags/TagsModal.tsx b/frontend/src/Series/Index/Select/Tags/TagsModal.tsx new file mode 100644 index 000000000..8db9d692b --- /dev/null +++ b/frontend/src/Series/Index/Select/Tags/TagsModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContent from './TagsModalContent'; + +interface TagsModalProps { + isOpen: boolean; + seriesIds: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModal(props: TagsModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Series/Index/Select/Tags/TagsModalContent.css b/frontend/src/Series/Index/Select/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Series/Index/Select/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..6e116c06b --- /dev/null +++ b/frontend/src/Series/Index/Select/Tags/TagsModalContent.tsx @@ -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([]); + 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 ( + + Tags + + +
+ + Tags + + + + + + Apply Tags + + + + + + 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 ( + + ); + })} + + {(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 ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Series/Index/SeriesIndex.css b/frontend/src/Series/Index/SeriesIndex.css index e4bd6662c..308e64f32 100644 --- a/frontend/src/Series/Index/SeriesIndex.css +++ b/frontend/src/Series/Index/SeriesIndex.css @@ -7,6 +7,7 @@ .contentBody { composes: contentBody from '~Components/Page/PageContentBody.css'; + position: relative; display: flex; flex-direction: column; } diff --git a/frontend/src/Series/Index/SeriesIndex.tsx b/frontend/src/Series/Index/SeriesIndex.tsx index 12d3652e9..0c16b1692 100644 --- a/frontend/src/Series/Index/SeriesIndex.tsx +++ b/frontend/src/Series/Index/SeriesIndex.tsx @@ -34,6 +34,10 @@ import SeriesIndexOverviews from './Overview/SeriesIndexOverviews'; import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal'; import SeriesIndexPosters from './Posters/SeriesIndexPosters'; 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 SeriesIndexTable from './Table/SeriesIndexTable'; import SeriesIndexTableOptions from './Table/SeriesIndexTableOptions'; @@ -201,7 +205,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => { const hasNoSeries = !totalItems; return ( - + @@ -224,13 +228,19 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => { - - {isSelectMode ? : null} + { ) : null} - {isLoaded && !!jumpBarItems.order.length ? ( { /> ) : null}
+ + {isSelectMode ? : null} + {view === 'posters' ? ( { + const checkInputCallback = useCallback(() => { // Mock handler to satisfy `onChange` being required for `CheckInput`. }, []); @@ -280,6 +281,19 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { ); } + if (name === 'seasonFolder') { + return ( + + + + ); + } + if (name === 'episodeProgress') { const progress = episodeCount ? (episodeFileCount / episodeCount) * 100 @@ -413,7 +427,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { name="useSceneNumbering" value={useSceneNumbering} isDisabled={true} - onChange={onUseSceneNumberingChange} + onChange={checkInputCallback} /> ); diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css index b390bf513..dc8a171c1 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css +++ b/frontend/src/Series/Index/Table/SeriesIndexTableHeader.css @@ -54,6 +54,12 @@ flex: 0 0 100px; } +.seasonFolder { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 150px; +} + .episodeProgress, .latestSeason { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; diff --git a/frontend/src/Store/Actions/seriesActions.js b/frontend/src/Store/Actions/seriesActions.js index 8a0c741be..c439d07aa 100644 --- a/frontend/src/Store/Actions/seriesActions.js +++ b/frontend/src/Store/Actions/seriesActions.js @@ -381,6 +381,8 @@ export const defaultState = { error: null, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, items: [], sortKey: 'sortTitle', sortDirection: sortDirections.ASCENDING, @@ -405,6 +407,8 @@ export const DELETE_SERIES = 'series/deleteSeries'; export const TOGGLE_SERIES_MONITORED = 'series/toggleSeriesMonitored'; export const TOGGLE_SEASON_MONITORED = 'series/toggleSeasonMonitored'; 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'; @@ -441,6 +445,8 @@ export const deleteSeries = createThunk(DELETE_SERIES, (payload) => { export const toggleSeriesMonitored = createThunk(TOGGLE_SERIES_MONITORED); export const toggleSeasonMonitored = createThunk(TOGGLE_SEASON_MONITORED); 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) => { return { @@ -659,6 +665,87 @@ export const actionHandlers = handleThunks({ 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 + })); + }); } }); diff --git a/frontend/src/Store/Actions/seriesEditorActions.js b/frontend/src/Store/Actions/seriesEditorActions.js index cfcd5e626..75ed0c70e 100644 --- a/frontend/src/Store/Actions/seriesEditorActions.js +++ b/frontend/src/Store/Actions/seriesEditorActions.js @@ -134,10 +134,19 @@ export const actionHandlers = handleThunks({ promise.done((data) => { dispatch(batchActions([ ...data.map((series) => { + + const { + alternateTitles, + images, + rootFolderPath, + statistics, + ...propsToUpdate + } = series; + return updateItem({ id: series.id, section: 'series', - ...series + ...propsToUpdate }); }), diff --git a/frontend/src/Store/Actions/seriesIndexActions.js b/frontend/src/Store/Actions/seriesIndexActions.js index 8cdadba95..a8e60199f 100644 --- a/frontend/src/Store/Actions/seriesIndexActions.js +++ b/frontend/src/Store/Actions/seriesIndexActions.js @@ -113,6 +113,12 @@ export const defaultState = { isSortable: true, isVisible: true }, + { + name: 'seasonFolder', + label: 'Season Folder', + isSortable: true, + isVisible: false + }, { name: 'episodeProgress', label: 'Episodes', diff --git a/frontend/src/Styles/Themes/dark.js b/frontend/src/Styles/Themes/dark.js index 2b6224571..ac3a63c4d 100644 --- a/frontend/src/Styles/Themes/dark.js +++ b/frontend/src/Styles/Themes/dark.js @@ -40,7 +40,7 @@ module.exports = { themeDarkColor: '#494949', themeLightColor: '#595959', pageBackground: '#202020', - pageFooterBackgroud: 'rgba(0, 0, 0, .25)', + pageFooterBackground: 'rgba(0, 0, 0, .25)', torrentColor: '#00853d', usenetColor: '#17b1d9', diff --git a/frontend/src/Styles/Themes/light.js b/frontend/src/Styles/Themes/light.js index 02f1698a5..acad51150 100644 --- a/frontend/src/Styles/Themes/light.js +++ b/frontend/src/Styles/Themes/light.js @@ -42,7 +42,7 @@ module.exports = { themeDarkColor: '#3a3f51', themeLightColor: '#4f566f', pageBackground: '#f5f7fa', - pageFooterBackgroud: '#f1f1f1', + pageFooterBackground: '#f1f1f1', torrentColor: '#00853d', usenetColor: '#17b1d9',