diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 212a24ad1..39520d971 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,10 +1,10 @@ -import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; import BlocklistAppState from './BlocklistAppState'; import CalendarAppState from './CalendarAppState'; import CommandAppState from './CommandAppState'; import EpisodeFilesAppState from './EpisodeFilesAppState'; import EpisodesAppState from './EpisodesAppState'; import HistoryAppState from './HistoryAppState'; +import InteractiveImportAppState from './InteractiveImportAppState'; import ParseAppState from './ParseAppState'; import QueueAppState from './QueueAppState'; import RootFolderAppState from './RootFolderAppState'; @@ -12,6 +12,7 @@ import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState'; import SettingsAppState from './SettingsAppState'; import SystemAppState from './SystemAppState'; import TagsAppState from './TagsAppState'; +import WantedAppState from './WantedAppState'; interface FilterBuilderPropOption { id: string; @@ -62,8 +63,8 @@ interface AppState { blocklist: BlocklistAppState; calendar: CalendarAppState; commands: CommandAppState; - episodes: EpisodesAppState; episodeFiles: EpisodeFilesAppState; + episodes: EpisodesAppState; episodesSelection: EpisodesAppState; history: HistoryAppState; interactiveImport: InteractiveImportAppState; @@ -75,6 +76,7 @@ interface AppState { settings: SettingsAppState; system: SystemAppState; tags: TagsAppState; + wanted: WantedAppState; } export default AppState; diff --git a/frontend/src/App/State/WantedAppState.ts b/frontend/src/App/State/WantedAppState.ts new file mode 100644 index 000000000..18a0fbd33 --- /dev/null +++ b/frontend/src/App/State/WantedAppState.ts @@ -0,0 +1,13 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Episode from 'Episode/Episode'; + +interface WantedCutoffUnmetAppState extends AppSectionState {} + +interface WantedMissingAppState extends AppSectionState {} + +interface WantedAppState { + cutoffUnmet: WantedCutoffUnmetAppState; + missing: WantedMissingAppState; +} + +export default WantedAppState; diff --git a/frontend/src/Commands/Command.ts b/frontend/src/Commands/Command.ts index ed0a449ab..cd875d56b 100644 --- a/frontend/src/Commands/Command.ts +++ b/frontend/src/Commands/Command.ts @@ -26,6 +26,7 @@ export interface CommandBody { seriesId?: number; seriesIds?: number[]; seasonNumber?: number; + episodeIds?: number[]; [key: string]: string | number | boolean | number[] | undefined; } diff --git a/frontend/src/Episode/Episode.ts b/frontend/src/Episode/Episode.ts index 5df98e889..87ae86657 100644 --- a/frontend/src/Episode/Episode.ts +++ b/frontend/src/Episode/Episode.ts @@ -19,6 +19,7 @@ interface Episode extends ModelBase { episodeFile?: object; hasFile: boolean; monitored: boolean; + grabbed?: boolean; unverifiedSceneNumbering: boolean; endTime?: string; grabDate?: string; diff --git a/frontend/src/Episode/EpisodeDetailsModal.js b/frontend/src/Episode/EpisodeDetailsModal.js deleted file mode 100644 index 0e9583e3a..000000000 --- a/frontend/src/Episode/EpisodeDetailsModal.js +++ /dev/null @@ -1,60 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import { sizes } from 'Helpers/Props'; -import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector'; - -class EpisodeDetailsModal extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - closeOnBackgroundClick: props.selectedTab !== 'search' - }; - } - - // - // Listeners - - onTabChange = (isSearch) => { - this.setState({ closeOnBackgroundClick: !isSearch }); - }; - - // - // Render - - render() { - const { - isOpen, - onModalClose, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -EpisodeDetailsModal.propTypes = { - selectedTab: PropTypes.string, - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default EpisodeDetailsModal; diff --git a/frontend/src/Episode/EpisodeDetailsModal.tsx b/frontend/src/Episode/EpisodeDetailsModal.tsx new file mode 100644 index 000000000..6bd1e32fb --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsModal.tsx @@ -0,0 +1,52 @@ +import React, { useCallback, useState } from 'react'; +import Modal from 'Components/Modal/Modal'; +import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab'; +import { EpisodeEntities } from 'Episode/useEpisode'; +import { sizes } from 'Helpers/Props'; +import EpisodeDetailsModalContent from './EpisodeDetailsModalContent'; + +interface EpisodeDetailsModalProps { + isOpen: boolean; + episodeId: number; + episodeEntity: EpisodeEntities; + seriesId: number; + episodeTitle: string; + isSaving?: boolean; + showOpenSeriesButton?: boolean; + selectedTab?: EpisodeDetailsTab; + startInteractiveSearch?: boolean; + onModalClose(): void; +} + +function EpisodeDetailsModal(props: EpisodeDetailsModalProps) { + const { selectedTab, isOpen, onModalClose, ...otherProps } = props; + + const [closeOnBackgroundClick, setCloseOnBackgroundClick] = useState( + selectedTab !== 'search' + ); + + const handleTabChange = useCallback( + (isSearch: boolean) => { + setCloseOnBackgroundClick(!isSearch); + }, + [setCloseOnBackgroundClick] + ); + + return ( + + + + ); +} + +export default EpisodeDetailsModal; diff --git a/frontend/src/Episode/EpisodeDetailsModalContent.js b/frontend/src/Episode/EpisodeDetailsModalContent.js deleted file mode 100644 index cd5f37fab..000000000 --- a/frontend/src/Episode/EpisodeDetailsModalContent.js +++ /dev/null @@ -1,222 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'; -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 MonitorToggleButton from 'Components/MonitorToggleButton'; -import episodeEntities from 'Episode/episodeEntities'; -import translate from 'Utilities/String/translate'; -import EpisodeHistoryConnector from './History/EpisodeHistoryConnector'; -import EpisodeSearchConnector from './Search/EpisodeSearchConnector'; -import SeasonEpisodeNumber from './SeasonEpisodeNumber'; -import EpisodeSummaryConnector from './Summary/EpisodeSummaryConnector'; -import styles from './EpisodeDetailsModalContent.css'; - -const tabs = [ - 'details', - 'history', - 'search' -]; - -class EpisodeDetailsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - selectedTab: props.selectedTab - }; - } - - // - // Listeners - - onTabSelect = (index, lastIndex) => { - const selectedTab = tabs[index]; - this.props.onTabChange(selectedTab === 'search'); - this.setState({ selectedTab }); - }; - - // - // Render - - render() { - const { - episodeId, - episodeEntity, - episodeFileId, - seriesId, - seriesTitle, - titleSlug, - seriesMonitored, - seriesType, - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - episodeTitle, - airDate, - monitored, - isSaving, - showOpenSeriesButton, - startInteractiveSearch, - onMonitorEpisodePress, - onModalClose - } = this.props; - - const seriesLink = `/series/${titleSlug}`; - - return ( - - - - - - {seriesTitle} - - - - - - - - - - - {episodeTitle} - - - - - - - {translate('Details')} - - - - {translate('History')} - - - - {translate('Search')} - - - - -
- -
-
- - -
- -
-
- - - {/* Don't wrap in tabContent so we not have a top margin */} - - -
-
- - - { - showOpenSeriesButton && - - } - - - -
- ); - } -} - -EpisodeDetailsModalContent.propTypes = { - episodeId: PropTypes.number.isRequired, - episodeEntity: PropTypes.string.isRequired, - episodeFileId: PropTypes.number, - seriesId: PropTypes.number.isRequired, - seriesTitle: PropTypes.string.isRequired, - titleSlug: PropTypes.string.isRequired, - seriesMonitored: PropTypes.bool.isRequired, - seriesType: PropTypes.string.isRequired, - seasonNumber: PropTypes.number.isRequired, - episodeNumber: PropTypes.number.isRequired, - absoluteEpisodeNumber: PropTypes.number, - airDate: PropTypes.string.isRequired, - episodeTitle: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - isSaving: PropTypes.bool, - showOpenSeriesButton: PropTypes.bool, - selectedTab: PropTypes.string.isRequired, - startInteractiveSearch: PropTypes.bool.isRequired, - onMonitorEpisodePress: PropTypes.func.isRequired, - onTabChange: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -EpisodeDetailsModalContent.defaultProps = { - selectedTab: 'details', - episodeEntity: episodeEntities.EPISODES, - startInteractiveSearch: false -}; - -export default EpisodeDetailsModalContent; diff --git a/frontend/src/Episode/EpisodeDetailsModalContent.tsx b/frontend/src/Episode/EpisodeDetailsModalContent.tsx new file mode 100644 index 000000000..d049ab9f7 --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsModalContent.tsx @@ -0,0 +1,204 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'; +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 MonitorToggleButton from 'Components/MonitorToggleButton'; +import Episode from 'Episode/Episode'; +import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab'; +import episodeEntities from 'Episode/episodeEntities'; +import useEpisode, { EpisodeEntities } from 'Episode/useEpisode'; +import Series from 'Series/Series'; +import useSeries from 'Series/useSeries'; +import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions'; +import { + cancelFetchReleases, + clearReleases, +} from 'Store/Actions/releaseActions'; +import translate from 'Utilities/String/translate'; +import EpisodeHistoryConnector from './History/EpisodeHistoryConnector'; +import EpisodeSearchConnector from './Search/EpisodeSearchConnector'; +import SeasonEpisodeNumber from './SeasonEpisodeNumber'; +import EpisodeSummary from './Summary/EpisodeSummary'; +import styles from './EpisodeDetailsModalContent.css'; + +const TABS: EpisodeDetailsTab[] = ['details', 'history', 'search']; + +export interface EpisodeDetailsModalContentProps { + episodeId: number; + episodeEntity: EpisodeEntities; + seriesId: number; + episodeTitle: string; + isSaving?: boolean; + showOpenSeriesButton?: boolean; + selectedTab?: EpisodeDetailsTab; + startInteractiveSearch?: boolean; + onTabChange(isSearch: boolean): void; + onModalClose(): void; +} + +function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) { + const { + episodeId, + episodeEntity = episodeEntities.EPISODES, + seriesId, + episodeTitle, + isSaving = false, + showOpenSeriesButton = false, + startInteractiveSearch = false, + selectedTab = 'details', + onTabChange, + onModalClose, + } = props; + + const dispatch = useDispatch(); + + const [currentlySelectedTab, setCurrentlySelectedTab] = useState(selectedTab); + + const { + title: seriesTitle, + titleSlug, + monitored: seriesMonitored, + seriesType, + } = useSeries(seriesId) as Series; + + const { + episodeFileId, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDate, + monitored, + } = useEpisode(episodeId, episodeEntity) as Episode; + + const handleTabSelect = useCallback( + (selectedIndex: number) => { + const tab = TABS[selectedIndex]; + onTabChange(tab === 'search'); + setCurrentlySelectedTab(tab); + }, + [onTabChange] + ); + + const handleMonitorEpisodePress = useCallback( + (monitored: boolean) => { + dispatch( + toggleEpisodeMonitored({ + episodeEntity, + episodeId, + monitored, + }) + ); + }, + [episodeEntity, episodeId, dispatch] + ); + + useEffect(() => { + return () => { + // Clear pending releases here, so we can reshow the search + // results even after switching tabs. + dispatch(cancelFetchReleases()); + dispatch(clearReleases()); + }; + }, [dispatch]); + + const seriesLink = `/series/${titleSlug}`; + + return ( + + + + + {seriesTitle} + + - + + + + - + + {episodeTitle} + + + + + + + {translate('Details')} + + + + {translate('History')} + + + + {translate('Search')} + + + + +
+ +
+
+ + +
+ +
+
+ + + {/* Don't wrap in tabContent so we not have a top margin */} + + +
+
+ + + {showOpenSeriesButton && ( + + )} + + + +
+ ); +} + +export default EpisodeDetailsModalContent; diff --git a/frontend/src/Episode/EpisodeDetailsModalContentConnector.js b/frontend/src/Episode/EpisodeDetailsModalContentConnector.js deleted file mode 100644 index 773933638..000000000 --- a/frontend/src/Episode/EpisodeDetailsModalContentConnector.js +++ /dev/null @@ -1,101 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import episodeEntities from 'Episode/episodeEntities'; -import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions'; -import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; -import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import EpisodeDetailsModalContent from './EpisodeDetailsModalContent'; - -function createMapStateToProps() { - return createSelector( - createEpisodeSelector(), - createSeriesSelector(), - (episode, series) => { - const { - title: seriesTitle, - titleSlug, - monitored: seriesMonitored, - seriesType - } = series; - - return { - seriesTitle, - titleSlug, - seriesMonitored, - seriesType, - ...episode - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchCancelFetchReleases() { - dispatch(cancelFetchReleases()); - }, - - dispatchClearReleases() { - dispatch(clearReleases()); - }, - - onMonitorEpisodePress(monitored) { - const { - episodeId, - episodeEntity - } = props; - - dispatch(toggleEpisodeMonitored({ - episodeEntity, - episodeId, - monitored - })); - } - }; -} - -class EpisodeDetailsModalContentConnector extends Component { - - // - // Lifecycle - - componentWillUnmount() { - // Clear pending releases here, so we can reshow the search - // results even after switching tabs. - - this.props.dispatchCancelFetchReleases(); - this.props.dispatchClearReleases(); - } - - // - // Render - - render() { - const { - dispatchCancelFetchReleases, - dispatchClearReleases, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -EpisodeDetailsModalContentConnector.propTypes = { - episodeId: PropTypes.number.isRequired, - episodeEntity: PropTypes.string.isRequired, - seriesId: PropTypes.number.isRequired, - dispatchCancelFetchReleases: PropTypes.func.isRequired, - dispatchClearReleases: PropTypes.func.isRequired -}; - -EpisodeDetailsModalContentConnector.defaultProps = { - episodeEntity: episodeEntities.EPISODES -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector); diff --git a/frontend/src/Episode/EpisodeDetailsTab.ts b/frontend/src/Episode/EpisodeDetailsTab.ts new file mode 100644 index 000000000..b568f24fa --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsTab.ts @@ -0,0 +1,3 @@ +type EpisodeDetailsTab = 'details' | 'history' | 'search'; + +export default EpisodeDetailsTab; diff --git a/frontend/src/Episode/EpisodeFormats.js b/frontend/src/Episode/EpisodeFormats.js deleted file mode 100644 index 1801767bd..000000000 --- a/frontend/src/Episode/EpisodeFormats.js +++ /dev/null @@ -1,33 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Label from 'Components/Label'; -import { kinds } from 'Helpers/Props'; - -function EpisodeFormats({ formats }) { - return ( -
- { - formats.map((format) => { - return ( - - ); - }) - } -
- ); -} - -EpisodeFormats.propTypes = { - formats: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -EpisodeFormats.defaultProps = { - formats: [] -}; - -export default EpisodeFormats; diff --git a/frontend/src/Episode/EpisodeFormats.tsx b/frontend/src/Episode/EpisodeFormats.tsx new file mode 100644 index 000000000..d774ad907 --- /dev/null +++ b/frontend/src/Episode/EpisodeFormats.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Label from 'Components/Label'; +import { kinds } from 'Helpers/Props'; +import CustomFormat from 'typings/CustomFormat'; + +interface EpisodeFormatsProps { + formats: CustomFormat[]; +} + +function EpisodeFormats({ formats }: EpisodeFormatsProps) { + return ( +
+ {formats.map(({ id, name }) => ( + + ))} +
+ ); +} + +export default EpisodeFormats; diff --git a/frontend/src/Episode/EpisodeSearchCell.js b/frontend/src/Episode/EpisodeSearchCell.js deleted file mode 100644 index 3ec76d365..000000000 --- a/frontend/src/Episode/EpisodeSearchCell.js +++ /dev/null @@ -1,86 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import { icons } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import EpisodeDetailsModal from './EpisodeDetailsModal'; -import styles from './EpisodeSearchCell.css'; - -class EpisodeSearchCell extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onManualSearchPress = () => { - this.setState({ isDetailsModalOpen: true }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - }; - - // - // Render - - render() { - const { - episodeId, - seriesId, - episodeTitle, - isSearching, - onSearchPress, - ...otherProps - } = this.props; - - return ( - - - - - - - - ); - } -} - -EpisodeSearchCell.propTypes = { - episodeId: PropTypes.number.isRequired, - seriesId: PropTypes.number.isRequired, - episodeTitle: PropTypes.string.isRequired, - isSearching: PropTypes.bool.isRequired, - onSearchPress: PropTypes.func.isRequired -}; - -export default EpisodeSearchCell; diff --git a/frontend/src/Episode/EpisodeSearchCell.tsx b/frontend/src/Episode/EpisodeSearchCell.tsx new file mode 100644 index 000000000..65ceb5d3a --- /dev/null +++ b/frontend/src/Episode/EpisodeSearchCell.tsx @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { EPISODE_SEARCH } from 'Commands/commandNames'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import { EpisodeEntities } from 'Episode/useEpisode'; +import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; +import translate from 'Utilities/String/translate'; +import EpisodeDetailsModal from './EpisodeDetailsModal'; +import styles from './EpisodeSearchCell.css'; + +interface EpisodeSearchCellProps { + episodeId: number; + episodeEntity: EpisodeEntities; + seriesId: number; + episodeTitle: string; +} + +function EpisodeSearchCell(props: EpisodeSearchCellProps) { + const { episodeId, episodeEntity, seriesId, episodeTitle } = props; + + const executingCommands = useSelector(createExecutingCommandsSelector()); + const isSearching = executingCommands.some(({ name, body }) => { + const { episodeIds = [] } = body; + return name === EPISODE_SEARCH && episodeIds.indexOf(episodeId) > -1; + }); + + const dispatch = useDispatch(); + + const [isDetailsModalOpen, setDetailsModalOpen, setDetailsModalClosed] = + useModalOpenState(false); + + const handleSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: EPISODE_SEARCH, + episodeIds: [episodeId], + }) + ); + }, [episodeId, dispatch]); + + return ( + + + + + + + + ); +} + +export default EpisodeSearchCell; diff --git a/frontend/src/Episode/EpisodeSearchCellConnector.js b/frontend/src/Episode/EpisodeSearchCellConnector.js deleted file mode 100644 index f1fde7cd2..000000000 --- a/frontend/src/Episode/EpisodeSearchCellConnector.js +++ /dev/null @@ -1,50 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import { isCommandExecuting } from 'Utilities/Command'; -import EpisodeSearchCell from './EpisodeSearchCell'; - -function createMapStateToProps() { - return createSelector( - (state, { episodeId }) => episodeId, - (state, { sceneSeasonNumber }) => sceneSeasonNumber, - createSeriesSelector(), - createCommandsSelector(), - (episodeId, sceneSeasonNumber, series, commands) => { - const isSearching = commands.some((command) => { - const episodeSearch = command.name === commandNames.EPISODE_SEARCH; - - if (!episodeSearch) { - return false; - } - - return ( - isCommandExecuting(command) && - command.body.episodeIds.indexOf(episodeId) > -1 - ); - }); - - return { - seriesMonitored: series.monitored, - seriesType: series.seriesType, - isSearching - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onSearchPress(name, path) { - dispatch(executeCommand({ - name: commandNames.EPISODE_SEARCH, - episodeIds: [props.episodeId] - })); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell); diff --git a/frontend/src/Episode/EpisodeStatus.js b/frontend/src/Episode/EpisodeStatus.tsx similarity index 68% rename from frontend/src/Episode/EpisodeStatus.js rename to frontend/src/Episode/EpisodeStatus.tsx index a70d877b2..b56e32157 100644 --- a/frontend/src/Episode/EpisodeStatus.js +++ b/frontend/src/Episode/EpisodeStatus.tsx @@ -1,34 +1,44 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import { useSelector } from 'react-redux'; import QueueDetails from 'Activity/Queue/QueueDetails'; import Icon from 'Components/Icon'; import ProgressBar from 'Components/ProgressBar'; +import Episode from 'Episode/Episode'; +import useEpisode, { EpisodeEntities } from 'Episode/useEpisode'; +import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; import { icons, kinds, sizes } from 'Helpers/Props'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; import isBefore from 'Utilities/Date/isBefore'; import translate from 'Utilities/String/translate'; import EpisodeQuality from './EpisodeQuality'; import styles from './EpisodeStatus.css'; -function EpisodeStatus(props) { +interface EpisodeStatusProps { + episodeId: number; + episodeEntity?: EpisodeEntities; + episodeFileId: number; +} + +function EpisodeStatus(props: EpisodeStatusProps) { + const { episodeId, episodeEntity = 'episodes', episodeFileId } = props; + const { airDateUtc, monitored, - grabbed, - queueItem, - episodeFile - } = props; + grabbed = false, + } = useEpisode(episodeId, episodeEntity) as Episode; + + const queueItem = useSelector(createQueueItemSelectorForHook(episodeId)); + const episodeFile = useEpisodeFile(episodeFileId); const hasEpisodeFile = !!episodeFile; const isQueued = !!queueItem; const hasAired = isBefore(airDateUtc); if (isQueued) { - const { - sizeleft, - size - } = queueItem; + const { sizeleft, size } = queueItem; - const progress = size ? (100 - sizeleft / size * 100) : 0; + const progress = size ? 100 - (sizeleft / size) * 100 : 0; return (
@@ -76,10 +86,7 @@ function EpisodeStatus(props) { if (!airDateUtc) { return (
- +
); } @@ -109,20 +116,9 @@ function EpisodeStatus(props) { return (
- +
); } -EpisodeStatus.propTypes = { - airDateUtc: PropTypes.string, - monitored: PropTypes.bool.isRequired, - grabbed: PropTypes.bool, - queueItem: PropTypes.object, - episodeFile: PropTypes.object -}; - export default EpisodeStatus; diff --git a/frontend/src/Episode/EpisodeStatusConnector.js b/frontend/src/Episode/EpisodeStatusConnector.js deleted file mode 100644 index ea2d7a1ea..000000000 --- a/frontend/src/Episode/EpisodeStatusConnector.js +++ /dev/null @@ -1,53 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; -import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import EpisodeStatus from './EpisodeStatus'; - -function createMapStateToProps() { - return createSelector( - createEpisodeSelector(), - createQueueItemSelector(), - createEpisodeFileSelector(), - (episode, queueItem, episodeFile) => { - const result = _.pick(episode, [ - 'airDateUtc', - 'monitored', - 'grabbed' - ]); - - result.queueItem = queueItem; - result.episodeFile = episodeFile; - - return result; - } - ); -} - -const mapDispatchToProps = { -}; - -class EpisodeStatusConnector extends Component { - - // - // Render - - render() { - return ( - - ); - } -} - -EpisodeStatusConnector.propTypes = { - episodeId: PropTypes.number.isRequired, - episodeFileId: PropTypes.number.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector); diff --git a/frontend/src/Episode/EpisodeTitleLink.tsx b/frontend/src/Episode/EpisodeTitleLink.tsx index e7455312d..9df6dbf33 100644 --- a/frontend/src/Episode/EpisodeTitleLink.tsx +++ b/frontend/src/Episode/EpisodeTitleLink.tsx @@ -1,13 +1,14 @@ import React, { useCallback, useState } from 'react'; import Link from 'Components/Link/Link'; import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import { EpisodeEntities } from 'Episode/useEpisode'; import FinaleType from './FinaleType'; import styles from './EpisodeTitleLink.css'; interface EpisodeTitleLinkProps { episodeId: number; seriesId: number; - episodeEntity: string; + episodeEntity: EpisodeEntities; episodeTitle: string; finaleType?: string; showOpenSeriesButton: boolean; diff --git a/frontend/src/Episode/SceneInfo.js b/frontend/src/Episode/SceneInfo.js deleted file mode 100644 index dc700c98e..000000000 --- a/frontend/src/Episode/SceneInfo.js +++ /dev/null @@ -1,130 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import padNumber from 'Utilities/Number/padNumber'; -import translate from 'Utilities/String/translate'; -import styles from './SceneInfo.css'; - -function SceneInfo(props) { - const { - seasonNumber, - episodeNumber, - sceneSeasonNumber, - sceneEpisodeNumber, - sceneAbsoluteEpisodeNumber, - alternateTitles, - seriesType - } = props; - - const reducedAlternateTitles = alternateTitles.map((alternateTitle) => { - let suffix = ''; - - const altSceneSeasonNumber = sceneSeasonNumber === undefined ? seasonNumber : sceneSeasonNumber; - const altSceneEpisodeNumber = sceneEpisodeNumber === undefined ? episodeNumber : sceneEpisodeNumber; - - const mappingSeasonNumber = alternateTitle.sceneOrigin === 'tvdb' ? seasonNumber : altSceneSeasonNumber; - const altSeasonNumber = (alternateTitle.sceneSeasonNumber !== -1 && alternateTitle.sceneSeasonNumber !== undefined) ? alternateTitle.sceneSeasonNumber : mappingSeasonNumber; - const altEpisodeNumber = alternateTitle.sceneOrigin === 'tvdb' ? episodeNumber : altSceneEpisodeNumber; - - if (altEpisodeNumber !== altSceneEpisodeNumber) { - suffix = `S${padNumber(altSeasonNumber, 2)}E${padNumber(altEpisodeNumber, 2)}`; - } else if (altSeasonNumber !== altSceneSeasonNumber) { - suffix = `S${padNumber(altSeasonNumber, 2)}`; - } - - return { - alternateTitle, - title: alternateTitle.title, - suffix, - comment: alternateTitle.comment - }; - }); - - const groupedAlternateTitles = _.map(_.groupBy(reducedAlternateTitles, (item) => `${item.title} ${item.suffix}`), (group) => { - return { - title: group[0].title, - suffix: group[0].suffix, - comment: _.uniq(group.map((item) => item.comment)).join('/') - }; - }); - - return ( - - { - sceneSeasonNumber !== undefined && - - } - - { - sceneEpisodeNumber !== undefined && - - } - - { - seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined && - - } - - { - !!alternateTitles.length && - - { - groupedAlternateTitles.map(({ title, suffix, comment }) => { - return ( -
- {title} - { - suffix && - ({suffix}) - } - { - comment && - {comment} - } -
- ); - }) - } -
- } - /> - } - - ); -} - -SceneInfo.propTypes = { - seasonNumber: PropTypes.number, - episodeNumber: PropTypes.number, - sceneSeasonNumber: PropTypes.number, - sceneEpisodeNumber: PropTypes.number, - sceneAbsoluteEpisodeNumber: PropTypes.number, - alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, - seriesType: PropTypes.string -}; - -export default SceneInfo; diff --git a/frontend/src/Episode/SceneInfo.tsx b/frontend/src/Episode/SceneInfo.tsx new file mode 100644 index 000000000..173f5d3f4 --- /dev/null +++ b/frontend/src/Episode/SceneInfo.tsx @@ -0,0 +1,168 @@ +import React, { useMemo } from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import { AlternateTitle } from 'Series/Series'; +import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; +import styles from './SceneInfo.css'; + +interface SceneInfoProps { + seasonNumber?: number; + episodeNumber?: number; + sceneSeasonNumber?: number; + sceneEpisodeNumber?: number; + sceneAbsoluteEpisodeNumber?: number; + alternateTitles: AlternateTitle[]; + seriesType?: string; +} + +function SceneInfo(props: SceneInfoProps) { + const { + seasonNumber, + episodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + alternateTitles, + seriesType, + } = props; + + const groupedAlternateTitles = useMemo(() => { + const reducedAlternateTitles = alternateTitles.map((alternateTitle) => { + let suffix = ''; + + const altSceneSeasonNumber = + sceneSeasonNumber === undefined ? seasonNumber : sceneSeasonNumber; + const altSceneEpisodeNumber = + sceneEpisodeNumber === undefined ? episodeNumber : sceneEpisodeNumber; + + const mappingSeasonNumber = + alternateTitle.sceneOrigin === 'tvdb' + ? seasonNumber + : altSceneSeasonNumber; + const altSeasonNumber = + alternateTitle.sceneSeasonNumber !== -1 && + alternateTitle.sceneSeasonNumber !== undefined + ? alternateTitle.sceneSeasonNumber + : mappingSeasonNumber; + const altEpisodeNumber = + alternateTitle.sceneOrigin === 'tvdb' + ? episodeNumber + : altSceneEpisodeNumber; + + if (altEpisodeNumber !== altSceneEpisodeNumber) { + suffix = `S${padNumber(altSeasonNumber as number, 2)}E${padNumber( + altEpisodeNumber as number, + 2 + )}`; + } else if (altSeasonNumber !== altSceneSeasonNumber) { + suffix = `S${padNumber(altSeasonNumber as number, 2)}`; + } + + return { + alternateTitle, + title: alternateTitle.title, + suffix, + comment: alternateTitle.comment, + }; + }); + + return Object.values( + reducedAlternateTitles.reduce( + ( + acc: Record< + string, + { title: string; suffix: string; comment: string } + >, + alternateTitle + ) => { + const key = alternateTitle.suffix + ? `${alternateTitle.title} ${alternateTitle.suffix}` + : alternateTitle.title; + const item = acc[key]; + + if (item) { + item.comment = alternateTitle.comment + ? `${item.comment}/${alternateTitle.comment}` + : item.comment; + } else { + acc[key] = { + title: alternateTitle.title, + suffix: alternateTitle.suffix, + comment: alternateTitle.comment ?? '', + }; + } + + return acc; + }, + {} + ) + ); + }, [ + alternateTitles, + seasonNumber, + episodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + ]); + + return ( + + {sceneSeasonNumber === undefined ? null : ( + + )} + + {sceneEpisodeNumber === undefined ? null : ( + + )} + + {seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined ? ( + + ) : null} + + {alternateTitles.length ? ( + + {groupedAlternateTitles.map(({ title, suffix, comment }) => { + return ( +
+ {title} + {suffix && ({suffix})} + {comment ? ( + {comment} + ) : null} +
+ ); + })} + + } + /> + ) : null} +
+ ); +} + +export default SceneInfo; diff --git a/frontend/src/Episode/Summary/EpisodeAiring.js b/frontend/src/Episode/Summary/EpisodeAiring.tsx similarity index 50% rename from frontend/src/Episode/Summary/EpisodeAiring.js rename to frontend/src/Episode/Summary/EpisodeAiring.tsx index 7a6f84e57..fd45167f8 100644 --- a/frontend/src/Episode/Summary/EpisodeAiring.js +++ b/frontend/src/Episode/Summary/EpisodeAiring.tsx @@ -1,28 +1,29 @@ import moment from 'moment'; -import PropTypes from 'prop-types'; import React from 'react'; +import { useSelector } from 'react-redux'; import Label from 'Components/Label'; import { kinds, sizes } from 'Helpers/Props'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import formatTime from 'Utilities/Date/formatTime'; import isInNextWeek from 'Utilities/Date/isInNextWeek'; import isToday from 'Utilities/Date/isToday'; import isTomorrow from 'Utilities/Date/isTomorrow'; import translate from 'Utilities/String/translate'; -function EpisodeAiring(props) { - const { - airDateUtc, - network, - shortDateFormat, - showRelativeDates, - timeFormat - } = props; +interface EpisodeAiringProps { + airDateUtc?: string; + network: string; +} + +function EpisodeAiring(props: EpisodeAiringProps) { + const { airDateUtc, network } = props; + + const { shortDateFormat, showRelativeDates, timeFormat } = useSelector( + createUISettingsSelector() + ); const networkLabel = ( -