New: Season Pass is now part of series list

This commit is contained in:
Mark McDowall 2023-01-26 20:26:12 -08:00 committed by Mark McDowall
parent a18c377466
commit bdcfef80d6
20 changed files with 464 additions and 26 deletions

View File

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

View File

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

View File

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ChangeMonitoringModalContent
seriesIds={seriesIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ChangeMonitoringModal;

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,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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('Monitor Series')}</ModalHeader>
<ModalBody>
<Form {...otherProps}>
<FormGroup>
<FormLabel>{translate('Monitoring')}</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
value={monitor}
includeNoChange={true}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</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('Save')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ChangeMonitoringModalContent;

View File

@ -0,0 +1,10 @@
.seasons {
display: flex;
flex-wrap: wrap;
}
.truncated {
align-self: center;
flex: 0 0 100%;
padding: 4px 6px;
}

View File

@ -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 (
<div className={styles.seasons}>
{latestSeasons.map((season) => {
const { seasonNumber, monitored, statistics, isSaving } = season;
return (
<SeasonPassSeason
key={seasonNumber}
seriesId={seriesId}
seasonNumber={seasonNumber}
monitored={monitored}
statistics={statistics}
isSaving={isSaving}
/>
);
})}
{latestSeasons.length < seasons.length ? (
<div className={styles.truncated}>
Only latest 25 seasons are shown, go to details to see all seasons
</div>
) : null}
</div>
);
}
export default SeasonDetails;

View File

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

View File

@ -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 (
<div className={styles.season}>
<div className={styles.info}>
<MonitorToggleButton
monitored={monitored}
isSaving={isSaving}
onPress={onSeasonMonitoredPress}
/>
<span>
{seasonNumber === 0 ? 'Specials' : `S${padNumber(seasonNumber, 2)}`}
</span>
</div>
<div
className={classNames(
styles.episodes,
percentOfEpisodes === 100 && styles.allEpisodes
)}
title={`${episodeFileCount}/${totalEpisodeCount} episodes downloaded`}
>
{totalEpisodeCount === 0
? '0/0'
: `${episodeFileCount}/${totalEpisodeCount}`}
</div>
</div>
);
}
export default SeasonPassSeason;

View File

@ -51,6 +51,10 @@
gap: 20px;
}
.actionButtons {
flex-wrap: wrap;
}
.actionButtons,
.deleteButtons {
display: flex;

View File

@ -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')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingMonitoring}
isDisabled={!anySelected || isOrganizingSeries}
onPress={onMonitoringPress}
>
{translate('Update Monitoring')}
</SpinnerButton>
</div>
<div className={styles.deleteButtons}>
@ -198,6 +236,13 @@ function SeriesIndexSelectFooter() {
onModalClose={onTagsModalClose}
/>
<ChangeMonitoringModal
isOpen={isMonitoringModalOpen}
seriesIds={seriesIds}
onSavePress={onMonitoringSavePress}
onModalClose={onMonitoringClose}
/>
<OrganizeSeriesModal
isOpen={isOrganizeModalOpen}
seriesIds={seriesIds}

View File

@ -0,0 +1,4 @@
.seasonCount {
width: 100%;
cursor: default;
}

View File

@ -0,0 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'seasonCount': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -0,0 +1,45 @@
import React from 'react';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import Popover from 'Components/Tooltip/Popover';
import TooltipPosition from 'Helpers/Props/TooltipPosition';
import SeasonDetails from 'Series/Index/Select/SeasonPass/SeasonDetails';
import { Season } from 'Series/Series';
import translate from 'Utilities/String/translate';
import styles from './SeasonsCell.css';
interface SeriesStatusCellProps {
className: string;
seriesId: number;
seasonCount: number;
seasons: Season[];
isSelectMode: boolean;
}
function SeasonsCell(props: SeriesStatusCellProps) {
const {
className,
seriesId,
seasonCount,
seasons,
isSelectMode,
...otherProps
} = props;
return (
<VirtualTableRowCell className={className} {...otherProps}>
{isSelectMode ? (
<Popover
className={styles.seasonCount}
anchor={seasonCount}
title={translate('Season Details')}
body={<SeasonDetails seriesId={seriesId} seasons={seasons} />}
position={TooltipPosition.Left}
/>
) : (
seasonCount
)}
</VirtualTableRowCell>
);
}
export default SeasonsCell;

View File

@ -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) {
<SeriesStatusCell
key={name}
className={styles[name]}
seriesId={seriesId}
monitored={monitored}
status={status}
isSelectMode={isSelectMode}
isSaving={isSaving}
component={VirtualTableRowCell}
/>
);
@ -275,9 +281,14 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
if (name === 'seasonCount') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{seasonCount}
</VirtualTableRowCell>
<SeasonsCell
key={name}
className={styles[name]}
seriesId={seriesId}
seasonCount={seasonCount}
seasons={seasons}
isSelectMode={isSelectMode}
/>
);
}

View File

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

View File

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

View File

@ -46,8 +46,9 @@ class MonitoringOptionsModalContentConnector extends Component {
onSavePress = ({ monitor }) => {
this.props.dispatchUpdateMonitoringOptions({
id: this.props.seriesId,
monitor
seriesIds: [this.props.seriesId],
monitor,
shouldFetchEpisodesAfterUpdate: true
});
};

View File

@ -25,6 +25,7 @@ export interface Season {
monitored: boolean;
seasonNumber: number;
statistics: Statistics;
isSaving?: boolean;
}
export interface Ratings {

View File

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