1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-26 22:56:23 -04:00
This commit is contained in:
Mark McDowall
2018-01-12 18:01:27 -08:00
committed by Taloth Saldono
parent 99feff549d
commit 5894b4fd95
1183 changed files with 91622 additions and 4978 deletions
@@ -0,0 +1,41 @@
import PropTypes from 'prop-types';
import React from 'react';
import { align } from 'Helpers/Props';
import FilterMenu from 'Components/Menu/FilterMenu';
import SeriesIndexFilterModalConnector from 'Series/Index/SeriesIndexFilterModalConnector';
function SeriesIndexFilterMenu(props) {
const {
selectedFilterKey,
filters,
customFilters,
isDisabled,
onFilterSelect
} = props;
return (
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={isDisabled}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={SeriesIndexFilterModalConnector}
onFilterSelect={onFilterSelect}
/>
);
}
SeriesIndexFilterMenu.propTypes = {
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
};
SeriesIndexFilterMenu.defaultProps = {
showCustomFilters: false
};
export default SeriesIndexFilterMenu;
@@ -0,0 +1,159 @@
import PropTypes from 'prop-types';
import React from 'react';
import { align, sortDirections } from 'Helpers/Props';
import SortMenu from 'Components/Menu/SortMenu';
import MenuContent from 'Components/Menu/MenuContent';
import SortMenuItem from 'Components/Menu/SortMenuItem';
function SeriesIndexSortMenu(props) {
const {
sortKey,
sortDirection,
isDisabled,
onSortSelect
} = props;
return (
<SortMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<MenuContent>
<SortMenuItem
name="status"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Monitored/Status
</SortMenuItem>
<SortMenuItem
name="sortTitle"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Title
</SortMenuItem>
<SortMenuItem
name="network"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Network
</SortMenuItem>
<SortMenuItem
name="qualityProfileId"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Quality Profile
</SortMenuItem>
<SortMenuItem
name="languageProfileId"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Language Profile
</SortMenuItem>
<SortMenuItem
name="nextAiring"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Next Airing
</SortMenuItem>
<SortMenuItem
name="previousAiring"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Previous Airing
</SortMenuItem>
<SortMenuItem
name="added"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Added
</SortMenuItem>
<SortMenuItem
name="seasonCount"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Seasons
</SortMenuItem>
<SortMenuItem
name="episodeProgress"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Episodes
</SortMenuItem>
<SortMenuItem
name="episodeCount"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Episode Count
</SortMenuItem>
<SortMenuItem
name="latestSeason"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Latest Season
</SortMenuItem>
<SortMenuItem
name="path"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Path
</SortMenuItem>
<SortMenuItem
name="sizeOnDisk"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Size on Disk
</SortMenuItem>
</MenuContent>
</SortMenu>
);
}
SeriesIndexSortMenu.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
isDisabled: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired
};
export default SeriesIndexSortMenu;
@@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import React from 'react';
import { align } from 'Helpers/Props';
import ViewMenu from 'Components/Menu/ViewMenu';
import MenuContent from 'Components/Menu/MenuContent';
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
function SeriesIndexViewMenu(props) {
const {
view,
isDisabled,
onViewSelect
} = props;
return (
<ViewMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<MenuContent>
<ViewMenuItem
name="table"
selectedView={view}
onPress={onViewSelect}
>
Table
</ViewMenuItem>
<ViewMenuItem
name="posters"
selectedView={view}
onPress={onViewSelect}
>
Posters
</ViewMenuItem>
<ViewMenuItem
name="overview"
selectedView={view}
onPress={onViewSelect}
>
Overview
</ViewMenuItem>
</MenuContent>
</ViewMenu>
);
}
SeriesIndexViewMenu.propTypes = {
view: PropTypes.string.isRequired,
isDisabled: PropTypes.bool.isRequired,
onViewSelect: PropTypes.func.isRequired
};
export default SeriesIndexViewMenu;
@@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import SeriesIndexOverviewOptionsModalContentConnector from './SeriesIndexOverviewOptionsModalContentConnector';
function SeriesIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SeriesIndexOverviewOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
SeriesIndexOverviewOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SeriesIndexOverviewOptionsModal;
@@ -0,0 +1,305 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
const posterSizeOptions = [
{ key: 'small', value: 'Small' },
{ key: 'medium', value: 'Medium' },
{ key: 'large', value: 'Large' }
];
class SeriesIndexOverviewOptionsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
detailedProgressBar: props.detailedProgressBar,
size: props.size,
showMonitored: props.showMonitored,
showNetwork: props.showNetwork,
showQualityProfile: props.showQualityProfile,
showPreviousAiring: props.showPreviousAiring,
showAdded: props.showAdded,
showSeasonCount: props.showSeasonCount,
showPath: props.showPath,
showSizeOnDisk: props.showSizeOnDisk,
showSearchAction: props.showSearchAction
};
}
componentDidUpdate(prevProps) {
const {
detailedProgressBar,
size,
showMonitored,
showNetwork,
showQualityProfile,
showPreviousAiring,
showAdded,
showSeasonCount,
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 (showNetwork !== prevProps.showNetwork) {
state.showNetwork = showNetwork;
}
if (showQualityProfile !== prevProps.showQualityProfile) {
state.showQualityProfile = showQualityProfile;
}
if (showPreviousAiring !== prevProps.showPreviousAiring) {
state.showPreviousAiring = showPreviousAiring;
}
if (showAdded !== prevProps.showAdded) {
state.showAdded = showAdded;
}
if (showSeasonCount !== prevProps.showSeasonCount) {
state.showSeasonCount = showSeasonCount;
}
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,
showNetwork,
showQualityProfile,
showPreviousAiring,
showAdded,
showSeasonCount,
showPath,
showSizeOnDisk,
showSearchAction
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Overview Options
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>Poster Size</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Detailed Progress Bar</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText="Show text on progess bar"
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Monitored</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Network</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showNetwork"
value={showNetwork}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Previous Airing</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showPreviousAiring"
value={showPreviousAiring}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Date Added</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showAdded"
value={showAdded}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Season Count</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSeasonCount"
value={showSeasonCount}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Path</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showPath"
value={showPath}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Size on Disk</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSizeOnDisk"
value={showSizeOnDisk}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText="Show search button on hover"
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SeriesIndexOverviewOptionsModalContent.propTypes = {
size: PropTypes.string.isRequired,
detailedProgressBar: PropTypes.bool.isRequired,
showMonitored: PropTypes.bool.isRequired,
showNetwork: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
showPreviousAiring: PropTypes.bool.isRequired,
showAdded: PropTypes.bool.isRequired,
showSeasonCount: 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 SeriesIndexOverviewOptionsModalContent;
@@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setSeriesOverviewOption } from 'Store/Actions/seriesIndexActions';
import SeriesIndexOverviewOptionsModalContent from './SeriesIndexOverviewOptionsModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.seriesIndex,
(seriesIndex) => {
return seriesIndex.overviewOptions;
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onChangeOverviewOption(payload) {
dispatch(setSeriesOverviewOption(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexOverviewOptionsModalContent);
@@ -0,0 +1,96 @@
$hoverScale: 1.05;
.container {
&:hover {
.content {
background-color: $tableRowHoverBackgroundColor;
}
}
}
.content {
display: flex;
flex-grow: 1;
}
.poster {
position: relative;
}
.posterContainer {
position: relative;
}
.link {
composes: link from 'Components/Link/Link.css';
display: block;
color: $defaultColor;
&:hover {
color: $defaultColor;
text-decoration: none;
}
}
.ended {
position: absolute;
top: 0;
right: 0;
z-index: 1;
width: 0;
height: 0;
border-width: 0 25px 25px 0;
border-style: solid;
border-color: transparent $dangerColor transparent transparent;
color: $white;
}
.info {
display: flex;
flex: 1 0 1px;
flex-direction: column;
overflow: hidden;
padding-left: 10px;
}
.titleRow {
display: flex;
justify-content: space-between;
flex: 0 0 auto;
margin-bottom: 10px;
line-height: 32px;
}
.title {
@add-mixin truncate;
composes: link;
flex: 1 0 1px;
font-weight: 300;
font-size: 30px;
}
.actions {
white-space: nowrap;
}
.details {
display: flex;
justify-content: space-between;
flex: 1 0 auto;
}
.overview {
composes: link;
flex: 0 1 1000px;
overflow: hidden;
min-height: 0;
}
@media only screen and (max-width: $breakpointSmall) {
.overview {
display: none;
}
}
@@ -0,0 +1,280 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import { icons } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import SeriesPoster from 'Series/SeriesPoster';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
import SeriesIndexOverviewInfo from './SeriesIndexOverviewInfo';
import styles from './SeriesIndexOverview.css';
const columnPadding = parseInt(dimensions.seriesIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen);
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 SeriesIndexOverview extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
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 });
}
//
// Render
render() {
const {
style,
id,
title,
overview,
monitored,
status,
titleSlug,
nextAiring,
statistics,
images,
posterWidth,
posterHeight,
qualityProfile,
overviewOptions,
showSearchAction,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat,
rowHeight,
isSmallScreen,
isRefreshingSeries,
isSearchingSeries,
onRefreshSeriesPress,
onSearchPress,
...otherProps
} = this.props;
const {
seasonCount,
episodeCount,
episodeFileCount,
totalEpisodeCount
} = statistics;
const {
isEditSeriesModalOpen,
isDeleteSeriesModalOpen
} = this.state;
const link = `/series/${titleSlug}`;
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`
};
const contentHeight = getContentHeight(rowHeight, isSmallScreen);
const overviewHeight = contentHeight - titleRowHeight;
return (
<div className={styles.container} style={style}>
<div className={styles.content}>
<div className={styles.poster}>
<div className={styles.posterContainer}>
{
status === 'ended' &&
<div
className={styles.ended}
title="Ended"
/>
}
<Link
className={styles.link}
style={elementStyle}
to={link}
>
<SeriesPoster
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
/>
</Link>
</div>
<SeriesIndexProgressBar
monitored={monitored}
status={status}
episodeCount={episodeCount}
episodeFileCount={episodeFileCount}
totalEpisodeCount={totalEpisodeCount}
posterWidth={posterWidth}
detailedProgressBar={overviewOptions.detailedProgressBar}
/>
</div>
<div className={styles.info} style={{ maxHeight: contentHeight }}>
<div className={styles.titleRow}>
<Link
className={styles.title}
to={link}
>
{title}
</Link>
<div className={styles.actions}>
<SpinnerIconButton
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
name={icons.EDIT}
title="Edit Series"
onPress={this.onEditSeriesPress}
/>
</div>
</div>
<div className={styles.details}>
<Link
className={styles.overview}
to={link}
>
<TextTruncate
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
text={overview}
/>
</Link>
<SeriesIndexOverviewInfo
height={overviewHeight}
monitored={monitored}
nextAiring={nextAiring}
seasonCount={seasonCount}
qualityProfile={qualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
{...overviewOptions}
{...otherProps}
/>
</div>
</div>
</div>
<EditSeriesModalConnector
isOpen={isEditSeriesModalOpen}
seriesId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteSeriesModal
isOpen={isDeleteSeriesModalOpen}
seriesId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</div>
);
}
}
SeriesIndexOverview.propTypes = {
style: PropTypes.object.isRequired,
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
overview: 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,
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,
isRefreshingSeries: PropTypes.bool.isRequired,
isSearchingSeries: PropTypes.bool.isRequired,
onRefreshSeriesPress: PropTypes.func.isRequired
};
SeriesIndexOverview.defaultProps = {
statistics: {
seasonCount: 0,
episodeCount: 0,
episodeFileCount: 0,
totalEpisodeCount: 0
}
};
export default SeriesIndexOverview;
@@ -0,0 +1,12 @@
.infos {
display: flex;
flex: 0 0 250px;
flex-direction: column;
margin-left: 10px;
}
@media only screen and (max-width: $breakpointSmall) {
.infos {
margin-left: 0;
}
}
@@ -0,0 +1,266 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import { icons } from 'Helpers/Props';
import dimensions from 'Styles/Variables/dimensions';
import SeriesIndexOverviewInfoRow from './SeriesIndexOverviewInfoRow';
import styles from './SeriesIndexOverviewInfo.css';
const infoRowHeight = parseInt(dimensions.seriesIndexOverviewInfoRowHeight);
const rows = [
{
name: 'monitored',
showProp: 'showMonitored',
valueProp: 'monitored'
},
{
name: 'network',
showProp: 'showNetwork',
valueProp: 'network'
},
{
name: 'qualityProfileId',
showProp: 'showQualityProfile',
valueProp: 'qualityProfileId'
},
{
name: 'previousAiring',
showProp: 'showPreviousAiring',
valueProp: 'previousAiring'
},
{
name: 'added',
showProp: 'showAdded',
valueProp: 'added'
},
{
name: 'seasonCount',
showProp: 'showSeasonCount',
valueProp: 'seasonCount'
},
{
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 === 'network') {
return {
title: 'Network',
iconName: icons.NETWORK,
label: props.network
};
}
if (name === 'qualityProfileId') {
return {
title: 'Quality Profile',
iconName: icons.PROFILE,
label: props.qualityProfile.name
};
}
if (name === 'previousAiring') {
const {
previousAiring,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat
} = props;
return {
title: `Previous Airing: ${formatDateTime(previousAiring, longDateFormat, timeFormat)}`,
iconName: icons.CALENDAR,
label: getRelativeDate(
previousAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
};
}
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 === 'seasonCount') {
const { seasonCount } = props;
let seasons = '1 season';
if (seasonCount === 0) {
seasons = 'No seasons';
} else if (seasonCount > 1) {
seasons = `${seasonCount} seasons`;
}
return {
title: 'Season Count',
iconName: icons.CIRCLE,
label: seasons
};
}
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 SeriesIndexOverviewInfo(props) {
const {
height,
nextAiring,
showRelativeDates,
shortDateFormat,
longDateFormat,
timeFormat
} = props;
let shownRows = 1;
const maxRows = Math.floor(height / (infoRowHeight + 4));
return (
<div className={styles.infos}>
{
!!nextAiring &&
<SeriesIndexOverviewInfoRow
title={formatDateTime(nextAiring, longDateFormat, timeFormat)}
iconName={icons.SCHEDULED}
label={getRelativeDate(
nextAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)}
/>
}
{
rows.map((row) => {
if (!isVisible(row, props)) {
return null;
}
if (shownRows >= maxRows) {
return null;
}
shownRows++;
const infoRowProps = getInfoRowProps(row, props);
return (
<SeriesIndexOverviewInfoRow
key={row.name}
{...infoRowProps}
/>
);
})
}
</div>
);
}
SeriesIndexOverviewInfo.propTypes = {
height: PropTypes.number.isRequired,
showNetwork: PropTypes.bool.isRequired,
showMonitored: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
showPreviousAiring: PropTypes.bool.isRequired,
showAdded: PropTypes.bool.isRequired,
showSeasonCount: PropTypes.bool.isRequired,
showPath: PropTypes.bool.isRequired,
showSizeOnDisk: PropTypes.bool.isRequired,
monitored: PropTypes.bool.isRequired,
nextAiring: PropTypes.string,
network: PropTypes.string,
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,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
};
export default SeriesIndexOverviewInfo;
@@ -0,0 +1,10 @@
.infoRow {
flex: 0 0 $seriesIndexOverviewInfoRowHeight;
margin: 2px 0;
}
.icon {
margin-right: 5px;
width: 25px !important;
text-align: center;
}
@@ -0,0 +1,35 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import styles from './SeriesIndexOverviewInfoRow.css';
function SeriesIndexOverviewInfoRow(props) {
const {
title,
iconName,
label
} = props;
return (
<div
className={styles.infoRow}
title={title}
>
<Icon
className={styles.icon}
name={iconName}
size={14}
/>
{label}
</div>
);
}
SeriesIndexOverviewInfoRow.propTypes = {
title: PropTypes.string,
iconName: PropTypes.object.isRequired,
label: PropTypes.string.isRequired
};
export default SeriesIndexOverviewInfoRow;
@@ -0,0 +1,3 @@
.grid {
flex: 1 0 auto;
}
@@ -0,0 +1,289 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Grid, WindowScroller } from 'react-virtualized';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import dimensions from 'Styles/Variables/dimensions';
import { sortDirections } from 'Helpers/Props';
import Measure from 'Components/Measure';
import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector';
import SeriesIndexOverview from './SeriesIndexOverview';
import styles from './SeriesIndexOverviews.css';
// Poster container dimensions
const columnPadding = parseInt(dimensions.seriesIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen);
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 SeriesIndexOverviews 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, {})
};
this._isInitialized = false;
this._grid = null;
}
componentDidMount() {
this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
}
componentDidUpdate(prevProps) {
const {
items,
filters,
sortKey,
sortDirection,
overviewOptions,
jumpToCharacter
} = this.props;
const itemsChanged = hasDifferentItems(prevProps.items, items);
const overviewOptionsChanged = !_.isMatch(prevProps.overviewOptions, overviewOptions);
if (
prevProps.sortKey !== sortKey ||
prevProps.overviewOptions !== overviewOptions ||
itemsChanged
) {
this.calculateGrid();
}
if (
prevProps.filters !== filters ||
prevProps.sortKey !== sortKey ||
prevProps.sortDirection !== sortDirection ||
itemsChanged ||
overviewOptionsChanged
) {
this._grid.recomputeGridSize();
}
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (index != null) {
const {
rowHeight
} = this.state;
const scrollTop = rowHeight * index;
this.props.onScroll({ scrollTop });
}
}
}
//
// Control
scrollToFirstCharacter(character) {
const items = this.props.items;
const {
rowHeight
} = this.state;
const index = getIndexOfFirstCharacter(items, character);
if (index != null) {
const scrollTop = rowHeight * index;
this.props.onScroll({ scrollTop });
}
}
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
} = this.props;
const {
posterWidth,
posterHeight,
rowHeight
} = this.state;
const series = items[rowIndex];
if (!series) {
return null;
}
return (
<SeriesIndexItemConnector
key={key}
component={SeriesIndexOverview}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
rowHeight={rowHeight}
overviewOptions={overviewOptions}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
isSmallScreen={isSmallScreen}
style={style}
seriesId={series.id}
languageProfileId={series.languageProfileId}
qualityProfileId={series.qualityProfileId}
/>
);
}
//
// Listeners
onMeasure = ({ width }) => {
this.calculateGrid(width, this.props.isSmallScreen);
}
onSectionRendered = () => {
if (!this._isInitialized && this._contentBodyNode) {
this.props.onRender();
this._isInitialized = true;
}
}
//
// Render
render() {
const {
items,
scrollTop,
isSmallScreen,
onScroll
} = this.props;
const {
width,
rowHeight
} = this.state;
return (
<Measure onMeasure={this.onMeasure}>
<WindowScroller
scrollElement={isSmallScreen ? undefined : this._contentBodyNode}
onScroll={onScroll}
>
{({ height, isScrolling }) => {
return (
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={1}
columnWidth={width}
rowCount={items.length}
rowHeight={rowHeight}
width={width}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
onSectionRendered={this.onSectionRendered}
/>
);
}
}
</WindowScroller>
</Measure>
);
}
}
SeriesIndexOverviews.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
overviewOptions: PropTypes.object.isRequired,
scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string,
contentBody: PropTypes.object.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
timeFormat: PropTypes.string.isRequired,
onRender: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired
};
export default SeriesIndexOverviews;
@@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import SeriesIndexOverviews from './SeriesIndexOverviews';
function createMapStateToProps() {
return createSelector(
(state) => state.seriesIndex.overviewOptions,
createClientSideCollectionSelector('series', 'seriesIndex'),
createUISettingsSelector(),
createDimensionsSelector(),
(overviewOptions, series, uiSettings, dimensions) => {
return {
overviewOptions,
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat,
isSmallScreen: dimensions.isSmallScreen,
...series
};
}
);
}
export default connect(createMapStateToProps)(SeriesIndexOverviews);
@@ -0,0 +1,25 @@
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,213 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
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 progess 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,23 @@
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);
@@ -0,0 +1,102 @@
$hoverScale: 1.05;
.container {
padding: 10px;
}
.content {
transition: all 200ms ease-in;
&:hover {
z-index: 2;
box-shadow: 0 0 12px $black;
transition: all 200ms ease-in;
.controls {
opacity: 0.9;
transition: opacity 200ms linear 150ms;
}
}
}
.posterContainer {
position: relative;
}
.link {
composes: link from 'Components/Link/Link.css';
position: relative;
display: block;
height: 70px;
background-color: $defaultColor;
}
.overlayTitle {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
width: 100%;
height: 100%;
color: $offWhite;
text-align: center;
font-size: 20px;
}
.nextAiring {
background-color: #fafbfc;
text-align: center;
font-size: $smallFontSize;
}
.title {
@add-mixin truncate;
background-color: #fafbfc;
text-align: center;
font-size: $smallFontSize;
}
.ended {
position: absolute;
top: 0;
right: 0;
z-index: 1;
width: 0;
height: 0;
border-width: 0 25px 25px 0;
border-style: solid;
border-color: transparent $dangerColor transparent transparent;
color: $white;
}
.controls {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 3;
border-radius: 4px;
background-color: #4f566f;
color: $white;
font-size: $smallFontSize;
opacity: 0;
transition: opacity 0;
}
.action {
composes: button from 'Components/Link/IconButton.css';
&:hover {
color: #ccc;
}
}
@media only screen and (max-width: $breakpointSmall) {
.container {
padding: 5px;
}
}
@@ -0,0 +1,293 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import SeriesPoster from 'Series/SeriesPoster';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar';
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 {
style,
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
} = statistics;
const {
hasPosterError,
isEditSeriesModalOpen,
isDeleteSeriesModalOpen
} = this.state;
const link = `/series/${titleSlug}`;
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`
};
return (
<div className={styles.container} style={style}>
<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}
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>
</div>
);
}
}
SeriesIndexPoster.propTypes = {
style: PropTypes.object.isRequired,
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,5 @@
.info {
background-color: #fafbfc;
text-align: center;
font-size: $smallFontSize;
}
@@ -0,0 +1,125 @@
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,3 @@
.grid {
flex: 1 0 auto;
}
@@ -0,0 +1,327 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Grid, WindowScroller } from 'react-virtualized';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import dimensions from 'Styles/Variables/dimensions';
import { sortDirections } from 'Helpers/Props';
import Measure from 'Components/Measure';
import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector';
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, {})
};
this._isInitialized = false;
this._grid = null;
}
componentDidMount() {
this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
}
componentDidUpdate(prevProps) {
const {
items,
filters,
sortKey,
sortDirection,
posterOptions,
jumpToCharacter
} = this.props;
const itemsChanged = hasDifferentItems(prevProps.items, items);
if (
prevProps.sortKey !== sortKey ||
prevProps.posterOptions !== posterOptions ||
itemsChanged
) {
this.calculateGrid();
}
if (
prevProps.filters !== filters ||
prevProps.sortKey !== sortKey ||
prevProps.sortDirection !== sortDirection ||
itemsChanged
) {
this._grid.recomputeGridSize();
}
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (index != null) {
const {
columnCount,
rowHeight
} = this.state;
const row = Math.floor(index / columnCount);
const scrollTop = rowHeight * row;
this.props.onScroll({ scrollTop });
}
}
}
//
// Control
setGridRef = (ref) => {
this._grid = ref;
}
calculateGrid = (width = this.state.width, isSmallScreen) => {
const {
sortKey,
posterOptions
} = this.props;
const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen);
const columnCount = Math.max(Math.floor(width / columnWidth), 1);
const posterWidth = columnWidth - padding;
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 (
<SeriesIndexItemConnector
key={key}
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}
languageProfileId={series.languageProfileId}
qualityProfileId={series.qualityProfileId}
/>
);
}
//
// Listeners
onMeasure = ({ width }) => {
this.calculateGrid(width, this.props.isSmallScreen);
}
onSectionRendered = () => {
if (!this._isInitialized && this._contentBodyNode) {
this.props.onRender();
this._isInitialized = true;
}
}
//
// Render
render() {
const {
items,
scrollTop,
isSmallScreen,
onScroll
} = this.props;
const {
width,
columnWidth,
columnCount,
rowHeight
} = this.state;
const rowCount = Math.ceil(items.length / columnCount);
return (
<Measure onMeasure={this.onMeasure}>
<WindowScroller
scrollElement={isSmallScreen ? undefined : this._contentBodyNode}
onScroll={onScroll}
>
{({ height, isScrolling }) => {
return (
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={columnCount}
columnWidth={columnWidth}
rowCount={rowCount}
rowHeight={rowHeight}
width={width}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
onSectionRendered={this.onSectionRendered}
/>
);
}
}
</WindowScroller>
</Measure>
);
}
}
SeriesIndexPosters.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
posterOptions: PropTypes.object.isRequired,
scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string,
contentBody: PropTypes.object.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
timeFormat: PropTypes.string.isRequired,
onRender: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired
};
export default SeriesIndexPosters;
@@ -0,0 +1,27 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import SeriesIndexPosters from './SeriesIndexPosters';
function createMapStateToProps() {
return createSelector(
(state) => state.seriesIndex.posterOptions,
createClientSideCollectionSelector('series', 'seriesIndex'),
createUISettingsSelector(),
createDimensionsSelector(),
(posterOptions, series, uiSettings, dimensions) => {
return {
posterOptions,
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
timeFormat: uiSettings.timeFormat,
isSmallScreen: dimensions.isSmallScreen,
...series
};
}
);
}
export default connect(createMapStateToProps)(SeriesIndexPosters);
@@ -0,0 +1,14 @@
.progress {
composes: container from 'Components/ProgressBar.css';
border-radius: 0;
background-color: #5b5b5b;
color: $white;
transition: width 200ms ease;
}
.progressBar {
composes: progressBar from 'Components/ProgressBar.css';
transition: width 200ms ease;
}
@@ -0,0 +1,47 @@
import PropTypes from 'prop-types';
import React from 'react';
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
import { sizes } from 'Helpers/Props';
import ProgressBar from 'Components/ProgressBar';
import styles from './SeriesIndexProgressBar.css';
function SeriesIndexProgressBar(props) {
const {
monitored,
status,
episodeCount,
episodeFileCount,
totalEpisodeCount,
posterWidth,
detailedProgressBar
} = props;
const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100;
const text = `${episodeFileCount} / ${episodeCount}`;
return (
<ProgressBar
className={styles.progressBar}
containerClassName={styles.progress}
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL}
showText={detailedProgressBar}
text={text}
title={`${episodeFileCount} / ${episodeCount} (Total: ${totalEpisodeCount})`}
width={posterWidth}
/>
);
}
SeriesIndexProgressBar.propTypes = {
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
episodeCount: PropTypes.number.isRequired,
episodeFileCount: PropTypes.number.isRequired,
totalEpisodeCount: PropTypes.number.isRequired,
posterWidth: PropTypes.number.isRequired,
detailedProgressBar: PropTypes.bool.isRequired
};
export default SeriesIndexProgressBar;
+51
View File
@@ -0,0 +1,51 @@
.pageContentBodyWrapper {
display: flex;
flex: 1 0 1px;
overflow: hidden;
}
.contentBody {
composes: contentBody from 'Components/Page/PageContentBody.css';
display: flex;
flex-direction: column;
}
.postersInnerContentBody {
composes: innerContentBody from 'Components/Page/PageContentBody.css';
display: flex;
flex-direction: column;
flex-grow: 1;
/* 5px less padding than normal to handle poster's 5px margin */
padding: calc($pageContentBodyPadding - 5px);
}
.tableInnerContentBody {
composes: innerContentBody from 'Components/Page/PageContentBody.css';
display: flex;
flex-direction: column;
flex-grow: 1;
}
.contentBodyContainer {
display: flex;
flex-direction: column;
flex-grow: 1;
}
@media only screen and (max-width: $breakpointSmall) {
.pageContentBodyWrapper {
flex-basis: auto;
}
.contentBody {
flex-basis: 1px;
}
.postersInnerContentBody {
padding: calc($pageContentBodyPaddingSmallScreen - 5px);
}
}
+369
View File
@@ -0,0 +1,369 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import { align, icons, sortDirections } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageJumpBar from 'Components/Page/PageJumpBar';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import NoSeries from 'Series/NoSeries';
import SeriesIndexTableConnector from './Table/SeriesIndexTableConnector';
import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal';
import SeriesIndexPostersConnector from './Posters/SeriesIndexPostersConnector';
import SeriesIndexOverviewOptionsModal from './Overview/Options/SeriesIndexOverviewOptionsModal';
import SeriesIndexOverviewsConnector from './Overview/SeriesIndexOverviewsConnector';
import SeriesIndexFooter from './SeriesIndexFooter';
import SeriesIndexFilterMenu from './Menus/SeriesIndexFilterMenu';
import SeriesIndexSortMenu from './Menus/SeriesIndexSortMenu';
import SeriesIndexViewMenu from './Menus/SeriesIndexViewMenu';
import styles from './SeriesIndex.css';
function getViewComponent(view) {
if (view === 'posters') {
return SeriesIndexPostersConnector;
}
if (view === 'overview') {
return SeriesIndexOverviewsConnector;
}
return SeriesIndexTableConnector;
}
class SeriesIndex extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
contentBody: null,
jumpBarItems: [],
jumpToCharacter: null,
isPosterOptionsModalOpen: false,
isOverviewOptionsModalOpen: false,
isRendered: false
};
}
componentDidMount() {
this.setJumpBarItems();
}
componentDidUpdate(prevProps) {
const {
items,
sortKey,
sortDirection,
scrollTop
} = this.props;
if (
hasDifferentItems(prevProps.items, items) ||
sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection
) {
this.setJumpBarItems();
}
if (this.state.jumpToCharacter != null && scrollTop !== prevProps.scrollTop) {
this.setState({ jumpToCharacter: null });
}
}
//
// Control
setContentBodyRef = (ref) => {
this.setState({ contentBody: ref });
}
setJumpBarItems() {
const {
items,
sortKey,
sortDirection
} = this.props;
// Reset if not sorting by sortTitle
if (sortKey !== 'sortTitle') {
this.setState({ jumpBarItems: [] });
return;
}
const characters = _.reduce(items, (acc, item) => {
const firstCharacter = item.sortTitle.charAt(0);
if (isNaN(firstCharacter)) {
acc.push(firstCharacter);
} else {
acc.push('#');
}
return acc;
}, []).sort();
// Reverse if sorting descending
if (sortDirection === sortDirections.DESCENDING) {
characters.reverse();
}
this.setState({ jumpBarItems: _.sortedUniq(characters) });
}
//
// Listeners
onPosterOptionsPress = () => {
this.setState({ isPosterOptionsModalOpen: true });
}
onPosterOptionsModalClose = () => {
this.setState({ isPosterOptionsModalOpen: false });
}
onOverviewOptionsPress = () => {
this.setState({ isOverviewOptionsModalOpen: true });
}
onOverviewOptionsModalClose = () => {
this.setState({ isOverviewOptionsModalOpen: false });
}
onJumpBarItemPress = (jumpToCharacter) => {
this.setState({ jumpToCharacter });
}
onRender = () => {
this.setState({ isRendered: true }, () => {
const {
scrollTop,
isSmallScreen
} = this.props;
if (isSmallScreen) {
// Seems to result in the view being off by 125px (distance to the top of the page)
// document.documentElement.scrollTop = document.body.scrollTop = scrollTop;
// This works, but then jumps another 1px after scrolling
document.documentElement.scrollTop = scrollTop;
}
});
}
onScroll = ({ scrollTop }) => {
this.props.onScroll({ scrollTop });
}
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
totalItems,
items,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
view,
isRefreshingSeries,
isRssSyncExecuting,
scrollTop,
onSortSelect,
onFilterSelect,
onViewSelect,
onRefreshSeriesPress,
onRssSyncPress,
...otherProps
} = this.props;
const {
contentBody,
jumpBarItems,
jumpToCharacter,
isPosterOptionsModalOpen,
isOverviewOptionsModalOpen,
isRendered
} = this.state;
const ViewComponent = getViewComponent(view);
const isLoaded = !!(!error && isPopulated && items.length && contentBody);
const hasNoSeries = !totalItems;
return (
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Update all"
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingSeries}
isDisabled={hasNoSeries}
onPress={onRefreshSeriesPress}
/>
<PageToolbarButton
label="RSS Sync"
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoSeries}
onPress={onRssSyncPress}
/>
</PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
collapseButtons={false}
>
{
view === 'posters' &&
<PageToolbarButton
label="Options"
iconName={icons.POSTER}
isDisabled={hasNoSeries}
onPress={this.onPosterOptionsPress}
/>
}
{
view === 'overview' &&
<PageToolbarButton
label="Options"
iconName={icons.OVERVIEW}
isDisabled={hasNoSeries}
onPress={this.onOverviewOptionsPress}
/>
}
{
(view === 'posters' || view === 'overview') &&
<PageToolbarSeparator />
}
<SeriesIndexViewMenu
view={view}
isDisabled={hasNoSeries}
onViewSelect={onViewSelect}
/>
<SeriesIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoSeries}
onSortSelect={onSortSelect}
/>
<SeriesIndexFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoSeries}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBodyConnector
ref={this.setContentBodyRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
scrollTop={isRendered ? scrollTop : 0}
onScroll={this.onScroll}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load series</div>
}
{
isLoaded &&
<div className={styles.contentBodyContainer}>
<ViewComponent
contentBody={contentBody}
scrollTop={scrollTop}
jumpToCharacter={jumpToCharacter}
onRender={this.onRender}
{...otherProps}
/>
<SeriesIndexFooter
series={items}
/>
</div>
}
{
!error && isPopulated && !items.length &&
<NoSeries totalItems={totalItems} />
}
</PageContentBodyConnector>
{
isLoaded && !!jumpBarItems.length &&
<PageJumpBar
items={jumpBarItems}
onItemPress={this.onJumpBarItemPress}
/>
}
</div>
<SeriesIndexPosterOptionsModal
isOpen={isPosterOptionsModalOpen}
onModalClose={this.onPosterOptionsModalClose}
/>
<SeriesIndexOverviewOptionsModal
isOpen={isOverviewOptionsModalOpen}
onModalClose={this.onOverviewOptionsModalClose}
/>
</PageContent>
);
}
}
SeriesIndex.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
totalItems: PropTypes.number.isRequired,
items: 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,
isRefreshingSeries: PropTypes.bool.isRequired,
isRssSyncExecuting: PropTypes.bool.isRequired,
scrollTop: PropTypes.number.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onViewSelect: PropTypes.func.isRequired,
onRefreshSeriesPress: PropTypes.func.isRequired,
onRssSyncPress: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired
};
export default SeriesIndex;
@@ -0,0 +1,164 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import dimensions from 'Styles/Variables/dimensions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { fetchSeries } from 'Store/Actions/seriesActions';
import scrollPositions from 'Store/scrollPositions';
import { setSeriesSort, setSeriesFilter, setSeriesView } from 'Store/Actions/seriesIndexActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import withScrollPosition from 'Components/withScrollPosition';
import SeriesIndex from './SeriesIndex';
const POSTERS_PADDING = 15;
const POSTERS_PADDING_SMALL_SCREEN = 5;
const TABLE_PADDING = parseInt(dimensions.pageContentBodyPadding);
const TABLE_PADDING_SMALL_SCREEN = parseInt(dimensions.pageContentBodyPaddingSmallScreen);
// If the scrollTop is greater than zero it needs to be offset
// by the padding so when it is set initially so it is correct
// after React Virtualized takes the padding into account.
function getScrollTop(view, scrollTop, isSmallScreen) {
if (scrollTop === 0) {
return 0;
}
let padding = isSmallScreen ? TABLE_PADDING_SMALL_SCREEN : TABLE_PADDING;
if (view === 'posters') {
padding = isSmallScreen ? POSTERS_PADDING_SMALL_SCREEN : POSTERS_PADDING;
}
return scrollTop + padding;
}
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('series', 'seriesIndex'),
createCommandExecutingSelector(commandNames.REFRESH_SERIES),
createCommandExecutingSelector(commandNames.RSS_SYNC),
createDimensionsSelector(),
(
series,
isRefreshingSeries,
isRssSyncExecuting,
dimensionsState
) => {
return {
...series,
isRefreshingSeries,
isRssSyncExecuting,
isSmallScreen: dimensionsState.isSmallScreen
};
}
);
}
const mapDispatchToProps = {
fetchSeries,
setSeriesSort,
setSeriesFilter,
setSeriesView,
executeCommand
};
class SeriesIndexConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
view,
scrollTop,
isSmallScreen
} = props;
this.state = {
scrollTop: getScrollTop(view, scrollTop, isSmallScreen)
};
}
componentDidMount() {
this.props.fetchSeries();
}
//
// Listeners
onSortSelect = (sortKey) => {
this.props.setSeriesSort({ sortKey });
}
onFilterSelect = (selectedFilterKey) => {
this.props.setSeriesFilter({ selectedFilterKey });
}
onViewSelect = (view) => {
// Reset the scroll position before changing the view
this.setState({ scrollTop: 0 }, () => {
this.props.setSeriesView({ view });
});
}
onScroll = ({ scrollTop }) => {
this.setState({
scrollTop
}, () => {
scrollPositions.seriesIndex = scrollTop;
});
}
onRefreshSeriesPress = () => {
this.props.executeCommand({
name: commandNames.REFRESH_SERIES
});
}
onRssSyncPress = () => {
this.props.executeCommand({
name: commandNames.RSS_SYNC
});
}
//
// Render
render() {
return (
<SeriesIndex
{...this.props}
scrollTop={this.state.scrollTop}
onSortSelect={this.onSortSelect}
onFilterSelect={this.onFilterSelect}
onViewSelect={this.onViewSelect}
onScroll={this.onScroll}
onRefreshSeriesPress={this.onRefreshSeriesPress}
onRssSyncPress={this.onRssSyncPress}
/>
);
}
}
SeriesIndexConnector.propTypes = {
isSmallScreen: PropTypes.bool.isRequired,
view: PropTypes.string.isRequired,
scrollTop: PropTypes.number.isRequired,
fetchSeries: PropTypes.func.isRequired,
setSeriesSort: PropTypes.func.isRequired,
setSeriesFilter: PropTypes.func.isRequired,
setSeriesView: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default withScrollPosition(
connect(createMapStateToProps, mapDispatchToProps)(SeriesIndexConnector),
'seriesIndex'
);
@@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setSeriesFilter } from 'Store/Actions/seriesIndexActions';
import FilterModal from 'Components/Filter/FilterModal';
function createMapStateToProps() {
return createSelector(
(state) => state.series.items,
(state) => state.seriesIndex.filterBuilderProps,
(sectionItems, filterBuilderProps) => {
return {
sectionItems,
filterBuilderProps,
customFilterType: 'seriesIndex'
};
}
);
}
const mapDispatchToProps = {
dispatchSetFilter: setSeriesFilter
};
export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
@@ -0,0 +1,66 @@
.footer {
display: flex;
flex-wrap: wrap;
margin-top: 20px;
font-size: $smallFontSize;
}
.legendItem {
display: flex;
margin-bottom: 4px;
line-height: 16px;
}
.legendItemColor {
margin-right: 8px;
width: 30px;
height: 16px;
border-radius: 4px;
}
.continuing {
composes: legendItemColor;
background-color: $primaryColor;
}
.ended {
composes: legendItemColor;
background-color: $successColor;
}
.missingMonitored {
composes: legendItemColor;
background-color: $dangerColor;
}
.missingUnmonitored {
composes: legendItemColor;
background-color: $warningColor;
}
.statistics {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
@media (max-width: $breakpointLarge) {
.statistics {
display: block;
}
}
@media (max-width: $breakpointSmall) {
.footer {
display: block;
}
.statistics {
display: flex;
margin-top: 20px;
}
}
@@ -0,0 +1,123 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import styles from './SeriesIndexFooter.css';
function SeriesIndexFooter({ series }) {
const count = series.length;
let episodes = 0;
let episodeFiles = 0;
let ended = 0;
let continuing = 0;
let monitored = 0;
let totalFileSize = 0;
series.forEach((s) => {
const { statistics = {} } = s;
const {
episodeCount = 0,
episodeFileCount = 0,
sizeOnDisk = 0
} = statistics;
episodes += episodeCount;
episodeFiles += episodeFileCount;
if (s.status === 'ended') {
ended++;
} else {
continuing++;
}
if (s.monitored) {
monitored++;
}
totalFileSize += sizeOnDisk;
});
return (
<div className={styles.footer}>
<div>
<div className={styles.legendItem}>
<div className={styles.continuing} />
<div>Continuing (All episodes downloaded)</div>
</div>
<div className={styles.legendItem}>
<div className={styles.ended} />
<div>Ended (All episodes downloaded)</div>
</div>
<div className={styles.legendItem}>
<div className={styles.missingMonitored} />
<div>Missing Episodes (Series monitored)</div>
</div>
<div className={styles.legendItem}>
<div className={styles.missingUnmonitored} />
<div>Missing Episodes (Series not monitored)</div>
</div>
</div>
<div className={styles.statistics}>
<DescriptionList>
<DescriptionListItem
title="Series"
data={count}
/>
<DescriptionListItem
title="Ended"
data={ended}
/>
<DescriptionListItem
title="Continuing"
data={continuing}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Monitored"
data={monitored}
/>
<DescriptionListItem
title="Unmonitored"
data={count - monitored}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Episodes"
data={episodes}
/>
<DescriptionListItem
title="Files"
data={episodeFiles}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Total File Size"
data={formatBytes(totalFileSize)}
/>
</DescriptionList>
</div>
</div>
);
}
SeriesIndexFooter.propTypes = {
series: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default SeriesIndexFooter;
@@ -0,0 +1,125 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { isCommandExecuting } from 'Utilities/Command';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
function selectShowSearchAction() {
return createSelector(
(state) => state.seriesIndex,
(seriesIndex) => {
const view = seriesIndex.view;
switch (view) {
case 'posters':
return seriesIndex.posterOptions.showSearchAction;
case 'overview':
return seriesIndex.overviewOptions.showSearchAction;
default:
return seriesIndex.tableOptions.showSearchAction;
}
}
);
}
function createMapStateToProps() {
return createSelector(
createSeriesSelector(),
createQualityProfileSelector(),
createLanguageProfileSelector(),
selectShowSearchAction(),
createCommandsSelector(),
(
series,
qualityProfile,
languageProfile,
showSearchAction,
commands
) => {
const isRefreshingSeries = commands.some((command) => {
return (
command.name === commandNames.REFRESH_SERIES &&
command.body.seriesId === series.id &&
isCommandExecuting(command)
);
});
const isSearchingSeries = commands.some((command) => {
return (
command.name === commandNames.SERIES_SEARCH &&
command.body.seriesId === series.id &&
isCommandExecuting(command)
);
});
const latestSeason = _.maxBy(series.seasons, (season) => season.seasonNumber);
return {
...series,
qualityProfile,
languageProfile,
latestSeason,
showSearchAction,
isRefreshingSeries,
isSearchingSeries
};
}
);
}
const mapDispatchToProps = {
executeCommand
};
class SeriesIndexItemConnector extends Component {
//
// Listeners
onRefreshSeriesPress = () => {
this.props.executeCommand({
name: commandNames.REFRESH_SERIES,
seriesId: this.props.id
});
}
onSearchPress = () => {
this.props.executeCommand({
name: commandNames.SERIES_SEARCH,
seriesId: this.props.id
});
}
//
// Render
render() {
const {
component: ItemComponent,
...otherProps
} = this.props;
return (
<ItemComponent
{...otherProps}
onRefreshSeriesPress={this.onRefreshSeriesPress}
onSearchPress={this.onSearchPress}
/>
);
}
}
SeriesIndexItemConnector.propTypes = {
id: PropTypes.number.isRequired,
component: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SeriesIndexItemConnector);
@@ -0,0 +1,102 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
class SeriesIndexActionsCell extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
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 });
}
//
// Render
render() {
const {
id,
isRefreshingSeries,
onRefreshSeriesPress,
...otherProps
} = this.props;
const {
isEditSeriesModalOpen,
isDeleteSeriesModalOpen
} = this.state;
return (
<VirtualTableRowCell
{...otherProps}
>
<SpinnerIconButton
name={icons.REFRESH}
title="Refresh series"
isSpinning={isRefreshingSeries}
onPress={onRefreshSeriesPress}
/>
<IconButton
name={icons.EDIT}
title="Edit Series"
onPress={this.onEditSeriesPress}
/>
<EditSeriesModalConnector
isOpen={isEditSeriesModalOpen}
seriesId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteSeriesModal
isOpen={isDeleteSeriesModalOpen}
seriesId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</VirtualTableRowCell>
);
}
}
SeriesIndexActionsCell.propTypes = {
id: PropTypes.number.isRequired,
isRefreshingSeries: PropTypes.bool.isRequired,
onRefreshSeriesPress: PropTypes.func.isRequired
};
export default SeriesIndexActionsCell;
@@ -0,0 +1,89 @@
.status {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 60px;
}
.sortTitle {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 4 0 110px;
}
.network {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 2 0 90px;
}
.qualityProfileId,
.languageProfileId {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 1 0 125px;
}
.nextAiring,
.previousAiring,
.added,
.genres {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 180px;
}
.seasonCount,
.certification {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 100px;
}
.episodeProgress,
.latestSeason {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 150px;
}
.episodeCount {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 130px;
}
.path {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 1 0 150px;
}
.sizeOnDisk {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 120px;
}
.ratings {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 80px;
}
.tags {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 1 0 60px;
}
.useSceneNumbering {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 145px;
}
.actions {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 90px;
}
@@ -0,0 +1,109 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal';
import SeriesIndexTableOptionsConnector from './SeriesIndexTableOptionsConnector';
import styles from './SeriesIndexHeader.css';
class SeriesIndexHeader 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 {
showSearchAction,
columns,
onTableOptionChange,
...otherProps
} = this.props;
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}
{...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={SeriesIndexTableOptionsConnector}
onTableOptionChange={onTableOptionChange}
onModalClose={this.onTableOptionsModalClose}
/>
</VirtualTableHeader>
);
}
}
SeriesIndexHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired
};
export default SeriesIndexHeader;
@@ -0,0 +1,13 @@
import { connect } from 'react-redux';
import { setSeriesTableOption } from 'Store/Actions/seriesIndexActions';
import SeriesIndexHeader from './SeriesIndexHeader';
function createMapDispatchToProps(dispatch, props) {
return {
onTableOptionChange(payload) {
dispatch(setSeriesTableOption(payload));
}
};
}
export default connect(undefined, createMapDispatchToProps)(SeriesIndexHeader);
@@ -0,0 +1,138 @@
.cell {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
display: flex;
align-items: center;
}
.status {
composes: cell;
flex: 0 0 60px;
}
.sortTitle {
composes: cell;
flex: 4 0 110px;
}
.banner {
flex: 0 0 379px;
}
.link {
composes: link from 'Components/Link/Link.css';
position: relative;
display: block;
height: 70px;
background-color: $defaultColor;
}
.bannerImage {
width: 379px;
height: 70px;
}
.overlayTitle {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
width: 100%;
height: 100%;
color: $offWhite;
text-align: center;
font-size: 20px;
}
.network {
composes: cell;
flex: 2 0 90px;
}
.qualityProfileId,
.languageProfileId {
composes: cell;
flex: 1 0 125px;
}
.nextAiring,
.previousAiring,
.added,
.genres {
composes: cell;
flex: 0 0 180px;
}
.seasonCount,
.certification {
composes: cell;
flex: 0 0 100px;
}
.episodeProgress,
.latestSeason {
composes: cell;
display: flex;
justify-content: center;
flex: 0 0 150px;
flex-direction: column;
}
.episodeCount {
composes: cell;
flex: 0 0 130px;
}
.path {
composes: cell;
flex: 1 0 150px;
}
.sizeOnDisk {
composes: cell;
flex: 0 0 120px;
}
.ratings {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 80px;
}
.tags {
composes: cell;
flex: 1 0 60px;
}
.useSceneNumbering {
composes: cell;
flex: 0 0 145px;
}
.actions {
composes: cell;
flex: 0 1 90px;
}
.checkInput {
composes: input from 'Components/Form/CheckInput.css';
margin-top: 0;
}
@@ -0,0 +1,515 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
import formatBytes from 'Utilities/Number/formatBytes';
import { icons } from 'Helpers/Props';
import HeartRating from 'Components/HeartRating';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar';
import TagListConnector from 'Components/TagListConnector';
import CheckInput from 'Components/Form/CheckInput';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
import SeriesBanner from 'Series/SeriesBanner';
import SeriesStatusCell from './SeriesStatusCell';
import styles from './SeriesIndexRow.css';
class SeriesIndexRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
hasBannerError: false,
isEditSeriesModalOpen: false,
isDeleteSeriesModalOpen: false
};
}
onEditSeriesPress = () => {
this.setState({ isEditSeriesModalOpen: true });
}
onEditSeriesModalClose = () => {
this.setState({ isEditSeriesModalOpen: false });
}
onDeleteSeriesPress = () => {
this.setState({
isEditSeriesModalOpen: false,
isDeleteSeriesModalOpen: true
});
}
onDeleteSeriesModalClose = () => {
this.setState({ isDeleteSeriesModalOpen: false });
}
onUseSceneNumberingChange = () => {
// Mock handler to satisfy `onChange` being required for `CheckInput`.
//
}
onBannerLoad = () => {
if (this.state.hasBannerError) {
this.setState({ hasBannerError: false });
}
}
onBannerLoadError = () => {
if (!this.state.hasBannerError) {
this.setState({ hasBannerError: true });
}
}
//
// Render
render() {
const {
style,
id,
monitored,
status,
title,
titleSlug,
network,
qualityProfile,
languageProfile,
nextAiring,
previousAiring,
added,
statistics,
latestSeason,
path,
genres,
ratings,
certification,
tags,
images,
useSceneNumbering,
showBanners,
showSearchAction,
columns,
isRefreshingSeries,
isSearchingSeries,
onRefreshSeriesPress,
onSearchPress
} = this.props;
const {
seasonCount,
episodeCount,
episodeFileCount,
totalEpisodeCount,
sizeOnDisk
} = statistics;
const {
hasBannerError,
isEditSeriesModalOpen,
isDeleteSeriesModalOpen
} = this.state;
return (
<VirtualTableRow style={style}>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<SeriesStatusCell
key={name}
className={styles[name]}
monitored={monitored}
status={status}
component={VirtualTableRowCell}
/>
);
}
if (name === 'sortTitle') {
return (
<VirtualTableRowCell
key={name}
className={classNames(
styles[name],
showBanners && styles.banner
)}
>
{
showBanners ?
<Link
className={styles.link}
to={`/series/${titleSlug}`}
>
<SeriesBanner
className={styles.bannerImage}
images={images}
lazy={false}
overflow={true}
onError={this.onBannerLoadError}
onLoad={this.onBannerLoad}
/>
{
hasBannerError &&
<div className={styles.overlayTitle}>
{title}
</div>
}
</Link> :
<SeriesTitleLink
titleSlug={titleSlug}
title={title}
/>
}
</VirtualTableRowCell>
);
}
if (name === 'network') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{network}
</VirtualTableRowCell>
);
}
if (name === 'qualityProfileId') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{qualityProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'languageProfileId') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{languageProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'nextAiring') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={nextAiring}
component={VirtualTableRowCell}
/>
);
}
if (name === 'previousAiring') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={previousAiring}
component={VirtualTableRowCell}
/>
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={added}
component={VirtualTableRowCell}
/>
);
}
if (name === 'seasonCount') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{seasonCount}
</VirtualTableRowCell>
);
}
if (name === 'episodeProgress') {
const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100;
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<ProgressBar
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
showText={true}
text={`${episodeFileCount} / ${episodeCount}`}
title={`${episodeFileCount} / ${episodeCount} (Total: ${totalEpisodeCount})`}
width={125}
/>
</VirtualTableRowCell>
);
}
if (name === 'latestSeason') {
if (!latestSeason) {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
/>
);
}
const seasonStatistics = latestSeason.statistics;
const progress = seasonStatistics.episodeCount ? seasonStatistics.episodeFileCount / seasonStatistics.episodeCount * 100 : 100;
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<ProgressBar
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
showText={true}
text={`${seasonStatistics.episodeFileCount} / ${seasonStatistics.episodeCount}`}
title={`${seasonStatistics.episodeFileCount} / ${seasonStatistics.episodeCount} (Total: ${seasonStatistics.totalEpisodeCount})`}
width={125}
/>
</VirtualTableRowCell>
);
}
if (name === 'episodeCount') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{totalEpisodeCount}
</VirtualTableRowCell>
);
}
if (name === 'path') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{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 === 'ratings') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<HeartRating
rating={ratings.value}
/>
</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 === 'useSceneNumbering') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<CheckInput
className={styles.checkInput}
name="useSceneNumbering"
value={useSceneNumbering}
isDisabled={true}
onChange={this.onUseSceneNumberingChange}
/>
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<SpinnerIconButton
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
name={icons.EDIT}
title="Edit Series"
onPress={this.onEditSeriesPress}
/>
</VirtualTableRowCell>
);
}
return null;
})
}
<EditSeriesModalConnector
isOpen={isEditSeriesModalOpen}
seriesId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteSeriesModal
isOpen={isDeleteSeriesModalOpen}
seriesId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</VirtualTableRow>
);
}
}
SeriesIndexRow.propTypes = {
style: PropTypes.object.isRequired,
id: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
network: PropTypes.string,
qualityProfile: PropTypes.object.isRequired,
languageProfile: PropTypes.object.isRequired,
nextAiring: PropTypes.string,
previousAiring: PropTypes.string,
added: PropTypes.string,
statistics: PropTypes.object.isRequired,
latestSeason: PropTypes.object,
path: PropTypes.string.isRequired,
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
ratings: PropTypes.object.isRequired,
certification: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
useSceneNumbering: PropTypes.bool.isRequired,
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isRefreshingSeries: PropTypes.bool.isRequired,
isSearchingSeries: PropTypes.bool.isRequired,
onRefreshSeriesPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
};
SeriesIndexRow.defaultProps = {
statistics: {
seasonCount: 0,
episodeCount: 0,
episodeFileCount: 0,
totalEpisodeCount: 0
},
genres: [],
tags: []
};
export default SeriesIndexRow;
@@ -0,0 +1,5 @@
.tableContainer {
composes: tableContainer from 'Components/Table/VirtualTable.css';
flex: 1 0 auto;
}
@@ -0,0 +1,131 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
import { sortDirections } from 'Helpers/Props';
import VirtualTable from 'Components/Table/VirtualTable';
import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector';
import SeriesIndexHeaderConnector from './SeriesIndexHeaderConnector';
import SeriesIndexRow from './SeriesIndexRow';
import styles from './SeriesIndexTable.css';
class SeriesIndexTable extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
scrollIndex: null
};
}
componentDidUpdate(prevProps) {
const jumpToCharacter = this.props.jumpToCharacter;
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const items = this.props.items;
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,
showBanners
} = this.props;
const series = items[rowIndex];
return (
<SeriesIndexItemConnector
key={key}
component={SeriesIndexRow}
style={style}
columns={columns}
seriesId={series.id}
languageProfileId={series.languageProfileId}
qualityProfileId={series.qualityProfileId}
showBanners={showBanners}
/>
);
}
//
// Render
render() {
const {
items,
columns,
filters,
sortKey,
sortDirection,
showBanners,
isSmallScreen,
scrollTop,
contentBody,
onSortPress,
onRender,
onScroll
} = this.props;
return (
<VirtualTable
className={styles.tableContainer}
items={items}
scrollTop={scrollTop}
scrollIndex={this.state.scrollIndex}
contentBody={contentBody}
isSmallScreen={isSmallScreen}
rowHeight={showBanners ? 70 : 38}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<SeriesIndexHeaderConnector
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
/>
}
columns={columns}
filters={filters}
sortKey={sortKey}
sortDirection={sortDirection}
onRender={onRender}
onScroll={onScroll}
/>
);
}
}
SeriesIndexTable.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
showBanners: PropTypes.bool.isRequired,
scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string,
contentBody: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onSortPress: PropTypes.func.isRequired,
onRender: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired
};
export default SeriesIndexTable;
@@ -0,0 +1,29 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { setSeriesSort } from 'Store/Actions/seriesIndexActions';
import SeriesIndexTable from './SeriesIndexTable';
function createMapStateToProps() {
return createSelector(
(state) => state.app.dimensions,
createClientSideCollectionSelector('series', 'seriesIndex'),
(dimensions, series) => {
return {
isSmallScreen: dimensions.isSmallScreen,
...series,
showBanners: series.tableOptions.showBanners
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSortPress(sortKey) {
dispatch(setSeriesSort({ sortKey }));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexTable);
@@ -0,0 +1,100 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import { inputTypes } from 'Helpers/Props';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
class SeriesIndexTableOptions extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
showBanners: props.showBanners,
showSearchAction: props.showSearchAction
};
}
componentDidUpdate(prevProps) {
const {
showBanners,
showSearchAction
} = this.props;
if (
showBanners !== prevProps.showBanners ||
showSearchAction !== prevProps.showSearchAction
) {
this.setState({
showBanners,
showSearchAction
});
}
}
//
// Listeners
onTableOptionChange = ({ name, value }) => {
this.setState({
[name]: value
}, () => {
this.props.onTableOptionChange({
tableOptions: {
...this.state,
[name]: value
}
});
});
}
//
// Render
render() {
const {
showBanners,
showSearchAction
} = this.state;
return (
<Fragment>
<FormGroup>
<FormLabel>Show Banners</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showBanners"
value={showBanners}
helpText="Show banners instead of titles"
onChange={this.onTableOptionChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Show Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText="Show search button on hover"
onChange={this.onTableOptionChange}
/>
</FormGroup>
</Fragment>
);
}
}
SeriesIndexTableOptions.propTypes = {
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired
};
export default SeriesIndexTableOptions;
@@ -0,0 +1,14 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import SeriesIndexTableOptions from './SeriesIndexTableOptions';
function createMapStateToProps() {
return createSelector(
(state) => state.seriesIndex.tableOptions,
(tableOptions) => {
return tableOptions;
}
);
}
export default connect(createMapStateToProps)(SeriesIndexTableOptions);
@@ -0,0 +1,9 @@
.status {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 60px;
}
.statusIcon {
width: 20px !important;
}
@@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
import styles from './SeriesStatusCell.css';
function SeriesStatusCell(props) {
const {
className,
monitored,
status,
component: Component,
...otherProps
} = props;
return (
<Component
className={className}
{...otherProps}
>
<Icon
className={styles.statusIcon}
name={monitored ? icons.MONITORED : icons.UNMONITORED}
title={monitored ? 'Series is monitored' : 'Series is unmonitored'}
/>
<Icon
className={styles.statusIcon}
name={status === 'ended' ? icons.SERIES_ENDED : icons.SERIES_CONTINUING}
title={status === 'ended' ? 'Ended' : 'Continuing'}
/>
</Component>
);
}
SeriesStatusCell.propTypes = {
className: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
component: PropTypes.func
};
SeriesStatusCell.defaultProps = {
className: styles.status,
component: VirtualTableRowCell
};
export default SeriesStatusCell;