diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js index c49a50a4e2..85ff4dd7da 100644 --- a/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovie.js @@ -82,8 +82,7 @@ class AddNewMovie extends Component { const { error, items, - hasExistingMovies, - colorImpairedMode + hasExistingMovies } = this.props; const term = this.state.term; @@ -150,7 +149,6 @@ class AddNewMovie extends Component { return ( ); @@ -223,8 +221,7 @@ AddNewMovie.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, hasExistingMovies: PropTypes.bool.isRequired, onMovieLookupChange: PropTypes.func.isRequired, - onClearMovieLookup: PropTypes.func.isRequired, - colorImpairedMode: PropTypes.bool.isRequired + onClearMovieLookup: PropTypes.func.isRequired }; export default AddNewMovie; diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js index 448fc18675..5a05865790 100644 --- a/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieConnector.js @@ -6,7 +6,6 @@ import { clearAddMovie, lookupMovie } from 'Store/Actions/addMovieActions'; import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions'; import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import parseUrl from 'Utilities/String/parseUrl'; @@ -17,15 +16,13 @@ function createMapStateToProps() { (state) => state.addMovie, (state) => state.movies.items.length, (state) => state.router.location, - createUISettingsSelector(), - (addMovie, existingMoviesCount, location, uiSettings) => { + (addMovie, existingMoviesCount, location) => { const { params } = parseUrl(location.search); return { ...addMovie, term: params.term, - hasExistingMovies: existingMoviesCount > 0, - colorImpairedMode: uiSettings.enableColorImpairedMode + hasExistingMovies: existingMoviesCount > 0 }; } ); diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.js index 5a97ad0eb9..e77cc189c7 100644 --- a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.js +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResult.js @@ -74,12 +74,9 @@ class AddNewMovieSearchResult extends Component { isExistingMovie, isExcluded, isSmallScreen, - colorImpairedMode, - id, monitored, isAvailable, movieFile, - queueItem, runtime, movieRuntimeFormat, certification @@ -285,14 +282,12 @@ class AddNewMovieSearchResult extends Component { { isExistingMovie && isSmallScreen && } @@ -337,12 +332,9 @@ AddNewMovieSearchResult.propTypes = { isExistingMovie: PropTypes.bool.isRequired, isExcluded: PropTypes.bool, isSmallScreen: PropTypes.bool.isRequired, - id: PropTypes.number, monitored: PropTypes.bool.isRequired, isAvailable: PropTypes.bool.isRequired, movieFile: PropTypes.object, - queueItem: PropTypes.object, - colorImpairedMode: PropTypes.bool, runtime: PropTypes.number.isRequired, movieRuntimeFormat: PropTypes.string.isRequired, certification: PropTypes.string diff --git a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js index 6e22256bce..ad3a5a3b0f 100644 --- a/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js +++ b/frontend/src/AddMovie/AddNewMovie/AddNewMovieSearchResultConnector.js @@ -8,19 +8,16 @@ function createMapStateToProps() { return createSelector( createExistingMovieSelector(), createDimensionsSelector(), - (state) => state.queue.details.items, (state) => state.movieFiles.items, (state, { internalId }) => internalId, (state) => state.settings.ui.item.movieRuntimeFormat, - (isExistingMovie, dimensions, queueItems, movieFiles, internalId, movieRuntimeFormat) => { - const queueItem = queueItems.find((item) => internalId > 0 && item.movieId === internalId); + (isExistingMovie, dimensions, movieFiles, internalId, movieRuntimeFormat) => { const movieFile = movieFiles.find((item) => internalId > 0 && item.movieId === internalId); return { existingMovieId: internalId, isExistingMovie, isSmallScreen: dimensions.isSmallScreen, - queueItem, movieFile, movieRuntimeFormat }; diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx index e1b2d2e11f..6f660c045c 100644 --- a/frontend/src/App/AppRoutes.tsx +++ b/frontend/src/App/AppRoutes.tsx @@ -10,7 +10,7 @@ import CollectionConnector from 'Collection/CollectionConnector'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector'; -import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector'; +import MovieDetailsPage from 'Movie/Details/MovieDetailsPage'; import MovieIndex from 'Movie/Index/MovieIndex'; import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage'; import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; @@ -67,7 +67,7 @@ function AppRoutes() { - + {/* Calendar diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index fdee6369bd..638c94da0a 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,6 +1,7 @@ import BlocklistAppState from './BlocklistAppState'; import CalendarAppState from './CalendarAppState'; import CommandAppState from './CommandAppState'; +import ExtraFilesAppState from './ExtraFilesAppState'; import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState'; import InteractiveImportAppState from './InteractiveImportAppState'; import MovieBlocklistAppState from './MovieBlocklistAppState'; @@ -53,6 +54,7 @@ export interface CustomFilter { export interface AppSectionState { isConnected: boolean; isReconnecting: boolean; + isSidebarVisible: boolean; version: string; prevVersion?: string; dimensions: { @@ -67,6 +69,7 @@ interface AppState { blocklist: BlocklistAppState; calendar: CalendarAppState; commands: CommandAppState; + extraFiles: ExtraFilesAppState; history: HistoryAppState; interactiveImport: InteractiveImportAppState; movieBlocklist: MovieBlocklistAppState; diff --git a/frontend/src/App/State/ExtraFilesAppState.ts b/frontend/src/App/State/ExtraFilesAppState.ts new file mode 100644 index 0000000000..ef1aff9cd9 --- /dev/null +++ b/frontend/src/App/State/ExtraFilesAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import { ExtraFile } from 'MovieFile/ExtraFile'; + +type ExtraFilesAppState = AppSectionState; + +export default ExtraFilesAppState; diff --git a/frontend/src/Components/Icon.tsx b/frontend/src/Components/Icon.tsx index ea52798402..ff1597bcf3 100644 --- a/frontend/src/Components/Icon.tsx +++ b/frontend/src/Components/Icon.tsx @@ -8,17 +8,20 @@ import { kinds } from 'Helpers/Props'; import { Kind } from 'Helpers/Props/kinds'; import styles from './Icon.css'; +export type IconName = FontAwesomeIconProps['icon']; +export type IconKind = Extract; + export interface IconProps extends Omit< FontAwesomeIconProps, 'icon' | 'spin' | 'name' | 'title' | 'size' > { containerClassName?: ComponentProps<'span'>['className']; - name: FontAwesomeIconProps['icon']; - kind?: Extract; + name: IconName; + kind?: IconKind; size?: number; isSpinning?: FontAwesomeIconProps['spin']; - title?: string | (() => string); + title?: string | (() => string) | null; } export default function Icon({ diff --git a/frontend/src/Components/InfoLabel.css b/frontend/src/Components/InfoLabel.css index 7edc667c8f..cae10be4f7 100644 --- a/frontend/src/Components/InfoLabel.css +++ b/frontend/src/Components/InfoLabel.css @@ -16,6 +16,10 @@ /** Kinds **/ +.default { + color: inherit; +} + /** Sizes **/ .small { diff --git a/frontend/src/Components/InfoLabel.css.d.ts b/frontend/src/Components/InfoLabel.css.d.ts index b5664ee83e..2dd14e33b2 100644 --- a/frontend/src/Components/InfoLabel.css.d.ts +++ b/frontend/src/Components/InfoLabel.css.d.ts @@ -1,6 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { + 'default': string; 'label': string; 'large': string; 'medium': string; diff --git a/frontend/src/Components/InfoLabel.js b/frontend/src/Components/InfoLabel.js deleted file mode 100644 index cdcdff377f..0000000000 --- a/frontend/src/Components/InfoLabel.js +++ /dev/null @@ -1,54 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { kinds, sizes } from 'Helpers/Props'; -import styles from './InfoLabel.css'; - -function InfoLabel(props) { - const { - className, - name, - kind, - size, - outline, - children, - ...otherProps - } = props; - - return ( - -
- {name} -
-
- {children} -
-
- ); -} - -InfoLabel.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - kind: PropTypes.oneOf(kinds.all).isRequired, - size: PropTypes.oneOf(sizes.all).isRequired, - outline: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired -}; - -InfoLabel.defaultProps = { - className: styles.label, - kind: kinds.DEFAULT, - size: sizes.SMALL, - outline: false -}; - -export default InfoLabel; diff --git a/frontend/src/Components/InfoLabel.tsx b/frontend/src/Components/InfoLabel.tsx new file mode 100644 index 0000000000..dadf5e4b6a --- /dev/null +++ b/frontend/src/Components/InfoLabel.tsx @@ -0,0 +1,41 @@ +import classNames from 'classnames'; +import React, { ComponentProps, ReactNode } from 'react'; +import { Kind } from 'Helpers/Props/kinds'; +import { Size } from 'Helpers/Props/sizes'; +import styles from './InfoLabel.css'; + +interface InfoLabelProps extends ComponentProps<'span'> { + className?: string; + name: string; + kind?: Extract; + size?: Extract; + outline?: boolean; + children: ReactNode; +} + +function InfoLabel({ + className = styles.label, + name, + kind = 'default', + size = 'small', + outline = false, + children, + ...otherProps +}: InfoLabelProps) { + return ( + +
{name}
+
{children}
+
+ ); +} + +export default InfoLabel; diff --git a/frontend/src/Components/Marquee.js b/frontend/src/Components/Marquee.js index c0c7ec30c6..c6195dca62 100644 --- a/frontend/src/Components/Marquee.js +++ b/frontend/src/Components/Marquee.js @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; @@ -7,26 +8,15 @@ const TIMEOUT = 1 / FPS * 1000; class Marquee extends Component { - static propTypes = { - text: PropTypes.string, - title: PropTypes.string, - hoverToStop: PropTypes.bool, - loop: PropTypes.bool, - className: PropTypes.string - }; + constructor(props, context) { + super(props, context); - static defaultProps = { - text: '', - title: '', - hoverToStop: true, - loop: false - }; - - state = { - animatedWidth: 0, - overflowWidth: 0, - direction: 0 - }; + this.state = { + animatedWidth: 0, + overflowWidth: 0, + direction: 0 + }; + } componentDidMount() { this.measureText(); @@ -138,7 +128,7 @@ class Marquee extends Component { ref={(el) => { this.container = el; }} - className={`ui-marquee ${this.props.className}`} + className={classNames('ui-marquee', this.props.className)} style={{ overflow: 'hidden' }} > { this.container = el; }} - className={`ui-marquee ${this.props.className}`.trim()} + className={classNames('ui-marquee', this.props.className)} style={{ overflow: 'hidden' }} onMouseEnter={this.onHandleMouseEnter} onMouseLeave={this.onHandleMouseLeave} @@ -178,4 +168,20 @@ class Marquee extends Component { } } +Marquee.propTypes = { + text: PropTypes.string, + title: PropTypes.string, + hoverToStop: PropTypes.bool, + loop: PropTypes.bool, + className: PropTypes.string +}; + +Marquee.defaultProps = { + text: '', + title: '', + hoverToStop: true, + loop: false, + className: '' +}; + export default Marquee; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js deleted file mode 100644 index c93603aa94..0000000000 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js +++ /dev/null @@ -1,58 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons } from 'Helpers/Props'; -import styles from './PageToolbarButton.css'; - -function PageToolbarButton(props) { - const { - label, - iconName, - spinningName, - isDisabled, - isSpinning, - ...otherProps - } = props; - - return ( - - - -
-
- {label} -
-
- - ); -} - -PageToolbarButton.propTypes = { - label: PropTypes.string.isRequired, - iconName: PropTypes.object.isRequired, - spinningName: PropTypes.object, - isSpinning: PropTypes.bool, - isDisabled: PropTypes.bool, - onPress: PropTypes.func -}; - -PageToolbarButton.defaultProps = { - spinningName: icons.SPINNER, - isDisabled: false, - isSpinning: false -}; - -export default PageToolbarButton; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.tsx b/frontend/src/Components/Page/Toolbar/PageToolbarButton.tsx new file mode 100644 index 0000000000..1b87c09fa2 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.tsx @@ -0,0 +1,49 @@ +import classNames from 'classnames'; +import React from 'react'; +import Icon, { IconName } from 'Components/Icon'; +import Link, { LinkProps } from 'Components/Link/Link'; +import { icons } from 'Helpers/Props'; +import styles from './PageToolbarButton.css'; + +export interface PageToolbarButtonProps extends LinkProps { + label: string; + iconName: IconName; + spinningName?: IconName; + isSpinning?: boolean; + isDisabled?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + overflowComponent?: React.ComponentType; +} + +function PageToolbarButton({ + label, + iconName, + spinningName = icons.SPINNER, + isDisabled = false, + isSpinning = false, + overflowComponent, + ...otherProps +}: PageToolbarButtonProps) { + return ( + + + +
+
{label}
+
+ + ); +} + +export default PageToolbarButton; diff --git a/frontend/src/Helpers/Props/kinds.ts b/frontend/src/Helpers/Props/kinds.ts index fc94defbbb..cb699eab09 100644 --- a/frontend/src/Helpers/Props/kinds.ts +++ b/frontend/src/Helpers/Props/kinds.ts @@ -36,4 +36,5 @@ export type Kind = | 'primary' | 'purple' | 'success' - | 'warning'; + | 'warning' + | 'queue'; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx index ef99019692..8a014fbca8 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.tsx @@ -192,10 +192,9 @@ const importModeSelector = createSelector( } ); -interface InteractiveImportModalContentProps { +export interface InteractiveImportModalContentProps { downloadId?: string; movieId?: number; - seasonNumber?: number; showMovie?: boolean; allowMovieChange?: boolean; showDelete?: boolean; @@ -217,7 +216,6 @@ function InteractiveImportModalContent( const { downloadId, movieId, - seasonNumber, allowMovieChange = true, showMovie = true, showFilterExistingFiles = false, @@ -343,7 +341,6 @@ function InteractiveImportModalContent( fetchInteractiveImportItems({ downloadId, movieId, - seasonNumber, folder, filterExistingFiles, }) diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.tsx b/frontend/src/InteractiveImport/InteractiveImportModal.tsx index 37b26012e9..28f8a823e7 100644 --- a/frontend/src/InteractiveImport/InteractiveImportModal.tsx +++ b/frontend/src/InteractiveImport/InteractiveImportModal.tsx @@ -4,9 +4,12 @@ import usePrevious from 'Helpers/Hooks/usePrevious'; import { sizes } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import InteractiveImportSelectFolderModalContent from './Folder/InteractiveImportSelectFolderModalContent'; -import InteractiveImportModalContent from './Interactive/InteractiveImportModalContent'; +import InteractiveImportModalContent, { + InteractiveImportModalContentProps, +} from './Interactive/InteractiveImportModalContent'; -interface InteractiveImportModalProps { +interface InteractiveImportModalProps + extends Omit { isOpen: boolean; folder?: string; downloadId?: string; diff --git a/frontend/src/Movie/Details/MovieDetails.css b/frontend/src/Movie/Details/MovieDetails.css index 1c8f955fc9..36a23987ae 100644 --- a/frontend/src/Movie/Details/MovieDetails.css +++ b/frontend/src/Movie/Details/MovieDetails.css @@ -160,9 +160,8 @@ } .overview { - flex: 1 0 auto; + flex: 1 0 0; margin-top: 8px; - padding-left: 7px; min-height: 0; font-size: $intermediateFontSize; } diff --git a/frontend/src/Movie/Details/MovieDetails.js b/frontend/src/Movie/Details/MovieDetails.js deleted file mode 100644 index 1b01f93d93..0000000000 --- a/frontend/src/Movie/Details/MovieDetails.js +++ /dev/null @@ -1,835 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextTruncate from 'react-text-truncate'; -import Alert from 'Components/Alert'; -import FieldSet from 'Components/FieldSet'; -import Icon from 'Components/Icon'; -import ImdbRating from 'Components/ImdbRating'; -import InfoLabel from 'Components/InfoLabel'; -import IconButton from 'Components/Link/IconButton'; -import Marquee from 'Components/Marquee'; -import Measure from 'Components/Measure'; -import MonitorToggleButton from 'Components/MonitorToggleButton'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; -import RottenTomatoRating from 'Components/RottenTomatoRating'; -import TmdbRating from 'Components/TmdbRating'; -import Popover from 'Components/Tooltip/Popover'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import TraktRating from 'Components/TraktRating'; -import { icons, kinds, sizes, sortDirections, tooltipPositions } from 'Helpers/Props'; -import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; -import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; -import EditMovieModal from 'Movie/Edit/EditMovieModal'; -import getMovieStatusDetails from 'Movie/getMovieStatusDetails'; -import MovieHistoryModal from 'Movie/History/MovieHistoryModal'; -import MovieCollectionLabel from 'Movie/MovieCollectionLabel'; -import MovieGenres from 'Movie/MovieGenres'; -import MoviePoster from 'Movie/MoviePoster'; -import MovieInteractiveSearchModal from 'Movie/Search/MovieInteractiveSearchModal'; -import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable'; -import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable'; -import OrganizePreviewModal from 'Organize/OrganizePreviewModal'; -import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; -import fonts from 'Styles/Variables/fonts'; -import * as keyCodes from 'Utilities/Constants/keyCodes'; -import formatRuntime from 'Utilities/Date/formatRuntime'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import MovieCastPosters from './Credits/Cast/MovieCastPosters'; -import MovieCrewPosters from './Credits/Crew/MovieCrewPosters'; -import MovieDetailsLinks from './MovieDetailsLinks'; -import MovieReleaseDates from './MovieReleaseDates'; -import MovieStatusLabel from './MovieStatusLabel'; -import MovieTagsConnector from './MovieTagsConnector'; -import MovieTitlesTable from './Titles/MovieTitlesTable'; -import styles from './MovieDetails.css'; - -const defaultFontSize = parseInt(fonts.defaultFontSize); -const lineHeight = parseFloat(fonts.lineHeight); - -function getFanartUrl(images) { - const image = images.find((img) => img.coverType === 'fanart'); - return image?.url ?? image?.remoteUrl; -} - -class MovieDetails extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isOrganizeModalOpen: false, - isEditMovieModalOpen: false, - isDeleteMovieModalOpen: false, - isInteractiveImportModalOpen: false, - isInteractiveSearchModalOpen: false, - isMovieHistoryModalOpen: false, - overviewHeight: 0, - titleWidth: 0 - }; - } - - componentDidMount() { - window.addEventListener('touchstart', this.onTouchStart); - window.addEventListener('touchend', this.onTouchEnd); - window.addEventListener('touchcancel', this.onTouchCancel); - window.addEventListener('touchmove', this.onTouchMove); - window.addEventListener('keyup', this.onKeyUp); - } - - componentWillUnmount() { - window.removeEventListener('touchstart', this.onTouchStart); - window.removeEventListener('touchend', this.onTouchEnd); - window.removeEventListener('touchcancel', this.onTouchCancel); - window.removeEventListener('touchmove', this.onTouchMove); - window.removeEventListener('keyup', this.onKeyUp); - } - - // - // Listeners - - onOrganizePress = () => { - this.setState({ isOrganizeModalOpen: true }); - }; - - onOrganizeModalClose = () => { - this.setState({ isOrganizeModalOpen: false }); - }; - - onInteractiveImportPress = () => { - this.setState({ isInteractiveImportModalOpen: true }); - }; - - onInteractiveImportModalClose = () => { - this.setState({ isInteractiveImportModalOpen: false }); - }; - - onEditMoviePress = () => { - this.setState({ isEditMovieModalOpen: true }); - }; - - onEditMovieModalClose = () => { - this.setState({ isEditMovieModalOpen: false }); - }; - - onInteractiveSearchPress = () => { - this.setState({ isInteractiveSearchModalOpen: true }); - }; - - onInteractiveSearchModalClose = () => { - this.setState({ isInteractiveSearchModalOpen: false }); - }; - - onDeleteMoviePress = () => { - this.setState({ - isEditMovieModalOpen: false, - isDeleteMovieModalOpen: true - }); - }; - - onDeleteMovieModalClose = () => { - this.setState({ isDeleteMovieModalOpen: false }); - }; - - onMovieHistoryPress = () => { - this.setState({ isMovieHistoryModalOpen: true }); - }; - - onMovieHistoryModalClose = () => { - this.setState({ isMovieHistoryModalOpen: false }); - }; - - onMeasure = ({ height }) => { - this.setState({ overviewHeight: height }); - }; - - onTitleMeasure = ({ width }) => { - this.setState({ titleWidth: width }); - }; - - onKeyUp = (event) => { - if (event.composedPath && event.composedPath().length === 4) { - if (event.keyCode === keyCodes.LEFT_ARROW) { - this.props.onGoToMovie(this.props.previousMovie.titleSlug); - } - if (event.keyCode === keyCodes.RIGHT_ARROW) { - this.props.onGoToMovie(this.props.nextMovie.titleSlug); - } - } - }; - - onTouchStart = (event) => { - const touches = event.touches; - const touchStart = touches[0].pageX; - const touchY = touches[0].pageY; - - // Only change when swipe is on header, we need horizontal scroll on tables - if (touchY > 470) { - return; - } - - if (touches.length !== 1) { - return; - } - - if ( - touchStart < 50 || - this.props.isSidebarVisible || - this.state.isOrganizeModalOpen || - this.state.isEditMovieModalOpen || - this.state.isDeleteMovieModalOpen || - this.state.isInteractiveImportModalOpen || - this.state.isInteractiveSearchModalOpen || - this.state.isMovieHistoryModalOpen - ) { - return; - } - - this._touchStart = touchStart; - }; - - onTouchEnd = (event) => { - const touches = event.changedTouches; - const currentTouch = touches[0].pageX; - - if (!this._touchStart) { - return; - } - - if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) { - this.props.onGoToMovie(this.props.previousMovie.titleSlug); - } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) { - this.props.onGoToMovie(this.props.nextMovie.titleSlug); - } - - this._touchStart = null; - }; - - onTouchCancel = (event) => { - this._touchStart = null; - }; - - onTouchMove = (event) => { - if (!this._touchStart) { - return; - } - }; - - // - // Render - - render() { - const { - id, - tmdbId, - imdbId, - title, - originalTitle, - year, - inCinemas, - physicalRelease, - digitalRelease, - runtime, - certification, - ratings, - path, - statistics, - qualityProfileId, - monitored, - studio, - originalLanguage, - genres, - collection, - overview, - status, - youTubeTrailerId, - isAvailable, - images, - tags, - isSaving, - isRefreshing, - isSearching, - isFetching, - isSmallScreen, - movieFilesError, - movieCreditsError, - extraFilesError, - hasMovieFiles, - previousMovie, - nextMovie, - onMonitorTogglePress, - onRefreshPress, - onSearchPress, - queueItem, - movieRuntimeFormat - } = this.props; - - const { - sizeOnDisk = 0 - } = statistics; - - const { - isOrganizeModalOpen, - isEditMovieModalOpen, - isDeleteMovieModalOpen, - isInteractiveImportModalOpen, - isInteractiveSearchModalOpen, - isMovieHistoryModalOpen, - overviewHeight, - titleWidth - } = this.state; - - const statusDetails = getMovieStatusDetails(status); - - const fanartUrl = getFanartUrl(images); - const marqueeWidth = isSmallScreen ? titleWidth : (titleWidth - 150); - - const titleWithYear = `${title}${year > 0 ? ` (${year})` : ''}`; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- -
- - -
- -
-
-
- -
- -
- - - - -
- - - -
- - - -
-
- { - certification ? - - {certification} - : - null - } - - - 0 ? ( - year - ) : ( - - ) - } - title={translate('ReleaseDates')} - body={ - - } - position={tooltipPositions.BOTTOM} - /> - - - { - runtime ? - - {formatRuntime(runtime, movieRuntimeFormat)} - : - null - } - - { - - - } - tooltip={ - - } - position={tooltipPositions.BOTTOM} - /> - - } - - { - !!tags.length && - - - } - tooltip={ - - } - position={tooltipPositions.BOTTOM} - /> - - } -
-
- -
- { - ratings.tmdb ? - - - : - null - } - { - ratings.imdb ? - - - : - null - } - { - ratings.rottenTomatoes ? - - - : - null - } - { - ratings.trakt ? - - - : - null - } -
- -
- - - {path} - - - - - - - - - - - - { - - } - - - - - - {formatBytes(sizeOnDisk)} - - - - { - collection ? - -
- -
-
: - null - } - - { - originalLanguage?.name && !isSmallScreen ? - - - {originalLanguage.name} - - : - null - } - - { - studio && !isSmallScreen ? - - - {studio} - - : - null - } - - { - genres.length && !isSmallScreen ? - - - : - null - } -
- - -
- -
-
- - - - -
- { - !isFetching && movieFilesError ? - - {translate('LoadingMovieFilesFailed')} - : - null - } - - { - !isFetching && movieCreditsError ? - - {translate('LoadingMovieCreditsFailed')} - : - null - } - - { - !isFetching && extraFilesError ? - - {translate('LoadingMovieExtraFilesFailed')} - : - null - } - -
- - - -
- -
- -
- -
- -
- -
- -
-
- - - - - - - - - - - - - - - ); - } -} - -MovieDetails.propTypes = { - id: PropTypes.number.isRequired, - tmdbId: PropTypes.number.isRequired, - imdbId: PropTypes.string, - title: PropTypes.string.isRequired, - originalTitle: PropTypes.string, - year: PropTypes.number.isRequired, - runtime: PropTypes.number.isRequired, - certification: PropTypes.string, - ratings: PropTypes.object.isRequired, - path: PropTypes.string.isRequired, - statistics: PropTypes.object.isRequired, - qualityProfileId: PropTypes.number.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - studio: PropTypes.string, - originalLanguage: PropTypes.object, - genres: PropTypes.arrayOf(PropTypes.string).isRequired, - collection: PropTypes.object, - youTubeTrailerId: PropTypes.string, - isAvailable: PropTypes.bool.isRequired, - inCinemas: PropTypes.string, - physicalRelease: PropTypes.string, - digitalRelease: PropTypes.string, - overview: PropTypes.string.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired, - tags: PropTypes.arrayOf(PropTypes.number).isRequired, - isSaving: PropTypes.bool.isRequired, - isRefreshing: PropTypes.bool.isRequired, - isSearching: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - isSidebarVisible: PropTypes.bool.isRequired, - movieFilesError: PropTypes.object, - movieCreditsError: PropTypes.object, - extraFilesError: PropTypes.object, - hasMovieFiles: PropTypes.bool.isRequired, - previousMovie: PropTypes.object.isRequired, - nextMovie: PropTypes.object.isRequired, - onMonitorTogglePress: PropTypes.func.isRequired, - onRefreshPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired, - onGoToMovie: PropTypes.func.isRequired, - queueItem: PropTypes.object, - movieRuntimeFormat: PropTypes.string.isRequired -}; - -MovieDetails.defaultProps = { - genres: [], - statistics: {}, - tags: [], - isSaving: false -}; - -export default MovieDetails; diff --git a/frontend/src/Movie/Details/MovieDetails.tsx b/frontend/src/Movie/Details/MovieDetails.tsx new file mode 100644 index 0000000000..c1b615670f --- /dev/null +++ b/frontend/src/Movie/Details/MovieDetails.tsx @@ -0,0 +1,956 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router'; +import TextTruncate from 'react-text-truncate'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import ImdbRating from 'Components/ImdbRating'; +import InfoLabel from 'Components/InfoLabel'; +import IconButton from 'Components/Link/IconButton'; +import Marquee from 'Components/Marquee'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import RottenTomatoRating from 'Components/RottenTomatoRating'; +import TmdbRating from 'Components/TmdbRating'; +import Popover from 'Components/Tooltip/Popover'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import TraktRating from 'Components/TraktRating'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { + icons, + kinds, + sizes, + sortDirections, + tooltipPositions, +} from 'Helpers/Props'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; +import EditMovieModal from 'Movie/Edit/EditMovieModal'; +import getMovieStatusDetails from 'Movie/getMovieStatusDetails'; +import MovieHistoryModal from 'Movie/History/MovieHistoryModal'; +import { Image, Statistics } from 'Movie/Movie'; +import MovieCollectionLabel from 'Movie/MovieCollectionLabel'; +import MovieGenres from 'Movie/MovieGenres'; +import MoviePoster from 'Movie/MoviePoster'; +import MovieInteractiveSearchModal from 'Movie/Search/MovieInteractiveSearchModal'; +import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable'; +import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable'; +import OrganizePreviewModal from 'Organize/OrganizePreviewModal'; +import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { + clearExtraFiles, + fetchExtraFiles, +} from 'Store/Actions/extraFileActions'; +import { toggleMovieMonitored } from 'Store/Actions/movieActions'; +import { + clearMovieCredits, + fetchMovieCredits, +} from 'Store/Actions/movieCreditsActions'; +import { + clearMovieFiles, + fetchMovieFiles, +} from 'Store/Actions/movieFileActions'; +import { + clearQueueDetails, + fetchQueueDetails, +} from 'Store/Actions/queueActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { fetchImportListSchema } from 'Store/Actions/Settings/importLists'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import fonts from 'Styles/Variables/fonts'; +import sortByProp from 'Utilities/Array/sortByProp'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import formatRuntime from 'Utilities/Date/formatRuntime'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import MovieCastPosters from './Credits/Cast/MovieCastPosters'; +import MovieCrewPosters from './Credits/Crew/MovieCrewPosters'; +import MovieDetailsLinks from './MovieDetailsLinks'; +import MovieReleaseDates from './MovieReleaseDates'; +import MovieStatusLabel from './MovieStatusLabel'; +import MovieTags from './MovieTags'; +import MovieTitlesTable from './Titles/MovieTitlesTable'; +import styles from './MovieDetails.css'; + +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +function getFanartUrl(images: Image[]) { + const image = images.find((image) => image.coverType === 'fanart'); + return image?.url ?? image?.remoteUrl; +} + +function createMovieFilesSelector() { + return createSelector( + (state: AppState) => state.movieFiles, + ({ items, isFetching, isPopulated, error }) => { + const hasMovieFiles = !!items.length; + + return { + isMovieFilesFetching: isFetching, + isMovieFilesPopulated: isPopulated, + movieFilesError: error, + hasMovieFiles, + }; + } + ); +} + +function createExtraFilesSelector() { + return createSelector( + (state: AppState) => state.extraFiles, + ({ isFetching, isPopulated, error }) => { + return { + isExtraFilesFetching: isFetching, + isExtraFilesPopulated: isPopulated, + extraFilesError: error, + }; + } + ); +} + +function createMovieCreditsSelector() { + return createSelector( + (state: AppState) => state.movieCredits, + ({ isFetching, isPopulated, error }) => { + return { + isMovieCreditsFetching: isFetching, + isMovieCreditsPopulated: isPopulated, + movieCreditsError: error, + }; + } + ); +} + +function createMovieSelector(movieId: number) { + return createSelector(createAllMoviesSelector(), (allMovies) => { + const sortedMovies = [...allMovies].sort(sortByProp('sortTitle')); + const movieIndex = sortedMovies.findIndex((movie) => movie.id === movieId); + + if (movieIndex === -1) { + return { + movie: undefined, + nextMovie: undefined, + previousMovie: undefined, + }; + } + + const movie = sortedMovies[movieIndex]; + const nextMovie = sortedMovies[movieIndex + 1] ?? sortedMovies[0]; + const previousMovie = + sortedMovies[movieIndex - 1] ?? sortedMovies[sortedMovies.length - 1]; + + return { + movie, + nextMovie: { + title: nextMovie.title, + titleSlug: nextMovie.titleSlug, + }, + previousMovie: { + title: previousMovie.title, + titleSlug: previousMovie.titleSlug, + }, + }; + }); +} + +interface MovieDetailsProps { + movieId: number; +} + +function MovieDetails({ movieId }: MovieDetailsProps) { + const dispatch = useDispatch(); + const history = useHistory(); + + const { movie, nextMovie, previousMovie } = useSelector( + createMovieSelector(movieId) + ); + const { isMovieFilesFetching, movieFilesError, hasMovieFiles } = useSelector( + createMovieFilesSelector() + ); + const { isExtraFilesFetching, extraFilesError } = useSelector( + createExtraFilesSelector() + ); + const { isMovieCreditsFetching, movieCreditsError } = useSelector( + createMovieCreditsSelector() + ); + const { movieRuntimeFormat } = useSelector(createUISettingsSelector()); + const isSidebarVisible = useSelector( + (state: AppState) => state.app.isSidebarVisible + ); + const { isSmallScreen } = useSelector(createDimensionsSelector()); + + const commands = useSelector(createCommandsSelector()); + const isSaving = useSelector((state: AppState) => state.movies.isSaving); + + const { isRefreshing, isRenaming, isSearching } = useMemo(() => { + const movieRefreshingCommand = findCommand(commands, { + name: commandNames.REFRESH_MOVIE, + }); + + const isMovieRefreshingCommandExecuting = isCommandExecuting( + movieRefreshingCommand + ); + + const allMoviesRefreshing = + isMovieRefreshingCommandExecuting && + !movieRefreshingCommand?.body.movieIds?.length; + + const isMovieRefreshing = + isMovieRefreshingCommandExecuting && + movieRefreshingCommand?.body.movieIds?.includes(movieId); + + const isSearchingExecuting = isCommandExecuting( + findCommand(commands, { + name: commandNames.MOVIE_SEARCH, + movieIds: [movieId], + }) + ); + + const isRenamingFiles = isCommandExecuting( + findCommand(commands, { + name: commandNames.RENAME_FILES, + movieId, + }) + ); + + const isRenamingMovieCommand = findCommand(commands, { + name: commandNames.RENAME_MOVIE, + }); + + const isRenamingMovie = + isCommandExecuting(isRenamingMovieCommand) && + isRenamingMovieCommand?.body?.movieIds?.includes(movieId); + + return { + isRefreshing: isMovieRefreshing || allMoviesRefreshing, + isRenaming: isRenamingFiles || isRenamingMovie, + isSearching: isSearchingExecuting, + }; + }, [movieId, commands]); + + const touchStart = useRef(null); + const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false); + const [isManageMoviesModalOpen, setIsManageMoviesModalOpen] = useState(false); + const [isInteractiveSearchModalOpen, setIsInteractiveSearchModalOpen] = + useState(false); + const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false); + const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false); + const [isMovieHistoryModalOpen, setIsMovieHistoryModalOpen] = useState(false); + const [titleRef, { width: titleWidth }] = useMeasure(); + const [overviewRef, { height: overviewHeight }] = useMeasure(); + const wasRefreshing = usePrevious(isRefreshing); + const wasRenaming = usePrevious(isRenaming); + + const handleOrganizePress = useCallback(() => { + setIsOrganizeModalOpen(true); + }, []); + + const handleOrganizeModalClose = useCallback(() => { + setIsOrganizeModalOpen(false); + }, []); + + const handleManageMoviesPress = useCallback(() => { + setIsManageMoviesModalOpen(true); + }, []); + + const handleManageMoviesModalClose = useCallback(() => { + setIsManageMoviesModalOpen(false); + }, []); + + const handleInteractiveSearchPress = useCallback(() => { + setIsInteractiveSearchModalOpen(true); + }, []); + + const handleInteractiveSearchModalClose = useCallback(() => { + setIsInteractiveSearchModalOpen(false); + }, []); + + const handleEditMoviePress = useCallback(() => { + setIsEditMovieModalOpen(true); + }, []); + + const handleEditMovieModalClose = useCallback(() => { + setIsEditMovieModalOpen(false); + }, []); + + const handleDeleteMoviePress = useCallback(() => { + setIsEditMovieModalOpen(false); + setIsDeleteMovieModalOpen(true); + }, []); + + const handleDeleteMovieModalClose = useCallback(() => { + setIsDeleteMovieModalOpen(false); + }, []); + + const handleMovieHistoryPress = useCallback(() => { + setIsMovieHistoryModalOpen(true); + }, []); + + const handleMovieHistoryModalClose = useCallback(() => { + setIsMovieHistoryModalOpen(false); + }, []); + + const handleMonitorTogglePress = useCallback( + (value: boolean) => { + dispatch( + toggleMovieMonitored({ + movieId, + monitored: value, + }) + ); + }, + [movieId, dispatch] + ); + + const handleRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: commandNames.REFRESH_MOVIE, + movieIds: [movieId], + }) + ); + }, [movieId, dispatch]); + + const handleSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: commandNames.MOVIE_SEARCH, + movieIds: [movieId], + }) + ); + }, [movieId, dispatch]); + + const handleTouchStart = useCallback( + (event: TouchEvent) => { + const touches = event.touches; + const currentTouch = touches[0].pageX; + const touchY = touches[0].pageY; + + // Only change when swipe is on header, we need horizontal scroll on tables + if (touchY > 470) { + return; + } + + if (touches.length !== 1) { + return; + } + + if ( + currentTouch < 50 || + isSidebarVisible || + isOrganizeModalOpen || + isEditMovieModalOpen || + isDeleteMovieModalOpen || + isManageMoviesModalOpen || + isInteractiveSearchModalOpen || + isMovieHistoryModalOpen + ) { + return; + } + + touchStart.current = currentTouch; + }, + [ + isSidebarVisible, + isOrganizeModalOpen, + isEditMovieModalOpen, + isDeleteMovieModalOpen, + isManageMoviesModalOpen, + isInteractiveSearchModalOpen, + isMovieHistoryModalOpen, + ] + ); + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!touchStart.current) { + return; + } + + if ( + currentTouch > touchStart.current && + currentTouch - touchStart.current > 100 && + previousMovie !== undefined + ) { + history.push(getPathWithUrlBase(`/movie/${previousMovie.titleSlug}`)); + } else if ( + currentTouch < touchStart.current && + touchStart.current - currentTouch > 100 && + nextMovie !== undefined + ) { + history.push(getPathWithUrlBase(`/movie/${nextMovie.titleSlug}`)); + } + + touchStart.current = null; + }, + [previousMovie, nextMovie, history] + ); + + const handleTouchCancel = useCallback(() => { + touchStart.current = null; + }, []); + + const handleTouchMove = useCallback(() => { + if (!touchStart.current) { + return; + } + }, []); + + const handleKeyUp = useCallback( + (event: KeyboardEvent) => { + if (event.composedPath && event.composedPath().length === 4) { + if (event.key === 'ArrowLeft' && previousMovie !== undefined) { + history.push(getPathWithUrlBase(`/movie/${previousMovie.titleSlug}`)); + } + + if (event.key === 'ArrowRight' && nextMovie !== undefined) { + history.push(getPathWithUrlBase(`/movie/${nextMovie.titleSlug}`)); + } + } + }, + [previousMovie, nextMovie, history] + ); + + const populate = useCallback(() => { + dispatch(fetchMovieFiles({ movieId })); + dispatch(fetchExtraFiles({ movieId })); + dispatch(fetchMovieCredits({ movieId })); + dispatch(fetchQueueDetails({ movieId })); + dispatch(fetchImportListSchema()); + dispatch(fetchRootFolders()); + }, [movieId, dispatch]); + + useEffect(() => { + populate(); + }, [populate]); + + useEffect(() => { + registerPagePopulator(populate, ['movieUpdated']); + + return () => { + unregisterPagePopulator(populate); + dispatch(clearMovieFiles()); + dispatch(clearExtraFiles()); + dispatch(clearMovieCredits()); + dispatch(clearQueueDetails()); + }; + }, [populate, dispatch]); + + useEffect(() => { + if ((!isRefreshing && wasRefreshing) || (!isRenaming && wasRenaming)) { + populate(); + } + }, [isRefreshing, wasRefreshing, isRenaming, wasRenaming, populate]); + + useEffect(() => { + window.addEventListener('touchstart', handleTouchStart); + window.addEventListener('touchend', handleTouchEnd); + window.addEventListener('touchcancel', handleTouchCancel); + window.addEventListener('touchmove', handleTouchMove); + window.addEventListener('keyup', handleKeyUp); + + return () => { + window.removeEventListener('touchstart', handleTouchStart); + window.removeEventListener('touchend', handleTouchEnd); + window.removeEventListener('touchcancel', handleTouchCancel); + window.removeEventListener('touchmove', handleTouchMove); + window.removeEventListener('keyup', handleKeyUp); + }; + }, [ + handleTouchStart, + handleTouchEnd, + handleTouchCancel, + handleTouchMove, + handleKeyUp, + ]); + + if (!movie) { + return null; + } + + const { + id, + tmdbId, + imdbId, + title, + originalTitle, + year, + inCinemas, + physicalRelease, + digitalRelease, + runtime, + certification, + ratings, + path, + statistics = {} as Statistics, + qualityProfileId, + monitored, + studio, + originalLanguage, + genres = [], + collection, + overview, + status, + youTubeTrailerId, + isAvailable, + images, + tags, + } = movie; + + const { sizeOnDisk = 0 } = statistics; + + const statusDetails = getMovieStatusDetails(status); + + const fanartUrl = getFanartUrl(images); + const isFetching = + isMovieFilesFetching || isExtraFilesFetching || isMovieCreditsFetching; + + const marqueeWidth = isSmallScreen ? titleWidth : titleWidth - 150; + + const titleWithYear = `${title}${year > 0 ? ` (${year})` : ''}`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ + +
+
+
+
+ +
+ +
+ + + + +
+ + + +
+ + +
+
+ {certification ? ( + + {certification} + + ) : null} + + + 0 ? ( + year + ) : ( + + ) + } + title={translate('ReleaseDates')} + body={ + + } + position={tooltipPositions.BOTTOM} + /> + + + {runtime ? ( + + {formatRuntime(runtime, movieRuntimeFormat)} + + ) : null} + + + } + tooltip={ + + } + position={tooltipPositions.BOTTOM} + /> + + + {!!tags.length && ( + + } + tooltip={} + position={tooltipPositions.BOTTOM} + /> + + )} +
+
+ +
+ {ratings.tmdb ? ( + + + + ) : null} + {ratings.imdb ? ( + + + + ) : null} + {ratings.rottenTomatoes ? ( + + + + ) : null} + {ratings.trakt ? ( + + + + ) : null} +
+ +
+ + {path} + + + + + + + + + + + + + + + + + {formatBytes(sizeOnDisk)} + + + + {collection ? ( + +
+ +
+
+ ) : null} + + {originalLanguage?.name && !isSmallScreen ? ( + + + {originalLanguage.name} + + + ) : null} + + {studio && !isSmallScreen ? ( + + {studio} + + ) : null} + + {genres.length && !isSmallScreen ? ( + + + + ) : null} +
+ +
+ +
+ + + + +
+ {!isFetching && movieFilesError ? ( + + {translate('LoadingMovieFilesFailed')} + + ) : null} + + {!isFetching && extraFilesError ? ( + + {translate('LoadingMovieExtraFilesFailed')} + + ) : null} + + {!isFetching && movieCreditsError ? ( + + {translate('LoadingMovieCreditsFailed')} + + ) : null} + +
+ + + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + ); +} + +export default MovieDetails; diff --git a/frontend/src/Movie/Details/MovieDetailsConnector.js b/frontend/src/Movie/Details/MovieDetailsConnector.js deleted file mode 100644 index a3af095f9b..0000000000 --- a/frontend/src/Movie/Details/MovieDetailsConnector.js +++ /dev/null @@ -1,334 +0,0 @@ -import { push } from 'connected-react-router'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { clearExtraFiles, fetchExtraFiles } from 'Store/Actions/extraFileActions'; -import { toggleMovieMonitored } from 'Store/Actions/movieActions'; -import { clearMovieCredits, fetchMovieCredits } from 'Store/Actions/movieCreditsActions'; -import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions'; -import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; -import { fetchImportListSchema } from 'Store/Actions/settingsActions'; -import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import { findCommand, isCommandExecuting } from 'Utilities/Command'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import MovieDetails from './MovieDetails'; - -const selectMovieFiles = createSelector( - (state) => state.movieFiles, - (movieFiles) => { - const { - items, - isFetching, - isPopulated, - error - } = movieFiles; - - const hasMovieFiles = !!items.length; - - return { - isMovieFilesFetching: isFetching, - isMovieFilesPopulated: isPopulated, - movieFilesError: error, - hasMovieFiles - }; - } -); - -const selectMovieCredits = createSelector( - (state) => state.movieCredits, - (movieCredits) => { - const { - isFetching, - isPopulated, - error - } = movieCredits; - - return { - isMovieCreditsFetching: isFetching, - isMovieCreditsPopulated: isPopulated, - movieCreditsError: error - }; - } -); - -const selectExtraFiles = createSelector( - (state) => state.extraFiles, - (extraFiles) => { - const { - isFetching, - isPopulated, - error - } = extraFiles; - - return { - isExtraFilesFetching: isFetching, - isExtraFilesPopulated: isPopulated, - extraFilesError: error - }; - } -); - -function createMapStateToProps() { - return createSelector( - (state, { titleSlug }) => titleSlug, - selectMovieFiles, - selectMovieCredits, - selectExtraFiles, - createAllMoviesSelector(), - createCommandsSelector(), - createDimensionsSelector(), - (state) => state.queue.details.items, - (state) => state.app.isSidebarVisible, - (state) => state.settings.ui.item.movieRuntimeFormat, - (titleSlug, movieFiles, movieCredits, extraFiles, allMovies, commands, dimensions, queueItems, isSidebarVisible, movieRuntimeFormat) => { - const sortedMovies = _.orderBy(allMovies, 'sortTitle'); - const movieIndex = _.findIndex(sortedMovies, { titleSlug }); - const movie = sortedMovies[movieIndex]; - - if (!movie) { - return {}; - } - - const { - isMovieFilesFetching, - isMovieFilesPopulated, - movieFilesError, - hasMovieFiles - } = movieFiles; - - const { - isMovieCreditsFetching, - isMovieCreditsPopulated, - movieCreditsError - } = movieCredits; - - const { - isExtraFilesFetching, - isExtraFilesPopulated, - extraFilesError - } = extraFiles; - - const previousMovie = sortedMovies[movieIndex - 1] || _.last(sortedMovies); - const nextMovie = sortedMovies[movieIndex + 1] || _.first(sortedMovies); - const isMovieRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_MOVIE, movieIds: [movie.id] })); - const movieRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_MOVIE }); - const allMoviesRefreshing = ( - isCommandExecuting(movieRefreshingCommand) && - !movieRefreshingCommand.body.movieId - ); - const isRefreshing = isMovieRefreshing || allMoviesRefreshing; - const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.MOVIE_SEARCH, movieIds: [movie.id] })); - const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, movieId: movie.id })); - const isRenamingMovieCommand = findCommand(commands, { name: commandNames.RENAME_MOVIE }); - const isRenamingMovie = ( - isCommandExecuting(isRenamingMovieCommand) && - isRenamingMovieCommand.body.movieIds.indexOf(movie.id) > -1 - ); - - const isFetching = isMovieFilesFetching || isMovieCreditsFetching || isExtraFilesFetching; - const isPopulated = isMovieFilesPopulated && isMovieCreditsPopulated && isExtraFilesPopulated; - const alternateTitles = _.reduce(movie.alternateTitles, (acc, alternateTitle) => { - acc.push(alternateTitle.title); - return acc; - }, []); - - const queueItem = queueItems.find((item) => item.movieId === movie.id); - - return { - ...movie, - alternateTitles, - isMovieRefreshing, - allMoviesRefreshing, - isRefreshing, - isSearching, - isRenamingFiles, - isRenamingMovie, - isFetching, - isPopulated, - movieFilesError, - movieCreditsError, - extraFilesError, - hasMovieFiles, - previousMovie, - nextMovie, - isSmallScreen: dimensions.isSmallScreen, - isSidebarVisible, - queueItem, - movieRuntimeFormat - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchFetchMovieFiles({ movieId }) { - dispatch(fetchMovieFiles({ movieId })); - }, - dispatchClearMovieFiles() { - dispatch(clearMovieFiles()); - }, - dispatchFetchMovieCredits({ movieId }) { - dispatch(fetchMovieCredits({ movieId })); - }, - dispatchClearMovieCredits() { - dispatch(clearMovieCredits()); - }, - dispatchFetchExtraFiles({ movieId }) { - dispatch(fetchExtraFiles({ movieId })); - }, - dispatchClearExtraFiles() { - dispatch(clearExtraFiles()); - }, - dispatchFetchQueueDetails({ movieId }) { - dispatch(fetchQueueDetails({ movieId })); - }, - dispatchClearQueueDetails() { - dispatch(clearQueueDetails()); - }, - dispatchFetchImportListSchema() { - dispatch(fetchImportListSchema()); - }, - dispatchToggleMovieMonitored(payload) { - dispatch(toggleMovieMonitored(payload)); - }, - dispatchExecuteCommand(payload) { - dispatch(executeCommand(payload)); - }, - onGoToMovie(titleSlug) { - dispatch(push(`${window.Radarr.urlBase}/movie/${titleSlug}`)); - } - }; -} - -class MovieDetailsConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - registerPagePopulator(this.populate, ['movieUpdated']); - this.populate(); - } - - componentDidUpdate(prevProps) { - const { - id, - isMovieRefreshing, - allMoviesRefreshing, - isRenamingFiles, - isRenamingMovie - } = this.props; - - if ( - (prevProps.isMovieRefreshing && !isMovieRefreshing) || - (prevProps.allMoviesRefreshing && !allMoviesRefreshing) || - (prevProps.isRenamingFiles && !isRenamingFiles) || - (prevProps.isRenamingMovie && !isRenamingMovie) - ) { - this.populate(); - } - - // If the id has changed we need to clear the episodes/episode - // files and fetch from the server. - - if (prevProps.id !== id) { - this.unpopulate(); - this.populate(); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.populate); - this.unpopulate(); - } - - // - // Control - - populate = () => { - const movieId = this.props.id; - - this.props.dispatchFetchMovieFiles({ movieId }); - this.props.dispatchFetchExtraFiles({ movieId }); - this.props.dispatchFetchMovieCredits({ movieId }); - this.props.dispatchFetchQueueDetails({ movieId }); - this.props.dispatchFetchImportListSchema(); - }; - - unpopulate = () => { - this.props.dispatchClearMovieFiles(); - this.props.dispatchClearExtraFiles(); - this.props.dispatchClearMovieCredits(); - this.props.dispatchClearQueueDetails(); - }; - - // - // Listeners - - onMonitorTogglePress = (monitored) => { - this.props.dispatchToggleMovieMonitored({ - movieId: this.props.id, - monitored - }); - }; - - onRefreshPress = () => { - this.props.dispatchExecuteCommand({ - name: commandNames.REFRESH_MOVIE, - movieIds: [this.props.id] - }); - }; - - onSearchPress = () => { - this.props.dispatchExecuteCommand({ - name: commandNames.MOVIE_SEARCH, - movieIds: [this.props.id] - }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -MovieDetailsConnector.propTypes = { - id: PropTypes.number.isRequired, - titleSlug: PropTypes.string.isRequired, - isMovieRefreshing: PropTypes.bool.isRequired, - allMoviesRefreshing: PropTypes.bool.isRequired, - isRefreshing: PropTypes.bool.isRequired, - isRenamingFiles: PropTypes.bool.isRequired, - isRenamingMovie: PropTypes.bool.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - dispatchFetchMovieFiles: PropTypes.func.isRequired, - dispatchClearMovieFiles: PropTypes.func.isRequired, - dispatchFetchExtraFiles: PropTypes.func.isRequired, - dispatchClearExtraFiles: PropTypes.func.isRequired, - dispatchFetchMovieCredits: PropTypes.func.isRequired, - dispatchClearMovieCredits: PropTypes.func.isRequired, - dispatchToggleMovieMonitored: PropTypes.func.isRequired, - dispatchFetchQueueDetails: PropTypes.func.isRequired, - dispatchClearQueueDetails: PropTypes.func.isRequired, - dispatchFetchImportListSchema: PropTypes.func.isRequired, - dispatchExecuteCommand: PropTypes.func.isRequired, - onGoToMovie: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(MovieDetailsConnector); diff --git a/frontend/src/Movie/Details/MovieDetailsPage.tsx b/frontend/src/Movie/Details/MovieDetailsPage.tsx new file mode 100644 index 0000000000..acfea2ab27 --- /dev/null +++ b/frontend/src/Movie/Details/MovieDetailsPage.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { useParams } from 'react-router'; +import { useHistory } from 'react-router-dom'; +import NotFound from 'Components/NotFound'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import translate from 'Utilities/String/translate'; +import MovieDetails from './MovieDetails'; + +function MovieDetailsPage() { + const allMovies = useSelector(createAllMoviesSelector()); + const { titleSlug } = useParams<{ titleSlug: string }>(); + const history = useHistory(); + + const movieIndex = allMovies.findIndex( + (movie) => movie.titleSlug === titleSlug + ); + + const previousIndex = usePrevious(movieIndex); + + useEffect(() => { + if ( + movieIndex === -1 && + previousIndex !== -1 && + previousIndex !== undefined + ) { + history.push(`${window.Radarr.urlBase}/`); + } + }, [movieIndex, previousIndex, history]); + + if (movieIndex === -1) { + return ; + } + + return ; +} + +export default MovieDetailsPage; diff --git a/frontend/src/Movie/Details/MovieDetailsPageConnector.js b/frontend/src/Movie/Details/MovieDetailsPageConnector.js deleted file mode 100644 index e324031876..0000000000 --- a/frontend/src/Movie/Details/MovieDetailsPageConnector.js +++ /dev/null @@ -1,125 +0,0 @@ -import { push } from 'connected-react-router'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import NotFound from 'Components/NotFound'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import translate from 'Utilities/String/translate'; -import MovieDetailsConnector from './MovieDetailsConnector'; -import styles from './MovieDetails.css'; - -function createMapStateToProps() { - return createSelector( - (state, { match }) => match, - (state) => state.movies, - (match, movies) => { - const titleSlug = match.params.titleSlug; - const { - isFetching, - isPopulated, - error, - items - } = movies; - - const movieIndex = _.findIndex(items, { titleSlug }); - - if (movieIndex > -1) { - return { - isFetching, - isPopulated, - titleSlug - }; - } - - return { - isFetching, - isPopulated, - error - }; - } - ); -} - -const mapDispatchToProps = { - push, - fetchRootFolders -}; - -class MovieDetailsPageConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.fetchRootFolders(); - } - - componentDidUpdate(prevProps) { - if (!this.props.titleSlug) { - this.props.push(`${window.Radarr.urlBase}/`); - return; - } - } - - // - // Render - - render() { - const { - titleSlug, - isFetching, - isPopulated, - error - } = this.props; - - if (isFetching && !isPopulated) { - return ( - - - - - - ); - } - - if (!isFetching && !!error) { - return ( -
- {getErrorMessage(error, translate('FailedToLoadMovieFromAPI'))} -
- ); - } - - if (!titleSlug) { - return ( - - ); - } - - return ( - - ); - } -} - -MovieDetailsPageConnector.propTypes = { - titleSlug: PropTypes.string, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired, - push: PropTypes.func.isRequired, - fetchRootFolders: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(MovieDetailsPageConnector); diff --git a/frontend/src/Movie/Details/MovieStatusLabel.js b/frontend/src/Movie/Details/MovieStatusLabel.tsx similarity index 60% rename from frontend/src/Movie/Details/MovieStatusLabel.js rename to frontend/src/Movie/Details/MovieStatusLabel.tsx index cde6f1a77e..21aa0e78c7 100644 --- a/frontend/src/Movie/Details/MovieStatusLabel.js +++ b/frontend/src/Movie/Details/MovieStatusLabel.tsx @@ -1,13 +1,23 @@ -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 { Kind } from 'Helpers/Props/kinds'; +import { MovieStatus } from 'Movie/Movie'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import Queue from 'typings/Queue'; import getQueueStatusText from 'Utilities/Movie/getQueueStatusText'; import firstCharToUpper from 'Utilities/String/firstCharToUpper'; import translate from 'Utilities/String/translate'; import styles from './MovieStatusLabel.css'; -function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = false) { +function getMovieStatus( + status: MovieStatus, + isMonitored: boolean, + isAvailable: boolean, + hasFiles: boolean, + queueItem: Queue | null = null +) { if (queueItem) { const queueStatus = queueItem.status; const queueState = queueItem.trackedDownloadStatus; @@ -18,11 +28,11 @@ function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = f } } - if (hasFile && !isMonitored) { + if (hasFiles && !isMonitored) { return 'availNotMonitored'; } - if (hasFile) { + if (hasFiles) { return 'ended'; } @@ -30,34 +40,52 @@ function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = f return 'deleted'; } - if (isAvailable && !isMonitored && !hasFile) { + if (isAvailable && !isMonitored && !hasFiles) { return 'missingUnmonitored'; } - if (isAvailable && !hasFile) { + if (isAvailable && !hasFiles) { return 'missingMonitored'; } return 'continuing'; } -function MovieStatusLabel(props) { - const { +interface MovieStatusLabelProps { + movieId: number; + monitored: boolean; + isAvailable: boolean; + hasMovieFiles: boolean; + status: MovieStatus; + useLabel?: boolean; +} + +function MovieStatusLabel({ + movieId, + monitored, + isAvailable, + hasMovieFiles, + status, + useLabel = false, +}: MovieStatusLabelProps) { + const queueItem = useSelector(createQueueItemSelectorForHook(movieId)); + + let movieStatus = getMovieStatus( status, - hasMovieFiles, monitored, isAvailable, - queueItem, - useLabel, - colorImpairedMode - } = props; + hasMovieFiles, + queueItem + ); - let movieStatus = getMovieStatus(status, hasMovieFiles, monitored, isAvailable, queueItem); let statusClass = movieStatus; if (movieStatus === 'availNotMonitored' || movieStatus === 'ended') { movieStatus = 'downloaded'; - } else if (movieStatus === 'missingMonitored' || movieStatus === 'missingUnmonitored') { + } else if ( + movieStatus === 'missingMonitored' || + movieStatus === 'missingUnmonitored' + ) { movieStatus = 'missing'; } else if (movieStatus === 'continuing') { movieStatus = 'notAvailable'; @@ -68,7 +96,7 @@ function MovieStatusLabel(props) { } if (useLabel) { - let kind = kinds.SUCCESS; + let kind: Kind = kinds.SUCCESS; switch (statusClass) { case 'queue': @@ -93,11 +121,7 @@ function MovieStatusLabel(props) { } return ( -