1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-26 22:56:23 -04:00

Refactor Series index to use react-window

This commit is contained in:
Mark McDowall
2023-01-05 18:20:49 -08:00
committed by Mark McDowall
parent de56862bb9
commit d022679b7d
92 changed files with 3527 additions and 4462 deletions
@@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import SeriesIndexPosterOptionsModalContentConnector from './SeriesIndexPosterOptionsModalContentConnector';
function SeriesIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SeriesIndexPosterOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
SeriesIndexPosterOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SeriesIndexPosterOptionsModal;
@@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import SeriesIndexPosterOptionsModalContent from './SeriesIndexPosterOptionsModalContent';
interface SeriesIndexPosterOptionsModalProps {
isOpen: boolean;
onModalClose(...args: unknown[]): unknown;
}
function SeriesIndexPosterOptionsModal({
isOpen,
onModalClose,
}: SeriesIndexPosterOptionsModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<SeriesIndexPosterOptionsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default SeriesIndexPosterOptionsModal;
@@ -1,213 +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';
const posterSizeOptions = [
{ key: 'small', value: 'Small' },
{ key: 'medium', value: 'Medium' },
{ key: 'large', value: 'Large' }
];
class SeriesIndexPosterOptionsModalContent 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,
showSearchAction: props.showSearchAction
};
}
componentDidUpdate(prevProps) {
const {
detailedProgressBar,
size,
showTitle,
showMonitored,
showQualityProfile,
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 (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,
showSearchAction
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Poster Options
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>Poster Size</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Detailed Progress Bar</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText="Show text on progress bar"
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Title</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTitle"
value={showTitle}
helpText="Show series title under poster"
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Monitored</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
helpText="Show monitored status under poster"
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
helpText="Show quality profile under poster"
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText="Show search button on hover"
onChange={this.onChangePosterOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SeriesIndexPosterOptionsModalContent.propTypes = {
size: PropTypes.string.isRequired,
showTitle: PropTypes.bool.isRequired,
showMonitored: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
detailedProgressBar: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
onChangePosterOption: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SeriesIndexPosterOptionsModalContent;
@@ -0,0 +1,138 @@
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 selectPosterOptions from 'Series/Index/Posters/selectPosterOptions';
import { setSeriesPosterOption } from 'Store/Actions/seriesIndexActions';
const posterSizeOptions = [
{ key: 'small', value: 'Small' },
{ key: 'medium', value: 'Medium' },
{ key: 'large', value: 'Large' },
];
interface SeriesIndexPosterOptionsModalContentProps {
onModalClose(...args: unknown[]): unknown;
}
function SeriesIndexPosterOptionsModalContent(
props: SeriesIndexPosterOptionsModalContentProps
) {
const { onModalClose } = props;
const posterOptions = useSelector(selectPosterOptions);
const {
detailedProgressBar,
size,
showTitle,
showMonitored,
showQualityProfile,
showSearchAction,
} = posterOptions;
const dispatch = useDispatch();
const onPosterOptionChange = useCallback(
({ name, value }) => {
dispatch(setSeriesPosterOption({ [name]: value }));
},
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Poster Options</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>Poster Size</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Detailed Progress Bar</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText="Show text on progress bar"
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Title</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTitle"
value={showTitle}
helpText="Show series title under poster"
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Monitored</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
helpText="Show monitored status under poster"
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
helpText="Show quality profile under poster"
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText="Show search button on hover"
onChange={onPosterOptionChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Close</Button>
</ModalFooter>
</ModalContent>
);
}
export default SeriesIndexPosterOptionsModalContent;
@@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setSeriesPosterOption } from 'Store/Actions/seriesIndexActions';
import SeriesIndexPosterOptionsModalContent from './SeriesIndexPosterOptionsModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.seriesIndex,
(seriesIndex) => {
return seriesIndex.posterOptions;
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onChangePosterOption(payload) {
dispatch(setSeriesPosterOption(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexPosterOptionsModalContent);
@@ -1,291 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
import SeriesPoster from 'Series/SeriesPoster';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import SeriesIndexPosterInfo from './SeriesIndexPosterInfo';
import styles from './SeriesIndexPoster.css';
class SeriesIndexPoster extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
hasPosterError: false,
isEditSeriesModalOpen: false,
isDeleteSeriesModalOpen: false
};
}
//
// Listeners
onEditSeriesPress = () => {
this.setState({ isEditSeriesModalOpen: true });
};
onEditSeriesModalClose = () => {
this.setState({ isEditSeriesModalOpen: false });
};
onDeleteSeriesPress = () => {
this.setState({
isEditSeriesModalOpen: false,
isDeleteSeriesModalOpen: true
});
};
onDeleteSeriesModalClose = () => {
this.setState({ isDeleteSeriesModalOpen: false });
};
onPosterLoad = () => {
if (this.state.hasPosterError) {
this.setState({ hasPosterError: false });
}
};
onPosterLoadError = () => {
if (!this.state.hasPosterError) {
this.setState({ hasPosterError: true });
}
};
//
// Render
render() {
const {
id,
title,
monitored,
status,
titleSlug,
nextAiring,
statistics,
images,
posterWidth,
posterHeight,
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
qualityProfile,
showSearchAction,
showRelativeDates,
shortDateFormat,
timeFormat,
isRefreshingSeries,
isSearchingSeries,
onRefreshSeriesPress,
onSearchPress,
...otherProps
} = this.props;
const {
seasonCount,
episodeCount,
episodeFileCount,
totalEpisodeCount,
sizeOnDisk
} = statistics;
const {
hasPosterError,
isEditSeriesModalOpen,
isDeleteSeriesModalOpen
} = this.state;
const link = `/series/${titleSlug}`;
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`
};
return (
<div className={styles.content}>
<div className={styles.posterContainer}>
<Label className={styles.controls}>
<SpinnerIconButton
className={styles.action}
name={icons.REFRESH}
title="Refresh series"
isSpinning={isRefreshingSeries}
onPress={onRefreshSeriesPress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title="Search for monitored episodes"
isSpinning={isSearchingSeries}
onPress={onSearchPress}
/>
}
<IconButton
className={styles.action}
name={icons.EDIT}
title="Edit Series"
onPress={this.onEditSeriesPress}
/>
</Label>
{
status === 'ended' &&
<div
className={styles.ended}
title="Ended"
/>
}
<Link
className={styles.link}
style={elementStyle}
to={link}
>
<SeriesPoster
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>
<SeriesIndexProgressBar
monitored={monitored}
status={status}
episodeCount={episodeCount}
episodeFileCount={episodeFileCount}
totalEpisodeCount={totalEpisodeCount}
posterWidth={posterWidth}
detailedProgressBar={detailedProgressBar}
/>
{
showTitle &&
<div className={styles.title}>
{title}
</div>
}
{
showMonitored &&
<div className={styles.title}>
{monitored ? 'Monitored' : 'Unmonitored'}
</div>
}
{
showQualityProfile &&
<div className={styles.title}>
{qualityProfile.name}
</div>
}
{
nextAiring &&
<div className={styles.nextAiring}>
{
getRelativeDate(
nextAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
</div>
}
<SeriesIndexPosterInfo
seasonCount={seasonCount}
sizeOnDisk={sizeOnDisk}
qualityProfile={qualityProfile}
showQualityProfile={showQualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
{...otherProps}
/>
<EditSeriesModalConnector
isOpen={isEditSeriesModalOpen}
seriesId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteSeriesModal
isOpen={isDeleteSeriesModalOpen}
seriesId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</div>
);
}
}
SeriesIndexPoster.propTypes = {
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
nextAiring: PropTypes.string,
statistics: PropTypes.object.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,
timeFormat: PropTypes.string.isRequired,
isRefreshingSeries: PropTypes.bool.isRequired,
isSearchingSeries: PropTypes.bool.isRequired,
onRefreshSeriesPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
};
SeriesIndexPoster.defaultProps = {
statistics: {
seasonCount: 0,
episodeCount: 0,
episodeFileCount: 0,
totalEpisodeCount: 0
}
};
export default SeriesIndexPoster;
@@ -0,0 +1,230 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import { icons } from 'Helpers/Props';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
import SeriesPoster from 'Series/SeriesPoster';
import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import createSeriesIndexItemSelector from '../createSeriesIndexItemSelector';
import selectPosterOptions from './selectPosterOptions';
import SeriesIndexPosterInfo from './SeriesIndexPosterInfo';
import styles from './SeriesIndexPoster.css';
interface SeriesIndexPosterProps {
seriesId: number;
sortKey: string;
posterWidth: number;
posterHeight: number;
}
function SeriesIndexPoster(props: SeriesIndexPosterProps) {
const { seriesId, sortKey, posterWidth, posterHeight } = props;
const { series, qualityProfile, isRefreshingSeries, isSearchingSeries } =
useSelector(createSeriesIndexItemSelector(props.seriesId));
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile,
showSearchAction,
} = useSelector(selectPosterOptions);
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const {
title,
monitored,
status,
path,
titleSlug,
nextAiring,
statistics,
images,
} = series;
const {
seasonCount,
episodeCount,
episodeFileCount,
totalEpisodeCount,
sizeOnDisk,
} = statistics;
const dispatch = useDispatch();
const [hasPosterError, setHasPosterError] = useState(false);
const [isEditSeriesModalOpen, setIsEditSeriesModalOpen] = useState(false);
const [isDeleteSeriesModalOpen, setIsDeleteSeriesModalOpen] = useState(false);
const onRefreshPress = useCallback(() => {
dispatch(
executeCommand({
name: REFRESH_SERIES,
seriesId,
})
);
}, [seriesId, dispatch]);
const onSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: SERIES_SEARCH,
seriesId,
})
);
}, [seriesId, dispatch]);
const onPosterLoadError = useCallback(() => {
setHasPosterError(true);
}, [setHasPosterError]);
const onPosterLoad = useCallback(() => {
setHasPosterError(false);
}, [setHasPosterError]);
const onEditSeriesPress = useCallback(() => {
setIsEditSeriesModalOpen(true);
}, [setIsEditSeriesModalOpen]);
const onEditSeriesModalClose = useCallback(() => {
setIsEditSeriesModalOpen(false);
}, [setIsEditSeriesModalOpen]);
const onDeleteSeriesPress = useCallback(() => {
setIsEditSeriesModalOpen(false);
setIsDeleteSeriesModalOpen(true);
}, [setIsDeleteSeriesModalOpen]);
const onDeleteSeriesModalClose = useCallback(() => {
setIsDeleteSeriesModalOpen(false);
}, [setIsDeleteSeriesModalOpen]);
const link = `/series/${titleSlug}`;
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`,
};
return (
<div className={styles.content}>
<div className={styles.posterContainer}>
<Label className={styles.controls}>
<SpinnerIconButton
className={styles.action}
name={icons.REFRESH}
title="Refresh series"
isSpinning={isRefreshingSeries}
onPress={onRefreshPress}
/>
{showSearchAction ? (
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title="Search for monitored episodes"
isSpinning={isSearchingSeries}
onPress={onSearchPress}
/>
) : null}
<IconButton
className={styles.action}
name={icons.EDIT}
title="Edit Series"
onPress={onEditSeriesPress}
/>
</Label>
{status === 'ended' ? (
<div className={styles.ended} title="Ended" />
) : null}
<Link className={styles.link} style={elementStyle} to={link}>
<SeriesPoster
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
onError={onPosterLoadError}
onLoad={onPosterLoad}
/>
{hasPosterError ? (
<div className={styles.overlayTitle}>{title}</div>
) : null}
</Link>
</div>
<SeriesIndexProgressBar
monitored={monitored}
status={status}
episodeCount={episodeCount}
episodeFileCount={episodeFileCount}
totalEpisodeCount={totalEpisodeCount}
posterWidth={posterWidth}
detailedProgressBar={detailedProgressBar}
/>
{showTitle ? <div className={styles.title}>{title}</div> : null}
{showMonitored ? (
<div className={styles.title}>
{monitored ? 'Monitored' : 'Unmonitored'}
</div>
) : null}
{showQualityProfile ? (
<div className={styles.title}>{qualityProfile.name}</div>
) : null}
{nextAiring ? (
<div className={styles.nextAiring}>
{getRelativeDate(nextAiring, shortDateFormat, showRelativeDates, {
timeFormat,
timeForToday: true,
})}
</div>
) : null}
<SeriesIndexPosterInfo
seasonCount={seasonCount}
sizeOnDisk={sizeOnDisk}
path={path}
qualityProfile={qualityProfile}
showQualityProfile={showQualityProfile}
showRelativeDates={showRelativeDates}
sortKey={sortKey}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
<EditSeriesModalConnector
isOpen={isEditSeriesModalOpen}
seriesId={seriesId}
onModalClose={onEditSeriesModalClose}
onDeleteSeriesPress={onDeleteSeriesPress}
/>
<DeleteSeriesModal
isOpen={isDeleteSeriesModalOpen}
seriesId={seriesId}
onModalClose={onDeleteSeriesModalClose}
/>
</div>
);
}
export default SeriesIndexPoster;
@@ -1,125 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import styles from './SeriesIndexPosterInfo.css';
function SeriesIndexPosterInfo(props) {
const {
network,
qualityProfile,
showQualityProfile,
previousAiring,
added,
seasonCount,
path,
sizeOnDisk,
sortKey,
showRelativeDates,
shortDateFormat,
timeFormat
} = props;
if (sortKey === 'network' && network) {
return (
<div className={styles.info}>
{network}
</div>
);
}
if (sortKey === 'qualityProfileId' && !showQualityProfile) {
return (
<div className={styles.info}>
{qualityProfile.name}
</div>
);
}
if (sortKey === 'previousAiring' && previousAiring) {
return (
<div className={styles.info}>
{
getRelativeDate(
previousAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
</div>
);
}
if (sortKey === 'added' && added) {
const addedDate = getRelativeDate(
added,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: false
}
);
return (
<div className={styles.info}>
{`Added ${addedDate}`}
</div>
);
}
if (sortKey === 'seasonCount') {
let seasons = '1 season';
if (seasonCount === 0) {
seasons = 'No seasons';
} else if (seasonCount > 1) {
seasons = `${seasonCount} seasons`;
}
return (
<div className={styles.info}>
{seasons}
</div>
);
}
if (sortKey === 'path') {
return (
<div className={styles.info}>
{path}
</div>
);
}
if (sortKey === 'sizeOnDisk') {
return (
<div className={styles.info}>
{formatBytes(sizeOnDisk)}
</div>
);
}
return null;
}
SeriesIndexPosterInfo.propTypes = {
network: PropTypes.string,
showQualityProfile: PropTypes.bool.isRequired,
qualityProfile: PropTypes.object.isRequired,
previousAiring: PropTypes.string,
added: PropTypes.string,
seasonCount: PropTypes.number.isRequired,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number,
sortKey: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default SeriesIndexPosterInfo;
@@ -0,0 +1,94 @@
import React from 'react';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import styles from './SeriesIndexPosterInfo.css';
interface SeriesIndexPosterInfoProps {
network?: string;
showQualityProfile: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
qualityProfile: any;
previousAiring?: string;
added?: string;
seasonCount: number;
path: string;
sizeOnDisk?: number;
sortKey: string;
showRelativeDates: boolean;
shortDateFormat: string;
timeFormat: string;
}
function SeriesIndexPosterInfo(props: SeriesIndexPosterInfoProps) {
const {
network,
qualityProfile,
showQualityProfile,
previousAiring,
added,
seasonCount,
path,
sizeOnDisk,
sortKey,
showRelativeDates,
shortDateFormat,
timeFormat,
} = props;
if (sortKey === 'network' && network) {
return <div className={styles.info}>{network}</div>;
}
if (sortKey === 'qualityProfileId' && !showQualityProfile) {
return <div className={styles.info}>{qualityProfile.name}</div>;
}
if (sortKey === 'previousAiring' && previousAiring) {
return (
<div className={styles.info}>
{getRelativeDate(previousAiring, shortDateFormat, showRelativeDates, {
timeFormat,
timeForToday: true,
})}
</div>
);
}
if (sortKey === 'added' && added) {
const addedDate = getRelativeDate(
added,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: false,
}
);
return <div className={styles.info}>{`Added ${addedDate}`}</div>;
}
if (sortKey === 'seasonCount') {
let seasons = '1 season';
if (seasonCount === 0) {
seasons = 'No seasons';
} else if (seasonCount > 1) {
seasons = `${seasonCount} seasons`;
}
return <div className={styles.info}>{seasons}</div>;
}
if (sortKey === 'path') {
return <div className={styles.info}>{path}</div>;
}
if (sortKey === 'sizeOnDisk') {
return <div className={styles.info}>{formatBytes(sizeOnDisk)}</div>;
}
return null;
}
export default SeriesIndexPosterInfo;
@@ -1,3 +0,0 @@
.grid {
flex: 1 0 auto;
}
@@ -1,333 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Grid, WindowScroller } from 'react-virtualized';
import Measure from 'Components/Measure';
import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector';
import dimensions from 'Styles/Variables/dimensions';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import SeriesIndexPoster from './SeriesIndexPoster';
import styles from './SeriesIndexPosters.css';
// Poster container dimensions
const columnPadding = parseInt(dimensions.seriesIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen);
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
} = 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);
}
switch (sortKey) {
case 'network':
case 'seasons':
case 'previousAiring':
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 SeriesIndexPosters 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,
scrollTop,
isSmallScreen
} = 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))) {
// 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
});
}
}
}
//
// 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
} = this.props;
const {
posterWidth,
posterHeight,
columnCount
} = this.state;
const {
detailedProgressBar,
showTitle,
showMonitored,
showQualityProfile
} = posterOptions;
const series = items[rowIndex * columnCount + columnIndex];
if (!series) {
return null;
}
return (
<div
key={key}
style={{
...style,
padding: this._padding
}}
>
<SeriesIndexItemConnector
key={series.id}
component={SeriesIndexPoster}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
detailedProgressBar={detailedProgressBar}
showTitle={showTitle}
showMonitored={showMonitored}
showQualityProfile={showQualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
style={style}
seriesId={series.id}
qualityProfileId={series.qualityProfileId}
/>
</div>
);
};
//
// Listeners
onMeasure = ({ width }) => {
this.calculateGrid(width, this.props.isSmallScreen);
};
//
// Render
render() {
const {
scroller,
items,
isSmallScreen
} = 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}
scrollToAlignment={'start'}
isScrollingOptOut={true}
/>
</div>
);
}
}
</WindowScroller>
</Measure>
);
}
}
SeriesIndexPosters.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
};
export default SeriesIndexPosters;
@@ -0,0 +1,282 @@
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 SeriesIndexPoster from 'Series/Index/Posters/SeriesIndexPoster';
import Series from 'Series/Series';
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.seriesIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(
dimensions.seriesIndexColumnPaddingSmallScreen
);
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: Series[];
sortKey: string;
}
interface SeriesIndexPostersProps {
items: Series[];
sortKey?: string;
sortDirection?: SortDirection;
jumpToCharacter?: string;
scrollTop?: number;
scrollerRef: React.MutableRefObject<HTMLElement>;
isSmallScreen: boolean;
}
const seriesIndexSelector = createSelector(
(state) => state.seriesIndex.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 series = items[index];
return (
<div
style={{
padding,
...style,
}}
>
<SeriesIndexPoster
seriesId={series.id}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
/>
</div>
);
};
function getWindowScrollTopPosition() {
return document.documentElement.scrollTop || document.body.scrollTop || 0;
}
export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props;
const { posterOptions } = useSelector(seriesIndexSelector);
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,
} = 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);
}
switch (sortKey) {
case 'network':
case 'seasons':
case 'previousAiring':
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 SeriesIndexPosters from './SeriesIndexPosters';
function createMapStateToProps() {
return createSelector(
(state) => state.seriesIndex.posterOptions,
createUISettingsSelector(),
createDimensionsSelector(),
(posterOptions, uiSettings, dimensions) => {
return {
posterOptions,
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
timeFormat: uiSettings.timeFormat,
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
export default connect(createMapStateToProps)(SeriesIndexPosters);
@@ -0,0 +1,8 @@
import { createSelector } from 'reselect';
const selectPosterOptions = createSelector(
(state) => state.seriesIndex.posterOptions,
(posterOptions) => posterOptions
);
export default selectPosterOptions;