diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js index 9ce73cf08..1fe92fcbf 100644 --- a/frontend/src/Components/Tooltip/Popover.js +++ b/frontend/src/Components/Tooltip/Popover.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; +import { tooltipPositions } from 'Helpers/Props'; import Tooltip from './Tooltip'; import styles from './Popover.css'; @@ -30,8 +31,13 @@ function Popover(props) { } Popover.propTypes = { + className: PropTypes.string, + bodyClassName: PropTypes.string, + anchor: PropTypes.node.isRequired, title: PropTypes.string.isRequired, - body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired + body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + position: PropTypes.oneOf(tooltipPositions.all), + canFlip: PropTypes.bool }; export default Popover; diff --git a/frontend/src/Helpers/Props/TooltipPosition.ts b/frontend/src/Helpers/Props/TooltipPosition.ts new file mode 100644 index 000000000..7a9351ac6 --- /dev/null +++ b/frontend/src/Helpers/Props/TooltipPosition.ts @@ -0,0 +1,8 @@ +enum TooltipPosition { + Top = 'top', + Right = 'right', + Bottom = 'bottom', + Left = 'left', +} + +export default TooltipPosition; diff --git a/frontend/src/Series/Index/Select/SeasonPass/ChangeMonitoringModal.tsx b/frontend/src/Series/Index/Select/SeasonPass/ChangeMonitoringModal.tsx new file mode 100644 index 000000000..0befba3ae --- /dev/null +++ b/frontend/src/Series/Index/Select/SeasonPass/ChangeMonitoringModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ChangeMonitoringModalContent from './ChangeMonitoringModalContent'; + +interface ChangeMonitoringModalProps { + isOpen: boolean; + seriesIds: number[]; + onSavePress(monitor: string): void; + onModalClose(): void; +} + +function ChangeMonitoringModal(props: ChangeMonitoringModalProps) { + const { isOpen, seriesIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ChangeMonitoringModal; diff --git a/frontend/src/Series/Index/Select/SeasonPass/ChangeMonitoringModalContent.css b/frontend/src/Series/Index/Select/SeasonPass/ChangeMonitoringModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Series/Index/Select/SeasonPass/ChangeMonitoringModalContent.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/SeasonPass/ChangeMonitoringModalContent.tsx b/frontend/src/Series/Index/Select/SeasonPass/ChangeMonitoringModalContent.tsx new file mode 100644 index 000000000..e7a874b15 --- /dev/null +++ b/frontend/src/Series/Index/Select/SeasonPass/ChangeMonitoringModalContent.tsx @@ -0,0 +1,79 @@ +import React, { useCallback, useState } from 'react'; +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 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 translate from 'Utilities/String/translate'; +import styles from './ChangeMonitoringModalContent.css'; + +const NO_CHANGE = 'noChange'; + +interface ChangeMonitoringModalContentProps { + seriesIds: number[]; + saveError?: object; + onSavePress(monitor: string): void; + onModalClose(): void; +} + +function ChangeMonitoringModalContent( + props: ChangeMonitoringModalContentProps +) { + const { seriesIds, onSavePress, onModalClose, ...otherProps } = props; + + const [monitor, setMonitor] = useState(NO_CHANGE); + + const onInputChange = useCallback( + ({ value }) => { + setMonitor(value); + }, + [setMonitor] + ); + + const onSavePressWrapper = useCallback(() => { + onSavePress(monitor); + }, [monitor, onSavePress]); + + const selectedCount = seriesIds.length; + + return ( + + {translate('Monitor Series')} + + +
+ + {translate('Monitoring')} + + + +
+
+ + +
+ {translate('{count} series selected', { count: selectedCount })} +
+ +
+ + + +
+
+
+ ); +} + +export default ChangeMonitoringModalContent; diff --git a/frontend/src/Series/Index/Select/SeasonPass/SeasonDetails.css b/frontend/src/Series/Index/Select/SeasonPass/SeasonDetails.css new file mode 100644 index 000000000..b40531d16 --- /dev/null +++ b/frontend/src/Series/Index/Select/SeasonPass/SeasonDetails.css @@ -0,0 +1,10 @@ +.seasons { + display: flex; + flex-wrap: wrap; +} + +.truncated { + align-self: center; + flex: 0 0 100%; + padding: 4px 6px; +} diff --git a/frontend/src/Series/Index/Select/SeasonPass/SeasonDetails.tsx b/frontend/src/Series/Index/Select/SeasonPass/SeasonDetails.tsx new file mode 100644 index 000000000..ee5348d8c --- /dev/null +++ b/frontend/src/Series/Index/Select/SeasonPass/SeasonDetails.tsx @@ -0,0 +1,44 @@ +import React, { useMemo } from 'react'; +import { Season } from 'Series/Series'; +import SeasonPassSeason from './SeasonPassSeason'; +import styles from './SeasonDetails.css'; + +interface SeasonDetailsProps { + seriesId: number; + seasons: Season[]; +} + +function SeasonDetails(props: SeasonDetailsProps) { + const { seriesId, seasons } = props; + + const latestSeasons = useMemo(() => { + return seasons.slice(Math.max(seasons.length - 25, 0)); + }, [seasons]); + + return ( +
+ {latestSeasons.map((season) => { + const { seasonNumber, monitored, statistics, isSaving } = season; + + return ( + + ); + })} + + {latestSeasons.length < seasons.length ? ( +
+ Only latest 25 seasons are shown, go to details to see all seasons +
+ ) : null} +
+ ); +} + +export default SeasonDetails; diff --git a/frontend/src/Series/Index/Select/SeasonPass/SeasonPassSeason.css b/frontend/src/Series/Index/Select/SeasonPass/SeasonPassSeason.css new file mode 100644 index 000000000..77c4abdad --- /dev/null +++ b/frontend/src/Series/Index/Select/SeasonPass/SeasonPassSeason.css @@ -0,0 +1,25 @@ +.season { + display: flex; + align-items: stretch; + overflow: hidden; + margin: 2px 4px; + border: 1px solid var(--borderColor); + border-radius: 4px; + background-color: var(--seasonBackgroundColor); + cursor: default; +} + +.info { + padding: 0 4px; +} + +.episodes { + padding: 0 4px; + background-color: var(--episodesBackgroundColor); + color: var(--defaultColor); +} + +.allEpisodes { + background-color: #e0ffe0; + color: var(--darkGray); +} diff --git a/frontend/src/Series/Index/Select/SeasonPass/SeasonPassSeason.tsx b/frontend/src/Series/Index/Select/SeasonPass/SeasonPassSeason.tsx new file mode 100644 index 000000000..76052def0 --- /dev/null +++ b/frontend/src/Series/Index/Select/SeasonPass/SeasonPassSeason.tsx @@ -0,0 +1,69 @@ +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import { Statistics } from 'Series/Series'; +import { toggleSeasonMonitored } from 'Store/Actions/seriesActions'; +import padNumber from 'Utilities/Number/padNumber'; +import styles from './SeasonPassSeason.css'; + +interface SeasonPassSeasonProps { + seriesId: number; + seasonNumber: number; + monitored: boolean; + statistics: Statistics; + isSaving: boolean; +} + +function SeasonPassSeason(props: SeasonPassSeasonProps) { + const { + seriesId, + seasonNumber, + monitored, + statistics = { + episodeFileCount: 0, + totalEpisodeCount: 0, + percentOfEpisodes: 0, + }, + isSaving = false, + } = props; + + const { episodeFileCount, totalEpisodeCount, percentOfEpisodes } = statistics; + + const dispatch = useDispatch(); + const onSeasonMonitoredPress = useCallback(() => { + dispatch( + toggleSeasonMonitored({ seriesId, seasonNumber, monitored: !monitored }) + ); + }, [seriesId, seasonNumber, monitored, dispatch]); + + return ( +
+
+ + + + {seasonNumber === 0 ? 'Specials' : `S${padNumber(seasonNumber, 2)}`} + +
+ +
+ {totalEpisodeCount === 0 + ? '0/0' + : `${episodeFileCount}/${totalEpisodeCount}`} +
+
+ ); +} + +export default SeasonPassSeason; diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.css b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.css index b226a06a0..d385923ef 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.css +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.css @@ -51,6 +51,10 @@ gap: 20px; } + .actionButtons { + flex-wrap: wrap; + } + .actionButtons, .deleteButtons { display: flex; diff --git a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx index f98909973..778bbd93f 100644 --- a/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx +++ b/frontend/src/Series/Index/Select/SeriesIndexSelectFooter.tsx @@ -7,13 +7,17 @@ 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 { + saveSeriesEditor, + updateSeriesMonitor, +} 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 ChangeMonitoringModal from './SeasonPass/ChangeMonitoringModal'; import TagsModal from './Tags/TagsModal'; import styles from './SeriesIndexSelectFooter.css'; @@ -21,12 +25,12 @@ const seriesEditorSelector = createSelector( (state) => state.series, (series) => { const { isSaving, isDeleting, deleteError } = series; - + return { isSaving, isDeleting, - deleteError - } + deleteError, + }; } ); @@ -43,9 +47,11 @@ function SeriesIndexSelectFooter() { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false); const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); + const [isMonitoringModalOpen, setIsMonitoringModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isSavingSeries, setIsSavingSeries] = useState(false); const [isSavingTags, setIsSavingTags] = useState(false); + const [isSavingMonitoring, setIsSavingMonitoring] = useState(false); const [selectState, selectDispatch] = useSelect(); const { selectedState } = selectState; @@ -111,6 +117,29 @@ function SeriesIndexSelectFooter() { [seriesIds, dispatch] ); + const onMonitoringPress = useCallback(() => { + setIsMonitoringModalOpen(true); + }, [setIsMonitoringModalOpen]); + + const onMonitoringClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsMonitoringModalOpen]); + + const onMonitoringSavePress = useCallback( + (monitor) => { + setIsSavingMonitoring(true); + setIsMonitoringModalOpen(false); + + dispatch( + updateSeriesMonitor({ + seriesIds, + monitor, + }) + ); + }, + [seriesIds, dispatch] + ); + const onDeletePress = useCallback(() => { setIsDeleteModalOpen(true); }, [setIsDeleteModalOpen]); @@ -123,6 +152,7 @@ function SeriesIndexSelectFooter() { if (!isSaving) { setIsSavingSeries(false); setIsSavingTags(false); + setIsSavingMonitoring(false); } }, [isSaving]); @@ -166,6 +196,14 @@ function SeriesIndexSelectFooter() { > {translate('Set Tags')} + + + {translate('Update Monitoring')} +
@@ -198,6 +236,13 @@ function SeriesIndexSelectFooter() { onModalClose={onTagsModalClose} /> + + + {isSelectMode ? ( + } + position={TooltipPosition.Left} + /> + ) : ( + seasonCount + )} + + ); +} + +export default SeasonsCell; diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx index 2e8aa8210..fae5ba364 100644 --- a/frontend/src/Series/Index/Table/SeriesIndexRow.tsx +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.tsx @@ -25,6 +25,7 @@ import formatBytes from 'Utilities/Number/formatBytes'; import getProgressBarKind from 'Utilities/Series/getProgressBarKind'; import titleCase from 'Utilities/String/titleCase'; import hasGrowableColumns from './hasGrowableColumns'; +import SeasonsCell from './SeasonsCell'; import selectTableOptions from './selectTableOptions'; import SeriesStatusCell from './SeriesStatusCell'; import styles from './SeriesIndexRow.css'; @@ -69,7 +70,9 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { useSceneNumbering, genres = [], ratings, + seasons = [], tags = [], + isSaving = false, } = series; const { @@ -169,8 +172,11 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { ); @@ -275,9 +281,14 @@ function SeriesIndexRow(props: SeriesIndexRowProps) { if (name === 'seasonCount') { return ( - - {seasonCount} - + ); } diff --git a/frontend/src/Series/Index/Table/SeriesStatusCell.css b/frontend/src/Series/Index/Table/SeriesStatusCell.css index fbcd5eee9..304d06c34 100644 --- a/frontend/src/Series/Index/Table/SeriesStatusCell.css +++ b/frontend/src/Series/Index/Table/SeriesStatusCell.css @@ -6,4 +6,5 @@ .statusIcon { width: 20px !important; + text-align: center; } diff --git a/frontend/src/Series/Index/Table/SeriesStatusCell.tsx b/frontend/src/Series/Index/Table/SeriesStatusCell.tsx index a406acbef..31fdd7928 100644 --- a/frontend/src/Series/Index/Table/SeriesStatusCell.tsx +++ b/frontend/src/Series/Index/Table/SeriesStatusCell.tsx @@ -1,35 +1,63 @@ -import React from 'react'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; import Icon from 'Components/Icon'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; import { icons } from 'Helpers/Props'; import { getSeriesStatusDetails } from 'Series/SeriesStatus'; +import { toggleSeriesMonitored } from 'Store/Actions/seriesActions'; +import translate from 'Utilities/String/translate'; import styles from './SeriesStatusCell.css'; interface SeriesStatusCellProps { className: string; + seriesId: number; monitored: boolean; status: string; + isSelectMode: boolean; + isSaving: boolean; component?: React.ElementType; } function SeriesStatusCell(props: SeriesStatusCellProps) { const { className, + seriesId, monitored, status, + isSelectMode, + isSaving, component: Component = VirtualTableRowCell, ...otherProps } = props; const statusDetails = getSeriesStatusDetails(status); + const dispatch = useDispatch(); + + const onMonitoredPress = useCallback(() => { + dispatch(toggleSeriesMonitored({ seriesId, monitored: !monitored })); + }, [seriesId, monitored, dispatch]); return ( - + {isSelectMode ? ( + + ) : ( + + )} { this.props.dispatchUpdateMonitoringOptions({ - id: this.props.seriesId, - monitor + seriesIds: [this.props.seriesId], + monitor, + shouldFetchEpisodesAfterUpdate: true }); }; diff --git a/frontend/src/Series/Series.ts b/frontend/src/Series/Series.ts index cd2dfde14..9cbce6b6e 100644 --- a/frontend/src/Series/Series.ts +++ b/frontend/src/Series/Series.ts @@ -25,6 +25,7 @@ export interface Season { monitored: boolean; seasonNumber: number; statistics: Statistics; + isSaving?: boolean; } export interface Ratings { diff --git a/frontend/src/Store/Actions/seriesActions.js b/frontend/src/Store/Actions/seriesActions.js index c439d07aa..5d8ff2995 100644 --- a/frontend/src/Store/Actions/seriesActions.js +++ b/frontend/src/Store/Actions/seriesActions.js @@ -621,15 +621,23 @@ export const actionHandlers = handleThunks({ [UPDATE_SERIES_MONITOR]: function(getState, payload, dispatch) { const { - id, - monitor + seriesIds, + monitor, + monitored, + shouldFetchEpisodesAfterUpdate = false } = payload; - const seriesToUpdate = { id }; + const series = []; - if (monitor !== 'None') { - seriesToUpdate.monitored = true; - } + seriesIds.forEach((id) => { + const seriesToUpdate = { id }; + + if (monitored != null) { + seriesToUpdate.monitored = monitored; + } + + series.push(seriesToUpdate); + }); dispatch(set({ section, @@ -640,16 +648,16 @@ export const actionHandlers = handleThunks({ url: '/seasonPass', method: 'POST', data: JSON.stringify({ - series: [ - seriesToUpdate - ], + series, monitoringOptions: { monitor } }), dataType: 'json' }).request; promise.done((data) => { - dispatch(fetchEpisodes({ seriesId: id })); + if (shouldFetchEpisodesAfterUpdate) { + dispatch(fetchEpisodes({ seriesId: seriesIds[0] })); + } dispatch(set({ section,