From bdc1adb2ed7faa91ca0191aea1a8923a9ca9e859 Mon Sep 17 00:00:00 2001 From: Qstick Date: Sun, 3 Nov 2019 00:55:02 -0400 Subject: [PATCH] New: Cast/Crew Tabs on Movie Details Page --- .../Movie/Details/Cast/MovieCastPoster.css | 76 ++++++ .../src/Movie/Details/Cast/MovieCastPoster.js | 123 ++++++++++ .../Movie/Details/Cast/MovieCastPosters.css | 7 + .../Movie/Details/Cast/MovieCastPosters.js | 228 ++++++++++++++++++ .../Details/Cast/MovieCastPostersConnector.js | 25 ++ .../Movie/Details/Crew/MovieCrewPoster.css | 76 ++++++ .../src/Movie/Details/Crew/MovieCrewPoster.js | 123 ++++++++++ .../Movie/Details/Crew/MovieCrewPosters.css | 7 + .../Movie/Details/Crew/MovieCrewPosters.js | 228 ++++++++++++++++++ .../Details/Crew/MovieCrewPostersConnector.js | 25 ++ frontend/src/Movie/Details/MovieDetails.js | 38 ++- .../Movie/Details/MovieDetailsConnector.js | 44 +++- .../{ => Details}/Titles/MovieTitlesRow.js | 0 .../{ => Details}/Titles/MovieTitlesTable.js | 0 .../Titles/MovieTitlesTableContent.css | 0 .../Titles/MovieTitlesTableContent.js | 1 + .../MovieTitlesTableContentConnector.js | 0 frontend/src/Movie/MovieHeadshot.js | 25 ++ frontend/src/Store/Actions/index.js | 2 + .../src/Store/Actions/movieCreditsActions.js | 81 +++++++ 20 files changed, 1102 insertions(+), 7 deletions(-) create mode 100644 frontend/src/Movie/Details/Cast/MovieCastPoster.css create mode 100644 frontend/src/Movie/Details/Cast/MovieCastPoster.js create mode 100644 frontend/src/Movie/Details/Cast/MovieCastPosters.css create mode 100644 frontend/src/Movie/Details/Cast/MovieCastPosters.js create mode 100644 frontend/src/Movie/Details/Cast/MovieCastPostersConnector.js create mode 100644 frontend/src/Movie/Details/Crew/MovieCrewPoster.css create mode 100644 frontend/src/Movie/Details/Crew/MovieCrewPoster.js create mode 100644 frontend/src/Movie/Details/Crew/MovieCrewPosters.css create mode 100644 frontend/src/Movie/Details/Crew/MovieCrewPosters.js create mode 100644 frontend/src/Movie/Details/Crew/MovieCrewPostersConnector.js rename frontend/src/Movie/{ => Details}/Titles/MovieTitlesRow.js (100%) rename frontend/src/Movie/{ => Details}/Titles/MovieTitlesTable.js (100%) rename frontend/src/Movie/{ => Details}/Titles/MovieTitlesTableContent.css (100%) rename frontend/src/Movie/{ => Details}/Titles/MovieTitlesTableContent.js (99%) rename frontend/src/Movie/{ => Details}/Titles/MovieTitlesTableContentConnector.js (100%) create mode 100644 frontend/src/Movie/MovieHeadshot.js create mode 100644 frontend/src/Store/Actions/movieCreditsActions.js diff --git a/frontend/src/Movie/Details/Cast/MovieCastPoster.css b/frontend/src/Movie/Details/Cast/MovieCastPoster.css new file mode 100644 index 000000000..3c0d27827 --- /dev/null +++ b/frontend/src/Movie/Details/Cast/MovieCastPoster.css @@ -0,0 +1,76 @@ +$hoverScale: 1.05; + +.content { + transition: all 200ms ease-in; + + &:hover { + z-index: 2; + box-shadow: 0 0 12px $black; + transition: all 200ms ease-in; + + .controls { + opacity: 0.9; + transition: opacity 200ms linear 150ms; + } + } +} + +.posterContainer { + position: relative; +} + +.poster { + position: relative; + display: block; + background-color: $defaultColor; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.title { + @add-mixin truncate; + + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.controls { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 3; + border-radius: 4px; + background-color: #707070; + color: $white; + font-size: $smallFontSize; + opacity: 0; + transition: opacity 0; +} + +.action { + composes: button from '~Components/Link/IconButton.css'; + + &:hover { + color: $radarrYellow; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .container { + padding: 5px; + } +} diff --git a/frontend/src/Movie/Details/Cast/MovieCastPoster.js b/frontend/src/Movie/Details/Cast/MovieCastPoster.js new file mode 100644 index 000000000..5237a72a4 --- /dev/null +++ b/frontend/src/Movie/Details/Cast/MovieCastPoster.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import MovieHeadshot from 'Movie/MovieHeadshot'; +import styles from './MovieCastPoster.css'; + +class MovieCastPoster extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPosterError: false, + isEditMovieModalOpen: false + }; + } + + // + // Listeners + + onEditMoviePress = () => { + this.setState({ isEditMovieModalOpen: true }); + } + + onEditMovieModalClose = () => { + this.setState({ isEditMovieModalOpen: false }); + } + + onPosterLoad = () => { + if (this.state.hasPosterError) { + this.setState({ hasPosterError: false }); + } + } + + onPosterLoadError = () => { + if (!this.state.hasPosterError) { + this.setState({ hasPosterError: true }); + } + } + + // + // Render + + render() { + const { + castName, + character, + images, + posterWidth, + posterHeight + } = this.props; + + const { + hasPosterError + } = this.state; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px` + }; + + return ( +
+
+ + +
+ + + { + hasPosterError && +
+ {castName} +
+ } +
+
+ +
+ {castName} +
+
+ {character} +
+
+ ); + } +} + +MovieCastPoster.propTypes = { + castId: PropTypes.number.isRequired, + castName: PropTypes.string.isRequired, + character: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired +}; + +export default MovieCastPoster; diff --git a/frontend/src/Movie/Details/Cast/MovieCastPosters.css b/frontend/src/Movie/Details/Cast/MovieCastPosters.css new file mode 100644 index 000000000..d80f951a0 --- /dev/null +++ b/frontend/src/Movie/Details/Cast/MovieCastPosters.css @@ -0,0 +1,7 @@ +.grid { + flex: 1 0 auto; +} + +.container { + padding: 10px; +} diff --git a/frontend/src/Movie/Details/Cast/MovieCastPosters.js b/frontend/src/Movie/Details/Cast/MovieCastPosters.js new file mode 100644 index 000000000..6ae16a533 --- /dev/null +++ b/frontend/src/Movie/Details/Cast/MovieCastPosters.js @@ -0,0 +1,228 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Grid, WindowScroller } from 'react-virtualized'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import dimensions from 'Styles/Variables/dimensions'; +import Measure from 'Components/Measure'; +import MovieCastPoster from './MovieCastPoster'; +import styles from './MovieCastPosters.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); + +const additionalColumnCount = { + small: 3, + medium: 2, + large: 1 +}; + +function calculateColumnWidth(width, posterSize, isSmallScreen) { + const maxiumColumnWidth = isSmallScreen ? 172 : 182; + const columns = Math.floor(width / maxiumColumnWidth); + const remainder = width % maxiumColumnWidth; + + if (remainder === 0 && posterSize === 'large') { + return maxiumColumnWidth; + } + + return Math.floor(width / (columns + additionalColumnCount[posterSize])); +} + +function calculateRowHeight(posterHeight, isSmallScreen) { + const titleHeight = 19; + const characterHeight = 19; + + const heights = [ + posterHeight, + titleHeight, + characterHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculatePosterHeight(posterWidth) { + return Math.ceil((250 / 170) * posterWidth); +} + +class MovieCastPosters extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnWidth: 182, + columnCount: 1, + posterWidth: 162, + posterHeight: 238, + rowHeight: calculateRowHeight(238, props.isSmallScreen) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidUpdate(prevProps, prevState) { + const { + cast + } = this.props; + + const { + width, + columnWidth, + columnCount, + rowHeight + } = this.state; + + if (this._grid && + (prevState.width !== width || + prevState.columnWidth !== columnWidth || + prevState.columnCount !== columnCount || + prevState.rowHeight !== rowHeight || + hasDifferentItemsOrOrder(prevProps.cast, cast))) { + // recomputeGridSize also forces Grid to discard its cache of rendered cells + this._grid.recomputeGridSize(); + } + } + + // + // Control + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + const columnWidth = calculateColumnWidth(width, 'small', isSmallScreen); + const columnCount = Math.max(Math.floor(width / columnWidth), 1); + const posterWidth = columnWidth - padding; + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, isSmallScreen); + + this.setState({ + width, + columnWidth, + columnCount, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, columnIndex, style }) => { + const { + cast + } = this.props; + + const { + posterWidth, + posterHeight, + columnCount + } = this.state; + + const movieIdx = rowIndex * columnCount + columnIndex; + const movie = cast[movieIdx]; + + if (!movie) { + return null; + } + + return ( +
+ +
+ ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + // + // Render + + render() { + const { + cast + } = this.props; + + const { + width, + columnWidth, + columnCount, + rowHeight + } = this.state; + + const rowCount = Math.ceil(cast.length / columnCount); + + return ( + + + {({ height, registerChild, onChildScroll, scrollTop }) => { + if (!height) { + return
; + } + + return ( +
+ +
+ ); + } + } + + + ); + } +} + +MovieCastPosters.propTypes = { + cast: PropTypes.arrayOf(PropTypes.object).isRequired, + isSmallScreen: PropTypes.bool.isRequired +}; + +export default MovieCastPosters; diff --git a/frontend/src/Movie/Details/Cast/MovieCastPostersConnector.js b/frontend/src/Movie/Details/Cast/MovieCastPostersConnector.js new file mode 100644 index 000000000..5311bedbe --- /dev/null +++ b/frontend/src/Movie/Details/Cast/MovieCastPostersConnector.js @@ -0,0 +1,25 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import MovieCastPosters from './MovieCastPosters'; + +function createMapStateToProps() { + return createSelector( + (state) => state.moviePeople.items, + (people) => { + const cast = _.reduce(people, (acc, person) => { + if (person.type === 'cast') { + acc.push(person); + } + + return acc; + }, []); + + return { + cast + }; + } + ); +} + +export default connect(createMapStateToProps)(MovieCastPosters); diff --git a/frontend/src/Movie/Details/Crew/MovieCrewPoster.css b/frontend/src/Movie/Details/Crew/MovieCrewPoster.css new file mode 100644 index 000000000..3c0d27827 --- /dev/null +++ b/frontend/src/Movie/Details/Crew/MovieCrewPoster.css @@ -0,0 +1,76 @@ +$hoverScale: 1.05; + +.content { + transition: all 200ms ease-in; + + &:hover { + z-index: 2; + box-shadow: 0 0 12px $black; + transition: all 200ms ease-in; + + .controls { + opacity: 0.9; + transition: opacity 200ms linear 150ms; + } + } +} + +.posterContainer { + position: relative; +} + +.poster { + position: relative; + display: block; + background-color: $defaultColor; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.title { + @add-mixin truncate; + + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.controls { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 3; + border-radius: 4px; + background-color: #707070; + color: $white; + font-size: $smallFontSize; + opacity: 0; + transition: opacity 0; +} + +.action { + composes: button from '~Components/Link/IconButton.css'; + + &:hover { + color: $radarrYellow; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .container { + padding: 5px; + } +} diff --git a/frontend/src/Movie/Details/Crew/MovieCrewPoster.js b/frontend/src/Movie/Details/Crew/MovieCrewPoster.js new file mode 100644 index 000000000..73142ff0c --- /dev/null +++ b/frontend/src/Movie/Details/Crew/MovieCrewPoster.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import MovieHeadshot from 'Movie/MovieHeadshot'; +import styles from './MovieCrewPoster.css'; + +class MovieCrewPoster extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPosterError: false, + isEditMovieModalOpen: false + }; + } + + // + // Listeners + + onEditMoviePress = () => { + this.setState({ isEditMovieModalOpen: true }); + } + + onEditMovieModalClose = () => { + this.setState({ isEditMovieModalOpen: false }); + } + + onPosterLoad = () => { + if (this.state.hasPosterError) { + this.setState({ hasPosterError: false }); + } + } + + onPosterLoadError = () => { + if (!this.state.hasPosterError) { + this.setState({ hasPosterError: true }); + } + } + + // + // Render + + render() { + const { + crewName, + job, + images, + posterWidth, + posterHeight + } = this.props; + + const { + hasPosterError + } = this.state; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px` + }; + + return ( +
+
+ + +
+ + + { + hasPosterError && +
+ {crewName} +
+ } +
+
+ +
+ {crewName} +
+
+ {job} +
+
+ ); + } +} + +MovieCrewPoster.propTypes = { + crewId: PropTypes.number.isRequired, + crewName: PropTypes.string.isRequired, + job: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired +}; + +export default MovieCrewPoster; diff --git a/frontend/src/Movie/Details/Crew/MovieCrewPosters.css b/frontend/src/Movie/Details/Crew/MovieCrewPosters.css new file mode 100644 index 000000000..d80f951a0 --- /dev/null +++ b/frontend/src/Movie/Details/Crew/MovieCrewPosters.css @@ -0,0 +1,7 @@ +.grid { + flex: 1 0 auto; +} + +.container { + padding: 10px; +} diff --git a/frontend/src/Movie/Details/Crew/MovieCrewPosters.js b/frontend/src/Movie/Details/Crew/MovieCrewPosters.js new file mode 100644 index 000000000..72f336a79 --- /dev/null +++ b/frontend/src/Movie/Details/Crew/MovieCrewPosters.js @@ -0,0 +1,228 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Grid, WindowScroller } from 'react-virtualized'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import dimensions from 'Styles/Variables/dimensions'; +import Measure from 'Components/Measure'; +import MovieCrewPoster from './MovieCrewPoster'; +import styles from './MovieCrewPosters.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); + +const additionalColumnCount = { + small: 3, + medium: 2, + large: 1 +}; + +function calculateColumnWidth(width, posterSize, isSmallScreen) { + const maxiumColumnWidth = isSmallScreen ? 172 : 182; + const columns = Math.floor(width / maxiumColumnWidth); + const remainder = width % maxiumColumnWidth; + + if (remainder === 0 && posterSize === 'large') { + return maxiumColumnWidth; + } + + return Math.floor(width / (columns + additionalColumnCount[posterSize])); +} + +function calculateRowHeight(posterHeight, isSmallScreen) { + const titleHeight = 19; + const characterHeight = 19; + + const heights = [ + posterHeight, + titleHeight, + characterHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculatePosterHeight(posterWidth) { + return Math.ceil((250 / 170) * posterWidth); +} + +class MovieCrewPosters extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnWidth: 182, + columnCount: 1, + posterWidth: 162, + posterHeight: 238, + rowHeight: calculateRowHeight(238, props.isSmallScreen) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidUpdate(prevProps, prevState) { + const { + crew + } = this.props; + + const { + width, + columnWidth, + columnCount, + rowHeight + } = this.state; + + if (this._grid && + (prevState.width !== width || + prevState.columnWidth !== columnWidth || + prevState.columnCount !== columnCount || + prevState.rowHeight !== rowHeight || + hasDifferentItemsOrOrder(prevProps.crew, crew))) { + // recomputeGridSize also forces Grid to discard its cache of rendered cells + this._grid.recomputeGridSize(); + } + } + + // + // Control + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + const columnWidth = calculateColumnWidth(width, 'small', isSmallScreen); + const columnCount = Math.max(Math.floor(width / columnWidth), 1); + const posterWidth = columnWidth - padding; + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, isSmallScreen); + + this.setState({ + width, + columnWidth, + columnCount, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, columnIndex, style }) => { + const { + crew + } = this.props; + + const { + posterWidth, + posterHeight, + columnCount + } = this.state; + + const movieIdx = rowIndex * columnCount + columnIndex; + const movie = crew[movieIdx]; + + if (!movie) { + return null; + } + + return ( +
+ +
+ ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + // + // Render + + render() { + const { + crew + } = this.props; + + const { + width, + columnWidth, + columnCount, + rowHeight + } = this.state; + + const rowCount = Math.ceil(crew.length / columnCount); + + return ( + + + {({ height, registerChild, onChildScroll, scrollTop }) => { + if (!height) { + return
; + } + + return ( +
+ +
+ ); + } + } + + + ); + } +} + +MovieCrewPosters.propTypes = { + crew: PropTypes.arrayOf(PropTypes.object).isRequired, + isSmallScreen: PropTypes.bool.isRequired +}; + +export default MovieCrewPosters; diff --git a/frontend/src/Movie/Details/Crew/MovieCrewPostersConnector.js b/frontend/src/Movie/Details/Crew/MovieCrewPostersConnector.js new file mode 100644 index 000000000..f23ee56b5 --- /dev/null +++ b/frontend/src/Movie/Details/Crew/MovieCrewPostersConnector.js @@ -0,0 +1,25 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import MovieCrewPosters from './MovieCrewPosters'; + +function createMapStateToProps() { + return createSelector( + (state) => state.moviePeople.items, + (people) => { + const crew = _.reduce(people, (acc, person) => { + if (person.type === 'crew') { + acc.push(person); + } + + return acc; + }, []); + + return { + crew + }; + } + ); +} + +export default connect(createMapStateToProps)(MovieCrewPosters); diff --git a/frontend/src/Movie/Details/MovieDetails.js b/frontend/src/Movie/Details/MovieDetails.js index 732603e77..277e35542 100644 --- a/frontend/src/Movie/Details/MovieDetails.js +++ b/frontend/src/Movie/Details/MovieDetails.js @@ -30,7 +30,9 @@ import MoviePoster from 'Movie/MoviePoster'; import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; import MovieHistoryTable from 'Movie/History/MovieHistoryTable'; -import MovieTitlesTable from 'Movie/Titles/MovieTitlesTable'; +import MovieTitlesTable from './Titles/MovieTitlesTable'; +import MovieCastPostersConnector from './Cast/MovieCastPostersConnector'; +import MovieCrewPostersConnector from './Crew/MovieCrewPostersConnector'; import MovieAlternateTitles from './MovieAlternateTitles'; import MovieDetailsLinks from './MovieDetailsLinks'; import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable'; @@ -177,7 +179,9 @@ class MovieDetails extends Component { isSearching, isFetching, isPopulated, + isSmallScreen, movieFilesError, + moviePeopleError, hasMovieFiles, previousMovie, nextMovie, @@ -460,12 +464,12 @@ class MovieDetails extends Component {
{ - !isPopulated && !movieFilesError && + !isPopulated && !movieFilesError && !moviePeopleError && } { - !isFetching && movieFilesError && + !isFetching && movieFilesError && !moviePeopleError &&
Loading movie files failed
} @@ -501,6 +505,20 @@ class MovieDetails extends Component { Titles + + Cast + + + + Crew + + { selectedTabIndex === 1 &&
@@ -533,6 +551,18 @@ class MovieDetails extends Component { movieId={id} /> + + + + + + + +
@@ -597,7 +627,9 @@ MovieDetails.propTypes = { isSearching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired, movieFilesError: PropTypes.object, + moviePeopleError: PropTypes.object, hasMovieFiles: PropTypes.bool.isRequired, previousMovie: PropTypes.object.isRequired, nextMovie: PropTypes.object.isRequired, diff --git a/frontend/src/Movie/Details/MovieDetailsConnector.js b/frontend/src/Movie/Details/MovieDetailsConnector.js index 5c3eff61c..36dd4ac09 100644 --- a/frontend/src/Movie/Details/MovieDetailsConnector.js +++ b/frontend/src/Movie/Details/MovieDetailsConnector.js @@ -7,7 +7,9 @@ import { findCommand, isCommandExecuting } from 'Utilities/Command'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import { fetchMovieFiles, clearMovieFiles } from 'Store/Actions/movieFileActions'; +import { fetchMoviePeople, clearMoviePeople } from 'Store/Actions/moviePeopleActions'; import { toggleMovieMonitored } from 'Store/Actions/movieActions'; import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; import { clearReleases, cancelFetchReleases } from 'Store/Actions/releaseActions'; @@ -39,13 +41,32 @@ const selectMovieFiles = createSelector( } ); +const selectMoviePeople = createSelector( + (state) => state.moviePeople, + (moviePeople) => { + const { + isFetching, + isPopulated, + error + } = moviePeople; + + return { + isMoviePeopleFetching: isFetching, + isMoviePeoplePopulated: isPopulated, + moviePeopleError: error + }; + } +); + function createMapStateToProps() { return createSelector( (state, { titleSlug }) => titleSlug, selectMovieFiles, + selectMoviePeople, createAllMoviesSelector(), createCommandsSelector(), - (titleSlug, movieFiles, allMovies, commands) => { + createDimensionsSelector(), + (titleSlug, movieFiles, moviePeople, allMovies, commands, dimensions) => { const sortedMovies = _.orderBy(allMovies, 'sortTitle'); const movieIndex = _.findIndex(sortedMovies, { titleSlug }); const movie = sortedMovies[movieIndex]; @@ -62,6 +83,12 @@ function createMapStateToProps() { sizeOnDisk } = movieFiles; + const { + isMoviePeopleFetching, + isMoviePeoplePopulated, + moviePeopleError + } = moviePeople; + const previousMovie = sortedMovies[movieIndex - 1] || _.last(sortedMovies); const nextMovie = sortedMovies[movieIndex + 1] || _.first(sortedMovies); const isMovieRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_MOVIE, movieId: movie.id })); @@ -79,8 +106,8 @@ function createMapStateToProps() { isRenamingMovieCommand.body.movieIds.indexOf(movie.id) > -1 ); - const isFetching = isMovieFilesFetching; - const isPopulated = isMovieFilesPopulated; + const isFetching = isMovieFilesFetching && isMoviePeopleFetching; + const isPopulated = isMovieFilesPopulated && isMoviePeoplePopulated; const alternateTitles = _.reduce(movie.alternateTitles, (acc, alternateTitle) => { acc.push(alternateTitle.title); return acc; @@ -98,10 +125,12 @@ function createMapStateToProps() { isFetching, isPopulated, movieFilesError, + moviePeopleError, hasMovieFiles, sizeOnDisk, previousMovie, - nextMovie + nextMovie, + isSmallScreen: dimensions.isSmallScreen }; } ); @@ -110,6 +139,8 @@ function createMapStateToProps() { const mapDispatchToProps = { fetchMovieFiles, clearMovieFiles, + fetchMoviePeople, + clearMoviePeople, clearReleases, cancelFetchReleases, toggleMovieMonitored, @@ -167,12 +198,14 @@ class MovieDetailsConnector extends Component { const movieId = this.props.id; this.props.fetchMovieFiles({ movieId }); + this.props.fetchMoviePeople({ movieId }); this.props.fetchQueueDetails({ movieId }); } unpopulate = () => { this.props.cancelFetchReleases(); this.props.clearMovieFiles(); + this.props.clearMoviePeople(); this.props.clearQueueDetails(); this.props.clearReleases(); } @@ -224,8 +257,11 @@ MovieDetailsConnector.propTypes = { isRefreshing: PropTypes.bool.isRequired, isRenamingFiles: PropTypes.bool.isRequired, isRenamingMovie: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired, fetchMovieFiles: PropTypes.func.isRequired, clearMovieFiles: PropTypes.func.isRequired, + fetchMoviePeople: PropTypes.func.isRequired, + clearMoviePeople: PropTypes.func.isRequired, clearReleases: PropTypes.func.isRequired, cancelFetchReleases: PropTypes.func.isRequired, toggleMovieMonitored: PropTypes.func.isRequired, diff --git a/frontend/src/Movie/Titles/MovieTitlesRow.js b/frontend/src/Movie/Details/Titles/MovieTitlesRow.js similarity index 100% rename from frontend/src/Movie/Titles/MovieTitlesRow.js rename to frontend/src/Movie/Details/Titles/MovieTitlesRow.js diff --git a/frontend/src/Movie/Titles/MovieTitlesTable.js b/frontend/src/Movie/Details/Titles/MovieTitlesTable.js similarity index 100% rename from frontend/src/Movie/Titles/MovieTitlesTable.js rename to frontend/src/Movie/Details/Titles/MovieTitlesTable.js diff --git a/frontend/src/Movie/Titles/MovieTitlesTableContent.css b/frontend/src/Movie/Details/Titles/MovieTitlesTableContent.css similarity index 100% rename from frontend/src/Movie/Titles/MovieTitlesTableContent.css rename to frontend/src/Movie/Details/Titles/MovieTitlesTableContent.css diff --git a/frontend/src/Movie/Titles/MovieTitlesTableContent.js b/frontend/src/Movie/Details/Titles/MovieTitlesTableContent.js similarity index 99% rename from frontend/src/Movie/Titles/MovieTitlesTableContent.js rename to frontend/src/Movie/Details/Titles/MovieTitlesTableContent.js index 1d00035f8..3492ad023 100644 --- a/frontend/src/Movie/Titles/MovieTitlesTableContent.js +++ b/frontend/src/Movie/Details/Titles/MovieTitlesTableContent.js @@ -5,6 +5,7 @@ import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import styles from './MovieTitlesTableContent.css'; import MovieTitlesRow from './MovieTitlesRow'; + const columns = [ { name: 'altTitle', diff --git a/frontend/src/Movie/Titles/MovieTitlesTableContentConnector.js b/frontend/src/Movie/Details/Titles/MovieTitlesTableContentConnector.js similarity index 100% rename from frontend/src/Movie/Titles/MovieTitlesTableContentConnector.js rename to frontend/src/Movie/Details/Titles/MovieTitlesTableContentConnector.js diff --git a/frontend/src/Movie/MovieHeadshot.js b/frontend/src/Movie/MovieHeadshot.js new file mode 100644 index 000000000..490d693bf --- /dev/null +++ b/frontend/src/Movie/MovieHeadshot.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import MovieImage from './MovieImage'; + +const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+MKCgEdHeShUbsAAALZSURBVHja7dxNcuwgDEZR1qAVmP1vMrNUJe91GfTzCSpXo575lAymjYWGXRIDKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKNA/AZ3fcTR0/owjofNDnAadnwPoPnS+xTXQeQZ0rkQ/dC4H0Gzo7ITO3bgGOnug/2PcAF3Mczt0fUj0QncG7znQBupw3PkWqh8qpkagpnyqjuArkkxaC02kRqGypCZANVYFdJZCdy9WTRVB5znQ6qTmjFFBWnOhdg20Lqnp0CpqAbRmAJRAK5JaA32zngTNvv910OSkVkJTs1oLtWugeTkNQZ/nkT2rotBHldUwNE6VQTVWGTQ6AHKggqGaBS23JkKf0hUgE1qa01Ro5fzPhoapR0HtCGg4q0poSCqFRgaAFhqxqqEr1EOgmdJaqHdaHQq1I6CunPZAHdY2aIJUBN2V9kE3H1Wd0BXrNVA7BLpgdUCtALo8pZqhdgd0Z6OyE7q1pdoH3dv7tS7o7iZ1E3R/N70Huuz795cQao65vvkqooT+vEgDdPcbj2s3zxTv9Qt/7cuhdgfUo2yAOplyqNuphfqZSqhFmEJo0HkcdPZCo0rRymRxpwSawHR+YtyBZihfvi+nQO0OqCmcYahGqYPGS4qCUJkzBpUpJdCkordyaFZxXi1UUpaZAJ2XQFOLh8ug2XXjVdD0+vYiqLIO3w1VH8EogtoxUPnpGxe04zyTA1p57i4T2nTmbnnnUuLMg1afYE2C1h+1zYEKjlknQLtPg9tb3YzU+dL054qOBb8cvcz3DlqBZhUmhdrnKo9j+pR0rkN5UHkznZHPtJIYN2TTCe1poTUyk9nWPO0bt8Ys7Ug34mlUMONtPUXMaEdXnXN1MnUzN2Z9q3Lr8XQN1DaLQJpXpiamZwltYdIUHShQoECBAgUKFChQoECBAgUKFChQoECBAgUKFChQoECBAgUKFCjQ+vgCff/mEp/vtiIAAAAASUVORK5CYII='; + +function MovieHeadshot(props) { + return ( + + ); +} + +MovieHeadshot.propTypes = { + size: PropTypes.number.isRequired +}; + +MovieHeadshot.defaultProps = { + size: 250 +}; + +export default MovieHeadshot; diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index d948cf7bf..03b67a0bc 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -19,6 +19,7 @@ import * as rootFolders from './rootFolderActions'; import * as movies from './movieActions'; import * as movieHistory from './movieHistoryActions'; import * as movieIndex from './movieIndexActions'; +import * as movieCredits from './movieCreditsActions'; import * as settings from './settingsActions'; import * as system from './systemActions'; import * as tags from './tagActions'; @@ -45,6 +46,7 @@ export default [ movies, movieHistory, movieIndex, + movieCredits, settings, system, tags diff --git a/frontend/src/Store/Actions/movieCreditsActions.js b/frontend/src/Store/Actions/movieCreditsActions.js new file mode 100644 index 000000000..004571fd8 --- /dev/null +++ b/frontend/src/Store/Actions/movieCreditsActions.js @@ -0,0 +1,81 @@ +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'movieCredits'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_MOVIE_CREDITS = 'movieCredits/fetchMovieCredits'; +export const CLEAR_MOVIE_CREDITS = 'movieCredits/clearMovieCredits'; + +// +// Action Creators + +export const fetchMovieCredits = createThunk(FETCH_MOVIE_CREDITS); +export const clearMovieCredits = createAction(CLEAR_MOVIE_CREDITS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_MOVIE_CREDITS]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const promise = createAjaxRequest({ + url: '/credit', + data: payload + }).request; + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_MOVIE_CREDITS]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section);