New: Release Profiles, Frontend updates (#580)

* New: Release Profiles - UI Updates

* New: Release Profiles - API Changes

* New: Release Profiles - Test Updates

* New: Release Profiles - Backend Updates

* New: Interactive Artist Search

* New: Change Montiored on Album Details Page

* New: Show Duration on Album Details Page

* Fixed: Manual Import not working if no albums are Missing

* Fixed: Sort search input by sortTitle

* Fixed: Queue columnLabel throwing JS error
pull/646/head
Qstick 5 years ago committed by GitHub
parent f126eafd26
commit 3f064c94b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -177,7 +177,7 @@
"no-undef": "error", "no-undef": "error",
"no-undef-init": "off", "no-undef-init": "off",
"no-undefined": "off", "no-undefined": "off",
"no-unused-vars": ["warn", { "args": "none" }], "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }],
"no-use-before-define": "error", "no-use-before-define": "error",
# Node.js and CommonJS # Node.js and CommonJS
@ -205,14 +205,13 @@
"func-style": ["error", "declaration"], "func-style": ["error", "declaration"],
"indent": ["error", 2, {"SwitchCase": 1}], "indent": ["error", 2, {"SwitchCase": 1}],
"key-spacing": ["error", {"beforeColon": false, "afterColon": true}], "key-spacing": ["error", {"beforeColon": false, "afterColon": true}],
"keyword-spacing": ["error", {before: true, after: true}], "keyword-spacing": ["error", { "before": true, "after": true}],
"lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }], "lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }],
"max-depth": ["error", {"maximum": 5}], "max-depth": ["error", {"maximum": 5}],
"max-nested-callbacks": ["error", 4], "max-nested-callbacks": ["error", 4],
"max-params": ["error", 7],
"max-statements": "off", "max-statements": "off",
"max-statements-per-line": ["error", { "max": 1 }], "max-statements-per-line": ["error", { "max": 1 }],
"new-cap": ["error", {"capIsNewExceptions": ["$.Deferred"]}], "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred", "DragDropContext", "DragLayer", "DragSource", "DropTarget"]}],
"new-parens": "error", "new-parens": "error",
"newline-after-var": "off", "newline-after-var": "off",
"newline-before-return": "off", "newline-before-return": "off",
@ -223,7 +222,7 @@
"no-inline-comments": "off", "no-inline-comments": "off",
"no-lonely-if": "warn", "no-lonely-if": "warn",
"no-mixed-spaces-and-tabs": "error", "no-mixed-spaces-and-tabs": "error",
"no-multiple-empty-lines": ["error", {max: 1}], "no-multiple-empty-lines": ["error", { "max": 1 }],
"no-negated-condition": "warn", "no-negated-condition": "warn",
"no-nested-ternary": "error", "no-nested-ternary": "error",
"no-new-object": "error", "no-new-object": "error",

@ -14,6 +14,7 @@ module.exports = (ctx, configPath, options) => {
return Object.assign(acc, reload(vars)); return Object.assign(acc, reload(vars));
}, {}) }, {})
}, },
'postcss-color-function': {},
'postcss-nested': {}, 'postcss-nested': {},
autoprefixer: { autoprefixer: {
browsers: [ browsers: [

@ -1,9 +1,10 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { icons } from 'Helpers/Props'; import { align, icons } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table'; import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager'; import TablePager from 'Components/Table/TablePager';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
@ -41,6 +42,18 @@ class Blacklist extends Component {
onPress={onClearBlacklistPress} onPress={onClearBlacklistPress}
/> />
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label="Options"
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
</PageToolbarSection>
</PageToolbar> </PageToolbar>
<PageContentBodyConnector> <PageContentBodyConnector>

@ -3,6 +3,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import withCurrentPage from 'Components/withCurrentPage';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import * as blacklistActions from 'Store/Actions/blacklistActions'; import * as blacklistActions from 'Store/Actions/blacklistActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
@ -33,8 +34,19 @@ class BlacklistConnector extends Component {
// Lifecycle // Lifecycle
componentDidMount() { componentDidMount() {
const {
useCurrentPage,
fetchBlacklist,
gotoBlacklistFirstPage
} = this.props;
registerPagePopulator(this.repopulate); registerPagePopulator(this.repopulate);
this.props.gotoBlacklistFirstPage();
if (useCurrentPage) {
fetchBlacklist();
} else {
gotoBlacklistFirstPage();
}
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@ -44,6 +56,7 @@ class BlacklistConnector extends Component {
} }
componentWillUnmount() { componentWillUnmount() {
this.props.clearBlacklist();
unregisterPagePopulator(this.repopulate); unregisterPagePopulator(this.repopulate);
} }
@ -53,7 +66,6 @@ class BlacklistConnector extends Component {
repopulate = () => { repopulate = () => {
this.props.fetchBlacklist(); this.props.fetchBlacklist();
} }
// //
// Listeners // Listeners
@ -93,6 +105,14 @@ class BlacklistConnector extends Component {
this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST }); this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST });
} }
onTableOptionChange = (payload) => {
this.props.setBlacklistTableOption(payload);
if (payload.pageSize) {
this.props.gotoBlacklistFirstPage();
}
}
// //
// Render // Render
@ -114,6 +134,7 @@ class BlacklistConnector extends Component {
} }
BlacklistConnector.propTypes = { BlacklistConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
isClearingBlacklistExecuting: PropTypes.bool.isRequired, isClearingBlacklistExecuting: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchBlacklist: PropTypes.func.isRequired, fetchBlacklist: PropTypes.func.isRequired,
@ -124,7 +145,10 @@ BlacklistConnector.propTypes = {
gotoBlacklistPage: PropTypes.func.isRequired, gotoBlacklistPage: PropTypes.func.isRequired,
setBlacklistSort: PropTypes.func.isRequired, setBlacklistSort: PropTypes.func.isRequired,
setBlacklistTableOption: PropTypes.func.isRequired, setBlacklistTableOption: PropTypes.func.isRequired,
clearBlacklist: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired executeCommand: PropTypes.func.isRequired
}; };
export default connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector); export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector)
);

@ -5,7 +5,7 @@ import IconButton from 'Components/Link/IconButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import EpisodeLanguage from 'Album/EpisodeLanguage'; import TrackLanguage from 'Album/TrackLanguage';
import TrackQuality from 'Album/TrackQuality'; import TrackQuality from 'Album/TrackQuality';
import ArtistNameLink from 'Artist/ArtistNameLink'; import ArtistNameLink from 'Artist/ArtistNameLink';
import BlacklistDetailsModal from './BlacklistDetailsModal'; import BlacklistDetailsModal from './BlacklistDetailsModal';
@ -90,7 +90,7 @@ class BlacklistRow extends Component {
key={name} key={name}
className={styles.language} className={styles.language}
> >
<EpisodeLanguage <TrackLanguage
language={language} language={language}
/> />
</TableRowCell> </TableRowCell>

@ -0,0 +1,5 @@
.description {
composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css';
overflow-wrap: break-word;
}

@ -7,6 +7,7 @@ import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
import styles from './HistoryDetails.css';
function getDetailedList(statusMessages) { function getDetailedList(statusMessages) {
return ( return (
@ -60,6 +61,7 @@ function HistoryDetails(props) {
return ( return (
<DescriptionList> <DescriptionList>
<DescriptionListItem <DescriptionListItem
descriptionClassName={styles.description}
title="Name" title="Name"
data={sourceTitle} data={sourceTitle}
/> />
@ -75,6 +77,7 @@ function HistoryDetails(props) {
{ {
!!releaseGroup && !!releaseGroup &&
<DescriptionListItem <DescriptionListItem
descriptionClassName={styles.description}
title="Release Group" title="Release Group"
data={releaseGroup} data={releaseGroup}
/> />
@ -136,6 +139,7 @@ function HistoryDetails(props) {
return ( return (
<DescriptionList> <DescriptionList>
<DescriptionListItem <DescriptionListItem
descriptionClassName={styles.description}
title="Name" title="Name"
data={sourceTitle} data={sourceTitle}
/> />
@ -160,6 +164,7 @@ function HistoryDetails(props) {
return ( return (
<DescriptionList> <DescriptionList>
<DescriptionListItem <DescriptionListItem
descriptionClassName={styles.description}
title="Name" title="Name"
data={sourceTitle} data={sourceTitle}
/> />
@ -167,6 +172,7 @@ function HistoryDetails(props) {
{ {
!!droppedPath && !!droppedPath &&
<DescriptionListItem <DescriptionListItem
descriptionClassName={styles.description}
title="Source" title="Source"
data={droppedPath} data={droppedPath}
/> />
@ -175,6 +181,7 @@ function HistoryDetails(props) {
{ {
!!importedPath && !!importedPath &&
<DescriptionListItem <DescriptionListItem
descriptionClassName={styles.description}
title="Imported To" title="Imported To"
data={importedPath} data={importedPath}
/> />

@ -5,6 +5,7 @@ import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table'; import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager'; import TablePager from 'Components/Table/TablePager';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
@ -75,6 +76,16 @@ class History extends Component {
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}> <PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label="Options"
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu <FilterMenu
alignMenu={align.RIGHT} alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey} selectedFilterKey={selectedFilterKey}

@ -5,6 +5,7 @@ import { createSelector } from 'reselect';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import withCurrentPage from 'Components/withCurrentPage';
import * as historyActions from 'Store/Actions/historyActions'; import * as historyActions from 'Store/Actions/historyActions';
import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions'; import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions';
import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
@ -43,8 +44,19 @@ class HistoryConnector extends Component {
// Lifecycle // Lifecycle
componentDidMount() { componentDidMount() {
const {
useCurrentPage,
fetchHistory,
gotoHistoryFirstPage
} = this.props;
registerPagePopulator(this.repopulate); registerPagePopulator(this.repopulate);
this.props.gotoHistoryFirstPage();
if (useCurrentPage) {
fetchHistory();
} else {
gotoHistoryFirstPage();
}
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@ -138,6 +150,7 @@ class HistoryConnector extends Component {
} }
HistoryConnector.propTypes = { HistoryConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchHistory: PropTypes.func.isRequired, fetchHistory: PropTypes.func.isRequired,
gotoHistoryFirstPage: PropTypes.func.isRequired, gotoHistoryFirstPage: PropTypes.func.isRequired,
@ -155,4 +168,6 @@ HistoryConnector.propTypes = {
clearTracks: PropTypes.func.isRequired clearTracks: PropTypes.func.isRequired
}; };
export default connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector); export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector)
);

@ -6,7 +6,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import AlbumTitleLink from 'Album/AlbumTitleLink'; import AlbumTitleLink from 'Album/AlbumTitleLink';
import EpisodeLanguage from 'Album/EpisodeLanguage'; import TrackLanguage from 'Album/TrackLanguage';
import TrackQuality from 'Album/TrackQuality'; import TrackQuality from 'Album/TrackQuality';
import ArtistNameLink from 'Artist/ArtistNameLink'; import ArtistNameLink from 'Artist/ArtistNameLink';
import HistoryEventTypeCell from './HistoryEventTypeCell'; import HistoryEventTypeCell from './HistoryEventTypeCell';
@ -131,7 +131,7 @@ class HistoryRow extends Component {
if (name === 'language') { if (name === 'language') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
<EpisodeLanguage <TrackLanguage
language={language} language={language}
isCutoffMet={languageCutoffNotMet} isCutoffMet={languageCutoffNotMet}
/> />

@ -3,9 +3,10 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import getSelectedIds from 'Utilities/Table/getSelectedIds'; import getSelectedIds from 'Utilities/Table/getSelectedIds';
import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
import selectAll from 'Utilities/Table/selectAll'; import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected'; import toggleSelected from 'Utilities/Table/toggleSelected';
import { icons } from 'Helpers/Props'; import { align, icons } from 'Helpers/Props';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table'; import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
@ -16,7 +17,9 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import RemoveQueueItemsModal from './RemoveQueueItemsModal'; import RemoveQueueItemsModal from './RemoveQueueItemsModal';
import QueueOptionsConnector from './QueueOptionsConnector';
import QueueRowConnector from './QueueRowConnector'; import QueueRowConnector from './QueueRowConnector';
class Queue extends Component { class Queue extends Component {
@ -42,22 +45,27 @@ class Queue extends Component {
// before albums start fetching or when albums start fetching. // before albums start fetching or when albums start fetching.
if ( if (
( this.props.isFetching &&
this.props.isFetching && nextProps.isPopulated &&
nextProps.isPopulated && hasDifferentItems(this.props.items, nextProps.items) &&
hasDifferentItems(this.props.items, nextProps.items) nextProps.items.some((e) => e.albumId)
) ||
(!this.props.isAlbumsFetching && nextProps.isAlbumsFetching)
) { ) {
return false; return false;
} }
if (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching) {
return false;
}
return true; return true;
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) { if (hasDifferentItems(prevProps.items, this.props.items)) {
this.setState({ selectedState: {} }); this.setState((state) => {
return removeOldSelectedState(state, prevProps.items);
});
return; return;
} }
@ -138,7 +146,7 @@ class Queue extends Component {
} = this.state; } = this.state;
const isRefreshing = isFetching || isAlbumsFetching || isCheckForFinishedDownloadExecuting; const isRefreshing = isFetching || isAlbumsFetching || isCheckForFinishedDownloadExecuting;
const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length); const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length || items.every((e) => !e.albumId));
const hasError = error || albumsError; const hasError = error || albumsError;
const selectedCount = this.getSelectedIds().length; const selectedCount = this.getSelectedIds().length;
const disableSelectedActions = selectedCount === 0; const disableSelectedActions = selectedCount === 0;
@ -172,6 +180,21 @@ class Queue extends Component {
onPress={this.onRemoveSelectedPress} onPress={this.onRemoveSelectedPress}
/> />
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
>
<TableOptionsModalWrapper
columns={columns}
{...otherProps}
optionsComponent={QueueOptionsConnector}
>
<PageToolbarButton
label="Options"
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
</PageToolbarSection>
</PageToolbar> </PageToolbar>
<PageContentBodyConnector> <PageContentBodyConnector>
@ -203,6 +226,7 @@ class Queue extends Component {
allSelected={allSelected} allSelected={allSelected}
allUnselected={allUnselected} allUnselected={allUnselected}
{...otherProps} {...otherProps}
optionsComponent={QueueOptionsConnector}
onSelectAllChange={this.onSelectAllChange} onSelectAllChange={this.onSelectAllChange}
> >
<TableBody> <TableBody>

@ -5,6 +5,7 @@ import { createSelector } from 'reselect';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import withCurrentPage from 'Components/withCurrentPage';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import * as queueActions from 'Store/Actions/queueActions'; import * as queueActions from 'Store/Actions/queueActions';
@ -15,14 +16,16 @@ import Queue from './Queue';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.albums, (state) => state.albums,
(state) => state.queue.options,
(state) => state.queue.paged, (state) => state.queue.paged,
createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD), createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD),
(albums, queue, isCheckForFinishedDownloadExecuting) => { (albums, options, queue, isCheckForFinishedDownloadExecuting) => {
return { return {
isAlbumsFetching: albums.isFetching, isAlbumsFetching: albums.isFetching,
isAlbumsPopulated: albums.isPopulated, isAlbumsPopulated: albums.isPopulated,
albumsError: albums.error, albumsError: albums.error,
isCheckForFinishedDownloadExecuting, isCheckForFinishedDownloadExecuting,
...options,
...queue ...queue
}; };
} }
@ -42,19 +45,37 @@ class QueueConnector extends Component {
// Lifecycle // Lifecycle
componentDidMount() { componentDidMount() {
const {
useCurrentPage,
fetchQueue,
gotoQueueFirstPage
} = this.props;
registerPagePopulator(this.repopulate); registerPagePopulator(this.repopulate);
this.props.gotoQueueFirstPage();
if (useCurrentPage) {
fetchQueue();
} else {
gotoQueueFirstPage();
}
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (hasDifferentItems(prevProps.items, this.props.items)) { if (hasDifferentItems(prevProps.items, this.props.items)) {
const albumIds = selectUniqueIds(this.props.items, 'albumId'); const albumIds = selectUniqueIds(this.props.items, 'albumId');
if (albumIds.length) { if (albumIds.length) {
this.props.fetchAlbums({ albumIds }); this.props.fetchAlbums({ albumIds });
} else { } else {
this.props.clearAlbums(); this.props.clearAlbums();
} }
}
if (
this.props.includeUnknownArtistItems !==
prevProps.includeUnknownArtistItems
) {
this.repopulate();
} }
} }
@ -160,4 +181,6 @@ QueueConnector.propTypes = {
executeCommand: PropTypes.func.isRequired executeCommand: PropTypes.func.isRequired
}; };
export default connect(createMapStateToProps, mapDispatchToProps)(QueueConnector); export default withCurrentPage(
connect(createMapStateToProps, mapDispatchToProps)(QueueConnector)
);

@ -0,0 +1,77 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import { inputTypes } from 'Helpers/Props';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
class QueueOptions extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
includeUnknownArtistItems: props.includeUnknownArtistItems
};
}
componentDidUpdate(prevProps) {
const {
includeUnknownArtistItems
} = this.props;
if (includeUnknownArtistItems !== prevProps.includeUnknownArtistItems) {
this.setState({
includeUnknownArtistItems
});
}
}
//
// Listeners
onOptionChange = ({ name, value }) => {
this.setState({
[name]: value
}, () => {
this.props.onOptionChange({
[name]: value
});
});
}
//
// Render
render() {
const {
includeUnknownArtistItems
} = this.state;
return (
<Fragment>
<FormGroup>
<FormLabel>Show Unknown Artist Items</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="includeUnknownArtistItems"
value={includeUnknownArtistItems}
helpText="Show items without a artist in the queue, this could include removed artists, movies or anything else in Lidarr's category"
onChange={this.onOptionChange}
/>
</FormGroup>
</Fragment>
);
}
}
QueueOptions.propTypes = {
includeUnknownArtistItems: PropTypes.bool.isRequired,
onOptionChange: PropTypes.func.isRequired
};
export default QueueOptions;

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setQueueOption } from 'Store/Actions/queueActions';
import QueueOptions from './QueueOptions';
function createMapStateToProps() {
return createSelector(
(state) => state.queue.options,
(options) => {
return options;
}
);
}
const mapDispatchToProps = {
onOptionChange: setQueueOption
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions);

@ -12,6 +12,7 @@ import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import AlbumTitleLink from 'Album/AlbumTitleLink'; import AlbumTitleLink from 'Album/AlbumTitleLink';
import TrackLanguage from 'Album/TrackLanguage';
import TrackQuality from 'Album/TrackQuality'; import TrackQuality from 'Album/TrackQuality';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import ArtistNameLink from 'Artist/ArtistNameLink'; import ArtistNameLink from 'Artist/ArtistNameLink';
@ -72,6 +73,7 @@ class QueueRow extends Component {
errorMessage, errorMessage,
artist, artist,
album, album,
language,
quality, quality,
protocol, protocol,
indexer, indexer,
@ -137,43 +139,58 @@ class QueueRow extends Component {
if (name === 'artist.sortName') { if (name === 'artist.sortName') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
<ArtistNameLink {
foreignArtistId={artist.foreignArtistId} artist ?
artistName={artist.artistName} <ArtistNameLink
/> foreignArtistId={artist.foreignArtistId}
artistName={artist.artistName}
/> :
title
}
</TableRowCell> </TableRowCell>
); );
} }
if (name === 'artist') { if (name === 'album.title') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
<ArtistNameLink {
foreignArtistId={artist.foreignArtistId} album ?
artistName={artist.artistName} <AlbumTitleLink
/> foreignAlbumId={album.foreignAlbumId}
title={album.title}
disambiguation={album.disambiguation}
/> :
'-'
}
</TableRowCell> </TableRowCell>
); );
} }
if (name === 'album.title') { if (name === 'album.releaseDate') {
if (album) {
return (
<RelativeDateCellConnector
key={name}
date={album.releaseDate}
/>
);
}
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
<AlbumTitleLink -
foreignAlbumId={album.foreignAlbumId}
title={album.title}
disambiguation={album.disambiguation}
/>
</TableRowCell> </TableRowCell>
); );
} }
if (name === 'album.releaseDate') { if (name === 'language') {
return ( return (
<RelativeDateCellConnector <TableRowCell key={name}>
key={name} <TrackLanguage
date={album.releaseDate} language={language}
/> />
</TableRowCell>
); );
} }
@ -326,8 +343,9 @@ QueueRow.propTypes = {
trackedDownloadStatus: PropTypes.string, trackedDownloadStatus: PropTypes.string,
statusMessages: PropTypes.arrayOf(PropTypes.object), statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string, errorMessage: PropTypes.string,
artist: PropTypes.object.isRequired, artist: PropTypes.object,
album: PropTypes.object.isRequired, album: PropTypes.object,
language: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,
protocol: PropTypes.string.isRequired, protocol: PropTypes.string.isRequired,
indexer: PropTypes.string, indexer: PropTypes.string,

@ -51,10 +51,6 @@ class QueueRowConnector extends Component {
// Render // Render
render() { render() {
if (!this.props.album) {
return null;
}
return ( return (
<QueueRow <QueueRow
{...this.props} {...this.props}

@ -9,12 +9,19 @@ function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.app, (state) => state.app,
(state) => state.queue.status, (state) => state.queue.status,
(app, status) => { (state) => state.queue.options.includeUnknownArtistItems,
(app, status, includeUnknownArtistItems) => {
const {
count,
unknownCount
} = status.item;
return { return {
isConnected: app.isConnected, isConnected: app.isConnected,
isReconnecting: app.isReconnecting, isReconnecting: app.isReconnecting,
isPopulated: status.isPopulated, isPopulated: status.isPopulated,
...status.item ...status.item,
count: includeUnknownArtistItems ? count : count - unknownCount
}; };
} }
); );

@ -100,8 +100,8 @@ class AddNewArtist extends Component {
name="artistLookup" name="artistLookup"
value={term} value={term}
placeholder="eg. Breaking Benjamin, lidarr:854a1807-025b-42a8-ba8c-2a39717f1d25" placeholder="eg. Breaking Benjamin, lidarr:854a1807-025b-42a8-ba8c-2a39717f1d25"
onChange={this.onSearchInputChange}
autoFocus={true} autoFocus={true}
onChange={this.onSearchInputChange}
/> />
<Button <Button

@ -74,7 +74,8 @@ class AddNewArtistModalContent extends Component {
showMetadataProfile, showMetadataProfile,
isSmallScreen, isSmallScreen,
onModalClose, onModalClose,
onInputChange onInputChange,
...otherProps
} = this.props; } = this.props;
return ( return (
@ -86,7 +87,8 @@ class AddNewArtistModalContent extends Component {
<ModalBody> <ModalBody>
<div className={styles.container}> <div className={styles.container}>
{ {
!isSmallScreen && isSmallScreen ?
null:
<div className={styles.poster}> <div className={styles.poster}>
<ArtistPoster <ArtistPoster
className={styles.poster} className={styles.poster}
@ -97,15 +99,19 @@ class AddNewArtistModalContent extends Component {
} }
<div className={styles.info}> <div className={styles.info}>
<div className={styles.overview}> {
<TextTruncate overview ?
truncateText="…" <div className={styles.overview}>
line={8} <TextTruncate
text={overview} truncateText="…"
/> line={8}
</div> text={overview}
/>
<Form> </div> :
null
}
<Form {...otherProps}>
<FormGroup> <FormGroup>
<FormLabel>Root Folder</FormLabel> <FormLabel>Root Folder</FormLabel>

@ -107,8 +107,11 @@ class AddNewArtistSearchResult extends Component {
{artistName} {artistName}
{ {
!name.contains(year) && !!year && !name.contains(year) && year ?
<span className={styles.year}>({year})</span> <span className={styles.year}>
({year})
</span> :
null
} }
{ {
@ -117,13 +120,14 @@ class AddNewArtistSearchResult extends Component {
} }
{ {
isExistingArtist && isExistingArtist ?
<Icon <Icon
className={styles.alreadyExistsIcon} className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE} name={icons.CHECK_CIRCLE}
size={36} size={36}
title="Already in your library" title="Already in your library"
/> /> :
null
} }
</div> </div>
@ -136,20 +140,22 @@ class AddNewArtistSearchResult extends Component {
</Label> </Label>
{ {
!!artistType && artistType ?
<Label size={sizes.LARGE}> <Label size={sizes.LARGE}>
{artistType} {artistType}
</Label> </Label> :
null
} }
{ {
status === 'ended' && status === 'ended' ?
<Label <Label
kind={kinds.DANGER} kind={kinds.DANGER}
size={sizes.LARGE} size={sizes.LARGE}
> >
Ended Ended
</Label> </Label> :
null
} }
</div> </div>

@ -116,9 +116,11 @@ class ImportArtistFooter extends Component {
isQualityProfileIdMixed, isQualityProfileIdMixed,
isLanguageProfileIdMixed, isLanguageProfileIdMixed,
isMetadataProfileIdMixed, isMetadataProfileIdMixed,
hasUnsearchedItems,
showLanguageProfile, showLanguageProfile,
showMetadataProfile, showMetadataProfile,
onImportPress, onImportPress,
onLookupPress,
onCancelLookupPress onCancelLookupPress
} = this.props; } = this.props;
@ -238,6 +240,17 @@ class ImportArtistFooter extends Component {
</Button> </Button>
} }
{
hasUnsearchedItems &&
<Button
className={styles.loadingButton}
kind={kinds.SUCCESS}
onPress={onLookupPress}
>
Start Processing
</Button>
}
{ {
isLookingUpArtist && isLookingUpArtist &&
<LoadingIndicator <LoadingIndicator
@ -271,10 +284,12 @@ ImportArtistFooter.propTypes = {
isLanguageProfileIdMixed: PropTypes.bool.isRequired, isLanguageProfileIdMixed: PropTypes.bool.isRequired,
isMetadataProfileIdMixed: PropTypes.bool.isRequired, isMetadataProfileIdMixed: PropTypes.bool.isRequired,
isAlbumFolderMixed: PropTypes.bool.isRequired, isAlbumFolderMixed: PropTypes.bool.isRequired,
hasUnsearchedItems: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired,
showMetadataProfile: PropTypes.bool.isRequired, showMetadataProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired,
onImportPress: PropTypes.func.isRequired, onImportPress: PropTypes.func.isRequired,
onLookupPress: PropTypes.func.isRequired,
onCancelLookupPress: PropTypes.func.isRequired onCancelLookupPress: PropTypes.func.isRequired
}; };

@ -2,7 +2,7 @@ import _ from 'lodash';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import ImportArtistFooter from './ImportArtistFooter'; import ImportArtistFooter from './ImportArtistFooter';
import { cancelLookupArtist } from 'Store/Actions/importArtistActions'; import { lookupUnsearchedArtist, cancelLookupArtist } from 'Store/Actions/importArtistActions';
function isMixed(items, selectedIds, defaultValue, key) { function isMixed(items, selectedIds, defaultValue, key) {
return _.some(items, (artist) => { return _.some(items, (artist) => {
@ -35,6 +35,7 @@ function createMapStateToProps() {
const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId'); const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId');
const isMetadataProfileIdMixed = isMixed(items, selectedIds, defaultMetadataProfileId, 'metadataProfileId'); const isMetadataProfileIdMixed = isMixed(items, selectedIds, defaultMetadataProfileId, 'metadataProfileId');
const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder'); const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder');
const hasUnsearchedItems = !isLookingUpArtist && items.some((item) => !item.isPopulated);
return { return {
selectedCount: selectedIds.length, selectedCount: selectedIds.length,
@ -49,13 +50,15 @@ function createMapStateToProps() {
isQualityProfileIdMixed, isQualityProfileIdMixed,
isLanguageProfileIdMixed, isLanguageProfileIdMixed,
isMetadataProfileIdMixed, isMetadataProfileIdMixed,
isAlbumFolderMixed isAlbumFolderMixed,
hasUnsearchedItems
}; };
} }
); );
} }
const mapDispatchToProps = { const mapDispatchToProps = {
onLookupPress: lookupUnsearchedArtist,
onCancelLookupPress: cancelLookupArtist onCancelLookupPress: cancelLookupArtist
}; };

@ -110,7 +110,6 @@ ImportArtistRow.propTypes = {
selectedArtist: PropTypes.object, selectedArtist: PropTypes.object,
isExistingArtist: PropTypes.bool.isRequired, isExistingArtist: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
queued: PropTypes.bool.isRequired,
showLanguageProfile: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired,
showMetadataProfile: PropTypes.bool.isRequired, showMetadataProfile: PropTypes.bool.isRequired,
isSelected: PropTypes.bool, isSelected: PropTypes.bool,

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions'; import { setImportArtistValue } from 'Store/Actions/importArtistActions';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import ImportArtistRow from './ImportArtistRow'; import ImportArtistRow from './ImportArtistRow';
@ -34,7 +34,6 @@ function createMapStateToProps() {
} }
const mapDispatchToProps = { const mapDispatchToProps = {
queueLookupArtist,
setImportArtistValue setImportArtistValue
}; };
@ -82,7 +81,6 @@ ImportArtistRowConnector.propTypes = {
monitor: PropTypes.string, monitor: PropTypes.string,
albumFolder: PropTypes.bool, albumFolder: PropTypes.bool,
items: PropTypes.arrayOf(PropTypes.object), items: PropTypes.arrayOf(PropTypes.object),
queueLookupArtist: PropTypes.func.isRequired,
setImportArtistValue: PropTypes.func.isRequired setImportArtistValue: PropTypes.func.isRequired
}; };

@ -1,10 +1,12 @@
.artistNameContainer { .artistNameContainer {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 0 1 auto;
overflow: hidden;
} }
.artistName { .artistName {
margin-right: 5px; @add-mixin truncate;
} }
.disambiguation { .disambiguation {
@ -12,11 +14,6 @@
color: $disabledColor; color: $disabledColor;
} }
.year {
margin-left: 5px;
color: $disabledColor;
}
.existing { .existing {
margin-left: 5px; margin-left: 5px;
} }

@ -8,7 +8,6 @@ function ImportArtistName(props) {
const { const {
artistName, artistName,
disambiguation, disambiguation,
// year,
isExistingArtist isExistingArtist
} = props; } = props;
@ -36,7 +35,6 @@ function ImportArtistName(props) {
ImportArtistName.propTypes = { ImportArtistName.propTypes = {
artistName: PropTypes.string.isRequired, artistName: PropTypes.string.isRequired,
disambiguation: PropTypes.string, disambiguation: PropTypes.string,
// year: PropTypes.number.isRequired,
isExistingArtist: PropTypes.bool.isRequired isExistingArtist: PropTypes.bool.isRequired
}; };

@ -30,8 +30,9 @@
} }
.dropdownArrowContainer { .dropdownArrowContainer {
position: absolute; flex: 1 0 auto;
right: 16px; margin-left: 5px;
text-align: right;
} }
.contentContainer { .contentContainer {
@ -68,3 +69,13 @@
border-radius: 0; border-radius: 0;
} }
.results {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
overflow-x: hidden;
overflow-y: scroll;
max-height: 165px;
}

@ -120,7 +120,7 @@ class ImportArtistSelectArtist extends Component {
isPopulated, isPopulated,
error, error,
items, items,
queued, isQueued,
isLookingUpArtist isLookingUpArtist
} = this.props; } = this.props;
@ -142,7 +142,7 @@ class ImportArtistSelectArtist extends Component {
onPress={this.onPress} onPress={this.onPress}
> >
{ {
isLookingUpArtist && queued && !isPopulated && isLookingUpArtist && isQueued && !isPopulated &&
<LoadingIndicator <LoadingIndicator
className={styles.loading} className={styles.loading}
size={20} size={20}
@ -170,7 +170,7 @@ class ImportArtistSelectArtist extends Component {
{ {
isPopulated && !selectedArtist && isPopulated && !selectedArtist &&
<div> <div className={styles.noMatches}>
<Icon <Icon
className={styles.warningIcon} className={styles.warningIcon}
name={icons.WARNING} name={icons.WARNING}
@ -265,7 +265,7 @@ ImportArtistSelectArtist.propTypes = {
isPopulated: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object, error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
queued: PropTypes.bool.isRequired, isQueued: PropTypes.bool.isRequired,
isLookingUpArtist: PropTypes.bool.isRequired, isLookingUpArtist: PropTypes.bool.isRequired,
onSearchInputChange: PropTypes.func.isRequired, onSearchInputChange: PropTypes.func.isRequired,
onArtistSelect: PropTypes.func.isRequired onArtistSelect: PropTypes.func.isRequired
@ -275,7 +275,7 @@ ImportArtistSelectArtist.defaultProps = {
isFetching: true, isFetching: true,
isPopulated: false, isPopulated: false,
items: [], items: [],
queued: true isQueued: true
}; };
export default ImportArtistSelectArtist; export default ImportArtistSelectArtist;

@ -1,48 +0,0 @@
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 ImportArtistRootFolderRow from './ImportArtistRootFolderRow';
function createMapStateToProps() {
return createSelector(
() => {
return {
};
}
);
}
const mapDispatchToProps = {
deleteRootFolder
};
class ImportArtistRootFolderRowConnector extends Component {
//
// Listeners
onDeletePress = () => {
this.props.deleteRootFolder({ id: this.props.id });
}
//
// Render
render() {
return (
<ImportArtistRootFolderRow
{...this.props}
onDeletePress={this.onDeletePress}
/>
);
}
}
ImportArtistRootFolderRowConnector.propTypes = {
id: PropTypes.number.isRequired,
deleteRootFolder: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRootFolderRowConnector);

@ -8,33 +8,9 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import Table from 'Components/Table/Table'; import RootFolders from 'RootFolder/RootFolders';
import TableBody from 'Components/Table/TableBody';
import ImportArtistRootFolderRowConnector from './ImportArtistRootFolderRowConnector';
import styles from './ImportArtistSelectFolder.css'; import styles from './ImportArtistSelectFolder.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 ImportArtistSelectFolder extends Component { class ImportArtistSelectFolder extends Component {
// //
@ -107,26 +83,13 @@ class ImportArtistSelectFolder extends Component {
{ {
items.length > 0 ? items.length > 0 ?
<div className={styles.recentFolders}> <div className={styles.recentFolders}>
<FieldSet legend="Recent Folders"> <FieldSet legend="Root Folders">
<Table <RootFolders
columns={rootFolderColumns} isFetching={isFetching}
> isPopulated={isPopulated}
<TableBody> error={error}
{ items={items}
items.map((rootFolder) => { />
return (
<ImportArtistRootFolderRowConnector
key={rootFolder.id}
id={rootFolder.id}
path={rootFolder.path}
freeSpace={rootFolder.freeSpace}
unmappedFolders={rootFolder.unmappedFolders}
/>
);
})
}
</TableBody>
</Table>
</FieldSet> </FieldSet>
<Button <Button
@ -178,8 +141,7 @@ ImportArtistSelectFolder.propTypes = {
isPopulated: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object, error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
onNewRootFolderSelect: PropTypes.func.isRequired, onNewRootFolderSelect: PropTypes.func.isRequired
onDeleteRootFolderPress: PropTypes.func.isRequired
}; };
export default ImportArtistSelectFolder; export default ImportArtistSelectFolder;

@ -5,7 +5,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { push } from 'react-router-redux'; import { push } from 'react-router-redux';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions'; import { fetchRootFolders, addRootFolder } from 'Store/Actions/rootFolderActions';
import ImportArtistSelectFolder from './ImportArtistSelectFolder'; import ImportArtistSelectFolder from './ImportArtistSelectFolder';
function createMapStateToProps() { function createMapStateToProps() {
@ -24,7 +24,6 @@ function createMapStateToProps() {
const mapDispatchToProps = { const mapDispatchToProps = {
fetchRootFolders, fetchRootFolders,
addRootFolder, addRootFolder,
deleteRootFolder,
push push
}; };
@ -60,10 +59,6 @@ class ImportArtistSelectFolderConnector extends Component {
this.props.addRootFolder({ path }); this.props.addRootFolder({ path });
} }
onDeleteRootFolderPress = (id) => {
this.props.deleteRootFolder({ id });
}
// //
// Render // Render
@ -72,7 +67,6 @@ class ImportArtistSelectFolderConnector extends Component {
<ImportArtistSelectFolder <ImportArtistSelectFolder
{...this.props} {...this.props}
onNewRootFolderSelect={this.onNewRootFolderSelect} onNewRootFolderSelect={this.onNewRootFolderSelect}
onDeleteRootFolderPress={this.onDeleteRootFolderPress}
/> />
); );
} }
@ -84,7 +78,6 @@ ImportArtistSelectFolderConnector.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchRootFolders: PropTypes.func.isRequired, fetchRootFolders: PropTypes.func.isRequired,
addRootFolder: PropTypes.func.isRequired, addRootFolder: PropTypes.func.isRequired,
deleteRootFolder: PropTypes.func.isRequired,
push: PropTypes.func.isRequired push: PropTypes.func.isRequired
}; };

@ -4,7 +4,7 @@ import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import InteractiveSearchModal from 'InteractiveSearch/InteractiveSearchModal'; import AlbumInteractiveSearchModalConnector from './Search/AlbumInteractiveSearchModalConnector';
import styles from './AlbumSearchCell.css'; import styles from './AlbumSearchCell.css';
class AlbumSearchCell extends Component { class AlbumSearchCell extends Component {
@ -37,6 +37,7 @@ class AlbumSearchCell extends Component {
render() { render() {
const { const {
albumId, albumId,
albumTitle,
isSearching, isSearching,
onSearchPress, onSearchPress,
...otherProps ...otherProps
@ -55,9 +56,10 @@ class AlbumSearchCell extends Component {
onPress={this.onManualSearchPress} onPress={this.onManualSearchPress}
/> />
<InteractiveSearchModal <AlbumInteractiveSearchModalConnector
isOpen={this.state.isDetailsModalOpen} isOpen={this.state.isDetailsModalOpen}
albumId={albumId} albumId={albumId}
albumTitle={albumTitle}
onModalClose={this.onDetailsModalClose} onModalClose={this.onDetailsModalClose}
{...otherProps} {...otherProps}
/> />

@ -32,13 +32,6 @@
color: $white; color: $white;
} }
.logo {
flex-shrink: 0;
margin-right: 35px;
width: 250px;
height: 97px;
}
.cover { .cover {
flex-shrink: 0; flex-shrink: 0;
margin-right: 35px; margin-right: 35px;
@ -61,19 +54,33 @@
.titleContainer { .titleContainer {
display: flex; display: flex;
justify-content: space-between; margin-bottom: 5px;
} }
.title { .title {
margin-bottom: 5px;
font-weight: 300; font-weight: 300;
font-size: 50px; font-size: 50px;
line-height: 50px; line-height: 50px;
} }
.toggleMonitoredContainer {
align-self: center;
margin-right: 10px;
}
.monitorToggleButton {
composes: toggleButton from 'Components/MonitorToggleButton.css';
width: 40px;
&:hover {
color: $iconButtonHoverLightColor;
}
}
.alternateTitlesIconContainer { .alternateTitlesIconContainer {
align-self: flex-end;
margin-left: 20px; margin-left: 20px;
line-height: 50px;
} }
.albumNavigationButtons { .albumNavigationButtons {
@ -87,14 +94,19 @@
width: 30px; width: 30px;
color: #e1e2e3; color: #e1e2e3;
white-space: nowrap; white-space: nowrap;
&:hover {
color: $iconButtonHoverLightColor;
}
} }
.details { .details {
margin-bottom: 8px;
font-weight: 300; font-weight: 300;
font-size: 20px; font-size: 20px;
} }
.runtime { .duration {
margin-right: 15px; margin-right: 15px;
} }
@ -115,7 +127,9 @@
.overview { .overview {
flex: 1 0 auto; flex: 1 0 auto;
margin-top: 8px;
min-height: 0; min-height: 0;
font-size: $intermediateFontSize;
} }
.contentContainer { .contentContainer {

@ -12,6 +12,7 @@ import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import Label from 'Components/Label'; import Label from 'Components/Label';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import Tooltip from 'Components/Tooltip/Tooltip'; import Tooltip from 'Components/Tooltip/Tooltip';
import AlbumCover from 'Album/AlbumCover'; import AlbumCover from 'Album/AlbumCover';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
@ -25,7 +26,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector'; import AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector';
import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal'; import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal';
import InteractiveSearchModal from 'InteractiveSearch/InteractiveSearchModal'; import AlbumInteractiveSearchModalConnector from 'Album/Search/AlbumInteractiveSearchModalConnector';
import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal'; import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
import AlbumDetailsLinks from './AlbumDetailsLinks'; import AlbumDetailsLinks from './AlbumDetailsLinks';
import styles from './AlbumDetails.css'; import styles from './AlbumDetails.css';
@ -41,6 +42,28 @@ function getFanartUrl(images) {
} }
} }
function formatDuration(timeSpan) {
const duration = moment.duration(timeSpan);
const hours = duration.get('hours');
const minutes = duration.get('minutes');
let hoursText = 'Hours';
let minText = 'Minutes';
if (minutes === 1) {
minText = 'Minute';
}
if (hours === 0) {
return `${minutes} ${minText}`;
}
if (hours === 1) {
hoursText = 'Hour';
}
return `${hours} ${hoursText} ${minutes} ${minText}`;
}
function getExpandedState(newState) { function getExpandedState(newState) {
return { return {
allExpanded: newState.allSelected, allExpanded: newState.allSelected,
@ -144,6 +167,7 @@ class AlbumDetails extends Component {
foreignAlbumId, foreignAlbumId,
title, title,
disambiguation, disambiguation,
duration,
overview, overview,
albumType, albumType,
statistics = {}, statistics = {},
@ -153,6 +177,7 @@ class AlbumDetails extends Component {
images, images,
links, links,
media, media,
isSaving,
isFetching, isFetching,
isPopulated, isPopulated,
albumsError, albumsError,
@ -162,6 +187,7 @@ class AlbumDetails extends Component {
previousAlbum, previousAlbum,
nextAlbum, nextAlbum,
isSearching, isSearching,
onMonitorTogglePress,
onSearchPress onSearchPress
} = this.props; } = this.props;
@ -259,9 +285,22 @@ class AlbumDetails extends Component {
/> />
<div className={styles.info}> <div className={styles.info}>
<div className={styles.titleContainer}> <div className={styles.titleRow}>
<div className={styles.title}> <div className={styles.titleContainer}>
{title}{disambiguation ? ` (${disambiguation})` : ''}
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
size={40}
onPress={onMonitorTogglePress}
/>
</div>
<div className={styles.title}>
{title}{disambiguation ? ` (${disambiguation})` : ''}
</div>
</div> </div>
<div className={styles.albumNavigationButtons}> <div className={styles.albumNavigationButtons}>
@ -293,6 +332,13 @@ class AlbumDetails extends Component {
<div className={styles.details}> <div className={styles.details}>
<div> <div>
{
!!duration &&
<span className={styles.duration}>
{formatDuration(duration)}
</span>
}
<HeartRating <HeartRating
rating={ratings.value} rating={ratings.value}
iconSize={20} iconSize={20}
@ -456,9 +502,10 @@ class AlbumDetails extends Component {
onModalClose={this.onManageTracksModalClose} onModalClose={this.onManageTracksModalClose}
/> />
<InteractiveSearchModal <AlbumInteractiveSearchModalConnector
isOpen={isInteractiveSearchModalOpen} isOpen={isInteractiveSearchModalOpen}
albumId={id} albumId={id}
albumTitle={title}
onModalClose={this.onInteractiveSearchModalClose} onModalClose={this.onInteractiveSearchModalClose}
/> />
@ -487,6 +534,7 @@ AlbumDetails.propTypes = {
foreignAlbumId: PropTypes.string.isRequired, foreignAlbumId: PropTypes.string.isRequired,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
disambiguation: PropTypes.string, disambiguation: PropTypes.string,
duration: PropTypes.number,
overview: PropTypes.string, overview: PropTypes.string,
albumType: PropTypes.string.isRequired, albumType: PropTypes.string.isRequired,
statistics: PropTypes.object.isRequired, statistics: PropTypes.object.isRequired,
@ -497,6 +545,7 @@ AlbumDetails.propTypes = {
media: PropTypes.arrayOf(PropTypes.object).isRequired, media: PropTypes.arrayOf(PropTypes.object).isRequired,
monitored: PropTypes.bool.isRequired, monitored: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired, shortDateFormat: PropTypes.string.isRequired,
isSaving: PropTypes.bool.isRequired,
isSearching: PropTypes.bool, isSearching: PropTypes.bool,
isFetching: PropTypes.bool, isFetching: PropTypes.bool,
isPopulated: PropTypes.bool, isPopulated: PropTypes.bool,
@ -506,6 +555,7 @@ AlbumDetails.propTypes = {
artist: PropTypes.object, artist: PropTypes.object,
previousAlbum: PropTypes.object, previousAlbum: PropTypes.object,
nextAlbum: PropTypes.object, nextAlbum: PropTypes.object,
onMonitorTogglePress: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func, onRefreshPress: PropTypes.func,
onSearchPress: PropTypes.func.isRequired onSearchPress: PropTypes.func.isRequired
}; };

@ -7,6 +7,7 @@ import { createSelector } from 'reselect';
import { findCommand } from 'Utilities/Command'; import { findCommand } from 'Utilities/Command';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import { toggleAlbumsMonitored } from 'Store/Actions/albumActions';
import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions'; import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
@ -64,7 +65,8 @@ const mapDispatchToProps = {
fetchTracks, fetchTracks,
clearTracks, clearTracks,
fetchTrackFiles, fetchTrackFiles,
clearTrackFiles clearTrackFiles,
toggleAlbumsMonitored
}; };
function getMonitoredReleases(props) { function getMonitoredReleases(props) {
@ -109,6 +111,13 @@ class AlbumDetailsConnector extends Component {
// //
// Listeners // Listeners
onMonitorTogglePress = (monitored) => {
this.props.toggleAlbumsMonitored({
albumIds: [this.props.id],
monitored
});
}
onSearchPress = () => { onSearchPress = () => {
this.props.executeCommand({ this.props.executeCommand({
name: commandNames.ALBUM_SEARCH, name: commandNames.ALBUM_SEARCH,
@ -123,6 +132,7 @@ class AlbumDetailsConnector extends Component {
return ( return (
<AlbumDetails <AlbumDetails
{...this.props} {...this.props}
onMonitorTogglePress={this.onMonitorTogglePress}
onSearchPress={this.onSearchPress} onSearchPress={this.onSearchPress}
/> />
); );
@ -138,6 +148,7 @@ AlbumDetailsConnector.propTypes = {
clearTracks: PropTypes.func.isRequired, clearTracks: PropTypes.func.isRequired,
fetchTrackFiles: PropTypes.func.isRequired, fetchTrackFiles: PropTypes.func.isRequired,
clearTrackFiles: PropTypes.func.isRequired, clearTrackFiles: PropTypes.func.isRequired,
toggleAlbumsMonitored: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired executeCommand: PropTypes.func.isRequired
}; };

@ -0,0 +1,36 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AlbumInteractiveSearchModalContent from './AlbumInteractiveSearchModalContent';
function AlbumInteractiveSearchModal(props) {
const {
isOpen,
albumId,
albumTitle,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<AlbumInteractiveSearchModalContent
albumId={albumId}
albumTitle={albumTitle}
onModalClose={onModalClose}
/>
</Modal>
);
}
AlbumInteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
albumId: PropTypes.number.isRequired,
albumTitle: PropTypes.string.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AlbumInteractiveSearchModal;

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import AlbumInteractiveSearchModal from './AlbumInteractiveSearchModal';
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
dispatch(cancelFetchReleases());
dispatch(clearReleases());
props.onModalClose();
}
};
}
export default connect(null, createMapDispatchToProps)(AlbumInteractiveSearchModal);

@ -0,0 +1,47 @@
import PropTypes from 'prop-types';
import React from 'react';
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 InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
function AlbumInteractiveSearchModalContent(props) {
const {
albumId,
albumTitle,
onModalClose
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Interactive Search {albumId != null && `- ${albumTitle}`}
</ModalHeader>
<ModalBody>
<InteractiveSearchConnector
type="album"
searchPayload={{
albumId
}}
/>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
AlbumInteractiveSearchModalContent.propTypes = {
albumId: PropTypes.number.isRequired,
albumTitle: PropTypes.string.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AlbumInteractiveSearchModalContent;

@ -3,7 +3,7 @@ import React from 'react';
import Label from 'Components/Label'; import Label from 'Components/Label';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
function EpisodeLanguage(props) { function TrackLanguage(props) {
const { const {
className, className,
language, language,
@ -24,14 +24,14 @@ function EpisodeLanguage(props) {
); );
} }
EpisodeLanguage.propTypes = { TrackLanguage.propTypes = {
className: PropTypes.string, className: PropTypes.string,
language: PropTypes.object, language: PropTypes.object,
isCutoffNotMet: PropTypes.bool isCutoffNotMet: PropTypes.bool
}; };
EpisodeLanguage.defaultProps = { TrackLanguage.defaultProps = {
isCutoffNotMet: true isCutoffNotMet: true
}; };
export default EpisodeLanguage; export default TrackLanguage;

@ -12,6 +12,7 @@ function AppUpdatedModal(props) {
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose} onModalClose={onModalClose}
> >
<AppUpdatedModalContentConnector <AppUpdatedModalContentConnector

@ -0,0 +1,6 @@
import React from 'react';
const ColorImpairedContext = React.createContext(false);
export const ColorImpairedConsumer = ColorImpairedContext.Consumer;
export default ColorImpairedContext;

@ -1,172 +1,25 @@
import _ from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React from 'react';
import LazyLoad from 'react-lazyload'; import ArtistImage from './ArtistImage';
const bannerPlaceholder = 'data:image/png;base64,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'; const bannerPlaceholder = 'data:image/png;base64,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';
function findBanner(images) { function ArtistBanner(props) {
return _.find(images, { coverType: 'banner' }); return (
} <ArtistImage
{...props}
function getBannerUrl(banner, size) { coverType="banner"
if (banner) { placeholder={bannerPlaceholder}
if (banner.url.contains('lastWrite=') || (/^https?:/).test(banner.url)) { />
// Remove protocol );
let url = banner.url.replace(/^https?:/, '');
url = url.replace('banner.jpg', `banner-${size}.jpg`);
return url;
}
}
}
class ArtistBanner extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const pixelRatio = Math.floor(window.devicePixelRatio);
const {
images,
size
} = props;
const banner = findBanner(images);
this.state = {
pixelRatio,
banner,
bannerUrl: getBannerUrl(banner, pixelRatio * size),
isLoaded: false,
hasError: false
};
}
componentDidUpdate(prevProps) {
const {
images,
size
} = this.props;
const {
banner,
pixelRatio
} = this.state;
const nextBanner = findBanner(images);
if (nextBanner && (!banner || nextBanner.url !== banner.url)) {
this.setState({
banner: nextBanner,
bannerUrl: getBannerUrl(nextBanner, pixelRatio * size),
hasError: false,
isLoaded: true
});
} else if (!nextBanner && banner) {
this.setState({
banner: nextBanner,
bannerUrl: bannerPlaceholder,
hasError: false
});
}
}
//
// Listeners
onError = () => {
this.setState({ hasError: true });
}
onLoad = () => {
this.setState({
isLoaded: true,
hasError: false
});
}
//
// Render
render() {
const {
className,
style,
size,
lazy,
overflow
} = this.props;
const {
bannerUrl,
hasError,
isLoaded
} = this.state;
if (hasError || !bannerUrl) {
return (
<img
className={className}
style={style}
src={bannerPlaceholder}
/>
);
}
if (lazy) {
return (
<LazyLoad
height={size}
offset={100}
overflow={overflow}
placeholder={
<img
className={className}
style={style}
src={bannerPlaceholder}
/>
}
>
<img
className={className}
style={style}
src={bannerUrl}
onError={this.onError}
/>
</LazyLoad>
);
}
return (
<img
className={className}
style={style}
src={isLoaded ? bannerUrl : bannerPlaceholder}
onError={this.onError}
onLoad={this.onLoad}
/>
);
}
} }
ArtistBanner.propTypes = { ArtistBanner.propTypes = {
className: PropTypes.string, size: PropTypes.number.isRequired
style: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
size: PropTypes.number.isRequired,
lazy: PropTypes.bool.isRequired,
overflow: PropTypes.bool.isRequired
}; };
ArtistBanner.defaultProps = { ArtistBanner.defaultProps = {
size: 70, size: 70
lazy: true,
overflow: false
}; };
export default ArtistBanner; export default ArtistBanner;

@ -0,0 +1,199 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LazyLoad from 'react-lazyload';
function findImage(images, coverType) {
return images.find((image) => image.coverType === coverType);
}
function getUrl(image, coverType, size) {
if (image) {
// Remove protocol
let url = image.url.replace(/^https?:/, '');
url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`);
return url;
}
}
class ArtistImage extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const pixelRatio = Math.floor(window.devicePixelRatio);
const {
images,
coverType,
size
} = props;
const image = findImage(images, coverType);
this.state = {
pixelRatio,
image,
url: getUrl(image, coverType, pixelRatio * size),
isLoaded: false,
hasError: false
};
}
componentDidMount() {
if (!this.state.url && this.props.onError) {
this.props.onError();
}
}
componentDidUpdate() {
const {
images,
coverType,
placeholder,
size,
onError
} = this.props;
const {
image,
pixelRatio
} = this.state;
const nextImage = findImage(images, coverType);
if (nextImage && (!image || nextImage.url !== image.url)) {
this.setState({
image: nextImage,
url: getUrl(nextImage, coverType, pixelRatio * size),
hasError: false
// Don't reset isLoaded, as we want to immediately try to
// show the new image, whether an image was shown previously
// or the placeholder was shown.
});
} else if (!nextImage && image) {
this.setState({
image: nextImage,
url: placeholder,
hasError: false
});
if (onError) {
onError();
}
}
}
//
// Listeners
onError = () => {
this.setState({
hasError: true
});
if (this.props.onError) {
this.props.onError();
}
}
onLoad = () => {
this.setState({
isLoaded: true,
hasError: false
});
if (this.props.onLoad) {
this.props.onLoad();
}
}
//
// Render
render() {
const {
className,
style,
placeholder,
size,
lazy,
overflow
} = this.props;
const {
url,
hasError,
isLoaded
} = this.state;
if (hasError || !url) {
return (
<img
className={className}
style={style}
src={placeholder}
/>
);
}
if (lazy) {
return (
<LazyLoad
height={size}
offset={100}
overflow={overflow}
placeholder={
<img
className={className}
style={style}
src={placeholder}
/>
}
>
<img
className={className}
style={style}
src={url}
onError={this.onError}
onLoad={this.onLoad}
/>
</LazyLoad>
);
}
return (
<img
className={className}
style={style}
src={isLoaded ? url : placeholder}
onError={this.onError}
onLoad={this.onLoad}
/>
);
}
}
ArtistImage.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
coverType: PropTypes.string.isRequired,
placeholder: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
lazy: PropTypes.bool.isRequired,
overflow: PropTypes.bool.isRequired,
onError: PropTypes.func,
onLoad: PropTypes.func
};
ArtistImage.defaultProps = {
size: 250,
lazy: true,
overflow: false
};
export default ArtistImage;

@ -1,172 +1,25 @@
import _ from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React from 'react';
import LazyLoad from 'react-lazyload'; import ArtistImage from './ArtistImage';
const posterPlaceholder = 'data:image/png;base64,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'; const posterPlaceholder = 'data:image/png;base64,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';
function findPoster(images) { function ArtistPoster(props) {
return _.find(images, { coverType: 'poster' }); return (
} <ArtistImage
{...props}
function getPosterUrl(poster, size) { coverType="poster"
if (poster) { placeholder={posterPlaceholder}
if (poster.url.contains('lastWrite=') || (/^https?:/).test(poster.url)) { />
// Remove protocol );
let url = poster.url.replace(/^https?:/, '');
url = url.replace('poster.jpg', `poster-${size}.jpg`);
return url;
}
}
}
class ArtistPoster extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const pixelRatio = Math.floor(window.devicePixelRatio);
const {
images,
size
} = props;
const poster = findPoster(images);
this.state = {
pixelRatio,
poster,
posterUrl: getPosterUrl(poster, pixelRatio * size),
isLoaded: false,
hasError: false
};
}
componentDidUpdate(prevProps) {
const {
images,
size
} = this.props;
const {
poster,
pixelRatio
} = this.state;
const nextPoster = findPoster(images);
if (nextPoster && (!poster || nextPoster.url !== poster.url)) {
this.setState({
poster: nextPoster,
posterUrl: getPosterUrl(nextPoster, pixelRatio * size),
hasError: false,
isLoaded: true
});
} else if (!nextPoster && poster) {
this.setState({
poster: nextPoster,
posterUrl: posterPlaceholder,
hasError: false
});
}
}
//
// Listeners
onError = () => {
this.setState({ hasError: true });
}
onLoad = () => {
this.setState({
isLoaded: true,
hasError: false
});
}
//
// Render
render() {
const {
className,
style,
size,
lazy,
overflow
} = this.props;
const {
posterUrl,
hasError,
isLoaded
} = this.state;
if (hasError || !posterUrl) {
return (
<img
className={className}
style={style}
src={posterPlaceholder}
/>
);
}
if (lazy) {
return (
<LazyLoad
height={size}
offset={100}
overflow={overflow}
placeholder={
<img
className={className}
style={style}
src={posterPlaceholder}
/>
}
>
<img
className={className}
style={style}
src={posterUrl}
onError={this.onError}
/>
</LazyLoad>
);
}
return (
<img
className={className}
style={style}
src={isLoaded ? posterUrl : posterPlaceholder}
onError={this.onError}
onLoad={this.onLoad}
/>
);
}
} }
ArtistPoster.propTypes = { ArtistPoster.propTypes = {
className: PropTypes.string, size: PropTypes.number.isRequired
style: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
size: PropTypes.number.isRequired,
lazy: PropTypes.bool.isRequired,
overflow: PropTypes.bool.isRequired
}; };
ArtistPoster.defaultProps = { ArtistPoster.defaultProps = {
size: 250, size: 250
lazy: true,
overflow: false
}; };
export default ArtistPoster; export default ArtistPoster;

@ -108,6 +108,7 @@
} }
.details { .details {
margin-bottom: 8px;
font-weight: 300; font-weight: 300;
font-size: 20px; font-size: 20px;
} }
@ -132,15 +133,11 @@
font-size: 17px; font-size: 17px;
} }
.path {
vertical-align: text-top;
font-size: $defaultFontSize;
font-family: $monoSpaceFontFamily;
}
.overview { .overview {
flex: 1 0 auto; flex: 1 0 auto;
margin-top: 8px;
min-height: 0; min-height: 0;
font-size: $intermediateFontSize;
} }
.contentContainer { .contentContainer {

@ -11,7 +11,6 @@ import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import Label from 'Components/Label'; import Label from 'Components/Label';
import Measure from 'Components/Measure';
import MonitorToggleButton from 'Components/MonitorToggleButton'; import MonitorToggleButton from 'Components/MonitorToggleButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
@ -35,6 +34,7 @@ import ArtistTagsConnector from './ArtistTagsConnector';
import ArtistDetailsLinks from './ArtistDetailsLinks'; import ArtistDetailsLinks from './ArtistDetailsLinks';
import styles from './ArtistDetails.css'; import styles from './ArtistDetails.css';
import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
import ArtistInteractiveSearchModalConnector from 'Artist/Search/ArtistInteractiveSearchModalConnector';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
const defaultFontSize = parseInt(fonts.defaultFontSize); const defaultFontSize = parseInt(fonts.defaultFontSize);
@ -71,6 +71,7 @@ class ArtistDetails extends Component {
isDeleteArtistModalOpen: false, isDeleteArtistModalOpen: false,
isArtistHistoryModalOpen: false, isArtistHistoryModalOpen: false,
isInteractiveImportModalOpen: false, isInteractiveImportModalOpen: false,
isInteractiveSearchModalOpen: false,
allExpanded: false, allExpanded: false,
allCollapsed: false, allCollapsed: false,
expandedState: {} expandedState: {}
@ -104,6 +105,14 @@ class ArtistDetails extends Component {
this.setState({ isInteractiveImportModalOpen: false }); this.setState({ isInteractiveImportModalOpen: false });
} }
onInteractiveSearchPress = () => {
this.setState({ isInteractiveSearchModalOpen: true });
}
onInteractiveSearchModalClose = () => {
this.setState({ isInteractiveSearchModalOpen: false });
}
onEditArtistPress = () => { onEditArtistPress = () => {
this.setState({ isEditArtistModalOpen: true }); this.setState({ isEditArtistModalOpen: true });
} }
@ -181,7 +190,9 @@ class ArtistDetails extends Component {
isPopulated, isPopulated,
albumsError, albumsError,
trackFilesError, trackFilesError,
hasAlbums,
hasMonitoredAlbums, hasMonitoredAlbums,
hasTrackFiles,
previousArtist, previousArtist,
nextArtist, nextArtist,
onMonitorTogglePress, onMonitorTogglePress,
@ -201,6 +212,7 @@ class ArtistDetails extends Component {
isDeleteArtistModalOpen, isDeleteArtistModalOpen,
isArtistHistoryModalOpen, isArtistHistoryModalOpen,
isInteractiveImportModalOpen, isInteractiveImportModalOpen,
isInteractiveSearchModalOpen,
allExpanded, allExpanded,
allCollapsed, allCollapsed,
expandedState expandedState
@ -240,29 +252,41 @@ class ArtistDetails extends Component {
<PageToolbarButton <PageToolbarButton
label="Search Monitored" label="Search Monitored"
iconName={icons.SEARCH} iconName={icons.SEARCH}
isDisabled={!monitored || !hasMonitoredAlbums} isDisabled={!monitored || !hasMonitoredAlbums || !hasAlbums}
isSpinning={isSearching} isSpinning={isSearching}
title={hasMonitoredAlbums ? undefined : 'No monitored albums for this artist'} title={hasMonitoredAlbums ? undefined : 'No monitored albums for this artist'}
onPress={onSearchPress} onPress={onSearchPress}
/> />
<PageToolbarButton
label="Interactive Search"
iconName={icons.INTERACTIVE}
isDisabled={!monitored || !hasMonitoredAlbums || !hasAlbums}
isSpinning={isSearching}
title={hasMonitoredAlbums ? undefined : 'No monitored albums for this artist'}
onPress={this.onInteractiveSearchPress}
/>
<PageToolbarSeparator /> <PageToolbarSeparator />
<PageToolbarButton <PageToolbarButton
label="Preview Rename" label="Preview Rename"
iconName={icons.ORGANIZE} iconName={icons.ORGANIZE}
isDisabled={!hasTrackFiles}
onPress={this.onOrganizePress} onPress={this.onOrganizePress}
/> />
<PageToolbarButton <PageToolbarButton
label="Manage Tracks" label="Manage Tracks"
iconName={icons.TRACK_FILE} iconName={icons.TRACK_FILE}
isDisabled={!hasTrackFiles}
onPress={this.onManageTracksPress} onPress={this.onManageTracksPress}
/> />
<PageToolbarButton <PageToolbarButton
label="History" label="History"
iconName={icons.HISTORY} iconName={icons.HISTORY}
isDisabled={!hasAlbums}
onPress={this.onArtistHistoryPress} onPress={this.onArtistHistoryPress}
/> />
@ -609,6 +633,12 @@ class ArtistDetails extends Component {
showImportMode={false} showImportMode={false}
onModalClose={this.onInteractiveImportModalClose} onModalClose={this.onInteractiveImportModalClose}
/> />
<ArtistInteractiveSearchModalConnector
isOpen={isInteractiveSearchModalOpen}
artistId={id}
onModalClose={this.onInteractiveSearchModalClose}
/>
</PageContentBodyConnector> </PageContentBodyConnector>
</PageContent> </PageContent>
); );
@ -638,7 +668,9 @@ ArtistDetails.propTypes = {
isPopulated: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired,
albumsError: PropTypes.object, albumsError: PropTypes.object,
trackFilesError: PropTypes.object, trackFilesError: PropTypes.object,
hasAlbums: PropTypes.bool.isRequired,
hasMonitoredAlbums: PropTypes.bool.isRequired, hasMonitoredAlbums: PropTypes.bool.isRequired,
hasTrackFiles: PropTypes.bool.isRequired,
previousArtist: PropTypes.object.isRequired, previousArtist: PropTypes.object.isRequired,
nextArtist: PropTypes.object.isRequired, nextArtist: PropTypes.object.isRequired,
onMonitorTogglePress: PropTypes.func.isRequired, onMonitorTogglePress: PropTypes.func.isRequired,

@ -16,11 +16,55 @@ import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import ArtistDetails from './ArtistDetails'; import ArtistDetails from './ArtistDetails';
const selectAlbums = createSelector(
(state) => state.albums,
(albums) => {
const {
items,
isFetching,
isPopulated,
error
} = albums;
const hasAlbums = !!items.length;
const hasMonitoredAlbums = items.some((e) => e.monitored);
return {
isAlbumsFetching: isFetching,
isAlbumsPopulated: isPopulated,
albumsError: error,
hasAlbums,
hasMonitoredAlbums
};
}
);
const selectTrackFiles = createSelector(
(state) => state.trackFiles,
(trackFiles) => {
const {
items,
isFetching,
isPopulated,
error
} = trackFiles;
const hasTrackFiles = !!items.length;
return {
isTrackFilesFetching: isFetching,
isTrackFilesPopulated: isPopulated,
trackFilesError: error,
hasTrackFiles
};
}
);
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state, { foreignArtistId }) => foreignArtistId, (state, { foreignArtistId }) => foreignArtistId,
(state) => state.albums, selectAlbums,
(state) => state.trackFiles, selectTrackFiles,
(state) => state.settings.metadataProfiles, (state) => state.settings.metadataProfiles,
createAllArtistSelector(), createAllArtistSelector(),
createCommandsSelector(), createCommandsSelector(),
@ -40,6 +84,21 @@ function createMapStateToProps() {
return {}; return {};
} }
const {
isAlbumsFetching,
isAlbumsPopulated,
albumsError,
hasAlbums,
hasMonitoredAlbums
} = albums;
const {
isTrackFilesFetching,
isTrackFilesPopulated,
trackFilesError,
hasTrackFiles
} = trackFiles;
const sortedAlbumTypes = _.orderBy(albumTypes); const sortedAlbumTypes = _.orderBy(albumTypes);
const previousArtist = sortedArtist[artistIndex - 1] || _.last(sortedArtist); const previousArtist = sortedArtist[artistIndex - 1] || _.last(sortedArtist);
@ -60,10 +119,9 @@ function createMapStateToProps() {
isRenamingArtistCommand.body.artistIds.indexOf(artist.id) > -1 isRenamingArtistCommand.body.artistIds.indexOf(artist.id) > -1
); );
const isFetching = albums.isFetching || trackFiles.isFetching; const isFetching = isAlbumsFetching || isTrackFilesFetching;
const isPopulated = albums.isPopulated && trackFiles.isPopulated; const isPopulated = isAlbumsPopulated && isTrackFilesPopulated;
const albumsError = albums.error;
const trackFilesError = trackFiles.error;
const alternateTitles = _.reduce(artist.alternateTitles, (acc, alternateTitle) => { const alternateTitles = _.reduce(artist.alternateTitles, (acc, alternateTitle) => {
if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) && if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) &&
(alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) { (alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) {
@ -73,8 +131,6 @@ function createMapStateToProps() {
return acc; return acc;
}, []); }, []);
const hasMonitoredAlbums = albums.items.some((e) => e.monitored);
return { return {
...artist, ...artist,
albumTypes: sortedAlbumTypes, albumTypes: sortedAlbumTypes,
@ -89,7 +145,9 @@ function createMapStateToProps() {
isPopulated, isPopulated,
albumsError, albumsError,
trackFilesError, trackFilesError,
hasAlbums,
hasMonitoredAlbums, hasMonitoredAlbums,
hasTrackFiles,
previousArtist, previousArtist,
nextArtist nextArtist
}; };

@ -62,7 +62,7 @@
composes: menuContent from 'Components/Menu/MenuContent.css'; composes: menuContent from 'Components/Menu/MenuContent.css';
white-space: nowrap; white-space: nowrap;
font-size: 14px; font-size: $defaultFontSize;
} }
.actionMenuIcon { .actionMenuIcon {

@ -34,8 +34,13 @@ class ArtistDetailsSeason extends Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.artistId !== this.props.artistId) { const {
artistId
} = this.props;
if (prevProps.artistId !== artistId) {
this._expandByDefault(); this._expandByDefault();
return;
} }
} }
@ -51,7 +56,7 @@ class ArtistDetailsSeason extends Component {
const expand = _.some(items, (item) => { const expand = _.some(items, (item) => {
return isAfter(item.releaseDate) || return isAfter(item.releaseDate) ||
isAfter(item.releaseDate, { days: -30 }); isAfter(item.releaseDate, { days: -365 });
}); });
onExpandPress(name, expand); onExpandPress(name, expand);
@ -113,7 +118,6 @@ class ArtistDetailsSeason extends Component {
items, items,
columns, columns,
isExpanded, isExpanded,
artistMonitored,
sortKey, sortKey,
sortDirection, sortDirection,
onSortPress, onSortPress,
@ -235,7 +239,6 @@ ArtistDetailsSeason.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isExpanded: PropTypes.bool, isExpanded: PropTypes.bool,
artistMonitored: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired, onTableOptionChange: PropTypes.func.isRequired,
onExpandPress: PropTypes.func.isRequired, onExpandPress: PropTypes.func.isRequired,

@ -4,14 +4,12 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createArtistSelector from 'Store/Selectors/createArtistSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { toggleAlbumsMonitored, setAlbumsTableOption, setAlbumsSort } from 'Store/Actions/albumActions'; import { toggleAlbumsMonitored, setAlbumsTableOption, setAlbumsSort } from 'Store/Actions/albumActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import ArtistDetailsSeason from './ArtistDetailsSeason'; import ArtistDetailsSeason from './ArtistDetailsSeason';
function createMapStateToProps() { function createMapStateToProps() {

@ -85,9 +85,7 @@ class EditArtistModalContent extends Component {
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<Form <Form {...otherProps}>
{...otherProps}
>
<FormGroup> <FormGroup>
<FormLabel>Monitored</FormLabel> <FormLabel>Monitored</FormLabel>

@ -8,7 +8,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Popover from 'Components/Tooltip/Popover'; import Popover from 'Components/Tooltip/Popover';
import EpisodeLanguage from 'Album/EpisodeLanguage'; import TrackLanguage from 'Album/TrackLanguage';
import TrackQuality from 'Album/TrackQuality'; import TrackQuality from 'Album/TrackQuality';
import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
@ -68,8 +68,6 @@ class ArtistHistoryRow extends Component {
qualityCutoffNotMet, qualityCutoffNotMet,
date, date,
data, data,
fullArtist,
artist,
album album
} = this.props; } = this.props;
@ -93,7 +91,7 @@ class ArtistHistoryRow extends Component {
</TableRowCell> </TableRowCell>
<TableRowCell> <TableRowCell>
<EpisodeLanguage <TrackLanguage
language={language} language={language}
isCutoffNotMet={languageCutoffNotMet} isCutoffNotMet={languageCutoffNotMet}
/> />

@ -7,12 +7,14 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent'; import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageJumpBar from 'Components/Page/PageJumpBar'; import PageJumpBar from 'Components/Page/PageJumpBar';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import NoArtist from 'Artist/NoArtist'; import NoArtist from 'Artist/NoArtist';
import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector'; import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector';
import ArtistIndexTableOptionsConnector from './Table/ArtistIndexTableOptionsConnector';
import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector'; import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector';
import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal'; import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal';
@ -187,6 +189,7 @@ class ArtistIndex extends Component {
error, error,
totalItems, totalItems,
items, items,
columns,
selectedFilterKey, selectedFilterKey,
filters, filters,
customFilters, customFilters,
@ -245,35 +248,52 @@ class ArtistIndex extends Component {
alignContent={align.RIGHT} alignContent={align.RIGHT}
collapseButtons={false} collapseButtons={false}
> >
{
view === 'table' ?
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
optionsComponent={ArtistIndexTableOptionsConnector}
>
<PageToolbarButton
label="Options"
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper> :
null
}
{ {
view === 'posters' && view === 'posters' ?
<PageToolbarButton <PageToolbarButton
label="Options" label="Options"
iconName={icons.POSTER} iconName={icons.POSTER}
isDisabled={hasNoArtist} isDisabled={hasNoArtist}
onPress={this.onPosterOptionsPress} onPress={this.onPosterOptionsPress}
/> /> :
null
} }
{ {
view === 'banners' && view === 'banners' ?
<PageToolbarButton <PageToolbarButton
label="Options" label="Options"
iconName={icons.POSTER} iconName={icons.POSTER}
isDisabled={hasNoArtist} isDisabled={hasNoArtist}
onPress={this.onBannerOptionsPress} onPress={this.onBannerOptionsPress}
/> /> :
null
} }
{ {
view === 'overview' && view === 'overview' ?
<PageToolbarButton <PageToolbarButton
label="Options" label="Options"
iconName={icons.OVERVIEW} iconName={icons.OVERVIEW}
isDisabled={hasNoArtist} isDisabled={hasNoArtist}
onPress={this.onOverviewOptionsPress} onPress={this.onOverviewOptionsPress}
/> /> :
null
} }
{ {
@ -382,6 +402,7 @@ ArtistIndex.propTypes = {
error: PropTypes.object, error: PropTypes.object,
totalItems: PropTypes.number.isRequired, totalItems: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,

@ -9,7 +9,7 @@ import createCommandExecutingSelector from 'Store/Selectors/createCommandExecuti
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { fetchArtist } from 'Store/Actions/artistActions'; import { fetchArtist } from 'Store/Actions/artistActions';
import scrollPositions from 'Store/scrollPositions'; import scrollPositions from 'Store/scrollPositions';
import { setArtistSort, setArtistFilter, setArtistView } from 'Store/Actions/artistIndexActions'; import { setArtistSort, setArtistFilter, setArtistView, setArtistTableOption } from 'Store/Actions/artistIndexActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import withScrollPosition from 'Components/withScrollPosition'; import withScrollPosition from 'Components/withScrollPosition';
@ -66,13 +66,41 @@ function createMapStateToProps() {
); );
} }
const mapDispatchToProps = { function createMapDispatchToProps(dispatch, props) {
fetchArtist, return {
setArtistSort, dispatchFetchArtist() {
setArtistFilter, dispatch(fetchArtist);
setArtistView, },
executeCommand
}; onTableOptionChange(payload) {
dispatch(setArtistTableOption(payload));
},
onSortSelect(sortKey) {
dispatch(setArtistSort({ sortKey }));
},
onFilterSelect(selectedFilterKey) {
dispatch(setArtistFilter({ selectedFilterKey }));
},
dispatchSetArtistView(view) {
dispatch(setArtistView({ view }));
},
onRefreshArtistPress() {
dispatch(executeCommand({
name: commandNames.REFRESH_ARTIST
}));
},
onRssSyncPress() {
dispatch(executeCommand({
name: commandNames.RSS_SYNC
}));
}
};
}
class ArtistIndexConnector extends Component { class ArtistIndexConnector extends Component {
@ -94,24 +122,16 @@ class ArtistIndexConnector extends Component {
} }
componentDidMount() { componentDidMount() {
this.props.fetchArtist(); this.props.dispatchFetchArtist();
} }
// //
// Listeners // Listeners
onSortSelect = (sortKey) => {
this.props.setArtistSort({ sortKey });
}
onFilterSelect = (selectedFilterKey) => {
this.props.setArtistFilter({ selectedFilterKey });
}
onViewSelect = (view) => { onViewSelect = (view) => {
// Reset the scroll position before changing the view // Reset the scroll position before changing the view
this.setState({ scrollTop: 0 }, () => { this.setState({ scrollTop: 0 }, () => {
this.props.setArtistView({ view }); this.props.dispatchSetArtistView(view);
}); });
} }
@ -123,18 +143,6 @@ class ArtistIndexConnector extends Component {
}); });
} }
onRefreshArtistPress = () => {
this.props.executeCommand({
name: commandNames.REFRESH_ARTIST
});
}
onRssSyncPress = () => {
this.props.executeCommand({
name: commandNames.RSS_SYNC
});
}
// //
// Render // Render
@ -143,12 +151,8 @@ class ArtistIndexConnector extends Component {
<ArtistIndex <ArtistIndex
{...this.props} {...this.props}
scrollTop={this.state.scrollTop} scrollTop={this.state.scrollTop}
onSortSelect={this.onSortSelect}
onFilterSelect={this.onFilterSelect}
onViewSelect={this.onViewSelect} onViewSelect={this.onViewSelect}
onScroll={this.onScroll} onScroll={this.onScroll}
onRefreshArtistPress={this.onRefreshArtistPress}
onRssSyncPress={this.onRssSyncPress}
/> />
); );
} }
@ -158,14 +162,10 @@ ArtistIndexConnector.propTypes = {
isSmallScreen: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired,
view: PropTypes.string.isRequired, view: PropTypes.string.isRequired,
scrollTop: PropTypes.number.isRequired, scrollTop: PropTypes.number.isRequired,
fetchArtist: PropTypes.func.isRequired, dispatchFetchArtist: PropTypes.func.isRequired
setArtistSort: PropTypes.func.isRequired,
setArtistFilter: PropTypes.func.isRequired,
setArtistView: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
}; };
export default withScrollPosition( export default withScrollPosition(
connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexConnector), connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexConnector),
'artistIndex' 'artistIndex'
); );

@ -34,12 +34,20 @@
composes: legendItemColor; composes: legendItemColor;
background-color: $dangerColor; background-color: $dangerColor;
&:global(.colorImpaired) {
background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px);
}
} }
.missingUnmonitored { .missingUnmonitored {
composes: legendItemColor; composes: legendItemColor;
background-color: $warningColor; background-color: $warningColor;
&:global(.colorImpaired) {
background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px);
}
} }
.statistics { .statistics {

@ -1,6 +1,8 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import styles from './ArtistIndexFooter.css'; import styles from './ArtistIndexFooter.css';
@ -40,79 +42,105 @@ function ArtistIndexFooter({ artist }) {
}); });
return ( return (
<div className={styles.footer}> <ColorImpairedConsumer>
<div> {(enableColorImpairedMode) => {
<div className={styles.legendItem}> return (
<div className={styles.continuing} /> <div className={styles.footer}>
<div>Continuing (All tracks downloaded)</div> <div>
</div> <div className={styles.legendItem}>
<div
<div className={styles.legendItem}> className={classNames(
<div className={styles.ended} /> styles.continuing,
<div>Ended (All tracks downloaded)</div> enableColorImpairedMode && 'colorImpaired'
</div> )}
/>
<div className={styles.legendItem}> <div>Continuing (All tracks downloaded)</div>
<div className={styles.missingMonitored} /> </div>
<div>Missing Tracks (Artist monitored)</div>
</div> <div className={styles.legendItem}>
<div
<div className={styles.legendItem}> className={classNames(
<div className={styles.missingUnmonitored} /> styles.ended,
<div>Missing Tracks (Artist not monitored)</div> enableColorImpairedMode && 'colorImpaired'
</div> )}
</div> />
<div>Ended (All tracks downloaded)</div>
<div className={styles.statistics}> </div>
<DescriptionList>
<DescriptionListItem <div className={styles.legendItem}>
title="Artist" <div
data={count} className={classNames(
/> styles.missingMonitored,
enableColorImpairedMode && 'colorImpaired'
<DescriptionListItem )}
title="Ended" />
data={ended} <div>Missing Tracks (Artist monitored)</div>
/> </div>
<DescriptionListItem <div className={styles.legendItem}>
title="Continuing" <div
data={continuing} className={classNames(
/> styles.missingUnmonitored,
</DescriptionList> enableColorImpairedMode && 'colorImpaired'
)}
<DescriptionList> />
<DescriptionListItem <div>Missing Tracks (Artist not monitored)</div>
title="Monitored" </div>
data={monitored} </div>
/>
<div className={styles.statistics}>
<DescriptionListItem <DescriptionList>
title="Unmonitored" <DescriptionListItem
data={count - monitored} title="Artist"
/> data={count}
</DescriptionList> />
<DescriptionList> <DescriptionListItem
<DescriptionListItem title="Ended"
title="Tracks" data={ended}
data={tracks} />
/>
<DescriptionListItem
<DescriptionListItem title="Continuing"
title="Files" data={continuing}
data={trackFiles} />
/> </DescriptionList>
</DescriptionList>
<DescriptionList>
<DescriptionList> <DescriptionListItem
<DescriptionListItem title="Monitored"
title="Total File Size" data={monitored}
data={formatBytes(totalFileSize)} />
/>
</DescriptionList> <DescriptionListItem
</div> title="Unmonitored"
</div> data={count - monitored}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Tracks"
data={tracks}
/>
<DescriptionListItem
title="Files"
data={trackFiles}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title="Total File Size"
data={formatBytes(totalFileSize)}
/>
</DescriptionList>
</div>
</div>
);
}}
</ColorImpairedConsumer>
); );
} }

@ -26,10 +26,27 @@ $hoverScale: 1.05;
.link { .link {
composes: link from 'Components/Link/Link.css'; composes: link from 'Components/Link/Link.css';
position: relative;
display: block; display: block;
height: 70px;
background-color: $defaultColor; background-color: $defaultColor;
} }
.overlayTitle {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
width: 100%;
height: 100%;
color: $offWhite;
text-align: center;
font-size: 20px;
}
.nextAiring { .nextAiring {
background-color: #fafbfc; background-color: #fafbfc;
text-align: center; text-align: center;
@ -49,6 +66,7 @@ $hoverScale: 1.05;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
z-index: 1;
width: 0; width: 0;
height: 0; height: 0;
border-width: 0 25px 25px 0; border-width: 0 25px 25px 0;

@ -22,6 +22,7 @@ class ArtistIndexPoster extends Component {
super(props, context); super(props, context);
this.state = { this.state = {
hasPosterError: false,
isEditArtistModalOpen: false, isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false isDeleteArtistModalOpen: false
}; };
@ -49,6 +50,18 @@ class ArtistIndexPoster extends Component {
this.setState({ isDeleteArtistModalOpen: false }); this.setState({ isDeleteArtistModalOpen: false });
} }
onPosterLoad = () => {
if (this.state.hasPosterError) {
this.setState({ hasPosterError: false });
}
}
onPosterLoadError = () => {
if (!this.state.hasPosterError) {
this.setState({ hasPosterError: true });
}
}
// //
// Render // Render
@ -90,6 +103,7 @@ class ArtistIndexPoster extends Component {
} = statistics; } = statistics;
const { const {
hasPosterError,
isEditArtistModalOpen, isEditArtistModalOpen,
isDeleteArtistModalOpen isDeleteArtistModalOpen
} = this.state; } = this.state;
@ -153,7 +167,17 @@ class ArtistIndexPoster extends Component {
size={250} size={250}
lazy={false} lazy={false}
overflow={true} overflow={true}
onError={this.onPosterLoadError}
onLoad={this.onPosterLoad}
/> />
{
hasPosterError &&
<div className={styles.overlayTitle}>
{artistName}
</div>
}
</Link> </Link>
</div> </div>

@ -10,6 +10,20 @@
flex: 4 0 110px; flex: 4 0 110px;
} }
.banner {
flex: 0 0 379px;
}
.bannerGrow {
flex-grow: 1;
}
.artistType {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 100px;
}
.qualityProfileId, .qualityProfileId,
.languageProfileId, .languageProfileId,
.metadataProfileId { .metadataProfileId {
@ -40,7 +54,6 @@
flex: 0 0 150px; flex: 0 0 150px;
} }
.artistType,
.trackCount { .trackCount {
composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';

@ -1,109 +1,86 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React from 'react';
import classNames from 'classnames';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import hasGrowableColumns from './hasGrowableColumns';
import ArtistIndexTableOptionsConnector from './ArtistIndexTableOptionsConnector'; import ArtistIndexTableOptionsConnector from './ArtistIndexTableOptionsConnector';
import styles from './ArtistIndexHeader.css'; import styles from './ArtistIndexHeader.css';
class ArtistIndexHeader extends Component { function ArtistIndexHeader(props) {
const {
// showBanners,
// Lifecycle columns,
onTableOptionChange,
constructor(props, context) { ...otherProps
super(props, context); } = props;
this.state = { return (
isTableOptionsModalOpen: false <VirtualTableHeader>
}; {
} columns.map((column) => {
const {
// name,
// Listeners label,
isSortable,
onTableOptionsPress = () => { isVisible
this.setState({ isTableOptionsModalOpen: true }); } = column;
}
if (!isVisible) {
onTableOptionsModalClose = () => { return null;
this.setState({ isTableOptionsModalOpen: false }); }
}
if (name === 'actions') {
//
// Render
render() {
const {
showSearchAction,
columns,
onTableOptionChange,
...otherProps
} = this.props;
return (
<VirtualTableHeader>
{
columns.map((column) => {
const {
name,
label,
isSortable,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
{...otherProps}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
onPress={this.onTableOptionsPress}
/>
</VirtualTableHeaderCell>
);
}
return ( return (
<VirtualTableHeaderCell <VirtualTableHeaderCell
key={name} key={name}
className={styles[name]} className={styles[name]}
name={name} name={name}
isSortable={isSortable} isSortable={false}
{...otherProps} {...otherProps}
> >
{label}
<TableOptionsModalWrapper
columns={columns}
optionsComponent={ArtistIndexTableOptionsConnector}
onTableOptionChange={onTableOptionChange}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
/>
</TableOptionsModalWrapper>
</VirtualTableHeaderCell> </VirtualTableHeaderCell>
); );
}) }
}
return (
<TableOptionsModal <VirtualTableHeaderCell
isOpen={this.state.isTableOptionsModalOpen} key={name}
columns={columns} className={classNames(
optionsComponent={ArtistIndexTableOptionsConnector} styles[name],
onTableOptionChange={onTableOptionChange} name === 'sortName' && showBanners && styles.banner,
onModalClose={this.onTableOptionsModalClose} name === 'sortName' && showBanners && !hasGrowableColumns(columns) && styles.bannerGrow
/> )}
</VirtualTableHeader> name={name}
); isSortable={isSortable}
} {...otherProps}
>
{label}
</VirtualTableHeaderCell>
);
})
}
</VirtualTableHeader>
);
} }
ArtistIndexHeader.propTypes = { ArtistIndexHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired onTableOptionChange: PropTypes.func.isRequired,
showBanners: PropTypes.bool.isRequired
}; };
export default ArtistIndexHeader; export default ArtistIndexHeader;

@ -1,19 +1,69 @@
.status { .cell {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
display: flex;
align-items: center;
}
.status {
composes: cell;
flex: 0 0 60px; flex: 0 0 60px;
} }
.sortName { .sortName {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
flex: 4 0 110px; flex: 4 0 110px;
} }
.artistType {
composes: cell;
flex: 0 0 100px;
}
.banner {
flex: 0 0 379px;
}
.bannerGrow {
flex-grow: 1;
}
.link {
composes: link from 'Components/Link/Link.css';
position: relative;
display: block;
height: 70px;
background-color: $defaultColor;
}
.bannerImage {
width: 379px;
height: 70px;
}
.overlayTitle {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
width: 100%;
height: 100%;
color: $offWhite;
text-align: center;
font-size: 20px;
}
.qualityProfileId, .qualityProfileId,
.languageProfileId, .languageProfileId,
.metadataProfileId { .metadataProfileId {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
flex: 1 0 125px; flex: 1 0 125px;
} }
@ -22,19 +72,19 @@
.lastAlbum, .lastAlbum,
.added, .added,
.genres { .genres {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
flex: 0 0 180px; flex: 0 0 180px;
} }
.albumCount { .albumCount {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
flex: 0 0 100px; flex: 0 0 100px;
} }
.trackProgress { .trackProgress {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -42,21 +92,20 @@
flex-direction: column; flex-direction: column;
} }
.artistType,
.trackCount { .trackCount {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
flex: 0 0 130px; flex: 0 0 130px;
} }
.path { .path {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
flex: 1 0 150px; flex: 1 0 150px;
} }
.sizeOnDisk { .sizeOnDisk {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
flex: 0 0 120px; flex: 0 0 120px;
} }
@ -68,21 +117,21 @@
} }
.tags { .tags {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
flex: 1 0 60px; flex: 1 0 60px;
} }
.useSceneNumbering { .useSceneNumbering {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
flex: 0 0 145px; flex: 0 0 145px;
} }
.actions { .actions {
composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; composes: cell;
flex: 0 1 90px; flex: 0 0 90px;
} }
.checkInput { .checkInput {

@ -1,10 +1,12 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import classNames from 'classnames';
import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; import getProgressBarKind from 'Utilities/Artist/getProgressBarKind';
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import HeartRating from 'Components/HeartRating'; import HeartRating from 'Components/HeartRating';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar'; import ProgressBar from 'Components/ProgressBar';
import TagListConnector from 'Components/TagListConnector'; import TagListConnector from 'Components/TagListConnector';
@ -16,6 +18,8 @@ import ArtistNameLink from 'Artist/ArtistNameLink';
import AlbumTitleLink from 'Album/AlbumTitleLink'; import AlbumTitleLink from 'Album/AlbumTitleLink';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import ArtistBanner from 'Artist/ArtistBanner';
import hasGrowableColumns from './hasGrowableColumns';
import ArtistStatusCell from './ArtistStatusCell'; import ArtistStatusCell from './ArtistStatusCell';
import styles from './ArtistIndexRow.css'; import styles from './ArtistIndexRow.css';
@ -28,6 +32,7 @@ class ArtistIndexRow extends Component {
super(props, context); super(props, context);
this.state = { this.state = {
hasBannerError: false,
isEditArtistModalOpen: false, isEditArtistModalOpen: false,
isDeleteArtistModalOpen: false isDeleteArtistModalOpen: false
}; };
@ -57,6 +62,18 @@ class ArtistIndexRow extends Component {
// //
} }
onBannerLoad = () => {
if (this.state.hasBannerError) {
this.setState({ hasBannerError: false });
}
}
onBannerLoadError = () => {
if (!this.state.hasBannerError) {
this.setState({ hasBannerError: true });
}
}
// //
// Render // Render
@ -80,6 +97,8 @@ class ArtistIndexRow extends Component {
ratings, ratings,
path, path,
tags, tags,
images,
showBanners,
showSearchAction, showSearchAction,
columns, columns,
isRefreshingArtist, isRefreshingArtist,
@ -97,6 +116,7 @@ class ArtistIndexRow extends Component {
} = statistics; } = statistics;
const { const {
hasBannerError,
isEditArtistModalOpen, isEditArtistModalOpen,
isDeleteArtistModalOpen isDeleteArtistModalOpen
} = this.state; } = this.state;
@ -130,12 +150,40 @@ class ArtistIndexRow extends Component {
return ( return (
<VirtualTableRowCell <VirtualTableRowCell
key={name} key={name}
className={styles[name]} className={classNames(
styles[name],
showBanners && styles.banner,
showBanners && !hasGrowableColumns(columns) && styles.bannerGrow
)}
> >
<ArtistNameLink {
foreignArtistId={foreignArtistId} showBanners ?
artistName={artistName} <Link
/> className={styles.link}
to={`/artist/${foreignArtistId}`}
>
<ArtistBanner
className={styles.bannerImage}
images={images}
lazy={false}
overflow={true}
onError={this.onBannerLoadError}
onLoad={this.onBannerLoad}
/>
{
hasBannerError &&
<div className={styles.overlayTitle}>
{artistName}
</div>
}
</Link> :
<ArtistNameLink
foreignArtistId={foreignArtistId}
artistName={artistName}
/>
}
</VirtualTableRowCell> </VirtualTableRowCell>
); );
} }
@ -424,6 +472,8 @@ ArtistIndexRow.propTypes = {
genres: PropTypes.arrayOf(PropTypes.string).isRequired, genres: PropTypes.arrayOf(PropTypes.string).isRequired,
ratings: PropTypes.object.isRequired, ratings: PropTypes.object.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired, tags: PropTypes.arrayOf(PropTypes.number).isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired, showSearchAction: PropTypes.bool.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isRefreshingArtist: PropTypes.bool.isRequired, isRefreshingArtist: PropTypes.bool.isRequired,

@ -43,7 +43,8 @@ class ArtistIndexTable extends Component {
rowRenderer = ({ key, rowIndex, style }) => { rowRenderer = ({ key, rowIndex, style }) => {
const { const {
items, items,
columns columns,
showBanners
} = this.props; } = this.props;
const artist = items[rowIndex]; const artist = items[rowIndex];
@ -58,6 +59,7 @@ class ArtistIndexTable extends Component {
languageProfileId={artist.languageProfileId} languageProfileId={artist.languageProfileId}
qualityProfileId={artist.qualityProfileId} qualityProfileId={artist.qualityProfileId}
metadataProfileId={artist.metadataProfileId} metadataProfileId={artist.metadataProfileId}
showBanners={showBanners}
/> />
); );
} }
@ -72,6 +74,7 @@ class ArtistIndexTable extends Component {
filters, filters,
sortKey, sortKey,
sortDirection, sortDirection,
showBanners,
isSmallScreen, isSmallScreen,
scrollTop, scrollTop,
contentBody, contentBody,
@ -88,11 +91,12 @@ class ArtistIndexTable extends Component {
scrollIndex={this.state.scrollIndex} scrollIndex={this.state.scrollIndex}
contentBody={contentBody} contentBody={contentBody}
isSmallScreen={isSmallScreen} isSmallScreen={isSmallScreen}
rowHeight={38} rowHeight={showBanners ? 70 : 38}
overscanRowCount={2} overscanRowCount={2}
rowRenderer={this.rowRenderer} rowRenderer={this.rowRenderer}
header={ header={
<ArtistIndexHeaderConnector <ArtistIndexHeaderConnector
showBanners={showBanners}
columns={columns} columns={columns}
sortKey={sortKey} sortKey={sortKey}
sortDirection={sortDirection} sortDirection={sortDirection}
@ -116,6 +120,7 @@ ArtistIndexTable.propTypes = {
filters: PropTypes.arrayOf(PropTypes.object).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string, sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all), sortDirection: PropTypes.oneOf(sortDirections.all),
showBanners: PropTypes.bool.isRequired,
scrollTop: PropTypes.number.isRequired, scrollTop: PropTypes.number.isRequired,
jumpToCharacter: PropTypes.string, jumpToCharacter: PropTypes.string,
contentBody: PropTypes.object.isRequired, contentBody: PropTypes.object.isRequired,

@ -11,7 +11,8 @@ function createMapStateToProps() {
(dimensions, artist) => { (dimensions, artist) => {
return { return {
isSmallScreen: dimensions.isSmallScreen, isSmallScreen: dimensions.isSmallScreen,
...artist ...artist,
showBanners: artist.tableOptions.showBanners
}; };
} }
); );

@ -1,5 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component, Fragment } from 'react';
import { inputTypes } from 'Helpers/Props'; import { inputTypes } from 'Helpers/Props';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
@ -14,15 +14,23 @@ class ArtistIndexTableOptions extends Component {
super(props, context); super(props, context);
this.state = { this.state = {
showBanners: props.showBanners,
showSearchAction: props.showSearchAction showSearchAction: props.showSearchAction
}; };
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { showSearchAction } = this.props; const {
showBanners,
showSearchAction
} = this.props;
if (showSearchAction !== prevProps.showSearchAction) { if (
showBanners !== prevProps.showBanners ||
showSearchAction !== prevProps.showSearchAction
) {
this.setState({ this.setState({
showBanners,
showSearchAction showSearchAction
}); });
} }
@ -49,26 +57,42 @@ class ArtistIndexTableOptions extends Component {
render() { render() {
const { const {
showBanners,
showSearchAction showSearchAction
} = this.state; } = this.state;
return ( return (
<FormGroup> <Fragment>
<FormLabel>Show Search</FormLabel> <FormGroup>
<FormLabel>Show Banners</FormLabel>
<FormInputGroup
type={inputTypes.CHECK} <FormInputGroup
name="showSearchAction" type={inputTypes.CHECK}
value={showSearchAction} name="showBanners"
helpText="Show search button" value={showBanners}
onChange={this.onTableOptionChange} helpText="Show banners instead of names"
/> onChange={this.onTableOptionChange}
</FormGroup> />
</FormGroup>
<FormGroup>
<FormLabel>Show Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText="Show search button on hover"
onChange={this.onTableOptionChange}
/>
</FormGroup>
</Fragment>
); );
} }
} }
ArtistIndexTableOptions.propTypes = { ArtistIndexTableOptions.propTypes = {
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired, showSearchAction: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired onTableOptionChange: PropTypes.func.isRequired
}; };

@ -0,0 +1,17 @@
const growableColumns = [
'qualityProfileId',
'languageProfileId',
'path',
'tags'
];
export default function hasGrowableColumns(columns) {
return columns.some((column) => {
const {
name,
isVisible
} = column;
return growableColumns.includes(name) && isVisible;
});
}

@ -0,0 +1,33 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ArtistInteractiveSearchModalContent from './ArtistInteractiveSearchModalContent';
function ArtistInteractiveSearchModal(props) {
const {
isOpen,
artistId,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>
<ArtistInteractiveSearchModalContent
artistId={artistId}
onModalClose={onModalClose}
/>
</Modal>
);
}
ArtistInteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
artistId: PropTypes.number.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistInteractiveSearchModal;

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import ArtistInteractiveSearchModal from './ArtistInteractiveSearchModal';
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
dispatch(cancelFetchReleases());
dispatch(clearReleases());
props.onModalClose();
}
};
}
export default connect(null, createMapDispatchToProps)(ArtistInteractiveSearchModal);

@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import React from 'react';
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 InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
function ArtistInteractiveSearchModalContent(props) {
const {
artistId,
onModalClose
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Interactive Search
</ModalHeader>
<ModalBody>
<InteractiveSearchConnector
type="artist"
searchPayload={{
artistId
}}
/>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
ArtistInteractiveSearchModalContent.propTypes = {
artistId: PropTypes.number.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default ArtistInteractiveSearchModalContent;

@ -3,15 +3,18 @@
overflow-x: hidden; overflow-x: hidden;
padding: 5px; padding: 5px;
border-bottom: 1px solid $borderColor; border-bottom: 1px solid $borderColor;
font-size: 14px; font-size: $defaultFontSize;
&:hover { &:hover {
background-color: $tableRowHoverBackgroundColor; background-color: $tableRowHoverBackgroundColor;
} }
} }
.status { .eventWrapper {
width: 10px; display: flex;
flex: 1 0 1px;
overflow-x: hidden;
padding-left: 6px;
border-left-width: 4px; border-left-width: 4px;
border-left-style: solid; border-left-style: solid;
} }
@ -24,6 +27,7 @@
.time { .time {
flex: 0 0 120px; flex: 0 0 120px;
margin-right: 10px; margin-right: 10px;
border: none !important;
} }
.artistName, .artistName,
@ -80,16 +84,16 @@
@media only screen and (max-width: $breakpointSmall) { @media only screen and (max-width: $breakpointSmall) {
.event { .event {
position: relative; flex-direction: column;
flex-wrap: wrap; }
padding-left: 10px;
.eventWrapper {
display: block;
flex: 0 0 auto;
} }
.status { .date {
position: absolute; margin-left: 10px;
top: 7%;
left: 0;
height: 86%;
} }
.date, .date,

@ -49,7 +49,8 @@ class AgendaEvent extends Component {
queueItem, queueItem,
showDate, showDate,
timeFormat, timeFormat,
longDateFormat longDateFormat,
colorImpairedMode
} = this.props; } = this.props;
const startTime = moment(releaseDate); const startTime = moment(releaseDate);
@ -74,8 +75,9 @@ class AgendaEvent extends Component {
<div <div
className={classNames( className={classNames(
styles.status, styles.eventWrapper,
styles[statusStyle] styles[statusStyle],
colorImpairedMode && 'colorImpaired'
)} )}
/> />

@ -15,7 +15,8 @@ function createMapStateToProps() {
artist, artist,
queueItem, queueItem,
timeFormat: uiSettings.timeFormat, timeFormat: uiSettings.timeFormat,
longDateFormat: uiSettings.longDateFormat longDateFormat: uiSettings.longDateFormat,
colorImpairedMode: uiSettings.enableColorImpairedMode
}; };
} }
); );

@ -41,8 +41,20 @@ class CalendarConnector extends Component {
} }
componentDidMount() { componentDidMount() {
const {
useCurrentPage,
fetchCalendar,
gotoCalendarToday
} = this.props;
registerPagePopulator(this.repopulate); registerPagePopulator(this.repopulate);
this.props.gotoCalendarToday();
if (useCurrentPage) {
fetchCalendar();
} else {
gotoCalendarToday();
}
this.scheduleUpdate(); this.scheduleUpdate();
} }

@ -10,7 +10,8 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import FilterMenu from 'Components/Menu/FilterMenu'; import FilterMenu from 'Components/Menu/FilterMenu';
import NoArtist from 'Artist/NoArtist'; import NoArtist from 'Artist/NoArtist';
import CalendarLinkModal from './iCal/CalendarLinkModal'; import CalendarLinkModal from './iCal/CalendarLinkModal';
import Legend from './Legend/Legend'; import CalendarOptionsModal from './Options/CalendarOptionsModal';
import LegendConnector from './Legend/LegendConnector';
import CalendarConnector from './CalendarConnector'; import CalendarConnector from './CalendarConnector';
import styles from './CalendarPage.css'; import styles from './CalendarPage.css';
@ -26,6 +27,7 @@ class CalendarPage extends Component {
this.state = { this.state = {
isCalendarLinkModalOpen: false, isCalendarLinkModalOpen: false,
isOptionsModalOpen: false,
width: 0 width: 0
}; };
} }
@ -48,6 +50,23 @@ class CalendarPage extends Component {
this.setState({ isCalendarLinkModalOpen: false }); this.setState({ isCalendarLinkModalOpen: false });
} }
onOptionsPress = () => {
this.setState({ isOptionsModalOpen: true });
}
onOptionsModalClose = () => {
this.setState({ isOptionsModalOpen: false });
}
onSearchMissingPress = () => {
const {
missingAlbumIds,
onSearchMissingPress
} = this.props;
onSearchMissingPress(missingAlbumIds);
}
// //
// Render // Render
@ -56,17 +75,20 @@ class CalendarPage extends Component {
selectedFilterKey, selectedFilterKey,
filters, filters,
hasArtist, hasArtist,
colorImpairedMode, missingAlbumIds,
isSearchingForMissing,
useCurrentPage,
onFilterSelect onFilterSelect
} = this.props; } = this.props;
const isMeasured = this.state.width > 0; const {
isCalendarLinkModalOpen,
isOptionsModalOpen
} = this.state;
let PageComponent = 'div'; const isMeasured = this.state.width > 0;
if (isMeasured) { const PageComponent = hasArtist ? CalendarConnector : NoArtist;
PageComponent = hasArtist ? CalendarConnector : NoArtist;
}
return ( return (
<PageContent title="Calendar"> <PageContent title="Calendar">
@ -77,9 +99,23 @@ class CalendarPage extends Component {
iconName={icons.CALENDAR} iconName={icons.CALENDAR}
onPress={this.onGetCalendarLinkPress} onPress={this.onGetCalendarLinkPress}
/> />
<PageToolbarButton
label="Search for Missing"
iconName={icons.SEARCH}
isDisabled={!missingAlbumIds.length}
isSpinning={isSearchingForMissing}
onPress={this.onSearchMissingPress}
/>
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}> <PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label="Options"
iconName={icons.POSTER}
onPress={this.onOptionsPress}
/>
<FilterMenu <FilterMenu
alignMenu={align.RIGHT} alignMenu={align.RIGHT}
isDisabled={!hasArtist} isDisabled={!hasArtist}
@ -99,19 +135,31 @@ class CalendarPage extends Component {
whitelist={['width']} whitelist={['width']}
onMeasure={this.onMeasure} onMeasure={this.onMeasure}
> >
<PageComponent /> {
isMeasured ?
<PageComponent
useCurrentPage={useCurrentPage}
/> :
<div />
}
</Measure> </Measure>
{ {
hasArtist && hasArtist &&
<Legend colorImpairedMode={colorImpairedMode} /> <LegendConnector />
} }
</PageContentBodyConnector> </PageContentBodyConnector>
<CalendarLinkModal <CalendarLinkModal
isOpen={this.state.isCalendarLinkModalOpen} isOpen={isCalendarLinkModalOpen}
onModalClose={this.onGetCalendarLinkModalClose} onModalClose={this.onGetCalendarLinkModalClose}
/> />
<CalendarOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={this.onOptionsModalClose}
/>
</PageContent> </PageContent>
); );
} }
@ -121,7 +169,10 @@ CalendarPage.propTypes = {
selectedFilterKey: PropTypes.string.isRequired, selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired,
hasArtist: PropTypes.bool.isRequired, hasArtist: PropTypes.bool.isRequired,
colorImpairedMode: PropTypes.bool.isRequired, missingAlbumIds: PropTypes.arrayOf(PropTypes.number).isRequired,
isSearchingForMissing: PropTypes.bool.isRequired,
useCurrentPage: PropTypes.bool.isRequired,
onSearchMissingPress: PropTypes.func.isRequired,
onDaysCountChange: PropTypes.func.isRequired, onDaysCountChange: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired onFilterSelect: PropTypes.func.isRequired
}; };

@ -1,22 +1,80 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; import moment from 'moment';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import withCurrentPage from 'Components/withCurrentPage';
import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
import createArtistCountSelector from 'Store/Selectors/createArtistCountSelector'; import createArtistCountSelector from 'Store/Selectors/createArtistCountSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import CalendarPage from './CalendarPage'; import CalendarPage from './CalendarPage';
function createMissingAlbumIdsSelector() {
return createSelector(
(state) => state.calendar.start,
(state) => state.calendar.end,
(state) => state.calendar.items,
(state) => state.queue.details.items,
(start, end, albums, queueDetails) => {
return albums.reduce((acc, album) => {
const releaseDate = album.releaseDate;
if (
album.percentOfTracks < 100 &&
moment(releaseDate).isAfter(start) &&
moment(releaseDate).isBefore(end) &&
isBefore(album.releaseDate) &&
!queueDetails.some((details) => !!details.album && details.album.id === album.id)
) {
acc.push(album.id);
}
return acc;
}, []);
}
);
}
function createIsSearchingSelector() {
return createSelector(
(state) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(commands.find((command) => {
return command.id === searchMissingCommandId;
}));
}
);
}
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.calendar, (state) => state.calendar.selectedFilterKey,
(state) => state.calendar.filters,
createArtistCountSelector(), createArtistCountSelector(),
createUISettingsSelector(), createUISettingsSelector(),
(calendar, artistCount, uiSettings) => { createMissingAlbumIdsSelector(),
createIsSearchingSelector(),
(
selectedFilterKey,
filters,
artistCount,
uiSettings,
missingAlbumIds,
isSearchingForMissing
) => {
return { return {
selectedFilterKey: calendar.selectedFilterKey, selectedFilterKey,
filters: calendar.filters, filters,
showUpcoming: calendar.showUpcoming,
colorImpairedMode: uiSettings.enableColorImpairedMode, colorImpairedMode: uiSettings.enableColorImpairedMode,
hasArtist: !!artistCount hasArtist: !!artistCount,
missingAlbumIds,
isSearchingForMissing
}; };
} }
); );
@ -24,6 +82,9 @@ function createMapStateToProps() {
function createMapDispatchToProps(dispatch, props) { function createMapDispatchToProps(dispatch, props) {
return { return {
onSearchMissingPress(albumIds) {
dispatch(searchMissing({ albumIds }));
},
onDaysCountChange(dayCount) { onDaysCountChange(dayCount) {
dispatch(setCalendarDaysCount({ dayCount })); dispatch(setCalendarDaysCount({ dayCount }));
}, },
@ -34,4 +95,6 @@ function createMapDispatchToProps(dispatch, props) {
}; };
} }
export default connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage); export default withCurrentPage(
connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage)
);

@ -22,7 +22,7 @@
.artistName { .artistName {
color: #3a3f51; color: #3a3f51;
font-size: 14px; font-size: $defaultFontSize;
} }
.absoluteEpisodeNumber { .absoluteEpisodeNumber {
@ -53,7 +53,7 @@
border-left-color: $gray; border-left-color: $gray;
&:global(.colorImpaired) { &:global(.colorImpaired) {
background: repeating-linear-gradient(45deg, transparent, transparent 5px, #eee 5px, #eee 10px); background: repeating-linear-gradient(45deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
} }
} }
@ -61,7 +61,7 @@
border-left-color: $dangerColor; border-left-color: $dangerColor;
&:global(.colorImpaired) { &:global(.colorImpaired) {
background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px); background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
} }
} }
@ -69,6 +69,6 @@
border-left-color: $blue; border-left-color: $blue;
&:global(.colorImpaired) { &:global(.colorImpaired) {
background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px); background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
} }
} }

@ -4,7 +4,6 @@ import React, { Component } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import getStatusStyle from 'Calendar/getStatusStyle'; import getStatusStyle from 'Calendar/getStatusStyle';
import albumEntities from 'Album/albumEntities';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import CalendarEventQueueDetails from './CalendarEventQueueDetails'; import CalendarEventQueueDetails from './CalendarEventQueueDetails';

@ -1,9 +1,29 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { icons, kinds } from 'Helpers/Props';
import LegendItem from './LegendItem'; import LegendItem from './LegendItem';
import LegendIconItem from './LegendIconItem';
import styles from './Legend.css'; import styles from './Legend.css';
function Legend({ colorImpairedMode }) { function Legend(props) {
const {
showCutoffUnmetIcon,
colorImpairedMode
} = props;
const iconsToShow = [];
if (showCutoffUnmetIcon) {
iconsToShow.push(
<LegendIconItem
name="Cutoff Not Met"
icon={icons.TRACK_FILE}
kind={kinds.WARNING}
tooltip="Quality or language cutoff has not been met"
/>
);
}
return ( return (
<div className={styles.legend}> <div className={styles.legend}>
<div> <div>
@ -47,11 +67,24 @@ function Legend({ colorImpairedMode }) {
colorImpairedMode={colorImpairedMode} colorImpairedMode={colorImpairedMode}
/> />
</div> </div>
<div>
{iconsToShow[0]}
</div>
{
iconsToShow.length > 1 &&
<div>
{iconsToShow[1]}
{iconsToShow[2]}
</div>
}
</div> </div>
); );
} }
Legend.propTypes = { Legend.propTypes = {
showCutoffUnmetIcon: PropTypes.bool.isRequired,
colorImpairedMode: PropTypes.bool.isRequired colorImpairedMode: PropTypes.bool.isRequired
}; };

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import Legend from './Legend';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
createUISettingsSelector(),
(calendarOptions, uiSettings) => {
return {
...calendarOptions,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);
}
export default connect(createMapStateToProps)(Legend);

@ -0,0 +1,10 @@
.legendIconItem {
margin: 3px 0;
margin-right: 6px;
width: 150px;
cursor: default;
}
.icon {
margin-right: 5px;
}

@ -0,0 +1,37 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import styles from './LegendIconItem.css';
function LegendIconItem(props) {
const {
name,
icon,
kind,
tooltip
} = props;
return (
<div
className={styles.legendIconItem}
title={tooltip}
>
<Icon
className={styles.icon}
name={icon}
kind={kind}
/>
{name}
</div>
);
}
LegendIconItem.propTypes = {
name: PropTypes.string.isRequired,
icon: PropTypes.object.isRequired,
kind: PropTypes.string.isRequired,
tooltip: PropTypes.string.isRequired
};
export default LegendIconItem;

@ -1,13 +1,12 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Modal from 'Components/Modal/Modal'; import Modal from 'Components/Modal/Modal';
import InteractiveSearchModalContentConnector from './InteractiveSearchModalContentConnector'; import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector';
function InteractiveSearchModal(props) { function CalendarOptionsModal(props) {
const { const {
isOpen, isOpen,
onModalClose, onModalClose
...otherProps
} = props; } = props;
return ( return (
@ -15,17 +14,16 @@ function InteractiveSearchModal(props) {
isOpen={isOpen} isOpen={isOpen}
onModalClose={onModalClose} onModalClose={onModalClose}
> >
<InteractiveSearchModalContentConnector <CalendarOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose} onModalClose={onModalClose}
/> />
</Modal> </Modal>
); );
} }
InteractiveSearchModal.propTypes = { CalendarOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired onModalClose: PropTypes.func.isRequired
}; };
export default InteractiveSearchModal; export default CalendarOptionsModal;

@ -0,0 +1,216 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Button from 'Components/Link/Button';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import { firstDayOfWeekOptions, weekColumnOptions, timeFormatOptions } from 'Settings/UI/UISettings';
class CalendarOptionsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
} = props;
this.state = {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
};
}
componentDidUpdate(prevProps) {
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
} = this.props;
if (
prevProps.firstDayOfWeek !== firstDayOfWeek ||
prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader ||
prevProps.timeFormat !== timeFormat ||
prevProps.enableColorImpairedMode !== enableColorImpairedMode
) {
this.setState({
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
});
}
}
//
// Listeners
onOptionInputChange = ({ name, value }) => {
const {
dispatchSetCalendarOption
} = this.props;
dispatchSetCalendarOption({ [name]: value });
}
onGlobalInputChange = ({ name, value }) => {
const {
dispatchSaveUISettings
} = this.props;
const setting = { [name]: value };
this.setState(setting, () => {
dispatchSaveUISettings(setting);
});
}
onLinkFocus = (event) => {
event.target.select();
}
//
// Render
render() {
const {
collapseMultipleAlbums,
showCutoffUnmetIcon,
onModalClose
} = this.props;
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Calendar Options
</ModalHeader>
<ModalBody>
<FieldSet legend="Local">
<Form>
<FormGroup>
<FormLabel>Collapse Multiple Albums</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="collapseMultipleAlbums"
value={collapseMultipleAlbums}
helpText="Collapse multiple albums releasing on the same day"
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Icon for Cutoff Unmet</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showCutoffUnmetIcon"
value={showCutoffUnmetIcon}
helpText="Show icon for files when the cutoff hasn't been met"
onChange={this.onOptionInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
<FieldSet legend="Global">
<Form>
<FormGroup>
<FormLabel>First Day of Week</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="firstDayOfWeek"
values={firstDayOfWeekOptions}
value={firstDayOfWeek}
onChange={this.onGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Week Column Header</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="calendarWeekColumnHeader"
values={weekColumnOptions}
value={calendarWeekColumnHeader}
onChange={this.onGlobalInputChange}
helpText="Shown above each column when week is the active view"
/>
</FormGroup>
<FormGroup>
<FormLabel>Time Format</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="timeFormat"
values={timeFormatOptions}
value={timeFormat}
onChange={this.onGlobalInputChange}
/>
</FormGroup><FormGroup>
<FormLabel>Enable Color-Impaired Mode</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableColorImpairedMode"
value={enableColorImpairedMode}
helpText="Altered style to allow color-impaired users to better distinguish color coded information"
onChange={this.onGlobalInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
CalendarOptionsModalContent.propTypes = {
collapseMultipleAlbums: PropTypes.bool.isRequired,
showCutoffUnmetIcon: PropTypes.bool.isRequired,
firstDayOfWeek: PropTypes.number.isRequired,
calendarWeekColumnHeader: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
enableColorImpairedMode: PropTypes.bool.isRequired,
dispatchSetCalendarOption: PropTypes.func.isRequired,
dispatchSaveUISettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarOptionsModalContent;

@ -0,0 +1,25 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setCalendarOption } from 'Store/Actions/calendarActions';
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
import { saveUISettings } from 'Store/Actions/settingsActions';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
(state) => state.settings.ui.item,
(options, uiSettings) => {
return {
...options,
...uiSettings
};
}
);
}
const mapDispatchToProps = {
dispatchSetCalendarOption: setCalendarOption,
dispatchSaveUISettings: saveUISettings
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);

@ -19,8 +19,10 @@ function getTagDisplayValue(value, selectedFilterBuilderProp) {
function getValue(input, selectedFilterBuilderProp) { function getValue(input, selectedFilterBuilderProp) {
if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) {
const match = input.match(/^(\d+)([kmgt](i?b)?)$/i); const match = input.match(/^(\d+)([kmgt](i?b)?)$/i);
if (match && match.length > 1) { if (match && match.length > 1) {
const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i); const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i);
switch (unit.toLowerCase()) { switch (unit.toLowerCase()) {
case 'k': case 'k':
return convertToBytes(value, 1, true); return convertToBytes(value, 1, true);
@ -118,6 +120,7 @@ class FilterBuilderRowValue extends Component {
name: tag && tag.name name: tag && tag.name
}; };
} }
return { return {
id, id,
name: getTagDisplayValue(id, selectedFilterBuilderProp) name: getTagDisplayValue(id, selectedFilterBuilderProp)

@ -12,7 +12,7 @@ function createMapStateToProps() {
(state) => state.settings.qualityProfiles, (state) => state.settings.qualityProfiles,
(qualityProfiles) => { (qualityProfiles) => {
const { const {
isFetchingSchema: isFetching, isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated, isSchemaPopulated: isPopulated,
schemaError: error, schemaError: error,
schema schema

@ -44,7 +44,7 @@ class AlbumReleaseSelectInputConnector extends Component {
albumReleases albumReleases
} = this.props; } = this.props;
let updatedReleases = _.map(albumReleases.value, (e) => ({ ...e, monitored: false })); const updatedReleases = _.map(albumReleases.value, (e) => ({ ...e, monitored: false }));
_.find(updatedReleases, { foreignReleaseId: value }).monitored = true; _.find(updatedReleases, { foreignReleaseId: value }).monitored = true;
this.props.onChange({ name, value: updatedReleases }); this.props.onChange({ name, value: updatedReleases });

@ -0,0 +1,58 @@
.input {
composes: input from 'Components/Form/Input.css';
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}
.inputWrapper {
display: flex;
}
.inputContainer {
position: relative;
flex-grow: 1;
}
.container {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.inputContainerOpen {
.container {
position: absolute;
z-index: 1;
overflow-y: auto;
max-height: 200px;
width: 100%;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
}
.list {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
.listItem {
padding: 0 16px;
}
.match {
font-weight: bold;
}
.highlighted {
background-color: $menuItemHoverBackgroundColor;
}

@ -0,0 +1,162 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import jdu from 'jdu';
import styles from './AutoCompleteInput.css';
class AutoCompleteInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
suggestions: []
};
}
//
// Control
getSuggestionValue(item) {
return item;
}
renderSuggestion(item) {
return item;
}
//
// Listeners
onInputChange = (event, { newValue }) => {
this.props.onChange({
name: this.props.name,
value: newValue
});
}
onInputKeyDown = (event) => {
const {
name,
value,
onChange
} = this.props;
const { suggestions } = this.state;
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== this.props.value
) {
event.preventDefault();
if (value) {
onChange({
name,
value: suggestions[0]
});
}
}
}
onInputBlur = () => {
this.setState({ suggestions: [] });
}
onSuggestionsFetchRequested = ({ value }) => {
const { values } = this.props;
const lowerCaseValue = jdu.replace(value).toLowerCase();
const filteredValues = values.filter((v) => {
return jdu.replace(v).toLowerCase().contains(lowerCaseValue);
});
this.setState({ suggestions: filteredValues });
}
onSuggestionsClearRequested = () => {
this.setState({ suggestions: [] });
}
//
// Render
render() {
const {
className,
inputClassName,
name,
value,
placeholder,
hasError,
hasWarning
} = this.props;
const { suggestions } = this.state;
const inputProps = {
className: classNames(
inputClassName,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onBlur: this.onInputBlur
};
const theme = {
container: styles.inputContainer,
containerOpen: styles.inputContainerOpen,
suggestionsContainer: styles.container,
suggestionsList: styles.list,
suggestion: styles.listItem,
suggestionHighlighted: styles.highlighted
};
return (
<div className={className}>
<Autosuggest
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
</div>
);
}
}
AutoCompleteInput.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.string).isRequired,
placeholder: PropTypes.string,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
onChange: PropTypes.func.isRequired
};
AutoCompleteInput.defaultProps = {
className: styles.inputWrapper,
inputClassName: styles.input,
value: ''
};
export default AutoCompleteInput;

@ -0,0 +1,3 @@
.validationFailures {
margin-bottom: 20px;
}

@ -2,37 +2,42 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import styles from './Form.css';
function Form({ children, validationErrors, validationWarnings, ...otherProps }) { function Form({ children, validationErrors, validationWarnings, ...otherProps }) {
return ( return (
<div> <div>
<div> {
{ validationErrors.length || validationWarnings.length ?
validationErrors.map((error, index) => { <div className={styles.validationFailures}>
return ( {
<Alert validationErrors.map((error, index) => {
key={index} return (
kind={kinds.DANGER} <Alert
> key={index}
{error.errorMessage} kind={kinds.DANGER}
</Alert> >
); {error.errorMessage}
}) </Alert>
} );
})
}
{ {
validationWarnings.map((warning, index) => { validationWarnings.map((warning, index) => {
return ( return (
<Alert <Alert
key={index} key={index}
kind={kinds.WARNING} kind={kinds.WARNING}
> >
{warning.errorMessage} {warning.errorMessage}
</Alert> </Alert>
); );
}) })
} }
</div> </div> :
null
}
{children} {children}
</div> </div>

@ -2,9 +2,11 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { inputTypes } from 'Helpers/Props'; import { inputTypes } from 'Helpers/Props';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import AutoCompleteInput from './AutoCompleteInput';
import CaptchaInputConnector from './CaptchaInputConnector'; import CaptchaInputConnector from './CaptchaInputConnector';
import CheckInput from './CheckInput'; import CheckInput from './CheckInput';
import DeviceInputConnector from './DeviceInputConnector'; import DeviceInputConnector from './DeviceInputConnector';
import KeyValueListInput from './KeyValueListInput';
import MonitorAlbumsSelectInput from './MonitorAlbumsSelectInput'; import MonitorAlbumsSelectInput from './MonitorAlbumsSelectInput';
import NumberInput from './NumberInput'; import NumberInput from './NumberInput';
import OAuthInputConnector from './OAuthInputConnector'; import OAuthInputConnector from './OAuthInputConnector';
@ -25,6 +27,9 @@ import styles from './FormInputGroup.css';
function getComponent(type) { function getComponent(type) {
switch (type) { switch (type) {
case inputTypes.AUTO_COMPLETE:
return AutoCompleteInput;
case inputTypes.CAPTCHA: case inputTypes.CAPTCHA:
return CaptchaInputConnector; return CaptchaInputConnector;
@ -34,6 +39,9 @@ function getComponent(type) {
case inputTypes.DEVICE: case inputTypes.DEVICE:
return DeviceInputConnector; return DeviceInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.MONITOR_ALBUMS_SELECT: case inputTypes.MONITOR_ALBUMS_SELECT:
return MonitorAlbumsSelectInput; return MonitorAlbumsSelectInput;

@ -0,0 +1,21 @@
.inputContainer {
composes: input from 'Components/Form/Input.css';
position: relative;
min-height: 35px;
height: auto;
&.isFocused {
outline: 0;
border-color: $inputFocusBorderColor;
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
}
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}

@ -0,0 +1,152 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import KeyValueListInputItem from './KeyValueListInputItem';
import styles from './KeyValueListInput.css';
class KeyValueListInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isFocused: false
};
}
//
// Listeners
onItemChange = (index, itemValue) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
if (index == null) {
newValue.push(itemValue);
} else {
newValue.splice(index, 1, itemValue);
}
onChange({
name,
value: newValue
});
}
onRemoveItem = (index) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
newValue.splice(index, 1);
onChange({
name,
value: newValue
});
}
onFocus = () => {
this.setState({
isFocused: true
});
}
onBlur = () => {
this.setState({
isFocused: false
});
const {
name,
value,
onChange
} = this.props;
const newValue = value.reduce((acc, v) => {
if (v.key || v.value) {
acc.push(v);
}
return acc;
}, []);
if (newValue.length !== value.length) {
onChange({
name,
value: newValue
});
}
}
//
// Render
render() {
const {
className,
value,
keyPlaceholder,
valuePlaceholder
} = this.props;
const { isFocused } = this.state;
return (
<div className={classNames(
className,
isFocused && styles.isFocused
)}
>
{
[...value, { key: '', value: '' }].map((v, index) => {
return (
<KeyValueListInputItem
key={index}
index={index}
keyValue={v.key}
value={v.value}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
isNew={index === value.length}
onChange={this.onItemChange}
onRemove={this.onRemoveItem}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
);
})
}
</div>
);
}
}
KeyValueListInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.object).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
keyPlaceholder: PropTypes.string,
valuePlaceholder: PropTypes.string,
onChange: PropTypes.func.isRequired
};
KeyValueListInput.defaultProps = {
className: styles.inputContainer,
value: []
};
export default KeyValueListInput;

@ -0,0 +1,14 @@
.itemContainer {
display: flex;
margin-bottom: 3px;
border-bottom: 1px solid $inputBorderColor;
&:last-child {
margin-bottom: 0;
}
}
.keyInput,
.valueInput {
border: none;
}

@ -0,0 +1,117 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import IconButton from 'Components/Link/IconButton';
import TextInput from './TextInput';
import styles from './KeyValueListInputItem.css';
class KeyValueListInputItem extends Component {
//
// Listeners
onKeyChange = ({ value: keyValue }) => {
const {
index,
value,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
}
onValueChange = ({ value }) => {
// TODO: Validate here or validate at a lower level component
const {
index,
keyValue,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
}
onRemovePress = () => {
const {
index,
onRemove
} = this.props;
onRemove(index);
}
onFocus = () => {
this.props.onFocus();
}
onBlur = () => {
this.props.onBlur();
}
//
// Render
render() {
const {
keyValue,
value,
keyPlaceholder,
valuePlaceholder,
isNew
} = this.props;
return (
<div className={styles.itemContainer}>
<TextInput
className={styles.keyInput}
name="key"
value={keyValue}
placeholder={keyPlaceholder}
onChange={this.onKeyChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
<TextInput
className={styles.valueInput}
name="value"
value={value}
placeholder={valuePlaceholder}
onChange={this.onValueChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
{
!isNew &&
<IconButton
name={icons.REMOVE}
tabIndex={-1}
onPress={this.onRemovePress}
/>
}
</div>
);
}
}
KeyValueListInputItem.propTypes = {
index: PropTypes.number,
keyValue: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
keyPlaceholder: PropTypes.string.isRequired,
valuePlaceholder: PropTypes.string.isRequired,
isNew: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onFocus: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired
};
KeyValueListInputItem.defaultProps = {
keyPlaceholder: 'Key',
valuePlaceholder: 'Value'
};
export default KeyValueListInputItem;

@ -1,17 +1,8 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import monitorOptions from 'Utilities/Artist/monitorOptions';
import SelectInput from './SelectInput'; import SelectInput from './SelectInput';
const monitorOptions = [
{ key: 'all', value: 'All Albums' },
{ key: 'future', value: 'Future Albums' },
{ key: 'missing', value: 'Missing Albums' },
{ key: 'existing', value: 'Existing Albums' },
{ key: 'first', value: 'Only First Album' },
{ key: 'latest', value: 'Only Latest Album' },
{ key: 'none', value: 'None' }
];
function MonitorAlbumsSelectInput(props) { function MonitorAlbumsSelectInput(props) {
const { const {
includeNoChange, includeNoChange,

@ -2,44 +2,91 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import TextInput from './TextInput'; import TextInput from './TextInput';
function parseValue(props, value) {
const {
isFloat,
min,
max
} = props;
if (value == null || value === '') {
return min;
}
let newValue = isFloat ? parseFloat(value) : parseInt(value);
if (min != null && newValue != null && newValue < min) {
newValue = min;
} else if (max != null && newValue != null && newValue > max) {
newValue = max;
}
return newValue;
}
class NumberInput extends Component { class NumberInput extends Component {
// //
// Listeners // Lifecycle
onChange = ({ name, value }) => { constructor(props, context) {
let newValue = null; super(props, context);
this.state = {
value: props.value == null ? '' : props.value.toString(),
isFocused: false
};
}
if (value) { componentDidUpdate(prevProps, prevState) {
newValue = this.props.isFloat ? parseFloat(value) : parseInt(value); const { value } = this.props;
if (value !== prevProps.value && !this.state.isFocused) {
this.setState({
value: value == null ? '' : value.toString()
});
} }
}
//
// Listeners
onChange = ({ name, value }) => {
this.setState({ value });
this.props.onChange({ this.props.onChange({
name, name,
value: newValue value: parseValue(this.props, value)
}); });
}
onFocus = () => {
this.setState({ isFocused: true });
} }
onBlur = () => { onBlur = () => {
const { const {
name, name,
value,
min,
max,
onChange onChange
} = this.props; } = this.props;
let newValue = value; const { value } = this.state;
const parsedValue = parseValue(this.props, value);
const stringValue = parsedValue == null ? '' : parsedValue.toString();
if (min != null && newValue != null && newValue < min) { if (stringValue === value) {
newValue = min; this.setState({ isFocused: false });
} else if (max != null && newValue != null && newValue > max) { } else {
newValue = max; this.setState({
value: stringValue,
isFocused: false
});
} }
onChange({ onChange({
name, name,
value: newValue value: parsedValue
}); });
} }
@ -47,18 +94,16 @@ class NumberInput extends Component {
// Render // Render
render() { render() {
const { const value = this.state.value;
value,
...otherProps
} = this.props;
return ( return (
<TextInput <TextInput
{...this.props}
type="number" type="number"
value={value == null ? '' : value} value={value == null ? '' : value}
{...otherProps}
onChange={this.onChange} onChange={this.onChange}
onBlur={this.onBlur} onBlur={this.onBlur}
onFocus={this.onFocus}
/> />
); );
} }

@ -14,9 +14,7 @@
} }
.freeSpace { .freeSpace {
@add-mixin truncate; flex: 0 0 auto;
flex: 1 0 0;
margin-left: 15px; margin-left: 15px;
color: $gray; color: $gray;
text-align: right; text-align: right;

@ -33,7 +33,10 @@ class TagInputTag extends Component {
} = this.props; } = this.props;
return ( return (
<Link onPress={this.onDelete}> <Link
tabIndex={-1}
onPress={this.onDelete}
>
<Label kind={kind}> <Label kind={kind}>
{tag.name} {tag.name}
</Label> </Label>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save