Refactor Movie index to use react-window

pull/8414/head
Robin Dadswell 1 year ago committed by Qstick
parent 68832a136e
commit e0b91c6406

@ -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 (
<PageContent title={translate('ImportMovies')}>
<PageContentBody
registerScroller={this.setScrollerRef}
onScroll={this.onScroll}
>
<PageContentBody ref={this.scrollerRef} >
{
rootFoldersFetching ? <LoadingIndicator /> : null
}
@ -126,14 +117,14 @@ class ImportMovie extends Component {
!rootFoldersFetching &&
rootFoldersPopulated &&
!!unmappedFolders.length &&
scroller ?
this.scrollerRef.current ?
<ImportMovieTableConnector
rootFolderId={rootFolderId}
unmappedFolders={unmappedFolders}
allSelected={allSelected}
allUnselected={allUnselected}
selectedState={selectedState}
scroller={scroller}
scroller={this.scrollerRef.current}
onSelectAllChange={this.onSelectAllChange}
onSelectedChange={this.onSelectedChange}
onRemoveSelectedStateItem={this.onRemoveSelectedStateItem}

@ -12,7 +12,7 @@ import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector';
import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector';
import MovieIndexConnector from 'Movie/Index/MovieIndexConnector';
import MovieIndex from 'Movie/Index/MovieIndex';
import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
@ -48,7 +48,7 @@ function AppRoutes(props) {
<Route
exact={true}
path="/"
component={MovieIndexConnector}
component={MovieIndex}
/>
{

@ -0,0 +1,5 @@
interface ModelBase {
id: number;
}
export default ModelBase;

@ -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 {
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
registerScroller={this.setScrollerRef}
ref={this.scrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
onScroll={onScroll}
>
{
isFetching && !isPopulated &&
@ -327,7 +322,7 @@ class Collection extends Component {
isLoaded &&
<div className={styles.contentBodyContainer}>
<ViewComponent
scroller={scroller}
scroller={this.scrollerRef.current}
items={items}
filters={filters}
sortKey={sortKey}

@ -260,6 +260,9 @@ FormInputGroup.propTypes = {
className: PropTypes.string.isRequired,
containerClassName: PropTypes.string.isRequired,
inputClassName: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.any,
values: PropTypes.arrayOf(PropTypes.any),
type: PropTypes.string.isRequired,
unit: PropTypes.string,
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
@ -269,7 +272,8 @@ FormInputGroup.propTypes = {
helpLink: PropTypes.string,
pending: PropTypes.bool,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object)
warnings: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func.isRequired
};
FormInputGroup.defaultProps = {

@ -4,16 +4,18 @@ import React from 'react';
import { sizes } from 'Helpers/Props';
import styles from './FormLabel.css';
function FormLabel({
children,
className,
errorClassName,
size,
name,
hasError,
isAdvanced,
...otherProps
}) {
function FormLabel(props) {
const {
children,
className,
errorClassName,
size,
name,
hasError,
isAdvanced,
...otherProps
} = props;
return (
<label
{...otherProps}
@ -31,13 +33,13 @@ function FormLabel({
}
FormLabel.propTypes = {
children: PropTypes.node.isRequired,
children: PropTypes.oneOfType([PropTypes.node, PropTypes.string]).isRequired,
className: PropTypes.string,
errorClassName: PropTypes.string,
size: PropTypes.oneOf(sizes.all),
name: PropTypes.string,
hasError: PropTypes.bool,
isAdvanced: PropTypes.bool.isRequired
isAdvanced: PropTypes.bool
};
FormLabel.defaultProps = {

@ -38,11 +38,13 @@ function IconButton(props) {
}
IconButton.propTypes = {
...Link.propTypes,
className: PropTypes.string.isRequired,
iconClassName: PropTypes.string,
kind: PropTypes.string,
name: PropTypes.object.isRequired,
size: PropTypes.number,
title: PropTypes.string,
isSpinning: PropTypes.bool,
isDisabled: PropTypes.bool
};

@ -23,6 +23,8 @@ function SpinnerIconButton(props) {
}
SpinnerIconButton.propTypes = {
...IconButton.propTypes,
className: PropTypes.string,
name: PropTypes.object.isRequired,
spinningName: PropTypes.object.isRequired,
isDisabled: PropTypes.bool.isRequired,

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import Menu from 'Components/Menu/Menu';
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
import { icons } from 'Helpers/Props';
import { align, icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function SortMenu(props) {
@ -31,7 +31,8 @@ function SortMenu(props) {
SortMenu.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
isDisabled: PropTypes.bool.isRequired
isDisabled: PropTypes.bool.isRequired,
alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT])
};
SortMenu.defaultProps = {

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import Menu from 'Components/Menu/Menu';
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
import { icons } from 'Helpers/Props';
import { align, icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function ViewMenu(props) {
@ -28,7 +28,8 @@ function ViewMenu(props) {
ViewMenu.propTypes = {
children: PropTypes.node.isRequired,
isDisabled: PropTypes.bool.isRequired
isDisabled: PropTypes.bool.isRequired,
alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT])
};
ViewMenu.defaultProps = {

@ -1,61 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Scroller from 'Components/Scroller/Scroller';
import { scrollDirections } from 'Helpers/Props';
import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css';
class PageContentBody extends Component {
//
// Listeners
onScroll = (props) => {
const { onScroll } = this.props;
if (this.props.onScroll && !isLocked()) {
onScroll(props);
}
};
//
// Render
render() {
const {
className,
innerClassName,
children,
dispatch,
...otherProps
} = this.props;
return (
<Scroller
className={className}
scrollDirection={scrollDirections.VERTICAL}
{...otherProps}
onScroll={this.onScroll}
>
<div className={innerClassName}>
{children}
</div>
</Scroller>
);
}
}
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;

@ -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<HTMLDivElement>
) => {
const {
className = styles.contentBody,
innerClassName = styles.innerContentBody,
children,
onScroll,
...otherProps
} = props;
const onScrollWrapper = useCallback(
(payload) => {
if (onScroll && !isLocked()) {
onScroll(payload);
}
},
[onScroll]
);
return (
<Scroller
ref={ref}
{...otherProps}
className={className}
scrollDirection={ScrollDirection.Vertical}
onScroll={onScrollWrapper}
>
<div className={innerClassName}>{children}</div>
</Scroller>
);
}
);
export default PageContentBody;

@ -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 = {

@ -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 (
<div
ref={this._setScrollerRef}
className={classNames(
className,
styles.scroller,
styles[scrollDirection],
autoScroll && styles.autoScroll
)}
tabIndex={-1}
{...otherProps}
>
{children}
</div>
);
}
}
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;

@ -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<HTMLDivElement>) => {
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 (
<div
{...otherProps}
ref={currentRef}
className={classNames(
className,
styles.scroller,
styles[scrollDirection],
autoScroll && styles.autoScroll
)}
tabIndex={-1}
>
{children}
</div>
);
}
);
export default Scroller;

@ -0,0 +1,10 @@
interface Column {
name: string;
label: string;
columnLabel: string;
isSortable: boolean;
isVisible: boolean;
isModifiable?: boolean;
}
export default Column;

@ -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 (
<WrappedComponent
{...props}
scrollTop={scrollTop}
/>
);
}
ScrollPosition.propTypes = {
history: PropTypes.object.isRequired
};
return ScrollPosition;
}
export default withScrollPosition;

@ -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 <WrappedComponent {...props} initialScrollTop={initialScrollTop} />;
}
ScrollPosition.propTypes = {
history: PropTypes.object.isRequired,
};
return ScrollPosition;
}
export default withScrollPosition;

@ -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 {
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
registerScroller={this.setScrollerRef}
ref={this.scrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
onScroll={onScroll}
>
{
isFetching && !isPopulated &&
@ -383,7 +378,7 @@ class DiscoverMovie extends Component {
isLoaded &&
<div className={styles.contentBodyContainer}>
<ViewComponent
scroller={scroller}
scroller={this.scrollerRef.current}
items={items}
filters={filters}
sortKey={sortKey}

@ -0,0 +1,21 @@
import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer';
import {
default as useMeasureHook,
Options,
RectReadOnly,
} from 'react-use-measure';
const ResizeObserver = window.ResizeObserver || ResizeObserverPolyfill;
export type Measurements = RectReadOnly;
function useMeasure(
options?: Omit<Options, 'polyfill'>
): ReturnType<typeof useMeasureHook> {
return useMeasureHook({
polyfill: ResizeObserver,
...options,
});
}
export default useMeasure;

@ -0,0 +1,8 @@
enum ScrollDirection {
Horizontal = 'horizontal',
Vertical = 'vertical',
None = 'none',
Both = 'both',
}
export default ScrollDirection;

@ -0,0 +1,6 @@
enum SortDirection {
Ascending = 'ascending',
Descending = 'descending',
}
export default SortDirection;

@ -28,6 +28,7 @@ function DeleteMovieModal(props) {
}
DeleteMovieModal.propTypes = {
...DeleteMovieModalContentConnector.propTypes,
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
previousMovie: PropTypes.string

@ -18,6 +18,7 @@ function EditMovieModal({ isOpen, onModalClose, ...otherProps }) {
}
EditMovieModal.propTypes = {
...EditMovieModalContentConnector.propTypes,
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};

@ -32,6 +32,7 @@ class EditMovieModalConnector extends Component {
}
EditMovieModalConnector.propTypes = {
...EditMovieModal.propTypes,
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};

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

@ -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 (
<SortMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<SortMenu isDisabled={isDisabled} alignMenu={align.RIGHT}>
<MenuContent>
<SortMenuItem
name="status"
@ -172,7 +164,7 @@ MovieIndexSortMenu.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
isDisabled: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired
onSortSelect: PropTypes.func.isRequired,
};
export default MovieIndexSortMenu;

@ -7,31 +7,16 @@ import { align } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function MovieIndexViewMenu(props) {
const {
view,
isDisabled,
onViewSelect
} = props;
const { view, isDisabled, onViewSelect } = props;
return (
<ViewMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<ViewMenu isDisabled={isDisabled} alignMenu={align.RIGHT}>
<MenuContent>
<ViewMenuItem
name="table"
selectedView={view}
onPress={onViewSelect}
>
<ViewMenuItem name="table" selectedView={view} onPress={onViewSelect}>
{translate('Table')}
</ViewMenuItem>
<ViewMenuItem
name="posters"
selectedView={view}
onPress={onViewSelect}
>
<ViewMenuItem name="posters" selectedView={view} onPress={onViewSelect}>
{translate('Posters')}
</ViewMenuItem>
@ -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;

@ -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 (
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={isMovieEditorActive && selectedMovieIds.length > 0 ? translate('UpdateSelected') : translate('UpdateAll')}
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingMovie}
isDisabled={hasNoMovie}
onPress={this.onRefreshMoviePress}
/>
<PageToolbarButton
label={translate('RSSSync')}
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoMovie}
onPress={onRssSyncPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={isMovieEditorActive ? searchEditorLabel : searchIndexLabel}
iconName={icons.SEARCH}
isDisabled={isSearchingMovies || !items.length}
onPress={this.onSearchPress}
/>
<PageToolbarButton
label={translate('ManualImport')}
iconName={icons.INTERACTIVE}
isDisabled={hasNoMovie}
onPress={this.onInteractiveImportPress}
/>
<PageToolbarSeparator />
{
isMovieEditorActive ?
<PageToolbarButton
label={translate('MovieIndex')}
iconName={icons.MOVIE_CONTINUING}
isDisabled={hasNoMovie}
onPress={this.onMovieEditorTogglePress}
/> :
<PageToolbarButton
label={translate('MovieEditor')}
iconName={icons.EDIT}
isDisabled={hasNoMovie}
onPress={this.onMovieEditorTogglePress}
/>
}
{
isMovieEditorActive ?
<PageToolbarButton
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
iconName={icons.CHECK_SQUARE}
isDisabled={hasNoMovie}
onPress={this.onSelectAllPress}
/> :
null
}
</PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
collapseButtons={false}
>
{
view === 'table' ?
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
optionsComponent={MovieIndexTableOptionsConnector}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper> :
null
}
{
view === 'posters' ?
<PageToolbarButton
label={translate('Options')}
iconName={icons.POSTER}
isDisabled={hasNoMovie}
onPress={this.onPosterOptionsPress}
/> :
null
}
{
view === 'overview' ?
<PageToolbarButton
label={translate('Options')}
iconName={icons.OVERVIEW}
isDisabled={hasNoMovie}
onPress={this.onOverviewOptionsPress}
/> :
null
}
<PageToolbarSeparator />
<MovieIndexViewMenu
view={view}
isDisabled={hasNoMovie}
onViewSelect={onViewSelect}
/>
<MovieIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoMovie}
onSortSelect={onSortSelect}
/>
<MovieIndexFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoMovie}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
registerScroller={this.setScrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
onScroll={onScroll}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div className={styles.errorMessage}>
{getErrorMessage(error, translate('FailedToLoadMovieFromAPI'))}
</div>
}
{
isLoaded &&
<div className={styles.contentBodyContainer}>
<ViewComponent
scroller={scroller}
items={items}
filters={filters}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isMovieEditorActive={isMovieEditorActive}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectedChange={this.onSelectedChange}
onSelectAllChange={this.onSelectAllChange}
selectedState={selectedState}
{...otherProps}
/>
{
!isMovieEditorActive &&
<MovieIndexFooterConnector />
}
</div>
}
{
!error && isPopulated && !items.length &&
<NoMovie totalItems={totalItems} />
}
</PageContentBody>
{
isLoaded && !!jumpBarItems.order.length &&
<PageJumpBar
items={jumpBarItems}
onItemPress={this.onJumpBarItemPress}
/>
}
</div>
{
isLoaded && isMovieEditorActive &&
<MovieEditorFooter
movieIds={selectedMovieIds}
selectedCount={selectedMovieIds.length}
isSaving={isSaving}
saveError={saveError}
isDeleting={isDeleting}
deleteError={deleteError}
isOrganizingMovie={isOrganizingMovie}
onSaveSelected={this.onSaveSelected}
onOrganizeMoviePress={this.onOrganizeMoviePress}
/>
}
<MovieIndexPosterOptionsModal
isOpen={isPosterOptionsModalOpen}
onModalClose={this.onPosterOptionsModalClose}
/>
<MovieIndexOverviewOptionsModal
isOpen={isOverviewOptionsModalOpen}
onModalClose={this.onOverviewOptionsModalClose}
/>
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
onModalClose={this.onInteractiveImportModalClose}
/>
<OrganizeMovieModal
isOpen={this.state.isOrganizingMovieModalOpen}
movieIds={selectedMovieIds}
onModalClose={this.onOrganizeMovieModalClose}
/>
<ConfirmModal
isOpen={isConfirmSearchModalOpen}
kind={kinds.DANGER}
title={translate('MassMovieSearch')}
message={
<div>
<div>
Are you sure you want to perform mass movie search for {isMovieEditorActive && selectedMovieIds.length > 0 ? selectedMovieIds.length : this.props.items.length} movies?
</div>
<div>
{translate('ThisCannotBeCancelled')}
</div>
</div>
}
confirmLabel={translate('Search')}
onConfirm={this.onSearchConfirmed}
onCancel={this.onConfirmSearchModalClose}
/>
</PageContent>
);
}
}
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;

@ -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<HTMLDivElement>();
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [jumpToCharacter, setJumpToCharacter] = useState<string | null>(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 (
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Update all"
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingMovie}
isDisabled={hasNoMovie}
onPress={onRefreshMoviePress}
/>
<PageToolbarButton
label="RSS Sync"
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoMovie}
onPress={onRssSyncPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT} collapseButtons={false}>
{view === 'table' ? (
<TableOptionsModalWrapper
columns={columns}
optionsComponent={MovieIndexTableOptions}
onTableOptionChange={onTableOptionChange}
>
<PageToolbarButton label="Options" iconName={icons.TABLE} />
</TableOptionsModalWrapper>
) : (
<PageToolbarButton
label="Options"
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
isDisabled={hasNoMovie}
onPress={onOptionsPress}
/>
)}
<PageToolbarSeparator />
<MovieIndexViewMenu
view={view}
isDisabled={hasNoMovie}
onViewSelect={onViewSelect}
/>
<MovieIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoMovie}
onSortSelect={onSortSelect}
/>
<MovieIndexFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoMovie}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
ref={scrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? <div>Unable to load movie</div> : null}
{isLoaded ? (
<div className={styles.contentBodyContainer}>
<ViewComponent
scrollerRef={scrollerRef}
items={items}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSmallScreen={isSmallScreen}
/>
<MovieIndexFooter />
</div>
) : null}
{!error && isPopulated && !items.length ? (
<NoMovie totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar items={jumpBarItems} onItemPress={onJumpBarItemPress} />
) : null}
</div>
{view === 'posters' ? (
<MovieIndexPosterOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
{view === 'overview' ? (
<MovieIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
</PageContent>
);
}
export default MovieIndex;

@ -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 (
<MovieIndex
{...this.props}
onViewSelect={this.onViewSelect}
onScroll={this.onScroll}
onSaveSelected={this.onSaveSelected}
/>
);
}
}
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'
);

@ -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 (
<FilterModal
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>
);
}

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

@ -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 (
<div className={styles.footer}>
<div>
<div className={styles.legendItem}>
<div className={styles.ended} />
<div>
{translate('DownloadedAndMonitored')}
</div>
</div>
<div className={styles.legendItem}>
<div className={styles.availNotMonitored} />
<div>
{translate('DownloadedButNotMonitored')}
</div>
</div>
<div className={styles.legendItem}>
<div className={classNames(
styles.missingMonitored,
colorImpairedMode && 'colorImpaired'
)}
/>
<div>
{translate('MissingMonitoredAndConsideredAvailable')}
</div>
</div>
<div className={styles.legendItem}>
<div className={classNames(
styles.missingUnmonitored,
colorImpairedMode && 'colorImpaired'
)}
/>
<div>
{translate('MissingNotMonitored')}
</div>
</div>
<div className={styles.legendItem}>
<div className={styles.queue} />
<div>
{translate('Queued')}
</div>
</div>
<div className={styles.legendItem}>
<div className={styles.continuing} />
<div>
{translate('Unreleased')}
</div>
</div>
</div>
<div className={styles.statistics}>
<DescriptionList>
<DescriptionListItem
title={translate('Movies')}
data={count}
/>
<DescriptionListItem
title={translate('MovieFiles')}
data={movieFiles}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('Monitored')}
data={monitored}
/>
<DescriptionListItem
title={translate('Unmonitored')}
data={count - monitored}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('TotalFileSize')}
data={formatBytes(totalFileSize)}
/>
</DescriptionList>
</div>
</div>
);
}
}
MovieIndexFooter.propTypes = {
movies: PropTypes.arrayOf(PropTypes.object).isRequired,
colorImpairedMode: PropTypes.bool.isRequired
};
export default MovieIndexFooter;

@ -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 (
<ColorImpairedConsumer>
{(enableColorImpairedMode) => {
return (
<div className={styles.footer}>
<div>
<div className={styles.legendItem}>
<div className={styles.ended} />
<div>{translate('DownloadedAndMonitored')}</div>
</div>
<div className={styles.legendItem}>
<div className={styles.availNotMonitored} />
<div>{translate('DownloadedButNotMonitored')}</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.missingMonitored,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>{translate('MissingMonitoredAndConsideredAvailable')}</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.missingUnmonitored,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>{translate('MissingNotMonitored')}</div>
</div>
<div className={styles.legendItem}>
<div className={styles.queue} />
<div>{translate('Queued')}</div>
</div>
<div className={styles.legendItem}>
<div className={styles.continuing} />
<div>{translate('Unreleased')}</div>
</div>
</div>
<div className={styles.statistics}>
<DescriptionList>
<DescriptionListItem title={translate('Movies')} data={count} />
<DescriptionListItem
title={translate('MovieFiles')}
data={movieFiles}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('Monitored')}
data={monitored}
/>
<DescriptionListItem
title={translate('Unmonitored')}
data={count - monitored}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('TotalFileSize')}
data={formatBytes(totalFileSize)}
/>
</DescriptionList>
</div>
</div>
);
}}
</ColorImpairedConsumer>
);
}

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

@ -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 (
<ItemComponent
{...otherProps}
id={id}
onRefreshMoviePress={this.onRefreshMoviePress}
onSearchPress={this.onSearchPress}
/>
);
}
}
MovieIndexItemConnector.propTypes = {
id: PropTypes.number,
component: PropTypes.elementType.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MovieIndexItemConnector);

@ -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 (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.poster}>
<div className={styles.posterContainer}>
{
isMovieEditorActive &&
<div className={styles.editorSelect}>
<CheckInput
className={styles.checkInput}
name={id.toString()}
value={isSelected}
onChange={this.onChange}
/>
</div>
}
<Link
className={styles.link}
style={elementStyle}
to={link}
>
<MoviePoster
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
/>
</Link>
</div>
<MovieIndexProgressBar
monitored={monitored}
hasFile={hasFile}
isAvailable={isAvailable}
status={status}
posterWidth={posterWidth}
detailedProgressBar={overviewOptions.detailedProgressBar}
queueStatus={queueStatus}
queueState={queueState}
/>
</div>
<div className={styles.info} style={{ maxHeight: contentHeight }}>
<div className={styles.titleRow}>
<Link
className={styles.title}
to={link}
>
{title}
</Link>
<div className={styles.actions}>
<span className={styles.externalLinks}>
<Popover
anchor={
<Icon
name={icons.EXTERNAL_LINK}
size={12}
/>
}
title={translate('Links')}
body={
<MovieDetailsLinks
tmdbId={tmdbId}
imdbId={imdbId}
youTubeTrailerId={youTubeTrailerId}
/>
}
/>
</span>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshMovie')}
isSpinning={isRefreshingMovie}
onPress={onRefreshMoviePress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMovie')}
isSpinning={isSearchingMovie}
onPress={onSearchPress}
/>
}
<IconButton
name={icons.EDIT}
title={translate('EditMovie')}
onPress={this.onEditMoviePress}
/>
</div>
</div>
<div className={styles.details}>
<Link
className={styles.overview}
to={link}
>
<TextTruncate
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
text={overview}
/>
</Link>
<MovieIndexOverviewInfo
height={overviewHeight}
monitored={monitored}
qualityProfile={qualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
{...overviewOptions}
{...otherProps}
/>
</div>
</div>
</div>
<EditMovieModalConnector
isOpen={isEditMovieModalOpen}
movieId={id}
onModalClose={this.onEditMovieModalClose}
onDeleteMoviePress={this.onDeleteMoviePress}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={id}
onModalClose={this.onDeleteMovieModalClose}
/>
</div>
);
}
}
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;

@ -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 (
<div>
<div className={styles.content}>
<div className={styles.poster}>
<div className={styles.posterContainer}>
<Link className={styles.link} style={elementStyle} to={link}>
<MoviePoster
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
/>
</Link>
</div>
<MovieIndexProgressBar
monitored={monitored}
hasFile={hasFile}
isAvailable={isAvailable}
status={status}
posterWidth={posterWidth}
detailedProgressBar={overviewOptions.detailedProgressBar}
queueStatus={queueStatus}
queueState={queueState}
bottomRadius={false}
/>
</div>
<div className={styles.info} style={{ maxHeight: contentHeight }}>
<div className={styles.titleRow}>
<Link className={styles.title} to={link}>
{title}
</Link>
<div className={styles.actions}>
<span className={styles.externalLinks}>
<Popover
anchor={<Icon name={icons.EXTERNAL_LINK} size={12} />}
title={translate('Links')}
body={
<MovieDetailsLinks
tmdbId={tmdbId}
imdbId={imdbId}
youTubeTrailerId={youTubeTrailerId}
/>
}
/>
</span>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshMovie')}
isSpinning={isRefreshingMovie}
onPress={onRefreshPress}
/>
{overviewOptions.showSearchAction ? (
<SpinnerIconButton
className={styles.actions}
name={icons.SEARCH}
title={translate('SearchForMovie')}
isSpinning={isSearchingMovie}
onPress={onSearchPress}
/>
) : null}
<IconButton
name={icons.EDIT}
title={translate('EditMovie')}
onPress={onEditMoviePress}
/>
</div>
</div>
<div className={styles.details}>
<Link className={styles.overview} to={link}>
<TextTruncate
line={Math.floor(
overviewHeight / (defaultFontSize * lineHeight)
)}
text={overview}
/>
</Link>
<MovieIndexOverviewInfo
height={overviewHeight}
monitored={monitored}
qualityProfile={qualityProfile}
path={path}
sortKey={sortKey}
{...overviewOptions}
/>
</div>
</div>
</div>
<EditMovieModalConnector
isOpen={isEditMovieModalOpen}
movieId={movieId}
onModalClose={onEditMovieModalClose}
onDeleteMoviePress={onDeleteMoviePress}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={movieId}
onModalClose={onDeleteMovieModalClose}
/>
</div>
);
}
export default MovieIndexOverview;

@ -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 (
<div className={styles.infos}>
{
rows.map((row) => {
if (!isVisible(row, props)) {
return null;
}
if (shownRows >= maxRows) {
return null;
}
shownRows++;
const infoRowProps = getInfoRowProps(row, props);
return (
<MovieIndexOverviewInfoRow
key={row.name}
{...infoRowProps}
/>
);
})
}
</div>
);
}
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;

@ -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 (
<div className={styles.infos}>
{rowInfo.map((row) => {
if (!row.isVisible) {
return null;
}
if (shownRows >= maxRows) {
return null;
}
shownRows++;
const infoRowProps = getInfoRowProps(row, props, uiSettings);
return <MovieIndexOverviewInfoRow key={row.name} {...infoRowProps} />;
})}
</div>
);
}
export default MovieIndexOverviewInfo;

@ -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 (
<div
className={styles.infoRow}
title={title}
>
<Icon
className={styles.icon}
name={iconName}
size={14}
/>
{label}
</div>
);
}
MovieIndexOverviewInfoRow.propTypes = {
title: PropTypes.string,
iconName: PropTypes.object.isRequired,
label: PropTypes.string.isRequired
};
export default MovieIndexOverviewInfoRow;

@ -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 (
<div className={styles.infoRow} title={title}>
<Icon className={styles.icon} name={iconName} size={14} />
{label}
</div>
);
}
export default MovieIndexOverviewInfoRow;

@ -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 (
<div
className={styles.container}
key={key}
style={style}
>
<MovieIndexItemConnector
key={movie.id}
component={MovieIndexOverview}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
rowHeight={rowHeight}
overviewOptions={overviewOptions}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
isSmallScreen={isSmallScreen}
movieId={movie.id}
qualityProfileId={movie.qualityProfileId}
isSelected={selectedState[movie.id]}
onSelectedChange={onSelectedChange}
isMovieEditorActive={isMovieEditorActive}
/>
</div>
);
};
//
// Listeners
onMeasure = ({ width }) => {
this.calculateGrid(width, this.props.isSmallScreen);
};
//
// Render
render() {
const {
isSmallScreen,
scroller,
items,
selectedState
} = this.props;
const {
width,
rowHeight
} = this.state;
return (
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<WindowScroller
scrollElement={isSmallScreen ? undefined : scroller}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return <div />;
}
return (
<div ref={registerChild}>
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={1}
columnWidth={width}
rowCount={items.length}
rowHeight={rowHeight}
width={width}
onScroll={onChildScroll}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
selectedState={selectedState}
scrollToAlignment={'start'}
isScrollingOptout={true}
/>
</div>
);
}
}
</WindowScroller>
</Measure>
);
}
}
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;

@ -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<HTMLElement>;
isSmallScreen: boolean;
}
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
const { items, ...otherData } = data;
if (index >= items.length) {
return null;
}
const movie = items[index];
return (
<div style={style}>
<MovieIndexOverview movieId={movie.id} {...otherData} />
</div>
);
};
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<List> = 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 (
<div ref={measureRef}>
<List<RowItemData>
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}
</List>
</div>
);
}
export default MovieIndexOverviews;

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

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<MovieIndexOverviewOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
MovieIndexOverviewOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default MovieIndexOverviewOptionsModal;

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<MovieIndexOverviewOptionsModalContent
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default MovieIndexOverviewOptionsModal;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('OverviewOptions')}
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('DetailedProgressBar')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowMonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowStudio')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showStudio"
value={showStudio}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowQualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowDateAdded')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showAdded"
value={showAdded}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowPath')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showPath"
value={showPath}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSizeOnDisk')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSizeOnDisk"
value={showSizeOnDisk}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchHelpText')}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
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;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('OverviewOptions')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('DetailedProgressBar')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowMonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowStudio')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showStudio"
value={showStudio}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowQualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowDateAdded')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showAdded"
value={showAdded}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowPath')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showPath"
value={showPath}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSizeOnDisk')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSizeOnDisk"
value={showSizeOnDisk}
onChange={onOverviewOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchHelpText')}
onChange={onOverviewOptionChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default MovieIndexOverviewOptionsModalContent;

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

@ -0,0 +1,8 @@
import { createSelector } from 'reselect';
const selectOverviewOptions = createSelector(
(state) => state.movieIndex.overviewOptions,
(overviewOptions) => overviewOptions
);
export default selectOverviewOptions;

@ -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 (
<div className={styles.content}>
<div className={styles.posterContainer}>
{
isMovieEditorActive &&
<div className={styles.editorSelect}>
<CheckInput
className={styles.checkInput}
name={id.toString()}
value={isSelected}
onChange={this.onChange}
/>
</div>
}
<Label className={styles.controls}>
<SpinnerIconButton
className={styles.action}
name={icons.REFRESH}
title={translate('RefreshMovie')}
isSpinning={isRefreshingMovie}
onPress={onRefreshMoviePress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMovie')}
isSpinning={isSearchingMovie}
onPress={onSearchPress}
/>
}
<IconButton
className={styles.action}
name={icons.EDIT}
title={translate('EditMovie')}
onPress={this.onEditMoviePress}
/>
<span className={styles.externalLinks}>
<Popover
anchor={
<Icon
name={icons.EXTERNAL_LINK}
size={12}
/>
}
title={translate('Links')}
body={
<MovieDetailsLinks
tmdbId={tmdbId}
imdbId={imdbId}
youTubeTrailerId={youTubeTrailerId}
/>
}
/>
</span>
</Label>
{
status === 'ended' &&
<div
className={styles.ended}
title={translate('Ended')}
/>
}
<Link
className={styles.link}
style={elementStyle}
to={link}
>
<MoviePoster
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
onError={this.onPosterLoadError}
onLoad={this.onPosterLoad}
/>
{
hasPosterError &&
<div className={styles.overlayTitle}>
{title}
</div>
}
</Link>
</div>
<MovieIndexProgressBar
monitored={monitored}
hasFile={hasFile}
status={status}
posterWidth={posterWidth}
detailedProgressBar={detailedProgressBar}
queueStatus={queueStatus}
queueState={queueState}
isAvailable={isAvailable}
/>
{
showTitle &&
<div className={styles.title}>
{title}
</div>
}
{
showMonitored &&
<div className={styles.title}>
{monitored ? translate('Monitored') : translate('Unmonitored')}
</div>
}
{
showQualityProfile &&
<div className={styles.title}>
{qualityProfile.name}
</div>
}
{
showCinemaRelease && inCinemas &&
<div className={styles.title}>
<Icon
name={icons.IN_CINEMAS}
/> {getRelativeDate(
inCinemas,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: false
}
)}
</div>
}
{
showReleaseDate && releaseDateType === 'Released' &&
<div className={styles.title}>
<Icon
name={icons.DISC}
/> {getRelativeDate(
releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: false
}
)}
</div>
}
{
showReleaseDate && releaseDateType === 'Digital' &&
<div className={styles.title}>
<Icon
name={icons.MOVIE_FILE}
/> {getRelativeDate(
releaseDate,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: false
}
)}
</div>
}
<MovieIndexPosterInfo
qualityProfile={qualityProfile}
showQualityProfile={showQualityProfile}
showReleaseDate={showReleaseDate}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
inCinemas={inCinemas}
physicalRelease={physicalRelease}
digitalRelease={digitalRelease}
{...otherProps}
/>
<EditMovieModalConnector
isOpen={isEditMovieModalOpen}
movieId={id}
onModalClose={this.onEditMovieModalClose}
onDeleteMoviePress={this.onDeleteMoviePress}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={id}
onModalClose={this.onDeleteMovieModalClose}
/>
</div>
);
}
}
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;

@ -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 (
<div className={styles.content}>
<div className={styles.posterContainer}>
<Label className={styles.controls}>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshMovie')}
isSpinning={isRefreshingMovie}
onPress={onRefreshPress}
/>
{showSearchAction ? (
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMovie')}
isSpinning={isSearchingMovie}
onPress={onSearchPress}
/>
) : null}
<IconButton
name={icons.EDIT}
title={translate('EditMovie')}
onPress={onEditMoviePress}
/>
<span className={styles.externalLinks}>
<Popover
anchor={<Icon name={icons.EXTERNAL_LINK} size={12} />}
title={translate('Links')}
body={
<MovieDetailsLinks
tmdbId={tmdbId}
imdbId={imdbId}
youTubeTrailerId={youTubeTrailerId}
/>
}
/>
</span>
</Label>
<Link className={styles.link} style={elementStyle} to={link}>
<MoviePoster
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
onError={onPosterLoadError}
onLoad={onPosterLoad}
/>
{hasPosterError ? (
<div className={styles.overlayTitle}>{title}</div>
) : null}
</Link>
</div>
<MovieIndexProgressBar
monitored={monitored}
hasFile={hasFile}
isAvailable={isAvailable}
status={status}
posterWidth={posterWidth}
detailedProgressBar={detailedProgressBar}
queueStatus={queueStatus}
queueState={queueState}
bottomRadius={false}
/>
{showTitle ? <div className={styles.title}>{title}</div> : null}
{showMonitored ? (
<div className={styles.title}>
{monitored ? translate('monitored') : translate('unmonitored')}
</div>
) : null}
{showQualityProfile ? (
<div className={styles.title}>{qualityProfile.name}</div>
) : null}
<MovieIndexPosterInfo
qualityProfile={qualityProfile}
showQualityProfile={showQualityProfile}
showReleaseDate={showReleaseDate}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
inCinemas={inCinemas}
physicalRelease={physicalRelease}
digitalRelease={digitalRelease}
sortKey={sortKey}
path={path}
certification={certification}
/>
<EditMovieModalConnector
isOpen={isEditMovieModalOpen}
movieId={movieId}
onModalClose={onEditMovieModalClose}
onDeleteMoviePress={onDeleteMoviePress}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={movieId}
onModalClose={onDeleteMovieModalClose}
/>
</div>
);
}
export default MovieIndexPoster;

@ -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 (
<div className={styles.info}>
{studio}
</div>
);
return <div className={styles.info}>{studio}</div>;
}
if (sortKey === 'qualityProfileId' && !showQualityProfile) {
return (
<div className={styles.info}>
{qualityProfile.name}
</div>
);
return <div className={styles.info}>{qualityProfile.name}</div>;
}
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 (
<div className={styles.info}>
<Icon
name={icons.IN_CINEMAS}
/> {inCinemasDate}
<Icon name={icons.IN_CINEMAS} /> {inCinemasDate}
</div>
);
}
@ -87,15 +95,13 @@ function MovieIndexPosterInfo(props) {
showRelativeDates,
{
timeFormat,
timeForToday: false
timeForToday: false,
}
);
return (
<div className={styles.info}>
<Icon
name={icons.MOVIE_FILE}
/> {digitalReleaseDate}
<Icon name={icons.MOVIE_FILE} /> {digitalReleaseDate}
</div>
);
}
@ -107,62 +113,30 @@ function MovieIndexPosterInfo(props) {
showRelativeDates,
{
timeFormat,
timeForToday: false
timeForToday: false,
}
);
return (
<div className={styles.info}>
<Icon
name={icons.DISC}
/> {physicalReleaseDate}
<Icon name={icons.DISC} /> {physicalReleaseDate}
</div>
);
}
if (sortKey === 'path') {
return (
<div className={styles.info}>
{path}
</div>
);
return <div className={styles.info}>{path}</div>;
}
if (sortKey === 'sizeOnDisk') {
return (
<div className={styles.info}>
{formatBytes(sizeOnDisk)}
</div>
);
return <div className={styles.info}>{formatBytes(sizeOnDisk)}</div>;
}
if (sortKey === 'certification') {
return (
<div className={styles.info}>
{certification}
</div>
);
return <div className={styles.info}>{certification}</div>;
}
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;

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

@ -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 (
<div
className={styles.container}
key={key}
style={{
...style,
padding: this._padding
}}
>
<MovieIndexItemConnector
key={movie.id}
component={MovieIndexPoster}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
detailedProgressBar={detailedProgressBar}
showTitle={showTitle}
showMonitored={showMonitored}
showQualityProfile={showQualityProfile}
showReleaseDate={showReleaseDate}
showCinemaRelease={showCinemaRelease}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
movieId={movie.id}
qualityProfileId={movie.qualityProfileId}
isSelected={selectedState[movie.id]}
onSelectedChange={onSelectedChange}
isMovieEditorActive={isMovieEditorActive}
/>
</div>
);
};
//
// 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 (
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<WindowScroller
scrollElement={isSmallScreen ? undefined : scroller}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return <div />;
}
return (
<div ref={registerChild}>
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={columnCount}
columnWidth={columnWidth}
rowCount={rowCount}
rowHeight={rowHeight}
width={width}
onScroll={onChildScroll}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
selectedState={selectedState}
scrollToAlignment={'start'}
isScrollingOptOut={true}
/>
</div>
);
}
}
</WindowScroller>
</Measure>
);
}
}
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;

@ -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<HTMLElement>;
isSmallScreen: boolean;
}
const movieIndexSelector = createSelector(
(state) => state.movieIndex.posterOptions,
(posterOptions) => {
return {
posterOptions,
};
}
);
const Cell: React.FC<GridChildComponentProps<CellItemData>> = ({
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 (
<div
style={{
padding,
...style,
}}
>
<MovieIndexPoster
movieId={movie.id}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
/>
</div>
);
};
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<Grid> = 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 (
<div ref={measureRef}>
<Grid<CellItemData>
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}
</Grid>
</div>
);
}

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

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<MovieIndexPosterOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
MovieIndexPosterOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default MovieIndexPosterOptionsModal;

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<MovieIndexPosterOptionsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default MovieIndexPosterOptionsModal;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('PosterOptions')}
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('DetailedProgressBar')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowTitle')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTitle"
value={showTitle}
helpText={translate('ShowTitleHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowMonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
helpText={translate('ShowMonitoredHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowQualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
helpText={translate('ShowQualityProfileHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowCinemaRelease')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showCinemaRelease"
value={showCinemaRelease}
helpText={translate('showCinemaReleaseHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowReleaseDate')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showReleaseDate"
value={showReleaseDate}
helpText={translate('ShowReleaseDateHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
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;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('PosterOptions')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('DetailedProgressBar')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowTitle')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTitle"
value={showTitle}
helpText={translate('ShowTitleHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowMonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
helpText={translate('ShowMonitoredHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowQualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
helpText={translate('ShowQualityProfileHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowCinemaRelease')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showCinemaRelease"
value={showCinemaRelease}
helpText={translate('showCinemaReleaseHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowReleaseDate')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showReleaseDate"
value={showReleaseDate}
helpText={translate('ShowReleaseDateHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default MovieIndexPosterOptionsModalContent;

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

@ -0,0 +1,8 @@
import { createSelector } from 'reselect';
const selectPosterOptions = createSelector(
(state) => state.movieIndex.posterOptions,
(posterOptions) => posterOptions
);
export default selectPosterOptions;

@ -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 (
<ProgressBar
className={styles.progressBar}
containerClassName={bottomRadius ? styles.progressRadius : styles.progress}
containerClassName={
bottomRadius ? styles.progressRadius : styles.progress
}
progress={progress}
kind={getStatusStyle(status, monitored, hasFile, isAvailable, 'kinds', queueStatusText)}
kind={getStatusStyle(
status,
monitored,
hasFile,
isAvailable,
'kinds',
queueStatusText
)}
size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL}
showText={detailedProgressBar}
width={posterWidth}
text={(queueStatusText) ? queueStatusText : translate(movieStatus)}
text={queueStatusText ? queueStatusText : translate(movieStatus)}
/>
);
}
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;

@ -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 (
<VirtualTableRowCell
{...otherProps}
>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshMovie')}
isSpinning={isRefreshingMovie}
onPress={onRefreshMoviePress}
/>
<IconButton
name={icons.EDIT}
title={translate('EditMovie')}
onPress={this.onEditMoviePress}
/>
<EditMovieModalConnector
isOpen={isEditMovieModalOpen}
movieId={id}
onModalClose={this.onEditMovieModalClose}
onDeleteMoviePress={this.onDeleteMoviePress}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={id}
onModalClose={this.onDeleteMovieModalClose}
/>
</VirtualTableRowCell>
);
}
}
MovieIndexActionsCell.propTypes = {
id: PropTypes.number.isRequired,
isRefreshingMovie: PropTypes.bool.isRequired,
onRefreshMoviePress: PropTypes.func.isRequired
};
export default MovieIndexActionsCell;

@ -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 (
<VirtualTableHeader>
{
columns.map((column) => {
const {
name,
label,
isSortable,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'select') {
if (isMovieEditorActive) {
return (
<VirtualTableSelectAllHeaderCell
key={name}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
);
}
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
{...otherProps}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
onPress={this.onTableOptionsPress}
/>
</VirtualTableHeaderCell>
);
}
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={isSortable}
{...otherProps}
>
{label}
</VirtualTableHeaderCell>
);
})
}
<TableOptionsModal
isOpen={this.state.isTableOptionsModalOpen}
columns={columns}
optionsComponent={MovieIndexTableOptionsConnector}
onTableOptionChange={onTableOptionChange}
onModalClose={this.onTableOptionsModalClose}
/>
</VirtualTableHeader>
);
}
}
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;

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

@ -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 (
<VirtualTableSelectCell
inputClassName={styles.checkInput}
id={id}
key={name}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
);
}
if (name === 'status') {
return (
<MovieStatusCell
key={name}
className={styles[name]}
monitored={monitored}
status={status}
component={VirtualTableRowCell}
/>
);
}
if (name === 'sortTitle') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<MovieTitleLink
titleSlug={titleSlug}
title={title}
/>
</VirtualTableRowCell>
);
}
if (name === 'collection') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{collection ? collection.title : null }
</VirtualTableRowCell>
);
}
if (name === 'studio') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{studio}
</VirtualTableRowCell>
);
}
if (name === 'originalLanguage') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{originalLanguage.name}
</VirtualTableRowCell>
);
}
if (name === 'originalTitle') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{originalTitle}
</VirtualTableRowCell>
);
}
if (name === 'qualityProfileId') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{qualityProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={added}
component={VirtualTableRowCell}
/>
);
}
if (name === 'year') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{year}
</VirtualTableRowCell>
);
}
if (name === 'inCinemas') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={inCinemas}
component={VirtualTableRowCell}
/>
);
}
if (name === 'digitalRelease') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={digitalRelease}
component={VirtualTableRowCell}
/>
);
}
if (name === 'physicalRelease') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={physicalRelease}
component={VirtualTableRowCell}
/>
);
}
if (name === 'runtime') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{formatRuntime(runtime, movieRuntimeFormat)}
</VirtualTableRowCell>
);
}
if (name === 'minimumAvailability') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{titleCase(minimumAvailability)}
</VirtualTableRowCell>
);
}
if (name === 'path') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
title={path}
>
{path}
</VirtualTableRowCell>
);
}
if (name === 'sizeOnDisk') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{formatBytes(sizeOnDisk)}
</VirtualTableRowCell>
);
}
if (name === 'genres') {
const joinedGenres = genres.join(', ');
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<span title={joinedGenres}>
{joinedGenres}
</span>
</VirtualTableRowCell>
);
}
if (name === 'movieStatus') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<MovieFileStatusConnector
movieId={id}
queueStatus={queueStatus}
queueState={queueState}
/>
</VirtualTableRowCell>
);
}
if (name === 'tmdbRating') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<TmdbRating
ratings={ratings}
/>
</VirtualTableRowCell>
);
}
if (name === 'rottenTomatoesRating') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<RottenTomatoRating
ratings={ratings}
/>
</VirtualTableRowCell>
);
}
if (name === 'imdbRating') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<ImdbRating
ratings={ratings}
/>
</VirtualTableRowCell>
);
}
if (name === 'certification') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{certification}
</VirtualTableRowCell>
);
}
if (name === 'tags') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<TagListConnector
tags={tags}
/>
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<span className={styles.externalLinks}>
<Tooltip
anchor={
<Icon
name={icons.EXTERNAL_LINK}
size={12}
/>
}
tooltip={
<MovieDetailsLinks
tmdbId={tmdbId}
imdbId={imdbId}
youTubeTrailerId={youTubeTrailerId}
/>
}
canFlip={true}
kind={kinds.INVERSE}
/>
</span>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshMovie')}
isSpinning={isRefreshingMovie}
onPress={onRefreshMoviePress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMovie')}
isSpinning={isSearchingMovie}
onPress={onSearchPress}
/>
}
<IconButton
name={icons.EDIT}
title={translate('EditMovie')}
onPress={this.onEditMoviePress}
/>
</VirtualTableRowCell>
);
}
return null;
})
}
<EditMovieModalConnector
isOpen={isEditMovieModalOpen}
movieId={id}
onModalClose={this.onEditMovieModalClose}
onDeleteMoviePress={this.onDeleteMoviePress}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={id}
onModalClose={this.onDeleteMovieModalClose}
/>
</>
);
}
}
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;

@ -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 (
<MovieStatusCell
key={name}
className={styles[name]}
monitored={monitored}
status={status}
component={VirtualTableRowCell}
/>
);
}
if (name === 'sortTitle') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<MovieTitleLink titleSlug={titleSlug} title={title} />
</VirtualTableRowCell>
);
}
if (name === 'collection') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{collection ? collection.title : null}
</VirtualTableRowCell>
);
}
if (name === 'studio') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{studio}
</VirtualTableRowCell>
);
}
if (name === 'originalLanguage') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{originalLanguage.name}
</VirtualTableRowCell>
);
}
if (name === 'originalTitle') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{originalTitle}
</VirtualTableRowCell>
);
}
if (name === 'qualityProfileId') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{qualityProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={added}
component={VirtualTableRowCell}
/>
);
}
if (name === 'year') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{year}
</VirtualTableRowCell>
);
}
if (name === 'inCinemas') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={inCinemas}
component={VirtualTableRowCell}
/>
);
}
if (name === 'digitalRelease') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={digitalRelease}
component={VirtualTableRowCell}
/>
);
}
if (name === 'physicalRelease') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={physicalRelease}
component={VirtualTableRowCell}
/>
);
}
if (name === 'runtime') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{formatRuntime(runtime, movieRuntimeFormat)}
</VirtualTableRowCell>
);
}
if (name === 'minimumAvailability') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{titleCase(minimumAvailability)}
</VirtualTableRowCell>
);
}
if (name === 'path') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
title={path}
>
{path}
</VirtualTableRowCell>
);
}
if (name === 'sizeOnDisk') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{formatBytes(sizeOnDisk)}
</VirtualTableRowCell>
);
}
if (name === 'genres') {
const joinedGenres = genres.join(', ');
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<span title={joinedGenres}>{joinedGenres}</span>
</VirtualTableRowCell>
);
}
if (name === 'movieStatus') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<MovieFileStatusConnector
movieId={movieId}
queueStatus={queueStatus}
queueState={queueState}
/>
</VirtualTableRowCell>
);
}
if (name === 'tmdbRating') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<TmdbRating ratings={ratings} />
</VirtualTableRowCell>
);
}
if (name === 'rottenTomatoesRating') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<RottenTomatoRating ratings={ratings} />
</VirtualTableRowCell>
);
}
if (name === 'imdbRating') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<ImdbRating ratings={ratings} />
</VirtualTableRowCell>
);
}
if (name === 'certification') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{certification}
</VirtualTableRowCell>
);
}
if (name === 'tags') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<TagListConnector tags={tags} />
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<span className={styles.externalLinks}>
<Tooltip
anchor={<Icon name={icons.EXTERNAL_LINK} size={12} />}
tooltip={
<MovieDetailsLinks
tmdbId={tmdbId}
imdbId={imdbId}
youTubeTrailerId={youTubeTrailerId}
/>
}
canFlip={true}
kind={kinds.INVERSE}
/>
</span>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshMovie')}
isSpinning={isRefreshingMovie}
onPress={onRefreshPress}
/>
{showSearchAction && (
<SpinnerIconButton
className={styles.actions}
name={icons.SEARCH}
title={translate('SearchForMovie')}
isSpinning={isSearchingMovie}
onPress={onSearchPress}
/>
)}
<IconButton
name={icons.EDIT}
title={translate('EditMovie')}
onPress={onEditMoviePress}
/>
</VirtualTableRowCell>
);
}
return null;
})}
<EditMovieModalConnector
isOpen={isEditMovieModalOpen}
movieId={movieId}
onModalClose={onEditMovieModalClose}
onDeleteMoviePress={onDeleteMoviePress}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={movieId}
onModalClose={onDeleteMovieModalClose}
/>
</>
);
}
export default MovieIndexRow;

@ -1,5 +1,3 @@
.tableContainer {
composes: tableContainer from '~Components/Table/VirtualTable.css';
flex: 1 0 auto;
.tableScroller {
position: relative;
}

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

@ -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 (
<VirtualTableRow
key={key}
style={style}
>
<MovieIndexItemConnector
key={movie.id}
component={MovieIndexRow}
columns={columns}
movieId={movie.id}
collectionId={movie.collectionId}
qualityProfileId={movie.qualityProfileId}
isSelected={selectedState[movie.id]}
onSelectedChange={onSelectedChange}
isMovieEditorActive={isMovieEditorActive}
movieRuntimeFormat={movieRuntimeFormat}
/>
</VirtualTableRow>
);
};
//
// Render
render() {
const {
items,
columns,
sortKey,
sortDirection,
isSmallScreen,
onSortPress,
scroller,
scrollTop,
allSelected,
allUnselected,
onSelectAllChange,
isMovieEditorActive,
selectedState
} = this.props;
return (
<VirtualTable
className={styles.tableContainer}
items={items}
scrollIndex={this.state.scrollIndex}
isSmallScreen={isSmallScreen}
scrollTop={scrollTop}
scroller={scroller}
rowHeight={38}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<MovieIndexHeaderConnector
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
isMovieEditorActive={isMovieEditorActive}
/>
}
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;

@ -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<HTMLElement>;
isSmallScreen: boolean;
}
const columnsSelector = createSelector(
(state) => state.movieIndex.columns,
(columns) => columns
);
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
index,
style,
data,
}) => {
const { items, sortKey, columns } = data;
if (index >= items.length) {
return null;
}
const movie = items[index];
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
...style,
}}
>
<MovieIndexRow movieId={movie.id} sortKey={sortKey} columns={columns} />
</div>
);
};
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<List> = 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 (
<div ref={measureRef}>
<Scroller
className={styles.tableScroller}
scrollDirection={ScrollDirection.Horizontal}
>
<MovieIndexTableHeader
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
/>
<List<RowItemData>
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}
</List>
</Scroller>
</div>
);
}
export default MovieIndexTable;

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

@ -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 (
<VirtualTableHeader>
{columns.map((column) => {
const { name, label, isSortable, isVisible } = column;
if (!isVisible) {
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
>
<TableOptionsModalWrapper
columns={columns}
optionsComponent={MovieIndexTableOptions}
onTableOptionChange={onTableOptionChange}
>
<IconButton name={icons.ADVANCED_SETTINGS} />
</TableOptionsModalWrapper>
</VirtualTableHeaderCell>
);
}
return (
<VirtualTableHeaderCell
key={name}
className={classNames(styles[name])}
name={name}
sortKey={sortKey}
sortDirection={sortDirection}
isSortable={isSortable}
onSortPress={onSortPress}
>
{label}
</VirtualTableHeaderCell>
);
})}
</VirtualTableHeader>
);
}
export default MovieIndexTableHeader;

@ -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 (
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchHelpText')}
onChange={this.onTableOptionChange}
/>
</FormGroup>
);
}
}
MovieIndexTableOptions.propTypes = {
showSearchAction: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired
};
export default MovieIndexTableOptions;

@ -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 (
<Fragment>
<FormGroup>
<FormLabel>{translate('ShowSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchHelpText')}
onChange={onTableOptionChangeWrapper}
/>
</FormGroup>
</Fragment>
);
}
export default MovieIndexTableOptions;

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

@ -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 (
<Component
className={className}
{...otherProps}
>
<Component className={className} {...otherProps}>
<Icon
className={styles.statusIcon}
name={monitored ? icons.MONITORED : icons.UNMONITORED}
title={monitored ? translate('MovieIsMonitored') : translate('MovieIsUnmonitored')}
title={
monitored
? translate('MovieIsMonitored')
: translate('MovieIsUnmonitored')
}
/>
<Icon
@ -34,21 +41,8 @@ function MovieStatusCell(props) {
name={statusDetails.icon}
title={`${statusDetails.title}: ${statusDetails.message}`}
/>
</Component>
);
}
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;

@ -0,0 +1,8 @@
import { createSelector } from 'reselect';
const selectTableOptions = createSelector(
(state) => state.movieIndex.tableOptions,
(tableOptions) => tableOptions
);
export default selectTableOptions;

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

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

@ -15,6 +15,10 @@ function MovieBanner(props) {
}
MovieBanner.propTypes = {
...MovieImage.propTypes,
coverType: PropTypes.string,
placeholder: PropTypes.string,
overflow: PropTypes.bool,
size: PropTypes.number.isRequired
};

@ -15,6 +15,10 @@ function MoviePoster(props) {
}
MoviePoster.propTypes = {
...MovieImage.propTypes,
coverType: PropTypes.string,
placeholder: PropTypes.string,
overflow: PropTypes.bool,
size: PropTypes.number.isRequired
};

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

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

@ -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 === '#') {

@ -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"
]
}

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

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

Loading…
Cancel
Save