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('{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,