Initial Commit Rework

This commit is contained in:
Qstick
2017-09-03 22:20:56 -04:00
parent 74a4cc048c
commit 95051cbd63
2483 changed files with 101351 additions and 111396 deletions
+20
View File
@@ -0,0 +1,20 @@
import PropTypes from 'prop-types';
import React from 'react';
import Link from 'Components/Link/Link';
function ArtistNameLink({ nameSlug, artistName }) {
const link = `/series/${nameSlug}`;
return (
<Link to={link}>
{artistName}
</Link>
);
}
ArtistNameLink.propTypes = {
nameSlug: PropTypes.string.isRequired,
artistName: PropTypes.string.isRequired
};
export default ArtistNameLink;
+160
View File
@@ -0,0 +1,160 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LazyLoad from 'react-lazyload';
const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3AgMAAAD0/fcFAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAGWklEQVRo3u2Zv2/TQBTHj4eQnKsY2S1QJHDE2J2RkQHHUVWVjIipY5QBnTpV3bNblSq5RqFTFyQE/4T3iiliZ2GgvLt3d8/ncxNEV7+lbvvJ87vvu1/vRQw22GCDDTbYYIPFJutvf+ryX8hPubGfO0G4yq39Vv/i85/8Zk3OVm0j4Tpv2dG2EC7zwD5scepeb38Wd7t96dyt7M/FTqeFQwu1y+kRotvdeuDAP037yb3c2dKj+U0vOmaRGJ31oj5Rt3V9656LPnKUk72r6/O6rn/ZX+f97yeygnMlPDu7+/0/SyFPUE75iSPoH/+yFISKbM0a9Olf1BoCkyQbwuIO/ZcaqgiFq/4sAI2pFIyKjNyqXql+m+fSouKqV66xj1RAKRFVfvnM+kL9aN4vvVf42hMsNLxGpXKomPRM2ofmbyWNRjg0lcbD9wB9bKYpEc8ZFRc52nE8qg09Vy30UzyuFb9flC1UNtG4WjsEILrSWvAeEo1qafyAVIjmRYqPkIh1d1wjGyrg8xeUa4XvAJZr3h4VhzpC57Dy/5dNZ1z7FKr22mwsWnCwh4EAFCqgwJVGWc61l4Bn4Hv6UFES6oAXfh6yACUtm6ki1EUrQwlGZlJQ0EeMHvqJNA9mwNEpLdul8GiRunEdB1otE3x4kDPqnK2tWqzVIqHPbBgl4qUNhbW6SeihZJQ0mLgHF3lRPjWiFRUIcaJREikVZ01rIYBL68hOL3Do3EnAqEUeddE3JAGjI42amEkARt/q3zQ6Z5QG23TRgqQk1MmxsPndMEoZTey/OQPvrfcqROd2wsxa6A3ltygdyuPaMyhnoMQ37WsBUkYtIvWoGS0Uok80Gnp9a4WdMjpFlAWov/1qSQArj0JjUXcDwKPoqrGsRQvFyQKRkFaY9Brt2y3ZmUhNDhg9MJOV5lUdWikuCLXKL1kr6KDK5OC7Rxc8WTLnjEy20ZFN1ph2WOfNoZSueYDu6zgiNAtRyusToxVwjMYqyuzcL+0fAjRaKSEdWqHSaOeE0vJ+ZEVqjAAZo3ZjFpLmDaMJ5qyEAE2F2evPPDq2KGnFghpUKvI68yj94UBEaEbpZLQgdCkAGFUaneB/TpoO+tAcnAGKtsY0QQ+6Qd8hChd96APUCiL0+nUHnRKqAlRrLVevaG4Tuu9Q1CpCG4sehuhBjE7yGFWILjsBpEJN8tci7XpNH6BWSYAq1Cr2WgLOxQ0o2UIr9HeRv0rFSTfWEc6rMFZEr0OvY4tOe9CG0E4KRrRhB3NAajTO1t5BjE5wqXanS4lzYCm6qCA08gqbGMWTUHXQXNHahHBq6y3o1E9tXjCgBWijdA6J9oJ5xCjQiiWj647oWbFShWhl7jhFwihtGTcKZCoUqESy1yTDHT4RKW8ZIzquMmHsrCXApdnZYdLd3ujEEmlLgDWdG5cxSsYoXNDIaSfkrRgcKv0agGsazjrcteN9nS55c4/ysSE7KJBIryhneeswEtl5fUv2x22ZE+2N7w58xAnhTsDfiLoCWPERxwcnl3Hv/iBJMRYi4YMTzX3qCZH0QXrxNDqOaYcfRygf8uHVIXncRhVorY4xgEnPLWMUes16LiQv7SH7zKO4rq1WUqTta87IoAlJwF7XweWJ0SONPumifCULLnqIPmKUJ4tNFqOFRkddlK+PwaUUVwY49NRqpdBB5i6lfNUVSj+w18zmaC8oDPatBGrsULNh5zMFIgkv0GODoj1wKAnwKr6WPzYS2KgZLYTT6ri3hCDUCHDoS6N5XJjAnkfxALjhwqSv3GkcKpsiLne4iKKwCc3yBRdRcWlGbgmdoNOUS7O+gu+FRddzRF3BF5eRJA6hnwWiXEbGxSmFgCgoQqk4jUre0pX6iEoVlLxRIZ0mCJwigGiZqERwIR2V5+Avopniind2Z9FvvD6nB1f0x60ERmm3jVoJfkptwKMlNxgL1df2UA4FFbY94mZK5VAcWNxMiVs0jPa3aMA2syyamUFx46e3nUSo1grWdzT1XrJbRHXQNS20xZbWlwI4LRXIqPUVNdTQIZwobqhta9PVCs7KjNt0W5t/9VnQ/LtPS5EiIH0udzQq9xhd72h/ipVHL/zTrlbt9HpXqxYahzbcAN7hdrrLKbst2On9W+BxY/3+7fr4S4D/+Grh/l9Y8Ncggw022GCDDTbYYPexvyOoQXprv7w6AAAAAElFTkSuQmCC';
function findPoster(images) {
return _.find(images, { coverType: 'poster' });
}
function getPosterUrl(poster, size) {
if (poster) {
// Remove protocol
let url = poster.url.replace(/^https?:/, '');
url = url.replace('poster.jpg', `poster-${size}.jpg`);
return url;
}
}
class ArtistPoster extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const pixelRatio = Math.floor(window.devicePixelRatio);
const {
images,
size
} = props;
const poster = findPoster(images);
this.state = {
pixelRatio,
poster,
posterUrl: getPosterUrl(poster, pixelRatio * size),
hasError: false,
isLoaded: false
};
}
componentDidUpdate(prevProps) {
const {
images,
size
} = this.props;
const {
pixelRatio
} = this.state;
const poster = findPoster(images);
if (poster && poster.url !== this.state.poster.url) {
this.setState({
poster,
posterUrl: getPosterUrl(poster, pixelRatio * size),
hasError: false,
isLoaded: false
});
}
}
//
// Listeners
onError = () => {
this.setState({ hasError: true });
}
onLoad = () => {
this.setState({ isLoaded: true });
}
//
// Render
render() {
const {
className,
style,
size,
lazy,
overflow
} = this.props;
const {
posterUrl,
hasError,
isLoaded
} = this.state;
if (hasError || !posterUrl) {
return (
<img
className={className}
style={style}
src={posterPlaceholder}
/>
);
}
if (lazy) {
return (
<LazyLoad
height={size}
offset={100}
overflow={overflow}
placeholder={
<img
className={className}
style={style}
src={posterPlaceholder}
/>
}
>
<img
className={className}
style={style}
src={posterUrl}
onError={this.onError}
/>
</LazyLoad>
);
}
return (
<img
className={className}
style={style}
src={isLoaded ? posterUrl : posterPlaceholder}
onError={this.onError}
onLoad={this.onLoad}
/>
);
}
}
ArtistPoster.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
size: PropTypes.number.isRequired,
lazy: PropTypes.bool.isRequired,
overflow: PropTypes.bool.isRequired
};
ArtistPoster.defaultProps = {
size: 250,
lazy: true,
overflow: false
};
export default ArtistPoster;
@@ -0,0 +1,39 @@
@import "Content/icons";
.delete-series-modal {
i {
margin-right : 5px;
//.fa-icon-color(white);
}
.path {
white-space : nowrap;
font-size : 16px;
padding-bottom : 20px;
}
.delete-files-info,
.delete-label {
color : @brand-danger-dark;
}
.delete-files-info {
display : none;
}
.checkbox {
display : inline-block;
}
.c-checkbox:hover .check {
border-color : @brand-danger-dark;
}
input[type=checkbox]:checked + span {
background-color : @brand-danger-dark;
border-color : @brand-danger-dark;
}
}
@@ -0,0 +1,33 @@
import PropTypes from 'prop-types';
import React from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal';
import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector';
function DeleteArtistModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={onModalClose}
>
<DeleteArtistModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
DeleteArtistModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default DeleteArtistModal;
@@ -0,0 +1,12 @@
.pathContainer {
margin-bottom: 20px;
}
.pathIcon {
margin-right: 8px;
}
.deleteFilesMessage {
margin-top: 20px;
color: $dangerColor;
}
@@ -0,0 +1,139 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Icon from 'Components/Icon';
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';
import styles from './DeleteArtistModalContent.css';
class DeleteArtistModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
deleteFiles: false
};
}
//
// Listeners
onDeleteFilesChange = ({ value }) => {
this.setState({ deleteFiles: value });
}
onDeleteSeriesConfirmed = () => {
const deleteFiles = this.state.deleteFiles;
this.setState({ deleteFiles: false });
this.props.onDeletePress(deleteFiles);
}
//
// Render
render() {
const {
artistName,
path,
trackFileCount,
sizeOnDisk,
onModalClose
} = this.props;
const deleteFiles = this.state.deleteFiles;
let deleteFilesLabel = `Delete ${trackFileCount} Track Files`;
let deleteFilesHelpText = 'Delete the track files and artist folder';
if (trackFileCount === 0) {
deleteFilesLabel = 'Delete Artist Folder';
deleteFilesHelpText = 'Delete the artist folder and it\'s contents';
}
return (
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
Delete - {artistName}
</ModalHeader>
<ModalBody>
<div className={styles.pathContainer}>
<Icon
className={styles.pathIcon}
name={icons.FOLDER}
/>
{path}
</div>
<FormGroup>
<FormLabel>{deleteFilesLabel}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={deleteFilesHelpText}
kind={kinds.DANGER}
onChange={this.onDeleteFilesChange}
/>
</FormGroup>
{
deleteFiles &&
<div className={styles.deleteFilesMessage}>
<div>The artist folder <strong>{path}</strong> and all it's content will be deleted.</div>
{
!!trackFileCount &&
<div>{trackFileCount} track files totaling {formatBytes(sizeOnDisk)}</div>
}
</div>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onDeleteSeriesConfirmed}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
);
}
}
DeleteArtistModalContent.propTypes = {
artistName: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
trackFileCount: PropTypes.number.isRequired,
sizeOnDisk: PropTypes.number.isRequired,
onDeletePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
DeleteArtistModalContent.defaultProps = {
trackFileCount: 0
};
export default DeleteArtistModalContent;
@@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import { deleteArtist } from 'Store/Actions/seriesActions';
import DeleteArtistModalContent from './DeleteArtistModalContent';
function createMapStateToProps() {
return createSelector(
createArtistSelector(),
(series) => {
return series;
}
);
}
const mapDispatchToProps = {
deleteArtist
};
class DeleteArtistModalContentConnector extends Component {
//
// Listeners
onDeletePress = (deleteFiles) => {
this.props.deleteArtist({
id: this.props.artistId,
deleteFiles
});
this.props.onModalClose(true);
}
//
// Render
render() {
return (
<DeleteArtistModalContent
{...this.props}
onDeletePress={this.onDeletePress}
/>
);
}
}
DeleteArtistModalContentConnector.propTypes = {
artistId: PropTypes.number.isRequired,
onModalClose: PropTypes.func.isRequired,
deleteArtist: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DeleteArtistModalContentConnector);
@@ -0,0 +1,26 @@
.title {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
white-space: nowrap;
}
.monitored {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 42px;
}
.episodeNumber {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 50px;
}
.language,
.audio,
.video,
.status {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 100px;
}
+266
View File
@@ -0,0 +1,266 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector';
import EpisodeNumber from 'Episode/EpisodeNumber';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector';
import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector';
import MediaInfoConnector from 'EpisodeFile/MediaInfoConnector';
import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes';
import styles from './EpisodeRow.css';
class EpisodeRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onManualSearchPress = () => {
this.setState({ isDetailsModalOpen: true });
}
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
}
onMonitorEpisodePress = (monitored, options) => {
this.props.onMonitorEpisodePress(this.props.id, monitored, options);
}
//
// Render
render() {
const {
id,
artistId,
episodeFileId,
monitored,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
sceneSeasonNumber,
sceneEpisodeNumber,
sceneAbsoluteEpisodeNumber,
airDateUtc,
title,
unverifiedSceneNumbering,
isSaving,
seriesMonitored,
seriesType,
episodeFilePath,
episodeFileRelativePath,
alternateTitles,
columns
} = this.props;
return (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'monitored') {
return (
<TableRowCell
key={name}
className={styles.monitored}
>
<MonitorToggleButton
monitored={monitored}
isDisabled={!seriesMonitored}
isSaving={isSaving}
onPress={this.onMonitorEpisodePress}
/>
</TableRowCell>
);
}
if (name === 'episodeNumber') {
return (
<TableRowCell
key={name}
className={styles.episodeNumber}
>
<EpisodeNumber
seasonNumber={seasonNumber}
episodeNumber={episodeNumber}
absoluteEpisodeNumber={absoluteEpisodeNumber}
unverifiedSceneNumbering={unverifiedSceneNumbering}
seriesType={seriesType}
sceneSeasonNumber={sceneSeasonNumber}
sceneEpisodeNumber={sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={sceneAbsoluteEpisodeNumber}
alternateTitles={alternateTitles}
/>
</TableRowCell>
);
}
if (name === 'title') {
return (
<TableRowCell
key={name}
className={styles.title}
>
<EpisodeTitleLink
episodeId={id}
artistId={artistId}
episodeTitle={title}
showOpenSeriesButton={false}
/>
</TableRowCell>
);
}
if (name === 'path') {
return (
<TableRowCell key={name}>
{
episodeFilePath
}
</TableRowCell>
);
}
if (name === 'relativePath') {
return (
<TableRowCell key={name}>
{
episodeFileRelativePath
}
</TableRowCell>
);
}
if (name === 'airDateUtc') {
return (
<RelativeDateCellConnector
key={name}
date={airDateUtc}
/>
);
}
if (name === 'language') {
return (
<TableRowCell
key={name}
className={styles.language}
>
<EpisodeFileLanguageConnector
episodeFileId={episodeFileId}
/>
</TableRowCell>
);
}
if (name === 'audioInfo') {
return (
<TableRowCell
key={name}
className={styles.audio}
>
<MediaInfoConnector
type={mediaInfoTypes.AUDIO}
episodeFileId={episodeFileId}
/>
</TableRowCell>
);
}
if (name === 'videoCodec') {
return (
<TableRowCell
key={name}
className={styles.video}
>
<MediaInfoConnector
type={mediaInfoTypes.VIDEO}
episodeFileId={episodeFileId}
/>
</TableRowCell>
);
}
if (name === 'status') {
return (
<TableRowCell
key={name}
className={styles.status}
>
<EpisodeStatusConnector
episodeId={id}
episodeFileId={episodeFileId}
/>
</TableRowCell>
);
}
if (name === 'actions') {
return (
<EpisodeSearchCellConnector
key={name}
episodeId={id}
artistId={artistId}
episodeTitle={title}
/>
);
}
return null;
})
}
</TableRow>
);
}
}
EpisodeRow.propTypes = {
id: PropTypes.number.isRequired,
artistId: PropTypes.number.isRequired,
episodeFileId: PropTypes.number,
monitored: PropTypes.bool.isRequired,
seasonNumber: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number,
sceneSeasonNumber: PropTypes.number,
sceneEpisodeNumber: PropTypes.number,
sceneAbsoluteEpisodeNumber: PropTypes.number,
airDateUtc: PropTypes.string,
title: PropTypes.string.isRequired,
isSaving: PropTypes.bool,
unverifiedSceneNumbering: PropTypes.bool,
seriesMonitored: PropTypes.bool.isRequired,
seriesType: PropTypes.string.isRequired,
episodeFilePath: PropTypes.string,
episodeFileRelativePath: PropTypes.string,
mediaInfo: PropTypes.object,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onMonitorEpisodePress: PropTypes.func.isRequired
};
export default EpisodeRow;
@@ -0,0 +1,29 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import EpisodeRow from './EpisodeRow';
function createMapStateToProps() {
return createSelector(
(state, { id }) => id,
(state, { sceneSeasonNumber }) => sceneSeasonNumber,
createArtistSelector(),
createEpisodeFileSelector(),
createCommandsSelector(),
(id, sceneSeasonNumber, series, episodeFile, commands) => {
const alternateTitles = sceneSeasonNumber ? _.filter(series.alternateTitles, { sceneSeasonNumber }) : [];
return {
seriesMonitored: series.monitored,
seriesType: series.seriesType,
episodeFilePath: episodeFile ? episodeFile.path : null,
episodeFileRelativePath: episodeFile ? episodeFile.relativePath : null,
alternateTitles
};
}
);
}
export default connect(createMapStateToProps)(EpisodeRow);
@@ -0,0 +1,3 @@
.alternateTitle {
white-space: nowrap;
}
@@ -0,0 +1,28 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './SeriesAlternateTitles.css';
function SeriesAlternateTitles({ alternateTitles }) {
return (
<ul>
{
alternateTitles.map((alternateTitle) => {
return (
<li
key={alternateTitle}
className={styles.alternateTitle}
>
{alternateTitle}
</li>
);
})
}
</ul>
);
}
SeriesAlternateTitles.propTypes = {
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default SeriesAlternateTitles;
@@ -0,0 +1,119 @@
.innerContentBody {
padding: 0;
}
.header {
position: relative;
width: 100%;
height: 425px;
}
.backdrop {
position: absolute;
z-index: -1;
width: 100%;
height: 100%;
background-size: cover;
}
.backdropOverlay {
position: absolute;
width: 100%;
height: 100%;
background: $black;
opacity: 0.7;
}
.headerContent {
display: flex;
padding: 30px;
width: 100%;
height: 100%;
color: $white;
}
.poster {
flex-shrink: 0;
margin-right: 35px;
width: 250px;
height: 368px;
}
.info {
flex-grow: 1;
overflow: hidden;
}
.titleContainer {
display: flex;
justify-content: space-between;
}
.title {
margin-bottom: 5px;
font-weight: 300;
font-size: 50px;
line-height: 50px;
}
.alternateTitlesIconContainer {
margin-left: 20px;
line-height: 50px;
}
.seriesNavigationButtons {
white-space: no-wrap;
}
.seriesNavigationButton {
composes: button from 'Components/Link/IconButton.css';
margin-left: 5px;
color: #e1e2e3;
white-space: nowrap;
}
.details {
font-weight: 300;
font-size: 20px;
}
.runtime {
margin-right: 15px;
}
.detailsLabel {
composes: label from 'Components/Label.css';
margin: 5px 10px 5px 0;
}
.sizeOnDisk,
.qualityProfileName,
.network,
.links,
.tags {
margin-left: 8px;
font-weight: 300;
font-size: 17px;
}
.contentContainer {
padding: 20px;
}
@media only screen and (max-width: $breakpointSmall) {
.contentContainer {
padding: 20px 0;
}
.headerContent {
padding: 15px;
}
}
@media only screen and (max-width: $breakpointLarge) {
.poster {
display: none;
}
}
@@ -0,0 +1,576 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Label from 'Components/Label';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import EpisodeFileEditorModal from 'EpisodeFile/Editor/EpisodeFileEditorModal';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import ArtistPoster from 'Artist/ArtistPoster';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import SeriesAlternateTitles from './SeriesAlternateTitles';
import SeriesDetailsSeasonConnector from './SeriesDetailsSeasonConnector';
import SeriesTagsConnector from './SeriesTagsConnector';
import SeriesDetailsLinks from './SeriesDetailsLinks';
import styles from './SeriesDetails.css';
function getFanartUrl(images) {
const fanartImage = _.find(images, { coverType: 'fanart' });
if (fanartImage) {
// Remove protocol
return fanartImage.url.replace(/^https?:/, '');
}
}
function getExpandedState(newState) {
return {
allExpanded: newState.allSelected,
allCollapsed: newState.allUnselected,
expandedState: newState.selectedState
};
}
class SeriesDetails extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isOrganizeModalOpen: false,
isManageEpisodesOpen: false,
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false,
allExpanded: false,
allCollapsed: false,
expandedState: {}
};
}
//
// Listeners
onOrganizePress = () => {
this.setState({ isOrganizeModalOpen: true });
}
onOrganizeModalClose = () => {
this.setState({ isOrganizeModalOpen: false });
}
onManageEpisodesPress = () => {
this.setState({ isManageEpisodesOpen: true });
}
onManageEpisodesModalClose = () => {
this.setState({ isManageEpisodesOpen: false });
}
onEditSeriesPress = () => {
this.setState({ isEditArtistModalOpen: true });
}
onEditSeriesModalClose = () => {
this.setState({ isEditArtistModalOpen: false });
}
onDeleteSeriesPress = () => {
this.setState({
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: true
});
}
onDeleteSeriesModalClose = () => {
this.setState({ isDeleteArtistModalOpen: false });
}
onExpandAllPress = () => {
const {
allExpanded,
expandedState
} = this.state;
this.setState(getExpandedState(selectAll(expandedState, !allExpanded)));
}
onExpandPress = (seasonNumber, isExpanded) => {
this.setState((state) => {
const convertedState = {
allSelected: state.allExpanded,
allUnselected: state.allCollapsed,
selectedState: state.expandedState
};
const newState = toggleSelected(convertedState, [], seasonNumber, isExpanded, false);
return getExpandedState(newState);
});
}
//
// Render
render() {
const {
id,
tvdbId,
tvMazeId,
imdbId,
title,
runtime,
ratings,
sizeOnDisk,
episodeFileCount,
qualityProfileId,
monitored,
status,
network,
overview,
images,
seasons,
alternateTitles,
tags,
isRefreshing,
isSearching,
isFetching,
isPopulated,
episodesError,
episodeFilesError,
previousSeries,
nextSeries,
onRefreshPress,
onSearchPress
} = this.props;
const {
isOrganizeModalOpen,
isManageEpisodesOpen,
isEditArtistModalOpen,
isDeleteArtistModalOpen,
allExpanded,
allCollapsed,
expandedState
} = this.state;
const continuing = status === 'continuing';
let episodeFilesCountMessage = 'No episode files';
if (episodeFileCount === 1) {
episodeFilesCountMessage = '1 episode file';
} else if (episodeFileCount > 1) {
episodeFilesCountMessage = `${episodeFileCount} episode files`;
}
let expandIcon = icons.EXPAND_INDETERMINATE;
if (allExpanded) {
expandIcon = icons.COLLAPSE;
} else if (allCollapsed) {
expandIcon = icons.EXPAND;
}
return (
<PageContent title={title}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh & Scan"
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
title="Refresh information and scan disk"
isSpinning={isRefreshing}
onPress={onRefreshPress}
/>
<PageToolbarButton
label="Search Monitored"
iconName={icons.SEARCH}
isSpinning={isSearching}
onPress={onSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label="Preview Rename"
iconName={icons.ORGANIZE}
onPress={this.onOrganizePress}
/>
<PageToolbarButton
label="Manage Episodes"
iconName={icons.EPISODE_FILE}
onPress={this.onManageEpisodesPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label="Edit"
iconName={icons.EDIT}
onPress={this.onEditSeriesPress}
/>
<PageToolbarButton
label="Delete"
iconName={icons.DELETE}
onPress={this.onDeleteSeriesPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={allExpanded ? 'Collapse All' : 'Expand All'}
iconName={expandIcon}
onPress={this.onExpandAllPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={{
backgroundImage: `url(${getFanartUrl(images)})`
}}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<ArtistPoster
className={styles.poster}
images={images}
size={500}
lazy={false}
/>
<div className={styles.info}>
<div className={styles.titleContainer}>
<div className={styles.title}>
{title}
{
!!alternateTitles.length &&
<span className={styles.alternateTitlesIconContainer}>
<Popover
anchor={
<Icon
name={icons.ALTERNATE_TITLES}
size={20}
/>
}
title="Alternate Titles"
body={<SeriesAlternateTitles alternateTitles={alternateTitles} />}
position={tooltipPositions.BOTTOM}
/>
</span>
}
</div>
<div className={styles.seriesNavigationButtons}>
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={`Go to ${previousSeries.title}`}
to={`/series/${previousSeries.titleSlug}`}
/>
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={`Go to ${nextSeries.title}`}
to={`/series/${nextSeries.titleSlug}`}
/>
</div>
</div>
<div className={styles.details}>
<div>
{
!!runtime &&
<span className={styles.runtime}>
{runtime} Minutes
</span>
}
<HeartRating
rating={ratings.value}
iconSize={20}
/>
</div>
</div>
<div className={styles.detailsLabels}>
<Label
className={styles.detailsLabel}
title={episodeFilesCountMessage}
size={sizes.LARGE}
>
<Icon
name={icons.DRIVE}
size={17}
/>
<span className={styles.sizeOnDisk}>
{
formatBytes(sizeOnDisk)
}
</span>
</Label>
<Label
className={styles.detailsLabel}
title="Quality Profile"
size={sizes.LARGE}
>
<Icon
name={icons.PROFILE}
size={17}
/>
<span className={styles.qualityProfileName}>
{
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
}
</span>
</Label>
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
size={17}
/>
<span className={styles.qualityProfileName}>
{monitored ? 'Monitored' : 'Unmonitored'}
</span>
</Label>
<Label
className={styles.detailsLabel}
title={continuing ? 'More episodes/another season is expected' : 'No additional episodes or or another season is expected'}
size={sizes.LARGE}
>
<Icon
name={continuing ? icons.SERIES_CONTINUING : icons.SERIES_ENDED}
size={17}
/>
<span className={styles.qualityProfileName}>
{continuing ? 'Continuing' : 'Ended'}
</span>
</Label>
{
!!network &&
<Label
className={styles.detailsLabel}
title="Network"
size={sizes.LARGE}
>
<Icon
name={icons.NETWORK}
size={17}
/>
<span className={styles.qualityProfileName}>
{network}
</span>
</Label>
}
<Tooltip
anchor={
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon
name={icons.EXTERNAL_LINK}
size={17}
/>
<span className={styles.links}>
Links
</span>
</Label>
}
tooltip={
<SeriesDetailsLinks
tvdbId={tvdbId}
tvMazeId={tvMazeId}
imdbId={imdbId}
/>
}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
{
!!tags.length &&
<Tooltip
anchor={
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon
name={icons.TAGS}
size={17}
/>
<span className={styles.tags}>
Tags
</span>
</Label>
}
tooltip={<SeriesTagsConnector artistId={id} />}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
}
</div>
<div>
{overview}
</div>
</div>
</div>
</div>
<div className={styles.contentContainer}>
{
!isPopulated && !episodesError && !episodeFilesError &&
<LoadingIndicator />
}
{
!isFetching && episodesError &&
<div>Loading episodes failed</div>
}
{
!isFetching && episodeFilesError &&
<div>Loading episode files failed</div>
}
{
isPopulated && !!seasons.length &&
<div>
{
seasons.slice(0).reverse().map((season) => {
return (
<SeriesDetailsSeasonConnector
key={season.seasonNumber}
artistId={id}
{...season}
isExpanded={expandedState[season.seasonNumber]}
onExpandPress={this.onExpandPress}
/>
);
})
}
</div>
}
{
isPopulated && !seasons.length &&
<div>
No episode information is available.
</div>
}
</div>
<OrganizePreviewModalConnector
isOpen={isOrganizeModalOpen}
artistId={id}
onModalClose={this.onOrganizeModalClose}
/>
<EpisodeFileEditorModal
isOpen={isManageEpisodesOpen}
artistId={id}
onModalClose={this.onManageEpisodesModalClose}
/>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</PageContentBodyConnector>
</PageContent>
);
}
}
SeriesDetails.propTypes = {
id: PropTypes.number.isRequired,
tvdbId: PropTypes.number.isRequired,
tvMazeId: PropTypes.number,
imdbId: PropTypes.string,
title: PropTypes.string.isRequired,
runtime: PropTypes.number.isRequired,
ratings: PropTypes.object.isRequired,
sizeOnDisk: PropTypes.number.isRequired,
episodeFileCount: PropTypes.number,
qualityProfileId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
network: PropTypes.string,
overview: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
seasons: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isRefreshing: PropTypes.bool.isRequired,
isSearching: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
episodesError: PropTypes.object,
episodeFilesError: PropTypes.object,
previousSeries: PropTypes.object.isRequired,
nextSeries: PropTypes.object.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
};
export default SeriesDetails;
@@ -0,0 +1,184 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { findCommand } from 'Utilities/Command';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions';
import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import SeriesDetails from './SeriesDetails';
function createMapStateToProps() {
return createSelector(
(state, { titleSlug }) => titleSlug,
(state) => state.episodes,
(state) => state.episodeFiles,
createAllSeriesSelector(),
createCommandsSelector(),
(titleSlug, episodes, episodeFiles, allSeries, commands) => {
const sortedSeries = _.orderBy(allSeries, 'sortTitle');
const seriesIndex = _.findIndex(sortedSeries, { titleSlug });
const series = sortedSeries[seriesIndex];
if (!series) {
return {};
}
const previousSeries = sortedSeries[seriesIndex - 1] || _.last(sortedSeries);
const nextSeries = sortedSeries[seriesIndex + 1] || _.first(sortedSeries);
const isSeriesRefreshing = !!findCommand(commands, { name: commandNames.REFRESH_SERIES, artistId: series.id });
const allSeriesRefreshing = _.some(commands, (command) => command.name === commandNames.REFRESH_SERIES && !command.body.artistId);
const isRefreshing = isSeriesRefreshing || allSeriesRefreshing;
const isSearching = !!findCommand(commands, { name: commandNames.SERIES_SEARCH, artistId: series.id });
const isRenamingFiles = !!findCommand(commands, { name: commandNames.RENAME_FILES, artistId: series.id });
const isRenamingSeriesCommand = findCommand(commands, { name: commandNames.RENAME_SERIES });
const isRenamingSeries = !!(isRenamingSeriesCommand && isRenamingSeriesCommand.body.artistId.indexOf(series.id) > -1);
const isFetching = episodes.isFetching || episodeFiles.isFetching;
const isPopulated = episodes.isPopulated && episodeFiles.isPopulated;
const episodesError = episodes.error;
const episodeFilesError = episodeFiles.error;
const alternateTitles = _.reduce(series.alternateTitles, (acc, alternateTitle) => {
if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) &&
(alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) {
acc.push(alternateTitle.title);
}
return acc;
}, []);
return {
...series,
alternateTitles,
isRefreshing,
isSearching,
isRenamingFiles,
isRenamingSeries,
isFetching,
isPopulated,
episodesError,
episodeFilesError,
previousSeries,
nextSeries
};
}
);
}
const mapDispatchToProps = {
fetchEpisodes,
clearEpisodes,
fetchEpisodeFiles,
clearEpisodeFiles,
fetchQueueDetails,
clearQueueDetails,
executeCommand
};
class SeriesDetailsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this._populate();
}
componentDidUpdate(prevProps) {
const {
id,
isRefreshing,
isRenamingFiles,
isRenamingSeries
} = this.props;
if (
(prevProps.isRefreshing && !isRefreshing) ||
(prevProps.isRenamingFiles && !isRenamingFiles) ||
(prevProps.isRenamingSeries && !isRenamingSeries)
) {
this._populate();
}
// If the id has changed we need to clear the episodes/episode
// files and fetch from the server.
if (prevProps.id !== id) {
this._unpopulate();
this._populate();
}
}
componentWillUnmount() {
this._unpopulate();
}
//
// Control
_populate() {
const artistId = this.props.id;
this.props.fetchEpisodes({ artistId });
this.props.fetchEpisodeFiles({ artistId });
this.props.fetchQueueDetails({ artistId });
}
_unpopulate() {
this.props.clearEpisodes();
this.props.clearEpisodeFiles();
this.props.clearQueueDetails();
}
//
// Listeners
onRefreshPress = () => {
this.props.executeCommand({
name: commandNames.REFRESH_SERIES,
artistId: this.props.id
});
}
onSearchPress = () => {
this.props.executeCommand({
name: commandNames.SERIES_SEARCH,
artistId: this.props.id
});
}
//
// Render
render() {
return (
<SeriesDetails
{...this.props}
onRefreshPress={this.onRefreshPress}
onSearchPress={this.onSearchPress}
/>
);
}
}
SeriesDetailsConnector.propTypes = {
id: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
isRefreshing: PropTypes.bool.isRequired,
isRenamingFiles: PropTypes.bool.isRequired,
isRenamingSeries: PropTypes.bool.isRequired,
fetchEpisodes: PropTypes.func.isRequired,
clearEpisodes: PropTypes.func.isRequired,
fetchEpisodeFiles: PropTypes.func.isRequired,
clearEpisodeFiles: PropTypes.func.isRequired,
fetchQueueDetails: PropTypes.func.isRequired,
clearQueueDetails: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsConnector);
@@ -0,0 +1,13 @@
.links {
margin: 0;
}
.link {
white-space: nowrap;
}
.linkLabel {
composes: label from 'Components/Label.css';
cursor: pointer;
}
@@ -0,0 +1,84 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds, sizes } from 'Helpers/Props';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import styles from './SeriesDetailsLinks.css';
function SeriesDetailsLinks(props) {
const {
tvdbId,
tvMazeId,
imdbId
} = props;
return (
<div className={styles.links}>
<Link
className={styles.link}
to={`http://www.thetvdb.com/?tab=series&id=${tvdbId}`}
>
<Label
className={styles.linkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
The TVDB
</Label>
</Link>
<Link
className={styles.link}
to={`http://trakt.tv/search/tvdb/${tvdbId}?id_type=show`}
>
<Label
className={styles.linkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
Trakt
</Label>
</Link>
{
!!tvMazeId &&
<Link
className={styles.link}
to={`http://www.tvmaze.com/shows/${tvMazeId}/_`}
>
<Label
className={styles.linkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
TV Maze
</Label>
</Link>
}
{
!!imdbId &&
<Link
className={styles.link}
to={`http://imdb.com/title/${imdbId}/`}
>
<Label
className={styles.linkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
IMDB
</Label>
</Link>
}
</div>
);
}
SeriesDetailsLinks.propTypes = {
tvdbId: PropTypes.number.isRequired,
tvMazeId: PropTypes.number,
imdbId: PropTypes.string
};
export default SeriesDetailsLinks;
@@ -0,0 +1,76 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { push } from 'react-router-redux';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import NotFound from 'Components/NotFound';
import SeriesDetailsConnector from './SeriesDetailsConnector';
function createMapStateToProps() {
return createSelector(
(state, { match }) => match,
createAllSeriesSelector(),
(match, allSeries) => {
const titleSlug = match.params.titleSlug;
const seriesIndex = _.findIndex(allSeries, { titleSlug });
if (seriesIndex > -1) {
return {
titleSlug
};
}
return {};
}
);
}
const mapDispatchToProps = {
push
};
class SeriesDetailsPageConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
if (!this.props.titleSlug) {
this.props.push(`${window.Sonarr.urlBase}/`);
return;
}
}
//
// Render
render() {
const {
titleSlug
} = this.props;
if (!titleSlug) {
return (
<NotFound
message="Sorry, that series cannot be found."
/>
);
}
return (
<SeriesDetailsConnector
titleSlug={titleSlug}
/>
);
}
}
SeriesDetailsPageConnector.propTypes = {
titleSlug: PropTypes.string,
match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired,
push: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsPageConnector);
@@ -0,0 +1,113 @@
.season {
margin-bottom: 20px;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
&:last-of-type {
margin-bottom: 0;
}
}
.header {
position: relative;
display: flex;
align-items: center;
width: 100%;
font-size: 24px;
}
.seasonNumber {
margin-right: 10px;
margin-left: 5px;
}
.episodeCountContainer {
margin-left: 10px;
vertical-align: text-bottom;
}
.expandButton {
composes: link from 'Components/Link/Link.css';
flex-grow: 1;
margin: 0 20px;
text-align: center;
}
.left {
display: flex;
align-items: center;
flex: 0 1 300px;
}
.left,
.actions {
padding: 15px 10px;
}
.actionsMenu {
composes: menu from 'Components/Menu/Menu.css';
flex: 0 0 45px;
}
.actionsMenuContent {
composes: menuContent from 'Components/Menu/MenuContent.css';
white-space: nowrap;
font-size: 14px;
}
.actionMenuIcon {
margin-right: 8px;
}
.actionButton {
composes: button from 'Components/Link/IconButton.css';
width: 30px;
}
.episodes {
padding-top: 15px;
border-top: 1px solid $borderColor;
}
.collapseButtonContainer {
padding: 10px 15px;
width: 100%;
border-top: 1px solid $borderColor;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
background-color: #fafafa;
text-align: center;
}
.expandButtonIcon {
composes: actionButton;
position: absolute;
top: 50%;
left: 50%;
margin-top: -12px;
margin-left: -15px;
}
.noEpisodes {
margin-bottom: 15px;
text-align: center;
}
@media only screen and (max-width: $breakpointSmall) {
.season {
border-right: 0;
border-left: 0;
border-radius: 0;
}
.expandButtonIcon {
position: static;
margin: 0;
}
}
@@ -0,0 +1,393 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import isAfter from 'Utilities/Date/isAfter';
import isBefore from 'Utilities/Date/isBefore';
import getToggledRange from 'Utilities/Table/getToggledRange';
import { align, icons, kinds, sizes } from 'Helpers/Props';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import SpinnerIcon from 'Components/SpinnerIcon';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import EpisodeFileEditorModal from 'EpisodeFile/Editor/EpisodeFileEditorModal';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import EpisodeRowConnector from './EpisodeRowConnector';
import styles from './SeriesDetailsSeason.css';
function getSeasonStatistics(episodes) {
let episodeCount = 0;
let episodeFileCount = 0;
let totalEpisodeCount = 0;
episodes.forEach((episode) => {
if (episode.episodeFileId || (episode.monitored && isBefore(episode.airDateUtc))) {
episodeCount++;
}
if (episode.episodeFileId) {
episodeFileCount++;
}
totalEpisodeCount++;
});
return {
episodeCount,
episodeFileCount,
totalEpisodeCount
};
}
function getEpisodeCountKind(monitored, episodeFileCount, episodeCount) {
if (episodeFileCount === episodeCount && episodeCount > 0) {
return kinds.SUCCESS;
}
if (!monitored) {
return kinds.WARNING;
}
return kinds.DANGER;
}
class SeriesDetailsSeason extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isOrganizeModalOpen: false,
isManageEpisodesOpen: false,
lastToggledEpisode: null
};
}
componentDidMount() {
this._expandByDefault();
}
componentDidUpdate(prevProps) {
if (prevProps.artistId !== this.props.artistId) {
this._expandByDefault();
}
}
//
// Control
_expandByDefault() {
const {
seasonNumber,
onExpandPress,
items
} = this.props;
const expand = _.some(items, (item) => {
return isAfter(item.airDateUtc) ||
isAfter(item.airDateUtc, { days: -30 });
});
onExpandPress(seasonNumber, expand && seasonNumber > 0);
}
//
// Listeners
onOrganizePress = () => {
this.setState({ isOrganizeModalOpen: true });
}
onOrganizeModalClose = () => {
this.setState({ isOrganizeModalOpen: false });
}
onManageEpisodesPress = () => {
this.setState({ isManageEpisodesOpen: true });
}
onManageEpisodesModalClose = () => {
this.setState({ isManageEpisodesOpen: false });
}
onExpandPress = () => {
const {
seasonNumber,
isExpanded
} = this.props;
this.props.onExpandPress(seasonNumber, !isExpanded);
}
onMonitorEpisodePress = (episodeId, monitored, { shiftKey }) => {
const lastToggled = this.state.lastToggledEpisode;
const episodeIds = [episodeId];
if (shiftKey && lastToggled) {
const { lower, upper } = getToggledRange(this.props.items, episodeId, lastToggled);
const items = this.props.items;
for (let i = lower; i < upper; i++) {
episodeIds.push(items[i].id);
}
}
this.setState({ lastToggledEpisode: episodeId });
this.props.onMonitorEpisodePress(_.uniq(episodeIds), monitored);
}
//
// Render
render() {
const {
artistId,
monitored,
seasonNumber,
items,
columns,
isSaving,
isExpanded,
isSearching,
seriesMonitored,
isSmallScreen,
onTableOptionChange,
onMonitorSeasonPress,
onSearchPress
} = this.props;
const {
episodeCount,
episodeFileCount,
totalEpisodeCount
} = getSeasonStatistics(items);
const {
isOrganizeModalOpen,
isManageEpisodesOpen
} = this.state;
return (
<div
className={styles.season}
>
<div className={styles.header}>
<div className={styles.left}>
<MonitorToggleButton
monitored={monitored}
isDisabled={!seriesMonitored}
isSaving={isSaving}
size={24}
onPress={onMonitorSeasonPress}
/>
{
seasonNumber === 0 ?
<span className={styles.seasonNumber}>
Specials
</span> :
<span className={styles.seasonNumber}>
Season {seasonNumber}
</span>
}
<Label
title={`${totalEpisodeCount} episodes total. ${episodeFileCount} episodes with files.`}
kind={getEpisodeCountKind(monitored, episodeFileCount, episodeCount)}
size={sizes.LARGE}
>
{
<span>{episodeFileCount} / {episodeCount}</span>
}
</Label>
</div>
<Link
className={styles.expandButton}
onPress={this.onExpandPress}
>
<Icon
className={styles.expandButtonIcon}
name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
title={isExpanded ? 'Hide episodes' : 'Show episodes'}
size={24}
/>
{
!isSmallScreen &&
<span>&nbsp;</span>
}
</Link>
{
isSmallScreen ?
<Menu
className={styles.actionsMenu}
alignMenu={align.RIGHT}
enforceMaxHeight={false}
>
<MenuButton>
<Icon
name={icons.ACTIONS}
size={22}
/>
</MenuButton>
<MenuContent className={styles.actionsMenuContent}>
<MenuItem
isDisabled={isSearching}
onPress={onSearchPress}
>
<SpinnerIcon
className={styles.actionMenuIcon}
name={icons.SEARCH}
isSpinning={isSearching}
/>
Search
</MenuItem>
<MenuItem
onPress={this.onOrganizePress}
>
<Icon
className={styles.actionMenuIcon}
name={icons.ORGANIZE}
/>
Preview Rename
</MenuItem>
<MenuItem
onPress={this.onManageEpisodesPress}
>
<Icon
className={styles.actionMenuIcon}
name={icons.EPISODE_FILE}
/>
Manage Episodes
</MenuItem>
</MenuContent>
</Menu> :
<div className={styles.actions}>
<SpinnerIconButton
className={styles.actionButton}
name={icons.SEARCH}
title="Search for monitored episodes in this seasons"
size={24}
isSpinning={isSearching}
onPress={onSearchPress}
/>
<IconButton
className={styles.actionButton}
name={icons.ORGANIZE}
title="Preview rename for this season"
size={24}
onPress={this.onOrganizePress}
/>
<IconButton
className={styles.actionButton}
name={icons.EPISODE_FILE}
title="Manage episode files in this series"
size={24}
onPress={this.onManageEpisodesPress}
/>
</div>
}
</div>
<div>
{
isExpanded &&
<div className={styles.episodes}>
{
items.length ?
<Table
columns={columns}
onTableOptionChange={onTableOptionChange}
>
<TableBody>
{
items.map((item) => {
return (
<EpisodeRowConnector
key={item.id}
columns={columns}
{...item}
onMonitorEpisodePress={this.onMonitorEpisodePress}
/>
);
})
}
</TableBody>
</Table> :
<div className={styles.noEpisodes}>
No episodes in this season
</div>
}
<div className={styles.collapseButtonContainer}>
<IconButton
name={icons.COLLAPSE}
size={20}
title="Hide episodes"
onPress={this.onExpandPress}
/>
</div>
</div>
}
</div>
<OrganizePreviewModalConnector
isOpen={isOrganizeModalOpen}
artistId={artistId}
seasonNumber={seasonNumber}
onModalClose={this.onOrganizeModalClose}
/>
<EpisodeFileEditorModal
isOpen={isManageEpisodesOpen}
artistId={artistId}
seasonNumber={seasonNumber}
onModalClose={this.onManageEpisodesModalClose}
/>
</div>
);
}
}
SeriesDetailsSeason.propTypes = {
artistId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
seasonNumber: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool,
isExpanded: PropTypes.bool,
isSearching: PropTypes.bool.isRequired,
seriesMonitored: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired,
onMonitorSeasonPress: PropTypes.func.isRequired,
onExpandPress: PropTypes.func.isRequired,
onMonitorEpisodePress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
};
export default SeriesDetailsSeason;
@@ -0,0 +1,118 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { findCommand } from 'Utilities/Command';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import { toggleSeasonMonitored } from 'Store/Actions/seriesActions';
import { toggleEpisodesMonitored, setEpisodesTableOption } from 'Store/Actions/episodeActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import SeriesDetailsSeason from './SeriesDetailsSeason';
function createMapStateToProps() {
return createSelector(
(state, { seasonNumber }) => seasonNumber,
(state) => state.episodes,
createArtistSelector(),
createCommandsSelector(),
createDimensionsSelector(),
(seasonNumber, episodes, series, commands, dimensions) => {
const isSearching = !!findCommand(commands, {
name: commandNames.SEASON_SEARCH,
artistId: series.id,
seasonNumber
});
const episodesInSeason = _.filter(episodes.items, { seasonNumber });
const sortedEpisodes = _.orderBy(episodesInSeason, 'episodeNumber', 'desc');
return {
items: sortedEpisodes,
columns: episodes.columns,
isSearching,
seriesMonitored: series.monitored,
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
const mapDispatchToProps = {
toggleSeasonMonitored,
toggleEpisodesMonitored,
setEpisodesTableOption,
executeCommand
};
class SeriesDetailsSeasonConnector extends Component {
//
// Listeners
onTableOptionChange = (payload) => {
this.props.setEpisodesTableOption(payload);
}
onMonitorSeasonPress = (monitored) => {
const {
artistId,
seasonNumber
} = this.props;
this.props.toggleSeasonMonitored({
artistId,
seasonNumber,
monitored
});
}
onSearchPress = () => {
const {
artistId,
seasonNumber
} = this.props;
this.props.executeCommand({
name: commandNames.SEASON_SEARCH,
artistId,
seasonNumber
});
}
onMonitorEpisodePress = (episodeIds, monitored) => {
this.props.toggleEpisodesMonitored({
episodeIds,
monitored
});
}
//
// Render
render() {
return (
<SeriesDetailsSeason
{...this.props}
onTableOptionChange={this.onTableOptionChange}
onMonitorSeasonPress={this.onMonitorSeasonPress}
onSearchPress={this.onSearchPress}
onMonitorEpisodePress={this.onMonitorEpisodePress}
/>
);
}
}
SeriesDetailsSeasonConnector.propTypes = {
artistId: PropTypes.number.isRequired,
seasonNumber: PropTypes.number.isRequired,
toggleSeasonMonitored: PropTypes.func.isRequired,
toggleEpisodesMonitored: PropTypes.func.isRequired,
setEpisodesTableOption: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsSeasonConnector);
@@ -0,0 +1,8 @@
.tags {
margin: 0;
padding-left: 20px;
}
.tag {
white-space: nowrap;
}
+31
View File
@@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds, sizes } from 'Helpers/Props';
import Label from 'Components/Label';
import styles from './SeriesTags.css';
function SeriesTags({ tags }) {
return (
<div>
{
tags.map((tag) => {
return (
<Label
key={tag}
kind={kinds.INFO}
size={sizes.LARGE}
>
{tag}
</Label>
);
})
}
</div>
);
}
SeriesTags.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default SeriesTags;
@@ -0,0 +1,30 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import SeriesTags from './SeriesTags';
function createMapStateToProps() {
return createSelector(
createArtistSelector(),
createTagsSelector(),
(series, tagList) => {
const tags = _.reduce(series.tags, (acc, tag) => {
const matchingTag = _.find(tagList, { id: tag });
if (matchingTag) {
acc.push(matchingTag.label);
}
return acc;
}, []);
return {
tags
};
}
);
}
export default connect(createMapStateToProps)(SeriesTags);
@@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import EditArtistModalContentConnector from './EditArtistModalContentConnector';
function EditArtistModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditArtistModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditArtistModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditArtistModal;
@@ -0,0 +1,39 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditArtistModal from './EditArtistModal';
const mapDispatchToProps = {
clearPendingChanges
};
class EditArtistModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'series' });
this.props.onModalClose();
}
//
// Render
render() {
return (
<EditArtistModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditArtistModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(undefined, mapDispatchToProps)(EditArtistModalConnector);
@@ -0,0 +1,5 @@
.deleteButton {
composes: button from 'Components/Link/Button.css';
margin-right: auto;
}
@@ -0,0 +1,165 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
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 styles from './EditArtistModalContent.css';
class EditArtistModalContent extends Component {
//
// Render
render() {
const {
artistName,
item,
isSaving,
showLanguageProfile,
onInputChange,
onSavePress,
onModalClose,
onDeleteSeriesPress,
...otherProps
} = this.props;
const {
monitored,
albumFolder,
qualityProfileId,
languageProfileId,
// seriesType,
path,
tags
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Edit - {artistName}
</ModalHeader>
<ModalBody>
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>Monitored</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="monitored"
helpText="Download monitored albums from this artist"
{...monitored}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Use Album Folder</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="albumFolder"
helpText="Sort tracks into album folders"
{...albumFolder}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
{...qualityProfileId}
onChange={onInputChange}
/>
</FormGroup>
{
showLanguageProfile &&
<FormGroup>
<FormLabel>Language Profile</FormLabel>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
{...languageProfileId}
onChange={onInputChange}
/>
</FormGroup>
}
<FormGroup>
<FormLabel>Path</FormLabel>
<FormInputGroup
type={inputTypes.PATH}
name="path"
{...path}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
{...tags}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteSeriesPress}
>
Delete
</Button>
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerButton
isSpinning={isSaving}
onPress={onSavePress}
>
Save
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
EditArtistModalContent.propTypes = {
artistId: PropTypes.number.isRequired,
artistName: PropTypes.string.isRequired,
item: PropTypes.object.isRequired,
isSaving: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteSeriesPress: PropTypes.func.isRequired
};
export default EditArtistModalContent;
@@ -0,0 +1,98 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import selectSettings from 'Store/Selectors/selectSettings';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import { setSeriesValue, saveArtist } from 'Store/Actions/seriesActions';
import EditArtistModalContent from './EditArtistModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.series,
(state) => state.settings.languageProfiles,
createArtistSelector(),
(seriesState, languageProfiles, series) => {
const {
isSaving,
saveError,
pendingChanges
} = seriesState;
const seriesSettings = _.pick(series, [
'monitored',
'albumFolder',
'qualityProfileId',
'languageProfileId',
// 'seriesType',
'path',
'tags'
]);
const settings = selectSettings(seriesSettings, pendingChanges, saveError);
return {
artistName: series.artistName,
isSaving,
saveError,
pendingChanges,
item: settings.settings,
showLanguageProfile: languageProfiles.items.length > 1,
...settings
};
}
);
}
const mapDispatchToProps = {
setSeriesValue,
saveArtist
};
class EditArtistModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setSeriesValue({ name, value });
}
onSavePress = () => {
this.props.saveArtist({ id: this.props.artistId });
}
//
// Render
render() {
return (
<EditArtistModalContent
{...this.props}
onInputChange={this.onInputChange}
onSavePress={this.onSavePress}
/>
);
}
}
EditArtistModalContentConnector.propTypes = {
artistId: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
setSeriesValue: PropTypes.func.isRequired,
saveArtist: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditArtistModalContentConnector);
@@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector';
function DeleteArtistModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<DeleteArtistModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
DeleteArtistModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default DeleteArtistModal;
@@ -0,0 +1,13 @@
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.pathContainer {
margin-left: 5px;
}
.path {
margin-left: 5px;
color: $dangerColor;
}
@@ -0,0 +1,123 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import Button from 'Components/Link/Button';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import styles from './DeleteArtistModalContent.css';
class DeleteArtistModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
deleteFiles: false
};
}
//
// Listeners
onDeleteFilesChange = ({ value }) => {
this.setState({ deleteFiles: value });
}
onDeleteSeriesConfirmed = () => {
const deleteFiles = this.state.deleteFiles;
this.setState({ deleteFiles: false });
this.props.onDeleteSelectedPress(deleteFiles);
}
//
// Render
render() {
const {
series,
onModalClose
} = this.props;
const deleteFiles = this.state.deleteFiles;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Delete Selected Series
</ModalHeader>
<ModalBody>
<div>
<FormGroup>
<FormLabel>{`Delete Series Folder${series.length > 1 ? 's' : ''}`}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={`Delete Series Folder${series.length > 1 ? 's' : ''} and all contents`}
kind={kinds.DANGER}
onChange={this.onDeleteFilesChange}
/>
</FormGroup>
</div>
<div className={styles.message}>
{`Are you sure you want to delete ${series.length} selected series${deleteFiles ? ' and all contents' : ''}?`}
</div>
<ul>
{
series.map((s) => {
return (
<li key={s.title}>
<span>{s.title}</span>
{
deleteFiles &&
<span className={styles.pathContainer}>
-
<span className={styles.path}>
{s.path}
</span>
</span>
}
</li>
);
})
}
</ul>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onDeleteSeriesConfirmed}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
);
}
}
DeleteArtistModalContent.propTypes = {
series: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteSelectedPress: PropTypes.func.isRequired
};
export default DeleteArtistModalContent;
@@ -0,0 +1,45 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import { bulkDeleteArtist } from 'Store/Actions/seriesEditorActions';
import DeleteArtistModalContent from './DeleteArtistModalContent';
function createMapStateToProps() {
return createSelector(
(state, { artistIds }) => artistIds,
createAllSeriesSelector(),
(artistIds, allSeries) => {
const selectedSeries = _.intersectionWith(allSeries, artistIds, (s, id) => {
return s.id === id;
});
const sortedSeries = _.orderBy(selectedSeries, 'sortTitle');
const series = _.map(sortedSeries, (s) => {
return {
title: s.title,
path: s.path
};
});
return {
series
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onDeleteSelectedPress(deleteFiles) {
dispatch(bulkDeleteArtist({
artistIds: props.artistIds,
deleteFiles
}));
props.onModalClose();
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteArtistModalContent);
@@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import OrganizeSeriesModalContentConnector from './OrganizeSeriesModalContentConnector';
function OrganizeSeriesModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<OrganizeSeriesModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
OrganizeSeriesModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default OrganizeSeriesModal;
@@ -0,0 +1,8 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}
@@ -0,0 +1,74 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons, kinds } from 'Helpers/Props';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import Icon from 'Components/Icon';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import styles from './OrganizeSeriesModalContent.css';
function OrganizeSeriesModalContent(props) {
const {
seriesTitles,
onModalClose,
onOrganizeSeriesPress
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Organize Selected Series
</ModalHeader>
<ModalBody>
<Alert>
Tip: To preview a rename... select "Cancel" then any series title and use the
<Icon
className={styles.renameIcon}
name={icons.ORGANIZE}
/>
</Alert>
<div className={styles.message}>
Are you sure you want to organize all files in the {seriesTitles.length} selected series?
</div>
<ul>
{
seriesTitles.map((title) => {
return (
<li key={title}>
{title}
</li>
);
})
}
</ul>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.DANGER}
onPress={onOrganizeSeriesPress}
>
Organize
</Button>
</ModalFooter>
</ModalContent>
);
}
OrganizeSeriesModalContent.propTypes = {
seriesTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
onModalClose: PropTypes.func.isRequired,
onOrganizeSeriesPress: PropTypes.func.isRequired
};
export default OrganizeSeriesModalContent;
@@ -0,0 +1,67 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import OrganizeSeriesModalContent from './OrganizeSeriesModalContent';
function createMapStateToProps() {
return createSelector(
(state, { artistIds }) => artistIds,
createAllSeriesSelector(),
(artistIds, allSeries) => {
const series = _.intersectionWith(allSeries, artistIds, (s, id) => {
return s.id === id;
});
const sortedSeries = _.orderBy(series, 'sortTitle');
const seriesTitles = _.map(sortedSeries, 'title');
return {
seriesTitles
};
}
);
}
const mapDispatchToProps = {
executeCommand
};
class OrganizeSeriesModalContentConnector extends Component {
//
// Listeners
onOrganizeSeriesPress = () => {
this.props.executeCommand({
name: commandNames.RENAME_SERIES,
artistIds: this.props.artistIds
});
this.props.onModalClose(true);
}
//
// Render
render(props) {
return (
<OrganizeSeriesModalContent
{...this.props}
onOrganizeSeriesPress={this.onOrganizeSeriesPress}
/>
);
}
}
OrganizeSeriesModalContentConnector.propTypes = {
artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
onModalClose: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeSeriesModalContentConnector);
+328
View File
@@ -0,0 +1,328 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import { align, sortDirections } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import FilterMenu from 'Components/Menu/FilterMenu';
import MenuContent from 'Components/Menu/MenuContent';
import FilterMenuItem from 'Components/Menu/FilterMenuItem';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import NoArtist from 'Artist/NoArtist';
import SeriesEditorRowConnector from './SeriesEditorRowConnector';
import SeriesEditorFooter from './SeriesEditorFooter';
import OrganizeSeriesModal from './Organize/OrganizeSeriesModal';
function getColumns(showLanguageProfile) {
return [
{
name: 'status',
isVisible: true
},
{
name: 'sortTitle',
label: 'Title',
isSortable: true,
isVisible: true
},
{
name: 'qualityProfileId',
label: 'Quality Profile',
isSortable: true,
isVisible: true
},
{
name: 'languageProfileId',
label: 'Language Profile',
isSortable: true,
isVisible: showLanguageProfile
},
{
name: 'seriesType',
label: 'Series Type',
isSortable: false,
isVisible: true
},
{
name: 'seasonFolder',
label: 'Season Folder',
isSortable: true,
isVisible: true
},
{
name: 'path',
label: 'Path',
isSortable: true,
isVisible: true
},
{
name: 'tags',
label: 'Tags',
isSortable: false,
isVisible: true
}
];
}
class SeriesEditor extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
isOrganizingSeriesModalOpen: false,
columns: getColumns(props.showLanguageProfile)
};
}
componentDidUpdate(prevProps) {
const {
isDeleting,
deleteError
} = this.props;
const hasFinishedDeleting = prevProps.isDeleting &&
!isDeleting &&
!deleteError;
if (hasFinishedDeleting) {
this.onSelectAllChange({ value: false });
}
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
}
//
// Listeners
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
}
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
}
onSaveSelected = (changes) => {
this.props.onSaveSelected({
artistIds: this.getSelectedIds(),
...changes
});
}
onOrganizeSeriesPress = () => {
this.setState({ isOrganizingSeriesModalOpen: true });
}
onOrganizeSeriesModalClose = (organized) => {
this.setState({ isOrganizingSeriesModalOpen: false });
if (organized === true) {
this.onSelectAllChange({ value: false });
}
}
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
filterKey,
filterValue,
sortKey,
sortDirection,
isSaving,
saveError,
isDeleting,
deleteError,
isOrganizingSeries,
showLanguageProfile,
onSortPress,
onFilterSelect
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
columns
} = this.state;
const selectedSeriesIds = this.getSelectedIds();
return (
<PageContent title="Series Editor">
<PageToolbar>
<PageToolbarSection />
<PageToolbarSection alignContent={align.RIGHT}>
<FilterMenu alignMenu={align.RIGHT}>
<MenuContent>
<FilterMenuItem
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
All
</FilterMenuItem>
<FilterMenuItem
name="monitored"
value={true}
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Monitored Only
</FilterMenuItem>
<FilterMenuItem
name="status"
value="continuing"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Continuing Only
</FilterMenuItem>
<FilterMenuItem
name="status"
value="ended"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Ended Only
</FilterMenuItem>
<FilterMenuItem
name="missing"
value={true}
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Missing Episodes
</FilterMenuItem>
</MenuContent>
</FilterMenu>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load the calendar</div>
}
{
!error && isPopulated && !!items.length &&
<div>
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSortPress={onSortPress}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<SeriesEditorRowConnector
key={item.id}
{...item}
columns={columns}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
</div>
}
{
!error && isPopulated && !items.length &&
<NoArtist />
}
</PageContentBodyConnector>
<SeriesEditorFooter
artistIds={selectedSeriesIds}
selectedCount={selectedSeriesIds.length}
isSaving={isSaving}
saveError={saveError}
isDeleting={isDeleting}
deleteError={deleteError}
isOrganizingSeries={isOrganizingSeries}
showLanguageProfile={showLanguageProfile}
onSaveSelected={this.onSaveSelected}
onOrganizeSeriesPress={this.onOrganizeSeriesPress}
/>
<OrganizeSeriesModal
isOpen={this.state.isOrganizingSeriesModalOpen}
artistIds={selectedSeriesIds}
onModalClose={this.onOrganizeSeriesModalClose}
/>
</PageContent>
);
}
}
SeriesEditor.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
isOrganizingSeries: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
onSortPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onSaveSelected: PropTypes.func.isRequired
};
export default SeriesEditor;
@@ -0,0 +1,86 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { createSelector } from 'reselect';
import connectSection from 'Store/connectSection';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandSelector from 'Store/Selectors/createCommandSelector';
import { setSeriesEditorSort, setSeriesEditorFilter, saveArtistEditor } from 'Store/Actions/seriesEditorActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import * as commandNames from 'Commands/commandNames';
import SeriesEditor from './SeriesEditor';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.languageProfiles,
createClientSideCollectionSelector(),
createCommandSelector(commandNames.RENAME_SERIES),
(languageProfiles, series, isOrganizingSeries) => {
return {
isOrganizingSeries,
showLanguageProfile: languageProfiles.items.length > 1,
...series
};
}
);
}
const mapDispatchToProps = {
setSeriesEditorSort,
setSeriesEditorFilter,
saveArtistEditor,
fetchRootFolders
};
class SeriesEditorConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchRootFolders();
}
//
// Listeners
onSortPress = (sortKey) => {
this.props.setSeriesEditorSort({ sortKey });
}
onFilterSelect = (filterKey, filterValue, filterType) => {
this.props.setSeriesEditorFilter({ filterKey, filterValue, filterType });
}
onSaveSelected = (payload) => {
this.props.saveArtistEditor(payload);
}
//
// Render
render() {
return (
<SeriesEditor
{...this.props}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onSaveSelected={this.onSaveSelected}
/>
);
}
}
SeriesEditorConnector.propTypes = {
setSeriesEditorSort: PropTypes.func.isRequired,
setSeriesEditorFilter: PropTypes.func.isRequired,
saveArtistEditor: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connectSection(
createMapStateToProps,
mapDispatchToProps,
undefined,
undefined,
{ section: 'series', uiSection: 'seriesEditor' }
)(SeriesEditorConnector);
@@ -0,0 +1,57 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
}
.buttonContainer {
display: flex;
justify-content: flex-end;
flex-grow: 1;
}
.buttonContainerContent {
flex-grow: 0;
}
.buttons {
display: flex;
justify-content: flex-end;
flex-grow: 1;
}
.organizeSelectedButton,
.tagsButton {
composes: button from 'Components/Link/SpinnerButton.css';
margin-right: 10px;
height: 35px;
}
.deleteSelectedButton {
composes: button from 'Components/Link/SpinnerButton.css';
margin-left: 50px;
height: 35px;
}
@media only screen and (max-width: $breakpointSmall) {
.inputContainer {
margin-right: 0;
}
.buttonContainer {
justify-content: flex-start;
}
.buttonContainerContent {
flex-grow: 1;
}
.buttons {
justify-content: space-between;
}
.selectedSeriesLabel {
text-align: left;
}
}
@@ -0,0 +1,314 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds } from 'Helpers/Props';
import SelectInput from 'Components/Form/SelectInput';
import LanguageProfileSelectInputConnector from 'Components/Form/LanguageProfileSelectInputConnector';
import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector';
import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector';
import SeriesTypeSelectInput from 'Components/Form/SeriesTypeSelectInput';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import TagsModal from './Tags/TagsModal';
import DeleteArtistModal from './Delete/DeleteArtistModal';
import SeriesEditorFooterLabel from './SeriesEditorFooterLabel';
import styles from './SeriesEditorFooter.css';
const NO_CHANGE = 'noChange';
class SeriesEditorFooter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
monitored: NO_CHANGE,
qualityProfileId: NO_CHANGE,
languageProfileId: NO_CHANGE,
seriesType: NO_CHANGE,
seasonFolder: NO_CHANGE,
rootFolderPath: NO_CHANGE,
savingTags: false,
isDeleteArtistModalOpen: false,
isTagsModalOpen: false
};
}
componentDidUpdate(prevProps) {
const {
isSaving,
saveError
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
this.setState({
monitored: NO_CHANGE,
qualityProfileId: NO_CHANGE,
languageProfileId: NO_CHANGE,
seriesType: NO_CHANGE,
seasonFolder: NO_CHANGE,
rootFolderPath: NO_CHANGE,
savingTags: false
});
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
if (value === NO_CHANGE) {
return;
}
switch (name) {
case 'monitored':
this.props.onSaveSelected({ [name]: value === 'monitored' });
break;
case 'seasonFolder':
this.props.onSaveSelected({ [name]: value === 'yes' });
break;
default:
this.props.onSaveSelected({ [name]: value });
}
}
onApplyTagsPress = (tags, applyTags) => {
this.setState({
savingTags: true,
isTagsModalOpen: false
});
this.props.onSaveSelected({
tags,
applyTags
});
}
onDeleteSelectedPress = () => {
this.setState({ isDeleteArtistModalOpen: true });
}
onDeleteSeriesModalClose = () => {
this.setState({ isDeleteArtistModalOpen: false });
}
onTagsPress = () => {
this.setState({ isTagsModalOpen: true });
}
onTagsModalClose = () => {
this.setState({ isTagsModalOpen: false });
}
//
// Render
render() {
const {
artistIds,
selectedCount,
isSaving,
isDeleting,
isOrganizingSeries,
showLanguageProfile,
onOrganizeSeriesPress
} = this.props;
const {
monitored,
qualityProfileId,
languageProfileId,
seriesType,
seasonFolder,
rootFolderPath,
savingTags,
isTagsModalOpen,
isDeleteArtistModalOpen
} = this.state;
const monitoredOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'monitored', value: 'Monitored', disabled: true },
{ key: 'unmonitored', value: 'Unmonitored' }
];
const seasonFolderOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'yes', value: 'Yes' },
{ key: 'no', value: 'No' }
];
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<SeriesEditorFooterLabel
label="Monitor Series"
isSaving={isSaving && monitored !== NO_CHANGE}
/>
<SelectInput
name="monitored"
value={monitored}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<SeriesEditorFooterLabel
label="Quality Profile"
isSaving={isSaving && qualityProfileId !== NO_CHANGE}
/>
<QualityProfileSelectInputConnector
name="qualityProfileId"
value={qualityProfileId}
includeNoChange={true}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
{
showLanguageProfile &&
<div className={styles.inputContainer}>
<SeriesEditorFooterLabel
label="Language Profile"
isSaving={isSaving && languageProfileId !== NO_CHANGE}
/>
<LanguageProfileSelectInputConnector
name="languageProfileId"
value={languageProfileId}
includeNoChange={true}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
}
<div className={styles.inputContainer}>
<SeriesEditorFooterLabel
label="Series Type"
isSaving={isSaving && seriesType !== NO_CHANGE}
/>
<SeriesTypeSelectInput
name="seriesType"
value={seriesType}
includeNoChange={true}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<SeriesEditorFooterLabel
label="Season Folder"
isSaving={isSaving && seasonFolder !== NO_CHANGE}
/>
<SelectInput
name="seasonFolder"
value={seasonFolder}
values={seasonFolderOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<SeriesEditorFooterLabel
label="Root Folder"
isSaving={isSaving && rootFolderPath !== NO_CHANGE}
/>
<RootFolderSelectInputConnector
name="rootFolderPath"
value={rootFolderPath}
includeNoChange={true}
isDisabled={!selectedCount}
selectedValueOptions={{ includeFreeSpace: false }}
onChange={this.onInputChange}
/>
</div>
<div className={styles.buttonContainer}>
<div className={styles.buttonContainerContent}>
<SeriesEditorFooterLabel
label={`${selectedCount} Series Selected`}
isSaving={false}
/>
<div className={styles.buttons}>
<div>
<SpinnerButton
className={styles.organizeSelectedButton}
kind={kinds.WARNING}
isSpinning={isOrganizingSeries}
isDisabled={!selectedCount || isOrganizingSeries}
onPress={onOrganizeSeriesPress}
>
Rename Files
</SpinnerButton>
<SpinnerButton
className={styles.tagsButton}
isSpinning={isSaving && savingTags}
isDisabled={!selectedCount || isOrganizingSeries}
onPress={this.onTagsPress}
>
Set Tags
</SpinnerButton>
</div>
<SpinnerButton
className={styles.deleteSelectedButton}
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!selectedCount || isDeleting}
onPress={this.onDeleteSelectedPress}
>
Delete
</SpinnerButton>
</div>
</div>
</div>
<TagsModal
isOpen={isTagsModalOpen}
artistIds={artistIds}
onApplyTagsPress={this.onApplyTagsPress}
onModalClose={this.onTagsModalClose}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistIds={artistIds}
onModalClose={this.onDeleteSeriesModalClose}
/>
</PageContentFooter>
);
}
}
SeriesEditorFooter.propTypes = {
artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
selectedCount: PropTypes.number.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
isOrganizingSeries: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
onSaveSelected: PropTypes.func.isRequired,
onOrganizeSeriesPress: PropTypes.func.isRequired
};
export default SeriesEditorFooter;
@@ -0,0 +1,8 @@
.label {
margin-bottom: 3px;
font-weight: bold;
}
.savingIcon {
margin-left: 8px;
}
@@ -0,0 +1,40 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons } from 'Helpers/Props';
import SpinnerIcon from 'Components/SpinnerIcon';
import styles from './SeriesEditorFooterLabel.css';
function SeriesEditorFooterLabel(props) {
const {
className,
label,
isSaving
} = props;
return (
<div className={className}>
{label}
{
isSaving &&
<SpinnerIcon
className={styles.savingIcon}
name={icons.SPINNER}
isSpinning={true}
/>
}
</div>
);
}
SeriesEditorFooterLabel.propTypes = {
className: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
isSaving: PropTypes.bool.isRequired
};
SeriesEditorFooterLabel.defaultProps = {
className: styles.label
};
export default SeriesEditorFooterLabel;
@@ -0,0 +1,5 @@
.seasonFolder {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 150px;
}
@@ -0,0 +1,120 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import titleCase from 'Utilities/String/titleCase';
import TagListConnector from 'Components/TagListConnector';
import CheckInput from 'Components/Form/CheckInput';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import ArtistNameLink from 'Artist/ArtistNameLink';
import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell';
import styles from './SeriesEditorRow.css';
class SeriesEditorRow extends Component {
//
// Listeners
onSeasonFolderChange = () => {
// Mock handler to satisfy `onChange` being required for `CheckInput`.
//
}
//
// Render
render() {
const {
id,
status,
titleSlug,
title,
monitored,
languageProfile,
qualityProfile,
seriesType,
seasonFolder,
path,
tags,
columns,
isSelected,
onSelectedChange
} = this.props;
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
<ArtistStatusCell
monitored={monitored}
status={status}
/>
<TableRowCell className={styles.title}>
<ArtistNameLink
titleSlug={titleSlug}
title={title}
/>
</TableRowCell>
<TableRowCell>
{qualityProfile.name}
</TableRowCell>
{
_.find(columns, { name: 'languageProfileId' }).isVisible &&
<TableRowCell>
{languageProfile.name}
</TableRowCell>
}
<TableRowCell>
{titleCase(seriesType)}
</TableRowCell>
<TableRowCell className={styles.seasonFolder}>
<CheckInput
name="seasonFolder"
value={seasonFolder}
isDisabled={true}
onChange={this.onSeasonFolderChange}
/>
</TableRowCell>
<TableRowCell>
{path}
</TableRowCell>
<TableRowCell>
<TagListConnector
tags={tags}
/>
</TableRowCell>
</TableRow>
);
}
}
SeriesEditorRow.propTypes = {
id: PropTypes.number.isRequired,
status: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
languageProfile: PropTypes.object.isRequired,
qualityProfile: PropTypes.object.isRequired,
seriesType: PropTypes.string.isRequired,
seasonFolder: PropTypes.bool.isRequired,
path: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired
};
export default SeriesEditorRow;
@@ -0,0 +1,34 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector';
import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
import SeriesEditorRow from './SeriesEditorRow';
function createMapStateToProps() {
return createSelector(
createLanguageProfileSelector(),
createQualityProfileSelector(),
(languageProfile, qualityProfile) => {
return {
languageProfile,
qualityProfile
};
}
);
}
function SeriesEditorRowConnector(props) {
return (
<SeriesEditorRow
{...props}
/>
);
}
SeriesEditorRowConnector.propTypes = {
qualityProfileId: PropTypes.number.isRequired
};
export default connect(createMapStateToProps)(SeriesEditorRowConnector);
@@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import TagsModalContentConnector from './TagsModalContentConnector';
function TagsModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<TagsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
TagsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default TagsModal;
@@ -0,0 +1,12 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.result {
padding-top: 4px;
}
@@ -0,0 +1,188 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
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 styles from './TagsModalContent.css';
class TagsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
tags: [],
applyTags: 'add'
};
}
//
// Lifecycle
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
}
onApplyTagsPress = () => {
const {
tags,
applyTags
} = this.state;
this.props.onApplyTagsPress(tags, applyTags);
}
//
// Render
render() {
const {
seriesTags,
tagList,
onModalClose,
onApplyTagsPress
} = this.props;
const {
tags,
applyTags
} = this.state;
const applyTagsOptions = [
{ key: 'add', value: 'Add' },
{ key: 'remove', value: 'Remove' },
{ key: 'replace', value: 'Replace' }
];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Tags
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
value={tags}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Apply Tags</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="applyTags"
value={applyTags}
values={applyTagsOptions}
helpTexts={[
'How to apply tags to the selected series',
'Add: Add the tags the existing list of tags',
'Remove: Remove the entered tags',
'Replace: Replace the tags with the entered tags (enter no tags to clear all tags)'
]}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Result</FormLabel>
<div className={styles.result}>
{
seriesTags.map((t) => {
const tag = _.find(tagList, { id: t });
if (!tag) {
return null;
}
const removeTag = (applyTags === 'remove' && tags.indexOf(t) > -1) ||
(applyTags === 'replace' && tags.indexOf(t) === -1);
return (
<Label
key={tag.id}
title={removeTag ? 'Removing tag' : 'Existing tag'}
kind={removeTag ? kinds.INVERSE : kinds.INFO}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})
}
{
(applyTags === 'add' || applyTags === 'replace') &&
tags.map((t) => {
const tag = _.find(tagList, { id: t });
if (!tag) {
return null;
}
if (seriesTags.indexOf(t) > -1) {
return null;
}
return (
<Label
key={tag.id}
title={'Adding tag'}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})
}
</div>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.PRIMARY}
onPress={this.onApplyTagsPress}
>
Apply
</Button>
</ModalFooter>
</ModalContent>
);
}
}
TagsModalContent.propTypes = {
seriesTags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired,
onApplyTagsPress: PropTypes.func.isRequired
};
export default TagsModalContent;
@@ -0,0 +1,36 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import TagsModalContent from './TagsModalContent';
function createMapStateToProps() {
return createSelector(
(state, { artistIds }) => artistIds,
createAllSeriesSelector(),
createTagsSelector(),
(artistIds, allSeries, tagList) => {
const series = _.intersectionWith(allSeries, artistIds, (s, id) => {
return s.id === id;
});
const seriesTags = _.uniq(_.concat(..._.map(series, 'tags')));
return {
seriesTags,
tagList
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onAction() {
// Do something
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(TagsModalContent);
+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);
}
}
+326
View File
@@ -0,0 +1,326 @@
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 NoArtist from 'Artist/NoArtist';
import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector';
import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector';
import ArtistIndexFooter from './ArtistIndexFooter';
import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu';
import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu';
import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu';
import styles from './ArtistIndex.css';
function getViewComponent(view) {
if (view === 'posters') {
return ArtistIndexPostersConnector;
}
return ArtistIndexTableConnector;
}
class ArtistIndex extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._viewComponent = null;
this.state = {
contentBody: null,
jumpBarItems: [],
isPosterOptionsModalOpen: false,
isRendered: false
};
}
componentDidMount() {
this.setJumpBarItems();
}
componentDidUpdate(prevProps) {
const {
items,
sortKey,
sortDirection
} = this.props;
if (
hasDifferentItems(prevProps.items, items) ||
sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection
) {
this.setJumpBarItems();
}
}
//
// Control
setContentBodyRef = (ref) => {
this.setState({ contentBody: ref });
}
setViewComponentRef = (ref) => {
this._viewComponent = ref;
}
setJumpBarItems() {
const {
items,
sortKey,
sortDirection
} = this.props;
// Reset if not sorting by sortTitle
if (sortKey !== 'sortName') {
this.setState({ jumpBarItems: [] });
return;
}
const characters = _.reduce(items, (acc, item) => {
const firstCharacter = item.sortName.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 });
}
onJumpBarItemPress = (item) => {
const viewComponent = this._viewComponent.getWrappedInstance();
viewComponent.scrollToFirstCharacter(item);
}
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,
items,
filterKey,
filterValue,
sortKey,
sortDirection,
view,
isRefreshingSeries,
isRssSyncExecuting,
scrollTop,
onSortSelect,
onFilterSelect,
onViewSelect,
onRefreshSeriesPress,
onRssSyncPress,
...otherProps
} = this.props;
const {
contentBody,
jumpBarItems,
isPosterOptionsModalOpen,
isRendered
} = this.state;
const ViewComponent = getViewComponent(view);
const isLoaded = !error && isPopulated && !!items.length && contentBody;
return (
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Update all"
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingSeries}
onPress={onRefreshSeriesPress}
/>
<PageToolbarButton
label="RSS Sync"
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
onPress={onRssSyncPress}
/>
</PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
collapseButtons={false}
>
{
view === 'posters' &&
<PageToolbarButton
label="Options"
iconName={icons.POSTER}
onPress={this.onPosterOptionsPress}
/>
}
{
view === 'posters' &&
<PageToolbarSeparator />
}
<ArtistIndexViewMenu
view={view}
onViewSelect={onViewSelect}
/>
<ArtistIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
onSortSelect={onSortSelect}
/>
<ArtistIndexFilterMenu
filterKey={filterKey}
filterValue={filterValue}
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
ref={this.setViewComponentRef}
contentBody={contentBody}
scrollTop={scrollTop}
onRender={this.onRender}
{...otherProps}
/>
<ArtistIndexFooter
series={items}
/>
</div>
}
{
!error && isPopulated && !items.length &&
<NoArtist />
}
</PageContentBodyConnector>
{
isLoaded && !!jumpBarItems.length &&
<PageJumpBar
items={jumpBarItems}
onItemPress={this.onJumpBarItemPress}
/>
}
</div>
<ArtistIndexPosterOptionsModal
isOpen={isPosterOptionsModalOpen}
onModalClose={this.onPosterOptionsModalClose}
/>
</PageContent>
);
}
}
ArtistIndex.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
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 ArtistIndex;
@@ -0,0 +1,160 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import dimensions from 'Styles/Variables/dimensions';
import createCommandSelector from 'Store/Selectors/createCommandSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { fetchArtist } from 'Store/Actions/seriesActions';
import scrollPositions from 'Store/scrollPositions';
import { setArtistSort, setArtistFilter, setArtistView } from 'Store/Actions/artistIndexActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import withScrollPosition from 'Components/withScrollPosition';
import ArtistIndex from './ArtistIndex';
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(
(state) => state.series,
(state) => state.seriesIndex,
createCommandSelector(commandNames.REFRESH_SERIES),
createCommandSelector(commandNames.RSS_SYNC),
createDimensionsSelector(),
(series, seriesIndex, isRefreshingSeries, isRssSyncExecuting, dimensionsState) => {
return {
isRefreshingSeries,
isRssSyncExecuting,
isSmallScreen: dimensionsState.isSmallScreen,
...series,
...seriesIndex
};
}
);
}
const mapDispatchToProps = {
fetchArtist,
setArtistSort,
setArtistFilter,
setArtistView,
executeCommand
};
class ArtistIndexConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
view,
scrollTop,
isSmallScreen
} = props;
this.state = {
scrollTop: getScrollTop(view, scrollTop, isSmallScreen)
};
}
componentDidMount() {
this.props.fetchArtist();
}
//
// Listeners
onSortSelect = (sortKey) => {
this.props.setArtistSort({ sortKey });
}
onFilterSelect = (filterKey, filterValue, filterType) => {
this.props.setArtistFilter({ filterKey, filterValue, filterType });
}
onViewSelect = (view) => {
// Reset the scroll position before changing the view
this.setState({ scrollTop: 0 }, () => {
this.props.setArtistView({ 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 (
<ArtistIndex
{...this.props}
scrollTop={this.state.scrollTop}
onSortSelect={this.onSortSelect}
onFilterSelect={this.onFilterSelect}
onViewSelect={this.onViewSelect}
onScroll={this.onScroll}
onRefreshSeriesPress={this.onRefreshSeriesPress}
onRssSyncPress={this.onRssSyncPress}
/>
);
}
}
ArtistIndexConnector.propTypes = {
view: PropTypes.string.isRequired,
scrollTop: PropTypes.number.isRequired,
fetchArtist: PropTypes.func.isRequired,
setArtistSort: PropTypes.func.isRequired,
setArtistFilter: PropTypes.func.isRequired,
setArtistView: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default withScrollPosition(
connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexConnector),
'seriesIndex'
);
@@ -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,104 @@
import PropTypes from 'prop-types';
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import styles from './ArtistIndexFooter.css';
function ArtistIndexFooter({ series }) {
const count = series.length;
let episodes = 0;
let episodeFiles = 0;
let ended = 0;
let continuing = 0;
let monitored = 0;
series.forEach((s) => {
episodes += s.trackCount || 0;
episodeFiles += s.trackFileCount || 0;
if (s.status === 'ended') {
ended++;
} else {
continuing++;
}
if (s.monitored) {
monitored++;
}
});
return (
<div className={styles.footer}>
<div>
<div className={styles.legendItem}>
<div className={styles.continuing}></div>
<div>Continuing (All tracks downloaded)</div>
</div>
<div className={styles.legendItem}>
<div className={styles.ended}></div>
<div>Ended (All tracks downloaded)</div>
</div>
<div className={styles.legendItem}>
<div className={styles.missingMonitored}></div>
<div>Missing Tracks (Artist monitored)</div>
</div>
<div className={styles.legendItem}>
<div className={styles.missingUnmonitored}></div>
<div>Missing Tracks (Artist not monitored)</div>
</div>
</div>
<div className={styles.statistics}>
<DescriptionList>
<DescriptionListItem
title="Artist"
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="Tracks"
data={episodes}
/>
<DescriptionListItem
title="Files"
data={episodeFiles}
/>
</DescriptionList>
</div>
</div>
);
}
ArtistIndexFooter.propTypes = {
series: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default ArtistIndexFooter;
@@ -0,0 +1,77 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
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 createMapStateToProps() {
return createSelector(
(state, { id }) => id,
(state, { seasons }) => seasons,
createQualityProfileSelector(),
createLanguageProfileSelector(),
createCommandsSelector(),
(artistId, seasons, qualityProfile, languageProfile, commands) => {
const isRefreshingSeries = _.some(commands, (command) => {
return command.name === commandNames.REFRESH_SERIES &&
command.body.artistId === artistId;
});
const latestSeason = _.maxBy(seasons, (season) => season.seasonNumber);
return {
qualityProfile,
languageProfile,
latestSeason,
isRefreshingSeries
};
}
);
}
const mapDispatchToProps = {
executeCommand
};
class ArtistIndexItemConnector extends Component {
//
// Listeners
onRefreshSeriesPress = () => {
this.props.executeCommand({
name: commandNames.REFRESH_SERIES,
artistId: this.props.id
});
}
//
// Render
render() {
const {
component: ItemComponent,
...otherProps
} = this.props;
return (
<ItemComponent
{...otherProps}
onRefreshSeriesPress={this.onRefreshSeriesPress}
/>
);
}
}
ArtistIndexItemConnector.propTypes = {
id: PropTypes.number.isRequired,
component: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexItemConnector);
@@ -0,0 +1,76 @@
import PropTypes from 'prop-types';
import React from 'react';
import { align } from 'Helpers/Props';
import FilterMenu from 'Components/Menu/FilterMenu';
import MenuContent from 'Components/Menu/MenuContent';
import FilterMenuItem from 'Components/Menu/FilterMenuItem';
function ArtistIndexFilterMenu(props) {
const {
filterKey,
filterValue,
onFilterSelect
} = props;
return (
<FilterMenu alignMenu={align.RIGHT}>
<MenuContent>
<FilterMenuItem
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
All
</FilterMenuItem>
<FilterMenuItem
name="monitored"
value={true}
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Monitored Only
</FilterMenuItem>
<FilterMenuItem
name="status"
value="continuing"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Continuing Only
</FilterMenuItem>
<FilterMenuItem
name="status"
value="ended"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Ended Only
</FilterMenuItem>
<FilterMenuItem
name="missing"
value={true}
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Missing Episodes
</FilterMenuItem>
</MenuContent>
</FilterMenu>
);
}
ArtistIndexFilterMenu.propTypes = {
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
onFilterSelect: PropTypes.func.isRequired
};
export default ArtistIndexFilterMenu;
@@ -0,0 +1,145 @@
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 ArtistIndexSortMenu(props) {
const {
sortKey,
sortDirection,
onSortSelect
} = props;
return (
<SortMenu alignMenu={align.RIGHT}>
<MenuContent>
<SortMenuItem
name="sortName"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Name
</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="albumCount"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Albums
</SortMenuItem>
<SortMenuItem
name="trackProgress"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Tracks
</SortMenuItem>
<SortMenuItem
name="trackCount"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Track 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>
);
}
ArtistIndexSortMenu.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
onSortSelect: PropTypes.func.isRequired
};
export default ArtistIndexSortMenu;
@@ -0,0 +1,42 @@
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 ArtistIndexViewMenu(props) {
const {
view,
onViewSelect
} = props;
return (
<ViewMenu alignMenu={align.RIGHT}>
<MenuContent>
<ViewMenuItem
name="table"
selectedView={view}
onPress={onViewSelect}
>
Table
</ViewMenuItem>
<ViewMenuItem
name="posters"
selectedView={view}
onPress={onViewSelect}
>
Posters
</ViewMenuItem>
</MenuContent>
</ViewMenu>
);
}
ArtistIndexViewMenu.propTypes = {
view: PropTypes.string.isRequired,
onViewSelect: PropTypes.func.isRequired
};
export default ArtistIndexViewMenu;
@@ -0,0 +1,88 @@
$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;
// Transforming causes the content to shift slightly
// transform: scale($hoverScale);
.controls {
opacity: 0.9;
transition: opacity 200ms linear 150ms;
}
}
}
.posterContainer {
position: relative;
}
.link {
composes: link from 'Components/Link/Link.css';
display: block;
background-color: $defaultColor;
}
.nextAiring {
background-color: $defaultColor;
color: $white;
text-align: center;
font-size: $smallFontSize;
}
.title {
composes: truncate from 'Styles/mixins/truncate.css';
background-color: $defaultColor;
color: $white;
text-align: center;
font-size: $smallFontSize;
}
.ended {
position: absolute;
top: 0;
right: 0;
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;
border-radius: 4px;
background-color: #216044;
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,230 @@
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 ArtistPoster from 'Artist/ArtistPoster';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import ArtistIndexPosterProgressBar from './ArtistIndexPosterProgressBar';
import ArtistIndexPosterInfo from './ArtistIndexPosterInfo';
import styles from './ArtistIndexPoster.css';
class ArtistIndexPoster extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false
};
}
//
// Listeners
onEditSeriesPress = () => {
this.setState({ isEditArtistModalOpen: true });
}
onEditSeriesModalClose = () => {
this.setState({ isEditArtistModalOpen: false });
}
onDeleteSeriesPress = () => {
this.setState({
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: true
});
}
onDeleteSeriesModalClose = () => {
this.setState({ isDeleteArtistModalOpen: false });
}
//
// Render
render() {
const {
style,
id,
artistName,
monitored,
status,
nameSlug,
nextAiring,
trackCount,
trackFileCount,
images,
posterWidth,
posterHeight,
detailedProgressBar,
showTitle,
showQualityProfile,
qualityProfile,
showRelativeDates,
shortDateFormat,
timeFormat,
isRefreshingSeries,
onRefreshSeriesPress,
...otherProps
} = this.props;
const {
isEditArtistModalOpen,
isDeleteArtistModalOpen
} = this.state;
const link = `/series/${nameSlug}`;
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 Artist"
isSpinning={isRefreshingSeries}
onPress={onRefreshSeriesPress}
/>
<IconButton
className={styles.action}
name={icons.EDIT}
title="Edit Artist"
onPress={this.onEditSeriesPress}
/>
</Label>
{
status === 'ended' &&
<div
className={styles.ended}
title="Ended"
/>
}
<Link
className={styles.link}
style={elementStyle}
to={link}
>
<ArtistPoster
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
/>
</Link>
</div>
<ArtistIndexPosterProgressBar
monitored={monitored}
status={status}
trackCount={trackCount}
trackFileCount={trackFileCount}
posterWidth={posterWidth}
detailedProgressBar={detailedProgressBar}
/>
{
showTitle &&
<div className={styles.title}>
{artistName}
</div>
}
{
showQualityProfile &&
<div className={styles.title}>
{qualityProfile.name}
</div>
}
<div className={styles.nextAiring}>
{
getRelativeDate(
nextAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
</div>
<ArtistIndexPosterInfo
qualityProfile={qualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
{...otherProps}
/>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</div>
</div>
);
}
}
ArtistIndexPoster.propTypes = {
style: PropTypes.object.isRequired,
id: PropTypes.number.isRequired,
artistName: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
nameSlug: PropTypes.string.isRequired,
nextAiring: PropTypes.string,
trackCount: PropTypes.number,
trackFileCount: PropTypes.number,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
posterWidth: PropTypes.number.isRequired,
posterHeight: PropTypes.number.isRequired,
detailedProgressBar: PropTypes.bool.isRequired,
showTitle: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
qualityProfile: PropTypes.object.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
isRefreshingSeries: PropTypes.bool.isRequired,
onRefreshSeriesPress: PropTypes.func.isRequired
};
ArtistIndexPoster.defaultProps = {
trackCount: 0,
trackFileCount: 0
};
export default ArtistIndexPoster;
@@ -0,0 +1,6 @@
.info {
background-color: $defaultColor;
color: $white;
text-align: center;
font-size: $smallFontSize;
}
@@ -0,0 +1,123 @@
import PropTypes from 'prop-types';
import React from 'react';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
import styles from './ArtistIndexPosterInfo.css';
function ArtistIndexPosterInfo(props) {
const {
network,
qualityProfile,
previousAiring,
added,
albumCount,
path,
sizeOnDisk,
sortKey,
showRelativeDates,
shortDateFormat,
timeFormat
} = props;
if (sortKey === 'network' && network) {
return (
<div className={styles.info}>
{network}
</div>
);
}
if (sortKey === 'qualityProfileId') {
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 === 'albumCount') {
let seasons = '1 season';
if (albumCount === 0) {
seasons = 'No seasons';
} else if (albumCount > 1) {
seasons = `${albumCount} 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;
}
ArtistIndexPosterInfo.propTypes = {
network: PropTypes.string,
qualityProfile: PropTypes.object.isRequired,
previousAiring: PropTypes.string,
added: PropTypes.string,
albumCount: 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 ArtistIndexPosterInfo;
@@ -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,45 @@
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 './ArtistIndexPosterProgressBar.css';
function ArtistIndexPosterProgressBar(props) {
const {
monitored,
status,
trackCount,
trackFileCount,
posterWidth,
detailedProgressBar
} = props;
const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
const text = `${trackFileCount} / ${trackCount}`;
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={detailedProgressBar ? null : text}
width={posterWidth}
/>
);
}
ArtistIndexPosterProgressBar.propTypes = {
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
trackCount: PropTypes.number.isRequired,
trackFileCount: PropTypes.number.isRequired,
posterWidth: PropTypes.number.isRequired,
detailedProgressBar: PropTypes.bool.isRequired
};
export default ArtistIndexPosterProgressBar;
@@ -0,0 +1,3 @@
.grid {
flex: 1 0 auto;
}
@@ -0,0 +1,326 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Measure from 'react-measure';
import { Grid, WindowScroller } from 'react-virtualized';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import dimensions from 'Styles/Variables/dimensions';
import { sortDirections } from 'Helpers/Props';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
import ArtistIndexPoster from './ArtistIndexPoster';
import styles from './ArtistIndexPosters.css';
// Poster container dimensions
const columnPadding = 20;
const columnPaddingSmallScreen = 10;
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,
showQualityProfile
} = posterOptions;
const nextAiringHeight = 19;
const heights = [
posterHeight,
detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
nextAiringHeight,
isSmallScreen ? columnPaddingSmallScreen : columnPadding
];
if (showTitle) {
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;
}
return heights.reduce((acc, height) => acc + height, 0);
}
function calculatePosterHeight(posterWidth) {
return Math.ceil((250 / 170) * posterWidth);
}
class ArtistIndexPosters 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,
filterKey,
filterValue,
sortKey,
sortDirection,
posterOptions
} = this.props;
const itemsChanged = hasDifferentItems(prevProps.items, items);
if (
prevProps.sortKey !== sortKey ||
prevProps.posterOptions !== posterOptions ||
itemsChanged
) {
this.calculateGrid();
}
if (
prevProps.filterKey !== filterKey ||
prevProps.filterValue !== filterValue ||
prevProps.sortKey !== sortKey ||
prevProps.sortDirection !== sortDirection ||
itemsChanged
) {
this._grid.recomputeGridSize();
}
}
//
// Control
scrollToFirstCharacter(character) {
const items = this.props.items;
const {
columnCount,
rowHeight
} = this.state;
const index = _.findIndex(items, (item) => {
const firstCharacter = item.sortTitle.charAt(0);
if (character === '#') {
return !isNaN(firstCharacter);
}
return firstCharacter === character;
});
if (index != null) {
const row = Math.floor(index / columnCount);
const scrollTop = rowHeight * row;
this.props.onScroll({ scrollTop });
}
}
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, this.props.posterOptions.size);
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,
showQualityProfile
} = posterOptions;
const series = items[rowIndex * columnCount + columnIndex];
if (!series) {
return null;
}
return (
<ArtistIndexItemConnector
key={key}
component={ArtistIndexPoster}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
detailedProgressBar={detailedProgressBar}
showTitle={showTitle}
showQualityProfile={showQualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
style={style}
{...series}
/>
);
}
//
// 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 ? null : 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>
);
}
}
ArtistIndexPosters.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
posterOptions: PropTypes.object.isRequired,
scrollTop: PropTypes.number.isRequired,
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 ArtistIndexPosters;
@@ -0,0 +1,33 @@
import { createSelector } from 'reselect';
import connectSection from 'Store/connectSection';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import ArtistIndexPosters from './ArtistIndexPosters';
function createMapStateToProps() {
return createSelector(
(state) => state.seriesIndex.posterOptions,
createClientSideCollectionSelector(),
createUISettingsSelector(),
createDimensionsSelector(),
(posterOptions, series, uiSettings, dimensions) => {
return {
posterOptions,
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
timeFormat: uiSettings.timeFormat,
isSmallScreen: dimensions.isSmallScreen,
...series
};
}
);
}
export default connectSection(
createMapStateToProps,
undefined,
undefined,
{ withRef: true },
{ section: 'series', uiSection: 'seriesIndex' }
)(ArtistIndexPosters);
@@ -0,0 +1,25 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ArtistIndexPosterOptionsModalContentConnector from './ArtistIndexPosterOptionsModalContentConnector';
function ArtistIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ArtistIndexPosterOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
ArtistIndexPosterOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistIndexPosterOptionsModal;
@@ -0,0 +1,173 @@
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 ArtistIndexPosterOptionsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
detailedProgressBar: props.detailedProgressBar,
size: props.size,
showTitle: props.showTitle,
showQualityProfile: props.showQualityProfile
};
}
componentDidUpdate(prevProps) {
const {
detailedProgressBar,
size,
showTitle,
showQualityProfile
} = 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 (showQualityProfile !== prevProps.showQualityProfile) {
state.showQualityProfile = showQualityProfile;
}
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,
showQualityProfile
} = 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 Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
helpText="Show quality profile under poster"
onChange={this.onChangePosterOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
ArtistIndexPosterOptionsModalContent.propTypes = {
size: PropTypes.string.isRequired,
showTitle: PropTypes.bool.isRequired,
showQualityProfile: PropTypes.bool.isRequired,
detailedProgressBar: PropTypes.bool.isRequired,
onChangePosterOption: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistIndexPosterOptionsModalContent;
@@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setArtistPosterOption } from 'Store/Actions/artistIndexActions';
import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.seriesIndex,
(seriesIndex) => {
return seriesIndex.posterOptions;
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onChangePosterOption(payload) {
dispatch(setArtistPosterOption(payload));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexPosterOptionsModalContent);
@@ -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 EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
class ArtistIndexActionsCell extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false
};
}
//
// Listeners
onEditSeriesPress = () => {
this.setState({ isEditArtistModalOpen: true });
}
onEditSeriesModalClose = () => {
this.setState({ isEditArtistModalOpen: false });
}
onDeleteSeriesPress = () => {
this.setState({
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: true
});
}
onDeleteSeriesModalClose = () => {
this.setState({ isDeleteArtistModalOpen: false });
}
//
// Render
render() {
const {
id,
isRefreshingSeries,
onRefreshSeriesPress,
...otherProps
} = this.props;
const {
isEditArtistModalOpen,
isDeleteArtistModalOpen
} = this.state;
return (
<VirtualTableRowCell
{...otherProps}
>
<SpinnerIconButton
name={icons.REFRESH}
title="Refresh Artist"
isSpinning={isRefreshingSeries}
onPress={onRefreshSeriesPress}
/>
<IconButton
name={icons.EDIT}
title="Edit Artist"
onPress={this.onEditSeriesPress}
/>
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</VirtualTableRowCell>
);
}
}
ArtistIndexActionsCell.propTypes = {
id: PropTypes.number.isRequired,
isRefreshingSeries: PropTypes.bool.isRequired,
onRefreshSeriesPress: PropTypes.func.isRequired
};
export default ArtistIndexActionsCell;
@@ -0,0 +1,81 @@
.status {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 60px;
}
.sortName {
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 {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 180px;
}
.albumCount {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 100px;
}
.trackProgress,
.latestSeason {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 150px;
}
.trackCount {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 120px;
}
.path {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 1 0 150px;
}
.sizeOnDisk {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 110px;
}
.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 0 70px;
}
@@ -0,0 +1,106 @@
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 styles from './ArtistIndexHeader.css';
class ArtistIndexHeader extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isTableOptionsModalOpen: false
};
}
//
// Listeners
onTableOptionsPress = () => {
this.setState({ isTableOptionsModalOpen: true });
}
onTableOptionsModalClose = () => {
this.setState({ isTableOptionsModalOpen: false });
}
//
// Render
render() {
const {
columns,
onTableOptionChange,
...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}
onTableOptionChange={onTableOptionChange}
onModalClose={this.onTableOptionsModalClose}
/>
</VirtualTableHeader>
);
}
}
ArtistIndexHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired
};
export default ArtistIndexHeader;
@@ -0,0 +1,13 @@
import { connect } from 'react-redux';
import { setArtistTableOption } from 'Store/Actions/artistIndexActions';
import ArtistIndexHeader from './ArtistIndexHeader';
function createMapDispatchToProps(dispatch, props) {
return {
onTableOptionChange(payload) {
dispatch(setArtistTableOption(payload));
}
};
}
export default connect(undefined, createMapDispatchToProps)(ArtistIndexHeader);
@@ -0,0 +1,90 @@
.status {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 0 60px;
}
.sortName {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 4 0 110px;
}
.network {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 2 0 90px;
}
.qualityProfileId,
.languageProfileId {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 1 0 125px;
}
.nextAiring,
.previousAiring,
.added {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 0 180px;
}
.albumCount {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 0 100px;
}
.trackProgress,
.latestSeason {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
display: flex;
justify-content: center;
flex: 0 0 150px;
flex-direction: column;
}
.trackCount {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 0 120px;
}
.path {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 1 0 150px;
}
.sizeOnDisk {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 0 110px;
}
.tags {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 1 0 60px;
}
.useSceneNumbering {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 0 145px;
}
.actions {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 0 70px;
}
.checkInput {
composes: input from 'Components/Form/CheckInput.css';
margin-top: 0;
}
@@ -0,0 +1,389 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
import formatBytes from 'Utilities/Number/formatBytes';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
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 ArtistNameLink from 'Artist/ArtistNameLink';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import ArtistStatusCell from './ArtistStatusCell';
import styles from './ArtistIndexRow.css';
class ArtistIndexRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false
};
}
onEditSeriesPress = () => {
this.setState({ isEditArtistModalOpen: true });
}
onEditSeriesModalClose = () => {
this.setState({ isEditArtistModalOpen: false });
}
onDeleteSeriesPress = () => {
this.setState({
isEditArtistModalOpen: false,
isDeleteArtistModalOpen: true
});
}
onDeleteSeriesModalClose = () => {
this.setState({ isDeleteArtistModalOpen: false });
}
onUseSceneNumberingChange = () => {
// Mock handler to satisfy `onChange` being required for `CheckInput`.
//
}
//
// Render
render() {
const {
style,
id,
monitored,
status,
artistName,
nameSlug,
network,
qualityProfile,
languageProfile,
nextAiring,
previousAiring,
added,
albumCount,
trackCount,
trackFileCount,
totalTrackCount,
latestSeason,
path,
sizeOnDisk,
tags,
// useSceneNumbering,
columns,
isRefreshingSeries,
onRefreshSeriesPress
} = this.props;
const {
isEditArtistModalOpen,
isDeleteArtistModalOpen
} = this.state;
return (
<VirtualTableRow style={style}>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<ArtistStatusCell
key={name}
className={styles[name]}
monitored={monitored}
status={status}
component={VirtualTableRowCell}
/>
);
}
if (name === 'sortName') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<ArtistNameLink
nameSlug={nameSlug}
artistName={artistName}
/>
</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 === 'albumCount') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{albumCount}
</VirtualTableRowCell>
);
}
if (name === 'trackProgress') {
const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<ProgressBar
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
showText={true}
text={`${trackFileCount} / ${trackCount}`}
title={`${trackFileCount} / ${trackCount} (Total: ${totalTrackCount})`}
width={125}
/>
</VirtualTableRowCell>
);
}
if (name === 'latestSeason') {
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 === 'trackCount') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{totalTrackCount}
</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 === '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 Artist"
isSpinning={isRefreshingSeries}
onPress={onRefreshSeriesPress}
/>
<IconButton
name={icons.EDIT}
title="Edit Artist"
onPress={this.onEditSeriesPress}
/>
</VirtualTableRowCell>
);
}
return null;
})
}
<EditArtistModalConnector
isOpen={isEditArtistModalOpen}
artistId={id}
onModalClose={this.onEditSeriesModalClose}
onDeleteSeriesPress={this.onDeleteSeriesPress}
/>
<DeleteArtistModal
isOpen={isDeleteArtistModalOpen}
artistId={id}
onModalClose={this.onDeleteSeriesModalClose}
/>
</VirtualTableRow>
);
}
}
ArtistIndexRow.propTypes = {
style: PropTypes.object.isRequired,
id: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
artistName: PropTypes.string.isRequired,
nameSlug: PropTypes.string.isRequired,
network: PropTypes.string,
qualityProfile: PropTypes.object.isRequired,
languageProfile: PropTypes.object.isRequired,
nextAiring: PropTypes.string,
previousAiring: PropTypes.string,
added: PropTypes.string,
albumCount: PropTypes.number.isRequired,
trackCount: PropTypes.number,
trackFileCount: PropTypes.number,
totalTrackCount: PropTypes.number,
latestSeason: PropTypes.object,
path: PropTypes.string.isRequired,
sizeOnDisk: PropTypes.number,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
// useSceneNumbering: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isRefreshingSeries: PropTypes.bool.isRequired,
onRefreshSeriesPress: PropTypes.func.isRequired
};
ArtistIndexRow.defaultProps = {
trackCount: 0,
trackFileCount: 0
};
export default ArtistIndexRow;
@@ -0,0 +1,5 @@
.tableContainer {
composes: tableContainer from 'Components/Table/VirtualTable.css';
flex: 1 0 auto;
}
@@ -0,0 +1,143 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { sortDirections } from 'Helpers/Props';
import VirtualTable from 'Components/Table/VirtualTable';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
import ArtistIndexHeaderConnector from './ArtistIndexHeaderConnector';
import ArtistIndexRow from './ArtistIndexRow';
import styles from './ArtistIndexTable.css';
class ArtistIndexTable extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._table = null;
}
componentDidUpdate(prevProps) {
const {
columns,
filterKey,
filterValue,
sortKey,
sortDirection
} = this.props;
if (prevProps.columns !== columns ||
prevProps.filterKey !== filterKey ||
prevProps.filterValue !== filterValue ||
prevProps.sortKey !== sortKey ||
prevProps.sortDirection !== sortDirection
) {
this._table.forceUpdateGrid();
}
}
//
// Control
scrollToFirstCharacter(character) {
const items = this.props.items;
const row = _.findIndex(items, (item) => {
const firstCharacter = item.sortTitle.charAt(0);
if (character === '#') {
return !isNaN(firstCharacter);
}
return firstCharacter === character;
});
if (row != null) {
this._table.scrollToRow(row);
}
}
setTableRef = (ref) => {
this._table = ref;
}
rowRenderer = ({ key, rowIndex, style }) => {
const {
items,
columns
} = this.props;
const series = items[rowIndex];
return (
<ArtistIndexItemConnector
key={key}
component={ArtistIndexRow}
style={style}
columns={columns}
{...series}
/>
);
}
//
// Render
render() {
const {
items,
columns,
sortKey,
sortDirection,
isSmallScreen,
scrollTop,
contentBody,
onSortPress,
onRender,
onScroll
} = this.props;
return (
<VirtualTable
ref={this.setTableRef}
className={styles.tableContainer}
items={items}
scrollTop={scrollTop}
contentBody={contentBody}
isSmallScreen={isSmallScreen}
rowHeight={38}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<ArtistIndexHeaderConnector
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
/>
}
onRender={onRender}
onScroll={onScroll}
/>
);
}
}
ArtistIndexTable.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
filterKey: PropTypes.string,
filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
scrollTop: PropTypes.number.isRequired,
contentBody: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onSortPress: PropTypes.func.isRequired,
onRender: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired
};
export default ArtistIndexTable;
@@ -0,0 +1,34 @@
import { createSelector } from 'reselect';
import connectSection from 'Store/connectSection';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { setArtistSort } from 'Store/Actions/artistIndexActions';
import ArtistIndexTable from './ArtistIndexTable';
function createMapStateToProps() {
return createSelector(
(state) => state.app.dimensions,
createClientSideCollectionSelector(),
(dimensions, series) => {
return {
isSmallScreen: dimensions.isSmallScreen,
...series
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSortPress(sortKey) {
dispatch(setArtistSort({ sortKey }));
}
};
}
export default connectSection(
createMapStateToProps,
createMapDispatchToProps,
undefined,
{ withRef: true },
{ section: 'series', uiSection: 'seriesIndex' }
)(ArtistIndexTable);
@@ -0,0 +1,9 @@
.status {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 60px;
}
.statusIcon {
width: 20px;
}
@@ -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 './ArtistStatusCell.css';
function ArtistStatusCell(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 ? 'Artist is monitored' : 'Artist is unmonitored'}
/>
<Icon
className={styles.statusIcon}
name={status === 'ended' ? icons.SERIES_ENDED : icons.SERIES_CONTINUING}
title={status === 'ended' ? 'Ended' : 'Continuing'}
/>
</Component>
);
}
ArtistStatusCell.propTypes = {
className: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
component: PropTypes.func
};
ArtistStatusCell.defaultProps = {
className: styles.status,
component: VirtualTableRowCell
};
export default ArtistStatusCell;
@@ -0,0 +1,138 @@
import React from 'react';
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
import ProgressBar from 'Components/ProgressBar';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import ArtistNameLink from 'Artist/ArtistNameLink';
import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
import ArtistIndexActionsCell from './ArtistIndexActionsCell';
import ArtistStatusCell from './ArtistStatusCell';
export default function artistIndexCellRenderers(cellProps) {
const {
cellKey,
dataKey,
rowData,
...otherProps
} = cellProps;
const {
id,
monitored,
status,
name,
nameSlug,
network,
qualityProfileId,
nextAiring,
previousAiring,
albumCount,
trackCount,
trackFileCount
} = rowData;
const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
if (dataKey === 'status') {
return (
<ArtistStatusCell
key={cellKey}
monitored={monitored}
status={status}
component={VirtualTableRowCell}
{...otherProps}
/>
);
}
if (dataKey === 'sortTitle') {
return (
<VirtualTableRowCell
key={cellKey}
{...otherProps}
>
<ArtistNameLink
nameSlug={nameSlug}
name={name}
/>
</VirtualTableRowCell>
);
}
if (dataKey === 'network') {
return (
<VirtualTableRowCell
key={cellKey}
{...otherProps}
>
{network}
</VirtualTableRowCell>
);
}
if (dataKey === 'qualityProfileId') {
return (
<VirtualTableRowCell
key={cellKey}
{...otherProps}
>
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
</VirtualTableRowCell>
);
}
if (dataKey === 'nextAiring') {
return (
<RelativeDateCellConnector
key={cellKey}
date={nextAiring}
component={VirtualTableRowCell}
{...otherProps}
/>
);
}
if (dataKey === 'seasonCount') {
return (
<VirtualTableRowCell
key={cellKey}
{...otherProps}
>
{albumCount}
</VirtualTableRowCell>
);
}
if (dataKey === 'episodeProgress') {
return (
<VirtualTableRowCell
key={cellKey}
{...otherProps}
>
<ProgressBar
progress={progress}
kind={getProgressBarKind(status, monitored, progress)}
showText={true}
text={`${trackFileCount} / ${trackCount}`}
width={125}
/>
</VirtualTableRowCell>
);
}
if (dataKey === 'actions') {
return (
<ArtistIndexItemConnector
key={cellKey}
component={ArtistIndexActionsCell}
id={id}
{...otherProps}
/>
);
}
}
+11
View File
@@ -0,0 +1,11 @@
.message {
margin-top: 10px;
margin-bottom: 30px;
text-align: center;
font-size: 20px;
}
.buttonContainer {
margin-top: 20px;
text-align: center;
}
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { kinds } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import styles from './NoArtist.css';
function NoArtist() {
return (
<div>
<div className={styles.message}>
No artist found, to get started you'll want to add a new artist or import some existing ones.
</div>
<div className={styles.buttonContainer}>
<Button
to="/add/import"
kind={kinds.PRIMARY}
>
Import Existing Artist(s)
</Button>
</div>
<div className={styles.buttonContainer}>
<Button
to="/add/new"
kind={kinds.PRIMARY}
>
Add New Artist
</Button>
</div>
</div>
);
}
export default NoArtist;