diff --git a/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js b/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js index a23687974..668b60da1 100644 --- a/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js +++ b/frontend/src/AddMovie/ImportMovie/Import/ImportMovie.js @@ -18,6 +18,8 @@ class ImportMovie extends Component { constructor(props, context) { super(props, context); + this.scrollerRef = React.createRef(); + this.state = { allSelected: false, allUnselected: false, @@ -27,13 +29,6 @@ class ImportMovie extends Component { }; } - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - // // Listeners @@ -88,16 +83,12 @@ class ImportMovie extends Component { const { allSelected, allUnselected, - selectedState, - scroller + selectedState } = this.state; return ( - + { rootFoldersFetching ? : null } @@ -126,14 +117,14 @@ class ImportMovie extends Component { !rootFoldersFetching && rootFoldersPopulated && !!unmappedFolders.length && - scroller ? + this.scrollerRef.current ? { diff --git a/frontend/src/App/ModelBase.ts b/frontend/src/App/ModelBase.ts new file mode 100644 index 000000000..187b12fb2 --- /dev/null +++ b/frontend/src/App/ModelBase.ts @@ -0,0 +1,5 @@ +interface ModelBase { + id: number; +} + +export default ModelBase; diff --git a/frontend/src/Collection/Collection.js b/frontend/src/Collection/Collection.js index ebb0c0fab..c1ad15c0b 100644 --- a/frontend/src/Collection/Collection.js +++ b/frontend/src/Collection/Collection.js @@ -35,8 +35,9 @@ class Collection extends Component { constructor(props, context) { super(props, context); + this.scrollerRef = React.createRef(); + this.state = { - scroller: null, jumpBarItems: { order: [] }, jumpToCharacter: null, isPosterOptionsModalOpen: false, @@ -78,10 +79,6 @@ class Collection extends Component { // // Control - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - getSelectedIds = () => { if (this.state.allUnselected) { return []; @@ -234,7 +231,6 @@ class Collection extends Component { } = this.props; const { - scroller, jumpBarItems, jumpToCharacter, isOverviewOptionsModalOpen, @@ -246,7 +242,7 @@ class Collection extends Component { const selectedMovieIds = this.getSelectedIds(); const ViewComponent = getViewComponent(view); - const isLoaded = !!(!error && isPopulated && items.length && scroller); + const isLoaded = !!(!error && isPopulated && items.length && this.scrollerRef.current); const hasNoCollection = !totalItems; return ( @@ -306,10 +302,9 @@ class Collection extends Component {
{ isFetching && !isPopulated && @@ -327,7 +322,7 @@ class Collection extends Component { isLoaded &&
{ - const { onScroll } = this.props; - - if (this.props.onScroll && !isLocked()) { - onScroll(props); - } - }; - - // - // Render - - render() { - const { - className, - innerClassName, - children, - dispatch, - ...otherProps - } = this.props; - - return ( - -
- {children} -
-
- ); - } -} - -PageContentBody.propTypes = { - className: PropTypes.string, - innerClassName: PropTypes.string, - children: PropTypes.node.isRequired, - onScroll: PropTypes.func, - dispatch: PropTypes.func -}; - -PageContentBody.defaultProps = { - className: styles.contentBody, - innerClassName: styles.innerContentBody -}; - -export default PageContentBody; diff --git a/frontend/src/Components/Page/PageContentBody.tsx b/frontend/src/Components/Page/PageContentBody.tsx new file mode 100644 index 000000000..75317f113 --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef, ReactNode, useCallback } from 'react'; +import Scroller from 'Components/Scroller/Scroller'; +import ScrollDirection from 'Helpers/Props/ScrollDirection'; +import { isLocked } from 'Utilities/scrollLock'; +import styles from './PageContentBody.css'; + +interface PageContentBodyProps { + className: string; + innerClassName: string; + children: ReactNode; + initialScrollTop?: number; + onScroll?: (payload) => void; +} + +const PageContentBody = forwardRef( + ( + props: PageContentBodyProps, + ref: React.MutableRefObject + ) => { + const { + className = styles.contentBody, + innerClassName = styles.innerContentBody, + children, + onScroll, + ...otherProps + } = props; + + const onScrollWrapper = useCallback( + (payload) => { + if (onScroll && !isLocked()) { + onScroll(payload); + } + }, + [onScroll] + ); + + return ( + +
{children}
+
+ ); + } +); + +export default PageContentBody; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js index 2d179396a..c93603aa9 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js @@ -45,7 +45,8 @@ PageToolbarButton.propTypes = { iconName: PropTypes.object.isRequired, spinningName: PropTypes.object, isSpinning: PropTypes.bool, - isDisabled: PropTypes.bool + isDisabled: PropTypes.bool, + onPress: PropTypes.func }; PageToolbarButton.defaultProps = { diff --git a/frontend/src/Components/Scroller/Scroller.js b/frontend/src/Components/Scroller/Scroller.js deleted file mode 100644 index 205f1aadd..000000000 --- a/frontend/src/Components/Scroller/Scroller.js +++ /dev/null @@ -1,95 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { scrollDirections } from 'Helpers/Props'; -import styles from './Scroller.css'; - -class Scroller extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scroller = null; - } - - componentDidMount() { - const { - scrollDirection, - autoFocus, - scrollTop - } = this.props; - - if (this.props.scrollTop != null) { - this._scroller.scrollTop = scrollTop; - } - - if (autoFocus && scrollDirection !== scrollDirections.NONE) { - this._scroller.focus({ preventScroll: true }); - } - } - - // - // Control - - _setScrollerRef = (ref) => { - this._scroller = ref; - - this.props.registerScroller(ref); - }; - - // - // Render - - render() { - const { - className, - scrollDirection, - autoScroll, - children, - scrollTop, - onScroll, - registerScroller, - ...otherProps - } = this.props; - - return ( -
- {children} -
- ); - } - -} - -Scroller.propTypes = { - className: PropTypes.string, - scrollDirection: PropTypes.oneOf(scrollDirections.all).isRequired, - autoFocus: PropTypes.bool.isRequired, - autoScroll: PropTypes.bool.isRequired, - scrollTop: PropTypes.number, - children: PropTypes.node, - onScroll: PropTypes.func, - registerScroller: PropTypes.func -}; - -Scroller.defaultProps = { - scrollDirection: scrollDirections.VERTICAL, - autoFocus: true, - autoScroll: true, - registerScroller: () => { /* no-op */ } -}; - -export default Scroller; diff --git a/frontend/src/Components/Scroller/Scroller.tsx b/frontend/src/Components/Scroller/Scroller.tsx new file mode 100644 index 000000000..2bcb899aa --- /dev/null +++ b/frontend/src/Components/Scroller/Scroller.tsx @@ -0,0 +1,90 @@ +import classNames from 'classnames'; +import { throttle } from 'lodash'; +import React, { forwardRef, ReactNode, useEffect, useRef } from 'react'; +import ScrollDirection from 'Helpers/Props/ScrollDirection'; +import styles from './Scroller.css'; + +interface ScrollerProps { + className?: string; + scrollDirection?: ScrollDirection; + autoFocus?: boolean; + autoScroll?: boolean; + scrollTop?: number; + initialScrollTop?: number; + children?: ReactNode; + onScroll?: (payload) => void; +} + +const Scroller = forwardRef( + (props: ScrollerProps, ref: React.MutableRefObject) => { + const { + className, + autoFocus = false, + autoScroll = true, + scrollDirection = ScrollDirection.Vertical, + children, + scrollTop, + initialScrollTop, + onScroll, + ...otherProps + } = props; + + const internalRef = useRef(); + const currentRef = ref ?? internalRef; + + useEffect( + () => { + if (initialScrollTop != null) { + currentRef.current.scrollTop = initialScrollTop; + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + if (scrollTop != null) { + currentRef.current.scrollTop = scrollTop; + } + + if (autoFocus && scrollDirection !== ScrollDirection.None) { + currentRef.current.focus({ preventScroll: true }); + } + }, [autoFocus, currentRef, scrollDirection, scrollTop]); + + useEffect(() => { + const div = currentRef.current; + + const handleScroll = throttle(() => { + const scrollLeft = div.scrollLeft; + const scrollTop = div.scrollTop; + + onScroll?.({ scrollLeft, scrollTop }); + }, 10); + + div.addEventListener('scroll', handleScroll); + + return () => { + div.removeEventListener('scroll', handleScroll); + }; + }, [currentRef, onScroll]); + + return ( +
+ {children} +
+ ); + } +); + +export default Scroller; diff --git a/frontend/src/Components/Table/Column.ts b/frontend/src/Components/Table/Column.ts new file mode 100644 index 000000000..f9ff7287c --- /dev/null +++ b/frontend/src/Components/Table/Column.ts @@ -0,0 +1,10 @@ +interface Column { + name: string; + label: string; + columnLabel: string; + isSortable: boolean; + isVisible: boolean; + isModifiable?: boolean; +} + +export default Column; diff --git a/frontend/src/Components/withScrollPosition.js b/frontend/src/Components/withScrollPosition.js deleted file mode 100644 index bb089b8b0..000000000 --- a/frontend/src/Components/withScrollPosition.js +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import scrollPositions from 'Store/scrollPositions'; - -function withScrollPosition(WrappedComponent, scrollPositionKey) { - function ScrollPosition(props) { - const { - history - } = props; - - const scrollTop = history.action === 'POP' || (history.location.state && history.location.state.restoreScrollPosition) ? - scrollPositions[scrollPositionKey] : - 0; - - return ( - - ); - } - - ScrollPosition.propTypes = { - history: PropTypes.object.isRequired - }; - - return ScrollPosition; -} - -export default withScrollPosition; diff --git a/frontend/src/Components/withScrollPosition.tsx b/frontend/src/Components/withScrollPosition.tsx new file mode 100644 index 000000000..ec13c6ab8 --- /dev/null +++ b/frontend/src/Components/withScrollPosition.tsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import scrollPositions from 'Store/scrollPositions'; + +function withScrollPosition(WrappedComponent, scrollPositionKey) { + function ScrollPosition(props) { + const { history } = props; + + const initialScrollTop = + history.action === 'POP' || + (history.location.state && history.location.state.restoreScrollPosition) + ? scrollPositions[scrollPositionKey] + : 0; + + return ; + } + + ScrollPosition.propTypes = { + history: PropTypes.object.isRequired, + }; + + return ScrollPosition; +} + +export default withScrollPosition; diff --git a/frontend/src/DiscoverMovie/DiscoverMovie.js b/frontend/src/DiscoverMovie/DiscoverMovie.js index 4199b380a..fb9272cb7 100644 --- a/frontend/src/DiscoverMovie/DiscoverMovie.js +++ b/frontend/src/DiscoverMovie/DiscoverMovie.js @@ -49,8 +49,9 @@ class DiscoverMovie extends Component { constructor(props, context) { super(props, context); + this.scrollerRef = React.createRef(); + this.state = { - scroller: null, jumpBarItems: { order: [] }, jumpToCharacter: null, isPosterOptionsModalOpen: false, @@ -92,10 +93,6 @@ class DiscoverMovie extends Component { // // Control - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - getSelectedIds = () => { if (this.state.allUnselected) { return []; @@ -258,7 +255,6 @@ class DiscoverMovie extends Component { } = this.props; const { - scroller, jumpBarItems, jumpToCharacter, isPosterOptionsModalOpen, @@ -271,7 +267,7 @@ class DiscoverMovie extends Component { const selectedMovieIds = this.getSelectedIds(); const ViewComponent = getViewComponent(view); - const isLoaded = !!(!error && isPopulated && items.length && scroller); + const isLoaded = !!(!error && isPopulated && items.length && this.scrollerRef.current); const hasNoMovie = !totalItems; return ( @@ -362,10 +358,9 @@ class DiscoverMovie extends Component {
{ isFetching && !isPopulated && @@ -383,7 +378,7 @@ class DiscoverMovie extends Component { isLoaded &&
+): ReturnType { + return useMeasureHook({ + polyfill: ResizeObserver, + ...options, + }); +} + +export default useMeasure; diff --git a/frontend/src/Helpers/Props/ScrollDirection.ts b/frontend/src/Helpers/Props/ScrollDirection.ts new file mode 100644 index 000000000..0da932d22 --- /dev/null +++ b/frontend/src/Helpers/Props/ScrollDirection.ts @@ -0,0 +1,8 @@ +enum ScrollDirection { + Horizontal = 'horizontal', + Vertical = 'vertical', + None = 'none', + Both = 'both', +} + +export default ScrollDirection; diff --git a/frontend/src/Helpers/Props/SortDirection.ts b/frontend/src/Helpers/Props/SortDirection.ts new file mode 100644 index 000000000..ac027fadc --- /dev/null +++ b/frontend/src/Helpers/Props/SortDirection.ts @@ -0,0 +1,6 @@ +enum SortDirection { + Ascending = 'ascending', + Descending = 'descending', +} + +export default SortDirection; diff --git a/frontend/src/Movie/Delete/DeleteMovieModal.js b/frontend/src/Movie/Delete/DeleteMovieModal.js index dc4e1de04..5497e3952 100644 --- a/frontend/src/Movie/Delete/DeleteMovieModal.js +++ b/frontend/src/Movie/Delete/DeleteMovieModal.js @@ -28,6 +28,7 @@ function DeleteMovieModal(props) { } DeleteMovieModal.propTypes = { + ...DeleteMovieModalContentConnector.propTypes, isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired, previousMovie: PropTypes.string diff --git a/frontend/src/Movie/Edit/EditMovieModal.js b/frontend/src/Movie/Edit/EditMovieModal.js index 24d9e432a..0b5ada1ca 100644 --- a/frontend/src/Movie/Edit/EditMovieModal.js +++ b/frontend/src/Movie/Edit/EditMovieModal.js @@ -18,6 +18,7 @@ function EditMovieModal({ isOpen, onModalClose, ...otherProps }) { } EditMovieModal.propTypes = { + ...EditMovieModalContentConnector.propTypes, isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Movie/Edit/EditMovieModalConnector.js b/frontend/src/Movie/Edit/EditMovieModalConnector.js index e403f585e..d3eeb0678 100644 --- a/frontend/src/Movie/Edit/EditMovieModalConnector.js +++ b/frontend/src/Movie/Edit/EditMovieModalConnector.js @@ -32,6 +32,7 @@ class EditMovieModalConnector extends Component { } EditMovieModalConnector.propTypes = { + ...EditMovieModal.propTypes, onModalClose: PropTypes.func.isRequired, clearPendingChanges: PropTypes.func.isRequired }; diff --git a/frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.js b/frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.tsx similarity index 76% rename from frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.js rename to frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.tsx index a34f310a9..c80586ef8 100644 --- a/frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.js +++ b/frontend/src/Movie/Index/Menus/MovieIndexFilterMenu.tsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import FilterMenu from 'Components/Menu/FilterMenu'; import { align } from 'Helpers/Props'; -import MovieIndexFilterModalConnector from 'Movie/Index/MovieIndexFilterModalConnector'; +import MovieIndexFilterModal from 'Movie/Index/MovieIndexFilterModal'; function MovieIndexFilterMenu(props) { const { @@ -10,7 +10,7 @@ function MovieIndexFilterMenu(props) { filters, customFilters, isDisabled, - onFilterSelect + onFilterSelect, } = props; return ( @@ -20,22 +20,23 @@ function MovieIndexFilterMenu(props) { selectedFilterKey={selectedFilterKey} filters={filters} customFilters={customFilters} - filterModalConnectorComponent={MovieIndexFilterModalConnector} + filterModalConnectorComponent={MovieIndexFilterModal} onFilterSelect={onFilterSelect} /> ); } MovieIndexFilterMenu.propTypes = { - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, isDisabled: PropTypes.bool.isRequired, - onFilterSelect: PropTypes.func.isRequired + onFilterSelect: PropTypes.func.isRequired, }; MovieIndexFilterMenu.defaultProps = { - showCustomFilters: false + showCustomFilters: false, }; export default MovieIndexFilterMenu; diff --git a/frontend/src/Movie/Index/Menus/MovieIndexSortMenu.js b/frontend/src/Movie/Index/Menus/MovieIndexSortMenu.tsx similarity index 95% rename from frontend/src/Movie/Index/Menus/MovieIndexSortMenu.js rename to frontend/src/Movie/Index/Menus/MovieIndexSortMenu.tsx index abe1b68bf..e7effeeab 100644 --- a/frontend/src/Movie/Index/Menus/MovieIndexSortMenu.js +++ b/frontend/src/Movie/Index/Menus/MovieIndexSortMenu.tsx @@ -7,18 +7,10 @@ import { align, sortDirections } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; function MovieIndexSortMenu(props) { - const { - sortKey, - sortDirection, - isDisabled, - onSortSelect - } = props; + const { sortKey, sortDirection, isDisabled, onSortSelect } = props; return ( - + + - + {translate('Table')} - + {translate('Posters')} @@ -50,7 +35,7 @@ function MovieIndexViewMenu(props) { MovieIndexViewMenu.propTypes = { view: PropTypes.string.isRequired, isDisabled: PropTypes.bool.isRequired, - onViewSelect: PropTypes.func.isRequired + onViewSelect: PropTypes.func.isRequired, }; export default MovieIndexViewMenu; diff --git a/frontend/src/Movie/Index/MovieIndex.js b/frontend/src/Movie/Index/MovieIndex.js deleted file mode 100644 index 8eab27460..000000000 --- a/frontend/src/Movie/Index/MovieIndex.js +++ /dev/null @@ -1,658 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ConfirmModal from 'Components/Modal/ConfirmModal'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageJumpBar from 'Components/Page/PageJumpBar'; -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 TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import { align, icons, kinds, sortDirections } from 'Helpers/Props'; -import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; -import MovieEditorFooter from 'Movie/Editor/MovieEditorFooter.js'; -import OrganizeMovieModal from 'Movie/Editor/Organize/OrganizeMovieModal'; -import NoMovie from 'Movie/NoMovie'; -import * as keyCodes from 'Utilities/Constants/keyCodes'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import translate from 'Utilities/String/translate'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import MovieIndexFilterMenu from './Menus/MovieIndexFilterMenu'; -import MovieIndexSortMenu from './Menus/MovieIndexSortMenu'; -import MovieIndexViewMenu from './Menus/MovieIndexViewMenu'; -import MovieIndexFooterConnector from './MovieIndexFooterConnector'; -import MovieIndexOverviewsConnector from './Overview/MovieIndexOverviewsConnector'; -import MovieIndexOverviewOptionsModal from './Overview/Options/MovieIndexOverviewOptionsModal'; -import MovieIndexPostersConnector from './Posters/MovieIndexPostersConnector'; -import MovieIndexPosterOptionsModal from './Posters/Options/MovieIndexPosterOptionsModal'; -import MovieIndexTableConnector from './Table/MovieIndexTableConnector'; -import MovieIndexTableOptionsConnector from './Table/MovieIndexTableOptionsConnector'; -import styles from './MovieIndex.css'; - -function getViewComponent(view) { - if (view === 'posters') { - return MovieIndexPostersConnector; - } - - if (view === 'overview') { - return MovieIndexOverviewsConnector; - } - - return MovieIndexTableConnector; -} - -class MovieIndex extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - scroller: null, - jumpBarItems: { order: [] }, - jumpToCharacter: null, - isPosterOptionsModalOpen: false, - isOverviewOptionsModalOpen: false, - isInteractiveImportModalOpen: false, - isMovieEditorActive: false, - isOrganizingMovieModalOpen: false, - isConfirmSearchModalOpen: false, - searchType: null, - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {} - }; - } - - componentDidMount() { - this.setJumpBarItems(); - this.setSelectedState(); - - window.addEventListener('keyup', this.onKeyUp); - } - - componentDidUpdate(prevProps) { - const { - items, - sortKey, - sortDirection, - isDeleting, - deleteError - } = this.props; - - if (sortKey !== prevProps.sortKey || - sortDirection !== prevProps.sortDirection || - hasDifferentItemsOrOrder(prevProps.items, items) - ) { - this.setJumpBarItems(); - this.setSelectedState(); - } - - if (this.state.jumpToCharacter != null) { - this.setState({ jumpToCharacter: null }); - } - - const hasFinishedDeleting = prevProps.isDeleting && - !isDeleting && - !deleteError; - - if (hasFinishedDeleting) { - this.onSelectAllChange({ value: false }); - } - } - - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - - getSelectedIds = () => { - if (this.state.allUnselected) { - return []; - } - return getSelectedIds(this.state.selectedState); - }; - - setSelectedState() { - const { - items - } = this.props; - - const { - selectedState - } = this.state; - - const newSelectedState = {}; - - items.forEach((movie) => { - const isItemSelected = selectedState[movie.id]; - - if (isItemSelected) { - newSelectedState[movie.id] = isItemSelected; - } else { - newSelectedState[movie.id] = false; - } - }); - - const selectedCount = getSelectedIds(newSelectedState).length; - const newStateCount = Object.keys(newSelectedState).length; - let isAllSelected = false; - let isAllUnselected = false; - - if (selectedCount === 0) { - isAllUnselected = true; - } else if (selectedCount === newStateCount) { - isAllSelected = true; - } - - this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected }); - } - - setJumpBarItems() { - const { - items, - sortKey, - sortDirection - } = this.props; - - // Reset if not sorting by sortTitle - if (sortKey !== 'sortTitle') { - this.setState({ jumpBarItems: { order: [] } }); - return; - } - - const characters = _.reduce(items, (acc, item) => { - let char = item.sortTitle.charAt(0); - - if (!isNaN(char)) { - char = '#'; - } - - if (char in acc) { - acc[char] = acc[char] + 1; - } else { - acc[char] = 1; - } - - return acc; - }, {}); - - const order = Object.keys(characters).sort(); - - // Reverse if sorting descending - if (sortDirection === sortDirections.DESCENDING) { - order.reverse(); - } - - const jumpBarItems = { - characters, - order - }; - - this.setState({ jumpBarItems }); - } - - // - // Listeners - - onPosterOptionsPress = () => { - this.setState({ isPosterOptionsModalOpen: true }); - }; - - onPosterOptionsModalClose = () => { - this.setState({ isPosterOptionsModalOpen: false }); - }; - - onOverviewOptionsPress = () => { - this.setState({ isOverviewOptionsModalOpen: true }); - }; - - onOverviewOptionsModalClose = () => { - this.setState({ isOverviewOptionsModalOpen: false }); - }; - - onInteractiveImportPress = () => { - this.setState({ isInteractiveImportModalOpen: true }); - }; - - onInteractiveImportModalClose = () => { - this.setState({ isInteractiveImportModalOpen: false }); - }; - - onMovieEditorTogglePress = () => { - if (this.state.isMovieEditorActive) { - this.setState({ isMovieEditorActive: false }); - } else { - const newState = selectAll(this.state.selectedState, false); - newState.isMovieEditorActive = true; - this.setState(newState); - } - }; - - onJumpBarItemPress = (jumpToCharacter) => { - this.setState({ jumpToCharacter }); - }; - - onKeyUp = (event) => { - const jumpBarItems = this.state.jumpBarItems.order; - if (event.composedPath && event.composedPath().length === 4) { - if (event.keyCode === keyCodes.HOME && event.ctrlKey) { - this.setState({ jumpToCharacter: jumpBarItems[0] }); - } - if (event.keyCode === keyCodes.END && event.ctrlKey) { - this.setState({ jumpToCharacter: jumpBarItems[jumpBarItems.length - 1] }); - } - } - }; - - onSelectAllChange = ({ value }) => { - this.setState(selectAll(this.state.selectedState, value)); - }; - - onSelectAllPress = () => { - this.onSelectAllChange({ value: !this.state.allSelected }); - }; - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - }; - - onSaveSelected = (changes) => { - this.props.onSaveSelected({ - movieIds: this.getSelectedIds(), - ...changes - }); - }; - - onOrganizeMoviePress = () => { - this.setState({ isOrganizingMovieModalOpen: true }); - }; - - onOrganizeMovieModalClose = (organized) => { - this.setState({ isOrganizingMovieModalOpen: false }); - - if (organized === true) { - this.onSelectAllChange({ value: false }); - } - }; - - onSearchPress = () => { - this.setState({ isConfirmSearchModalOpen: true, searchType: 'moviesSearch' }); - }; - - onRefreshMoviePress = () => { - const selectedMovieIds = this.getSelectedIds(); - const refreshIds = this.state.isMovieEditorActive && selectedMovieIds.length > 0 ? selectedMovieIds : []; - - this.props.onRefreshMoviePress(refreshIds); - }; - - onSearchConfirmed = () => { - const selectedMovieIds = this.getSelectedIds(); - const searchIds = this.state.isMovieEditorActive && selectedMovieIds.length > 0 ? selectedMovieIds : this.props.items.map((m) => m.id); - - this.props.onSearchPress(this.state.searchType, searchIds); - this.setState({ isConfirmSearchModalOpen: false }); - }; - - onConfirmSearchModalClose = () => { - this.setState({ isConfirmSearchModalOpen: false }); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - totalItems, - items, - columns, - selectedFilterKey, - filters, - customFilters, - sortKey, - sortDirection, - view, - isRefreshingMovie, - isRssSyncExecuting, - isOrganizingMovie, - isSearchingMovies, - isSaving, - saveError, - isDeleting, - deleteError, - onScroll, - onSortSelect, - onFilterSelect, - onViewSelect, - onRefreshMoviePress, - onRssSyncPress, - onSearchPress, - ...otherProps - } = this.props; - - const { - scroller, - jumpBarItems, - jumpToCharacter, - isPosterOptionsModalOpen, - isOverviewOptionsModalOpen, - isInteractiveImportModalOpen, - isConfirmSearchModalOpen, - isMovieEditorActive, - selectedState, - allSelected, - allUnselected - } = this.state; - - const selectedMovieIds = this.getSelectedIds(); - - const ViewComponent = getViewComponent(view); - const isLoaded = !!(!error && isPopulated && items.length && scroller); - const hasNoMovie = !totalItems; - - const searchIndexLabel = selectedFilterKey === 'all' ? translate('SearchAll') : translate('SearchFiltered'); - const searchEditorLabel = selectedMovieIds.length > 0 ? translate('SearchSelected') : translate('SearchAll'); - - return ( - - - - 0 ? translate('UpdateSelected') : translate('UpdateAll')} - iconName={icons.REFRESH} - spinningName={icons.REFRESH} - isSpinning={isRefreshingMovie} - isDisabled={hasNoMovie} - onPress={this.onRefreshMoviePress} - /> - - - - - - - - - - - - { - isMovieEditorActive ? - : - - } - - { - isMovieEditorActive ? - : - null - } - - - - - { - view === 'table' ? - - - : - null - } - - { - view === 'posters' ? - : - null - } - - { - view === 'overview' ? - : - null - } - - - - - - - - - - - -
- - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && -
- {getErrorMessage(error, translate('FailedToLoadMovieFromAPI'))} -
- } - - { - isLoaded && -
- - - { - !isMovieEditorActive && - - } -
- } - - { - !error && isPopulated && !items.length && - - } -
- - { - isLoaded && !!jumpBarItems.order.length && - - } -
- - { - isLoaded && isMovieEditorActive && - - } - - - - - - - - - - -
- Are you sure you want to perform mass movie search for {isMovieEditorActive && selectedMovieIds.length > 0 ? selectedMovieIds.length : this.props.items.length} movies? -
-
- {translate('ThisCannotBeCancelled')} -
-
- } - confirmLabel={translate('Search')} - onConfirm={this.onSearchConfirmed} - onCancel={this.onConfirmSearchModalClose} - /> - - ); - } -} - -MovieIndex.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - totalItems: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - view: PropTypes.string.isRequired, - isRefreshingMovie: PropTypes.bool.isRequired, - isOrganizingMovie: PropTypes.bool.isRequired, - isSearchingMovies: PropTypes.bool.isRequired, - isRssSyncExecuting: PropTypes.bool.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - isDeleting: PropTypes.bool.isRequired, - deleteError: PropTypes.object, - onSortSelect: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired, - onViewSelect: PropTypes.func.isRequired, - onRefreshMoviePress: PropTypes.func.isRequired, - onRssSyncPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired, - onScroll: PropTypes.func.isRequired, - onSaveSelected: PropTypes.func.isRequired -}; - -export default MovieIndex; diff --git a/frontend/src/Movie/Index/MovieIndex.tsx b/frontend/src/Movie/Index/MovieIndex.tsx new file mode 100644 index 000000000..dc8e5db31 --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndex.tsx @@ -0,0 +1,306 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { REFRESH_MOVIE, RSS_SYNC } from 'Commands/commandNames'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageJumpBar from 'Components/Page/PageJumpBar'; +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 TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import { align, icons } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; +import NoMovie from 'Movie/NoMovie'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { + setMovieFilter, + setMovieSort, + setMovieTableOption, + setMovieView, +} from 'Store/Actions/movieIndexActions'; +import scrollPositions from 'Store/scrollPositions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createMovieClientSideCollectionItemsSelector from 'Store/Selectors/createMovieClientSideCollectionItemsSelector'; +import MovieIndexFilterMenu from './Menus/MovieIndexFilterMenu'; +import MovieIndexSortMenu from './Menus/MovieIndexSortMenu'; +import MovieIndexViewMenu from './Menus/MovieIndexViewMenu'; +import MovieIndexFooter from './MovieIndexFooter'; +import MovieIndexOverviews from './Overview/MovieIndexOverviews'; +import MovieIndexOverviewOptionsModal from './Overview/Options/MovieIndexOverviewOptionsModal'; +import MovieIndexPosters from './Posters/MovieIndexPosters'; +import MovieIndexPosterOptionsModal from './Posters/Options/MovieIndexPosterOptionsModal'; +import MovieIndexTable from './Table/MovieIndexTable'; +import MovieIndexTableOptions from './Table/MovieIndexTableOptions'; +import styles from './MovieIndex.css'; + +function getViewComponent(view: string) { + if (view === 'posters') { + return MovieIndexPosters; + } + + if (view === 'overview') { + return MovieIndexOverviews; + } + + return MovieIndexTable; +} + +function MovieIndex() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + columns, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + view, + } = useSelector(createMovieClientSideCollectionItemsSelector('movieIndex')); + + const isRefreshingMovie = useSelector( + createCommandExecutingSelector(REFRESH_MOVIE) + ); + const isRssSyncExecuting = useSelector( + createCommandExecutingSelector(RSS_SYNC) + ); + const { isSmallScreen } = useSelector(createDimensionsSelector()); + const dispatch = useDispatch(); + const scrollerRef = useRef(); + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + const [jumpToCharacter, setJumpToCharacter] = useState(null); + + const onRefreshMoviePress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_MOVIE, + }) + ); + }, [dispatch]); + + const onRssSyncPress = useCallback(() => { + dispatch( + executeCommand({ + name: RSS_SYNC, + }) + ); + }, [dispatch]); + + const onTableOptionChange = useCallback( + (payload) => { + dispatch(setMovieTableOption(payload)); + }, + [dispatch] + ); + + const onViewSelect = useCallback( + (value) => { + dispatch(setMovieView({ view: value })); + + if (scrollerRef.current) { + scrollerRef.current.scrollTo(0, 0); + } + }, + [scrollerRef, dispatch] + ); + + const onSortSelect = useCallback( + (value) => { + dispatch(setMovieSort({ sortKey: value })); + }, + [dispatch] + ); + + const onFilterSelect = useCallback( + (value) => { + dispatch(setMovieFilter({ selectedFilterKey: value })); + }, + [dispatch] + ); + + const onOptionsPress = useCallback(() => { + setIsOptionsModalOpen(true); + }, [setIsOptionsModalOpen]); + + const onOptionsModalClose = useCallback(() => { + setIsOptionsModalOpen(false); + }, [setIsOptionsModalOpen]); + + const onJumpBarItemPress = useCallback( + (character) => { + setJumpToCharacter(character); + }, + [setJumpToCharacter] + ); + + const onScroll = useCallback( + ({ scrollTop }) => { + setJumpToCharacter(null); + scrollPositions.movieIndex = scrollTop; + }, + [setJumpToCharacter] + ); + + const jumpBarItems = useMemo(() => { + // Reset if not sorting by sortTitle + if (sortKey !== 'sortTitle') { + return { + order: [], + }; + } + + const characters = items.reduce((acc, item) => { + let char = item.sortTitle.charAt(0); + + if (!isNaN(char)) { + char = '#'; + } + + if (char in acc) { + acc[char] = acc[char] + 1; + } else { + acc[char] = 1; + } + + return acc; + }, {}); + + const order = Object.keys(characters).sort(); + + // Reverse if sorting descending + if (sortDirection === SortDirection.Descending) { + order.reverse(); + } + + return { + characters, + order, + }; + }, [items, sortKey, sortDirection]); + const ViewComponent = useMemo(() => getViewComponent(view), [view]); + + const isLoaded = !!(!error && isPopulated && items.length); + const hasNoMovie = !totalItems; + + return ( + + + + + + + + + + {view === 'table' ? ( + + + + ) : ( + + )} + + + + + + + + + + +
+ + {isFetching && !isPopulated ? : null} + + {!isFetching && !!error ?
Unable to load movie
: null} + + {isLoaded ? ( +
+ + + +
+ ) : null} + + {!error && isPopulated && !items.length ? ( + + ) : null} +
+ + {isLoaded && !!jumpBarItems.order.length ? ( + + ) : null} +
+ {view === 'posters' ? ( + + ) : null} + {view === 'overview' ? ( + + ) : null} +
+ ); +} + +export default MovieIndex; diff --git a/frontend/src/Movie/Index/MovieIndexConnector.js b/frontend/src/Movie/Index/MovieIndexConnector.js deleted file mode 100644 index 9cb3703a5..000000000 --- a/frontend/src/Movie/Index/MovieIndexConnector.js +++ /dev/null @@ -1,160 +0,0 @@ -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 withScrollPosition from 'Components/withScrollPosition'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { saveMovieEditor, setMovieFilter, setMovieSort, setMovieTableOption, setMovieView } from 'Store/Actions/movieIndexActions'; -import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; -import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; -import scrollPositions from 'Store/scrollPositions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createMovieClientSideCollectionItemsSelector from 'Store/Selectors/createMovieClientSideCollectionItemsSelector'; -import MovieIndex from './MovieIndex'; - -function createMapStateToProps() { - return createSelector( - createMovieClientSideCollectionItemsSelector('movieIndex'), - createCommandExecutingSelector(commandNames.REFRESH_MOVIE), - createCommandExecutingSelector(commandNames.RSS_SYNC), - createCommandExecutingSelector(commandNames.RENAME_MOVIE), - createCommandExecutingSelector(commandNames.CUTOFF_UNMET_MOVIES_SEARCH), - createCommandExecutingSelector(commandNames.MISSING_MOVIES_SEARCH), - createDimensionsSelector(), - ( - movies, - isRefreshingMovie, - isRssSyncExecuting, - isOrganizingMovie, - isCutoffMoviesSearch, - isMissingMoviesSearch, - dimensionsState - ) => { - return { - ...movies, - isRefreshingMovie, - isRssSyncExecuting, - isOrganizingMovie, - isSearchingMovies: isCutoffMoviesSearch || isMissingMoviesSearch, - isSmallScreen: dimensionsState.isSmallScreen - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - fetchQueueDetails() { - dispatch(fetchQueueDetails()); - }, - - clearQueueDetails() { - dispatch(clearQueueDetails()); - }, - - dispatchFetchRootFolders() { - dispatch(fetchRootFolders()); - }, - - onTableOptionChange(payload) { - dispatch(setMovieTableOption(payload)); - }, - - onSortSelect(sortKey) { - dispatch(setMovieSort({ sortKey })); - }, - - onFilterSelect(selectedFilterKey) { - dispatch(setMovieFilter({ selectedFilterKey })); - }, - - dispatchSetMovieView(view) { - dispatch(setMovieView({ view })); - }, - - dispatchSaveMovieEditor(payload) { - dispatch(saveMovieEditor(payload)); - }, - - onRefreshMoviePress(items) { - dispatch(executeCommand({ - name: commandNames.REFRESH_MOVIE, - movieIds: items - })); - }, - - onRssSyncPress() { - dispatch(executeCommand({ - name: commandNames.RSS_SYNC - })); - }, - - onSearchPress(command, items) { - dispatch(executeCommand({ - name: command, - movieIds: items - })); - } - }; -} - -class MovieIndexConnector extends Component { - - componentDidMount() { - // TODO: Fetch root folders here for now, but should eventually fetch on editor toggle and check loaded before showing controls - this.props.dispatchFetchRootFolders(); - this.props.fetchQueueDetails(); - } - - componentWillUnmount() { - this.props.clearQueueDetails(); - } - - // - // Listeners - - onViewSelect = (view) => { - // Reset the scroll position before changing the view - this.props.dispatchSetMovieView(view); - }; - - onSaveSelected = (payload) => { - this.props.dispatchSaveMovieEditor(payload); - }; - - onScroll = ({ scrollTop }) => { - scrollPositions.movieIndex = scrollTop; - }; - - // - // Render - - render() { - return ( - - ); - } -} - -MovieIndexConnector.propTypes = { - isSmallScreen: PropTypes.bool.isRequired, - view: PropTypes.string.isRequired, - dispatchFetchRootFolders: PropTypes.func.isRequired, - dispatchSetMovieView: PropTypes.func.isRequired, - dispatchSaveMovieEditor: PropTypes.func.isRequired, - fetchQueueDetails: PropTypes.func.isRequired, - clearQueueDetails: PropTypes.func.isRequired, - items: PropTypes.arrayOf(PropTypes.object) -}; - -export default withScrollPosition( - connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexConnector), - 'movieIndex' -); diff --git a/frontend/src/Movie/Index/MovieIndexFilterModal.tsx b/frontend/src/Movie/Index/MovieIndexFilterModal.tsx new file mode 100644 index 000000000..5162941c6 --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndexFilterModal.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setMovieFilter } from 'Store/Actions/movieIndexActions'; + +function createMovieSelector() { + return createSelector( + (state) => state.movies.items, + (movies) => { + return movies; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state) => state.movieIndex.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +export default function MovieIndexFilterModal(props) { + const sectionItems = useSelector(createMovieSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'movieIndex'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload) => { + dispatch(setMovieFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Movie/Index/MovieIndexFilterModalConnector.js b/frontend/src/Movie/Index/MovieIndexFilterModalConnector.js deleted file mode 100644 index ff95111d6..000000000 --- a/frontend/src/Movie/Index/MovieIndexFilterModalConnector.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterModal from 'Components/Filter/FilterModal'; -import { setMovieFilter } from 'Store/Actions/movieIndexActions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.movies.items, - (state) => state.movieIndex.filterBuilderProps, - (sectionItems, filterBuilderProps) => { - return { - sectionItems, - filterBuilderProps, - customFilterType: 'movieIndex' - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetFilter: setMovieFilter -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Movie/Index/MovieIndexFooter.js b/frontend/src/Movie/Index/MovieIndexFooter.js deleted file mode 100644 index ad746d09a..000000000 --- a/frontend/src/Movie/Index/MovieIndexFooter.js +++ /dev/null @@ -1,132 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import styles from './MovieIndexFooter.css'; - -class MovieIndexFooter extends PureComponent { - - render() { - const { - movies, - colorImpairedMode - } = this.props; - - const count = movies.length; - let movieFiles = 0; - let monitored = 0; - let totalFileSize = 0; - - movies.forEach((s) => { - - if (s.hasFile) { - movieFiles += 1; - } - - if (s.monitored) { - monitored++; - } - - totalFileSize += s.sizeOnDisk; - }); - - return ( -
-
-
-
-
- {translate('DownloadedAndMonitored')} -
-
- -
-
-
- {translate('DownloadedButNotMonitored')} -
-
- -
-
-
- {translate('MissingMonitoredAndConsideredAvailable')} -
-
- -
-
-
- {translate('MissingNotMonitored')} -
-
- -
-
-
- {translate('Queued')} -
-
- -
-
-
- {translate('Unreleased')} -
-
-
- -
- - - - - - - - - - - - - - - -
-
- ); - } -} - -MovieIndexFooter.propTypes = { - movies: PropTypes.arrayOf(PropTypes.object).isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - -export default MovieIndexFooter; diff --git a/frontend/src/Movie/Index/MovieIndexFooter.tsx b/frontend/src/Movie/Index/MovieIndexFooter.tsx new file mode 100644 index 000000000..76bb3ab37 --- /dev/null +++ b/frontend/src/Movie/Index/MovieIndexFooter.tsx @@ -0,0 +1,137 @@ +import classNames from 'classnames'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import styles from './MovieIndexFooter.css'; + +function createUnoptimizedSelector() { + return createSelector( + createClientSideCollectionSelector('movies', 'movieIndex'), + (movies) => { + return movies.items.map((m) => { + const { monitored, status } = m; + + return { + monitored, + status, + }; + }); + } + ); +} + +function createMovieSelector() { + return createDeepEqualSelector( + createUnoptimizedSelector(), + (movies) => movies + ); +} + +export default function MovieIndexFooter() { + const movies = useSelector(createMovieSelector()); + const count = movies.length; + let movieFiles = 0; + let monitored = 0; + let totalFileSize = 0; + + movies.forEach((s) => { + if (s.hasFile) { + movieFiles += 1; + } + + if (s.monitored) { + monitored++; + } + + totalFileSize += s.sizeOnDisk; + }); + + return ( + + {(enableColorImpairedMode) => { + return ( +
+
+
+
+
{translate('DownloadedAndMonitored')}
+
+ +
+
+
{translate('DownloadedButNotMonitored')}
+
+ +
+
+
{translate('MissingMonitoredAndConsideredAvailable')}
+
+ +
+
+
{translate('MissingNotMonitored')}
+
+ +
+
+
{translate('Queued')}
+
+ +
+
+
{translate('Unreleased')}
+
+
+ +
+ + + + + + + + + + + + + + + +
+
+ ); + }} + + ); +} diff --git a/frontend/src/Movie/Index/MovieIndexFooterConnector.js b/frontend/src/Movie/Index/MovieIndexFooterConnector.js deleted file mode 100644 index ac017036f..000000000 --- a/frontend/src/Movie/Index/MovieIndexFooterConnector.js +++ /dev/null @@ -1,53 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import MovieIndexFooter from './MovieIndexFooter'; - -function createUnoptimizedSelector() { - return createSelector( - createClientSideCollectionSelector('movies', 'movieIndex'), - (movies) => { - return movies.items.map((s) => { - const { - monitored, - status, - statistics, - sizeOnDisk, - hasFile - } = s; - - return { - monitored, - status, - statistics, - sizeOnDisk, - hasFile - }; - }); - } - ); -} - -function createMoviesSelector() { - return createDeepEqualSelector( - createUnoptimizedSelector(), - (movies) => movies - ); -} - -function createMapStateToProps() { - return createSelector( - createMoviesSelector(), - createUISettingsSelector(), - (movies, uiSettings) => { - return { - movies, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(MovieIndexFooter); diff --git a/frontend/src/Movie/Index/MovieIndexItemConnector.js b/frontend/src/Movie/Index/MovieIndexItemConnector.js deleted file mode 100644 index 5f5710750..000000000 --- a/frontend/src/Movie/Index/MovieIndexItemConnector.js +++ /dev/null @@ -1,136 +0,0 @@ -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 createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; -import createMovieQualityProfileSelector from 'Store/Selectors/createMovieQualityProfileSelector'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; - -function selectShowSearchAction() { - return createSelector( - (state) => state.movieIndex, - (movieIndex) => { - const view = movieIndex.view; - - switch (view) { - case 'posters': - return movieIndex.posterOptions.showSearchAction; - case 'overview': - return movieIndex.overviewOptions.showSearchAction; - default: - return movieIndex.tableOptions.showSearchAction; - } - } - ); -} - -function createMapStateToProps() { - return createSelector( - createMovieSelector(), - createMovieQualityProfileSelector(), - selectShowSearchAction(), - createExecutingCommandsSelector(), - (state) => state.queue.details.items, - ( - movie, - qualityProfile, - showSearchAction, - executingCommands, - queueItems - ) => { - - // If a movie is deleted this selector may fire before the parent - // selecors, which will result in an undefined movie, if that happens - // we want to return early here and again in the render function to avoid - // trying to show a movie that has no information available. - - if (!movie) { - return {}; - } - - const isRefreshingMovie = executingCommands.some((command) => { - return ( - command.name === commandNames.REFRESH_MOVIE && - command.body.movieIds.includes(movie.id) - ); - }); - - const isSearchingMovie = executingCommands.some((command) => { - return ( - command.name === commandNames.MOVIE_SEARCH && - command.body.movieIds.includes(movie.id) - ); - }); - - const firstQueueItem = queueItems.find((q) => q.movieId === movie.id); - - return { - ...movie, - qualityProfile, - showSearchAction, - isRefreshingMovie, - isSearchingMovie, - queueStatus: firstQueueItem ? firstQueueItem.status : null, - queueState: firstQueueItem ? firstQueueItem.trackedDownloadState : null - }; - } - ); -} - -const mapDispatchToProps = { - dispatchExecuteCommand: executeCommand -}; - -class MovieIndexItemConnector extends Component { - - // - // Listeners - - onRefreshMoviePress = () => { - 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() { - const { - id, - component: ItemComponent, - ...otherProps - } = this.props; - - if (!id) { - return null; - } - - return ( - - ); - } -} - -MovieIndexItemConnector.propTypes = { - id: PropTypes.number, - component: PropTypes.elementType.isRequired, - dispatchExecuteCommand: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(MovieIndexItemConnector); diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverview.js b/frontend/src/Movie/Index/Overview/MovieIndexOverview.js deleted file mode 100644 index 9d296fd40..000000000 --- a/frontend/src/Movie/Index/Overview/MovieIndexOverview.js +++ /dev/null @@ -1,315 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextTruncate from 'react-text-truncate'; -import CheckInput from 'Components/Form/CheckInput'; -import Icon from 'Components/Icon'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import Popover from 'Components/Tooltip/Popover'; -import { icons } from 'Helpers/Props'; -import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; -import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; -import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; -import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; -import MoviePoster from 'Movie/MoviePoster'; -import dimensions from 'Styles/Variables/dimensions'; -import fonts from 'Styles/Variables/fonts'; -import translate from 'Utilities/String/translate'; -import MovieIndexOverviewInfo from './MovieIndexOverviewInfo'; -import styles from './MovieIndexOverview.css'; - -const columnPadding = parseInt(dimensions.movieIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); -const defaultFontSize = parseInt(fonts.defaultFontSize); -const lineHeight = parseFloat(fonts.lineHeight); - -// Hardcoded height beased on line-height of 32 + bottom margin of 10. -// Less side-effecty than using react-measure. -const titleRowHeight = 42; - -function getContentHeight(rowHeight, isSmallScreen) { - const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; - - return rowHeight - padding; -} - -class MovieIndexOverview extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditMovieModalOpen: false, - isDeleteMovieModalOpen: false - }; - } - - // - // Listeners - - onEditMoviePress = () => { - this.setState({ isEditMovieModalOpen: true }); - }; - - onEditMovieModalClose = () => { - this.setState({ isEditMovieModalOpen: false }); - }; - - onDeleteMoviePress = () => { - this.setState({ - isEditMovieModalOpen: false, - isDeleteMovieModalOpen: true - }); - }; - - onDeleteMovieModalClose = () => { - this.setState({ isDeleteMovieModalOpen: false }); - }; - - onChange = ({ value, shiftKey }) => { - const { - id, - onSelectedChange - } = this.props; - - onSelectedChange({ id, value, shiftKey }); - }; - - // - // Render - - render() { - const { - id, - tmdbId, - imdbId, - youTubeTrailerId, - title, - overview, - monitored, - hasFile, - isAvailable, - status, - titleSlug, - images, - posterWidth, - posterHeight, - qualityProfile, - overviewOptions, - showSearchAction, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - rowHeight, - isSmallScreen, - isRefreshingMovie, - isSearchingMovie, - onRefreshMoviePress, - onSearchPress, - isMovieEditorActive, - isSelected, - onSelectedChange, - queueStatus, - queueState, - ...otherProps - } = this.props; - - const { - isEditMovieModalOpen, - isDeleteMovieModalOpen - } = this.state; - - const link = `/movie/${titleSlug}`; - - const elementStyle = { - width: `${posterWidth}px`, - height: `${posterHeight}px` - }; - - const contentHeight = getContentHeight(rowHeight, isSmallScreen); - const overviewHeight = contentHeight - titleRowHeight; - - return ( -
-
-
-
- { - isMovieEditorActive && -
- -
- } - - - - -
- - -
- -
-
- - {title} - - -
- - - } - title={translate('Links')} - body={ - - } - /> - - - - - { - showSearchAction && - - } - - -
-
- -
- - - - - -
-
-
- - - - -
- ); - } -} - -MovieIndexOverview.propTypes = { - id: PropTypes.number.isRequired, - title: PropTypes.string.isRequired, - overview: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - hasFile: PropTypes.bool.isRequired, - isAvailable: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - titleSlug: PropTypes.string.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - posterWidth: PropTypes.number.isRequired, - posterHeight: PropTypes.number.isRequired, - rowHeight: PropTypes.number.isRequired, - qualityProfile: PropTypes.object.isRequired, - overviewOptions: PropTypes.object.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - isRefreshingMovie: PropTypes.bool.isRequired, - isSearchingMovie: PropTypes.bool.isRequired, - onRefreshMoviePress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired, - isMovieEditorActive: PropTypes.bool.isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired, - tmdbId: PropTypes.number.isRequired, - imdbId: PropTypes.string, - youTubeTrailerId: PropTypes.string, - queueStatus: PropTypes.string, - queueState: PropTypes.string -}; - -export default MovieIndexOverview; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverview.tsx b/frontend/src/Movie/Index/Overview/MovieIndexOverview.tsx new file mode 100644 index 000000000..da6e42252 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverview.tsx @@ -0,0 +1,244 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import TextTruncate from 'react-text-truncate'; +import { MOVIE_SEARCH, REFRESH_MOVIE } from 'Commands/commandNames'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Popover from 'Components/Tooltip/Popover'; +import { icons } from 'Helpers/Props'; +import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; +import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; +import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; +import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; +import MoviePoster from 'Movie/MoviePoster'; +import { executeCommand } from 'Store/Actions/commandActions'; +import dimensions from 'Styles/Variables/dimensions'; +import fonts from 'Styles/Variables/fonts'; +import translate from 'Utilities/String/translate'; +import createMovieIndexItemSelector from '../createMovieIndexItemSelector'; +import MovieIndexOverviewInfo from './MovieIndexOverviewInfo'; +import selectOverviewOptions from './selectOverviewOptions'; +import styles from './MovieIndexOverview.css'; + +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.movieIndexColumnPaddingSmallScreen +); +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +// Hardcoded height beased on line-height of 32 + bottom margin of 10. +// Less side-effecty than using react-measure. +const titleRowHeight = 42; + +interface MovieIndexOverviewProps { + movieId: number; + sortKey: string; + posterWidth: number; + posterHeight: number; + rowHeight: number; + isSmallScreen: boolean; +} + +function MovieIndexOverview(props: MovieIndexOverviewProps) { + const { + movieId, + sortKey, + posterWidth, + posterHeight, + rowHeight, + isSmallScreen, + } = props; + + const { movie, qualityProfile, isRefreshingMovie, isSearchingMovie } = + useSelector(createMovieIndexItemSelector(props.movieId)); + + const overviewOptions = useSelector(selectOverviewOptions); + + const { + title, + monitored, + status, + path, + overview, + images, + hasFile, + isAvailable, + tmdbId, + imdbId, + youTubeTrailerId, + queueStatus, + queueState, + } = movie; + + const dispatch = useDispatch(); + const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false); + const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_MOVIE, + movieId, + }) + ); + }, [movieId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: MOVIE_SEARCH, + movieId, + }) + ); + }, [movieId, dispatch]); + + const onEditMoviePress = useCallback(() => { + setIsEditMovieModalOpen(true); + }, [setIsEditMovieModalOpen]); + + const onEditMovieModalClose = useCallback(() => { + setIsEditMovieModalOpen(false); + }, [setIsEditMovieModalOpen]); + + const onDeleteMoviePress = useCallback(() => { + setIsEditMovieModalOpen(false); + setIsDeleteMovieModalOpen(true); + }, [setIsDeleteMovieModalOpen]); + + const onDeleteMovieModalClose = useCallback(() => { + setIsDeleteMovieModalOpen(false); + }, [setIsDeleteMovieModalOpen]); + + const link = `/movie/${tmdbId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + }; + + const contentHeight = useMemo(() => { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; + }, [rowHeight, isSmallScreen]); + + const overviewHeight = contentHeight - titleRowHeight; + + return ( +
+
+
+
+ + + +
+ + +
+ +
+
+ + {title} + + +
+ + } + title={translate('Links')} + body={ + + } + /> + + + + + {overviewOptions.showSearchAction ? ( + + ) : null} + + +
+
+ +
+ + + + + +
+
+
+ + + + +
+ ); +} + +export default MovieIndexOverview; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.js deleted file mode 100644 index 1135a1f69..000000000 --- a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.js +++ /dev/null @@ -1,192 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { icons } from 'Helpers/Props'; -import dimensions from 'Styles/Variables/dimensions'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import formatBytes from 'Utilities/Number/formatBytes'; -import MovieIndexOverviewInfoRow from './MovieIndexOverviewInfoRow'; -import styles from './MovieIndexOverviewInfo.css'; - -const infoRowHeight = parseInt(dimensions.movieIndexOverviewInfoRowHeight); - -const rows = [ - { - name: 'monitored', - showProp: 'showMonitored', - valueProp: 'monitored' - - }, - { - name: 'studio', - showProp: 'showStudio', - valueProp: 'studio' - }, - { - name: 'qualityProfileId', - showProp: 'showQualityProfile', - valueProp: 'qualityProfileId' - }, - { - name: 'added', - showProp: 'showAdded', - valueProp: 'added' - }, - { - name: 'path', - showProp: 'showPath', - valueProp: 'path' - }, - { - name: 'sizeOnDisk', - showProp: 'showSizeOnDisk', - valueProp: 'sizeOnDisk' - } -]; - -function isVisible(row, props) { - const { - name, - showProp, - valueProp - } = row; - - if (props[valueProp] == null) { - return false; - } - - return props[showProp] || props.sortKey === name; -} - -function getInfoRowProps(row, props) { - const { name } = row; - - if (name === 'monitored') { - const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; - - return { - title: monitoredText, - iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, - label: monitoredText - }; - } - - if (name === 'studio') { - return { - title: 'Studio', - iconName: icons.STUDIO, - label: props.studio - }; - } - - if (name === 'qualityProfileId') { - return { - title: 'Quality Profile', - iconName: icons.PROFILE, - label: props.qualityProfile.name - }; - } - - if (name === 'added') { - const { - added, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat - } = props; - - return { - title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, - iconName: icons.ADD, - label: getRelativeDate( - added, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - }; - } - - if (name === 'path') { - return { - title: 'Path', - iconName: icons.FOLDER, - label: props.path - }; - } - - if (name === 'sizeOnDisk') { - return { - title: 'Size on Disk', - iconName: icons.DRIVE, - label: formatBytes(props.sizeOnDisk) - }; - } -} - -function MovieIndexOverviewInfo(props) { - const { - height - // showRelativeDates, - // shortDateFormat, - // longDateFormat, - // timeFormat - } = props; - - let shownRows = 1; - const maxRows = Math.floor(height / (infoRowHeight + 4)); - - return ( -
- { - rows.map((row) => { - if (!isVisible(row, props)) { - return null; - } - - if (shownRows >= maxRows) { - return null; - } - - shownRows++; - - const infoRowProps = getInfoRowProps(row, props); - - return ( - - ); - }) - } -
- ); -} - -MovieIndexOverviewInfo.propTypes = { - height: PropTypes.number.isRequired, - showStudio: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showAdded: PropTypes.bool.isRequired, - showPath: PropTypes.bool.isRequired, - showSizeOnDisk: PropTypes.bool.isRequired, - monitored: PropTypes.bool.isRequired, - studio: PropTypes.string, - qualityProfile: PropTypes.object.isRequired, - added: PropTypes.string, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - sortKey: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default MovieIndexOverviewInfo; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.tsx b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.tsx new file mode 100644 index 000000000..2226b1052 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfo.tsx @@ -0,0 +1,166 @@ +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { icons } from 'Helpers/Props'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import dimensions from 'Styles/Variables/dimensions'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import MovieIndexOverviewInfoRow from './MovieIndexOverviewInfoRow'; +import styles from './MovieIndexOverviewInfo.css'; + +const infoRowHeight = parseInt(dimensions.movieIndexOverviewInfoRowHeight); + +const rows = [ + { + name: 'monitored', + showProp: 'showMonitored', + valueProp: 'monitored', + }, + { + name: 'studio', + showProp: 'showStudio', + valueProp: 'studio', + }, + { + name: 'qualityProfileId', + showProp: 'showQualityProfile', + valueProp: 'qualityProfileId', + }, + { + name: 'added', + showProp: 'showAdded', + valueProp: 'added', + }, + { + name: 'path', + showProp: 'showPath', + valueProp: 'path', + }, + { + name: 'sizeOnDisk', + showProp: 'showSizeOnDisk', + valueProp: 'sizeOnDisk', + }, +]; + +function getInfoRowProps(row, props, uiSettings) { + const { name } = row; + + if (name === 'monitored') { + const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; + + return { + title: monitoredText, + iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, + label: monitoredText, + }; + } + + if (name === 'studio') { + return { + title: 'Studio', + iconName: icons.STUDIO, + label: props.studio, + }; + } + + if (name === 'qualityProfileId') { + return { + title: 'Quality Profile', + iconName: icons.PROFILE, + label: props.qualityProfile.name, + }; + } + + if (name === 'added') { + const added = props.added; + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + uiSettings; + + return { + title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, + iconName: icons.ADD, + label: getRelativeDate(added, shortDateFormat, showRelativeDates, { + timeFormat, + timeForToday: true, + }), + }; + } + + if (name === 'path') { + return { + title: 'Path', + iconName: icons.FOLDER, + label: props.path, + }; + } + + if (name === 'sizeOnDisk') { + return { + title: 'Size on Disk', + iconName: icons.DRIVE, + label: formatBytes(props.sizeOnDisk), + }; + } +} + +interface MovieIndexOverviewInfoProps { + height: number; + showMonitored: boolean; + showQualityProfile: boolean; + showAdded: boolean; + showPath: boolean; + showSizeOnDisk: boolean; + monitored: boolean; + qualityProfile: object; + added?: string; + path: string; + sizeOnDisk?: number; + sortKey: string; +} + +function MovieIndexOverviewInfo(props: MovieIndexOverviewInfoProps) { + const height = props.height; + + const uiSettings = useSelector(createUISettingsSelector()); + + let shownRows = 1; + const maxRows = Math.floor(height / (infoRowHeight + 4)); + + const rowInfo = useMemo(() => { + return rows.map((row) => { + const { name, showProp, valueProp } = row; + + const isVisible = + props[valueProp] != null && (props[showProp] || props.sortKey === name); + + return { + ...row, + isVisible, + }; + }); + }, [props]); + + return ( +
+ {rowInfo.map((row) => { + if (!row.isVisible) { + return null; + } + + if (shownRows >= maxRows) { + return null; + } + + shownRows++; + + const infoRowProps = getInfoRowProps(row, props, uiSettings); + + return ; + })} +
+ ); +} + +export default MovieIndexOverviewInfo; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.js deleted file mode 100644 index 15e77426d..000000000 --- a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.js +++ /dev/null @@ -1,35 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import styles from './MovieIndexOverviewInfoRow.css'; - -function MovieIndexOverviewInfoRow(props) { - const { - title, - iconName, - label - } = props; - - return ( -
- - - {label} -
- ); -} - -MovieIndexOverviewInfoRow.propTypes = { - title: PropTypes.string, - iconName: PropTypes.object.isRequired, - label: PropTypes.string.isRequired -}; - -export default MovieIndexOverviewInfoRow; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.tsx b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.tsx new file mode 100644 index 000000000..df22c18af --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviewInfoRow.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './MovieIndexOverviewInfoRow.css'; + +interface MovieIndexOverviewInfoRowProps { + title?: string; + iconName: object; + label: string; +} + +function MovieIndexOverviewInfoRow(props: MovieIndexOverviewInfoRowProps) { + const { title, iconName, label } = props; + + return ( +
+ + + {label} +
+ ); +} + +export default MovieIndexOverviewInfoRow; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviews.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.js deleted file mode 100644 index f924b1f3e..000000000 --- a/frontend/src/Movie/Index/Overview/MovieIndexOverviews.js +++ /dev/null @@ -1,285 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import Measure from 'Components/Measure'; -import MovieIndexItemConnector from 'Movie/Index/MovieIndexItemConnector'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import MovieIndexOverview from './MovieIndexOverview'; -import styles from './MovieIndexOverviews.css'; - -// Poster container dimensions -const columnPadding = parseInt(dimensions.movieIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -function calculatePosterWidth(posterSize, isSmallScreen) { - const maxiumPosterWidth = isSmallScreen ? 152 : 162; - - if (posterSize === 'large') { - return maxiumPosterWidth; - } - - if (posterSize === 'medium') { - return Math.floor(maxiumPosterWidth * 0.75); - } - - return Math.floor(maxiumPosterWidth * 0.5); -} - -function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) { - const { - detailedProgressBar - } = overviewOptions; - - const heights = [ - posterHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculatePosterHeight(posterWidth) { - return Math.ceil((250 / 170) * posterWidth); -} - -class MovieIndexOverviews extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnCount: 1, - posterWidth: 162, - posterHeight: 238, - rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._grid = null; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - overviewOptions, - jumpToCharacter, - scrollTop, - isMovieEditorActive, - isSmallScreen - } = this.props; - - const { - width, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.overviewOptions !== overviewOptions) { - this.calculateGrid(this.state.width, isSmallScreen); - } - - if ( - this._grid && - (prevState.width !== width || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items) || - prevProps.overviewOptions !== overviewOptions || - prevProps.isMovieEditorActive !== isMovieEditorActive)) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - - this._grid.scrollToCell({ - rowIndex: index, - columnIndex: 0 - }); - } - } - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - overviewOptions - } = this.props; - - const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen); - const posterHeight = calculatePosterHeight(posterWidth); - const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions); - - this.setState({ - width, - posterWidth, - posterHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, style }) => { - const { - items, - sortKey, - overviewOptions, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - isSmallScreen, - selectedState, - isMovieEditorActive, - onSelectedChange - } = this.props; - - const { - posterWidth, - posterHeight, - rowHeight - } = this.state; - - const movie = items[rowIndex]; - - if (!movie) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - isSmallScreen, - scroller, - items, - selectedState - } = this.props; - - const { - width, - rowHeight - } = this.state; - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( -
- -
- ); - } - } - - - ); - } -} - -MovieIndexOverviews.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - overviewOptions: PropTypes.object.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number.isRequired, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired, - selectedState: PropTypes.object.isRequired, - onSelectedChange: PropTypes.func.isRequired, - isMovieEditorActive: PropTypes.bool.isRequired -}; - -export default MovieIndexOverviews; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviews.tsx b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.tsx new file mode 100644 index 000000000..08c3ce764 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/MovieIndexOverviews.tsx @@ -0,0 +1,203 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import Movie from 'Movie/Movie'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import MovieIndexOverview from './MovieIndexOverview'; +import selectOverviewOptions from './selectOverviewOptions'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.movieIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); + +interface RowItemData { + items: Movie[]; + sortKey: string; + posterWidth: number; + posterHeight: number; + rowHeight: number; + isSmallScreen: boolean; +} + +interface MovieIndexOverviewsProps { + items: Movie[]; + sortKey?: string; + sortDirection?: string; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const Row: React.FC> = ({ + index, + style, + data, +}) => { + const { items, ...otherData } = data; + + if (index >= items.length) { + return null; + } + + const movie = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +function MovieIndexOverviews(props: MovieIndexOverviewsProps) { + const { items, sortKey, jumpToCharacter, isSmallScreen, scrollerRef } = props; + + const { size: posterSize, detailedProgressBar } = useSelector( + selectOverviewOptions + ); + const listRef: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const posterWidth = useMemo(() => { + const maxiumPosterWidth = isSmallScreen ? 152 : 162; + + if (posterSize === 'large') { + return maxiumPosterWidth; + } + + if (posterSize === 'medium') { + return Math.floor(maxiumPosterWidth * 0.75); + } + + return Math.floor(maxiumPosterWidth * 0.5); + }, [posterSize, isSmallScreen]); + + const posterHeight = useMemo(() => { + return Math.ceil((250 / 170) * posterWidth); + }, [posterWidth]); + + const rowHeight = useMemo(() => { + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + return heights.reduce((acc, height) => acc + height, 0); + }, [detailedProgressBar, posterHeight, isSmallScreen]); + + useEffect(() => { + const current = scrollerRef.current as HTMLElement; + + if (isSmallScreen) { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = + (isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + listRef.current.scrollTo(scrollTop); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, listRef, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + let scrollTop = index * rowHeight; + + // If the offset is zero go to the top, otherwise offset + // by the approximate size of the header + padding (37 + 20). + if (scrollTop > 0) { + const offset = 57; + + scrollTop += offset; + } + + listRef.current.scrollTo(scrollTop); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); + + return ( +
+ + ref={listRef} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + itemCount={items.length} + itemSize={rowHeight} + itemData={{ + items, + sortKey, + posterWidth, + posterHeight, + rowHeight, + isSmallScreen, + }} + > + {Row} + +
+ ); +} + +export default MovieIndexOverviews; diff --git a/frontend/src/Movie/Index/Overview/MovieIndexOverviewsConnector.js b/frontend/src/Movie/Index/Overview/MovieIndexOverviewsConnector.js deleted file mode 100644 index 4a46dc1c1..000000000 --- a/frontend/src/Movie/Index/Overview/MovieIndexOverviewsConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import MovieIndexOverviews from './MovieIndexOverviews'; - -function createMapStateToProps() { - return createSelector( - (state) => state.movieIndex.overviewOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (overviewOptions, uiSettings, dimensions) => { - return { - overviewOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(MovieIndexOverviews); diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.js b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.js deleted file mode 100644 index 75b38e228..000000000 --- a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import MovieIndexOverviewOptionsModalContentConnector from './MovieIndexOverviewOptionsModalContentConnector'; - -function MovieIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -MovieIndexOverviewOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default MovieIndexOverviewOptionsModal; diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.tsx b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.tsx new file mode 100644 index 000000000..78e238906 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModal.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import MovieIndexOverviewOptionsModalContent from './MovieIndexOverviewOptionsModalContent'; + +interface MovieIndexOverviewOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): void; +} + +function MovieIndexOverviewOptionsModal({ + isOpen, + onModalClose, + ...otherProps +}: MovieIndexOverviewOptionsModalProps) { + return ( + + + + ); +} + +export default MovieIndexOverviewOptionsModal; diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.js b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.js deleted file mode 100644 index 391b43f29..000000000 --- a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.js +++ /dev/null @@ -1,268 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const posterSizeOptions = [ - { key: 'small', value: translate('Small') }, - { key: 'medium', value: translate('Medium') }, - { key: 'large', value: translate('Large') } -]; - -class MovieIndexOverviewOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showMonitored: props.showMonitored, - showStudio: props.showStudio, - showQualityProfile: props.showQualityProfile, - showAdded: props.showAdded, - showPath: props.showPath, - showSizeOnDisk: props.showSizeOnDisk, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showMonitored, - showStudio, - showQualityProfile, - showAdded, - showPath, - showSizeOnDisk, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showStudio !== prevProps.showStudio) { - state.showStudio = showStudio; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showAdded !== prevProps.showAdded) { - state.showAdded = showAdded; - } - - if (showPath !== prevProps.showPath) { - state.showPath = showPath; - } - - if (showSizeOnDisk !== prevProps.showSizeOnDisk) { - state.showSizeOnDisk = showSizeOnDisk; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangeOverviewOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangeOverviewOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showMonitored, - showStudio, - showQualityProfile, - showAdded, - showPath, - showSizeOnDisk, - showSearchAction - } = this.state; - - return ( - - - {translate('OverviewOptions')} - - - -
- - {translate('PosterSize')} - - - - - - {translate('DetailedProgressBar')} - - - - - - {translate('ShowMonitored')} - - - - - - {translate('ShowStudio')} - - - - - - {translate('ShowQualityProfile')} - - - - - - {translate('ShowDateAdded')} - - - - - - {translate('ShowPath')} - - - - - - {translate('ShowSizeOnDisk')} - - - - - - {translate('ShowSearch')} - - - -
-
- - - - -
- ); - } -} - -MovieIndexOverviewOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showStudio: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showAdded: PropTypes.bool.isRequired, - showPath: PropTypes.bool.isRequired, - showSizeOnDisk: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangeOverviewOption: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default MovieIndexOverviewOptionsModalContent; diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.tsx b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.tsx new file mode 100644 index 000000000..36351a322 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContent.tsx @@ -0,0 +1,170 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import { setMovieOverviewOption } from 'Store/Actions/movieIndexActions'; +import translate from 'Utilities/String/translate'; +import selectOverviewOptions from '../selectOverviewOptions'; + +const posterSizeOptions = [ + { key: 'small', value: translate('Small') }, + { key: 'medium', value: translate('Medium') }, + { key: 'large', value: translate('Large') }, +]; + +interface MovieIndexOverviewOptionsModalContentProps { + onModalClose(...args: unknown[]): void; +} + +function MovieIndexOverviewOptionsModalContent( + props: MovieIndexOverviewOptionsModalContentProps +) { + const { onModalClose } = props; + + const { + detailedProgressBar, + size, + showMonitored, + showStudio, + showQualityProfile, + showAdded, + showPath, + showSizeOnDisk, + showSearchAction, + } = useSelector(selectOverviewOptions); + + const dispatch = useDispatch(); + + const onOverviewOptionChange = useCallback( + ({ name, value }) => { + dispatch(setMovieOverviewOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('OverviewOptions')} + + +
+ + {translate('PosterSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowStudio')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowDateAdded')} + + + + + + {translate('ShowPath')} + + + + + + {translate('ShowSizeOnDisk')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default MovieIndexOverviewOptionsModalContent; diff --git a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContentConnector.js b/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContentConnector.js deleted file mode 100644 index 06e3d7eb8..000000000 --- a/frontend/src/Movie/Index/Overview/Options/MovieIndexOverviewOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setMovieOverviewOption } from 'Store/Actions/movieIndexActions'; -import MovieIndexOverviewOptionsModalContent from './MovieIndexOverviewOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.movieIndex, - (movieIndex) => { - return movieIndex.overviewOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangeOverviewOption(payload) { - dispatch(setMovieOverviewOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexOverviewOptionsModalContent); diff --git a/frontend/src/Movie/Index/Overview/selectOverviewOptions.ts b/frontend/src/Movie/Index/Overview/selectOverviewOptions.ts new file mode 100644 index 000000000..3e445c456 --- /dev/null +++ b/frontend/src/Movie/Index/Overview/selectOverviewOptions.ts @@ -0,0 +1,8 @@ +import { createSelector } from 'reselect'; + +const selectOverviewOptions = createSelector( + (state) => state.movieIndex.overviewOptions, + (overviewOptions) => overviewOptions +); + +export default selectOverviewOptions; diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPoster.js b/frontend/src/Movie/Index/Posters/MovieIndexPoster.js deleted file mode 100644 index 56d25df8a..000000000 --- a/frontend/src/Movie/Index/Posters/MovieIndexPoster.js +++ /dev/null @@ -1,401 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import CheckInput from 'Components/Form/CheckInput'; -import Icon from 'Components/Icon'; -import Label from 'Components/Label'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import Popover from 'Components/Tooltip/Popover'; -import { icons } from 'Helpers/Props'; -import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; -import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; -import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; -import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; -import MoviePoster from 'Movie/MoviePoster'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import translate from 'Utilities/String/translate'; -import MovieIndexPosterInfo from './MovieIndexPosterInfo'; -import styles from './MovieIndexPoster.css'; - -class MovieIndexPoster extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - hasPosterError: false, - isEditMovieModalOpen: false, - isDeleteMovieModalOpen: false - }; - } - - // - // Listeners - - onEditMoviePress = () => { - this.setState({ isEditMovieModalOpen: true }); - }; - - onEditMovieModalClose = () => { - this.setState({ isEditMovieModalOpen: false }); - }; - - onDeleteMoviePress = () => { - this.setState({ - isEditMovieModalOpen: false, - isDeleteMovieModalOpen: true - }); - }; - - onDeleteMovieModalClose = () => { - this.setState({ isDeleteMovieModalOpen: false }); - }; - - onPosterLoad = () => { - if (this.state.hasPosterError) { - this.setState({ hasPosterError: false }); - } - }; - - onPosterLoadError = () => { - if (!this.state.hasPosterError) { - this.setState({ hasPosterError: true }); - } - }; - - onChange = ({ value, shiftKey }) => { - const { - id, - onSelectedChange - } = this.props; - - onSelectedChange({ id, value, shiftKey }); - }; - - // - // Render - - render() { - const { - id, - tmdbId, - imdbId, - youTubeTrailerId, - title, - monitored, - hasFile, - isAvailable, - status, - titleSlug, - images, - posterWidth, - posterHeight, - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - qualityProfile, - showSearchAction, - showRelativeDates, - shortDateFormat, - showReleaseDate, - showCinemaRelease, - inCinemas, - physicalRelease, - digitalRelease, - timeFormat, - isRefreshingMovie, - isSearchingMovie, - onRefreshMoviePress, - onSearchPress, - isMovieEditorActive, - isSelected, - onSelectedChange, - queueStatus, - queueState, - ...otherProps - } = this.props; - - const { - hasPosterError, - isEditMovieModalOpen, - isDeleteMovieModalOpen - } = this.state; - - const link = `/movie/${titleSlug}`; - - const elementStyle = { - width: `${posterWidth}px`, - height: `${posterHeight}px` - }; - - let releaseDate = ''; - let releaseDateType = ''; - if (physicalRelease && digitalRelease) { - releaseDate = (physicalRelease < digitalRelease) ? physicalRelease : digitalRelease; - releaseDateType = (physicalRelease < digitalRelease) ? 'Released' : 'Digital'; - } else if (physicalRelease && !digitalRelease) { - releaseDate = physicalRelease; - releaseDateType = 'Released'; - } else if (digitalRelease && !physicalRelease) { - releaseDate = digitalRelease; - releaseDateType = 'Digital'; - } - - return ( -
-
- { - isMovieEditorActive && -
- -
- } - - - { - status === 'ended' && -
- } - - - - - { - hasPosterError && -
- {title} -
- } - -
- - - - { - showTitle && -
- {title} -
- } - - { - showMonitored && -
- {monitored ? translate('Monitored') : translate('Unmonitored')} -
- } - - { - showQualityProfile && -
- {qualityProfile.name} -
- } - - { - showCinemaRelease && inCinemas && -
- {getRelativeDate( - inCinemas, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: false - } - )} -
- } - - { - showReleaseDate && releaseDateType === 'Released' && -
- {getRelativeDate( - releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: false - } - )} -
- } - - { - showReleaseDate && releaseDateType === 'Digital' && -
- {getRelativeDate( - releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: false - } - )} -
- } - - - - - - -
- ); - } -} - -MovieIndexPoster.propTypes = { - id: PropTypes.number.isRequired, - title: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - hasFile: PropTypes.bool.isRequired, - isAvailable: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - titleSlug: PropTypes.string.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - posterWidth: PropTypes.number.isRequired, - posterHeight: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - qualityProfile: PropTypes.object.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - showCinemaRelease: PropTypes.bool.isRequired, - showReleaseDate: PropTypes.bool.isRequired, - inCinemas: PropTypes.string, - physicalRelease: PropTypes.string, - digitalRelease: PropTypes.string, - timeFormat: PropTypes.string.isRequired, - isRefreshingMovie: PropTypes.bool.isRequired, - isSearchingMovie: PropTypes.bool.isRequired, - onRefreshMoviePress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired, - isMovieEditorActive: PropTypes.bool.isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired, - tmdbId: PropTypes.number.isRequired, - imdbId: PropTypes.string, - youTubeTrailerId: PropTypes.string, - queueStatus: PropTypes.string, - queueState: PropTypes.string -}; - -MovieIndexPoster.defaultProps = { - statistics: { - movieFileCount: 0 - } -}; - -export default MovieIndexPoster; diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPoster.tsx b/frontend/src/Movie/Index/Posters/MovieIndexPoster.tsx new file mode 100644 index 000000000..a5e579998 --- /dev/null +++ b/frontend/src/Movie/Index/Posters/MovieIndexPoster.tsx @@ -0,0 +1,238 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { MOVIE_SEARCH, REFRESH_MOVIE } from 'Commands/commandNames'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Popover from 'Components/Tooltip/Popover'; +import { icons } from 'Helpers/Props'; +import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; +import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; +import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; +import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; +import MoviePoster from 'Movie/MoviePoster'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import createMovieIndexItemSelector from '../createMovieIndexItemSelector'; +import MovieIndexPosterInfo from './MovieIndexPosterInfo'; +import selectPosterOptions from './selectPosterOptions'; +import styles from './MovieIndexPoster.css'; + +interface MovieIndexPosterProps { + movieId: number; + sortKey: string; + posterWidth: number; + posterHeight: number; +} + +function MovieIndexPoster(props: MovieIndexPosterProps) { + const { movieId, sortKey, posterWidth, posterHeight } = props; + + const { movie, qualityProfile, isRefreshingMovie, isSearchingMovie } = + useSelector(createMovieIndexItemSelector(props.movieId)); + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showReleaseDate, + showSearchAction, + } = useSelector(selectPosterOptions); + + const { showRelativeDates, shortDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + + const { + title, + monitored, + status, + images, + tmdbId, + imdbId, + youTubeTrailerId, + hasFile, + isAvailable, + inCinemas, + physicalRelease, + digitalRelease, + path, + certification, + queueStatus, + queueState, + } = movie; + + const dispatch = useDispatch(); + const [hasPosterError, setHasPosterError] = useState(false); + const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false); + const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_MOVIE, + movieId, + }) + ); + }, [movieId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: MOVIE_SEARCH, + movieId, + }) + ); + }, [movieId, dispatch]); + + const onPosterLoadError = useCallback(() => { + setHasPosterError(true); + }, [setHasPosterError]); + + const onPosterLoad = useCallback(() => { + setHasPosterError(false); + }, [setHasPosterError]); + + const onEditMoviePress = useCallback(() => { + setIsEditMovieModalOpen(true); + }, [setIsEditMovieModalOpen]); + + const onEditMovieModalClose = useCallback(() => { + setIsEditMovieModalOpen(false); + }, [setIsEditMovieModalOpen]); + + const onDeleteMoviePress = useCallback(() => { + setIsEditMovieModalOpen(false); + setIsDeleteMovieModalOpen(true); + }, [setIsDeleteMovieModalOpen]); + + const onDeleteMovieModalClose = useCallback(() => { + setIsDeleteMovieModalOpen(false); + }, [setIsDeleteMovieModalOpen]); + + const link = `/movie/${tmdbId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + }; + + return ( +
+
+ + + + + + {hasPosterError ? ( +
{title}
+ ) : null} + +
+ + + + {showTitle ?
{title}
: null} + + {showMonitored ? ( +
+ {monitored ? translate('monitored') : translate('unmonitored')} +
+ ) : null} + + {showQualityProfile ? ( +
{qualityProfile.name}
+ ) : null} + + + + + + +
+ ); +} + +export default MovieIndexPoster; diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.js b/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.tsx similarity index 57% rename from frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.js rename to frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.tsx index 33af9e33b..888b0865a 100644 --- a/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.js +++ b/frontend/src/Movie/Index/Posters/MovieIndexPosterInfo.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from 'Components/Icon'; import { icons } from 'Helpers/Props'; @@ -7,39 +6,50 @@ import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; import styles from './MovieIndexPosterInfo.css'; -function MovieIndexPosterInfo(props) { +interface MovieIndexPosterInfoProps { + studio?: string; + showQualityProfile: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + qualityProfile: any; + added?: string; + inCinemas?: string; + digitalRelease?: string; + physicalRelease?: string; + path: string; + certification: string; + sizeOnDisk?: number; + sortKey: string; + showRelativeDates: boolean; + showReleaseDate: boolean; + shortDateFormat: string; + timeFormat: string; +} + +function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) { const { studio, - qualityProfile, showQualityProfile, - showReleaseDate, + qualityProfile, added, inCinemas, digitalRelease, physicalRelease, - certification, path, + certification, sizeOnDisk, sortKey, showRelativeDates, + showReleaseDate, shortDateFormat, - timeFormat + timeFormat, } = props; if (sortKey === 'studio' && studio) { - return ( -
- {studio} -
- ); + return
{studio}
; } if (sortKey === 'qualityProfileId' && !showQualityProfile) { - return ( -
- {qualityProfile.name} -
- ); + return
{qualityProfile.name}
; } if (sortKey === 'added' && added) { @@ -49,7 +59,7 @@ function MovieIndexPosterInfo(props) { showRelativeDates, { timeFormat, - timeForToday: false + timeForToday: false, } ); @@ -67,15 +77,13 @@ function MovieIndexPosterInfo(props) { showRelativeDates, { timeFormat, - timeForToday: false + timeForToday: false, } ); return (
- {inCinemasDate} + {inCinemasDate}
); } @@ -87,15 +95,13 @@ function MovieIndexPosterInfo(props) { showRelativeDates, { timeFormat, - timeForToday: false + timeForToday: false, } ); return (
- {digitalReleaseDate} + {digitalReleaseDate}
); } @@ -107,62 +113,30 @@ function MovieIndexPosterInfo(props) { showRelativeDates, { timeFormat, - timeForToday: false + timeForToday: false, } ); return (
- {physicalReleaseDate} + {physicalReleaseDate}
); } if (sortKey === 'path') { - return ( -
- {path} -
- ); + return
{path}
; } if (sortKey === 'sizeOnDisk') { - return ( -
- {formatBytes(sizeOnDisk)} -
- ); + return
{formatBytes(sizeOnDisk)}
; } if (sortKey === 'certification') { - return ( -
- {certification} -
- ); + return
{certification}
; } return null; } -MovieIndexPosterInfo.propTypes = { - studio: PropTypes.string, - showQualityProfile: PropTypes.bool.isRequired, - qualityProfile: PropTypes.object.isRequired, - added: PropTypes.string, - inCinemas: PropTypes.string, - certification: PropTypes.string, - digitalRelease: PropTypes.string, - physicalRelease: PropTypes.string, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - sortKey: PropTypes.string.isRequired, - showReleaseDate: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - export default MovieIndexPosterInfo; diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosters.css b/frontend/src/Movie/Index/Posters/MovieIndexPosters.css deleted file mode 100644 index 9c6520fb5..000000000 --- a/frontend/src/Movie/Index/Posters/MovieIndexPosters.css +++ /dev/null @@ -1,3 +0,0 @@ -.grid { - flex: 1 0 auto; -} diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosters.css.d.ts b/frontend/src/Movie/Index/Posters/MovieIndexPosters.css.d.ts deleted file mode 100644 index b97436b41..000000000 --- a/frontend/src/Movie/Index/Posters/MovieIndexPosters.css.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file is automatically generated. -// Please do not change this file! -interface CssExports { - 'grid': string; -} -export const cssExports: CssExports; -export default cssExports; diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosters.js b/frontend/src/Movie/Index/Posters/MovieIndexPosters.js deleted file mode 100644 index 1ef6cf70d..000000000 --- a/frontend/src/Movie/Index/Posters/MovieIndexPosters.js +++ /dev/null @@ -1,358 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import Measure from 'Components/Measure'; -import MovieIndexItemConnector from 'Movie/Index/MovieIndexItemConnector'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import MovieIndexPoster from './MovieIndexPoster'; -import styles from './MovieIndexPosters.css'; - -// Poster container dimensions -const columnPadding = parseInt(dimensions.movieIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -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, sortKey, isSmallScreen, posterOptions) { - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showReleaseDate - } = posterOptions; - - const nextAiringHeight = 19; - - const heights = [ - posterHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - nextAiringHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - if (showTitle) { - heights.push(19); - } - - if (showMonitored) { - heights.push(19); - } - - if (showQualityProfile) { - heights.push(19); - } - - if (showReleaseDate) { - heights.push(19); - } - - switch (sortKey) { - case 'studio': - case 'added': - case 'path': - case 'sizeOnDisk': - heights.push(19); - break; - case 'qualityProfileId': - if (!showQualityProfile) { - heights.push(19); - } - break; - default: - // No need to add a height of 0 - } - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculatePosterHeight(posterWidth) { - return Math.ceil((250 / 170) * posterWidth); -} - -class MovieIndexPosters extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnWidth: 182, - columnCount: 1, - posterWidth: 162, - posterHeight: 238, - rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._isInitialized = false; - this._grid = null; - this._padding = props.isSmallScreen ? columnPaddingSmallScreen : columnPadding; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - posterOptions, - jumpToCharacter, - isSmallScreen, - isMovieEditorActive, - scrollTop - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.posterOptions !== posterOptions) { - this.calculateGrid(width, isSmallScreen); - } - - if (this._grid && - (prevState.width !== width || - prevState.columnWidth !== columnWidth || - prevState.columnCount !== columnCount || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items) || - prevState.isMovieEditorActive !== isMovieEditorActive)) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - const row = Math.floor(index / columnCount); - - this._grid.scrollToCell({ - rowIndex: row, - columnIndex: 0 - }); - } - } - - if (this._grid && scrollTop !== 0) { - this._grid.scrollToPosition({ scrollTop }); - } - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - posterOptions - } = this.props; - - const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen); - const columnCount = Math.max(Math.floor(width / columnWidth), 1); - const posterWidth = columnWidth - this._padding * 2; - const posterHeight = calculatePosterHeight(posterWidth); - const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions); - - this.setState({ - width, - columnWidth, - columnCount, - posterWidth, - posterHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, columnIndex, style }) => { - const { - items, - sortKey, - posterOptions, - showRelativeDates, - shortDateFormat, - timeFormat, - selectedState, - isMovieEditorActive, - onSelectedChange - } = this.props; - - const { - posterWidth, - posterHeight, - columnCount - } = this.state; - - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showCinemaRelease, - showReleaseDate - } = posterOptions; - - const movieIdx = rowIndex * columnCount + columnIndex; - const movie = items[movieIdx]; - - if (!movie) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - isSmallScreen, - scroller, - items, - selectedState - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight - } = this.state; - - const rowCount = Math.ceil(items.length / columnCount); - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( -
- -
- ); - } - } - - - ); - } -} - -MovieIndexPosters.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - posterOptions: PropTypes.object.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number.isRequired, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired, - selectedState: PropTypes.object.isRequired, - onSelectedChange: PropTypes.func.isRequired, - isMovieEditorActive: PropTypes.bool.isRequired -}; - -export default MovieIndexPosters; diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPosters.tsx b/frontend/src/Movie/Index/Posters/MovieIndexPosters.tsx new file mode 100644 index 000000000..018a3951e --- /dev/null +++ b/frontend/src/Movie/Index/Posters/MovieIndexPosters.tsx @@ -0,0 +1,285 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window'; +import { createSelector } from 'reselect'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import SortDirection from 'Helpers/Props/SortDirection'; +import MovieIndexPoster from 'Movie/Index/Posters/MovieIndexPoster'; +import Movie from 'Movie/Movie'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.movieIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const ADDITIONAL_COLUMN_COUNT = { + small: 3, + medium: 2, + large: 1, +}; + +interface CellItemData { + layout: { + columnCount: number; + padding: number; + posterWidth: number; + posterHeight: number; + }; + items: Movie[]; + sortKey: string; +} + +interface MovieIndexPostersProps { + items: Movie[]; + sortKey?: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const movieIndexSelector = createSelector( + (state) => state.movieIndex.posterOptions, + (posterOptions) => { + return { + posterOptions, + }; + } +); + +const Cell: React.FC> = ({ + columnIndex, + rowIndex, + style, + data, +}) => { + const { layout, items, sortKey } = data; + + const { columnCount, padding, posterWidth, posterHeight } = layout; + + const index = rowIndex * columnCount + columnIndex; + + if (index >= items.length) { + return null; + } + + const movie = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +export default function MovieIndexPosters(props: MovieIndexPostersProps) { + const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props; + + const { posterOptions } = useSelector(movieIndexSelector); + const ref: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const columnWidth = useMemo(() => { + const { width } = size; + const maximumColumnWidth = isSmallScreen ? 172 : 182; + const columns = Math.floor(width / maximumColumnWidth); + const remainder = width % maximumColumnWidth; + return remainder === 0 + ? maximumColumnWidth + : Math.floor( + width / (columns + ADDITIONAL_COLUMN_COUNT[posterOptions.size]) + ); + }, [isSmallScreen, posterOptions, size]); + + const columnCount = useMemo( + () => Math.max(Math.floor(size.width / columnWidth), 1), + [size, columnWidth] + ); + const padding = props.isSmallScreen + ? columnPaddingSmallScreen + : columnPadding; + const posterWidth = columnWidth - padding * 2; + const posterHeight = Math.ceil((250 / 170) * posterWidth); + + const rowHeight = useMemo(() => { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showReleaseDate, + } = posterOptions; + + const nextAiringHeight = 19; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + if (showReleaseDate) { + heights.push(19); + } + + switch (sortKey) { + case 'studio': + case 'added': + case 'path': + case 'sizeOnDisk': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); + }, [isSmallScreen, posterOptions, sortKey, posterHeight]); + + useEffect(() => { + const current = scrollerRef.current; + + if (isSmallScreen) { + const padding = bodyPaddingSmallScreen - 5; + + setSize({ + width: window.innerWidth - padding * 2, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = bodyPadding - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, ref, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const rowIndex = Math.floor(index / columnCount); + + const scrollTop = rowIndex * rowHeight + padding; + + ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [ + jumpToCharacter, + rowHeight, + columnCount, + padding, + items, + scrollerRef, + ref, + ]); + + return ( +
+ + ref={ref} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + columnCount={columnCount} + columnWidth={columnWidth} + rowCount={Math.ceil(items.length / columnCount)} + rowHeight={rowHeight} + itemData={{ + layout: { + columnCount, + padding, + posterWidth, + posterHeight, + }, + items, + sortKey, + }} + > + {Cell} + +
+ ); +} diff --git a/frontend/src/Movie/Index/Posters/MovieIndexPostersConnector.js b/frontend/src/Movie/Index/Posters/MovieIndexPostersConnector.js deleted file mode 100644 index c54804957..000000000 --- a/frontend/src/Movie/Index/Posters/MovieIndexPostersConnector.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import MovieIndexPosters from './MovieIndexPosters'; - -function createMapStateToProps() { - return createSelector( - (state) => state.movieIndex.posterOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (posterOptions, uiSettings, dimensions) => { - return { - posterOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(MovieIndexPosters); diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.js b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.js deleted file mode 100644 index a9aeaaed7..000000000 --- a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import MovieIndexPosterOptionsModalContentConnector from './MovieIndexPosterOptionsModalContentConnector'; - -function MovieIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -MovieIndexPosterOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default MovieIndexPosterOptionsModal; diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.tsx b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.tsx new file mode 100644 index 000000000..8a8d9b4cc --- /dev/null +++ b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import MovieIndexPosterOptionsModalContent from './MovieIndexPosterOptionsModalContent'; + +interface MovieIndexPosterOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): unknown; +} + +function MovieIndexPosterOptionsModal({ + isOpen, + onModalClose, +}: MovieIndexPosterOptionsModalProps) { + return ( + + + + ); +} + +export default MovieIndexPosterOptionsModal; diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.js b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.js deleted file mode 100644 index 9341f3962..000000000 --- a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.js +++ /dev/null @@ -1,254 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const posterSizeOptions = [ - { key: 'small', value: translate('Small') }, - { key: 'medium', value: translate('Medium') }, - { key: 'large', value: translate('Large') } -]; - -class MovieIndexPosterOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showTitle: props.showTitle, - showMonitored: props.showMonitored, - showQualityProfile: props.showQualityProfile, - showCinemaRelease: props.showCinemaRelease, - showReleaseDate: props.showReleaseDate, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showCinemaRelease, - showReleaseDate, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showTitle !== prevProps.showTitle) { - state.showTitle = showTitle; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showCinemaRelease !== prevProps.showCinemaRelease) { - state.showCinemaRelease = showCinemaRelease; - } - - if (showReleaseDate !== prevProps.showReleaseDate) { - state.showReleaseDate = showReleaseDate; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangePosterOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangePosterOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showCinemaRelease, - showReleaseDate, - showSearchAction - } = this.state; - - return ( - - - {translate('PosterOptions')} - - - -
- - {translate('PosterSize')} - - - - - - {translate('DetailedProgressBar')} - - - - - - {translate('ShowTitle')} - - - - - - {translate('ShowMonitored')} - - - - - - {translate('ShowQualityProfile')} - - - - - - {translate('ShowCinemaRelease')} - - - - - - {translate('ShowReleaseDate')} - - - - - - {translate('ShowSearch')} - - - -
-
- - - - -
- ); - } -} - -MovieIndexPosterOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showCinemaRelease: PropTypes.bool.isRequired, - showReleaseDate: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangePosterOption: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default MovieIndexPosterOptionsModalContent; diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.tsx b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.tsx new file mode 100644 index 000000000..c7857cfec --- /dev/null +++ b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContent.tsx @@ -0,0 +1,165 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import { setMoviePosterOption } from 'Store/Actions/movieIndexActions'; +import translate from 'Utilities/String/translate'; +import selectPosterOptions from '../selectPosterOptions'; + +const posterSizeOptions = [ + { key: 'small', value: translate('Small') }, + { key: 'medium', value: translate('Medium') }, + { key: 'large', value: translate('Large') }, +]; + +interface MovieIndexPosterOptionsModalContentProps { + onModalClose(...args: unknown[]): unknown; +} + +function MovieIndexPosterOptionsModalContent( + props: MovieIndexPosterOptionsModalContentProps +) { + const { onModalClose } = props; + + const posterOptions = useSelector(selectPosterOptions); + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showCinemaRelease, + showReleaseDate, + showSearchAction, + } = posterOptions; + + const dispatch = useDispatch(); + + const onPosterOptionChange = useCallback( + ({ name, value }) => { + dispatch(setMoviePosterOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('PosterOptions')} + + +
+ + {translate('PosterSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowTitle')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowCinemaRelease')} + + + + + + {translate('ShowReleaseDate')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default MovieIndexPosterOptionsModalContent; diff --git a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContentConnector.js b/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContentConnector.js deleted file mode 100644 index c8b9a2a88..000000000 --- a/frontend/src/Movie/Index/Posters/Options/MovieIndexPosterOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setMoviePosterOption } from 'Store/Actions/movieIndexActions'; -import MovieIndexPosterOptionsModalContent from './MovieIndexPosterOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.movieIndex, - (movieIndex) => { - return movieIndex.posterOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangePosterOption(payload) { - dispatch(setMoviePosterOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexPosterOptionsModalContent); diff --git a/frontend/src/Movie/Index/Posters/selectPosterOptions.ts b/frontend/src/Movie/Index/Posters/selectPosterOptions.ts new file mode 100644 index 000000000..a8f2d97c9 --- /dev/null +++ b/frontend/src/Movie/Index/Posters/selectPosterOptions.ts @@ -0,0 +1,8 @@ +import { createSelector } from 'reselect'; + +const selectPosterOptions = createSelector( + (state) => state.movieIndex.posterOptions, + (posterOptions) => posterOptions +); + +export default selectPosterOptions; diff --git a/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.tsx similarity index 57% rename from frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js rename to frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.tsx index 888b0c05d..b96ca037a 100644 --- a/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js +++ b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import ProgressBar from 'Components/ProgressBar'; import { sizes } from 'Helpers/Props'; @@ -7,7 +6,19 @@ import getStatusStyle from 'Utilities/Movie/getStatusStyle'; import translate from 'Utilities/String/translate'; import styles from './MovieIndexProgressBar.css'; -function MovieIndexProgressBar(props) { +interface MovieIndexProgressBarProps { + monitored: boolean; + status: string; + hasFile: boolean; + isAvailable: boolean; + posterWidth: number; + detailedProgressBar: boolean; + bottomRadius: boolean; + queueStatus: string; + queueState: string; +} + +function MovieIndexProgressBar(props: MovieIndexProgressBarProps) { const { monitored, status, @@ -17,12 +28,12 @@ function MovieIndexProgressBar(props) { detailedProgressBar, bottomRadius, queueStatus, - queueState + queueState, } = props; const progress = 100; const queueStatusText = getQueueStatusText(queueStatus, queueState); - let movieStatus = (status === 'released' && hasFile) ? 'downloaded' : status; + let movieStatus = status === 'released' && hasFile ? 'downloaded' : status; if (movieStatus === 'deleted') { movieStatus = 'Missing'; @@ -41,31 +52,24 @@ function MovieIndexProgressBar(props) { return ( ); } -MovieIndexProgressBar.propTypes = { - monitored: PropTypes.bool.isRequired, - hasFile: PropTypes.bool.isRequired, - bottomRadius: PropTypes.bool, - isAvailable: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - posterWidth: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - queueStatus: PropTypes.string, - queueState: PropTypes.string -}; - -MovieIndexProgressBar.defaultProps = { - bottomRadius: false -}; - export default MovieIndexProgressBar; diff --git a/frontend/src/Movie/Index/Table/MovieIndexActionsCell.js b/frontend/src/Movie/Index/Table/MovieIndexActionsCell.js deleted file mode 100644 index abbf41a2d..000000000 --- a/frontend/src/Movie/Index/Table/MovieIndexActionsCell.js +++ /dev/null @@ -1,103 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import { icons } from 'Helpers/Props'; -import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; -import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; -import translate from 'Utilities/String/translate'; - -class MovieIndexActionsCell extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditMovieModalOpen: false, - isDeleteMovieModalOpen: false - }; - } - - // - // Listeners - - onEditMoviePress = () => { - this.setState({ isEditMovieModalOpen: true }); - }; - - onEditMovieModalClose = () => { - this.setState({ isEditMovieModalOpen: false }); - }; - - onDeleteMoviePress = () => { - this.setState({ - isEditMovieModalOpen: false, - isDeleteMovieModalOpen: true - }); - }; - - onDeleteMovieModalClose = () => { - this.setState({ isDeleteMovieModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - isRefreshingMovie, - onRefreshMoviePress, - ...otherProps - } = this.props; - - const { - isEditMovieModalOpen, - isDeleteMovieModalOpen - } = this.state; - - return ( - - - - - - - - - - ); - } -} - -MovieIndexActionsCell.propTypes = { - id: PropTypes.number.isRequired, - isRefreshingMovie: PropTypes.bool.isRequired, - onRefreshMoviePress: PropTypes.func.isRequired -}; - -export default MovieIndexActionsCell; diff --git a/frontend/src/Movie/Index/Table/MovieIndexHeader.js b/frontend/src/Movie/Index/Table/MovieIndexHeader.js deleted file mode 100644 index 561ea0f41..000000000 --- a/frontend/src/Movie/Index/Table/MovieIndexHeader.js +++ /dev/null @@ -1,132 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal'; -import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; -import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; -import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; -import { icons } from 'Helpers/Props'; -import MovieIndexTableOptionsConnector from './MovieIndexTableOptionsConnector'; -import styles from './MovieIndexHeader.css'; - -class MovieIndexHeader extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isTableOptionsModalOpen: false - }; - } - - // - // Listeners - - onTableOptionsPress = () => { - this.setState({ isTableOptionsModalOpen: true }); - }; - - onTableOptionsModalClose = () => { - this.setState({ isTableOptionsModalOpen: false }); - }; - - // - // Render - - render() { - const { - columns, - onTableOptionChange, - allSelected, - allUnselected, - onSelectAllChange, - isMovieEditorActive, - ...otherProps - } = this.props; - - return ( - - { - columns.map((column) => { - const { - name, - label, - isSortable, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'select') { - if (isMovieEditorActive) { - return ( - - ); - } - - return null; - } - - if (name === 'actions') { - return ( - - - - ); - } - - return ( - - {label} - - ); - }) - } - - - - ); - } -} - -MovieIndexHeader.propTypes = { - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onTableOptionChange: PropTypes.func.isRequired, - allSelected: PropTypes.bool.isRequired, - allUnselected: PropTypes.bool.isRequired, - onSelectAllChange: PropTypes.func.isRequired, - isMovieEditorActive: PropTypes.bool.isRequired -}; - -export default MovieIndexHeader; diff --git a/frontend/src/Movie/Index/Table/MovieIndexHeaderConnector.js b/frontend/src/Movie/Index/Table/MovieIndexHeaderConnector.js deleted file mode 100644 index d56915879..000000000 --- a/frontend/src/Movie/Index/Table/MovieIndexHeaderConnector.js +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux'; -import { setMovieTableOption } from 'Store/Actions/movieIndexActions'; -import MovieIndexHeader from './MovieIndexHeader'; - -function createMapDispatchToProps(dispatch, props) { - return { - onTableOptionChange(payload) { - dispatch(setMovieTableOption(payload)); - } - }; -} - -export default connect(undefined, createMapDispatchToProps)(MovieIndexHeader); diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.js b/frontend/src/Movie/Index/Table/MovieIndexRow.js deleted file mode 100644 index ecb11c5cd..000000000 --- a/frontend/src/Movie/Index/Table/MovieIndexRow.js +++ /dev/null @@ -1,537 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import ImdbRating from 'Components/ImdbRating'; -import IconButton from 'Components/Link/IconButton'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import RottenTomatoRating from 'Components/RottenTomatoRating'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; -import TagListConnector from 'Components/TagListConnector'; -import TmdbRating from 'Components/TmdbRating'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import { icons, kinds } from 'Helpers/Props'; -import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; -import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; -import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; -import MovieFileStatusConnector from 'Movie/MovieFileStatusConnector'; -import MovieTitleLink from 'Movie/MovieTitleLink'; -import formatRuntime from 'Utilities/Date/formatRuntime'; -import formatBytes from 'Utilities/Number/formatBytes'; -import titleCase from 'Utilities/String/titleCase'; -import translate from 'Utilities/String/translate'; -import MovieStatusCell from './MovieStatusCell'; -import styles from './MovieIndexRow.css'; - -class MovieIndexRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditMovieModalOpen: false, - isDeleteMovieModalOpen: false - }; - } - - onEditMoviePress = () => { - this.setState({ isEditMovieModalOpen: true }); - }; - - onEditMovieModalClose = () => { - this.setState({ isEditMovieModalOpen: false }); - }; - - onDeleteMoviePress = () => { - this.setState({ - isEditMovieModalOpen: false, - isDeleteMovieModalOpen: true - }); - }; - - onDeleteMovieModalClose = () => { - this.setState({ isDeleteMovieModalOpen: false }); - }; - - onUseSceneNumberingChange = () => { - // Mock handler to satisfy `onChange` being required for `CheckInput`. - // - }; - - // - // Render - - render() { - const { - id, - tmdbId, - imdbId, - youTubeTrailerId, - monitored, - status, - title, - titleSlug, - collection, - studio, - qualityProfile, - added, - year, - inCinemas, - physicalRelease, - originalLanguage, - originalTitle, - digitalRelease, - runtime, - minimumAvailability, - path, - sizeOnDisk, - genres, - ratings, - certification, - tags, - showSearchAction, - columns, - isRefreshingMovie, - isSearchingMovie, - isMovieEditorActive, - isSelected, - onRefreshMoviePress, - onSearchPress, - onSelectedChange, - queueStatus, - queueState, - movieRuntimeFormat - } = this.props; - - const { - isEditMovieModalOpen, - isDeleteMovieModalOpen - } = this.state; - - return ( - <> - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (isMovieEditorActive && name === 'select') { - return ( - - ); - } - - if (name === 'status') { - return ( - - ); - } - - if (name === 'sortTitle') { - return ( - - - - - - ); - } - - if (name === 'collection') { - return ( - - {collection ? collection.title : null } - - ); - } - - if (name === 'studio') { - return ( - - {studio} - - ); - } - - if (name === 'originalLanguage') { - return ( - - {originalLanguage.name} - - ); - } - - if (name === 'originalTitle') { - return ( - - {originalTitle} - - ); - } - - if (name === 'qualityProfileId') { - return ( - - {qualityProfile.name} - - ); - } - - if (name === 'added') { - return ( - - ); - } - - if (name === 'year') { - return ( - - {year} - - ); - } - - if (name === 'inCinemas') { - return ( - - ); - } - - if (name === 'digitalRelease') { - return ( - - ); - } - - if (name === 'physicalRelease') { - return ( - - ); - } - - if (name === 'runtime') { - return ( - - {formatRuntime(runtime, movieRuntimeFormat)} - - ); - } - - if (name === 'minimumAvailability') { - return ( - - {titleCase(minimumAvailability)} - - ); - } - - if (name === 'path') { - return ( - - {path} - - ); - } - - if (name === 'sizeOnDisk') { - return ( - - {formatBytes(sizeOnDisk)} - - ); - } - - if (name === 'genres') { - const joinedGenres = genres.join(', '); - - return ( - - - {joinedGenres} - - - ); - } - - if (name === 'movieStatus') { - return ( - - - - ); - } - - if (name === 'tmdbRating') { - return ( - - - - ); - } - - if (name === 'rottenTomatoesRating') { - return ( - - - - ); - } - - if (name === 'imdbRating') { - return ( - - - - ); - } - - if (name === 'certification') { - return ( - - {certification} - - ); - } - - if (name === 'tags') { - return ( - - - - ); - } - - if (name === 'actions') { - return ( - - - - } - tooltip={ - - } - canFlip={true} - kind={kinds.INVERSE} - /> - - - - - { - showSearchAction && - - } - - - - ); - } - - return null; - }) - } - - - - - - ); - } -} - -MovieIndexRow.propTypes = { - id: PropTypes.number.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - titleSlug: PropTypes.string.isRequired, - originalTitle: PropTypes.string.isRequired, - originalLanguage: PropTypes.object.isRequired, - studio: PropTypes.string, - collection: PropTypes.object, - qualityProfile: PropTypes.object.isRequired, - added: PropTypes.string, - year: PropTypes.number, - inCinemas: PropTypes.string, - physicalRelease: PropTypes.string, - digitalRelease: PropTypes.string, - runtime: PropTypes.number, - minimumAvailability: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number.isRequired, - genres: PropTypes.arrayOf(PropTypes.string).isRequired, - ratings: PropTypes.object.isRequired, - certification: PropTypes.string, - tags: PropTypes.arrayOf(PropTypes.number).isRequired, - showSearchAction: PropTypes.bool.isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - isRefreshingMovie: PropTypes.bool.isRequired, - isSearchingMovie: PropTypes.bool.isRequired, - onRefreshMoviePress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired, - isMovieEditorActive: PropTypes.bool.isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired, - tmdbId: PropTypes.number.isRequired, - imdbId: PropTypes.string, - youTubeTrailerId: PropTypes.string, - queueStatus: PropTypes.string, - queueState: PropTypes.string, - movieRuntimeFormat: PropTypes.string.isRequired -}; - -MovieIndexRow.defaultProps = { - genres: [], - tags: [] -}; - -export default MovieIndexRow; diff --git a/frontend/src/Movie/Index/Table/MovieIndexRow.tsx b/frontend/src/Movie/Index/Table/MovieIndexRow.tsx new file mode 100644 index 000000000..077c28f74 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexRow.tsx @@ -0,0 +1,396 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { MOVIE_SEARCH, REFRESH_MOVIE } from 'Commands/commandNames'; +import Icon from 'Components/Icon'; +import ImdbRating from 'Components/ImdbRating'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import RottenTomatoRating from 'Components/RottenTomatoRating'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import Column from 'Components/Table/Column'; +import TagListConnector from 'Components/TagListConnector'; +import TmdbRating from 'Components/TmdbRating'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { icons } from 'Helpers/Props'; +import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal'; +import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks'; +import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; +import createMovieIndexItemSelector from 'Movie/Index/createMovieIndexItemSelector'; +import MovieFileStatusConnector from 'Movie/MovieFileStatusConnector'; +import MovieTitleLink from 'Movie/MovieTitleLink'; +import { executeCommand } from 'Store/Actions/commandActions'; +import formatRuntime from 'Utilities/Date/formatRuntime'; +import formatBytes from 'Utilities/Number/formatBytes'; +import titleCase from 'Utilities/String/titleCase'; +import translate from 'Utilities/String/translate'; +import MovieStatusCell from './MovieStatusCell'; +import selectTableOptions from './selectTableOptions'; +import styles from './MovieIndexRow.css'; + +interface MovieIndexRowProps { + movieId: number; + sortKey: string; + columns: Column[]; +} + +function MovieIndexRow(props: MovieIndexRowProps) { + const { movieId, columns } = props; + + const { movie, qualityProfile, isRefreshingMovie, isSearchingMovie } = + useSelector(createMovieIndexItemSelector(props.movieId)); + + const { showSearchAction } = useSelector(selectTableOptions); + + const { + monitored, + titleSlug, + title, + collection, + studio, + originalLanguage, + originalTitle, + added, + year, + inCinemas, + digitalRelease, + physicalRelease, + runtime, + minimumAvailability, + path, + sizeOnDisk, + genres, + queueStatus, + queueState, + ratings, + certification, + tags, + tmdbId, + imdbId, + youTubeTrailerId, + kinds, + movieRuntimeFormat, + } = movie; + + const dispatch = useDispatch(); + const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false); + const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_MOVIE, + movieId, + }) + ); + }, [movieId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: MOVIE_SEARCH, + movieId, + }) + ); + }, [movieId, dispatch]); + + const onEditMoviePress = useCallback(() => { + setIsEditMovieModalOpen(true); + }, [setIsEditMovieModalOpen]); + + const onEditMovieModalClose = useCallback(() => { + setIsEditMovieModalOpen(false); + }, [setIsEditMovieModalOpen]); + + const onDeleteMoviePress = useCallback(() => { + setIsEditMovieModalOpen(false); + setIsDeleteMovieModalOpen(true); + }, [setIsDeleteMovieModalOpen]); + + const onDeleteMovieModalClose = useCallback(() => { + setIsDeleteMovieModalOpen(false); + }, [setIsDeleteMovieModalOpen]); + + return ( + <> + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'sortTitle') { + return ( + + + + ); + } + + if (name === 'collection') { + return ( + + {collection ? collection.title : null} + + ); + } + + if (name === 'studio') { + return ( + + {studio} + + ); + } + + if (name === 'originalLanguage') { + return ( + + {originalLanguage.name} + + ); + } + + if (name === 'originalTitle') { + return ( + + {originalTitle} + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile.name} + + ); + } + + if (name === 'added') { + return ( + + ); + } + + if (name === 'year') { + return ( + + {year} + + ); + } + + if (name === 'inCinemas') { + return ( + + ); + } + + if (name === 'digitalRelease') { + return ( + + ); + } + + if (name === 'physicalRelease') { + return ( + + ); + } + + if (name === 'runtime') { + return ( + + {formatRuntime(runtime, movieRuntimeFormat)} + + ); + } + + if (name === 'minimumAvailability') { + return ( + + {titleCase(minimumAvailability)} + + ); + } + + if (name === 'path') { + return ( + + {path} + + ); + } + + if (name === 'sizeOnDisk') { + return ( + + {formatBytes(sizeOnDisk)} + + ); + } + + if (name === 'genres') { + const joinedGenres = genres.join(', '); + + return ( + + {joinedGenres} + + ); + } + + if (name === 'movieStatus') { + return ( + + + + ); + } + + if (name === 'tmdbRating') { + return ( + + + + ); + } + + if (name === 'rottenTomatoesRating') { + return ( + + + + ); + } + + if (name === 'imdbRating') { + return ( + + + + ); + } + + if (name === 'certification') { + return ( + + {certification} + + ); + } + + if (name === 'tags') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + + } + tooltip={ + + } + canFlip={true} + kind={kinds.INVERSE} + /> + + + + + {showSearchAction && ( + + )} + + + + ); + } + + return null; + })} + + + + + + ); +} + +export default MovieIndexRow; diff --git a/frontend/src/Movie/Index/Table/MovieIndexTable.css b/frontend/src/Movie/Index/Table/MovieIndexTable.css index 23ab127b5..455f0bc7c 100644 --- a/frontend/src/Movie/Index/Table/MovieIndexTable.css +++ b/frontend/src/Movie/Index/Table/MovieIndexTable.css @@ -1,5 +1,3 @@ -.tableContainer { - composes: tableContainer from '~Components/Table/VirtualTable.css'; - - flex: 1 0 auto; +.tableScroller { + position: relative; } diff --git a/frontend/src/Movie/Index/Table/MovieIndexTable.css.d.ts b/frontend/src/Movie/Index/Table/MovieIndexTable.css.d.ts index fbc2e3b9a..712cb8f72 100644 --- a/frontend/src/Movie/Index/Table/MovieIndexTable.css.d.ts +++ b/frontend/src/Movie/Index/Table/MovieIndexTable.css.d.ts @@ -1,7 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'tableContainer': string; + 'tableScroller': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Movie/Index/Table/MovieIndexTable.js b/frontend/src/Movie/Index/Table/MovieIndexTable.js deleted file mode 100644 index 77e77ee8b..000000000 --- a/frontend/src/Movie/Index/Table/MovieIndexTable.js +++ /dev/null @@ -1,148 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import VirtualTable from 'Components/Table/VirtualTable'; -import VirtualTableRow from 'Components/Table/VirtualTableRow'; -import { sortDirections } from 'Helpers/Props'; -import MovieIndexItemConnector from 'Movie/Index/MovieIndexItemConnector'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import MovieIndexHeaderConnector from './MovieIndexHeaderConnector'; -import MovieIndexRow from './MovieIndexRow'; -import styles from './MovieIndexTable.css'; - -class MovieIndexTable extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - scrollIndex: null - }; - } - - componentDidUpdate(prevProps) { - const { - items, - jumpToCharacter - } = this.props; - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - - const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (scrollIndex != null) { - this.setState({ scrollIndex }); - } - } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) { - this.setState({ scrollIndex: null }); - } - } - - // - // Control - - rowRenderer = ({ key, rowIndex, style }) => { - const { - items, - columns, - selectedState, - onSelectedChange, - isMovieEditorActive, - movieRuntimeFormat - } = this.props; - - const movie = items[rowIndex]; - - return ( - - - - ); - }; - - // - // Render - - render() { - const { - items, - columns, - sortKey, - sortDirection, - isSmallScreen, - onSortPress, - scroller, - scrollTop, - allSelected, - allUnselected, - onSelectAllChange, - isMovieEditorActive, - selectedState - } = this.props; - - return ( - - } - selectedState={selectedState} - columns={columns} - /> - ); - } -} - -MovieIndexTable.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - jumpToCharacter: PropTypes.string, - isSmallScreen: PropTypes.bool.isRequired, - scrollTop: PropTypes.number, - scroller: PropTypes.instanceOf(Element).isRequired, - onSortPress: PropTypes.func.isRequired, - allSelected: PropTypes.bool.isRequired, - allUnselected: PropTypes.bool.isRequired, - selectedState: PropTypes.object.isRequired, - onSelectedChange: PropTypes.func.isRequired, - onSelectAllChange: PropTypes.func.isRequired, - isMovieEditorActive: PropTypes.bool.isRequired, - movieRuntimeFormat: PropTypes.string.isRequired -}; - -export default MovieIndexTable; diff --git a/frontend/src/Movie/Index/Table/MovieIndexTable.tsx b/frontend/src/Movie/Index/Table/MovieIndexTable.tsx new file mode 100644 index 000000000..9cac07d47 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexTable.tsx @@ -0,0 +1,200 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; +import { createSelector } from 'reselect'; +import Scroller from 'Components/Scroller/Scroller'; +import Column from 'Components/Table/Column'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import ScrollDirection from 'Helpers/Props/ScrollDirection'; +import SortDirection from 'Helpers/Props/SortDirection'; +import Movie from 'Movie/Movie'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import MovieIndexRow from './MovieIndexRow'; +import MovieIndexTableHeader from './MovieIndexTableHeader'; +import selectTableOptions from './selectTableOptions'; +import styles from './MovieIndexTable.css'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); + +interface RowItemData { + items: Movie[]; + sortKey: string; + columns: Column[]; +} + +interface MovieIndexTableProps { + items: Movie[]; + sortKey?: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const columnsSelector = createSelector( + (state) => state.movieIndex.columns, + (columns) => columns +); + +const Row: React.FC> = ({ + index, + style, + data, +}) => { + const { items, sortKey, columns } = data; + + if (index >= items.length) { + return null; + } + + const movie = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +function MovieIndexTable(props: MovieIndexTableProps) { + const { + items, + sortKey, + sortDirection, + jumpToCharacter, + isSmallScreen, + scrollerRef, + } = props; + + const columns = useSelector(columnsSelector); + const { showBanners } = useSelector(selectTableOptions); + const listRef: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const rowHeight = useMemo(() => { + return showBanners ? 70 : 38; + }, [showBanners]); + + useEffect(() => { + const current = scrollerRef.current as HTMLElement; + + if (isSmallScreen) { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = + (isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + listRef.current.scrollTo(scrollTop); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, listRef, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + let scrollTop = index * rowHeight; + + // If the offset is zero go to the top, otherwise offset + // by the approximate size of the header + padding (37 + 20). + if (scrollTop > 0) { + const offset = 57; + + scrollTop += offset; + } + + listRef.current.scrollTo(scrollTop); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); + + return ( +
+ + + + ref={listRef} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + itemCount={items.length} + itemSize={rowHeight} + itemData={{ + items, + sortKey, + columns, + }} + > + {Row} + + +
+ ); +} + +export default MovieIndexTable; diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableConnector.js b/frontend/src/Movie/Index/Table/MovieIndexTableConnector.js deleted file mode 100644 index 1ba04366c..000000000 --- a/frontend/src/Movie/Index/Table/MovieIndexTableConnector.js +++ /dev/null @@ -1,31 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setMovieSort } from 'Store/Actions/movieIndexActions'; -import MovieIndexTable from './MovieIndexTable'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.dimensions, - (state) => state.movieIndex.tableOptions, - (state) => state.movieIndex.columns, - (state) => state.settings.ui.item.movieRuntimeFormat, - (dimensions, tableOptions, columns, movieRuntimeFormat) => { - return { - isSmallScreen: dimensions.isSmallScreen, - showBanners: tableOptions.showBanners, - columns, - movieRuntimeFormat - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onSortPress(sortKey) { - dispatch(setMovieSort({ sortKey })); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexTable); diff --git a/frontend/src/Movie/Index/Table/MovieIndexHeader.css b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.css similarity index 100% rename from frontend/src/Movie/Index/Table/MovieIndexHeader.css rename to frontend/src/Movie/Index/Table/MovieIndexTableHeader.css diff --git a/frontend/src/Movie/Index/Table/MovieIndexHeader.css.d.ts b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.css.d.ts similarity index 100% rename from frontend/src/Movie/Index/Table/MovieIndexHeader.css.d.ts rename to frontend/src/Movie/Index/Table/MovieIndexTableHeader.css.d.ts diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableHeader.tsx b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.tsx new file mode 100644 index 000000000..0645f98b5 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexTableHeader.tsx @@ -0,0 +1,88 @@ +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import IconButton from 'Components/Link/IconButton'; +import Column from 'Components/Table/Column'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import { icons } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { + setMovieSort, + setMovieTableOption, +} from 'Store/Actions/movieIndexActions'; +import MovieIndexTableOptions from './MovieIndexTableOptions'; +import styles from './MovieIndexTableHeader.css'; + +interface MovieIndexTableHeaderProps { + columns: Column[]; + sortKey?: string; + sortDirection?: SortDirection; +} + +function MovieIndexTableHeader(props: MovieIndexTableHeaderProps) { + const { columns, sortKey, sortDirection, isSelectMode } = props; + const dispatch = useDispatch(); + + const onSortPress = useCallback( + (value) => { + dispatch(setMovieSort({ sortKey: value })); + }, + [dispatch] + ); + + const onTableOptionChange = useCallback( + (payload) => { + dispatch(setMovieTableOption(payload)); + }, + [dispatch] + ); + + return ( + + {columns.map((column) => { + const { name, label, isSortable, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return ( + + {label} + + ); + })} + + ); +} + +export default MovieIndexTableHeader; diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableOptions.js b/frontend/src/Movie/Index/Table/MovieIndexTableOptions.js deleted file mode 100644 index e90573acd..000000000 --- a/frontend/src/Movie/Index/Table/MovieIndexTableOptions.js +++ /dev/null @@ -1,77 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class MovieIndexTableOptions extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { showSearchAction } = this.props; - - if (showSearchAction !== prevProps.showSearchAction) { - this.setState({ - showSearchAction - }); - } - } - - // - // Listeners - - onTableOptionChange = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onTableOptionChange({ - tableOptions: { - ...this.state, - [name]: value - } - }); - }); - }; - - // - // Render - - render() { - const { - showSearchAction - } = this.state; - - return ( - - {translate('ShowSearch')} - - - - ); - } -} - -MovieIndexTableOptions.propTypes = { - showSearchAction: PropTypes.bool.isRequired, - onTableOptionChange: PropTypes.func.isRequired -}; - -export default MovieIndexTableOptions; diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableOptions.tsx b/frontend/src/Movie/Index/Table/MovieIndexTableOptions.tsx new file mode 100644 index 000000000..e94beee85 --- /dev/null +++ b/frontend/src/Movie/Index/Table/MovieIndexTableOptions.tsx @@ -0,0 +1,50 @@ +import React, { Fragment, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import selectTableOptions from './selectTableOptions'; + +interface MovieIndexTableOptionsProps { + onTableOptionChange(...args: unknown[]): unknown; +} + +function MovieIndexTableOptions(props: MovieIndexTableOptionsProps) { + const { onTableOptionChange } = props; + + const tableOptions = useSelector(selectTableOptions); + + const showSearchAction = tableOptions; + + const onTableOptionChangeWrapper = useCallback( + ({ name, value }) => { + onTableOptionChange({ + tableOptions: { + ...tableOptions, + [name]: value, + }, + }); + }, + [tableOptions, onTableOptionChange] + ); + + return ( + + + {translate('ShowSearch')} + + + + + ); +} + +export default MovieIndexTableOptions; diff --git a/frontend/src/Movie/Index/Table/MovieIndexTableOptionsConnector.js b/frontend/src/Movie/Index/Table/MovieIndexTableOptionsConnector.js deleted file mode 100644 index 018a98678..000000000 --- a/frontend/src/Movie/Index/Table/MovieIndexTableOptionsConnector.js +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import MovieIndexTableOptions from './MovieIndexTableOptions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.movieIndex.tableOptions, - (tableOptions) => { - return tableOptions; - } - ); -} - -export default connect(createMapStateToProps)(MovieIndexTableOptions); diff --git a/frontend/src/Movie/Index/Table/MovieStatusCell.js b/frontend/src/Movie/Index/Table/MovieStatusCell.tsx similarity index 58% rename from frontend/src/Movie/Index/Table/MovieStatusCell.js rename to frontend/src/Movie/Index/Table/MovieStatusCell.tsx index d8f129573..c155336b6 100644 --- a/frontend/src/Movie/Index/Table/MovieStatusCell.js +++ b/frontend/src/Movie/Index/Table/MovieStatusCell.tsx @@ -1,5 +1,4 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { Component } from 'react'; import Icon from 'Components/Icon'; import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; import { icons } from 'Helpers/Props'; @@ -7,26 +6,34 @@ import { getMovieStatusDetails } from 'Movie/MovieStatus'; import translate from 'Utilities/String/translate'; import styles from './MovieStatusCell.css'; -function MovieStatusCell(props) { +interface MovieStatusCellProps { + className: string; + monitored: boolean; + status: string; + component?: React.ElementType; +} + +function MovieStatusCell(props: MovieStatusCellProps) { const { className, monitored, status, - component: Component, + component: Component = VirtualTableRowCell, ...otherProps } = props; const statusDetails = getMovieStatusDetails(status); return ( - + - ); } -MovieStatusCell.propTypes = { - className: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - component: PropTypes.elementType -}; - -MovieStatusCell.defaultProps = { - className: styles.status, - component: VirtualTableRowCell -}; - export default MovieStatusCell; diff --git a/frontend/src/Movie/Index/Table/selectTableOptions.ts b/frontend/src/Movie/Index/Table/selectTableOptions.ts new file mode 100644 index 000000000..a3ecc7004 --- /dev/null +++ b/frontend/src/Movie/Index/Table/selectTableOptions.ts @@ -0,0 +1,8 @@ +import { createSelector } from 'reselect'; + +const selectTableOptions = createSelector( + (state) => state.movieIndex.tableOptions, + (tableOptions) => tableOptions +); + +export default selectTableOptions; diff --git a/frontend/src/Movie/Index/createMovieIndexItemSelector.ts b/frontend/src/Movie/Index/createMovieIndexItemSelector.ts new file mode 100644 index 000000000..54d1c9fc5 --- /dev/null +++ b/frontend/src/Movie/Index/createMovieIndexItemSelector.ts @@ -0,0 +1,44 @@ +import { createSelector } from 'reselect'; +import { MOVIE_SEARCH, REFRESH_MOVIE } from 'Commands/commandNames'; +import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; +import createMovieQualityProfileSelector from 'Store/Selectors/createMovieQualityProfileSelector'; +import createMovieSelector from 'Store/Selectors/createMovieSelector'; + +function createMovieIndexItemSelector(movieId: number) { + return createSelector( + createMovieSelector(movieId), + createMovieQualityProfileSelector(movieId), + createExecutingCommandsSelector(), + (movie, qualityProfile, executingCommands) => { + // If a movie is deleted this selector may fire before the parent + // selectors, which will result in an undefined movie, if that happens + // we want to return early here and again in the render function to avoid + // trying to show a movie that has no information available. + + if (!movie) { + return {}; + } + + const isRefreshingMovie = executingCommands.some((command) => { + return ( + command.Name === REFRESH_MOVIE && command.body.movieId === movie.id + ); + }); + + const isSearchingMovie = executingCommands.some((command) => { + return ( + command.name === MOVIE_SEARCH && command.body.movieId === movie.id + ); + }); + + return { + movie, + qualityProfile, + isRefreshingMovie, + isSearchingMovie, + }; + } + ); +} + +export default createMovieIndexItemSelector; diff --git a/frontend/src/Movie/Movie.ts b/frontend/src/Movie/Movie.ts new file mode 100644 index 000000000..3f67fd7ad --- /dev/null +++ b/frontend/src/Movie/Movie.ts @@ -0,0 +1,43 @@ +import ModelBase from 'App/ModelBase'; + +export interface Image { + coverType: string; + url: string; + remoteUrl: string; +} + +export interface Language { + id: number; + name: string; +} + +interface Movie extends ModelBase { + tmdbId: number; + imdbId: string; + youTubeTrailerId: string; + monitored: boolean; + status: string; + title: string; + titleSlug: string; + collection: object; + studio: string; + qualityProfile: object; + added: Date; + year: number; + inCinemas: Date; + physicalRelease: Date; + originalLanguage: Language; + originalTitle: string; + digitalRelease: Date; + runtime: number; + minimumAvailability: string; + path: string; + sizeOnDisk: number; + genres: string[]; + ratings: object; + certification: string; + tags: number[]; + images: Image; +} + +export default Movie; diff --git a/frontend/src/Movie/MovieBanner.js b/frontend/src/Movie/MovieBanner.js index 3e24f78e7..1dfcd17bb 100644 --- a/frontend/src/Movie/MovieBanner.js +++ b/frontend/src/Movie/MovieBanner.js @@ -15,6 +15,10 @@ function MovieBanner(props) { } MovieBanner.propTypes = { + ...MovieImage.propTypes, + coverType: PropTypes.string, + placeholder: PropTypes.string, + overflow: PropTypes.bool, size: PropTypes.number.isRequired }; diff --git a/frontend/src/Movie/MoviePoster.js b/frontend/src/Movie/MoviePoster.js index 75776a8e1..453e2289b 100644 --- a/frontend/src/Movie/MoviePoster.js +++ b/frontend/src/Movie/MoviePoster.js @@ -15,6 +15,10 @@ function MoviePoster(props) { } MoviePoster.propTypes = { + ...MovieImage.propTypes, + coverType: PropTypes.string, + placeholder: PropTypes.string, + overflow: PropTypes.bool, size: PropTypes.number.isRequired }; diff --git a/frontend/src/Store/Selectors/createMovieQualityProfileSelector.js b/frontend/src/Store/Selectors/createMovieQualityProfileSelector.js index 3b804bcb4..66a6bdcaa 100644 --- a/frontend/src/Store/Selectors/createMovieQualityProfileSelector.js +++ b/frontend/src/Store/Selectors/createMovieQualityProfileSelector.js @@ -1,10 +1,10 @@ import { createSelector } from 'reselect'; import createMovieSelector from './createMovieSelector'; -function createMovieQualityProfileSelector() { +function createMovieQualityProfileSelector(movieId) { return createSelector( (state) => state.settings.qualityProfiles.items, - createMovieSelector(), + createMovieSelector(movieId), (qualityProfiles, movie = {}) => { return qualityProfiles.find((profile) => { return profile.id === movie.qualityProfileId; diff --git a/frontend/src/Store/Selectors/createMovieSelector.js b/frontend/src/Store/Selectors/createMovieSelector.js index caf55d7d3..2ba0649b3 100644 --- a/frontend/src/Store/Selectors/createMovieSelector.js +++ b/frontend/src/Store/Selectors/createMovieSelector.js @@ -1,15 +1,22 @@ import { createSelector } from 'reselect'; -function createMovieSelector() { +function createMovieSelector(id) { + if (id == null) { + return createSelector( + (state, { movieId }) => movieId, + (state) => state.movies.itemMap, + (state) => state.movies.items, + (movieId, itemMap, allMovies) => { + return allMovies[itemMap[movieId]]; + } + ); + } + return createSelector( - (state, { movieId }) => movieId, (state) => state.movies.itemMap, (state) => state.movies.items, - (movieId, itemMap, allMovies) => { - if (allMovies && itemMap && movieId in itemMap) { - return allMovies[itemMap[movieId]]; - } - return undefined; + (itemMap, allMovies) => { + return allMovies[itemMap[id]]; } ); } diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js index 165bb5cc1..5cbb30085 100644 --- a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js +++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js @@ -1,7 +1,5 @@ -import _ from 'lodash'; - export default function getIndexOfFirstCharacter(items, character) { - return _.findIndex(items, (item) => { + return items.findIndex((item) => { const firstCharacter = item.sortTitle.charAt(0); if (character === '#') { diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index dfddb15a3..ffb7167ed 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,29 +1,30 @@ { - "compilerOptions": { - "target": "es6", - "allowJs": true, - "checkJs": false, - "baseUrl": "src", - "jsx": "react", - "module": "commonjs", - "moduleResolution": "node", - "noEmit": true, - "esModuleInterop": true, - "typeRoots": ["node_modules/@types", "typings"], - "paths": { - "*": [ - "*" - ] - }, - "plugins": [{ "name": "typescript-plugin-css-modules" }] - }, - "include": [ - "./src/**/*", - "./.eslintrc.js", - "./build/webpack.config.js", - "./typings/*.ts", - ], - "exclude": [ - "node_modules" - ] -} + "compilerOptions": { + "target": "es6", + "allowJs": true, + "checkJs": false, + "baseUrl": "src", + "jsx": "react", + "module": "commonjs", + "moduleResolution": "node", + "noEmit": true, + "esModuleInterop": true, + "typeRoots": ["node_modules/@types", "typings"], + "paths": { + "*": [ + "*" + ] + }, + "plugins": [{ "name": "typescript-plugin-css-modules" }] + }, + "include": [ + "./src/**/*", + "./.eslintrc.js", + "./build/webpack.config.js", + "./typings/*.ts", + ], + "exclude": [ + "node_modules" + ] + } + \ No newline at end of file diff --git a/package.json b/package.json index c3013ab70..af66e9316 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@fortawesome/free-regular-svg-icons": "6.1.0", "@fortawesome/free-solid-svg-icons": "6.1.0", "@fortawesome/react-fontawesome": "0.1.18", + "@juggle/resize-observer": "3.4.0", "@microsoft/signalr": "6.0.16", "@sentry/browser": "6.18.2", "@sentry/integrations": "6.18.2", @@ -73,13 +74,15 @@ "react-slider": "1.1.4", "react-tabs": "3.2.2", "react-text-truncate": "0.18.0", + "react-use-measure": "2.1.1", "react-virtualized": "9.21.1", - "redux": "4.1.0", + "react-window": "1.8.8", + "redux": "4.2.0", "redux-actions": "2.6.5", "redux-batched-actions": "0.5.0", "redux-localstorage": "0.4.1", "redux-thunk": "2.3.0", - "reselect": "4.0.0", + "reselect": "4.1.5", "swiper": "8.3.2", "typescript": "4.9.4" }, @@ -99,6 +102,7 @@ "@babel/preset-env": "7.16.11", "@babel/preset-react": "7.16.7", "@babel/preset-typescript": "7.18.6", + "@types/react-window": "1.8.5", "@typescript-eslint/eslint-plugin": "5.48.1", "@typescript-eslint/parser": "5.48.0", "autoprefixer": "10.2.5", diff --git a/yarn.lock b/yarn.lock index ae97d7953..a4aafd8d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1244,6 +1244,11 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@juggle/resize-observer@3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" + integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== + "@microsoft/signalr@6.0.16": version "6.0.16" resolved "https://registry.yarnpkg.com/@microsoft/signalr/-/signalr-6.0.16.tgz#d36498a9b16bf11c0e9213d77d24c0ad8ebffa47" @@ -1514,6 +1519,13 @@ hoist-non-react-statics "^3.3.0" redux "^4.0.0" +"@types/react-window@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1" + integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw== + dependencies: + "@types/react" "*" + "@types/react@*": version "18.2.0" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.0.tgz#15cda145354accfc09a18d2f2305f9fc099ada21" @@ -2932,6 +2944,11 @@ cuint@^0.2.2: resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" integrity sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw== +debounce@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== + debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -5326,6 +5343,11 @@ memfs@^3.4.1: dependencies: fs-monkey "^1.0.3" +"memoize-one@>=3.1.1 <6": + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + meow@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/meow/-/meow-9.0.0.tgz#cd9510bc5cac9dee7d03c73ee1f9ad959f4ea364" @@ -6599,6 +6621,13 @@ react-themeable@^1.1.0: dependencies: object-assign "^3.0.0" +react-use-measure@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/react-use-measure/-/react-use-measure-2.1.1.tgz#5824537f4ee01c9469c45d5f7a8446177c6cc4ba" + integrity sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig== + dependencies: + debounce "^1.2.1" + react-virtualized@9.21.1: version "9.21.1" resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.21.1.tgz#4dbbf8f0a1420e2de3abf28fbb77120815277b3a" @@ -6612,6 +6641,14 @@ react-virtualized@9.21.1: prop-types "^15.6.0" react-lifecycles-compat "^3.0.4" +react-window@1.8.8: + version "1.8.8" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.8.tgz#1b52919f009ddf91970cbdb2050a6c7be44df243" + integrity sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" @@ -6721,10 +6758,10 @@ redux-thunk@2.3.0: resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== -redux@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.0.tgz#eb049679f2f523c379f1aff345c8612f294c88d4" - integrity sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g== +redux@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13" + integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA== dependencies: "@babel/runtime" "^7.9.2" @@ -6851,10 +6888,10 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== -reselect@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" - integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== +reselect@4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.5.tgz#852c361247198da6756d07d9296c2b51eddb79f6" + integrity sha512-uVdlz8J7OO+ASpBYoz1Zypgx0KasCY20H+N8JD13oUMtPvSHQuscrHop4KbXrbsBcdB9Ds7lVK7eRkBIfO43vQ== reserved-words@^0.1.2: version "0.1.2"