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

View file

@ -0,0 +1,54 @@
.searchContainer {
display: flex;
margin-bottom: 10px;
}
.searchIconContainer {
width: 58px;
height: 46px;
border: 1px solid $inputBorderColor;
border-right: none;
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
background-color: #edf1f2;
text-align: center;
line-height: 46px;
}
.searchInput {
composes: text from 'Components/Form/TextInput.css';
height: 46px;
border-radius: 0;
font-size: 18px;
}
.clearLookupButton {
border: 1px solid $inputBorderColor;
border-left: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
}
.message {
margin-top: 30px;
text-align: center;
}
.helpText {
margin-bottom: 10px;
font-weight: 300;
font-size: 24px;
}
.noResults {
margin-bottom: 10px;
font-weight: 300;
font-size: 30px;
}
.searchResults {
margin-top: 30px;
}

View file

@ -0,0 +1,184 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TextInput from 'Components/Form/TextInput';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector';
import styles from './AddNewSeries.css';
class AddNewSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
term: props.term || '',
isFetching: false
};
}
componentDidMount() {
const term = this.state.term;
if (term) {
this.props.onSeriesLookupChange(term);
}
}
componentDidUpdate(prevProps) {
const {
term,
isFetching
} = this.props;
if (term && term !== prevProps.term) {
this.setState({
term,
isFetching: true
});
this.props.onSeriesLookupChange(term);
} else if (isFetching !== prevProps.isFetching) {
this.setState({
isFetching
});
}
}
//
// Listeners
onSearchInputChange = ({ value }) => {
const hasValue = !!value.trim();
this.setState({ term: value, isFetching: hasValue }, () => {
if (hasValue) {
this.props.onSeriesLookupChange(value);
} else {
this.props.onClearSeriesLookup();
}
});
}
onClearSeriesLookupPress = () => {
this.setState({ term: '' });
this.props.onClearSeriesLookup();
}
//
// Render
render() {
const {
error,
items
} = this.props;
const term = this.state.term;
const isFetching = this.state.isFetching;
return (
<PageContent title="Add New Artist">
<PageContentBodyConnector>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon
name={icons.SEARCH}
size={20}
/>
</div>
<TextInput
className={styles.searchInput}
name="seriesLookup"
value={term}
placeholder="eg. Breaking Benjamin, lidarr:####"
onChange={this.onSearchInputChange}
/>
<Button
className={styles.clearLookupButton}
onPress={this.onClearSeriesLookupPress}
>
<Icon
name={icons.REMOVE}
size={20}
/>
</Button>
</div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Failed to load search results, please try again.</div>
}
{
!isFetching && !error && !!items.length &&
<div className={styles.searchResults}>
{
items.map((item) => {
return (
<AddNewSeriesSearchResultConnector
key={item.foreignArtistId}
{...item}
/>
);
})
}
</div>
}
{
!isFetching && !error && !items.length && !!term &&
<div className={styles.message}>
<div className={styles.noResults}>Couldn't find any results for '{term}'</div>
<div>You can also search using MusicBrainz ID of a show. eg. lidarr:71663</div>
<div>
<Link to="https://github.com/Sonarr/Sonarr/wiki/FAQ#why-cant-i-add-a-new-series-when-i-know-the-tvdb-id">
Why can't I find my artist?
</Link>
</div>
</div>
}
{
!term &&
<div className={styles.message}>
<div className={styles.helpText}>It's easy to add a new artist, just start typing the name the artist you want to add.</div>
<div>You can also search using MusicBrainz ID of a show. eg. lidarr:71663</div>
</div>
}
<div>
</div>
</PageContentBodyConnector>
</PageContent>
);
}
}
AddNewSeries.propTypes = {
term: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onSeriesLookupChange: PropTypes.func.isRequired,
onClearSeriesLookup: PropTypes.func.isRequired
};
export default AddNewSeries;

View file

@ -0,0 +1,102 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import queryString from 'query-string';
import { lookupSeries, clearAddSeries } from 'Store/Actions/addSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import AddNewSeries from './AddNewSeries';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.routing.location,
(addSeries, location) => {
const query = queryString.parse(location.search);
return {
term: query.term,
...addSeries
};
}
);
}
const mapDispatchToProps = {
lookupSeries,
clearAddSeries,
fetchRootFolders
};
class AddNewSeriesConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._seriesLookupTimeout = null;
}
componentDidMount() {
this.props.fetchRootFolders();
}
componentWillUnmount() {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
this.props.clearAddSeries();
}
//
// Listeners
onSeriesLookupChange = (term) => {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
if (term.trim() === '') {
this.props.clearAddSeries();
} else {
this._seriesLookupTimeout = setTimeout(() => {
this.props.lookupSeries({ term });
}, 300);
}
}
onClearSeriesLookup = () => {
this.props.clearAddSeries();
}
//
// Render
render() {
const {
term,
...otherProps
} = this.props;
return (
<AddNewSeries
term={term}
{...otherProps}
onSeriesLookupChange={this.onSeriesLookupChange}
onClearSeriesLookup={this.onClearSeriesLookup}
/>
);
}
}
AddNewSeriesConnector.propTypes = {
term: PropTypes.string,
lookupSeries: PropTypes.func.isRequired,
clearAddSeries: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector);

View file

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddNewSeriesModalContentConnector from './AddNewSeriesModalContentConnector';
function AddNewSeriesModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddNewSeriesModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddNewSeriesModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddNewSeriesModal;

View file

@ -0,0 +1,74 @@
.container {
display: flex;
}
.year {
margin-left: 5px;
color: $disabledColor;
}
.poster {
flex: 0 0 170px;
margin-right: 20px;
height: 250px;
}
.info {
flex-grow: 1;
}
.overview {
margin-bottom: 30px;
}
.labelIcon {
margin-left: 8px;
}
.searchForMissingEpisodesLabelContainer {
display: flex;
margin-top: 2px;
}
.searchForMissingEpisodesLabel {
margin-right: 8px;
font-weight: normal;
}
.searchForMissingEpisodesContainer {
composes: container from 'Components/Form/CheckInput.css';
flex: 0 1 0;
}
.searchForMissingEpisodesInput {
composes: input from 'Components/Form/CheckInput.css';
margin-top: 0;
}
.modalFooter {
composes: modalFooter from 'Components/Modal/ModalFooter.css';
}
.addButton {
composes: button from 'Components/Link/SpinnerButton.css';
composes: truncate from 'Styles/mixins/truncate.css';
}
.hideLanguageProfile {
composes: group from 'Components/Form/FormGroup.css';
display: none;
}
@media only screen and (max-width: $breakpointSmall) {
.modalFooter {
display: block;
text-align: center;
}
.addButton {
margin-top: 10px;
}
}

View file

@ -0,0 +1,265 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds, inputTypes, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
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 CheckInput from 'Components/Form/CheckInput';
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 Popover from 'Components/Tooltip/Popover';
import ArtistPoster from 'Artist/ArtistPoster';
import SeriesMonitoringOptionsPopoverContent from 'AddArtist/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddArtist/SeriesTypePopoverContent';
import styles from './AddNewSeriesModalContent.css';
class AddNewSeriesModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
searchForMissingEpisodes: false
};
}
//
// Listeners
onSearchForMissingEpisodesChange = ({ value }) => {
this.setState({ searchForMissingEpisodes: value });
}
onQualityProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
}
onLanguageProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'languageProfileId', value: parseInt(value) });
}
onAddSeriesPress = () => {
this.props.onAddSeriesPress(this.state.searchForMissingEpisodes);
}
//
// Render
render() {
const {
artistName,
year,
overview,
images,
isAdding,
rootFolderPath,
monitor,
qualityProfileId,
languageProfileId,
seriesType,
albumFolder,
tags,
showLanguageProfile,
isSmallScreen,
onModalClose,
onInputChange
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{artistName}
{
!name.contains(year) &&
<span className={styles.year}>({year})</span>
}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
!isSmallScreen &&
<div className={styles.poster}>
<ArtistPoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
<div className={styles.overview}>
{overview}
</div>
<Form>
<FormGroup>
<FormLabel>Root Folder</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
onChange={onInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
Monitor
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title="Monitoring Options"
body={<SeriesMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
onChange={onInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={this.onQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup className={showLanguageProfile ? null : styles.hideLanguageProfile}>
<FormLabel>Language Profile</FormLabel>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
onChange={this.onLanguageProfileIdChange}
{...languageProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>
Series Type
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title="Series Types"
body={<SeriesTypePopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
onChange={onInputChange}
{...seriesType}
/>
</FormGroup>
<FormGroup>
<FormLabel>Album Folder</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="albumFolder"
onChange={onInputChange}
{...albumFolder}
/>
</FormGroup>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<label className={styles.searchForMissingEpisodesLabelContainer}>
<span className={styles.searchForMissingEpisodesLabel}>
Start search for missing episodes
</span>
<CheckInput
containerClassName={styles.searchForMissingEpisodesContainer}
className={styles.searchForMissingEpisodesInput}
name="searchForMissingEpisodes"
value={this.state.searchForMissingEpisodes}
onChange={this.onSearchForMissingEpisodesChange}
/>
</label>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={this.onAddSeriesPress}
>
Add {artistName}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
AddNewSeriesModalContent.propTypes = {
artistName: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
overview: PropTypes.string,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
languageProfileId: PropTypes.object,
seriesType: PropTypes.object.isRequired,
albumFolder: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired,
onAddSeriesPress: PropTypes.func.isRequired
};
export default AddNewSeriesModalContent;

View file

@ -0,0 +1,105 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setAddSeriesDefault, addSeries } from 'Store/Actions/addSeriesActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import AddNewSeriesModalContent from './AddNewSeriesModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.settings.languageProfiles,
createDimensionsSelector(),
(addSeriesState, languageProfiles, dimensions) => {
const {
isAdding,
addError,
defaults
} = addSeriesState;
const {
settings,
validationErrors,
validationWarnings
} = selectSettings(defaults, {}, addError);
return {
isAdding,
addError,
showLanguageProfile: languageProfiles.length > 1,
isSmallScreen: dimensions.isSmallScreen,
validationErrors,
validationWarnings,
...settings
};
}
);
}
const mapDispatchToProps = {
setAddSeriesDefault,
addSeries
};
class AddNewSeriesModalContentConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setAddSeriesDefault({ [name]: value });
}
onAddSeriesPress = (searchForMissingEpisodes) => {
const {
foreignArtistId,
rootFolderPath,
monitor,
qualityProfileId,
languageProfileId,
albumFolder,
tags
} = this.props;
this.props.addSeries({
foreignArtistId,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
languageProfileId: languageProfileId.value,
albumFolder: albumFolder.value,
tags: tags.value,
searchForMissingEpisodes
});
}
//
// Render
render() {
return (
<AddNewSeriesModalContent
{...this.props}
onInputChange={this.onInputChange}
onAddSeriesPress={this.onAddSeriesPress}
/>
);
}
}
AddNewSeriesModalContentConnector.propTypes = {
foreignArtistId: PropTypes.string.isRequired,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
languageProfileId: PropTypes.object,
albumFolder: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
onModalClose: PropTypes.func.isRequired,
setAddSeriesDefault: PropTypes.func.isRequired,
addSeries: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesModalContentConnector);

View file

@ -0,0 +1,40 @@
.searchResult {
display: flex;
margin: 20px 0;
padding: 20px;
width: 100%;
background-color: $white;
color: inherit;
transition: background 500ms;
&:hover {
background-color: #eaf2ff;
color: inherit;
text-decoration: none;
}
}
.poster {
flex: 0 0 170px;
margin-right: 20px;
height: 250px;
}
.title {
font-weight: 300;
font-size: 36px;
}
.year {
margin-left: 10px;
color: $disabledColor;
}
.alreadyExistsIcon {
margin-left: 10px;
color: #37bc9b;
}
.overview {
margin-top: 20px;
}

View file

@ -0,0 +1,169 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds, sizes } from 'Helpers/Props';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import ArtistPoster from 'Artist/ArtistPoster';
import AddNewSeriesModal from './AddNewSeriesModal';
import styles from './AddNewSeriesSearchResult.css';
class AddNewSeriesSearchResult extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isNewAddSeriesModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (!prevProps.isExistingSeries && this.props.isExistingSeries) {
this.onAddSerisModalClose();
}
}
//
// Listeners
onPress = () => {
this.setState({ isNewAddSeriesModalOpen: true });
}
onAddSerisModalClose = () => {
this.setState({ isNewAddSeriesModalOpen: false });
}
//
// Render
render() {
const {
foreignArtistId,
artistName,
nameSlug,
year,
network,
status,
overview,
seasonCount,
ratings,
images,
isExistingSeries,
isSmallScreen
} = this.props;
const linkProps = isExistingSeries ? { to: `/series/${nameSlug}` } : { onPress: this.onPress };
let seasons = '1 Season';
if (seasonCount > 1) {
seasons = `${seasonCount} Seasons`;
}
return (
<Link
className={styles.searchResult}
{...linkProps}
>
{
!isSmallScreen &&
<ArtistPoster
className={styles.poster}
images={images}
size={250}
/>
}
<div>
<div className={styles.name}>
{artistName}
{
!name.contains(year) && !!year &&
<span className={styles.year}>({year})</span>
}
{
isExistingSeries &&
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={36}
artistName="Already in your library"
/>
}
</div>
<div>
<Label size={sizes.LARGE}>
<HeartRating
rating={ratings.value}
iconSize={13}
/>
</Label>
{
!!network &&
<Label size={sizes.LARGE}>
{network}
</Label>
}
{
!!seasonCount &&
<Label size={sizes.LARGE}>
{seasons}
</Label>
}
{
status === 'ended' &&
<Label
kind={kinds.DANGER}
size={sizes.LARGE}
>
Ended
</Label>
}
</div>
<div className={styles.overview}>
{overview}
</div>
</div>
<AddNewSeriesModal
isOpen={this.state.isNewAddSeriesModalOpen && !isExistingSeries}
foreignArtistId={foreignArtistId}
artistName={artistName}
year={year}
overview={overview}
images={images}
onModalClose={this.onAddSerisModalClose}
/>
</Link>
);
}
}
AddNewSeriesSearchResult.propTypes = {
foreignArtistId: PropTypes.string.isRequired,
artistName: PropTypes.string.isRequired,
nameSlug: PropTypes.string.isRequired,
year: PropTypes.number,
network: PropTypes.string,
status: PropTypes.string.isRequired,
overview: PropTypes.string,
seasonCount: PropTypes.number,
ratings: PropTypes.object.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isExistingSeries: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired
};
export default AddNewSeriesSearchResult;

View file

@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
function createMapStateToProps() {
return createSelector(
createExistingSeriesSelector(),
createDimensionsSelector(),
(isExistingSeries, dimensions) => {
return {
isExistingSeries,
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
export default connect(createMapStateToProps)(AddNewSeriesSearchResult);

View file

@ -0,0 +1,173 @@
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 LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import ImportSeriesTableConnector from './ImportSeriesTableConnector';
import ImportSeriesFooterConnector from './ImportSeriesFooterConnector';
class ImportSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
contentBody: null,
scrollTop: 0
};
}
//
// Control
setContentBodyRef = (ref) => {
this.setState({ contentBody: ref });
}
//
// Listeners
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState, { parseIds: false });
}
onSelectAllChange = ({ value }) => {
// Only select non-dupes
this.setState(selectAll(this.state.selectedState, value));
}
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
}
onRemoveSelectedStateItem = (id) => {
this.setState((state) => {
const selectedState = Object.assign({}, state.selectedState);
delete selectedState[id];
return {
...state,
selectedState
};
});
}
onInputChange = ({ name, value }) => {
this.props.onInputChange(this.getSelectedIds(), name, value);
}
onImportPress = () => {
this.props.onImportPress(this.getSelectedIds());
}
onScroll = ({ scrollTop }) => {
this.setState({ scrollTop });
}
//
// Render
render() {
const {
rootFolderId,
path,
rootFoldersFetching,
rootFoldersPopulated,
rootFoldersError,
unmappedFolders,
showLanguageProfile
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
contentBody
} = this.state;
return (
<PageContent title="Import Series">
<PageContentBodyConnector
ref={this.setContentBodyRef}
onScroll={this.onScroll}
>
{
rootFoldersFetching && !rootFoldersPopulated &&
<LoadingIndicator />
}
{
!rootFoldersFetching && !!rootFoldersError &&
<div>Unable to load root folders</div>
}
{
!rootFoldersError && rootFoldersPopulated && !unmappedFolders.length &&
<div>
All series in {path} have been imported
</div>
}
{
!rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody &&
<ImportSeriesTableConnector
rootFolderId={rootFolderId}
unmappedFolders={unmappedFolders}
allSelected={allSelected}
allUnselected={allUnselected}
selectedState={selectedState}
contentBody={contentBody}
showLanguageProfile={showLanguageProfile}
scrollTop={this.state.scrollTop}
onSelectAllChange={this.onSelectAllChange}
onSelectedChange={this.onSelectedChange}
onRemoveSelectedStateItem={this.onRemoveSelectedStateItem}
onScroll={this.onScroll}
/>
}
</PageContentBodyConnector>
{
!rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length &&
<ImportSeriesFooterConnector
selectedIds={this.getSelectedIds()}
showLanguageProfile={showLanguageProfile}
onInputChange={this.onInputChange}
onImportPress={this.onImportPress}
/>
}
</PageContent>
);
}
}
ImportSeries.propTypes = {
rootFolderId: PropTypes.number.isRequired,
path: PropTypes.string,
rootFoldersFetching: PropTypes.bool.isRequired,
rootFoldersPopulated: PropTypes.bool.isRequired,
rootFoldersError: PropTypes.object,
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
items: PropTypes.arrayOf(PropTypes.object),
showLanguageProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onImportPress: PropTypes.func.isRequired
};
ImportSeries.defaultProps = {
unmappedFolders: []
};
export default ImportSeries;

View file

@ -0,0 +1,121 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setImportSeriesValue, importSeries, clearImportSeries } from 'Store/Actions/importSeriesActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
import ImportSeries from './ImportSeries';
function createMapStateToProps() {
return createSelector(
(state, { match }) => match,
(state) => state.rootFolders,
(state) => state.addSeries,
(state) => state.importSeries,
(state) => state.settings.languageProfiles,
(match, rootFolders, addSeries, importSeriesState, languageProfiles) => {
const {
isFetching: rootFoldersFetching,
isPopulated: rootFoldersPopulated,
error: rootFoldersError,
items
} = rootFolders;
const rootFolderId = parseInt(match.params.rootFolderId);
const result = {
rootFolderId,
rootFoldersFetching,
rootFoldersPopulated,
rootFoldersError,
showLanguageProfile: languageProfiles.items.length > 1
};
if (items.length) {
const rootFolder = _.find(items, { id: rootFolderId });
return {
...result,
...rootFolder,
items: importSeriesState.items
};
}
return result;
}
);
}
const mapDispatchToProps = {
setImportSeriesValue,
importSeries,
clearImportSeries,
fetchRootFolders,
setAddSeriesDefault
};
class ImportSeriesConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.rootFoldersPopulated) {
this.props.fetchRootFolders();
}
}
componentWillUnmount() {
this.props.clearImportSeries();
}
//
// Listeners
onInputChange = (ids, name, value) => {
this.props.setAddSeriesDefault({ [name]: value });
ids.forEach((id) => {
this.props.setImportSeriesValue({
id,
[name]: value
});
});
}
onImportPress = (ids) => {
this.props.importSeries({ ids });
}
//
// Render
render() {
return (
<ImportSeries
{...this.props}
onInputChange={this.onInputChange}
onImportPress={this.onImportPress}
/>
);
}
}
const routeMatchShape = createRouteMatchShape({
rootFolderId: PropTypes.string.isRequired
});
ImportSeriesConnector.propTypes = {
match: routeMatchShape.isRequired,
rootFoldersPopulated: PropTypes.bool.isRequired,
setImportSeriesValue: PropTypes.func.isRequired,
importSeries: PropTypes.func.isRequired,
clearImportSeries: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired,
setAddSeriesDefault: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector);

View file

@ -0,0 +1,27 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
}
.label {
margin-bottom: 3px;
font-weight: bold;
}
.importButtonContainer {
display: flex;
align-items: center;
}
.importButton {
composes: button from 'Components/Link/SpinnerButton.css';
height: 35px;
}
.loading {
composes: loading from 'Components/Loading/LoadingIndicator.css';
margin: 0 10px 0 12px;
text-align: left;
}

View file

@ -0,0 +1,263 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import CheckInput from 'Components/Form/CheckInput';
import FormInputGroup from 'Components/Form/FormInputGroup';
import PageContentFooter from 'Components/Page/PageContentFooter';
import styles from './ImportSeriesFooter.css';
const MIXED = 'mixed';
class ImportSeriesFooter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
defaultMonitor,
defaultQualityProfileId,
defaultLanguageProfileId,
defaultSeasonFolder,
defaultSeriesType
} = props;
this.state = {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
languageProfileId: defaultLanguageProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder
};
}
componentDidUpdate(prevProps, prevState) {
const {
defaultMonitor,
defaultQualityProfileId,
defaultLanguageProfileId,
defaultSeriesType,
defaultSeasonFolder,
isMonitorMixed,
isQualityProfileIdMixed,
isLanguageProfileIdMixed,
isSeriesTypeMixed,
isSeasonFolderMixed
} = this.props;
const {
monitor,
qualityProfileId,
languageProfileId,
seriesType,
seasonFolder
} = this.state;
const newState = {};
if (isMonitorMixed && monitor !== MIXED) {
newState.monitor = MIXED;
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
newState.monitor = defaultMonitor;
}
if (isQualityProfileIdMixed && qualityProfileId !== MIXED) {
newState.qualityProfileId = MIXED;
} else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) {
newState.qualityProfileId = defaultQualityProfileId;
}
if (isLanguageProfileIdMixed && languageProfileId !== MIXED) {
newState.languageProfileId = MIXED;
} else if (!isLanguageProfileIdMixed && languageProfileId !== defaultLanguageProfileId) {
newState.languageProfileId = defaultLanguageProfileId;
}
if (isSeriesTypeMixed && seriesType !== MIXED) {
newState.seriesType = MIXED;
} else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) {
newState.seriesType = defaultSeriesType;
}
if (isSeasonFolderMixed && seasonFolder != null) {
newState.seasonFolder = null;
} else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) {
newState.seasonFolder = defaultSeasonFolder;
}
if (!_.isEmpty(newState)) {
this.setState(newState);
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
this.props.onInputChange({ name, value });
}
//
// Render
render() {
const {
selectedCount,
isImporting,
isLookingUpSeries,
isMonitorMixed,
isQualityProfileIdMixed,
isLanguageProfileIdMixed,
isSeriesTypeMixed,
showLanguageProfile,
onImportPress
} = this.props;
const {
monitor,
qualityProfileId,
languageProfileId,
seriesType,
seasonFolder
} = this.state;
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<div className={styles.label}>
Monitor
</div>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
value={monitor}
isDisabled={!selectedCount}
includeMixed={isMonitorMixed}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
Quality Profile
</div>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
isDisabled={!selectedCount}
includeMixed={isQualityProfileIdMixed}
onChange={this.onInputChange}
/>
</div>
{
showLanguageProfile &&
<div className={styles.inputContainer}>
<div className={styles.label}>
Language Profile
</div>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
value={languageProfileId}
isDisabled={!selectedCount}
includeMixed={isLanguageProfileIdMixed}
onChange={this.onInputChange}
/>
</div>
}
<div className={styles.inputContainer}>
<div className={styles.label}>
Series Type
</div>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
value={seriesType}
isDisabled={!selectedCount}
includeMixed={isSeriesTypeMixed}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
Season Folder
</div>
<CheckInput
name="seasonFolder"
value={seasonFolder}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div>
<div className={styles.label}>
&nbsp;
</div>
<div className={styles.importButtonContainer}>
<SpinnerButton
className={styles.importButton}
kind={kinds.PRIMARY}
isSpinning={isImporting}
isDisabled={!selectedCount || isLookingUpSeries}
onPress={onImportPress}
>
Import {selectedCount} Series
</SpinnerButton>
{
isLookingUpSeries &&
<LoadingIndicator
className={styles.loading}
size={24}
/>
}
{
isLookingUpSeries &&
'Processing Folders'
}
</div>
</div>
</PageContentFooter>
);
}
}
ImportSeriesFooter.propTypes = {
selectedCount: PropTypes.number.isRequired,
isImporting: PropTypes.bool.isRequired,
isLookingUpSeries: PropTypes.bool.isRequired,
defaultMonitor: PropTypes.string.isRequired,
defaultQualityProfileId: PropTypes.number,
defaultLanguageProfileId: PropTypes.number,
defaultSeriesType: PropTypes.string.isRequired,
defaultSeasonFolder: PropTypes.bool.isRequired,
isMonitorMixed: PropTypes.bool.isRequired,
isQualityProfileIdMixed: PropTypes.bool.isRequired,
isLanguageProfileIdMixed: PropTypes.bool.isRequired,
isSeriesTypeMixed: PropTypes.bool.isRequired,
isSeasonFolderMixed: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onImportPress: PropTypes.func.isRequired
};
export default ImportSeriesFooter;

View file

@ -0,0 +1,57 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import ImportSeriesFooter from './ImportSeriesFooter';
function isMixed(items, selectedIds, defaultValue, key) {
return _.some(items, (series) => {
return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue;
});
}
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.importSeries,
(state, { selectedIds }) => selectedIds,
(addSeries, importSeries, selectedIds) => {
const {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
languageProfileId: defaultLanguageProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder
} = addSeries.defaults;
const items = importSeries.items;
const isLookingUpSeries = _.some(importSeries.items, (series) => {
return !series.isPopulated && series.error == null;
});
const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId');
const isSeriesTypeMixed = isMixed(items, selectedIds, defaultSeriesType, 'seriesType');
const isSeasonFolderMixed = isMixed(items, selectedIds, defaultSeasonFolder, 'seasonFolder');
return {
selectedCount: selectedIds.length,
isImporting: importSeries.isImporting,
isLookingUpSeries,
defaultMonitor,
defaultQualityProfileId,
defaultLanguageProfileId,
defaultSeriesType,
defaultSeasonFolder,
isMonitorMixed,
isQualityProfileIdMixed,
isLanguageProfileIdMixed,
isSeriesTypeMixed,
isSeasonFolderMixed
};
}
);
}
export default connect(createMapStateToProps)(ImportSeriesFooter);

View file

@ -0,0 +1,45 @@
.folder {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 1 0 200px;
}
.monitor {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 200px;
min-width: 185px;
}
.qualityProfile,
.languageProfile {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 250px;
min-width: 170px;
}
.seriesType {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 200px;
min-width: 120px;
}
.seasonFolder {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 150px;
min-width: 120px;
}
.series {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 400px;
min-width: 300px;
}
.detailsIcon {
margin-left: 8px;
}

View file

@ -0,0 +1,115 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import SeriesMonitoringOptionsPopoverContent from 'AddArtist/SeriesMonitoringOptionsPopoverContent';
import SeriesTypePopoverContent from 'AddArtist/SeriesTypePopoverContent';
import styles from './ImportSeriesHeader.css';
function ImportSeriesHeader(props) {
const {
showLanguageProfile,
allSelected,
allUnselected,
onSelectAllChange
} = props;
return (
<VirtualTableHeader>
<VirtualTableSelectAllHeaderCell
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
<VirtualTableHeaderCell
className={styles.folder}
name="folder"
>
Folder
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.monitor}
name="monitor"
>
Monitor
<Popover
anchor={
<Icon
className={styles.detailsIcon}
name={icons.INFO}
/>
}
title="Monitoring Options"
body={<SeriesMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.qualityProfile}
name="qualityProfileId"
>
Quality Profile
</VirtualTableHeaderCell>
{
showLanguageProfile &&
<VirtualTableHeaderCell
className={styles.languageProfile}
name="languageProfileId"
>
Language Profile
</VirtualTableHeaderCell>
}
<VirtualTableHeaderCell
className={styles.seriesType}
name="seriesType"
>
Series Type
<Popover
anchor={
<Icon
className={styles.detailsIcon}
name={icons.INFO}
/>
}
title="Series Type"
body={<SeriesTypePopoverContent />}
position={tooltipPositions.RIGHT}
/>
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.seasonFolder}
name="seasonFolder"
>
Season Folder
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.series}
name="series"
>
Series
</VirtualTableHeaderCell>
</VirtualTableHeader>
);
}
ImportSeriesHeader.propTypes = {
showLanguageProfile: PropTypes.bool.isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired
};
export default ImportSeriesHeader;

View file

@ -0,0 +1,52 @@
.selectInput {
composes: input from 'Components/Form/CheckInput.css';
}
.folder {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 1 0 200px;
line-height: 36px;
}
.monitor {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 200px;
min-width: 185px;
}
.qualityProfile,
.languageProfile {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 250px;
min-width: 170px;
}
.seriesType {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 200px;
min-width: 120px;
}
.seasonFolder {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 150px;
min-width: 120px;
}
.series {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 400px;
min-width: 300px;
}
.hideLanguageProfile {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
display: none;
}

View file

@ -0,0 +1,121 @@
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes } from 'Helpers/Props';
import FormInputGroup from 'Components/Form/FormInputGroup';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import ImportSeriesSelectSeriesConnector from './SelectSeries/ImportSeriesSelectSeriesConnector';
import styles from './ImportSeriesRow.css';
function ImportSeriesRow(props) {
const {
style,
id,
monitor,
qualityProfileId,
languageProfileId,
seasonFolder,
seriesType,
selectedSeries,
isExistingSeries,
showLanguageProfile,
isSelected,
onSelectedChange,
onInputChange
} = props;
return (
<VirtualTableRow style={style}>
<VirtualTableSelectCell
inputClassName={styles.selectInput}
id={id}
isSelected={isSelected}
isDisabled={!selectedSeries || isExistingSeries}
onSelectedChange={onSelectedChange}
/>
<VirtualTableRowCell className={styles.folder}>
{id}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.monitor}>
<FormInputGroup
type={inputTypes.MONITOR_EPISODES_SELECT}
name="monitor"
value={monitor}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.qualityProfile}>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell
className={showLanguageProfile ? styles.languageProfile : styles.hideLanguageProfile}
>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
value={languageProfileId}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.seriesType}>
<FormInputGroup
type={inputTypes.SERIES_TYPE_SELECT}
name="seriesType"
value={seriesType}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.seasonFolder}>
<FormInputGroup
type={inputTypes.CHECK}
name="seasonFolder"
value={seasonFolder}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.series}>
<ImportSeriesSelectSeriesConnector
id={id}
isExistingSeries={isExistingSeries}
/>
</VirtualTableRowCell>
</VirtualTableRow>
);
}
ImportSeriesRow.propTypes = {
style: PropTypes.object.isRequired,
id: PropTypes.string.isRequired,
monitor: PropTypes.string.isRequired,
qualityProfileId: PropTypes.number.isRequired,
languageProfileId: PropTypes.number.isRequired,
seriesType: PropTypes.string.isRequired,
seasonFolder: PropTypes.bool.isRequired,
selectedSeries: PropTypes.object,
isExistingSeries: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
queued: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired
};
ImportSeriesRow.defaultsProps = {
items: []
};
export default ImportSeriesRow;

View file

@ -0,0 +1,91 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import ImportSeriesRow from './ImportSeriesRow';
function createImportSeriesItemSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.importSeries.items,
(id, items) => {
return _.find(items, { id }) || {};
}
);
}
function createMapStateToProps() {
return createSelector(
createImportSeriesItemSelector(),
createAllSeriesSelector(),
(item, series) => {
const selectedSeries = item && item.selectedSeries;
const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId });
return {
...item,
isExistingSeries
};
}
);
}
const mapDispatchToProps = {
queueLookupSeries,
setImportSeriesValue
};
class ImportSeriesRowConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setImportSeriesValue({
id: this.props.id,
[name]: value
});
}
//
// Render
render() {
// Don't show the row until we have the information we require for it.
const {
items,
monitor,
seriesType,
seasonFolder
} = this.props;
if (!items || !monitor || !seriesType || !seasonFolder == null) {
return null;
}
return (
<ImportSeriesRow
{...this.props}
onInputChange={this.onInputChange}
onSeriesSelect={this.onSeriesSelect}
/>
);
}
}
ImportSeriesRowConnector.propTypes = {
rootFolderId: PropTypes.number.isRequired,
id: PropTypes.string.isRequired,
monitor: PropTypes.string,
seriesType: PropTypes.string,
seasonFolder: PropTypes.bool,
items: PropTypes.arrayOf(PropTypes.object),
queueLookupSeries: PropTypes.func.isRequired,
setImportSeriesValue: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector);

View file

@ -0,0 +1,3 @@
.input {
composes: input from 'Components/Form/CheckInput.css';
}

View file

@ -0,0 +1,213 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import VirtualTable from 'Components/Table/VirtualTable';
import ImportSeriesHeader from './ImportSeriesHeader';
import ImportSeriesRowConnector from './ImportSeriesRowConnector';
class ImportSeriesTable extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._table = null;
}
componentDidMount() {
const {
unmappedFolders,
defaultMonitor,
defaultQualityProfileId,
defaultLanguageProfileId,
defaultSeriesType,
defaultSeasonFolder,
onSeriesLookup,
onSetImportSeriesValue
} = this.props;
const values = {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
languageProfileId: defaultLanguageProfileId,
seriesType: defaultSeriesType,
seasonFolder: defaultSeasonFolder
};
unmappedFolders.forEach((unmappedFolder) => {
const id = unmappedFolder.name;
onSeriesLookup(id, unmappedFolder.path);
onSetImportSeriesValue({
id,
...values
});
});
}
// This isn't great, but it's the most reliable way to ensure the items
// are checked off even if they aren't actually visible since the cells
// are virtualized.
componentDidUpdate(prevProps) {
const {
items,
selectedState,
onSelectedChange,
onRemoveSelectedStateItem
} = this.props;
prevProps.items.forEach((prevItem) => {
const {
id
} = prevItem;
const item = _.find(items, { id });
if (!item) {
onRemoveSelectedStateItem(id);
return;
}
const selectedSeries = item.selectedSeries;
const isSelected = selectedState[id];
const isExistingSeries = !!selectedSeries &&
_.some(prevProps.allSeries, { tvdbId: selectedSeries.tvdbId });
// Props doesn't have a selected series or
// the selected series is an existing series.
if ((selectedSeries && !prevItem.selectedSeries) || (isExistingSeries && !prevItem.selectedSeries)) {
onSelectedChange({ id, value: false });
return;
}
// State is selected, but a series isn't selected or
// the selected series is an existing series.
if (isSelected && (!selectedSeries || isExistingSeries)) {
onSelectedChange({ id, value: false });
return;
}
// A series is being selected that wasn't previously selected.
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
onSelectedChange({ id, value: true });
return;
}
});
// Forces the table to re-render if the selected state
// has changed otherwise it will be stale.
if (prevProps.selectedState !== selectedState && this._table) {
this._table.forceUpdateGrid();
}
}
//
// Control
setTableRef = (ref) => {
this._table = ref;
}
rowRenderer = ({ key, rowIndex, style }) => {
const {
rootFolderId,
items,
selectedState,
showLanguageProfile,
onSelectedChange
} = this.props;
const item = items[rowIndex];
return (
<ImportSeriesRowConnector
key={key}
style={style}
rootFolderId={rootFolderId}
showLanguageProfile={showLanguageProfile}
isSelected={selectedState[item.id]}
onSelectedChange={onSelectedChange}
id={item.id}
/>
);
}
//
// Render
render() {
const {
items,
allSelected,
allUnselected,
isSmallScreen,
contentBody,
showLanguageProfile,
scrollTop,
onSelectAllChange,
onScroll
} = this.props;
if (!items.length) {
return null;
}
return (
<VirtualTable
ref={this.setTableRef}
items={items}
contentBody={contentBody}
isSmallScreen={isSmallScreen}
rowHeight={52}
scrollTop={scrollTop}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<ImportSeriesHeader
showLanguageProfile={showLanguageProfile}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
}
onScroll={onScroll}
/>
);
}
}
ImportSeriesTable.propTypes = {
rootFolderId: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
defaultMonitor: PropTypes.string.isRequired,
defaultQualityProfileId: PropTypes.number,
defaultLanguageProfileId: PropTypes.number,
defaultSeriesType: PropTypes.string.isRequired,
defaultSeasonFolder: PropTypes.bool.isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
selectedState: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
allSeries: PropTypes.arrayOf(PropTypes.object),
contentBody: PropTypes.object.isRequired,
showLanguageProfile: PropTypes.bool.isRequired,
scrollTop: PropTypes.number.isRequired,
onSelectAllChange: PropTypes.func.isRequired,
onSelectedChange: PropTypes.func.isRequired,
onRemoveSelectedStateItem: PropTypes.func.isRequired,
onSeriesLookup: PropTypes.func.isRequired,
onSetImportSeriesValue: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired
};
export default ImportSeriesTable;

View file

@ -0,0 +1,44 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import ImportSeriesTable from './ImportSeriesTable';
function createMapStateToProps() {
return createSelector(
(state) => state.addSeries,
(state) => state.importSeries,
(state) => state.app.dimensions,
createAllSeriesSelector(),
(addSeries, importSeries, dimensions, allSeries) => {
return {
defaultMonitor: addSeries.defaults.monitor,
defaultQualityProfileId: addSeries.defaults.qualityProfileId,
defaultLanguageProfileId: addSeries.defaults.languageProfileId,
defaultSeriesType: addSeries.defaults.seriesType,
defaultSeasonFolder: addSeries.defaults.seasonFolder,
items: importSeries.items,
isSmallScreen: dimensions.isSmallScreen,
allSeries
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onSeriesLookup(name, path) {
dispatch(queueLookupSeries({
name,
path,
term: name
}));
},
onSetImportSeriesValue(values) {
dispatch(setImportSeriesValue(values));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(ImportSeriesTable);

View file

@ -0,0 +1,8 @@
.series {
padding: 10px 20px;
width: 100%;
&:hover {
background-color: $menuItemHoverColor;
}
}

View file

@ -0,0 +1,52 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSearchResult.css';
class ImportSeriesSearchResult extends Component {
//
// Listeners
onPress = () => {
this.props.onPress(this.props.tvdbId);
}
//
// Render
render() {
const {
title,
year,
network,
isExistingSeries
} = this.props;
return (
<Link
className={styles.series}
onPress={this.onPress}
>
<ImportSeriesTitle
title={title}
year={year}
network={network}
isExistingSeries={isExistingSeries}
/>
</Link>
);
}
}
ImportSeriesSearchResult.propTypes = {
tvdbId: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
network: PropTypes.string,
isExistingSeries: PropTypes.bool.isRequired,
onPress: PropTypes.func.isRequired
};
export default ImportSeriesSearchResult;

View file

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
function createMapStateToProps() {
return createSelector(
createExistingSeriesSelector(),
(isExistingSeries) => {
return {
isExistingSeries
};
}
);
}
export default connect(createMapStateToProps)(ImportSeriesSearchResult);

View file

@ -0,0 +1,72 @@
.tether {
z-index: 2000;
}
.button {
composes: link from 'Components/Link/Link.css';
position: relative;
display: flex;
align-items: center;
padding: 6px 16px;
width: 100%;
height: 35px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
.loading {
display: inline-block;
}
.warningIcon {
margin-right: 8px;
}
.existing {
margin-left: 5px;
}
.dropdownArrowContainer {
position: absolute;
right: 16px;
}
.contentContainer {
margin-top: 4px;
padding: 0 8px;
width: 400px;
}
.content {
padding: 4px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
}
.searchContainer {
display: flex;
}
.searchIconContainer {
width: 58px;
border: 1px solid $inputBorderColor;
border-right: none;
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
background-color: #edf1f2;
text-align: center;
line-height: 33px;
}
.searchInput {
composes: text from 'Components/Form/TextInput.css';
/*border-left: 0;*/
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}

View file

@ -0,0 +1,268 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import TetherComponent from 'react-tether';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import SpinnerIcon from 'Components/SpinnerIcon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TextInput from 'Components/Form/TextInput';
import ImportSeriesSearchResultConnector from './ImportSeriesSearchResultConnector';
import ImportSeriesTitle from './ImportSeriesTitle';
import styles from './ImportSeriesSelectSeries.css';
const tetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
}
],
attachment: 'top center',
targetAttachment: 'bottom center'
};
class ImportSeriesSelectSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._seriesLookupTimeout = null;
this.state = {
term: props.id,
isOpen: false
};
}
//
// Control
_setButtonRef = (ref) => {
this._buttonRef = ref;
}
_setContentRef = (ref) => {
this._contentRef = ref;
}
_addListener() {
window.addEventListener('click', this.onWindowClick);
}
_removeListener() {
window.removeEventListener('click', this.onWindowClick);
}
//
// Listeners
onWindowClick = (event) => {
const button = ReactDOM.findDOMNode(this._buttonRef);
const content = ReactDOM.findDOMNode(this._contentRef);
if (!button) {
return;
}
if (!button.contains(event.target) && content && !content.contains(event.target) && this.state.isOpen) {
this.setState({ isOpen: false });
this._removeListener();
}
}
onPress = () => {
if (this.state.isOpen) {
this._removeListener();
} else {
this._addListener();
}
this.setState({ isOpen: !this.state.isOpen });
}
onSearchInputChange = ({ value }) => {
if (this._seriesLookupTimeout) {
clearTimeout(this._seriesLookupTimeout);
}
this.setState({ term: value }, () => {
this._seriesLookupTimeout = setTimeout(() => {
this.props.onSearchInputChange(value);
}, 200);
});
}
onSeriesSelect = (tvdbId) => {
this.setState({ isOpen: false });
this.props.onSeriesSelect(tvdbId);
}
//
// Render
render() {
const {
selectedSeries,
isExistingSeries,
isFetching,
isPopulated,
error,
items,
queued,
onSeriesSelect
} = this.props;
const errorMessage = error &&
error.responseJSON &&
error.responseJSON.message;
return (
<TetherComponent
classes={{
element: styles.tether
}}
{...tetherOptions}
>
<Link
ref={this._setButtonRef}
className={styles.button}
component="div"
onPress={this.onPress}
>
{
queued && !isPopulated &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
{
isPopulated && selectedSeries && isExistingSeries &&
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
}
{
isPopulated && selectedSeries &&
<ImportSeriesTitle
title={selectedSeries.title}
year={selectedSeries.year}
network={selectedSeries.network}
isExistingSeries={isExistingSeries}
/>
}
{
isPopulated && !selectedSeries &&
<div>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
No match found!
</div>
}
{
!isFetching && !!error &&
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
Search failed, please try again later.
</div>
}
<div className={styles.dropdownArrowContainer}>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
{
this.state.isOpen &&
<div
ref={this._setContentRef}
className={styles.contentContainer}
>
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<SpinnerIcon
name={icons.SEARCH}
isSpinning={isFetching}
/>
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={this.state.term}
onChange={this.onSearchInputChange}
/>
</div>
<div className={styles.results}>
{
items.map((item) => {
return (
<ImportSeriesSearchResultConnector
key={item.tvdbId}
tvdbId={item.tvdbId}
title={item.title}
year={item.year}
network={item.network}
onPress={this.onSeriesSelect}
/>
);
})
}
</div>
</div>
</div>
}
</TetherComponent>
);
}
}
ImportSeriesSelectSeries.propTypes = {
id: PropTypes.string.isRequired,
selectedSeries: PropTypes.object,
isExistingSeries: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
queued: PropTypes.bool.isRequired,
onSearchInputChange: PropTypes.func.isRequired,
onSeriesSelect: PropTypes.func.isRequired
};
ImportSeriesSelectSeries.defaultProps = {
isFetching: true,
isPopulated: false,
items: [],
queued: true
};
export default ImportSeriesSelectSeries;

View file

@ -0,0 +1,71 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions';
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
import ImportSeriesSelectSeries from './ImportSeriesSelectSeries';
function createMapStateToProps() {
return createSelector(
createImportSeriesItemSelector(),
(item) => {
return item;
}
);
}
const mapDispatchToProps = {
queueLookupSeries,
setImportSeriesValue
};
class ImportSeriesSelectSeriesConnector extends Component {
//
// Listeners
onSearchInputChange = (term) => {
this.props.queueLookupSeries({
name: this.props.id,
term
});
}
onSeriesSelect = (tvdbId) => {
const {
id,
items
} = this.props;
this.props.setImportSeriesValue({
id,
selectedSeries: _.find(items, { tvdbId })
});
}
//
// Render
render() {
return (
<ImportSeriesSelectSeries
{...this.props}
onSearchInputChange={this.onSearchInputChange}
onSeriesSelect={this.onSeriesSelect}
/>
);
}
}
ImportSeriesSelectSeriesConnector.propTypes = {
id: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
selectedSeries: PropTypes.object,
isSelected: PropTypes.bool,
queueLookupSeries: PropTypes.func.isRequired,
setImportSeriesValue: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectSeriesConnector);

View file

@ -0,0 +1,17 @@
.titleContainer {
display: flex;
align-items: center;
}
.title {
margin-right: 5px;
}
.year {
margin-left: 5px;
color: $disabledColor;
}
.existing {
margin-left: 5px;
}

View file

@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import Label from 'Components/Label';
import styles from './ImportSeriesTitle.css';
function ImportSeriesTitle(props) {
const {
title,
year,
network,
isExistingSeries
} = props;
return (
<div className={styles.titleContainer}>
<div className={styles.title}>
{title}
{
!title.contains(year) &&
<span className={styles.year}>({year})</span>
}
</div>
{
!!network &&
<Label>{network}</Label>
}
{
isExistingSeries &&
<Label
kind={kinds.WARNING}
>
Existing
</Label>
}
</div>
);
}
ImportSeriesTitle.propTypes = {
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
network: PropTypes.string,
isExistingSeries: PropTypes.bool.isRequired
};
export default ImportSeriesTitle;

View file

@ -0,0 +1,30 @@
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import Switch from 'Components/Router/Switch';
import ImportSeriesSelectFolderConnector from 'AddArtist/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector';
import ImportSeriesConnector from 'AddArtist/ImportSeries/Import/ImportSeriesConnector';
class ImportSeries extends Component {
//
// Render
render() {
return (
<Switch>
<Route
exact={true}
path="/add/import"
component={ImportSeriesSelectFolderConnector}
/>
<Route
path="/add/import/:rootFolderId"
component={ImportSeriesConnector}
/>
</Switch>
);
}
}
export default ImportSeries;

View file

@ -0,0 +1,18 @@
.link {
composes: link from 'Components/Link/Link.css';
display: block;
}
.freeSpace,
.unmappedFolders {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 150px;
}
.actions {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 45px;
}

View file

@ -0,0 +1,64 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import styles from './ImportSeriesRootFolderRow.css';
function ImportSeriesRootFolderRow(props) {
const {
id,
path,
freeSpace,
unmappedFolders,
onDeletePress
} = props;
const unmappedFoldersCount = unmappedFolders.length || '-';
return (
<TableRow>
<TableRowCell>
<Link
className={styles.link}
to={`/add/import/${id}`}
>
{path}
</Link>
</TableRowCell>
<TableRowCell className={styles.freeSpace}>
{formatBytes(freeSpace) || '-'}
</TableRowCell>
<TableRowCell className={styles.unmappedFolders}>
{unmappedFoldersCount}
</TableRowCell>
<TableRowCell className={styles.actions}>
<IconButton
name={icons.REMOVE}
onPress={onDeletePress}
/>
</TableRowCell>
</TableRow>
);
}
ImportSeriesRootFolderRow.propTypes = {
id: PropTypes.number.isRequired,
path: PropTypes.string.isRequired,
freeSpace: PropTypes.number.isRequired,
unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
onDeletePress: PropTypes.func.isRequired
};
ImportSeriesRootFolderRow.defaultProps = {
freeSpace: 0,
unmappedFolders: []
};
export default ImportSeriesRootFolderRow;

View file

@ -0,0 +1,48 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteRootFolder } from 'Store/Actions/rootFolderActions';
import ImportSeriesRootFolderRow from './ImportSeriesRootFolderRow';
function createMapStateToProps() {
return createSelector(
() => {
return {
};
}
);
}
const mapDispatchToProps = {
deleteRootFolder
};
class ImportSeriesRootFolderRowConnector extends Component {
//
// Listeners
onDeletePress = () => {
this.props.deleteRootFolder({ id: this.props.id });
}
//
// Render
render() {
return (
<ImportSeriesRootFolderRow
{...this.props}
onDeletePress={this.onDeletePress}
/>
);
}
}
ImportSeriesRootFolderRowConnector.propTypes = {
id: PropTypes.number.isRequired,
deleteRootFolder: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRootFolderRowConnector);

View file

@ -0,0 +1,32 @@
.header {
margin-bottom: 40px;
text-align: center;
font-weight: 300;
font-size: 36px;
}
.tips {
font-size: 20px;
}
.tip {
font-size: $defaultFontSize;
}
.code {
font-size: 12px;
font-family: $monoSpaceFontFamily;
}
.recentFolders {
margin-top: 40px;
}
.startImport {
margin-top: 40px;
text-align: center;
}
.importButtonIcon {
margin-right: 8px;
}

View file

@ -0,0 +1,188 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds, sizes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import ImportSeriesRootFolderRowConnector from './ImportSeriesRootFolderRowConnector';
import styles from './ImportSeriesSelectFolder.css';
const rootFolderColumns = [
{
name: 'path',
label: 'Path',
isVisible: true
},
{
name: 'freeSpace',
label: 'Free Space',
isVisible: true
},
{
name: 'unmappedFolders',
label: 'Unmapped Folders',
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
class ImportSeriesSelectFolder extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddNewRootFolderModalOpen: false
};
}
//
// Lifecycle
onAddNewRootFolderPress = () => {
this.setState({ isAddNewRootFolderModalOpen: true });
}
onNewRootFolderSelect = ({ value }) => {
this.props.onNewRootFolderSelect(value);
}
onAddRootFolderModalClose = () => {
this.setState({ isAddNewRootFolderModalOpen: false });
}
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items
} = this.props;
return (
<PageContent title="Import Series">
<PageContentBodyConnector>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load root folders</div>
}
{
!error && isPopulated &&
<div>
<div className={styles.header}>
Import series you already have
</div>
<div className={styles.tips}>
Some tips to ensure the import goes smoothly:
<ul>
<li className={styles.tip}>
Make sure your files include the quality in the name. eg. <span className={styles.code}>episode.s02e15.bluray.mkv</span>
</li>
<li className={styles.tip}>
Point Sonarr to the folder containing all of your tv shows not a specific one. eg. <span className={styles.code}>"\tv shows\"</span> and not <span className={styles.code}>"\tv shows\the simpsons\"</span>
</li>
</ul>
</div>
{
items.length > 0 ?
<div className={styles.recentFolders}>
<FieldSet
legend="Recent Folders"
>
<Table
columns={rootFolderColumns}
>
<TableBody>
{
items.map((rootFolder) => {
return (
<ImportSeriesRootFolderRowConnector
key={rootFolder.id}
id={rootFolder.id}
path={rootFolder.path}
freeSpace={rootFolder.freeSpace}
unmappedFolders={rootFolder.unmappedFolders}
/>
);
})
}
</TableBody>
</Table>
</FieldSet>
<Button
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={this.onAddNewRootFolderPress}
>
<Icon
className={styles.importButtonIcon}
name="fa fa-hdd-o"
/>
Choose another folder
</Button>
</div> :
<div className={styles.startImport}>
<Button
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={this.onAddNewRootFolderPress}
>
<Icon
className={styles.importButtonIcon}
name="fa fa-hdd-o"
/>
Start Import
</Button>
</div>
}
<FileBrowserModal
isOpen={this.state.isAddNewRootFolderModalOpen}
name="rootFolderPath"
value=""
onChange={this.onNewRootFolderSelect}
onModalClose={this.onAddRootFolderModalClose}
/>
</div>
}
</PageContentBodyConnector>
</PageContent>
);
}
}
ImportSeriesSelectFolder.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onNewRootFolderSelect: PropTypes.func.isRequired,
onDeleteRootFolderPress: PropTypes.func.isRequired
};
export default ImportSeriesSelectFolder;

View file

@ -0,0 +1,87 @@
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 hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions';
import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
function createMapStateToProps() {
return createSelector(
(state) => state.rootFolders,
(rootFolders) => {
return rootFolders;
}
);
}
const mapDispatchToProps = {
fetchRootFolders,
addRootFolder,
deleteRootFolder,
push
};
class ImportSeriesSelectFolderConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchRootFolders();
}
componentDidUpdate(prevProps) {
const {
items,
isSaving,
saveError
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id);
if (newRootFolders.length === 1) {
this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`);
}
}
}
//
// Listeners
onNewRootFolderSelect = (path) => {
this.props.addRootFolder({ path });
}
onDeleteRootFolderPress = (id) => {
this.props.deleteRootFolder({ id });
}
//
// Render
render() {
return (
<ImportSeriesSelectFolder
{...this.props}
onNewRootFolderSelect={this.onNewRootFolderSelect}
onDeleteRootFolderPress={this.onDeleteRootFolderPress}
/>
);
}
}
ImportSeriesSelectFolderConnector.propTypes = {
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchRootFolders: PropTypes.func.isRequired,
addRootFolder: PropTypes.func.isRequired,
deleteRootFolder: PropTypes.func.isRequired,
push: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectFolderConnector);

View file

@ -0,0 +1,46 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
function SeriesMonitoringOptionsPopoverContent() {
return (
<DescriptionList>
<DescriptionListItem
title="All Episodes"
data="Monitor all episodes except specials"
/>
<DescriptionListItem
title="Future Episodes"
data="Monitor episodes that have not aired yet"
/>
<DescriptionListItem
title="Missing Episodes"
data="Monitor episodes that do not have files or have not aired yet"
/>
<DescriptionListItem
title="Existing Episodes"
data="Monitor episodes that have files or have not aired yet"
/>
<DescriptionListItem
title="First Season"
data="Monitor all episodes of the first season. All other seasons will be ignored"
/>
<DescriptionListItem
title="Latest Season"
data="Monitor all episodes of the latest season and future seasons"
/>
<DescriptionListItem
title="None"
data="No episodes will be monitored."
/>
</DescriptionList>
);
}
export default SeriesMonitoringOptionsPopoverContent;

View file

@ -0,0 +1,26 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
function SeriesTypePopoverContent() {
return (
<DescriptionList>
<DescriptionListItem
title="Anime"
data="Episodes released using an absolute episode number"
/>
<DescriptionListItem
title="Daily"
data="Episodes released daily or less frequently that use year-month-day (2017-05-25)"
/>
<DescriptionListItem
title="Standard"
data="Episodes released with SxxEyy pattern"
/>
</DescriptionList>
);
}
export default SeriesTypePopoverContent;