mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-18 21:35:27 -04:00
v3 UI
This commit is contained in:
committed by
Taloth Saldono
parent
99feff549d
commit
5894b4fd95
@@ -0,0 +1,39 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector';
|
||||
|
||||
class EpisodeDetailsModal extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
size={sizes.EXTRA_LARGE}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EpisodeDetailsModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeDetailsModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeDetailsModal;
|
||||
@@ -0,0 +1,44 @@
|
||||
.seriesTitle {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: -32px;
|
||||
}
|
||||
|
||||
.tabList {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
bottom: -1px;
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-top: none;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectedTab {
|
||||
border-color: $borderColor;
|
||||
border-radius: 0 0 5px 5px;
|
||||
background-color: rgba(239, 239, 239, 0.4);
|
||||
color: $black;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.openSeriesButton {
|
||||
composes: button from 'Components/Link/Button.css';
|
||||
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
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 MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import EpisodeSummaryConnector from './Summary/EpisodeSummaryConnector';
|
||||
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
|
||||
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
|
||||
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
|
||||
import styles from './EpisodeDetailsModalContent.css';
|
||||
|
||||
const tabs = [
|
||||
'details',
|
||||
'history',
|
||||
'search'
|
||||
];
|
||||
|
||||
class EpisodeDetailsModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
selectedTab: props.selectedTab
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onTabSelect = (index, lastIndex) => {
|
||||
this.setState({ selectedTab: tabs[index] });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
episodeId,
|
||||
episodeEntity,
|
||||
episodeFileId,
|
||||
seriesId,
|
||||
seriesTitle,
|
||||
titleSlug,
|
||||
seriesMonitored,
|
||||
seriesType,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
episodeTitle,
|
||||
airDate,
|
||||
monitored,
|
||||
isSaving,
|
||||
showOpenSeriesButton,
|
||||
startInteractiveSearch,
|
||||
onMonitorEpisodePress,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const seriesLink = `/series/${titleSlug}`;
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalHeader>
|
||||
<MonitorToggleButton
|
||||
className={styles.toggleButton}
|
||||
id={episodeId}
|
||||
monitored={monitored}
|
||||
size={18}
|
||||
isDisabled={!seriesMonitored}
|
||||
isSaving={isSaving}
|
||||
onPress={onMonitorEpisodePress}
|
||||
/>
|
||||
|
||||
<span className={styles.seriesTitle}>
|
||||
{seriesTitle}
|
||||
</span>
|
||||
|
||||
<span className={styles.separator}>-</span>
|
||||
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={seasonNumber}
|
||||
episodeNumber={episodeNumber}
|
||||
absoluteEpisodeNumber={absoluteEpisodeNumber}
|
||||
airDate={airDate}
|
||||
seriesType={seriesType}
|
||||
/>
|
||||
|
||||
<span className={styles.separator}>-</span>
|
||||
|
||||
{episodeTitle}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Tabs
|
||||
className={styles.tabs}
|
||||
selectedIndex={tabs.indexOf(this.state.selectedTab)}
|
||||
onSelect={this.onTabSelect}
|
||||
>
|
||||
<TabList
|
||||
className={styles.tabList}
|
||||
>
|
||||
<Tab
|
||||
className={styles.tab}
|
||||
selectedClassName={styles.selectedTab}
|
||||
>
|
||||
Details
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
className={styles.tab}
|
||||
selectedClassName={styles.selectedTab}
|
||||
>
|
||||
History
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
className={styles.tab}
|
||||
selectedClassName={styles.selectedTab}
|
||||
>
|
||||
Search
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel>
|
||||
<div className={styles.tabContent}>
|
||||
<EpisodeSummaryConnector
|
||||
episodeId={episodeId}
|
||||
episodeEntity={episodeEntity}
|
||||
episodeFileId={episodeFileId}
|
||||
seriesId={seriesId}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<div className={styles.tabContent}>
|
||||
<EpisodeHistoryConnector
|
||||
episodeId={episodeId}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{/* Don't wrap in tabContent so we not have a top margin */}
|
||||
<EpisodeSearchConnector
|
||||
episodeId={episodeId}
|
||||
startInteractiveSearch={startInteractiveSearch}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{
|
||||
showOpenSeriesButton &&
|
||||
<Button
|
||||
className={styles.openSeriesButton}
|
||||
to={seriesLink}
|
||||
onPress={onModalClose}
|
||||
>
|
||||
Open Series
|
||||
</Button>
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeDetailsModalContent.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
episodeEntity: PropTypes.string.isRequired,
|
||||
episodeFileId: PropTypes.number,
|
||||
seriesId: PropTypes.number.isRequired,
|
||||
seriesTitle: PropTypes.string.isRequired,
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
seriesMonitored: PropTypes.bool.isRequired,
|
||||
seriesType: PropTypes.string.isRequired,
|
||||
seasonNumber: PropTypes.number.isRequired,
|
||||
episodeNumber: PropTypes.number.isRequired,
|
||||
absoluteEpisodeNumber: PropTypes.number,
|
||||
airDate: PropTypes.string.isRequired,
|
||||
episodeTitle: PropTypes.string.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool,
|
||||
showOpenSeriesButton: PropTypes.bool,
|
||||
selectedTab: PropTypes.string.isRequired,
|
||||
startInteractiveSearch: PropTypes.bool.isRequired,
|
||||
onMonitorEpisodePress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EpisodeDetailsModalContent.defaultProps = {
|
||||
selectedTab: 'details',
|
||||
episodeEntity: episodeEntities.EPISODES,
|
||||
startInteractiveSearch: false
|
||||
};
|
||||
|
||||
export default EpisodeDetailsModalContent;
|
||||
@@ -0,0 +1,100 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
|
||||
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
|
||||
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createEpisodeSelector(),
|
||||
createSeriesSelector(),
|
||||
(episode, series) => {
|
||||
const {
|
||||
title: seriesTitle,
|
||||
titleSlug,
|
||||
monitored: seriesMonitored,
|
||||
seriesType
|
||||
} = series;
|
||||
|
||||
return {
|
||||
seriesTitle,
|
||||
titleSlug,
|
||||
seriesMonitored,
|
||||
seriesType,
|
||||
...episode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
dispatchCancelFetchReleases() {
|
||||
dispatch(cancelFetchReleases());
|
||||
},
|
||||
|
||||
dispatchClearReleases() {
|
||||
dispatch(clearReleases());
|
||||
},
|
||||
|
||||
onMonitorEpisodePress(monitored) {
|
||||
const {
|
||||
episodeId,
|
||||
episodeEntity
|
||||
} = props;
|
||||
|
||||
dispatch(toggleEpisodeMonitored({
|
||||
episodeEntity,
|
||||
episodeId,
|
||||
monitored
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class EpisodeDetailsModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentWillUnmount() {
|
||||
// Clear pending releases here so we can reshow the search
|
||||
// results even after switching tabs.
|
||||
|
||||
this.props.dispatchCancelFetchReleases();
|
||||
this.props.dispatchClearReleases();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatchClearReleases,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<EpisodeDetailsModalContent {...otherProps} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeDetailsModalContentConnector.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
episodeEntity: PropTypes.string.isRequired,
|
||||
seriesId: PropTypes.number.isRequired,
|
||||
dispatchCancelFetchReleases: PropTypes.func.isRequired,
|
||||
dispatchClearReleases: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EpisodeDetailsModalContentConnector.defaultProps = {
|
||||
episodeEntity: episodeEntities.EPISODES
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector);
|
||||
@@ -0,0 +1,37 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
|
||||
function EpisodeLanguage(props) {
|
||||
const {
|
||||
className,
|
||||
language,
|
||||
isCutoffNotMet
|
||||
} = props;
|
||||
|
||||
if (!language) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
className={className}
|
||||
kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT}
|
||||
>
|
||||
{language.name}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeLanguage.propTypes = {
|
||||
className: PropTypes.string,
|
||||
language: PropTypes.object,
|
||||
isCutoffNotMet: PropTypes.bool
|
||||
};
|
||||
|
||||
EpisodeLanguage.defaultProps = {
|
||||
isCutoffNotMet: true
|
||||
};
|
||||
|
||||
export default EpisodeLanguage;
|
||||
@@ -0,0 +1,7 @@
|
||||
.absoluteEpisodeNumber {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin-left: 8px;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Fragment } from 'react';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import SceneInfo from './SceneInfo';
|
||||
import styles from './EpisodeNumber.css';
|
||||
|
||||
function getAlternateTitles(seasonNumber, sceneSeasonNumber, alternateTitles) {
|
||||
return alternateTitles.filter((alternateTitle) => {
|
||||
if (sceneSeasonNumber && sceneSeasonNumber === alternateTitle.sceneSeasonNumber) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return seasonNumber === alternateTitle.seasonNumber;
|
||||
});
|
||||
}
|
||||
|
||||
function EpisodeNumber(props) {
|
||||
const {
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
sceneSeasonNumber,
|
||||
sceneEpisodeNumber,
|
||||
sceneAbsoluteEpisodeNumber,
|
||||
unverifiedSceneNumbering,
|
||||
alternateTitles: seriesAlternateTitles,
|
||||
seriesType,
|
||||
showSeasonNumber
|
||||
} = props;
|
||||
|
||||
const alternateTitles = getAlternateTitles(seasonNumber, sceneSeasonNumber, seriesAlternateTitles);
|
||||
|
||||
const hasSceneInformation = sceneSeasonNumber !== undefined ||
|
||||
sceneEpisodeNumber !== undefined ||
|
||||
(seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) ||
|
||||
!!alternateTitles.length;
|
||||
|
||||
return (
|
||||
<span>
|
||||
{
|
||||
hasSceneInformation ?
|
||||
<Popover
|
||||
anchor={
|
||||
<span>
|
||||
{
|
||||
showSeasonNumber && seasonNumber != null &&
|
||||
<Fragment>
|
||||
{seasonNumber}x
|
||||
</Fragment>
|
||||
}
|
||||
|
||||
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
|
||||
|
||||
{
|
||||
seriesType === 'anime' && !!absoluteEpisodeNumber &&
|
||||
<span className={styles.absoluteEpisodeNumber}>
|
||||
({absoluteEpisodeNumber})
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
title="Scene Information"
|
||||
body={
|
||||
<SceneInfo
|
||||
sceneSeasonNumber={sceneSeasonNumber}
|
||||
sceneEpisodeNumber={sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={sceneAbsoluteEpisodeNumber}
|
||||
alternateTitles={alternateTitles}
|
||||
seriesType={seriesType}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/> :
|
||||
<span>
|
||||
{
|
||||
showSeasonNumber && seasonNumber != null &&
|
||||
<Fragment>
|
||||
{seasonNumber}x
|
||||
</Fragment>
|
||||
}
|
||||
|
||||
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
|
||||
|
||||
{
|
||||
seriesType === 'anime' && !!absoluteEpisodeNumber &&
|
||||
<span className={styles.absoluteEpisodeNumber}>
|
||||
({absoluteEpisodeNumber})
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
unverifiedSceneNumbering &&
|
||||
<Icon
|
||||
className={styles.warning}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
title="Scene number hasn't been verified yet"
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
seriesType === 'anime' && !absoluteEpisodeNumber &&
|
||||
<Icon
|
||||
className={styles.warning}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
title="Episode does not have an absolute episode number"
|
||||
/>
|
||||
}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeNumber.propTypes = {
|
||||
seasonNumber: PropTypes.number.isRequired,
|
||||
episodeNumber: PropTypes.number.isRequired,
|
||||
absoluteEpisodeNumber: PropTypes.number,
|
||||
sceneSeasonNumber: PropTypes.number,
|
||||
sceneEpisodeNumber: PropTypes.number,
|
||||
sceneAbsoluteEpisodeNumber: PropTypes.number,
|
||||
unverifiedSceneNumbering: PropTypes.bool.isRequired,
|
||||
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
seriesType: PropTypes.string,
|
||||
showSeasonNumber: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
EpisodeNumber.defaultProps = {
|
||||
unverifiedSceneNumbering: false,
|
||||
alternateTitles: [],
|
||||
showSeasonNumber: false
|
||||
};
|
||||
|
||||
export default EpisodeNumber;
|
||||
@@ -0,0 +1,57 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import Label from 'Components/Label';
|
||||
|
||||
function getTooltip(title, quality, size) {
|
||||
const revision = quality.revision;
|
||||
|
||||
if (revision.real && revision.real > 0) {
|
||||
title += ' [REAL]';
|
||||
}
|
||||
|
||||
if (revision.version && revision.version > 1) {
|
||||
title += ' [PROPER]';
|
||||
}
|
||||
|
||||
if (size) {
|
||||
title += ` - ${formatBytes(size)}`;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
function EpisodeQuality(props) {
|
||||
const {
|
||||
className,
|
||||
title,
|
||||
quality,
|
||||
size,
|
||||
isCutoffNotMet
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Label
|
||||
className={className}
|
||||
kind={isCutoffNotMet ? kinds.INVERSE : kinds.DEFAULT}
|
||||
title={getTooltip(title, quality, size)}
|
||||
>
|
||||
{quality.quality.name}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeQuality.propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
quality: PropTypes.object.isRequired,
|
||||
size: PropTypes.number,
|
||||
isCutoffNotMet: PropTypes.bool
|
||||
};
|
||||
|
||||
EpisodeQuality.defaultProps = {
|
||||
title: ''
|
||||
};
|
||||
|
||||
export default EpisodeQuality;
|
||||
@@ -0,0 +1,6 @@
|
||||
.episodeSearchCell {
|
||||
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 70px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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 TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import EpisodeDetailsModal from './EpisodeDetailsModal';
|
||||
import styles from './EpisodeSearchCell.css';
|
||||
|
||||
class EpisodeSearchCell extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isDetailsModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onManualSearchPress = () => {
|
||||
this.setState({ isDetailsModalOpen: true });
|
||||
}
|
||||
|
||||
onDetailsModalClose = () => {
|
||||
this.setState({ isDetailsModalOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
episodeId,
|
||||
seriesId,
|
||||
episodeTitle,
|
||||
isSearching,
|
||||
onSearchPress,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.episodeSearchCell}>
|
||||
<SpinnerIconButton
|
||||
name={icons.SEARCH}
|
||||
isSpinning={isSearching}
|
||||
onPress={onSearchPress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
name={icons.INTERACTIVE}
|
||||
onPress={this.onManualSearchPress}
|
||||
/>
|
||||
|
||||
<EpisodeDetailsModal
|
||||
isOpen={this.state.isDetailsModalOpen}
|
||||
episodeId={episodeId}
|
||||
seriesId={seriesId}
|
||||
episodeTitle={episodeTitle}
|
||||
selectedTab="search"
|
||||
startInteractiveSearch={true}
|
||||
onModalClose={this.onDetailsModalClose}
|
||||
{...otherProps}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeSearchCell.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
seriesId: PropTypes.number.isRequired,
|
||||
episodeTitle: PropTypes.string.isRequired,
|
||||
isSearching: PropTypes.bool.isRequired,
|
||||
onSearchPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeSearchCell;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import EpisodeSearchCell from './EpisodeSearchCell';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { episodeId }) => episodeId,
|
||||
(state, { sceneSeasonNumber }) => sceneSeasonNumber,
|
||||
createSeriesSelector(),
|
||||
createCommandsSelector(),
|
||||
(episodeId, sceneSeasonNumber, series, commands) => {
|
||||
const isSearching = commands.some((command) => {
|
||||
const episodeSearch = command.name === commandNames.EPISODE_SEARCH;
|
||||
|
||||
if (!episodeSearch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isCommandExecuting(command) &&
|
||||
command.body.episodeIds.indexOf(episodeId) > -1
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
seriesMonitored: series.monitored,
|
||||
seriesType: series.seriesType,
|
||||
isSearching
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onSearchPress(name, path) {
|
||||
dispatch(executeCommand({
|
||||
name: commandNames.EPISODE_SEARCH,
|
||||
episodeIds: [props.episodeId]
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell);
|
||||
@@ -0,0 +1,4 @@
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||
import EpisodeQuality from './EpisodeQuality';
|
||||
import styles from './EpisodeStatus.css';
|
||||
|
||||
function EpisodeStatus(props) {
|
||||
const {
|
||||
airDateUtc,
|
||||
monitored,
|
||||
grabbed,
|
||||
queueItem,
|
||||
episodeFile
|
||||
} = props;
|
||||
|
||||
const hasEpisodeFile = !!episodeFile;
|
||||
const isQueued = !!queueItem;
|
||||
const hasAired = isBefore(airDateUtc);
|
||||
|
||||
if (isQueued) {
|
||||
const {
|
||||
sizeleft,
|
||||
size
|
||||
} = queueItem;
|
||||
|
||||
const progress = (100 - sizeleft / size * 100);
|
||||
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<QueueDetails
|
||||
{...queueItem}
|
||||
progressBar={
|
||||
<ProgressBar
|
||||
title={`Episode is downloading - ${progress.toFixed(1)}% ${queueItem.title}`}
|
||||
progress={progress}
|
||||
kind={kinds.PURPLE}
|
||||
size={sizes.MEDIUM}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (grabbed) {
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.DOWNLOADING}
|
||||
title="Episode is downloading"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasEpisodeFile) {
|
||||
const quality = episodeFile.quality;
|
||||
const isCutoffNotMet = episodeFile.qualityCutoffNotMet;
|
||||
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<EpisodeQuality
|
||||
quality={quality}
|
||||
size={episodeFile.size}
|
||||
isCutoffNotMet={isCutoffNotMet}
|
||||
title="Episode Downloaded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!airDateUtc) {
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.TBA}
|
||||
title="TBA"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!monitored) {
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.UNMONITORED}
|
||||
kind={kinds.DISABLED}
|
||||
title="Episode is not monitored"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasAired) {
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.MISSING}
|
||||
title="Episode missing from disk"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.NOT_AIRED}
|
||||
title="Episode has not aired"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeStatus.propTypes = {
|
||||
airDateUtc: PropTypes.string,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
grabbed: PropTypes.bool,
|
||||
queueItem: PropTypes.object,
|
||||
episodeFile: PropTypes.object
|
||||
};
|
||||
|
||||
export default EpisodeStatus;
|
||||
@@ -0,0 +1,53 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
|
||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
||||
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
||||
import EpisodeStatus from './EpisodeStatus';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createEpisodeSelector(),
|
||||
createQueueItemSelector(),
|
||||
createEpisodeFileSelector(),
|
||||
(episode, queueItem, episodeFile) => {
|
||||
const result = _.pick(episode, [
|
||||
'airDateUtc',
|
||||
'monitored',
|
||||
'grabbed'
|
||||
]);
|
||||
|
||||
result.queueItem = queueItem;
|
||||
result.episodeFile = episodeFile;
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
};
|
||||
|
||||
class EpisodeStatusConnector extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EpisodeStatus
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeStatusConnector.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
episodeFileId: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector);
|
||||
@@ -0,0 +1,8 @@
|
||||
.link {
|
||||
composes: link from 'Components/Link/Link.css';
|
||||
|
||||
&:hover {
|
||||
color: $linkHoverColor;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||
import styles from './EpisodeTitleLink.css';
|
||||
|
||||
class EpisodeTitleLink extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isDetailsModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onLinkPress = () => {
|
||||
this.setState({ isDetailsModalOpen: true });
|
||||
}
|
||||
|
||||
onModalClose = () => {
|
||||
this.setState({ isDetailsModalOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
episodeTitle,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
className={styles.link}
|
||||
onPress={this.onLinkPress}
|
||||
>
|
||||
{episodeTitle}
|
||||
</Link>
|
||||
|
||||
<EpisodeDetailsModal
|
||||
isOpen={this.state.isDetailsModalOpen}
|
||||
episodeTitle={episodeTitle}
|
||||
{...otherProps}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeTitleLink.propTypes = {
|
||||
episodeTitle: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
EpisodeTitleLink.defaultProps = {
|
||||
showSeriesButton: false
|
||||
};
|
||||
|
||||
export default EpisodeTitleLink;
|
||||
@@ -0,0 +1,117 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import EpisodeHistoryRow from './EpisodeHistoryRow';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'eventType',
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'sourceTitle',
|
||||
label: 'Source Title',
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
label: 'Language',
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: 'Quality',
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
label: 'Date',
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'details',
|
||||
label: 'Details',
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: 'Actions',
|
||||
isVisible: true
|
||||
}
|
||||
];
|
||||
|
||||
class EpisodeHistory extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
onMarkAsFailedPress
|
||||
} = this.props;
|
||||
|
||||
const hasItems = !!items.length;
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<LoadingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFetching && !!error) {
|
||||
return (
|
||||
<div>Unable to load episode history.</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPopulated && !hasItems && !error) {
|
||||
return (
|
||||
<div>No episode history.</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPopulated && hasItems && !error) {
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
>
|
||||
<TableBody>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<EpisodeHistoryRow
|
||||
key={item.id}
|
||||
{...item}
|
||||
onMarkAsFailedPress={onMarkAsFailedPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeHistory.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onMarkAsFailedPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EpisodeHistory.defaultProps = {
|
||||
selectedTab: 'details'
|
||||
};
|
||||
|
||||
export default EpisodeHistory;
|
||||
@@ -0,0 +1,63 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchEpisodeHistory, clearEpisodeHistory, episodeHistoryMarkAsFailed } from 'Store/Actions/episodeHistoryActions';
|
||||
import EpisodeHistory from './EpisodeHistory';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.episodeHistory,
|
||||
(episodeHistory) => {
|
||||
return episodeHistory;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchEpisodeHistory,
|
||||
clearEpisodeHistory,
|
||||
episodeHistoryMarkAsFailed
|
||||
};
|
||||
|
||||
class EpisodeHistoryConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchEpisodeHistory({ episodeId: this.props.episodeId });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.clearEpisodeHistory();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMarkAsFailedPress = (historyId) => {
|
||||
this.props.episodeHistoryMarkAsFailed({ historyId, episodeId: this.props.episodeId });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EpisodeHistory
|
||||
{...this.props}
|
||||
onMarkAsFailedPress={this.onMarkAsFailedPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeHistoryConnector.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
fetchEpisodeHistory: PropTypes.func.isRequired,
|
||||
clearEpisodeHistory: PropTypes.func.isRequired,
|
||||
episodeHistoryMarkAsFailed: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeHistoryConnector);
|
||||
@@ -0,0 +1,6 @@
|
||||
.details,
|
||||
.actions {
|
||||
composes: cell from 'Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 65px;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import EpisodeLanguage from 'Episode/EpisodeLanguage';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
|
||||
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
|
||||
import styles from './EpisodeHistoryRow.css';
|
||||
|
||||
function getTitle(eventType) {
|
||||
switch (eventType) {
|
||||
case 'grabbed': return 'Grabbed';
|
||||
case 'seriesFolderImported': return 'Series Folder Imported';
|
||||
case 'downloadFolderImported': return 'Download Folder Imported';
|
||||
case 'downloadFailed': return 'Download Failed';
|
||||
case 'episodeFileDeleted': return 'Episode File Deleted';
|
||||
case 'episodeFileRenamed': return 'Episode File Renamed';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
class EpisodeHistoryRow extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isMarkAsFailedModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMarkAsFailedPress = () => {
|
||||
this.setState({ isMarkAsFailedModalOpen: true });
|
||||
}
|
||||
|
||||
onConfirmMarkAsFailed = () => {
|
||||
this.props.onMarkAsFailedPress(this.props.id);
|
||||
this.setState({ isMarkAsFailedModalOpen: false });
|
||||
}
|
||||
|
||||
onMarkAsFailedModalClose = () => {
|
||||
this.setState({ isMarkAsFailedModalOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
eventType,
|
||||
sourceTitle,
|
||||
language,
|
||||
languageCutoffNotMet,
|
||||
quality,
|
||||
qualityCutoffNotMet,
|
||||
date,
|
||||
data
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isMarkAsFailedModalOpen
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<HistoryEventTypeCell
|
||||
eventType={eventType}
|
||||
data={data}
|
||||
/>
|
||||
|
||||
<TableRowCell>
|
||||
{sourceTitle}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<EpisodeLanguage
|
||||
language={language}
|
||||
isCutoffNotMet={languageCutoffNotMet}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<EpisodeQuality
|
||||
quality={quality}
|
||||
isCutoffNotMet={qualityCutoffNotMet}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<RelativeDateCellConnector
|
||||
date={date}
|
||||
/>
|
||||
|
||||
<TableRowCell className={styles.details}>
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
title={getTitle(eventType)}
|
||||
body={
|
||||
<HistoryDetailsConnector
|
||||
eventType={eventType}
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.actions}>
|
||||
{
|
||||
eventType === 'grabbed' &&
|
||||
<IconButton
|
||||
title="Mark as failed"
|
||||
name={icons.REMOVE}
|
||||
onPress={this.onMarkAsFailedPress}
|
||||
/>
|
||||
}
|
||||
</TableRowCell>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isMarkAsFailedModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title="Mark as Failed"
|
||||
message={`Are you sure you want to mark '${sourceTitle}' as failed?`}
|
||||
confirmLabel="Mark as Failed"
|
||||
onConfirm={this.onConfirmMarkAsFailed}
|
||||
onCancel={this.onMarkAsFailedModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeHistoryRow.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
eventType: PropTypes.string.isRequired,
|
||||
sourceTitle: PropTypes.string.isRequired,
|
||||
language: PropTypes.object.isRequired,
|
||||
languageCutoffNotMet: PropTypes.bool.isRequired,
|
||||
quality: PropTypes.object.isRequired,
|
||||
qualityCutoffNotMet: PropTypes.bool.isRequired,
|
||||
date: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
onMarkAsFailedPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeHistoryRow;
|
||||
@@ -0,0 +1,17 @@
|
||||
.descriptionList {
|
||||
composes: descriptionList from 'Components/DescriptionList/DescriptionList.css';
|
||||
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
composes: title from 'Components/DescriptionList/DescriptionListItemTitle.css';
|
||||
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.description {
|
||||
composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css';
|
||||
|
||||
margin-left: 100px;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import styles from './SceneInfo.css';
|
||||
|
||||
function SceneInfo(props) {
|
||||
const {
|
||||
sceneSeasonNumber,
|
||||
sceneEpisodeNumber,
|
||||
sceneAbsoluteEpisodeNumber,
|
||||
alternateTitles,
|
||||
seriesType
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<DescriptionList className={styles.descriptionList}>
|
||||
{
|
||||
sceneSeasonNumber !== undefined &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="Season"
|
||||
data={sceneSeasonNumber}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
sceneEpisodeNumber !== undefined &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="Episode"
|
||||
data={sceneEpisodeNumber}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title="Absolute"
|
||||
data={sceneAbsoluteEpisodeNumber}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!!alternateTitles.length &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title={alternateTitles.length === 1 ? 'Title' : 'Titles'}
|
||||
data={
|
||||
<div>
|
||||
{
|
||||
alternateTitles.map((alternateTitle) => {
|
||||
return (
|
||||
<div
|
||||
key={alternateTitle.title}
|
||||
>
|
||||
{alternateTitle.title}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
SceneInfo.propTypes = {
|
||||
sceneSeasonNumber: PropTypes.number,
|
||||
sceneEpisodeNumber: PropTypes.number,
|
||||
sceneAbsoluteEpisodeNumber: PropTypes.number,
|
||||
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
seriesType: PropTypes.string
|
||||
};
|
||||
|
||||
export default SceneInfo;
|
||||
@@ -0,0 +1,16 @@
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
composes: button from 'Components/Link/Button.css';
|
||||
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Icon from 'Components/Icon';
|
||||
import styles from './EpisodeSearch.css';
|
||||
|
||||
function EpisodeSearch(props) {
|
||||
const {
|
||||
onQuickSearchPress,
|
||||
onInteractiveSearchPress
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
size={sizes.LARGE}
|
||||
onPress={onQuickSearchPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.buttonIcon}
|
||||
name={icons.QUICK}
|
||||
/>
|
||||
|
||||
Quick Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
kind={kinds.PRIMARY}
|
||||
size={sizes.LARGE}
|
||||
onPress={onInteractiveSearchPress}
|
||||
>
|
||||
<Icon
|
||||
className={styles.buttonIcon}
|
||||
name={icons.INTERACTIVE}
|
||||
/>
|
||||
|
||||
Interactive Search
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeSearch.propTypes = {
|
||||
onQuickSearchPress: PropTypes.func.isRequired,
|
||||
onInteractiveSearchPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeSearch;
|
||||
@@ -0,0 +1,93 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import EpisodeSearch from './EpisodeSearch';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.releases,
|
||||
(releases) => {
|
||||
return {
|
||||
isPopulated: releases.isPopulated
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
executeCommand
|
||||
};
|
||||
|
||||
class EpisodeSearchConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isInteractiveSearchOpen: props.startInteractiveSearch
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.isPopulated) {
|
||||
this.setState({ isInteractiveSearchOpen: true });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onQuickSearchPress = () => {
|
||||
this.props.executeCommand({
|
||||
name: commandNames.EPISODE_SEARCH,
|
||||
episodeIds: [this.props.episodeId]
|
||||
});
|
||||
|
||||
this.props.onModalClose();
|
||||
}
|
||||
|
||||
onInteractiveSearchPress = () => {
|
||||
this.setState({ isInteractiveSearchOpen: true });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const { episodeId } = this.props;
|
||||
|
||||
if (this.state.isInteractiveSearchOpen) {
|
||||
return (
|
||||
<InteractiveSearchConnector
|
||||
type="episode"
|
||||
searchPayload={{ episodeId }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EpisodeSearch
|
||||
{...this.props}
|
||||
onQuickSearchPress={this.onQuickSearchPress}
|
||||
onInteractiveSearchPress={this.onInteractiveSearchPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeSearchConnector.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
startInteractiveSearch: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
executeCommand: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeSearchConnector);
|
||||
@@ -0,0 +1,32 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import EpisodeNumber from './EpisodeNumber';
|
||||
|
||||
function SeasonEpisodeNumber(props) {
|
||||
const {
|
||||
airDate,
|
||||
seriesType,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
if (seriesType === 'daily' && airDate) {
|
||||
return (
|
||||
<span>{airDate}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EpisodeNumber
|
||||
seriesType={seriesType}
|
||||
showSeasonNumber={true}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
SeasonEpisodeNumber.propTypes = {
|
||||
airDate: PropTypes.string,
|
||||
seriesType: PropTypes.string
|
||||
};
|
||||
|
||||
export default SeasonEpisodeNumber;
|
||||
@@ -0,0 +1,86 @@
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import isInNextWeek from 'Utilities/Date/isInNextWeek';
|
||||
import isToday from 'Utilities/Date/isToday';
|
||||
import isTomorrow from 'Utilities/Date/isTomorrow';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import Label from 'Components/Label';
|
||||
|
||||
function EpisodeAiring(props) {
|
||||
const {
|
||||
airDateUtc,
|
||||
network,
|
||||
shortDateFormat,
|
||||
showRelativeDates,
|
||||
timeFormat
|
||||
} = props;
|
||||
|
||||
const networkLabel = (
|
||||
<Label
|
||||
kind={kinds.INFO}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
{network}
|
||||
</Label>
|
||||
);
|
||||
|
||||
if (!airDateUtc) {
|
||||
return (
|
||||
<span>
|
||||
TBA on {networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const time = formatTime(airDateUtc, timeFormat);
|
||||
|
||||
if (!showRelativeDates) {
|
||||
return (
|
||||
<span>
|
||||
{moment(airDateUtc).format(shortDateFormat)} at {time} on {networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isToday(airDateUtc)) {
|
||||
return (
|
||||
<span>
|
||||
{time} on {networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isTomorrow(airDateUtc)) {
|
||||
return (
|
||||
<span>
|
||||
Tomorrow at {time} on {networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInNextWeek(airDateUtc)) {
|
||||
return (
|
||||
<span>
|
||||
{moment(airDateUtc).format('dddd')} at {time} on {networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{moment(airDateUtc).format(shortDateFormat)} at {time} on {networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeAiring.propTypes = {
|
||||
airDateUtc: PropTypes.string.isRequired,
|
||||
network: PropTypes.string.isRequired,
|
||||
shortDateFormat: PropTypes.string.isRequired,
|
||||
showRelativeDates: PropTypes.bool.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeAiring;
|
||||
@@ -0,0 +1,20 @@
|
||||
import _ from 'lodash';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import EpisodeAiring from './EpisodeAiring';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createUISettingsSelector(),
|
||||
(uiSettings) => {
|
||||
return _.pick(uiSettings, [
|
||||
'shortDateFormat',
|
||||
'showRelativeDates',
|
||||
'timeFormat'
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(EpisodeAiring);
|
||||
@@ -0,0 +1,48 @@
|
||||
.infoTitle {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.overview,
|
||||
.files {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.filesHeader {
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.filesHeader {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid $borderColor;
|
||||
}
|
||||
|
||||
.fileRow {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.path {
|
||||
@add-mixin truncate;
|
||||
|
||||
flex: 1 0 1px;
|
||||
}
|
||||
|
||||
.size,
|
||||
.quality {
|
||||
flex: 0 0 125px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex: 0 0 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.size,
|
||||
.quality {
|
||||
flex: 0 0 80px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import EpisodeAiringConnector from './EpisodeAiringConnector';
|
||||
import MediaInfo from './MediaInfo';
|
||||
import styles from './EpisodeSummary.css';
|
||||
|
||||
class EpisodeSummary extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isRemoveEpisodeFileModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRemoveEpisodeFilePress = () => {
|
||||
this.setState({ isRemoveEpisodeFileModalOpen: true });
|
||||
}
|
||||
|
||||
onConfirmRemoveEpisodeFile = () => {
|
||||
this.props.onDeleteEpisodeFile();
|
||||
this.setState({ isRemoveEpisodeFileModalOpen: false });
|
||||
}
|
||||
|
||||
onRemoveEpisodeFileModalClose = () => {
|
||||
this.setState({ isRemoveEpisodeFileModalOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
qualityProfileId,
|
||||
network,
|
||||
overview,
|
||||
airDateUtc,
|
||||
mediaInfo,
|
||||
path,
|
||||
size,
|
||||
quality,
|
||||
qualityCutoffNotMet
|
||||
} = this.props;
|
||||
|
||||
const hasOverview = !!overview;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<span className={styles.infoTitle}>Airs</span>
|
||||
|
||||
<EpisodeAiringConnector
|
||||
airDateUtc={airDateUtc}
|
||||
network={network}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={styles.infoTitle}>Quality Profile</span>
|
||||
|
||||
<Label
|
||||
kind={kinds.PRIMARY}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<QualityProfileNameConnector
|
||||
qualityProfileId={qualityProfileId}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>
|
||||
{
|
||||
hasOverview ?
|
||||
overview :
|
||||
'No episode overview.'
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
path &&
|
||||
<div className={styles.files}>
|
||||
<div className={styles.filesHeader}>
|
||||
<div className={styles.path}>
|
||||
Path
|
||||
</div>
|
||||
|
||||
<div className={styles.size}>
|
||||
Size
|
||||
</div>
|
||||
|
||||
<div className={styles.quality}>
|
||||
Quality
|
||||
</div>
|
||||
|
||||
<div className={styles.actions} />
|
||||
</div>
|
||||
|
||||
<div className={styles.fileRow}>
|
||||
<div
|
||||
className={styles.path}
|
||||
title={path}
|
||||
>
|
||||
{path}
|
||||
</div>
|
||||
|
||||
<div className={styles.size}>
|
||||
{formatBytes(size)}
|
||||
</div>
|
||||
|
||||
<div className={styles.quality}>
|
||||
<EpisodeQuality
|
||||
quality={quality}
|
||||
isCutoffNotMet={qualityCutoffNotMet}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
name={icons.MEDIA_INFO}
|
||||
/>
|
||||
}
|
||||
title="Media Info"
|
||||
body={<MediaInfo {...mediaInfo} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
title="Delete episode from disk"
|
||||
name={icons.REMOVE}
|
||||
onPress={this.onRemoveEpisodeFilePress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={this.state.isRemoveEpisodeFileModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title="Delete Episode File"
|
||||
message={`Are you sure you want to delete '${path}'?`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={this.onConfirmRemoveEpisodeFile}
|
||||
onCancel={this.onRemoveEpisodeFileModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeSummary.propTypes = {
|
||||
episodeFileId: PropTypes.number.isRequired,
|
||||
qualityProfileId: PropTypes.number.isRequired,
|
||||
network: PropTypes.string.isRequired,
|
||||
overview: PropTypes.string,
|
||||
airDateUtc: PropTypes.string.isRequired,
|
||||
mediaInfo: PropTypes.object,
|
||||
path: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
quality: PropTypes.object,
|
||||
qualityCutoffNotMet: PropTypes.bool,
|
||||
onDeleteEpisodeFile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeSummary;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { deleteEpisodeFile } from 'Store/Actions/episodeFileActions';
|
||||
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
|
||||
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import EpisodeSummary from './EpisodeSummary';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSeriesSelector(),
|
||||
createEpisodeSelector(),
|
||||
createEpisodeFileSelector(),
|
||||
(series, episode, episodeFile = {}) => {
|
||||
const {
|
||||
qualityProfileId,
|
||||
network
|
||||
} = series;
|
||||
|
||||
const {
|
||||
airDateUtc,
|
||||
overview
|
||||
} = episode;
|
||||
|
||||
const {
|
||||
mediaInfo,
|
||||
path,
|
||||
size,
|
||||
quality,
|
||||
qualityCutoffNotMet
|
||||
} = episodeFile;
|
||||
|
||||
return {
|
||||
network,
|
||||
qualityProfileId,
|
||||
airDateUtc,
|
||||
overview,
|
||||
mediaInfo,
|
||||
path,
|
||||
size,
|
||||
quality,
|
||||
qualityCutoffNotMet
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onDeleteEpisodeFile() {
|
||||
dispatch(deleteEpisodeFile({
|
||||
id: props.episodeFileId,
|
||||
episodeEntity: props.episodeEntity
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSummary);
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
|
||||
function MediaInfo(props) {
|
||||
return (
|
||||
<DescriptionList>
|
||||
{
|
||||
Object.keys(props).map((key) => {
|
||||
const title = key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (str) => str.toUpperCase());
|
||||
|
||||
const value = props[key];
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DescriptionListItem
|
||||
key={key}
|
||||
title={title}
|
||||
data={props[key]}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
export default MediaInfo;
|
||||
@@ -0,0 +1,13 @@
|
||||
export const CALENDAR = 'calendar';
|
||||
export const EPISODES = 'episodes';
|
||||
export const INTERACTIVE_IMPORT = 'interactiveImport.episodes';
|
||||
export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet';
|
||||
export const WANTED_MISSING = 'wanted.missing';
|
||||
|
||||
export default {
|
||||
CALENDAR,
|
||||
EPISODES,
|
||||
INTERACTIVE_IMPORT,
|
||||
WANTED_CUTOFF_UNMET,
|
||||
WANTED_MISSING
|
||||
};
|
||||
Reference in New Issue
Block a user